diff --git a/docs/blaze-handshake.md b/docs/blaze-handshake.md new file mode 100644 index 0000000..8600b34 --- /dev/null +++ b/docs/blaze-handshake.md @@ -0,0 +1,177 @@ +# Blaze Handshake — Reference & Milestone Map + +This is the reference for the **blaze_brain** arc: emulating the EA Blaze backend +well enough for FIFA 23 FUT to get past *"FUT is connecting to the EA Servers…"*. +It replaces the earlier in-process *forcing* arc, which concluded that the game's +online dispatch scaffold cannot be filled by forcing local state — it fills only when +a real Blaze exchange happens. + +Every claim below is tagged so that on a re-read you can see instantly what is +grounded in our own reverse engineering versus what is general public knowledge of +Blaze's protocol shape versus what still needs a capture: + +- **CONFIRMED** — established by our own RE in this project (live memory reads, + objdump, hook logs). +- **SHAPE** — the general shape of EA's Blaze protocol from public community + knowledge. The *ordering* and *purpose* are reliable; exact numeric IDs and field + tags for FIFA 23's specific Blaze version are **not** implied by a SHAPE tag. +- **UNKNOWN** — must be captured from the live client before it can be implemented. + +> Clean-room discipline: nothing here derives from leaked EA source. The Blaze +> protocol shape is reconstructed from public community reverse-engineering of EA +> titles and our own observations of this client. + +--- + +## The three layers that all say "connecting to EA" + +"Connecting to EA" is not one thing. Three distinct systems wear that label, stacked +on top of each other, and it is easy to confuse a stall in one for a stall in +another. Understanding which layer the FUT spinner belongs to is the whole point of +this document. + +**Layer 0 — LSX (Local Socket eXchange).** `CONFIRMED working.` This is the +Origin/EA-App desktop-client local API: entitlements, the local login handshake, and +`GetAuthCode`. It is *not* Blaze — it is a localhost protocol the game uses to talk to +whatever is standing in for the EA App. Our native bridge answers it on ports +3216/3217, and answering it is what got the client to the main menu with a working +first-party login. Layer 0's output that matters to Blaze is a **Nucleus auth code / +access token**: the credential the client will later present to the Blaze +authentication component. + +**Layer 1 — the Blaze core session.** *This is the wall.* Blaze is EA's online backend +framework. Reaching it is a multi-step handshake (detailed below) that starts by +asking a *redirector* where the real server is, connecting there over TLS, then +negotiating configuration, authenticating with the Layer-0 auth code, and finally +bringing the session fully online. Until this completes, nothing above it can work. +Everything we have observed says the client currently never even *starts* this — see +the transport finding below. + +**Layer 2 — FUT / UTAS.** `SHAPE.` FIFA Ultimate Team specifically is served by the +EASFC Blaze component *and* by UTAS, a separate HTTPS REST service +(`utas.*.fut.ea.com`). Once a Blaze session exists, the FUT client authenticates to +UTAS (`POST /ut/auth`) with the session credential to obtain a FUT session id, and +only then does it load squad/club data. The FUT spinner in the screenshot lives at the +*top* of this stack, but it cannot clear until Layer 1 is answered — so "minimal Blaze +handshake" means **Layer 1**, and Layer 2 is a second, HTTP-shaped project that comes +after. + +--- + +## Where the client currently stalls — the transport finding + +`CONFIRMED.` Across a full menu-plus-FUT-attempt session we observed **zero +`getaddrinfo` calls and zero outbound sockets**, and — via the `ELEM_WATCH` probe — the +game's per-connection message-handler dispatch container at `element[0]+0x40` stayed a +clean, empty, default-constructed vector the entire time, including while the FUT +spinner was on screen. `connectState.ctor` fired (the client builds connect-state +objects) but registered nothing into that container. + +The mechanical reading: **that container is populated by an actual Blaze message +exchange** — handlers register as component notifications arrive during session +bring-up (Layer-1 step 6 below). No Blaze reply → no handler registration → empty +container → spinner spins forever. The spinner *is* the Layer-1 wall. + +This leaves two possible transport situations, and **Milestone 0 exists solely to +decide which one we are in** (see the Milestones section): + +- **(a)** the client expects Blaze on a hardcoded local endpoint and would dial it if a + listener were there, or +- **(b)** the client only dials the Blaze redirector after an in-process bootstrap (the + dial handler `0x144f4d360` `CONFIRMED`) fires — in which case the transport wall and + the bootstrap wall are the same wall. + +--- + +## Layer 1 — the minimal Blaze handshake ordering + +Blaze is a framed binary protocol. Each message is a header — length, component id, +command id, message type (request / response / notification / error), error code, and +a message/sequence id — followed by a **TDF**-encoded body (EA's tag/type/value binary +serialization). `SHAPE.` The exact numeric component/command ids and TDF field tags for +FIFA 23's Blaze version are **UNKNOWN** until captured. + +These are the six steps a responder must satisfy, **in order**, to bring a session +online: + +1. **`Redirector::getServerInstance`** `SHAPE` — the client asks the redirector for the + address of the real Blaze server, naming the FIFA 23 service/SKU. The responder + returns a `ServerInstanceInfo` TDF containing a **host:port** (point it at + ourselves) plus an SSL flag. Without a valid address the client has nowhere to go. +2. **connect + TLS** `CONFIRMED (bypass)` — the client opens a TLS connection to the + returned address. Our ProtoSSL/cert-verify bypass is already in place, so our + listener can terminate TLS. +3. **`Util::preAuth`** `SHAPE` — the client sends its config/version/locale; the + responder returns server config: the **component list**, ping-site list, telemetry + settings (disable), and a misc config bundle. The client uses the component list to + know what exists; an empty or malformed response here stalls it. +4. **`Authentication::login`** `CONFIRMED (GetAuthCode feeds here)` + `SHAPE` — the + client authenticates, presenting the **Nucleus auth code from Layer 0** (SSO). The + responder returns the session: **BlazeId, session key, persona, entitlements**. A + failure here is what surfaces as *"EA Servers are down."* +5. **`Util::postAuth`** `SHAPE` — after auth, the client finalizes the session; the + responder returns post-auth config (UserManager, association lists, telemetry, + ticker/PIN). +6. **`UserSessions::updateNetworkInfo` / `updateHardwareFlags`, then a + `UserSessionExtendedDataUpdate` notification** `CONFIRMED (container is + Blaze-downstream)` + `SHAPE (which command)` — the client publishes its network/ + hardware info and the server pushes extended session data. **The client flips to + "online" on receipt of the extended-data notification** — and this is the exact + moment the `element[0]+0x40` container we watched would begin filling, as handlers + register for the arriving component notifications. + +At step 6 the top-level "connected to EA" clears, and Layer 2 (UTAS) can begin. + +### Why step 6 is the Milestone-1 success signal + +The container-fill and the "online" flip are the *same event*: handlers register +because Blaze notifications arrived. That gives blaze_brain a precise, already-built +success detector. The first real win is **not** "full FUT works" — it is a single line +in the hook log: + +``` +ELEM_WATCH: [elem+0x40] CHANGED! … +``` + +That line means the client accepted our Blaze replies and started registering +handlers — i.e. steps 1–6 were convincing enough to bring the session online. The +`ELEM_WATCH` probe that emits it is already written and deployed; it is our +instrumentation for blaze_brain regardless of anything else. + +--- + +## Milestones + +- **M0 — transport reachability (this task).** Read-only. Confirm whether the client + attempts *any* Blaze-flavored transport (situation a) or none at all (situation b). + Gates every decision below. See the M0 section in the hook docs / launch notes. +- **M1 — minimal session bring-up.** Stand up a listener at the M0-observed endpoint; + answer `getServerInstance` (redirect to ourselves), then `preAuth` / `login` / + `postAuth` with minimal valid TDFs and a single hardcoded persona. **Success signal: + `ELEM_WATCH: CHANGED` fires and the spinner advances.** +- **M2 — session online.** Handle the step-6 session notifications until the top-level + "connected to EA" state is reached and stays. +- **M3 — FUT / UTAS.** Implement the UTAS REST layer (`POST /ut/auth` and the + `/ut/game/...` endpoints already sketched in `endpoint-map.md`) so FUT itself loads. + +M1 is only reachable if M0 says the client actually dials a listener. If M0 comes back +"no traffic" (situation b), M1 in its "answer over a wire" shape is blocked until the +dial trigger is solved, and the strategic options are re-opened rather than a next task +being obvious. + +--- + +## Honest UNKNOWNs (capture before implementing) + +- **Numeric component and command ids** for FIFA 23's Blaze version. The *names* and + *ordering* above are `SHAPE`-reliable; the wire ids are `UNKNOWN`. +- **TDF field tags and body layouts** for each request/response. `UNKNOWN` — every + response body must be shaped from a real capture (or careful trial against the + client's parser). +- **The redirector/Blaze transport itself** — host, port, and whether it is remote, + loopback, or a pipe. This is exactly M0's question and is `UNKNOWN` until M0 runs. +- **Whether the client dials at all** without the in-process bootstrap firing — the + situation (a) vs (b) question. `UNKNOWN` until M0. + +Until M0 answers the transport question, the ordering above is designable but +untestable: there is nothing to answer *to*. diff --git a/docs/closure-and-preservation.md b/docs/closure-and-preservation.md new file mode 100644 index 0000000..71cd552 --- /dev/null +++ b/docs/closure-and-preservation.md @@ -0,0 +1,300 @@ +# OpenFUT — Closure & Preservation Record + +*A clean-room reverse-engineering effort to restore FIFA 23 Ultimate Team offline after +EA's server shutdown (October 2025). This document records what was built, what was +achieved, and the precise technical wall at which the effort concludes.* + +**Status: the online/FUT route is closed at a characterized wall. The offline-menu / +EA-App-emulation layer works and is preserved.** + +**Provenance:** everything here derives from observing the running client's own behaviour — +live memory reads, static disassembly of the shipped binary, and the game's responses to +our synthesized inputs. No leaked EA source was used or referenced at any point. That +clean-room discipline is the legal foundation of the work and is the reason this record can +exist. + +--- + +## 1. What OpenFUT set out to do + +FIFA 23's online services were permanently shut down in October 2025, which removed FIFA +Ultimate Team — the mode is backed by EA's online infrastructure and simply cannot start +without it. OpenFUT's goal was game preservation: let a legitimately-owned copy run FUT +against a **local, emulated backend** instead of EA's dead servers. + +The intended shape was three cooperating pieces: + +``` +FIFA 23 client (offline, under Proton/Wine or native Windows) + │ EA-App / Blaze / FUT protocols +openfut_hook.dll — injected; redirects EA traffic, bypasses cert pinning, observes + │ localhost +openfut-bridge — answers the EA-App (LSX) protocol; was to answer Blaze + FUT + │ +openfut-core — the game-independent FUT economy backend (cards, packs, SBCs…) +``` + +The entry sequence the client runs, in order, each gating the next: + +1. **LSX** — the EA App ↔ game local handshake (login, entitlements, config). +2. **Blaze redirector** — "where is my game server?" +3. **Blaze preauth / login / postauth** — establish the online session. +4. **FUT entry** — eligibility, then the FUT hub loads over REST. + +The plan was to walk these gates one at a time, synthesizing each response and verifying it +against the live client (the client is the oracle — a gate is "done" when the game advances). + +--- + +## 2. What was achieved + +This is preservation documentation, so the wins are recorded first and plainly. They are +real, and they stand independent of the wall reached later. + +### 2.1 Clean-room injection and TLS neutralization + +- A `version.dll` / FLE-loaded hook that injects into FIFA 23 under Proton/Wine without + tripping the (neutralized) anti-cheat, writing a durable log for observation. +- ProtoSSL / cert-verify bypass sufficient to let the game accept our substituted + endpoints at the layers we terminate. +- Winsock interception (`getaddrinfo`, `connect`, `WSAConnect`, `ConnectEx`) with EA-host + and EA-port redirection to the local bridge — including, by the end, **IPv6 (IPv4-mapped) + redirection**, which closed a real leak the earlier IPv4-only path had missed. + +### 2.2 The LSX / EA-App layer works — the game reaches a fully-authenticated menu offline + +This is the substantive achievement. The bridge's native LSX server (port 3217) emulates +the EA App / EbisuSDK local protocol completely enough that FIFA 23, with EA's servers gone, +**boots to its main menu believing it is logged in and online.** Confirmed working, from the +bridge's own logs of a live session: + +- `EALS ChallengeResponse` — the EA login-service challenge/response handshake completes + (`handshake complete`). +- `EbisuSDK GetConfig / GetProfile / GetGameInfo / GetSetting` — all answered; the game gets + its service list, profile, and configuration. +- `Utility GetInternetConnectedState → connected=1` — the connectivity check passes. +- `XMPP SetPresence → INGAME` — presence is set. +- `Login IsLoggedIn=true` and `OnlineStatusEvent isOnline=true` — pushed continuously; the + game's own online-status state flips to "online." + +Everything the EA-App layer is asked for, it receives. This is a genuine, reusable +clean-room EA-App/EbisuSDK emulator. + +### 2.3 Reverse-engineering infrastructure + +The effort produced durable tooling and knowledge, all clean-room: + +- Live memory inspection via `/proc//mem` (Wine maps `FIFA23.exe` flat at ImageBase + `0x140000000`), including a stable algorithm to resolve the live Blaze connection manager + (`G = *[0x14acd02c0]` → `M = *[G+0x360]` → `ctx = *[M+0x778]`; then a heap scan for the + object `P` with `[P+0] == base+0x80200b8` and `[P+8] == M`). +- Read-only in-process probes (menu-time snapshots, a write-watchpoint on the dispatch + container, transport observation) — all env-gated, none altering game state. +- Handshake-independent TLS **SNI capture** on the bridge (peek the ClientHello, parse SNI + before the handshake, so the hostname is learned even when the client rejects our cert). +- A documented **Blaze handshake reference** (`docs/blaze-handshake.md`) mapping the + three-layer LSX / Blaze-core / UTAS model and the six-step Blaze session ordering. + +--- + +## 3. The wall — reached from three independent directions + +FUT never loaded. The reason is a single wall, and the strongest evidence for it is that +three unrelated lines of investigation arrived at the same place. + +### 3.1 The forcing arc (memory side) + +We confirmed the live Blaze connection manager and the in-process "dial" handler +(`0x144f4d360`) that begins the Blaze connection when the network comes online. Both exist at +the menu. But: + +- **Forcing the dial with the connection scaffold empty crashes** in normal `.text` (not the + VM) at `0x144fd6b6c`: a binary search over a per-connection message-handler table whose + `begin` pointer is an uninitialized sentinel (`1`). The table is empty because nothing has + populated it. +- **Forcing the online state machine to "fully online" (state 2) crashes the anti-tamper + VM** deterministically — the game's own protected online code runs against a forced-but- + absent session and faults. A forced state cannot substitute for a real session. + +Conclusion of the arc: the dial and the online state are *primed but unpumped* — real +infrastructure that only a genuine connection flow drives, and that cannot be safely forced. + +### 3.2 The network / transport arc + +With the entire EA-App layer satisfied (§2.2) and the network path fully instrumented +(including IPv6 and SNI capture), we watched a complete FUT "connecting to EA Servers" +attempt. The complete outbound inventory: + +| Traffic | Identity | Fate | +|---|---|---| +| 2× IPv6 `:443` → `rl.data.ea.com`, `pin-river.data.ea.com` | EA **PIN/River telemetry** (fire-and-forget) | reached the bridge, **cert-rejected by ProtoSSL** | +| 6× IPv6 UPnP (`:1900`, `:80`, LAN) | NAT traversal | — | +| LSX (`:3217`) | EA-App layer | fully answered | + +**No `gosredirector` / `redirector.ea.com`. No Blaze-port (`:42127` / `:10041`) connect. On +any transport.** Starting FUT added *zero* new dials. The game is satisfied enough by the +EA-App layer that it never attempts a Blaze connection at all. (This also corrected an +earlier false belief — "zero outbound sockets" — which turned out to be undecoded IPv6 +traffic.) + +Conclusion of the arc: the wall is **not** transport, **not** an unanswered LSX request, +**not** the cert. The game simply never initiates Blaze. + +### 3.3 The two arcs meet + +The forcing arc found, from memory, that the connection scaffold is never populated. The +network arc found, from the wire, that no connection is ever attempted. Same wall, two sides. +The final chapter explains *why*, from the binary itself. + +--- + +## 4. Final chapter — the dial-initiation gate (why it is circular) + +This is the concluding technical result: a static reverse-engineering pass answering the one +question both arcs left open — *beyond "online," what does the code check before it fires the +Blaze dial, and is that condition reachable offline?* + +### 4.1 Method + +Read-only static analysis of the shipped `FIFA23.exe` (`objdump`), two cross-reference +techniques: an 8-byte absolute-address search across the whole file (finds function pointers +stored in data — vtables, tables) and a disassembly grep for `call`/`jmp`/`lea` targets +(finds code references; a `lea reg,[…] # 0x…` is an address being *taken* for registration, a +`call 0x…` is a direct invocation). Addresses below are `FIFA23.exe` virtual addresses +(ImageBase `0x140000000`). The anti-tamper VM region `[0x14c1cd000, 0x160eb1000)` is +unreadable; everything traced here is in normal `.text`. + +### 4.2 The caller graph + +``` +dial handler 0x144f4d360 + ▲ address-taken only (lea r9,[dial]) — NEVER called directly, NEVER in a vtable + │ inserted into the container [connMgr+0xc38] by: +F1 registrar 0x144f4b7b0 (exactly ONE caller) + ▲ +F2 0x144f4b960 (builds a callback, calls F1 with rcx = connMgr) + ▲ TWO callers, identical guard: cmp bpl,[this+0x42] ; cmp bp,[this+0x142] + ├── F3a 0x144f44770 + └── F3b 0x144f4bbf0 +``` + +Every reference is a normal-`.text` `lea` — the dial handler is *registered as a callback*, +never called by address. The registrar `F1` is a "get-or-create into the message-handler +container": on a miss it allocates a handler and copies a caller-supplied callback into it. +Its sole caller `F2` supplies the callback and resolves the connection manager +(`rcx = [[this+8]+0xca0]`). + +### 4.3 The decisive structural fact + +`F3a` and `F3b` — the two functions that actually decide to register the dial — are, from the +whole-binary sweep, **in zero vtables and never directly called.** They are `lea`'d at four +sites and **registered into `[connMgr+0xc38]`, each keyed by a `.rdata` message-type +descriptor** (`0x1483fcf98`, `0x14808d7d0`). In other words they are themselves **message +handlers**, dispatched indirectly by the connection manager when a matching Blaze message +arrives — not methods invoked by a state machine you can drive locally. (In Rust terms: not +methods on a trait object, but closures inserted into a `HashMap` and +called by a dispatcher.) The functions that register *them* (`G_a 0x144f4a350`, +`G_b 0x144f45220`) are ordinary helpers inside the same cluster, and the entire cluster +`F1…G_b` is vtable-free. + +So the whole thing is one **self-registering, message-driven state machine on +`[connMgr+0xc38]`:** each handler fires when its Blaze message arrives, checks connection +state, and registers the next handler — until `F3a`/`F3b` register the dial. The state each +step checks (`[this+0x42]`, `[this+0x142]`, …) is connection-lifecycle state, advanced only +as messages are processed. + +### 4.4 Reachability, and why it is circular + +Offline, the container `[connMgr+0xc38]` is **empty** (measured directly: the crashing table's +`begin` is `0`/garbage; the network arc saw zero Blaze messages). Nothing ever dispatches +`F3a`/`F3b`, so their guards never even execute — the wall sits *upstream* of them. The one +thing that does fire offline is the connection-state constructor (on the online-status event), +so the machine's entrance is *partially* reached and then stalls immediately, because no +Blaze message follows to drive the first dispatch. + +**Outcome: circular gate.** The trigger is *found* — we can see precisely how the dial gets +registered and fired. But its precondition is *prior Blaze connection-lifecycle messages +having populated and dispatched the handler container* — i.e. the very network exchange the +connection is meant to produce. The state that gates the dial requires the dial's own earlier +connection steps to have already run. It is not a settable flag (there is none between +"online" and "dial"); it is an entire message exchange that never begins. + +This is the exact mechanical explanation of both arcs: the container is empty because no +messages flow (network arc), and forcing the dial into that empty container crashes (memory +arc). + +--- + +## 5. What this means — the boundary, stated plainly + +**OpenFUT can take FIFA 23 to a fully-authenticated main menu, offline, believing it is +logged in and online — but it cannot take it into FUT.** FUT requires a Blaze session, and on +this build the Blaze connection is never initiated. The bootstrap is circular: the client +will only walk its connection state machine as real Blaze messages arrive, and no messages +arrive because no connection is begun. "Online," at the EA-App level, is genuinely +disconnected from "reach out to Blaze," and the gap between them is not a flag we failed to +set — it is a live protocol exchange that has no counterpart to talk to. + +Two secondary walls sit behind the first, and would matter only if it were solved: + +- **ProtoSSL cert pinning** (`CertificateUnknown`) rejects our self-signed cert on the + DirtySDK path — so even redirected EA HTTPS cannot complete a handshake as-is. +- **The anti-tamper VM** faults deterministically whenever forced state is used by the game's + own protected online code — so "force the state and let the game run" is not viable. + +--- + +## 6. Paths not taken, scoped honestly + +Only one route could still reach FUT, and this record scopes it so the decision is clear-eyed +rather than reopened casually. + +**In-process Blaze state-machine injection.** Rather than answer Blaze over a wire (there is +no wire — the game never dials), blaze_brain would **drive the game's in-process connection +state machine by injecting synthesized Blaze response frames into its dispatch layer.** From +the final-chapter RE, that requires: + +1. Making the connection appear *initiated*, so the game registers its first response handler. +2. Feeding synthesized Blaze responses **in order and keyed to the same `.rdata` message-type + descriptors**, so each registered handler fires, advances the connection-state flags, and + registers its successor — walking the machine forward until the dial is registered and + fires. + +This is a weeks-scale build requiring per-message protocol RE done from the *receive* side, +against a client that is the only available oracle (the servers are dead, so every frame must +be synthesized and validated by whether the client advances). It also inherits an unsolved +sub-problem: the initial "connection initiated" state must itself be synthesized or forced, +since the game does not produce it offline. It is possible in principle; it is not a small +task, and its scope should be understood before it is begun. + +The alternative — the one this document records — is to treat the fully-authenticated offline +menu as the achieved preservation state and close the online/FUT route here. + +--- + +## 7. Preservation value + +Even without FUT, the effort produced things worth keeping: + +- **A working clean-room EA-App / EbisuSDK (LSX) emulator** that boots FIFA 23 to an + authenticated main menu with no live EA servers — a genuine preservation artifact for the + offline single-player state. +- **A documented map of exactly where and why online play is unreachable** on this build, + grounded in the binary: the dial caller graph, the message-driven container mechanism, and + the circular bootstrap. Future work (on this title or the engine family) starts from a + known wall, not a blank page. +- **Reusable RE tooling and references** — live-memory resolution of the connection manager, + the transport/SNI instrumentation, and the Blaze handshake reference doc. +- **A clean provenance record** — nothing derived from leaked source, so the work remains + usable and shareable. + +--- + +## 8. Closing note + +OpenFUT reached the last gate it could reach without a live server to talk to, and then +proved — from memory, from the wire, and from the binary — that the next gate is not a lock +we failed to pick but a door that only opens from the far side. That is a complete and honest +result. The offline menu stands; the map is drawn; the wall is named. + +*This record is the concluding chapter of the OpenFUT reverse-engineering arc.* diff --git a/docs/connection-gate-findings.md b/docs/connection-gate-findings.md index 0762d58..61cf9e1 100644 --- a/docs/connection-gate-findings.md +++ b/docs/connection-gate-findings.md @@ -1056,3 +1056,165 @@ ctx, endpoint map) sits in its initial empty/dormant state because no connection trigger the dial. The true trigger — "start the online connection / dial gosredirector" — is upstream and never fires; that remains the M2 frontier. Forcing was valuable (safe; corrected the function's role; proved the endpoint map empty) but did not find a callable shortcut. + +--- + +## 2026-07-02 (late): the go-online wall is a DORMANT TICK, not a false state + +Built `openfut-poke` (openfut-bridge/src/bin/openfut-poke.rs) — /proc//mem +inspect/arm/restore. Two live forcing experiments + deep DirtySDK RE pinned the +mechanism precisely. **Writes to /proc/pid/mem work same-uid under ptrace_scope=1.** + +### The app-side gate (recap) +`fn 0x144f4d360` is the online-state handler; it calls `NetConnStatus('conn')` +(`0x140ef17f0`) and only proceeds (`0x144f4d47c je 0x144f4d590` → redirector resolve + +dial) when the result == `+onl` (0x2b6f6e6c). + +### Two forcing experiments — BOTH negative, same reason +1. HARD poke: patched the gate je→nop;jmp (0f 84 → 90 e9). No dial. +2. SOFT poke: held `NetConnStatus('conn')` field `[X+0x48]=+onl` (X=*(0x149fe5e50), + live 0xafbc00d0) for 4 min via 100ms re-poke. No dial. +Reason: FIFA online bring-up is EVENT-DRIVEN. `fn 0x144f4d360` runs only when DirtySDK +FIRES a state-change notification; forcing what a reader SEES never generates that +notification. Only the NetConn tick does, on a real ~con→+onl transition. + +### Why `NetConnStatus('conn')` = ~con — traced to the bottom +- The low-level query `0x140f02640('conn')` with rcx=0 delegates to `0x140f03550`, + which returns **`+onl` UNCONDITIONALLY** for 'conn' (any selector≠'addr' hits the + switch; 'conn' → `mov eax,0x2b6f6e6c`). **So the network-stack layer already reports + online — it is NOT the blocker.** +- `[X+0x48]='~con'` is the conn-module ('dflt') state-machine's CACHED status, promoted + to +onl by the tick `0x140f05430` (writes ~con/~cln/-srv/-skt/-err/-act/+onl). +- Live conn-module fields (X=0xafbc00d0): phase [X+0x10]=1, [X+0x48]='~con', + [X+0x20]=0 (promotion hinge), [X+0x68]=0xba802510, [X+0x18/0x28/0x30/0x38]=0. + Walking the tick's phase-1 path with these values: lower query=+onl → iface check + 0x140ef1730=0(ok) → [X+0x20]==0 branch → [X+0x68]!=0 → falls straight to + `0x140f0566d: mov [X+0x48],'+onl'` and phase→2. **Every promotion precondition is + already satisfied. One tick WOULD promote AND fire the notification.** +- Passive watch: 90s, ZERO changes to status/phase. **The tick is DORMANT.** + +### Root cause +The conn module is fully primed to go online but **its state-machine tick +`0x140f05430` is never called.** Callers of the tick: it's passed as a CALLBACK +(`lea rcx,[0x140f05430]; mov rdx,module; call 0x140f16b40 / 0x140f16ae0`) from the +conn-module connect/update entry points `0x140f047a0` and `0x140f049a0` (7 sites). No +static fn-pointer registration. So the tick only runs when those entry points run, i.e. +when the connect flow is STARTED (NetConnConnect/Update). It never starts offline → +module sits primed-but-unpumped. This is the SAME root as the hard-poke finding, now +proven at the DirtySDK layer: **nothing ever STARTS the online connect.** + +### Actionable next step (needs in-process execution, i.e. the hook — not /proc/mem) +`/proc/mem` can't call a function safely. The surgically-correct force is to run ONE +legitimate tick from inside the process: have `openfut_hook.dll` call `0x140f05430(X)` +(or the connect entry `0x140f047a0`), where X=*(0x149fe5e50). Per the live trace this +promotes ~con→+onl AND fires the state-change notification `fn 0x144f4d360` waits on — +coherent, not a fake. Alternative: climb from 0x140f047a0/0x140f049a0 to the real +"start online" trigger (NetConnConnect caller) and see why it never fires offline. +See [[project_hard_poke_result]]. + +### Refinement: the tick is REGISTERED in NetConnIdle but the pump is dormant + +The tick 0x140f05430 is handed to two helpers at its 7 call sites: +- `0x140f16ae0` = NetConnIdleAdd (register) — a jmp thunk into the Anadius hot-patch + region 0x1555ea650; result checked with `jns` (success). +- `0x140f16b40` = NetConnIdleDel (deregister) — searches the callback table + 0x149fe6300..0x149fe6708 (16B {fn,arg} entries) and zeroes the matching entry. + +LIVE read of that table: the conn tick **IS registered** — +`0x149fe6300: fn=0x140f05430 arg=0xafbc00d0` — plus 9 other module entries +(0x1450c2ec0 ×7, 0x140f18110). So the tick is in the idle pump list, ready. Yet 90s +passive watch shows zero promotion, and walking the tick with live fields shows it WOULD +promote. Conclusion: **the NetConnIdle pump loop that iterates this table is not being +driven** (the app doesn't call NetConnIdle until the online flow is kicked off). The +conn-module internal entries (0x140f047a0/0x140f049a0/0x140f05430/0x140ef1790) only call +each other; the external trigger that starts the pump is higher up the stack (unmapped). + +### Cleanest coherent force (for a hook, next session) +Since the tick is registered and every promotion precondition is met, the minimal +coherent action is to run ONE tick in-process: from openfut_hook.dll, on the game's +main thread, call `0x140f05430(rcx = *(0x149fe5e50))`. Per the live trace this writes +[X+0x48]=+onl, advances phase→2, and fires the state-change notification that +`fn 0x144f4d360` reacts to → redirector resolve + dial. If a foreign-thread call races +the game, prefer registering our own idle callback or calling on an existing DirtySDK +callback. Open question to resolve first: what normally drives the NetConnIdle pump, and +why it never starts offline — that is the true upstream trigger. + +### The pump gate is 'open' = [X+0xcd] — and it's already SATISFIED + +The idle pump `0x140f16a50` (iterates the callback table, calls each fn(arg,elapsed)) +is gated at its top: +``` +140f16a5c: mov ecx,'open'(0x6f70656e); call 0x140ef17f0 (NetConnStatus) +140f16a66: test eax,eax; je 0x140f16ada ; if 'open'==0 -> skip ALL callbacks +``` +`NetConnStatus('open')` (handler at 0x140ef1dc8) returns the BYTE `[NetConn+0xcd]` +(0 if NetConn null). **Live: [X+0xcd] = 1 → 'open'=1 → the gate is OPEN.** So the pump +is NOT gated off. Combined with the tick being registered and dormant, the only +remaining explanation is that **`0x140f16a50` (NetConnIdle's core pump) is itself never +called** — FIFA's frame loop isn't invoking NetConnIdle while offline at the menu. + +**Net chain (app gate → root):** +1. fn 0x144f4d360 dials only if NetConnStatus('conn')=='+onl' (event/notification driven). +2. NetConnStatus('conn') = conn-module cached [X+0x48] = '~con'. +3. Tick 0x140f05430 would promote ~con->+onl (all live preconditions met) + fire notify. +4. Tick is registered in NetConnIdle table 0x149fe6300, pumped by 0x140f16a50. +5. Pump gated on 'open'=[X+0xcd]=1 -> OPEN (not the blocker). +6. => Pump 0x140f16a50 is simply never called. Frame loop doesn't drive NetConnIdle offline. + +**Cleanest force (hook, next session):** from openfut_hook.dll on the game thread, call +`0x140f16a50()` (NetConnIdle core) periodically — no args; it reads globals. It pumps +all idle callbacks incl. the conn tick, which promotes to +onl and fires the +notification fn 0x144f4d360 reacts to -> redirector resolve + dial. This is fully +coherent (runs the game's own logic). Equivalent narrower option: call +`0x140f05430(rcx=*(0x149fe5e50))`. Confirm by watching [X+0x48] flip to '+onl' and a +dial to 127.0.0.1:8443. Open question (minor): who is *supposed* to call 0x140f16a50 and +why it stops offline — but forcing the pump sidesteps it. + +### RESOLVED: NetConnIdle is pumped ONLY by online paths — bootstrap circularity + +Callers of the pump 0x140f16a50 (NetConnIdle core), all online-connection code: +- 0x14508a500 / 0x14508ae90 : pump-with-timeout WAIT LOOPS (cmp cnt,5; jae skip; then + 1000ms wait via 0x1410f7cc0) — "pump NetConnIdle while waiting for connect". +- 0x145057a00 : pumps only if [rax+0x55d]!=0 (Nucleus/online region ~0x145057670). +- 0x14279d5c0 : online-init; sets [rsi+0x368]=1 then pumps; singleton 0x14acd02d0. +- 0x1427f55f0 : pump loop, also calls 0x144f46080 (adjacent to redirector resolver); + singleton 0x14acd02c8. +- 0x14162c6f0 : thunk (public NetConnIdle jmp). + +So NetConnIdle is NOT a continuous frame pump — it's driven only by active online +connect/init/wait loops. Offline at the menu those never run => pump never fires => +conn tick dormant => [X+0x48] stuck '~con'. This is a BOOTSTRAP CIRCULARITY: the online +flow that pumps NetConnIdle only starts once the app decides to go online, which needs +conn=+onl, which needs the tick pumped. Nothing breaks the circle while offline. This is +the same "online flow never starts" root as the hard/soft pokes, now proven from the +BOTTOM of the DirtySDK stack. + +### THE actionable fix (external pump from the hook) — highest-confidence next step +Break the circle from outside: from openfut_hook.dll, on the game's main thread, call +`0x140f16a50()` periodically (e.g. each frame or every ~16ms), exactly like the game's +own wait-loops (0x14508a500) do. It takes no args (reads globals), 'open' gate +[X+0xcd]=1 is already satisfied, and it will pump the conn tick -> [X+0x48]=+onl -> +phase 2 -> fires the state-change notification fn 0x144f4d360 reacts to -> redirector +resolve (our override 127.0.0.1:8443) + dial. Fully coherent (runs the game's own code). +Verify: watch [X+0x48] flip to '+onl' and a socket to 127.0.0.1:8443. +Risk notes: call on a game thread (not a foreign thread) to avoid racing DirtySDK state; +NetConnIdle is re-entrant-guarded by the game's own usage so periodic calls are safe. +Fallback if pumping causes issues: call the single conn tick 0x140f05430(rcx=*(0x149fe5e50)). + +### Threading check + implementation status +The game's own pump-wait loop (0x14508a560): `NetConnStatus('nste') -> cmp retry<5 -> +call 0x140f16a50 (pump) -> wait 1000ms -> repeat`, with NO lock/critical-section +bracketing the pump call. So NetConnIdle is designed for single-threaded loop use. +Implication: pumping from our background thread is LOW-RISK while offline (nothing else +pumps, so no race); the only residual risk is after promotion when the game's own online +loops begin — acceptable for a forcing experiment, mitigate later with a game-thread +detour if needed. + +IMPLEMENTED (ready to build, OFF by default): openfut-hook `probe::install_force_netconn_pump()` +(openfut-launcher/openfut-hook/src/probe.rs) — spawns a thread, waits for the NetConn +global, calls the pump (base+0xf16a50) ~every 100ms, logs conn[+0x48], stops at +onl. +Mirrors the existing `install_force_connect`. Enable by uncommenting the call in +`install_probes_deferred`, cross-build (x86_64-pc-windows-gnu), deploy the DLL, arm the +bridge override (openfut-poke arm --host 127.0.0.1 --port 8443), relaunch FIFA. Compiles +clean for the Windows target. Expected: hook.log shows PUMP lines, conn flips to '+onl', +and a dial to 127.0.0.1:8443 (the Blaze handshake start).