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:
@@ -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*.
|
||||||
@@ -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.*
|
||||||
@@ -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
|
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
|
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.
|
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).
|
||||||
|
|||||||
Reference in New Issue
Block a user