diff --git a/docs/connection-gate-findings.md b/docs/connection-gate-findings.md
index d1e5d2a..774c2ec 100644
--- a/docs/connection-gate-findings.md
+++ b/docs/connection-gate-findings.md
@@ -592,6 +592,175 @@ from it during online-init. With the service absent, the handle was null → the
registry Name). This is the first change that targets the *actual* crash address, so
it is the real test of the loading-screen gate.
+### M2 unblock lever RECOVERED: the ONLINE_STATUS_EVENT push format (2026-07-02)
+
+With the LSX gate cleared, FIFA sits at "connecting to EA Servers", polling GetProfile
+over LSX. Per the M1/M2 findings this is the async-push wall: FIFA calls GoOnline
+(handled in-process by anadius/EbisuSDK), gets success, then **waits for an
+`ONLINE_STATUS_EVENT` push on the LSX socket that never arrives.** Earlier attempts
+couldn't send it — it was unclear the bridge even owned the LSX socket (anadius
+intercepted in-process). **That blocker is now gone: our bridge provably owns FIFA's
+LSX socket** (full session runs on :3217), so we can finally push the event.
+
+RE recovered the exact format the client parses (authoritative — from FIFA23.exe's
+own OriginSDK deserializer, not a guess):
+
+- **Event wrapper** (from anadius64.dll's IGOEvent template):
+ `<…/>`
+- **Element** `OnlineStatusEvent` — confirmed via FIFA23.exe's retained RTTI symbol
+ `Origin::EventHandler::HandleMessage`
+ (source path `OriginSDK/10.6.2.19-fb-fifagame/src/impl/OriginEventClasses`) and the
+ event-name dispatch table. Handler parser at FIFA23.exe+0x274d4ae.
+- **Attribute** `isOnline` (a single bool) — recovered from the typed deserializer at
+ FIFA23.exe+0x28a4d0: it inits the output byte to 0 (offline), builds an `lsx:`
+ namespace prefix, reads the `isOnline` attribute, and string→bool converts it.
+
+Best-effort push (value format `1` vs `true` is `TODO/CONFIRM` against the client —
+OriginSDK bool attributes elsewhere use `true`/`false`):
+
+```xml
+
+```
+
+**Next implementation step:** give the bridge LSX server an async, unsolicited-push
+path — encrypt this event with the live session seed and write it on the open socket
+after the session establishes (or after FIFA's GoOnline settles). Success signal: FIFA
+stops polling and **dials the Blaze redirector** (gosredirector → :42127 via the hook),
+which yields the first Fire2 frame and moves us into M3/M4 (fifa-blaze).
+
+### RESULT: OnlineStatusEvent push drives FIFA online — a first — but the auth→Blaze chain is the next wall (2026-07-02)
+
+Implemented an unsolicited `OnlineStatusEvent` push in the bridge LSX server
+(`select!` loop, encrypted with the session seed, re-pushed every 2s). Value
+`isOnline="true"` (FIFA's bool parser at FIFA23.exe+0x28fb50 string-compares to
+`false`; `isOnline="1"` was delivered but parsed as not-online — pinned the encoding).
+
+**FIFA reacted — the event format is correct and effective:**
+- Rapid GetProfile polling (~0.5s) stopped the instant the pushes began (13s gap).
+- FIFA set `SetPresence … Presence="INGAME"` — an *online* action.
+- Then it went **silent on LSX** (idle, connection alive, no crash).
+
+This is the first time FIFA has been driven "online" at the EbisuSDK/LSX level —
+**anadius never does this** (it is offline DRM and deliberately keeps FIFA offline),
+so this path is genuinely uncharted, not a regression from a known-good baseline.
+
+**But FIFA does NOT proceed to Blaze.** After going online it:
+- sent **no** `GetAuthCode` (an LSX request the bridge already answers) — so it never
+ started the Nucleus-token exchange;
+- resolved **no** hostname (`hooked_getaddrinfo` logs every call; the hook log has
+ none) — no gosredirector, no accounts.ea.com;
+- opened **no** socket beyond LSX (:3216).
+
+So a single connectivity event ≠ full online login. RE of FIFA23.exe confirms the
+remaining chain and its anchors: `GetAuthCode` (12×) → `NucleusAccessToken` /
+`access_token` / `Bearer` / `Authorization` (redirector header) → `gosredirector`
+(4×) / `BlazeHub` (3×) → Blaze auth. `OnlineStatusEventT::HandleMessage`
+(FIFA23.exe+0x274d4ae) only builds the typed event and dispatches it via the SDK
+callback; the game-side listener that would kick off the auth chain needs more than
+the connectivity flag (candidate: a login/authenticated-online event or an in-process
+EbisuSDK login step that our LSX-only redirect doesn't cover).
+
+**Strategic note:** this is the M2→M6 research frontier the 2026-06-30 status review
+priced at months of expert RE, now empirically confirmed. It sits against the
+2026-06-30 direction pivot, which made the app-centric FLE/career-mode path the
+primary plan over full Blaze-backend RE. Decision point before sinking weeks into the
+Nucleus/Blaze stack.
+
+### Trigger hunt: the missing link is the `Login` event (OriginLoginT) (2026-07-02)
+
+Traced why FIFA stalls after going online. The full post-online chain and its
+FIFA23.exe anchors:
+
+```
+OnlineStatusEvent(isOnline=true) ✓ delivered, FIFA set INGAME presence
+ │ [MISSING game-side trigger]
+GetAuthCode (LSX request we answer) ── never sent
+ │
+Nucleus token exchange (accounts.ea.com HTTPS, Bearer/Authorization) ── no DNS
+ │
+spring18.gosredirector.ea.com (HTTPS redirector: Authorization + serviceName,
+ env-selected by ENVIRONMENT="production"; headers X-BLAZE-ERRORCODE)
+ │
+Blaze server (BlazeHub::LoginManagerImpl, Fire2) ── never reached
+```
+
+Login lives inside Blaze (`BlazeHub::mLoginManagers`), which needs a Nucleus token
+first, which needs `GetAuthCode`, which FIFA never issues. So the block is upstream:
+the game-side reaction to online doesn't start the auth chain.
+
+**Likely cause — no authenticated session.** FIFA's Origin event set (from retained
+RTTI) includes `Origin::EventHandler` — a
+`Login` event carrying a full `OriginLoginT` struct (identity/session/token), distinct
+from `OnlineStatusEvent`'s single bool. Our push set *connectivity* (presence went
+INGAME) but not an authenticated *session*. anadius never pushes `Login` (offline DRM),
+so FIFA has a persona (from GetProfile) but no online session token → nothing to drive
+`GetAuthCode`.
+
+**Next experiment:** RE the `LoginT` deserializer (element `Login`, payload
+`OriginLoginT`) the same way as OnlineStatusEvent, synthesize a `` event push
+with a fabricated session, and relaunch. Success signal: FIFA issues `GetAuthCode`
+over LSX and/or resolves `spring18.gosredirector.ea.com` (both logged) — the first
+move into the Nucleus/Blaze stack.
+
+### Login event pushed — FIFA still won't start the auth chain (2026-07-02)
+
+RE'd the `Login` event from FIFA23.exe's `LoginT` deserializer (0x142787a30): fields
+`UserIndex` (int), `IsLoggedIn` (bool, same true/false parser as isOnline),
+`LoginReasonCode` (int). Pushed `` before OnlineStatusEvent each cycle.
+
+Result: **no change.** FIFA reacts to connectivity (presence INGAME, polling backs
+off) but issues no `GetAuthCode`, resolves no host (`getaddrinfo` count 0), dials no
+Blaze. So Login + OnlineStatus (connectivity + authenticated flags) are **not**
+sufficient to trigger the game-side auth chain.
+
+**Boundary reached for black-box LSX emulation.** We can drive every LSX-wire signal
+FIFA reads, and FIFA visibly reacts, but the decision to start GetAuthCode→Nucleus→
+Blaze lives in FIFA's game-side online orchestrator, whose gating state is invisible
+from the LSX wire (it also makes in-process EbisuSDK calls that go to anadius, not our
+socket — we can't see those). Guessing more LSX events is low-yield from here.
+
+To progress, the next step must convert guessing into observation — **instrument
+FIFA's in-process online flow** via the hook DLL: detour the EbisuSDK/game functions
+around `GetAuthCode`, `GoOnline` (anadius64.dll+0x2BB90), and the OnlineStatusEvent
+consumer, and log what FIFA does after our events (whether it calls GetAuthCode
+in-process, what it checks, where it returns early). That reveals the exact gate.
+Alternatively this is the wall the 2026-06-30 direction pivot anticipated — the
+app-centric FLE/career path avoids the online-backend entirely.
+
+Bank: LSX gate cleared; FIFA driven "online" at the EbisuSDK connectivity/login level
+for the first time (anadius never does); OnlineStatusEvent + Login formats recovered
+and confirmed effective at the event level.
+
+### In-process instrumentation results (2026-07-02)
+
+Built a `probe` feature into openfut-hook (unhook/rehook logging detours, like
+connect_hook) on four online-flow functions. Deployed + ran with the event push
+active. Results (C:\openfut_hook.log):
+
+- **Our events reach FIFA's code and parse successfully.** `OnlineStatus.deser`
+ (FIFA23.exe+0x28a4d0) and `Login.deser` (+0x287a30) each fired 44× (once per 2s
+ re-push) and returned success (0x1 / valid ptr). The push works at the deepest
+ level — not a delivery problem.
+- **`GoOnline` (anadius64.dll+0x2bb90) never fires.** The 2026-06-30 review saw FIFA
+ calling GoOnline and *retrying every ~7s*; with our bridge + events FIFA is not in
+ that retry loop at all. Our events moved it out of the retry state — but it does not
+ advance.
+- **`GetInternetConnectedState` (anadius64.dll+0x27790) never fires** — FIFA asks it
+ over LSX (to our bridge) instead; anadius is fully bypassed for LSX.
+
+**Gate localized to the game-side event consumer.** After the deserializer,
+`OnlineStatusEventT::HandleMessage` delivers the value via a virtual call
+(`call [rax+0x28]`, FIFA23.exe+0x274d4e2) to a polymorphic game listener. FIFA parses
+"online + logged in" every cycle but the listener does not act (no GoOnline, no auth,
+no Blaze). The concrete listener is a runtime vtable target — not statically pinnable.
+
+Note vs. the old M2 finding: forcing anadius's in-process state flags to online left
+FIFA *retrying*; pushing our events *stops* the retry but doesn't advance. Neither
+path alone flips the game-side online state machine — the real gate is in FIFA's own
+online orchestrator, reachable only by probing the runtime virtual listener or a
+deeper RE of that state machine.
+
### Next gate: Blaze/online (M2)
"connecting to EA servers" is the Blaze layer. The hook log shows FIFA has made **no**
diff --git a/src/lsx.rs b/src/lsx.rs
index 8dbd4d7..b972721 100644
--- a/src/lsx.rs
+++ b/src/lsx.rs
@@ -13,6 +13,48 @@ use tracing::{debug, info, warn};
const GREETING_KEY: &str = "cacf897a20b6d612ad0c05e011df52bb";
const AES_KEY: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
+/// The async `ONLINE_STATUS_EVENT` push FIFA waits for after `GoOnline`.
+///
+/// FIFA calls GoOnline (handled in-process by EbisuSDK/anadius), gets success,
+/// then blocks waiting for an unsolicited `OnlineStatusEvent` push on the LSX
+/// socket — the M2 wall. Now that our bridge owns FIFA's LSX socket we can send
+/// it. Format RE'd from FIFA23.exe's OriginSDK deserializer (authoritative):
+/// element `OnlineStatusEvent` (RTTI `Origin::EventHandler`, parser FIFA23.exe+0x274d4ae), single bool attribute `isOnline`
+/// (deserializer FIFA23.exe+0x28a4d0). See docs/connection-gate-findings.md.
+///
+/// The value is the string literal `true`/`false` (not `1`/`0`): FIFA's bool
+/// parser (FIFA23.exe+0x28fb50) string-compares the attribute against `"false"`,
+/// matching the OriginSDK convention every other bool in our LSX responses uses.
+/// An initial `isOnline="1"` push was delivered but parsed as not-online (FIFA
+/// kept polling), which pinned the encoding. Re-push cadence below still
+/// TODO/CONFIRM (single push may suffice once value is correct).
+const ONLINE_STATUS_EVENT: &str =
+ r#""#;
+
+/// The authenticated-session `Login` event. OnlineStatusEvent alone set FIFA's
+/// *connectivity* (presence went INGAME) but not an authenticated session, so FIFA
+/// never started the GetAuthCode→Nucleus→Blaze chain. FIFA's Origin event set has a
+/// separate `Login` event (`Origin::EventHandler`) that
+/// carries the login state. Format RE'd from FIFA23.exe's LoginT deserializer
+/// (0x142787a30): fields `UserIndex` (int, primary user 0), `IsLoggedIn` (bool,
+/// parsed by the same true/false helper as isOnline), `LoginReasonCode` (int/enum).
+///
+/// Pushed BEFORE OnlineStatusEvent each cycle (log in, then go online).
+/// TODO/CONFIRM `LoginReasonCode` — 0 is the natural "no error / normal" value; if
+/// FIFA switches on a specific success code, RE the consumer and adjust.
+const LOGIN_EVENT: &str =
+ r#""#;
+
+/// Events pushed on the LSX socket each interval, in order, to drive FIFA online.
+const ONLINE_PUSH_EVENTS: &[&str] = &[LOGIN_EVENT, ONLINE_STATUS_EVENT];
+
+/// Delay before the first `OnlineStatusEvent` push (and the re-push interval).
+/// Long enough that FIFA has finished LSX bootstrap and subscribed its event
+/// listeners + issued GoOnline (GetInternetConnectedState was seen ~2s in), short
+/// enough to fire while FIFA is still polling and waiting.
+const ONLINE_PUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2000);
+
// Account identity + locale that anadius's in-process LSX emu reports (recovered
// from anadius.cfg — the config the emu reads). FIFA carries these values into
// its online/loading state, so our bridge must report the SAME ones anadius does;
@@ -92,22 +134,53 @@ async fn handle(mut stream: TcpStream) -> anyhow::Result<()> {
stream.write_all(accepted.as_bytes()).await?;
info!("LSX: handshake complete");
- // 4. Session loop
+ // 4. Session loop — multiplexes request handling with an unsolicited
+ // OnlineStatusEvent push. A single task keeps owning the stream; the
+ // `select!` services reads and lets a timer fire the push, so no socket
+ // split / shared writer is needed.
+ let mut push_timer = tokio::time::interval(ONLINE_PUSH_INTERVAL);
+ // interval fires immediately on the first tick — consume it so the first
+ // push lands after ONLINE_PUSH_INTERVAL (giving FIFA time to subscribe).
+ push_timer.tick().await;
loop {
- let raw = match read_msg(&mut stream).await {
- Ok(b) => b,
- Err(_) => break,
- };
- // Dump the exact wire bytes so the session seed can be brute-forced
- // offline against this ciphertext (see docs/connection-gate-findings.md).
- debug!("LSX: session raw ({} bytes): {}", raw.len(), bytes_to_hex(&raw));
+ tokio::select! {
+ read = read_msg(&mut stream) => {
+ let raw = match read {
+ Ok(b) => b,
+ Err(_) => break,
+ };
+ // Dump the exact wire bytes so the session seed can be brute-forced
+ // offline against this ciphertext (see docs/connection-gate-findings.md).
+ debug!("LSX: session raw ({} bytes): {}", raw.len(), bytes_to_hex(&raw));
- let out = process_frame(&raw, seed);
- if out.is_empty() {
- continue;
- }
- if stream.write_all(&out).await.is_err() {
- break;
+ let out = process_frame(&raw, seed);
+ if out.is_empty() {
+ continue;
+ }
+ if stream.write_all(&out).await.is_err() {
+ break;
+ }
+ }
+ _ = push_timer.tick() => {
+ // Push the Login (authenticated session) then OnlineStatusEvent
+ // (connectivity) so FIFA leaves the "connecting" wait and starts the
+ // GetAuthCode→Nucleus→Blaze chain. Each is encrypted + null-terminated
+ // like any session frame. Re-pushed each interval to cover the
+ // subscribe-timing race until FIFA advances (or the socket closes).
+ let mut push_err = false;
+ for ev in ONLINE_PUSH_EVENTS {
+ let mut frame = lsx_encrypt(ev, seed).into_bytes();
+ frame.push(0);
+ debug!("LSX: pushing event: {ev}");
+ if stream.write_all(&frame).await.is_err() {
+ push_err = true;
+ break;
+ }
+ }
+ if push_err {
+ break;
+ }
+ }
}
}