docs: Blaze handshake reference, connection-gate findings, closure write-up

- blaze-handshake.md: three-layer LSX/Blaze-core/UTAS model + six-step Blaze
  session ordering, tagged CONFIRMED/SHAPE/UNKNOWN.
- connection-gate-findings.md: accumulated RE of the online-connect gate.
- closure-and-preservation.md: the capstone. Records what was achieved
  (clean-room LSX/EbisuSDK emulator booting FIFA to an authenticated offline
  menu), the wall reached from three directions, the dial-initiation gate RE
  as the final chapter (Outcome B, circular), and the boundary. Clean-room
  throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-03 15:58:37 -07:00
parent 61b138db89
commit 3649c3d6f0
3 changed files with 639 additions and 0 deletions
+177
View File
@@ -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 16 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*.
+300
View File
@@ -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/<pid>/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<MessageType, Handler>` 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.*
+162
View File
@@ -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/<pid>/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).