Compare commits
13 Commits
c58e7326a1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3649c3d6f0 | |||
| 61b138db89 | |||
| 80107b7800 | |||
| e4108fcff8 | |||
| 8f5b6b1d59 | |||
| 105cca1cbe | |||
| 404064f0bb | |||
| 6bd1241bdf | |||
| ba7bb4f4be | |||
| c8f6adfb70 | |||
| 24c1925b5f | |||
| feb03fec78 | |||
| 0138a39f3e |
Generated
+72
@@ -2,6 +2,17 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -182,6 +193,16 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -208,6 +229,25 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -337,6 +377,16 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -694,6 +744,15 @@ dependencies = [
|
|||||||
"hashbrown",
|
"hashbrown",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@@ -845,6 +904,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|||||||
name = "openfut-bridge"
|
name = "openfut-bridge"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1700,6 +1760,12 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
@@ -1760,6 +1826,12 @@ version = "0.2.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "want"
|
name = "want"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ path = "src/main.rs"
|
|||||||
name = "openfut-bridge-replay"
|
name = "openfut-bridge-replay"
|
||||||
path = "src/bin/replay.rs"
|
path = "src/bin/replay.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "openfut-bridge-xref"
|
||||||
|
path = "src/bin/xref.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "openfut-poke"
|
||||||
|
path = "src/bin/openfut-poke.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.7", features = ["macros"] }
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
@@ -45,6 +53,7 @@ tokio-rustls = "0.24"
|
|||||||
# hyper 1.x + hyper-util (same versions axum 0.7 pulls in)
|
# hyper 1.x + hyper-util (same versions axum 0.7 pulls in)
|
||||||
hyper = { version = "1", features = ["http1", "http2"] }
|
hyper = { version = "1", features = ["http1", "http2"] }
|
||||||
hyper-util = { version = "0.1", features = ["server-auto", "tokio"] }
|
hyper-util = { version = "0.1", features = ["server-auto", "tokio"] }
|
||||||
|
aes = "0.8"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Handoff — LSX handshake complete end-to-end
|
||||||
|
|
||||||
|
**Date:** 2026-07-02
|
||||||
|
**Scope of the session:** `openfut-bridge` (the delivery test) that expanded into
|
||||||
|
`openfut-launcher/openfut-hook` (the fix). Nothing was committed — everything below is
|
||||||
|
uncommitted working-tree state.
|
||||||
|
|
||||||
|
Read `docs/connection-gate-findings.md` (bottom two sections) for the full technical
|
||||||
|
narrative. This file is the short "where we are / what to do next" version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
FIFA 23 now completes the **LSX handshake and holds a sustained encrypted session
|
||||||
|
against our own bridge**, live — the first EA-side step our code services end-to-end.
|
||||||
|
Three changes made it work:
|
||||||
|
|
||||||
|
1. **Hook redirect** — FIFA's `connect(127.0.0.1:3216)` is rewritten to `:3217` by our
|
||||||
|
hook. The existing in-process offline shim keys on **port 3216 specifically**, so a
|
||||||
|
different port slips past it and lands on our host bridge (loopback is shared with
|
||||||
|
the Wine/Proton prefix — same netns).
|
||||||
|
2. **AES key** = `000102030405060708090a0b0c0d0e0f`. The `[0,1,2,…,15]` sequence that
|
||||||
|
looked like a placeholder is the actual session key; confirmed by round-tripping
|
||||||
|
against a captured live session.
|
||||||
|
3. **Session-seed fix** — `compute_seed()` must take the **raw ASCII bytes** of the
|
||||||
|
first two chars of our response hex, not parse them as a hex pair.
|
||||||
|
|
||||||
|
Last live run: FIFA held one `ESTAB` connection to the bridge and drove 20 EbisuSDK
|
||||||
|
requests with **no crash**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current deployment state (IMPORTANT)
|
||||||
|
|
||||||
|
- **The redirect hook is currently deployed** as `/mnt/games/FIFA 23/version.dll`
|
||||||
|
(md5 `dab3952809c24122af228a3201b9357b`). While this is in place, FIFA routes LSX to
|
||||||
|
our bridge and **requires the bridge running on 3217** or LSX will not complete.
|
||||||
|
- **Backup of the previous hook:**
|
||||||
|
`/mnt/games/FIFA 23/version.dll.pre-lsx-redirect.bak` (md5 `fb7a5aae…`).
|
||||||
|
- **To restore the previous behavior for normal offline play:**
|
||||||
|
```bash
|
||||||
|
cp "/mnt/games/FIFA 23/version.dll.pre-lsx-redirect.bak" "/mnt/games/FIFA 23/version.dll"
|
||||||
|
```
|
||||||
|
- The bridge process from this session may or may not still be running. Check:
|
||||||
|
`pgrep -a openfut-bridge` / `ss -tlnp | grep 3217`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Uncommitted changes (nothing is committed)
|
||||||
|
|
||||||
|
### `openfut-bridge`
|
||||||
|
- `src/lsx.rs`:
|
||||||
|
- **Seed fix** in `compute_seed()` (the load-bearing change).
|
||||||
|
- AES-128 hand-roll replaced with the `aes` crate (+ a FIPS-197 unit test
|
||||||
|
`aes128_matches_fips197_and_round_trips`).
|
||||||
|
- A debug log line dumping raw session ciphertext (used during verification against
|
||||||
|
the capture; harmless to keep, or remove).
|
||||||
|
- `Cargo.toml` / `Cargo.lock`: added `aes = "0.8"`.
|
||||||
|
- `docs/connection-gate-findings.md`: corrected classification + new "LSX complete"
|
||||||
|
section.
|
||||||
|
- `docs/HANDOFF-lsx-2026-07-02.md`: this file.
|
||||||
|
- `captures/lsx-handshake-capture-2026-07-02.log`: a captured live handshake plus the
|
||||||
|
raw session ciphertext, kept as a reference sample for verifying the implementation.
|
||||||
|
|
||||||
|
### `openfut-launcher/openfut-hook`
|
||||||
|
- `src/connect_hook.rs`: added a `PORT_LSX_NBO (3216) → PORT_LSX_TARGET_NBO (3217)` arm
|
||||||
|
in `redirect_if_ea`, plus result logging on the redirect path.
|
||||||
|
|
||||||
|
> There is **other** pre-existing uncommitted work in these trees from before this
|
||||||
|
> session (e.g. bridge TLS/proxy changes, core changes). Do not sweep it into a commit
|
||||||
|
> without checking with the user. Confirm scope before committing anything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to reproduce the working LSX session
|
||||||
|
|
||||||
|
1. **Build the hook** (redirect):
|
||||||
|
```bash
|
||||||
|
cd openfut-launcher/openfut-hook
|
||||||
|
cargo build --release --target x86_64-pc-windows-gnu
|
||||||
|
```
|
||||||
|
2. **Deploy it** (back up first — already backed up once):
|
||||||
|
```bash
|
||||||
|
SRC=openfut-launcher/openfut-hook/target/x86_64-pc-windows-gnu/release/openfut_hook.dll
|
||||||
|
cp -f "$SRC" "/mnt/games/FIFA 23/version.dll"
|
||||||
|
```
|
||||||
|
3. **Build + run the bridge on 3217:**
|
||||||
|
```bash
|
||||||
|
cd openfut-bridge && cargo build --release
|
||||||
|
LSX_ADDR=127.0.0.1:3217 RUST_LOG=openfut_bridge=debug \
|
||||||
|
./target/release/openfut-bridge > /tmp/bridge-lsx.log 2>&1 &
|
||||||
|
```
|
||||||
|
4. **Launch FIFA** (user does this — umu/Steam via
|
||||||
|
`/home/alex/Desktop/launch-fifa23-live-editor.sh`).
|
||||||
|
5. **Verify:** `ss -tnp | grep 3217` should show `ESTAB FIFA23.exe ↔ openfut-bridge`,
|
||||||
|
and `/tmp/bridge-lsx.log` should show multiple `LSX: dispatch …` lines (not one
|
||||||
|
garbage message).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key facts / constants
|
||||||
|
|
||||||
|
| Thing | Value |
|
||||||
|
|---|---|
|
||||||
|
| EA App LSX port (FIFA connects here) | `127.0.0.1:3216` |
|
||||||
|
| Redirect target (our bridge LSX) | `127.0.0.1:3217` (`LSX_ADDR`) |
|
||||||
|
| LSX AES-128-ECB key | `000102030405060708090a0b0c0d0e0f` |
|
||||||
|
| Our greeting key (challenge plaintext) | `cacf897a20b6d612ad0c05e011df52bb` |
|
||||||
|
| Session seed rule | first **two ASCII chars** of our `ChallengeAccepted` response hex, as raw bytes (e.g. `"0f…"` → `0x3066`), NOT the parsed hex pair |
|
||||||
|
| Existing shim interception | in-process, keyed on **port 3216**; loopback is shared (FIFA netns == host netns) |
|
||||||
|
|
||||||
|
Small verification helpers (`keyscan.py`, `seedscan.py`) live in the session
|
||||||
|
scratchpad — trivial to recreate from `connection-gate-findings.md` if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next steps (in priority order)
|
||||||
|
|
||||||
|
1. **Add a `GetGameInfo GameInfoId=…` handler** to the bridge dispatcher (`src/lsx.rs`
|
||||||
|
`dispatch()`). It currently falls back to the generic `<Ok/>` ("unknown request
|
||||||
|
type"). FIFA tolerated that, but may need real responses to progress. This is the
|
||||||
|
immediate iteration surface now that we can see FIFA's real requests.
|
||||||
|
2. **Watch how far FIFA advances** with the LSX session satisfied — the next step is
|
||||||
|
the Blaze/online layer (the old M2 `ONLINE_STATUS_EVENT` blocker). With LSX now
|
||||||
|
handled by our bridge, that picture may change; re-evaluate M2 with FIFA actually
|
||||||
|
reaching it.
|
||||||
|
3. **Port the seed fix into the hook's own `src/lsx.rs`** if the in-process LSX path is
|
||||||
|
ever revived — it has the same `compute_seed` bug.
|
||||||
|
4. **Decide on the redirect's permanence** — right now it's a manual DLL swap. If
|
||||||
|
LSX-via-bridge becomes the intended path, the launcher's setup/deploy flow should
|
||||||
|
own it.
|
||||||
|
|
||||||
|
## Open question for the user
|
||||||
|
|
||||||
|
We never captured what FIFA showed **on screen** during the successful run (advanced to
|
||||||
|
a menu? sat at "connecting"? waited on `GetGameInfo`?). Ask, or observe on the next
|
||||||
|
launch — it tells you whether LSX is fully satisfied or whether `GetGameInfo` is the
|
||||||
|
next thing to address.
|
||||||
@@ -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.*
|
||||||
@@ -260,3 +260,961 @@ The clean-room spec (this document) is the finished, valuable artifact.
|
|||||||
- `TODO/CONFIRM`: exact text of the offline/connected value strings
|
- `TODO/CONFIRM`: exact text of the offline/connected value strings
|
||||||
(`+0xADE64` / `+0xAF530`); whether any secondary check gates the attempt after
|
(`+0xADE64` / `+0xAF530`); whether any secondary check gates the attempt after
|
||||||
the state flips.
|
the state flips.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LSX delivery channel — 2026-06-30
|
||||||
|
|
||||||
|
**Question:** does FIFA's port-3216 connection reach the bridge LSX server, or is it
|
||||||
|
serviced inside anadius before the TCP stack (making the bridge irrelevant)?
|
||||||
|
|
||||||
|
### Evidence from `openfut_hook.log` (6 consecutive sessions, no bridge running)
|
||||||
|
|
||||||
|
```
|
||||||
|
connect_hook: call 1.0.0.127:3216 sock_type=1
|
||||||
|
connect_hook: result=0 wsa_err=0
|
||||||
|
```
|
||||||
|
|
||||||
|
- `1.0.0.127:3216` — FIFA's EbisuSDK connects to `127.0.0.1:3216` (address in the log
|
||||||
|
is little-endian u32; port is big-endian u16 → both decode to `127.0.0.1:3216`).
|
||||||
|
- `sock_type=1` — `SOCK_STREAM` (TCP), not a pipe or named-object.
|
||||||
|
- `result=0 wsa_err=0` — the OS `connect()` syscall returned success.
|
||||||
|
- This line appears exactly once per game session, at early startup (DLL load phase),
|
||||||
|
before the game reaches any menu. No other external `connect()` calls are logged.
|
||||||
|
|
||||||
|
### Implication: anadius is NOT in-process-only for LSX
|
||||||
|
|
||||||
|
Our hook is an **inline detour on the real `ws2_32.dll!connect`** function (not an IAT
|
||||||
|
patch). It fires regardless of who calls `connect()`. Because it fires for the 3216
|
||||||
|
call and `result=0`, the connection goes through the real TCP stack — it is NOT
|
||||||
|
intercepted inside anadius before the socket layer.
|
||||||
|
|
||||||
|
In the sessions above, anadius was the answering server (bridge not running); it binds
|
||||||
|
`127.0.0.1:3216` as a real Wine/Proton TCP socket. **Anadius is a true TCP server on
|
||||||
|
port 3216, not an in-process detour.**
|
||||||
|
|
||||||
|
### Bridge delivery test (native Linux process)
|
||||||
|
|
||||||
|
Bridge binary confirmed bindable: `./target/release/openfut-bridge` starts and logs
|
||||||
|
`LSX server listening on 127.0.0.1:3216` immediately. Port 3216 is free before FIFA
|
||||||
|
launches (anadius only binds it when FIFA/anadius64.dll loads).
|
||||||
|
|
||||||
|
**Pending live test:** start the bridge (native Linux process → real Linux socket on
|
||||||
|
3216), THEN launch FIFA. When EbisuSDK calls `connect(127.0.0.1:3216)`:
|
||||||
|
- If bridge receives it → bridge logs `DEBUG openfut_bridge::lsx: LSX: connection from 127.0.0.1:xxxxx`
|
||||||
|
- If anadius somehow wins (or game crashes because anadius can't bind) → bridge logs nothing
|
||||||
|
|
||||||
|
**Run once to confirm:**
|
||||||
|
```bash
|
||||||
|
cd /home/alex/Documents/OpenFUT/openfut-bridge
|
||||||
|
RUST_LOG=openfut_bridge=debug ./target/release/openfut-bridge 2>&1 | tee /tmp/bridge-lsx-test.log &
|
||||||
|
# then launch FIFA normally, reach any menu, check log:
|
||||||
|
grep "LSX:" /tmp/bridge-lsx-test.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-test concern: LSX challenge-response AES key
|
||||||
|
|
||||||
|
The bridge `lsx.rs` uses a placeholder `AES_KEY = [0,1,2,...,15]`. The real key was
|
||||||
|
RE'd from anadius but not yet substituted. If FIFA validates the `ChallengeAccepted`
|
||||||
|
response the bridge sends, the handshake fails and FIFA may not reach the main menu.
|
||||||
|
This does **not** affect the delivery classification — `LSX: connection from ...` fires
|
||||||
|
at TCP `accept()`, before any protocol. But it may cause the game to stall/crash after
|
||||||
|
the first connection, which would be a separate issue to fix.
|
||||||
|
|
||||||
|
### Classification — CONFIRMED 2026-07-02 (supersedes the 2026-06-30 prediction)
|
||||||
|
|
||||||
|
**CONFIRMED NEGATIVE — a host-side bridge on 3216 does NOT receive FIFA's LSX
|
||||||
|
traffic. Cause: anadius handles LSX *in-process*, inside FIFA23.exe, before the
|
||||||
|
connect reaches the host TCP stack. This is NOT a network/loopback problem — the
|
||||||
|
network namespace is shared and the bridge is reachable.**
|
||||||
|
|
||||||
|
The 2026-06-30 prediction that this subsection replaced was wrong on both counts
|
||||||
|
(it guessed `"anadius intercepts in-process" = RULED OUT` and `"bridge receives
|
||||||
|
LSX" = EXPECTED`); the live test showed the exact opposite.
|
||||||
|
|
||||||
|
**Evidence (live test, bridge holding host 127.0.0.1:3216 throughout):**
|
||||||
|
- **Namespace is shared, not isolated.** `readlink /proc/<FIFA23.exe>/ns/net`
|
||||||
|
equals the host's `/proc/self/ns/net` (both `net:[4026531833]`). Pressure-vessel
|
||||||
|
/umu does not unshare the network — Wine's `127.0.0.1` *is* the host's.
|
||||||
|
- **The bridge is reachable and logs correctly.** A host-side
|
||||||
|
`connect(127.0.0.1:3216)` was accepted, logged `DEBUG openfut_bridge::lsx: LSX:
|
||||||
|
connection from 127.0.0.1:34730`, and returned the 157-byte LSX greeting. So
|
||||||
|
"bridge logged nothing during FIFA" is a real absence, not a logging/filter
|
||||||
|
artefact.
|
||||||
|
- **FIFA's connect succeeds but never reaches the bridge.** `openfut_hook.log`
|
||||||
|
shows 8× `connect_hook: call 1.0.0.127:3216 ... result=0 wsa_err=0` (real,
|
||||||
|
successful connects) while the bridge log stayed at its 8-line startup baseline
|
||||||
|
(zero `LSX: connection from`). FIFA then advanced to "connecting to FUT" — anadius
|
||||||
|
satisfied LSX in-process; the hook log stopped growing once it did.
|
||||||
|
- **Our hook does not fake the 3216 result — it passes it through.** In
|
||||||
|
`openfut-launcher/openfut-hook/src/connect_hook.rs`, `redirect_if_ea` rewrites
|
||||||
|
only 443/10041/42127; port 3216 falls through to the real `ws2_32!connect` and its
|
||||||
|
genuine return is logged. The comment there ("3216 ... handled by the native
|
||||||
|
openfut-bridge LSX server — just let connect() go through") encodes the disproven
|
||||||
|
premise: "going through" reaches anadius in-process, not the host bridge.
|
||||||
|
|
||||||
|
**Conclusion:** the "native Linux bridge binds 3216 first and wins the race"
|
||||||
|
strategy cannot work as designed — there is no host-stack connect to win; anadius
|
||||||
|
answers inside the process. A host-side LSX server is architecturally off the path.
|
||||||
|
|
||||||
|
> **OVERTURNED 2026-07-02** — see the next section. This conclusion was correct only
|
||||||
|
> for *port 3216*. anadius keys its in-process interception on port 3216 specifically;
|
||||||
|
> a hook-side redirect of FIFA's connect to a *different* host port (3217) slips past
|
||||||
|
> it and lands on the host bridge over the shared loopback. A host-side LSX server
|
||||||
|
> **does** work once the redirect is in place. The "fix direction" below is now done.
|
||||||
|
|
||||||
|
**Consequences / open items:**
|
||||||
|
- `TODO/CONFIRM`: the exact interception layer anadius uses (IAT hook on FIFA's
|
||||||
|
`connect` import / Winsock LSP / inline hook on a lower function) is undetermined.
|
||||||
|
Our inline hook on `ws2_32!connect` *does* fire, so FIFA reaches `ws2_32!connect`;
|
||||||
|
yet the real call returns 0 without touching the host socket — naming the layer
|
||||||
|
that absorbs it requires inspecting anadius.
|
||||||
|
- The placeholder `AES_KEY` was **never exercised** — delivery fails upstream of any
|
||||||
|
handshake, so the challenge-response never ran. The earlier "supply the real AES
|
||||||
|
key" follow-up is moot unless/until FIFA is actually routed to our LSX server.
|
||||||
|
- **Fix direction (not yet implemented):** the viable lever is our own hook, not
|
||||||
|
anadius (which is closed, load-bearing, and binds no host port to free). Candidate:
|
||||||
|
have the hook redirect the 3216 connect to a distinct host port the bridge listens
|
||||||
|
on, to slip past anadius's port-specific in-process interception — routing LSX to
|
||||||
|
our bridge on the shared host stack (this revives the real-AES-key requirement
|
||||||
|
above). Scoped to `openfut-hook`, not this repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LSX SOLVED via hook redirect + session-seed fix — 2026-07-02
|
||||||
|
|
||||||
|
**RESULT: FIFA now completes the LSX handshake AND a sustained encrypted session
|
||||||
|
against our own bridge, live.** The "fix direction" from the previous section was
|
||||||
|
implemented and works. This is the first EA-side gate serviced end-to-end by our code.
|
||||||
|
|
||||||
|
### 1. Hook redirect gets FIFA onto the bridge (delivery achieved)
|
||||||
|
|
||||||
|
Added one arm to `redirect_if_ea` in `openfut-launcher/openfut-hook/src/connect_hook.rs`:
|
||||||
|
FIFA's connect to `127.0.0.1:3216` is rewritten to `127.0.0.1:3217`, where the bridge
|
||||||
|
runs its LSX server (`LSX_ADDR=127.0.0.1:3217`). anadius's in-process interception is
|
||||||
|
keyed on port **3216 specifically**, so the redirected connect slips past it and lands
|
||||||
|
on the host bridge over the shared loopback.
|
||||||
|
|
||||||
|
- Hook log: `connect_hook: 1.0.0.127:3216 → 127.0.0.1:3217` then `redirect result=0`.
|
||||||
|
- Bridge log: `LSX: connection from 127.0.0.1:…` — **FIFA reached the bridge.** Overturns
|
||||||
|
the "off the path" conclusion above.
|
||||||
|
|
||||||
|
### 2. AES key was never a placeholder — it is `000102…0f`
|
||||||
|
|
||||||
|
The `AES_KEY = [0,1,2,…,15]` in `src/lsx.rs` (and the hook's `lsx.rs`) is the **real,
|
||||||
|
correct** key, confirmed two ways:
|
||||||
|
- **Oracle:** a captured FIFA `ChallengeResponse` gives a known plaintext→ciphertext
|
||||||
|
pair under the real key — our greeting `cacf897a20b6d612ad0c05e011df52bb` (ASCII) →
|
||||||
|
FIFA's `5c52e8ca…`. `AES-ECB-PKCS7([0..15], greeting)` reproduces it byte-for-byte.
|
||||||
|
- **Static:** that same 16-byte value is embedded literally in `anadius64.dll` at
|
||||||
|
offsets `0xa1710`, `0xa1890`, `0xa3c0f`.
|
||||||
|
|
||||||
|
The earlier "supply the real AES key" TODO is therefore **closed — no key extraction
|
||||||
|
was needed.** The handshake auth was always correct; FIFA accepted our `ChallengeAccepted`.
|
||||||
|
|
||||||
|
### 3. The real bug: session-seed derivation (now fixed)
|
||||||
|
|
||||||
|
After a *correct* handshake, the first encrypted session message still decrypted to
|
||||||
|
garbage. Cause: `compute_seed()` derived the session-key seed by **parsing** the first
|
||||||
|
hex pair of our response (`"0f"` → `0x0F`), when the client uses the **raw ASCII byte
|
||||||
|
values of the first two characters** (`'0','f'` → `0x30,0x66`).
|
||||||
|
|
||||||
|
Confirmed by capturing the raw session ciphertext (bridge patched to log wire bytes)
|
||||||
|
and brute-forcing all 65,536 u16 seeds offline through `get_lsx_key(seed)`:
|
||||||
|
- Exactly **one** seed, `0x3066` (12390), decrypted the message to valid XML:
|
||||||
|
`<LSX><Request recipient="EbisuSDK" id="…"><GetConfig version="3"/></Request></LSX>`.
|
||||||
|
- `0x3066` = ASCII `'0','f'` = the first two characters of our response hex `0f73…`,
|
||||||
|
**not** the parsed value `0x0F73` (3955) we were using.
|
||||||
|
|
||||||
|
Fix in `src/lsx.rs` `compute_seed()`: take `hex.as_bytes()[0..2]` directly instead of
|
||||||
|
`u8::from_str_radix` on the hex pairs. (Capture preserved at
|
||||||
|
`captures/lsx-handshake-capture-2026-07-02.log`; brute-forcer in scratchpad.)
|
||||||
|
|
||||||
|
### 4. Live confirmation
|
||||||
|
|
||||||
|
With the redirect deployed and the seed-fixed bridge on 3217, FIFA relaunched and:
|
||||||
|
- Held a **single sustained LSX connection** (`ss` shows `ESTAB FIFA23.exe ↔
|
||||||
|
openfut-bridge:3217`) instead of the previous connect-once-then-crash.
|
||||||
|
- Drove **20 real EbisuSDK requests** in one conversation: `GetConfig`, `GetProfile`
|
||||||
|
(×5), `GetSetting`, `GetGameInfo` (×8), `SetPresence`, `GetInternetConnectedState`.
|
||||||
|
- **Did not crash** at the LSX phase.
|
||||||
|
|
||||||
|
### 5. Remaining gap (next step, not a blocker to this result)
|
||||||
|
|
||||||
|
`GetGameInfo GameInfoId=…` had no handler in the bridge dispatcher → fell to the
|
||||||
|
generic `<Ok/>` and logged `unknown request type`. FIFA tolerated it (session stayed
|
||||||
|
up), but real `GetGameInfo` responses may be needed to progress toward the next gate
|
||||||
|
(Blaze/online).
|
||||||
|
|
||||||
|
**Update 2026-07-02 (later):** a `get_game_info` handler was added to `dispatch()` in
|
||||||
|
`src/lsx.rs`. It routes `GetGameInfo GameInfoId=…`, **logs the requested `GameInfoId`**
|
||||||
|
(so the next live run reveals which keys FIFA asks for — we still have no captured real
|
||||||
|
response to model), and replies with a well-formed `<GetGameInfoResponse GameInfoId="…"
|
||||||
|
Value="" />` echo. A dispatcher routing test (`dispatch_routes_get_game_info_and_echoes_id`)
|
||||||
|
pins the fragile positional `split('"')` parsing. `TODO/CONFIRM` remains: the real
|
||||||
|
response schema and whether FIFA needs non-empty values — resolve by launching FIFA
|
||||||
|
with this build, reading the logged `GameInfoId`s, and observing how far FIFA advances.
|
||||||
|
|
||||||
|
### Provenance note
|
||||||
|
|
||||||
|
Clean-room intact: the AES key was recovered by validating candidate byte windows from
|
||||||
|
the shipping `anadius64.dll` against the client's own observed output, and the seed rule
|
||||||
|
was recovered by brute-forcing against a live capture — nothing derived from EA source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LSX fully satisfied — FIFA reaches the Blaze/online gate — 2026-07-02 (later)
|
||||||
|
|
||||||
|
**RESULT (unconfirmed — read the caveat): FIFA reached the "connecting to EA servers"
|
||||||
|
screen alive, but the launch that did so used a CACHED EA session and did NOT re-hit our
|
||||||
|
bridge (no new `3216` connect, empty bridge log). The last launch that actually ran a
|
||||||
|
fresh LSX session through our bridge still CRASHED. So it is NOT yet established that the
|
||||||
|
fixed bridge carries a fresh FIFA launch past the crash — the crash-clear is confounded
|
||||||
|
by session caching.** The fixes below are believed-correct and unit-tested, but need the
|
||||||
|
clean re-test in the "Confirmation caveat" section before LSX can be called solved.
|
||||||
|
Earlier in the day FIFA crashed ~15–17s into LSX, "right before the loading screen."
|
||||||
|
Root-causing that crash took several live runs and disproved two wrong theories.
|
||||||
|
|
||||||
|
### The real root cause: dropped pipelined LSX messages (session-loop framing bug)
|
||||||
|
|
||||||
|
FIFA batches multiple LSX messages into a single TCP segment — e.g. `SetPresence`
|
||||||
|
immediately followed by a `GetGameInfo GameInfoId="LANGUAGES"` query, two
|
||||||
|
null-terminated ciphertext-hex blocks in one read. The old session loop decrypted the
|
||||||
|
whole read as one blob and dispatched only the **first** message, silently dropping the
|
||||||
|
rest. The dropped request left FIFA waiting on a response that never came; it timed out
|
||||||
|
after ~15s (the mysterious silent gap in every crash log) and then crashed.
|
||||||
|
|
||||||
|
Fix: `src/lsx.rs` `process_frame()` splits each read on the null terminators and
|
||||||
|
answers **every** sub-message, returning the concatenated null-terminated responses.
|
||||||
|
Regression test: `process_frame_answers_every_pipelined_message`. After the fix, FIFA
|
||||||
|
flowed through the entire LSX sequence in ~1.3s instead of hanging 15s.
|
||||||
|
|
||||||
|
### Two theories the live runs DISPROVED first (recorded so we don't repeat them)
|
||||||
|
|
||||||
|
1. **"Empty/incorrect INSTALLED_LANGUAGE value crashes it."** Changing the value
|
||||||
|
(`""` → `en_US`) changed nothing — same 3× re-query, same crash. Because…
|
||||||
|
2. **"Wrong attribute name only."** Switching `Value=""` → `InstalledLanguage="en_US"`
|
||||||
|
also changed nothing *on its own* — because the crash-relevant request (the
|
||||||
|
pipelined `LANGUAGES`) was never being dispatched at all (see root cause). The
|
||||||
|
attribute name *did* matter, but only became observable once pipelining was fixed:
|
||||||
|
FIFA reads a lowercase `value` attribute (case-sensitive), so the response now emits
|
||||||
|
the value under BOTH `value` and the per-key CamelCase name (`InstalledLanguage` /
|
||||||
|
`Languages`).
|
||||||
|
|
||||||
|
### anadius.cfg is the response oracle
|
||||||
|
|
||||||
|
`/mnt/games/FIFA 23/anadius.cfg` is the config anadius's in-process LSX emu reads to
|
||||||
|
build its responses — the clean, correct values to mirror (same provenance basis as the
|
||||||
|
AES-key recovery; it is anadius's own config, not EA source):
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| `Language` (INSTALLED_LANGUAGE) | `en_US` |
|
||||||
|
| `Languages` (LANGUAGES) | `pt_PT,tr_TR,ko_KR,cs_CZ,zh_CN,zh_HK,da_DK,no_NO,sv_SE,en_US,pt_BR,de_DE,es_ES,fr_FR,it_IT,ja_JP,es_MX,nl_NL,pl_PL,ru_RU,ar_SA` |
|
||||||
|
| `PersonaId` | `1144668899` |
|
||||||
|
| `UserId` | `1000200030000` |
|
||||||
|
| `Username` (Persona) | `fun` |
|
||||||
|
|
||||||
|
`src/lsx.rs` now returns these real values (GetProfile IDs, GetGameInfo language keys)
|
||||||
|
instead of fabricated ones; fabricated persona/user IDs flow into FIFA's online state
|
||||||
|
and are a likely downstream break. Also added a real `IsProgressiveInstallationAvailable`
|
||||||
|
(`Available="false"`, attribute recovered from `anadius64.dll`).
|
||||||
|
|
||||||
|
### Confirmation caveat + how to verify cleanly
|
||||||
|
|
||||||
|
The launch that first reached the online gate did **not** re-hit our bridge — FIFA
|
||||||
|
presented a cached EA session (no new `3216` connect; empty bridge log) and jumped
|
||||||
|
straight to "connecting to EA servers." So that specific run didn't exercise the final
|
||||||
|
`value`-attribute change. To confirm on a clean boot, clear FIFA's cached EA/Origin
|
||||||
|
session token in the Wine prefix so it re-runs LSX through the bridge, and verify: one
|
||||||
|
sustained LSX session, `INSTALLED_LANGUAGE` queried **once** (not 3×), no crash, and
|
||||||
|
FIFA arriving at "connecting to EA servers".
|
||||||
|
|
||||||
|
### GetGameInfo response shape (RE'd from the anadius64.dll builder, 2026-07-02)
|
||||||
|
|
||||||
|
Populating `GetAllGameInfo` did **not** fix the crash — FIFA still ran the full LSX
|
||||||
|
session (~2.6s, 17 dispatches) then crashed at the same point, ending with the 3×
|
||||||
|
`INSTALLED_LANGUAGE` re-query. So `GetAllGameInfo` was never the trigger.
|
||||||
|
|
||||||
|
Disassembling the **individual** `GetGameInfo` response builder in `anadius64.dll`
|
||||||
|
(around `0x18002a076`) settled the actual wire shape. It emits, in order:
|
||||||
|
`<` + `GetGameInfoResponse` + ` ` + `GameInfo` + `="` + <value> + `"` + ` />`. The
|
||||||
|
attribute name is the **fixed 8-byte literal `GameInfo`** (string constant at
|
||||||
|
`.rdata` VA `0x1800af818`), *independent of the requested `GameInfoId`* — the value
|
||||||
|
is looked up per key but the attribute is always `GameInfo`. So the correct reply to
|
||||||
|
`GetGameInfo GameInfoId=INSTALLED_LANGUAGE` is:
|
||||||
|
|
||||||
|
```
|
||||||
|
<GetGameInfoResponse GameInfo="en_US" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Our bridge had been sending the key as the attribute name
|
||||||
|
(`<GetGameInfoResponse InstalledLanguage="en_US" />`), an attribute FIFA never reads.
|
||||||
|
That is why FIFA always saw a null installed-language, re-queried `INSTALLED_LANGUAGE`
|
||||||
|
three times, and crashed — and why *every* prior value change (empty → `en_US`,
|
||||||
|
per-key naming) had no effect: FIFA wasn't reading our attribute at all. `src/lsx.rs`
|
||||||
|
`get_game_info()` now emits the fixed `GameInfo="<value>"` attribute.
|
||||||
|
|
||||||
|
### ROOT CAUSE of the loading-screen crash: GetConfig missing PROGRESSIVE_INSTALLATION (2026-07-02)
|
||||||
|
|
||||||
|
FIFA's own crash reports (`FIFA 23/Logs/CrashReport_*.json`) pinned it. **Every** run
|
||||||
|
through our bridge — all 11 today, both the pre-pipelining (`Up Seconds: 17`) and
|
||||||
|
post-pipelining (`Up Seconds: 2`) runs — crashed at the **identical** address:
|
||||||
|
|
||||||
|
```
|
||||||
|
EXCEPTION_ACCESS_VIOLATION, reading 0x18
|
||||||
|
RIP = FIFA23.exe+0x133dfc5 → mov rax, QWORD PTR [r13+0x18] with r13 = NULL
|
||||||
|
Is Online: False, Blaze State: {} (crash is pre-Blaze, ~2s after LSX)
|
||||||
|
```
|
||||||
|
|
||||||
|
So none of the earlier LSX-response fixes (pipelining, account IDs, GetGameInfo
|
||||||
|
attribute, GetAllGameInfo population) ever touched the crash — it was **invariant**,
|
||||||
|
i.e. caused by a response that had been wrong the *whole time*. Disassembling the
|
||||||
|
faulting function showed r13 is its 3rd argument (a Frostbite ref-counted service
|
||||||
|
*handle*) — a null handle deref, i.e. a **failed service lookup**.
|
||||||
|
|
||||||
|
That pointed straight at `GetConfig` (request #1, the fabricated service list, never
|
||||||
|
verified). Extracting anadius's real service-registration table from `anadius64.dll`
|
||||||
|
(the `lea rdx=Name / lea r8=Facility` sequence at `0x180026d40`) and diffing it
|
||||||
|
against our `get_config()` showed our list was correct on all 32 entries **except it
|
||||||
|
omitted two services**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Facility="PROGRESSIVE_INSTALLATION" Name="PI"
|
||||||
|
Facility="PROGRESSIVE_INSTALLATION_EVENT" Name="PI"
|
||||||
|
```
|
||||||
|
|
||||||
|
FIFA queries `IsProgressiveInstallationAvailable` in every LSX session; it builds its
|
||||||
|
service registry from `GetConfig` and resolves the `PROGRESSIVE_INSTALLATION` handle
|
||||||
|
from it during online-init. With the service absent, the handle was null → the
|
||||||
|
0x133dfc5 deref. Fix: `get_config()` now declares both services, and
|
||||||
|
`is_progressive_installation_available()` responds from `sender="PI"` (the service's
|
||||||
|
registry Name). This is the first change that targets the *actual* crash address, so
|
||||||
|
it is the real test of the loading-screen gate.
|
||||||
|
|
||||||
|
### M2 unblock lever RECOVERED: the ONLINE_STATUS_EVENT push format (2026-07-02)
|
||||||
|
|
||||||
|
With the LSX gate cleared, FIFA sits at "connecting to EA Servers", polling GetProfile
|
||||||
|
over LSX. Per the M1/M2 findings this is the async-push wall: FIFA calls GoOnline
|
||||||
|
(handled in-process by anadius/EbisuSDK), gets success, then **waits for an
|
||||||
|
`ONLINE_STATUS_EVENT` push on the LSX socket that never arrives.** Earlier attempts
|
||||||
|
couldn't send it — it was unclear the bridge even owned the LSX socket (anadius
|
||||||
|
intercepted in-process). **That blocker is now gone: our bridge provably owns FIFA's
|
||||||
|
LSX socket** (full session runs on :3217), so we can finally push the event.
|
||||||
|
|
||||||
|
RE recovered the exact format the client parses (authoritative — from FIFA23.exe's
|
||||||
|
own OriginSDK deserializer, not a guess):
|
||||||
|
|
||||||
|
- **Event wrapper** (from anadius64.dll's IGOEvent template):
|
||||||
|
`<LSX><Event sender="EbisuSDK"><…/></Event></LSX>`
|
||||||
|
- **Element** `OnlineStatusEvent` — confirmed via FIFA23.exe's retained RTTI symbol
|
||||||
|
`Origin::EventHandler<struct lsx::OnlineStatusEventT,bool>::HandleMessage`
|
||||||
|
(source path `OriginSDK/10.6.2.19-fb-fifagame/src/impl/OriginEventClasses`) and the
|
||||||
|
event-name dispatch table. Handler parser at FIFA23.exe+0x274d4ae.
|
||||||
|
- **Attribute** `isOnline` (a single bool) — recovered from the typed deserializer at
|
||||||
|
FIFA23.exe+0x28a4d0: it inits the output byte to 0 (offline), builds an `lsx:`
|
||||||
|
namespace prefix, reads the `isOnline` attribute, and string→bool converts it.
|
||||||
|
|
||||||
|
Best-effort push (value format `1` vs `true` is `TODO/CONFIRM` against the client —
|
||||||
|
OriginSDK bool attributes elsewhere use `true`/`false`):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<LSX><Event sender="EbisuSDK"><OnlineStatusEvent isOnline="1"/></Event></LSX>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Next implementation step:** give the bridge LSX server an async, unsolicited-push
|
||||||
|
path — encrypt this event with the live session seed and write it on the open socket
|
||||||
|
after the session establishes (or after FIFA's GoOnline settles). Success signal: FIFA
|
||||||
|
stops polling and **dials the Blaze redirector** (gosredirector → :42127 via the hook),
|
||||||
|
which yields the first Fire2 frame and moves us into M3/M4 (fifa-blaze).
|
||||||
|
|
||||||
|
### RESULT: OnlineStatusEvent push drives FIFA online — a first — but the auth→Blaze chain is the next wall (2026-07-02)
|
||||||
|
|
||||||
|
Implemented an unsolicited `OnlineStatusEvent` push in the bridge LSX server
|
||||||
|
(`select!` loop, encrypted with the session seed, re-pushed every 2s). Value
|
||||||
|
`isOnline="true"` (FIFA's bool parser at FIFA23.exe+0x28fb50 string-compares to
|
||||||
|
`false`; `isOnline="1"` was delivered but parsed as not-online — pinned the encoding).
|
||||||
|
|
||||||
|
**FIFA reacted — the event format is correct and effective:**
|
||||||
|
- Rapid GetProfile polling (~0.5s) stopped the instant the pushes began (13s gap).
|
||||||
|
- FIFA set `SetPresence … Presence="INGAME"` — an *online* action.
|
||||||
|
- Then it went **silent on LSX** (idle, connection alive, no crash).
|
||||||
|
|
||||||
|
This is the first time FIFA has been driven "online" at the EbisuSDK/LSX level —
|
||||||
|
**anadius never does this** (it is offline DRM and deliberately keeps FIFA offline),
|
||||||
|
so this path is genuinely uncharted, not a regression from a known-good baseline.
|
||||||
|
|
||||||
|
**But FIFA does NOT proceed to Blaze.** After going online it:
|
||||||
|
- sent **no** `GetAuthCode` (an LSX request the bridge already answers) — so it never
|
||||||
|
started the Nucleus-token exchange;
|
||||||
|
- resolved **no** hostname (`hooked_getaddrinfo` logs every call; the hook log has
|
||||||
|
none) — no gosredirector, no accounts.ea.com;
|
||||||
|
- opened **no** socket beyond LSX (:3216).
|
||||||
|
|
||||||
|
So a single connectivity event ≠ full online login. RE of FIFA23.exe confirms the
|
||||||
|
remaining chain and its anchors: `GetAuthCode` (12×) → `NucleusAccessToken` /
|
||||||
|
`access_token` / `Bearer` / `Authorization` (redirector header) → `gosredirector`
|
||||||
|
(4×) / `BlazeHub` (3×) → Blaze auth. `OnlineStatusEventT::HandleMessage`
|
||||||
|
(FIFA23.exe+0x274d4ae) only builds the typed event and dispatches it via the SDK
|
||||||
|
callback; the game-side listener that would kick off the auth chain needs more than
|
||||||
|
the connectivity flag (candidate: a login/authenticated-online event or an in-process
|
||||||
|
EbisuSDK login step that our LSX-only redirect doesn't cover).
|
||||||
|
|
||||||
|
**Strategic note:** this is the M2→M6 research frontier the 2026-06-30 status review
|
||||||
|
priced at months of expert RE, now empirically confirmed. It sits against the
|
||||||
|
2026-06-30 direction pivot, which made the app-centric FLE/career-mode path the
|
||||||
|
primary plan over full Blaze-backend RE. Decision point before sinking weeks into the
|
||||||
|
Nucleus/Blaze stack.
|
||||||
|
|
||||||
|
### Trigger hunt: the missing link is the `Login` event (OriginLoginT) (2026-07-02)
|
||||||
|
|
||||||
|
Traced why FIFA stalls after going online. The full post-online chain and its
|
||||||
|
FIFA23.exe anchors:
|
||||||
|
|
||||||
|
```
|
||||||
|
OnlineStatusEvent(isOnline=true) ✓ delivered, FIFA set INGAME presence
|
||||||
|
│ [MISSING game-side trigger]
|
||||||
|
GetAuthCode (LSX request we answer) ── never sent
|
||||||
|
│
|
||||||
|
Nucleus token exchange (accounts.ea.com HTTPS, Bearer/Authorization) ── no DNS
|
||||||
|
│
|
||||||
|
spring18.gosredirector.ea.com (HTTPS redirector: Authorization + serviceName,
|
||||||
|
env-selected by ENVIRONMENT="production"; headers X-BLAZE-ERRORCODE)
|
||||||
|
│
|
||||||
|
Blaze server (BlazeHub::LoginManagerImpl, Fire2) ── never reached
|
||||||
|
```
|
||||||
|
|
||||||
|
Login lives inside Blaze (`BlazeHub::mLoginManagers`), which needs a Nucleus token
|
||||||
|
first, which needs `GetAuthCode`, which FIFA never issues. So the block is upstream:
|
||||||
|
the game-side reaction to online doesn't start the auth chain.
|
||||||
|
|
||||||
|
**Likely cause — no authenticated session.** FIFA's Origin event set (from retained
|
||||||
|
RTTI) includes `Origin::EventHandler<struct lsx::LoginT, struct OriginLoginT>` — a
|
||||||
|
`Login` event carrying a full `OriginLoginT` struct (identity/session/token), distinct
|
||||||
|
from `OnlineStatusEvent`'s single bool. Our push set *connectivity* (presence went
|
||||||
|
INGAME) but not an authenticated *session*. anadius never pushes `Login` (offline DRM),
|
||||||
|
so FIFA has a persona (from GetProfile) but no online session token → nothing to drive
|
||||||
|
`GetAuthCode`.
|
||||||
|
|
||||||
|
**Next experiment:** RE the `LoginT` deserializer (element `Login`, payload
|
||||||
|
`OriginLoginT`) the same way as OnlineStatusEvent, synthesize a `<Login>` event push
|
||||||
|
with a fabricated session, and relaunch. Success signal: FIFA issues `GetAuthCode`
|
||||||
|
over LSX and/or resolves `spring18.gosredirector.ea.com` (both logged) — the first
|
||||||
|
move into the Nucleus/Blaze stack.
|
||||||
|
|
||||||
|
### Login event pushed — FIFA still won't start the auth chain (2026-07-02)
|
||||||
|
|
||||||
|
RE'd the `Login` event from FIFA23.exe's `LoginT` deserializer (0x142787a30): fields
|
||||||
|
`UserIndex` (int), `IsLoggedIn` (bool, same true/false parser as isOnline),
|
||||||
|
`LoginReasonCode` (int). Pushed `<Login UserIndex="0" IsLoggedIn="true"
|
||||||
|
LoginReasonCode="0"/>` before OnlineStatusEvent each cycle.
|
||||||
|
|
||||||
|
Result: **no change.** FIFA reacts to connectivity (presence INGAME, polling backs
|
||||||
|
off) but issues no `GetAuthCode`, resolves no host (`getaddrinfo` count 0), dials no
|
||||||
|
Blaze. So Login + OnlineStatus (connectivity + authenticated flags) are **not**
|
||||||
|
sufficient to trigger the game-side auth chain.
|
||||||
|
|
||||||
|
**Boundary reached for black-box LSX emulation.** We can drive every LSX-wire signal
|
||||||
|
FIFA reads, and FIFA visibly reacts, but the decision to start GetAuthCode→Nucleus→
|
||||||
|
Blaze lives in FIFA's game-side online orchestrator, whose gating state is invisible
|
||||||
|
from the LSX wire (it also makes in-process EbisuSDK calls that go to anadius, not our
|
||||||
|
socket — we can't see those). Guessing more LSX events is low-yield from here.
|
||||||
|
|
||||||
|
To progress, the next step must convert guessing into observation — **instrument
|
||||||
|
FIFA's in-process online flow** via the hook DLL: detour the EbisuSDK/game functions
|
||||||
|
around `GetAuthCode`, `GoOnline` (anadius64.dll+0x2BB90), and the OnlineStatusEvent
|
||||||
|
consumer, and log what FIFA does after our events (whether it calls GetAuthCode
|
||||||
|
in-process, what it checks, where it returns early). That reveals the exact gate.
|
||||||
|
Alternatively this is the wall the 2026-06-30 direction pivot anticipated — the
|
||||||
|
app-centric FLE/career path avoids the online-backend entirely.
|
||||||
|
|
||||||
|
Bank: LSX gate cleared; FIFA driven "online" at the EbisuSDK connectivity/login level
|
||||||
|
for the first time (anadius never does); OnlineStatusEvent + Login formats recovered
|
||||||
|
and confirmed effective at the event level.
|
||||||
|
|
||||||
|
### In-process instrumentation results (2026-07-02)
|
||||||
|
|
||||||
|
Built a `probe` feature into openfut-hook (unhook/rehook logging detours, like
|
||||||
|
connect_hook) on four online-flow functions. Deployed + ran with the event push
|
||||||
|
active. Results (C:\openfut_hook.log):
|
||||||
|
|
||||||
|
- **Our events reach FIFA's code and parse successfully.** `OnlineStatus.deser`
|
||||||
|
(FIFA23.exe+0x28a4d0) and `Login.deser` (+0x287a30) each fired 44× (once per 2s
|
||||||
|
re-push) and returned success (0x1 / valid ptr). The push works at the deepest
|
||||||
|
level — not a delivery problem.
|
||||||
|
- **`GoOnline` (anadius64.dll+0x2bb90) never fires.** The 2026-06-30 review saw FIFA
|
||||||
|
calling GoOnline and *retrying every ~7s*; with our bridge + events FIFA is not in
|
||||||
|
that retry loop at all. Our events moved it out of the retry state — but it does not
|
||||||
|
advance.
|
||||||
|
- **`GetInternetConnectedState` (anadius64.dll+0x27790) never fires** — FIFA asks it
|
||||||
|
over LSX (to our bridge) instead; anadius is fully bypassed for LSX.
|
||||||
|
|
||||||
|
**Gate localized to the game-side event consumer.** After the deserializer,
|
||||||
|
`OnlineStatusEventT::HandleMessage` delivers the value via a virtual call
|
||||||
|
(`call [rax+0x28]`, FIFA23.exe+0x274d4e2) to a polymorphic game listener. FIFA parses
|
||||||
|
"online + logged in" every cycle but the listener does not act (no GoOnline, no auth,
|
||||||
|
no Blaze). The concrete listener is a runtime vtable target — not statically pinnable.
|
||||||
|
|
||||||
|
Note vs. the old M2 finding: forcing anadius's in-process state flags to online left
|
||||||
|
FIFA *retrying*; pushing our events *stops* the retry but doesn't advance. Neither
|
||||||
|
path alone flips the game-side online state machine — the real gate is in FIFA's own
|
||||||
|
online orchestrator, reachable only by probing the runtime virtual listener or a
|
||||||
|
deeper RE of that state machine.
|
||||||
|
|
||||||
|
### Live listener captured — the event callback is a NO-OP (2026-07-02)
|
||||||
|
|
||||||
|
Added a behavior-preserving mid-function detour (probe.rs `openfut_listener_stub`,
|
||||||
|
patch at FIFA23.exe+0x274d4d7) to capture the runtime target of the OnlineStatusEvent
|
||||||
|
dispatch (`call [rax+0x28]`). Result:
|
||||||
|
|
||||||
|
```
|
||||||
|
PROBE OnlineStatus.listener: vtable=0x1480b9ac0 (rva 0x80b9ac0) fn=0x142751060 (rva 0x2751060)
|
||||||
|
```
|
||||||
|
|
||||||
|
`FIFA23.exe+0x2751060` is `ret 0x0` — a **no-op**, one of a table of identical `ret 0`
|
||||||
|
stubs (i.e. the default/empty virtual slot; this object does not override the
|
||||||
|
online-status notification). So the immediate event callback does nothing.
|
||||||
|
|
||||||
|
**Reframes the approach.** `OnlineStatusEventT::HandleMessage` does two things: it
|
||||||
|
stores the parsed bool into a map (state), then calls this (no-op) notify. So our push
|
||||||
|
updates FIFA's *stored* online state but triggers no active reaction — there is no
|
||||||
|
callback to kick off the auth/Blaze chain. The transition must be driven by FIFA
|
||||||
|
**polling** that stored state (consistent with the observed GetProfile /
|
||||||
|
GetInternetConnectedState polling), gated on some combined condition our single
|
||||||
|
online flag doesn't satisfy. Event-pushing alone cannot trigger it; the next dig is
|
||||||
|
the state reader / poll gate (who reads the map HandleMessage writes, and what else it
|
||||||
|
requires), which is deeper FIFA-internal RE.
|
||||||
|
|
||||||
|
The probe tooling now cleanly captures live virtual-dispatch targets (alignment-safe
|
||||||
|
mid-function detour) — reusable for the next layer.
|
||||||
|
|
||||||
|
### Next gate: Blaze/online (M2)
|
||||||
|
|
||||||
|
"connecting to EA servers" is the Blaze layer. The hook log shows FIFA has made **no**
|
||||||
|
Blaze connect yet (nothing on 443/10041/42127) while spinning, so it is stalled at
|
||||||
|
pre-auth. Per the architecture, Blaze rides ProtoSSL in-process (a different transport
|
||||||
|
than LSX), so serving it is separate, larger work — the M2 effort, now actually reached.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GetAuthCode request format recovered — the gate is job-enqueue, not event-push (2026-07-02)
|
||||||
|
|
||||||
|
Continuing past the "live listener is a NO-OP" finding, I traced the *other end* of
|
||||||
|
the gate: the LSX request FIFA must send to start the Nucleus→Blaze chain, and what
|
||||||
|
gates it. Static RE of FIFA23.exe (clean-room; the client's own serializers are the
|
||||||
|
oracle).
|
||||||
|
|
||||||
|
**The request FIFA would send (exact, from its serializer at FIFA23.exe+0x278d2c0):**
|
||||||
|
|
||||||
|
```
|
||||||
|
<GetAuthCode UserId="<int>" ClientId="<str>" Scope="<str>" AppendAuthSource="true|false"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Serializer `GetAuthCodeT::Serialize` @ **FIFA23.exe+0x278d2c0**. Element string
|
||||||
|
`GetAuthCode` @ .rdata 0x1480c27d0 (its *only* xref — this is the sole builder).
|
||||||
|
- Field layout in the request struct: `UserId=[rbx+0x00]`, `ClientId=[rbx+0x08]`
|
||||||
|
(string), `Scope=[rbx+0x28]` (string), `AppendAuthSource=[rbx+0x48]` (bool).
|
||||||
|
- Attribute-name strings: `UserId` 0x147ea7bc8, `ClientId` 0x1480c2c98,
|
||||||
|
`Scope` 0x147c79514, `AppendAuthSource` 0x1480c2ca8.
|
||||||
|
- This is rung 1 of M2's auth chain: FIFA asks the SDK (== us, over LSX) for a Nucleus
|
||||||
|
auth code; it then exchanges that code at accounts.ea.com → token → gosredirector →
|
||||||
|
Blaze. We now know the exact request to answer once we can make FIFA send it.
|
||||||
|
|
||||||
|
**Who sends it — and the actual gate.** The request is wrapped by a **`GetAuthCodeJob`**
|
||||||
|
inside FIFA's EASFC online-job module (a large block ~FIFA23.exe+0x51b0000..0x528xxxx;
|
||||||
|
job/response telemetry tags `GetAuthCodeJob` @ 0x1484630d0, `GetAuthCodeJobResponse` @
|
||||||
|
0x148455b98). The job's completion/response handler is at **FIFA23.exe+0x51b66c0** (sets
|
||||||
|
its done-flag `[r15+8]=1`, logs via those tags). The serializer is invoked polymorphically
|
||||||
|
from the LSX client when the job runs — it is **not** called directly, so there is no
|
||||||
|
static call-xref from the trigger; the job is *enqueued* by the EASFC online controller's
|
||||||
|
state machine.
|
||||||
|
|
||||||
|
**Conclusion (the gate, precisely stated).** The wall is **not** on the LSX wire and
|
||||||
|
**not** in event delivery (our OnlineStatus/Login events parse fine; cf. the no-op-listener
|
||||||
|
finding above). The wall is that FIFA's **EASFC online controller never enqueues
|
||||||
|
`GetAuthCodeJob`**. That controller decides "am I online enough to authenticate?" by
|
||||||
|
*polling* its cached online state (the map `OnlineStatusEventT::HandleMessage` writes),
|
||||||
|
and our single `isOnline=true` flag does not satisfy its combined condition. So:
|
||||||
|
|
||||||
|
- Event-push alone cannot cross it (confirmed twice now, from both ends).
|
||||||
|
- The true next artifact is the **EASFC controller's online-readiness poll** — the guard,
|
||||||
|
in that 0x51b0000..0x528xxxx module, that must pass before `GetAuthCodeJob` is enqueued.
|
||||||
|
That is a large state-machine RE (hundreds of KB of EASFC job code), i.e. the genuine
|
||||||
|
multi-week M2 frontier the status review priced.
|
||||||
|
|
||||||
|
**Net for this session:** recovered the exact `GetAuthCode` request format (so the round
|
||||||
|
trip is ready when we cross the gate) and localized the gate to a specific FIFA module and
|
||||||
|
mechanism (EASFC job-enqueue poll, not LSX/events). This closes the "is it the events?"
|
||||||
|
question — it is not. The remaining work is FIFA-internal EASFC state-machine RE, which
|
||||||
|
sits directly against the [[project_direction_pivot]] (FLE career mode is the stated
|
||||||
|
primary plan) as a cost/priority decision, not a technical unknown on the LSX side.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gate pinned to a concrete object: the Nucleus session context `[mgr+0x778]` (2026-07-02, dig 2)
|
||||||
|
|
||||||
|
Traced the send path all the way up to FIFA's game-side Nucleus-connect layer and
|
||||||
|
found the exact null-check that stops the auth chain. Clean-room (FIFA's own code +
|
||||||
|
its embedded source-path strings are the oracle).
|
||||||
|
|
||||||
|
**Send path (confirmed, top to bottom):**
|
||||||
|
- `GetAuthCodeT::Serialize` @ FIFA23.exe+0x278d2c0 → request
|
||||||
|
`<GetAuthCode UserId ClientId Scope AppendAuthSource/>`.
|
||||||
|
- Sent by `Origin::OriginSDK::SendXml` (embedded path
|
||||||
|
`E:\packages\OriginSDK\10.6.2.19-fb-fifagame_11164847\src\impl\OriginSDKimpl.cpp`),
|
||||||
|
internal senders FIFA23.exe+0x2732f50 / dispatch +0x2737df0. Statically linked into
|
||||||
|
FIFA23.exe (not a DLL) — anadius does not emulate this layer.
|
||||||
|
- Requested by FIFA's **game-side Nucleus-connect layer**: `nucleusConnectREST`
|
||||||
|
@ FIFA23.exe+0x2861910 and `nucleusConnectTrusted` @ FIFA23.exe+0x5078370
|
||||||
|
(found via the log-tag strings `nucleusConnectREST` 0x1480db550 /
|
||||||
|
`nucleusConnectTrusted` 0x148441c18).
|
||||||
|
|
||||||
|
**THE GATE (sharp).** Both connect paths **null-check the same object** — the Nucleus
|
||||||
|
session/context at **`[NucleusManager+0x778]`** — and short-circuit when it is null:
|
||||||
|
- `nucleusConnectREST` (+0x2861910): loads `mgr = (*GetMgr())->vt[0x178]()`, reads
|
||||||
|
`[mgr+0x778]`; **if null → returns 0, builds/sends nothing** (no GetAuthCode).
|
||||||
|
- `nucleusConnectTrusted` (+0x5078370): if `[[this+0x10]+0x778]` is null → returns
|
||||||
|
HRESULT **0x80060000** ("not ready"); its caller state (+0x507d660, part of the
|
||||||
|
online-login state machine — state handlers are vtable slots dispatched via
|
||||||
|
`[rax+0xb8]`) then waits/retries instead of advancing.
|
||||||
|
|
||||||
|
So the online→auth transition is gated on **the Nucleus session context `+0x778` being
|
||||||
|
instantiated**, and in our setup it never is. This is fully consistent with the earlier
|
||||||
|
no-op-listener finding: pushing `OnlineStatusEvent` updates FIFA's *stored* online bool
|
||||||
|
but does not drive the login state machine to the step that creates the `+0x778` context,
|
||||||
|
so both auth-code paths keep null-checking it and bailing. Event-push cannot create it.
|
||||||
|
|
||||||
|
**Why static tracing stops here:** `nucleusConnectREST` is a virtual (vtable slot
|
||||||
|
0x1480db478) and the manager-getter (+0x27d9970) is a ubiquitous singleton (hundreds of
|
||||||
|
callers), so the exact state that *would* call REST — and the predicate that instantiates
|
||||||
|
`+0x778` — are not pinnable by call-xref alone.
|
||||||
|
|
||||||
|
**Concrete next step (dynamic, cheap, ready to build):** with FIFA at "connecting to EA
|
||||||
|
servers," probe (hook DLL, existing `probe` feature) **nucleusConnectREST @ +0x2861910**
|
||||||
|
and the **`[mgr+0x778]` read** to observe: (a) is REST even called on our path? (b) what
|
||||||
|
does the manager hold at +0x778, and (c) if REST is never called, which online-login state
|
||||||
|
FIFA is parked in (log the state object's vtable). That directly reveals what instantiates
|
||||||
|
`+0x778`. This replaces "months-long unknown" with a single targeted probe against a named
|
||||||
|
object. Still a cost/priority call against [[project_direction_pivot]] (FLE career primary).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DYNAMIC PROBE RESULT — hypothesis overturned: connect flow is never triggered (2026-07-02, run 3)
|
||||||
|
|
||||||
|
Built a probe (openfut-hook `probe` feature) with entry detours on the game-side connect
|
||||||
|
functions + a guarded sampler that reads the session-context chain directly. Ran it live at
|
||||||
|
"connecting to EA Servers". **Deploy note:** FIFA is launched via **FLE**, which injects
|
||||||
|
`openfut_hook.dll` from the FLE dir — NOT the game-dir `version.dll`; earlier redeploys to
|
||||||
|
version.dll were never loaded (see [[reference_hook_deploy_path]]).
|
||||||
|
|
||||||
|
**Results (decisive):**
|
||||||
|
- Sampler: `X=[0x14acd02c0]` (OriginSDK singleton), `M=[X+0x360]`, `ctx=[M+0x778]` →
|
||||||
|
**`ctx = 0xba8315a0`, non-null and stable** the entire time.
|
||||||
|
- `nucleusConnectREST` enter=**0**, `nucleusConnectTrusted` enter=**0**,
|
||||||
|
`connectState.tick` (+0x507d660) enter=**0**.
|
||||||
|
- Bridge LSX log: FIFA does its full bootstrap (GetConfig, GetProfile, GetGameInfo,
|
||||||
|
GetSetting, GetInternetConnectedState, IsProgressiveInstallationAvailable, SetPresence,
|
||||||
|
SetDownloaderUtilization) then **goes silent** — only receiving our 2 s event pushes. Over
|
||||||
|
the whole session it **never** sends QueryEntitlements, RequestLicense, or GetAuthCode.
|
||||||
|
|
||||||
|
**Conclusion — the dig-2 static hypothesis was WRONG.** The Nucleus session context `[M+0x778]`
|
||||||
|
is NOT null; it exists. The connect functions would pass their null-check — but they are
|
||||||
|
**never called**. The blocker is not a missing object; it is that FIFA's EASFC/Nucleus
|
||||||
|
**online-connect flow is never triggered at all**. FIFA completes LSX bootstrap, parks at
|
||||||
|
"connecting", and never advances to the connect state (whose objects are created at init —
|
||||||
|
loop @ +0x4f47920 building the array [ctrl+0x780], count [ctrl+0x298] — but never driven).
|
||||||
|
|
||||||
|
**This is exactly why we probe:** the static call-graph looked like a null-check gate; the live
|
||||||
|
client showed the gate is upstream — a *trigger*, not a precondition. Event-push (OnlineStatus/
|
||||||
|
Login) parses fine and even sets FIFA's stored online bool (via the no-op listener's map store),
|
||||||
|
but does not kick the online controller into starting the connect. **Next question: what
|
||||||
|
transition starts the EASFC connect** (idle connect-state → nucleusConnectREST). Candidates to
|
||||||
|
probe next: the connect-state's earlier vtable steps (+0xa8/+0xb0 = +0x507cd60/+0x507cf90) to
|
||||||
|
see if the state is entered-but-stalls vs never-entered, and the online-controller update that
|
||||||
|
would advance it. Still a cost/priority call vs [[project_direction_pivot]].
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RUN 4 — the EASFC connect subsystem is created-but-dormant (2026-07-02)
|
||||||
|
|
||||||
|
Probed the connect-state lifecycle to settle entered-but-stalled vs never-entered.
|
||||||
|
|
||||||
|
| probe | enter | reading |
|
||||||
|
|---|---|---|
|
||||||
|
| `connectState.ctor` (+0x5078d20) | **4** | connect states ARE created (4 slots) at boot init |
|
||||||
|
| `ctrl.GetConnState` (+0x4f46570) | **0** | nothing ever reads/iterates the connect-state array |
|
||||||
|
| `connState.m_a8/m_b0/tick_b8/m_c0` | **0** | no vtable method ever invoked on any connect state |
|
||||||
|
| `nucleusConnectREST` (+0x2861910) | 0 | never reached |
|
||||||
|
| `OnlineStatus.deser` (+0x278a4d0) | 33 | control: our pushed events keep arriving/parsing |
|
||||||
|
| sampler `ctx[M+0x778]` | non-null, stable | control: session context exists throughout |
|
||||||
|
|
||||||
|
**Definitive picture.** The EASFC/Nucleus online-connect subsystem is **constructed at boot
|
||||||
|
(as part of OriginSDK/`X` init — chain: connect-state ctor ← array-init +0x4f47920 ← ctor
|
||||||
|
+0x4f47b70 ← … ← OriginSDK ctor +0x279d5c0) and then left completely dormant**: its
|
||||||
|
connect-state array is built and never touched again — no iteration, no vtable call, no
|
||||||
|
connect attempt. So the block is not inside the connect flow at all; **nothing ever activates
|
||||||
|
the subsystem.** FIFA parks at "connecting to EA Servers" in the earlier EbisuSDK-login layer
|
||||||
|
and never hands off to EASFC. Our OnlineStatus/Login events parse and set FIFA's stored online
|
||||||
|
bool, but the game-side listener is a no-op (see run 2), so no active handler reacts to "online"
|
||||||
|
by starting EASFC.
|
||||||
|
|
||||||
|
**What this means strategically.** Every object we can see (session context, connect states)
|
||||||
|
exists; the missing piece is the runtime *activation* of the online subsystem — the game's
|
||||||
|
top-level "go online" transition, which anadius (offline DRM) never drives and which our LSX
|
||||||
|
bridge does not reach. Pinning the exact dormancy guard (the EASFC controller's per-frame Update
|
||||||
|
early-returns before reading the array) is the identified next lever, BUT: forcing it only moves
|
||||||
|
FIFA to the *next* wall — dialing the Blaze redirector (gosredirector) and speaking Fire2 — which
|
||||||
|
is the full multi-month M2→M6 Blaze-emulation frontier. This run empirically confirms we are
|
||||||
|
standing exactly at the M2 boundary the status review priced, with the pre-Blaze infrastructure
|
||||||
|
proven present. Decision point vs [[project_direction_pivot]] (FLE career = stated primary plan).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MAPPING SYNTHESIS — the online-activation gate, and a tooling limit (2026-07-02)
|
||||||
|
|
||||||
|
Goal after run 4: map the exact trigger that would wake the dormant EASFC subsystem.
|
||||||
|
Result: the gate is characterized precisely at the subsystem level, but the *single*
|
||||||
|
activation predicate could not be isolated with byte-level tooling — because the online
|
||||||
|
subsystem is pervasive, not gated by one flag. That pervasiveness is itself the finding.
|
||||||
|
|
||||||
|
**The complete gate map (proven, top to bottom):**
|
||||||
|
1. LSX bootstrap — DONE. FIFA reaches "connecting to EA Servers" cleanly; bridge answers
|
||||||
|
GetConfig/GetProfile/GetGameInfo/GetSetting/GetInternetConnectedState/etc.
|
||||||
|
2. EbisuSDK online report — DONE at the SDK level. Our OnlineStatus/Login event pushes parse
|
||||||
|
(deserializers fire) and set FIFA's stored online bool (HandleMessage map store). BUT the
|
||||||
|
game-side listener is a no-op (dispatch → +0x2751060 = `ret 0`): no active handler reacts.
|
||||||
|
3. EASFC/Nucleus online-connect subsystem — **DORMANT.** Its connect-state array is built at
|
||||||
|
boot (ctor fires 4x) as part of OriginSDK init, then never touched (GetConnState 0x, no
|
||||||
|
vtable step fires). Session context [M+0x778] exists. nucleusConnectREST/GetAuthCode never
|
||||||
|
fire because nothing iterates/activates the subsystem.
|
||||||
|
4. GetAuthCode → Nucleus token → Blaze redirector → Fire2 — NEVER REACHED (the M2→M6 frontier).
|
||||||
|
|
||||||
|
**The missing link = runtime "go online" activation** between (2) and (3): the thing that, on
|
||||||
|
"online", makes FIFA's online controller start iterating/driving the connect states. anadius
|
||||||
|
(offline DRM) never drives it; our LSX bridge reaches the SDK layer (2) but not the game's
|
||||||
|
top-level online-mode activation (3). Attempts to isolate a single activation flag via static
|
||||||
|
byte-scan failed: GetConnState has 61 callers, the connect-state getter family and the online
|
||||||
|
globals resolve to hundreds of sites — the subsystem is woven throughout, so there is likely no
|
||||||
|
one boolean to flip. This is consistent with FIFA being a full online client whose online mode
|
||||||
|
is an entire subsystem activation, not a switch.
|
||||||
|
|
||||||
|
**Tooling note for whoever continues:** byte-level objdump + offset scans were enough to
|
||||||
|
CHARACTERIZE the gate (this whole document) but are the wrong tool to find the activation
|
||||||
|
predicate inside a 500 MB pervasive subsystem. The right next tool is a decompiler (Ghidra/IDA)
|
||||||
|
to recover FIFA's online-controller Update and its guard as structured code, or continued
|
||||||
|
dynamic probing (the openfut-hook `probe` framework — 8 entry detours + a VirtualQuery-guarded
|
||||||
|
memory sampler — is reusable) to watch the controller Update's guard value live. Either way this
|
||||||
|
is confirmed to be the multi-month M2 frontier; pre-Blaze infrastructure is proven present, and
|
||||||
|
FIFA sits one activation away from the first Blaze dial. Weigh against [[project_direction_pivot]].
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FORCING EXPERIMENT (2026-07-02) — reframes the gate: the endpoint config map is EMPTY
|
||||||
|
|
||||||
|
Per the user's call, forced the connect via the hook: a background thread called
|
||||||
|
`nucleusConnectREST()` (FIFA23.exe+0x2861910) directly, 3x, once the ctx chain was valid.
|
||||||
|
FIFA SURVIVED (self-contained as predicted). Result: **returned 0x0, sent nothing** — no
|
||||||
|
GetAuthCode on the bridge, no hostname resolved, no Blaze dial.
|
||||||
|
|
||||||
|
**Why — and it corrects the dig-2 reading.** `nucleusConnectREST` is NOT a connect-sender.
|
||||||
|
Its inner call `ctx->vt[0x40]` (= FIFA23.exe+0x5057670) is a **map lookup**: it looks up the
|
||||||
|
string key `"nucleusConnectREST"` in the endpoint config map at `[ctx+0x19c8]` and returns the
|
||||||
|
value, al=1 if found / 0 if not. It returned **0 = key not found**. So `nucleusConnectREST` is
|
||||||
|
the *endpoint-URL accessor* for the Nucleus-connect REST endpoint; the string is a config KEY.
|
||||||
|
|
||||||
|
**Live memory confirms the map is EMPTY.** `[ctx+0x19c8]` → map object (e.g. 0xbae32f18):
|
||||||
|
vtable 0x147f79ab8, bucket ptr +0x10 = 0, end/count +0x20 = 0. No entries. So FIFA has **no
|
||||||
|
online endpoint URLs at all**.
|
||||||
|
|
||||||
|
**And FIFA never tries to load them:** the hook log shows FIFA resolves **zero hostnames**
|
||||||
|
(no getaddrinfo calls), connects nowhere except LSX, and makes no config fetch. Its only channel
|
||||||
|
is LSX (which we own). In a live setup the EA App/Origin client fetches the online config and
|
||||||
|
feeds the endpoint URLs to the SDK; anadius (offline DRM) never does, so the map stays empty.
|
||||||
|
|
||||||
|
**Reframed gate (root cause candidate).** Online can't start because the endpoint config map is
|
||||||
|
empty — not (only) because a subsystem is dormant; the subsystem is dormant *because it has no
|
||||||
|
server URLs*. This is more actionable: if we can populate `[ctx+0x19c8]` with endpoint entries
|
||||||
|
pointing at localhost/our bridge (via the LSX response that normally carries them, or by direct
|
||||||
|
memory construction), FIFA would have somewhere to connect — to us. Next: find the map's
|
||||||
|
POPULATOR (who inserts into [ctx+0x19c8]) and its data source/channel. 29 funcs reference
|
||||||
|
+0x19c8; the map object's own vtable is 0x147f79ab8. Candidates for the config loader:
|
||||||
|
+0x14153e010, +0x141603fc0, +0x146f5a400 (store sites). This needs the LSX-response→map mapping
|
||||||
|
or a decompiler. Live-memory + hook-call forcing (openfut-hook `probe`/force) is proven safe and
|
||||||
|
reusable. Weigh vs [[project_direction_pivot]].
|
||||||
|
|
||||||
|
### Correction/nuance (same session): the empty map is partly downstream
|
||||||
|
|
||||||
|
Checked the LSX channel: FIFA's only GetSetting requests are IS_IGO_ENABLED, IS_IGO_AVAILABLE,
|
||||||
|
ENVIRONMENT — NOT endpoint URLs. And no HTTP config fetch happens. So the endpoint config map is
|
||||||
|
NOT populated by a request we're mishandling; in a live client it's filled from the online
|
||||||
|
connection/login response (post-redirector). Therefore the empty map is (at least partly)
|
||||||
|
*downstream* of "never connected", not a pure root cause. Net integrated picture across all
|
||||||
|
runs: FIFA's frontend shows "connecting" but the online subsystem never initiates ANY network
|
||||||
|
connection (no getaddrinfo, no redirector dial); every deeper object (connect states, session
|
||||||
|
ctx, endpoint map) sits in its initial empty/dormant state because no connection ever happened.
|
||||||
|
`nucleusConnectREST` is a config-lookup (empty), not a sender, so it can't be used to trivially
|
||||||
|
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).
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
//! openfut-poke — live inspector / gate-forcer for FIFA 23's "go online" decision.
|
||||||
|
//!
|
||||||
|
//! Reconstructed to the project spec + the resolver layout established earlier in the RE
|
||||||
|
//! (fn 0x144f46650 reads its config from `[this+8]` = M, with host buffer at M+0x560, flag at
|
||||||
|
//! M+0x660, port WORD at M+0x662; the "go online" gate is the `je` at 0x144f4d47c).
|
||||||
|
//!
|
||||||
|
//! It operates over `/proc/<pid>/mem` directly (pread/pwrite via FileExt) — no ptrace attach,
|
||||||
|
//! matching how the earlier data pokes worked. Three subcommands:
|
||||||
|
//!
|
||||||
|
//! inspect (READ-ONLY, safe): resolve M, print env / override-host / flag / port and the live
|
||||||
|
//! bytes at the gate.
|
||||||
|
//! arm (LIVE WRITE): write an override redirector host into M+0x560 (+ optional port at
|
||||||
|
//! M+0x662), then patch the gate `0f 84 → 90 e9` (je → nop; jmp). The 4-byte rel32 is
|
||||||
|
//! left untouched, so the branch target stays 0x144f46590... (0x144f4d590). Saves the
|
||||||
|
//! original bytes to a restore file first.
|
||||||
|
//! restore (LIVE WRITE): write the saved original bytes back and clear the override.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! openfut-poke inspect --pid <PID>
|
||||||
|
//! openfut-poke arm --pid <PID> --host <HOST> [--port <PORT>] [--flag <BYTE>]
|
||||||
|
//! openfut-poke restore --pid <PID>
|
||||||
|
//!
|
||||||
|
//! The `#[repr]`-free, dependency-light design uses only std + anyhow.
|
||||||
|
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::os::unix::fs::FileExt; // gives read_exact_at / write_all_at (pread/pwrite semantics)
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Established RE constants — DO NOT change. Validated by prior analysis + live probe.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Fixed global that holds pointer X. Chain: M = *(*(SINGLETON_PTR) + X_TO_M).
|
||||||
|
const SINGLETON_PTR: u64 = 0x1_4acd_02c0;
|
||||||
|
const X_TO_M: u64 = 0x360;
|
||||||
|
|
||||||
|
/// Fields inside the config object M (all relative to M):
|
||||||
|
const ENV_OFF: u64 = 0x54c; // u32 environment enum: 0=sdev 1=stest 2=scert 3=prod
|
||||||
|
const HOST_OFF: u64 = 0x560; // override redirector host: NUL-terminated buffer, 0x100 bytes
|
||||||
|
const HOST_LEN: usize = 0x100; // resolver copies up to 0x100 bytes from here
|
||||||
|
const FLAG_OFF: u64 = 0x660; // u8 flag (semantics UNCONFIRMED — do not touch unless asked)
|
||||||
|
const PORT_OFF: u64 = 0x662; // u16 override port (resolver loads this into r10w)
|
||||||
|
|
||||||
|
/// The "go online" gate: `je 0x144f4d590` at this VA. Taken only when the state code == "+onl".
|
||||||
|
const GATE_VA: u64 = 0x1_44f4_d47c;
|
||||||
|
/// Original 6 bytes: 0F 84 = je rel32, rel32 = 0x0000010E (→ 0x144f4d590).
|
||||||
|
const GATE_ORIG: [u8; 6] = [0x0f, 0x84, 0x0e, 0x01, 0x00, 0x00];
|
||||||
|
/// Armed 6 bytes: 90 = nop, E9 = jmp rel32, SAME rel32 0x0000010E. Because the jmp's rel32 is
|
||||||
|
/// measured from the end of the instruction (identical to the je's end), the target is unchanged.
|
||||||
|
const GATE_ARMED: [u8; 6] = [0x90, 0xe9, 0x0e, 0x01, 0x00, 0x00];
|
||||||
|
/// The (unconditional) target both encodings resolve to — printed for the operator's sanity check.
|
||||||
|
const GATE_TARGET: u64 = 0x1_44f4_d590;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// /proc/<pid>/mem helpers — pread/pwrite at a virtual address (Wine maps FIFA flat at 0x140000000)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn open_mem_ro(pid: u32) -> Result<File> {
|
||||||
|
File::open(format!("/proc/{pid}/mem"))
|
||||||
|
.with_context(|| format!("open /proc/{pid}/mem for reading (is the PID right? permissions?)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_mem_rw(pid: u32) -> Result<File> {
|
||||||
|
OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(format!("/proc/{pid}/mem"))
|
||||||
|
.with_context(|| format!("open /proc/{pid}/mem read-write (ptrace_scope? try sudo)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_bytes(f: &File, va: u64, buf: &mut [u8]) -> Result<()> {
|
||||||
|
f.read_exact_at(buf, va)
|
||||||
|
.with_context(|| format!("read {} bytes at {va:#x}", buf.len()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_bytes(f: &File, va: u64, buf: &[u8]) -> Result<()> {
|
||||||
|
f.write_all_at(buf, va)
|
||||||
|
.with_context(|| format!("write {} bytes at {va:#x} (EPERM here usually means ptrace_scope=1 blocks the write — sudo or lower it)", buf.len()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u16(f: &File, va: u64) -> Result<u16> {
|
||||||
|
let mut b = [0u8; 2];
|
||||||
|
read_bytes(f, va, &mut b)?;
|
||||||
|
Ok(u16::from_le_bytes(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(f: &File, va: u64) -> Result<u32> {
|
||||||
|
let mut b = [0u8; 4];
|
||||||
|
read_bytes(f, va, &mut b)?;
|
||||||
|
Ok(u32::from_le_bytes(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u64(f: &File, va: u64) -> Result<u64> {
|
||||||
|
let mut b = [0u8; 8];
|
||||||
|
read_bytes(f, va, &mut b)?;
|
||||||
|
Ok(u64::from_le_bytes(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the config object M via the validated chain: M = *(*(SINGLETON_PTR) + X_TO_M).
|
||||||
|
fn resolve_m(f: &File) -> Result<u64> {
|
||||||
|
let x = read_u64(f, SINGLETON_PTR)?;
|
||||||
|
if x == 0 {
|
||||||
|
bail!(
|
||||||
|
"singleton at {SINGLETON_PTR:#x} is NULL — the game isn't initialized yet.\n\
|
||||||
|
Get FIFA to the main menu and retry."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let m = read_u64(f, x + X_TO_M)?;
|
||||||
|
if m == 0 {
|
||||||
|
bail!(
|
||||||
|
"M (*({x:#x}+{X_TO_M:#x})) is NULL — the online subsystem hasn't populated the config yet.\n\
|
||||||
|
Wait a few seconds on the menu (or step toward an online menu) and re-inspect."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the NUL-terminated override host string out of the M+0x560 buffer.
|
||||||
|
fn read_host(f: &File, m: u64) -> Result<String> {
|
||||||
|
let mut buf = [0u8; HOST_LEN];
|
||||||
|
read_bytes(f, m + HOST_OFF, &mut buf)?;
|
||||||
|
let end = buf.iter().position(|&b| b == 0).unwrap_or(HOST_LEN);
|
||||||
|
Ok(String::from_utf8_lossy(&buf[..end]).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_label(env: u32) -> &'static str {
|
||||||
|
match env {
|
||||||
|
0 => "sdev",
|
||||||
|
1 => "stest",
|
||||||
|
2 => "scert",
|
||||||
|
3 => "prod",
|
||||||
|
_ => "??? (out of 0-3 range)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_path(pid: u32) -> String {
|
||||||
|
format!("/tmp/openfut-poke-restore-{pid}.bin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Subcommands
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn cmd_inspect(pid: u32) -> Result<()> {
|
||||||
|
let f = open_mem_ro(pid)?;
|
||||||
|
|
||||||
|
println!("== openfut-poke inspect (read-only) — pid {pid} ==\n");
|
||||||
|
|
||||||
|
// Chain resolution.
|
||||||
|
let x = read_u64(&f, SINGLETON_PTR)?;
|
||||||
|
let m = resolve_m(&f)?;
|
||||||
|
println!("chain:");
|
||||||
|
println!(" *({SINGLETON_PTR:#x}) = X = {x:#x}");
|
||||||
|
println!(" *(X + {X_TO_M:#x}) = M = {m:#x} (config object)");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Config fields.
|
||||||
|
let env = read_u32(&f, m + ENV_OFF)?;
|
||||||
|
let host = read_host(&f, m)?;
|
||||||
|
let flag = {
|
||||||
|
let mut b = [0u8; 1];
|
||||||
|
read_bytes(&f, m + FLAG_OFF, &mut b)?;
|
||||||
|
b[0]
|
||||||
|
};
|
||||||
|
let port = read_u16(&f, m + PORT_OFF)?;
|
||||||
|
println!("config @ M:");
|
||||||
|
println!(" env [M+{ENV_OFF:#x}] = {env} ({})", env_label(env));
|
||||||
|
println!(
|
||||||
|
" host [M+{HOST_OFF:#x}] = {}",
|
||||||
|
if host.is_empty() { "(empty)".to_string() } else { format!("{host:?}") }
|
||||||
|
);
|
||||||
|
println!(" flag [M+{FLAG_OFF:#x}] = {flag:#04x} (semantics unconfirmed)");
|
||||||
|
println!(" port [M+{PORT_OFF:#x}] = {port}");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Gate bytes.
|
||||||
|
let mut gate = [0u8; 6];
|
||||||
|
read_bytes(&f, GATE_VA, &mut gate)?;
|
||||||
|
let gate_state = if gate == GATE_ORIG {
|
||||||
|
"RECOGNISED original je (not armed)"
|
||||||
|
} else if gate == GATE_ARMED {
|
||||||
|
"ARMED (nop; jmp — already forced)"
|
||||||
|
} else {
|
||||||
|
"UNRECOGNISED"
|
||||||
|
};
|
||||||
|
println!("gate @ {GATE_VA:#x}: {} → {gate_state}", hex(&gate));
|
||||||
|
println!(" (original je target = {GATE_TARGET:#x}; armed keeps the same target)");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// ---- operator-facing verdict ----
|
||||||
|
println!("== read ==");
|
||||||
|
println!(" • singleton non-null, M resolved: YES ({m:#x})");
|
||||||
|
match gate_state.chars().next() {
|
||||||
|
Some('R') => println!(" • gate reads the recognised original je: YES → safe to arm"),
|
||||||
|
Some('A') => println!(" • gate is ALREADY ARMED — run `restore` before arming again"),
|
||||||
|
_ => println!(
|
||||||
|
" • gate is UNRECOGNISED ({}) → WRONG PROCESS or DIFFERENT BUILD. Do NOT arm.",
|
||||||
|
hex(&gate)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if (0..=3).contains(&env) {
|
||||||
|
println!(" • env is sane ({env}={}) → real green light", env_label(env));
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
" • env={env} is out of 0-3. If host/flag/port are also empty/garbage, the menu is up\n\
|
||||||
|
\x20 but the online subsystem hasn't populated config yet — wait a few seconds and re-inspect."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
" • override host currently {}",
|
||||||
|
if host.is_empty() { "EMPTY (expected)".to_string() } else { format!("NON-EMPTY: {host:?} (already overridden?)") }
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_arm(pid: u32, host: &str, port: Option<u16>, flag: Option<u8>) -> Result<()> {
|
||||||
|
if host.is_empty() {
|
||||||
|
bail!("--host must be a non-empty redirector hostname");
|
||||||
|
}
|
||||||
|
if host.len() + 1 > HOST_LEN {
|
||||||
|
bail!("--host too long ({} bytes); buffer is {HOST_LEN} bytes incl. NUL", host.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
let f = open_mem_rw(pid)?;
|
||||||
|
let m = resolve_m(&f)?;
|
||||||
|
println!("== openfut-poke arm — pid {pid}, M={m:#x} ==\n");
|
||||||
|
|
||||||
|
// 1) Verify the gate is exactly the original je before we save/patch. This prevents clobbering
|
||||||
|
// the restore file with already-armed (or foreign) bytes.
|
||||||
|
let mut gate = [0u8; 6];
|
||||||
|
read_bytes(&f, GATE_VA, &mut gate)?;
|
||||||
|
if gate == GATE_ARMED {
|
||||||
|
bail!("gate at {GATE_VA:#x} is ALREADY armed ({}). Run `restore` first.", hex(&gate));
|
||||||
|
}
|
||||||
|
if gate != GATE_ORIG {
|
||||||
|
bail!(
|
||||||
|
"gate at {GATE_VA:#x} is UNRECOGNISED ({}). Expected {} — wrong process/build. Refusing to write.",
|
||||||
|
hex(&gate),
|
||||||
|
hex(&GATE_ORIG)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Save the ORIGINAL bytes we're about to touch (gate + the whole override region) so
|
||||||
|
// `restore` can put everything back even across separate invocations.
|
||||||
|
let mut orig_region = [0u8; 0x104]; // host 0x100 + flag/pad 0x2 + port 0x2, i.e. M+0x560..M+0x664
|
||||||
|
read_bytes(&f, m + HOST_OFF, &mut orig_region)?;
|
||||||
|
let mut save = Vec::with_capacity(6 + orig_region.len());
|
||||||
|
save.extend_from_slice(&gate);
|
||||||
|
save.extend_from_slice(&orig_region);
|
||||||
|
std::fs::write(restore_path(pid), &save)
|
||||||
|
.with_context(|| format!("write restore file {}", restore_path(pid)))?;
|
||||||
|
println!("saved restore file: {} ({} bytes)", restore_path(pid), save.len());
|
||||||
|
|
||||||
|
// 3) Write the override host (bytes + a single NUL terminator) into M+0x560.
|
||||||
|
let mut host_bytes = host.as_bytes().to_vec();
|
||||||
|
host_bytes.push(0);
|
||||||
|
write_bytes(&f, m + HOST_OFF, &host_bytes)?;
|
||||||
|
println!("wrote override host [M+{HOST_OFF:#x}] = {host:?}");
|
||||||
|
|
||||||
|
// 4) Optionally write the override port (WORD) at M+0x662.
|
||||||
|
if let Some(p) = port {
|
||||||
|
write_bytes(&f, m + PORT_OFF, &p.to_le_bytes())?;
|
||||||
|
println!("wrote override port [M+{PORT_OFF:#x}] = {p}");
|
||||||
|
} else {
|
||||||
|
println!("port left unchanged (no --port)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Optionally write the flag (only if explicitly requested — semantics unconfirmed).
|
||||||
|
if let Some(fl) = flag {
|
||||||
|
write_bytes(&f, m + FLAG_OFF, &[fl])?;
|
||||||
|
println!("wrote flag [M+{FLAG_OFF:#x}] = {fl:#04x}");
|
||||||
|
} else {
|
||||||
|
println!("flag left unchanged (no --flag)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) Patch the gate: je → nop; jmp (same rel32, same target).
|
||||||
|
write_bytes(&f, GATE_VA, &GATE_ARMED)?;
|
||||||
|
let mut check = [0u8; 6];
|
||||||
|
read_bytes(&f, GATE_VA, &mut check)?;
|
||||||
|
if check != GATE_ARMED {
|
||||||
|
bail!("gate write-back verify FAILED: read {} after patch", hex(&check));
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"patched gate @ {GATE_VA:#x}: {} → {} (je → nop; jmp {GATE_TARGET:#x})",
|
||||||
|
hex(&GATE_ORIG),
|
||||||
|
hex(&GATE_ARMED)
|
||||||
|
);
|
||||||
|
println!("\nARMED. Run `restore --pid {pid}` to reverse.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_restore(pid: u32) -> Result<()> {
|
||||||
|
let path = restore_path(pid);
|
||||||
|
let save = std::fs::read(&path)
|
||||||
|
.with_context(|| format!("read restore file {path} (was `arm` run for this pid?)"))?;
|
||||||
|
if save.len() != 6 + 0x104 {
|
||||||
|
bail!("restore file {path} has unexpected size {} (corrupt?)", save.len());
|
||||||
|
}
|
||||||
|
let gate_orig = &save[0..6];
|
||||||
|
let region_orig = &save[6..];
|
||||||
|
|
||||||
|
let f = open_mem_rw(pid)?;
|
||||||
|
let m = resolve_m(&f)?;
|
||||||
|
println!("== openfut-poke restore — pid {pid}, M={m:#x} ==\n");
|
||||||
|
|
||||||
|
// Put the override region back (this also clears the injected host, since the saved region
|
||||||
|
// was captured before arm).
|
||||||
|
write_bytes(&f, m + HOST_OFF, region_orig)?;
|
||||||
|
println!("restored override region [M+{HOST_OFF:#x}..+0x104]");
|
||||||
|
|
||||||
|
// Put the gate back and verify.
|
||||||
|
write_bytes(&f, GATE_VA, gate_orig)?;
|
||||||
|
let mut check = [0u8; 6];
|
||||||
|
read_bytes(&f, GATE_VA, &mut check)?;
|
||||||
|
println!("restored gate @ {GATE_VA:#x}: {}", hex(&check));
|
||||||
|
if check == GATE_ORIG {
|
||||||
|
println!("verify: gate is back to the original je (0f 84 0e 01 00 00). ✔");
|
||||||
|
} else {
|
||||||
|
bail!("verify FAILED: gate reads {} (expected {})", hex(&check), hex(&GATE_ORIG));
|
||||||
|
}
|
||||||
|
// Best-effort: remove the restore file now that we've applied it.
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Tiny helpers + CLI
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Format bytes as space-separated lowercase hex, e.g. "0f 84 0e 01 00 00".
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an integer that may be 0x-prefixed hex or decimal.
|
||||||
|
fn parse_int(s: &str) -> Result<u64> {
|
||||||
|
let t = s.trim();
|
||||||
|
if let Some(h) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) {
|
||||||
|
u64::from_str_radix(h, 16).with_context(|| format!("bad hex: {s:?}"))
|
||||||
|
} else {
|
||||||
|
t.parse::<u64>().with_context(|| format!("bad number: {s:?}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage() -> ! {
|
||||||
|
eprintln!(
|
||||||
|
"openfut-poke — inspect/force FIFA 23's go-online gate via /proc/<pid>/mem\n\
|
||||||
|
\n\
|
||||||
|
USAGE:\n\
|
||||||
|
\x20 openfut-poke inspect --pid <PID>\n\
|
||||||
|
\x20 openfut-poke arm --pid <PID> --host <HOST> [--port <PORT>] [--flag <BYTE>]\n\
|
||||||
|
\x20 openfut-poke restore --pid <PID>\n"
|
||||||
|
);
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
let sub = args.next().unwrap_or_default();
|
||||||
|
|
||||||
|
let mut pid: Option<u32> = None;
|
||||||
|
let mut host: Option<String> = None;
|
||||||
|
let mut port: Option<u16> = None;
|
||||||
|
let mut flag: Option<u8> = None;
|
||||||
|
|
||||||
|
while let Some(a) = args.next() {
|
||||||
|
match a.as_str() {
|
||||||
|
"--pid" => pid = Some(parse_int(&args.next().context("--pid needs a value")?)? as u32),
|
||||||
|
"--host" => host = Some(args.next().context("--host needs a value")?),
|
||||||
|
"--port" => port = Some(parse_int(&args.next().context("--port needs a value")?)? as u16),
|
||||||
|
"--flag" => flag = Some(parse_int(&args.next().context("--flag needs a value")?)? as u8),
|
||||||
|
"-h" | "--help" => usage(),
|
||||||
|
other => bail!("unknown argument: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pid = match pid {
|
||||||
|
Some(p) => p,
|
||||||
|
None => usage(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match sub.as_str() {
|
||||||
|
"inspect" => cmd_inspect(pid),
|
||||||
|
"arm" => cmd_arm(pid, &host.context("arm needs --host")?, port, flag),
|
||||||
|
"restore" => cmd_restore(pid),
|
||||||
|
_ => usage(),
|
||||||
|
}
|
||||||
|
}
|
||||||
+1179
File diff suppressed because it is too large
Load Diff
+12
-4
@@ -48,19 +48,27 @@ impl CapturedRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Persist a capture to disk as JSON.
|
/// Persist a capture to disk as JSON.
|
||||||
|
/// Sensitive auth headers (`X-UT-SID`, `X-UT-PHISHING-TOKEN`) are stripped
|
||||||
|
/// before writing so they are never stored on disk.
|
||||||
pub fn save_capture(captures_dir: &str, capture: &CapturedRequest) -> anyhow::Result<()> {
|
pub fn save_capture(captures_dir: &str, capture: &CapturedRequest) -> anyhow::Result<()> {
|
||||||
let dir = Path::new(captures_dir);
|
let dir = Path::new(captures_dir);
|
||||||
std::fs::create_dir_all(dir)?;
|
std::fs::create_dir_all(dir)?;
|
||||||
|
|
||||||
|
let mut sanitized = capture.clone();
|
||||||
|
sanitized.headers.retain(|(k, _)| {
|
||||||
|
let lower = k.to_lowercase();
|
||||||
|
lower != "x-ut-sid" && lower != "x-ut-phishing-token"
|
||||||
|
});
|
||||||
|
|
||||||
let filename = format!(
|
let filename = format!(
|
||||||
"{}_{}_{}.json",
|
"{}_{}_{}.json",
|
||||||
capture.timestamp.replace(':', "-"),
|
sanitized.timestamp.replace(':', "-"),
|
||||||
capture.method,
|
sanitized.method,
|
||||||
capture.id
|
sanitized.id
|
||||||
);
|
);
|
||||||
let path = dir.join(filename);
|
let path = dir.join(filename);
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(capture)?;
|
let json = serde_json::to_string_pretty(&sanitized)?;
|
||||||
std::fs::write(&path, json)?;
|
std::fs::write(&path, json)?;
|
||||||
|
|
||||||
tracing::debug!("Capture saved: {:?}", path);
|
tracing::debug!("Capture saved: {:?}", path);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod capture;
|
pub mod capture;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod lsx;
|
||||||
pub mod mapper;
|
pub mod mapper;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
|||||||
+667
@@ -0,0 +1,667 @@
|
|||||||
|
/// EA App LSX server (TCP port 3216).
|
||||||
|
///
|
||||||
|
/// FIFA 23 opens two concurrent connections to this port at startup.
|
||||||
|
/// Protocol: server speaks first — sends an XML greeting with a challenge
|
||||||
|
/// key; client replies with ChallengeResponse; server replies with
|
||||||
|
/// ChallengeAccepted; then an AES-128-ECB encrypted session loop follows.
|
||||||
|
use aes::cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit};
|
||||||
|
use aes::Aes128;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
const GREETING_KEY: &str = "cacf897a20b6d612ad0c05e011df52bb";
|
||||||
|
const AES_KEY: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
|
||||||
|
|
||||||
|
/// The async `ONLINE_STATUS_EVENT` push FIFA waits for after `GoOnline`.
|
||||||
|
///
|
||||||
|
/// FIFA calls GoOnline (handled in-process by EbisuSDK/anadius), gets success,
|
||||||
|
/// then blocks waiting for an unsolicited `OnlineStatusEvent` push on the LSX
|
||||||
|
/// socket — the M2 wall. Now that our bridge owns FIFA's LSX socket we can send
|
||||||
|
/// it. Format RE'd from FIFA23.exe's OriginSDK deserializer (authoritative):
|
||||||
|
/// element `OnlineStatusEvent` (RTTI `Origin::EventHandler<lsx::OnlineStatusEventT,
|
||||||
|
/// bool>`, parser FIFA23.exe+0x274d4ae), single bool attribute `isOnline`
|
||||||
|
/// (deserializer FIFA23.exe+0x28a4d0). See docs/connection-gate-findings.md.
|
||||||
|
///
|
||||||
|
/// The value is the string literal `true`/`false` (not `1`/`0`): FIFA's bool
|
||||||
|
/// parser (FIFA23.exe+0x28fb50) string-compares the attribute against `"false"`,
|
||||||
|
/// matching the OriginSDK convention every other bool in our LSX responses uses.
|
||||||
|
/// An initial `isOnline="1"` push was delivered but parsed as not-online (FIFA
|
||||||
|
/// kept polling), which pinned the encoding. Re-push cadence below still
|
||||||
|
/// TODO/CONFIRM (single push may suffice once value is correct).
|
||||||
|
const ONLINE_STATUS_EVENT: &str =
|
||||||
|
r#"<LSX><Event sender="EbisuSDK"><OnlineStatusEvent isOnline="true"/></Event></LSX>"#;
|
||||||
|
|
||||||
|
/// The authenticated-session `Login` event. OnlineStatusEvent alone set FIFA's
|
||||||
|
/// *connectivity* (presence went INGAME) but not an authenticated session, so FIFA
|
||||||
|
/// never started the GetAuthCode→Nucleus→Blaze chain. FIFA's Origin event set has a
|
||||||
|
/// separate `Login` event (`Origin::EventHandler<lsx::LoginT,OriginLoginT>`) that
|
||||||
|
/// carries the login state. Format RE'd from FIFA23.exe's LoginT deserializer
|
||||||
|
/// (0x142787a30): fields `UserIndex` (int, primary user 0), `IsLoggedIn` (bool,
|
||||||
|
/// parsed by the same true/false helper as isOnline), `LoginReasonCode` (int/enum).
|
||||||
|
///
|
||||||
|
/// Pushed BEFORE OnlineStatusEvent each cycle (log in, then go online).
|
||||||
|
/// TODO/CONFIRM `LoginReasonCode` — 0 is the natural "no error / normal" value; if
|
||||||
|
/// FIFA switches on a specific success code, RE the consumer and adjust.
|
||||||
|
const LOGIN_EVENT: &str =
|
||||||
|
r#"<LSX><Event sender="EbisuSDK"><Login UserIndex="0" IsLoggedIn="true" LoginReasonCode="0"/></Event></LSX>"#;
|
||||||
|
|
||||||
|
/// Events pushed on the LSX socket each interval, in order, to drive FIFA online.
|
||||||
|
const ONLINE_PUSH_EVENTS: &[&str] = &[LOGIN_EVENT, ONLINE_STATUS_EVENT];
|
||||||
|
|
||||||
|
/// Delay before the first `OnlineStatusEvent` push (and the re-push interval).
|
||||||
|
/// Long enough that FIFA has finished LSX bootstrap and subscribed its event
|
||||||
|
/// listeners + issued GoOnline (GetInternetConnectedState was seen ~2s in), short
|
||||||
|
/// enough to fire while FIFA is still polling and waiting.
|
||||||
|
const ONLINE_PUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2000);
|
||||||
|
|
||||||
|
// Account identity + locale that anadius's in-process LSX emu reports (recovered
|
||||||
|
// from anadius.cfg — the config the emu reads). FIFA carries these values into
|
||||||
|
// its online/loading state, so our bridge must report the SAME ones anadius does;
|
||||||
|
// fabricated values crash FIFA before the loading screen. See the LSX-regression
|
||||||
|
// section in docs/connection-gate-findings.md.
|
||||||
|
const PERSONA_ID: &str = "1144668899";
|
||||||
|
const USER_ID: &str = "1000200030000";
|
||||||
|
const USERNAME: &str = "fun";
|
||||||
|
const INSTALLED_LANGUAGE: &str = "en_US";
|
||||||
|
const LANGUAGES: &str =
|
||||||
|
"pt_PT,tr_TR,ko_KR,cs_CZ,zh_CN,zh_HK,da_DK,no_NO,sv_SE,en_US,pt_BR,de_DE,\
|
||||||
|
es_ES,fr_FR,it_IT,ja_JP,es_MX,nl_NL,pl_PL,ru_RU,ar_SA";
|
||||||
|
|
||||||
|
/// The full game-info attribute set anadius's `GetAllGameInfo` handler emits,
|
||||||
|
/// as (GameInfoId key, response attribute name, value). Recovered by
|
||||||
|
/// disassembling anadius64.dll's builder (attribute names + order) plus
|
||||||
|
/// anadius.cfg (version/language values). Our old `<GetAllGameInfoResponse />`
|
||||||
|
/// was empty — FIFA reads these fields during load and crashes without them.
|
||||||
|
/// TODO/CONFIRM the exact values for the entitlement/group fields (the names are
|
||||||
|
/// certain from the disassembly; a few values are best-effort defaults).
|
||||||
|
const GAME_INFO: &[(&str, &str, &str)] = &[
|
||||||
|
("DISPLAY_NAME", "DisplayName", "FIFA 23"),
|
||||||
|
("INSTALLED_VERSION", "InstalledVersion", "1.0.82.43747"),
|
||||||
|
("AVAILABLE_VERSION", "AvailableVersion", "1.0.82.43747"),
|
||||||
|
("UPTODATE", "UpToDate", "true"),
|
||||||
|
("FULLGAME_IS_RELEASED", "FullGameReleased", "true"),
|
||||||
|
("FULLGAME_RELEASE_DATE", "FullGameReleaseDate", "2022-09-30T00:00:00"),
|
||||||
|
("FULLGAME_PURCHASED", "FullGamePurchased", "true"),
|
||||||
|
("FREETRIAL", "FreeTrial", "false"),
|
||||||
|
("EXPIRATION", "Expiration", "0000-00-00T00:00:00"),
|
||||||
|
("ENTITLEMENT_SOURCE", "EntitlementSource", "NORMAL"),
|
||||||
|
("MAX_GROUP_SIZE", "MaxGroupSize", "0"),
|
||||||
|
("LANGUAGES", "Languages", LANGUAGES),
|
||||||
|
("INSTALLED_LANGUAGE", "InstalledLanguage", INSTALLED_LANGUAGE),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub async fn start_server(addr: &str) -> anyhow::Result<()> {
|
||||||
|
let listener = TcpListener::bind(addr).await?;
|
||||||
|
info!("LSX server listening on {addr}");
|
||||||
|
loop {
|
||||||
|
let (stream, peer) = listener.accept().await?;
|
||||||
|
debug!("LSX: connection from {peer}");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = handle(stream).await {
|
||||||
|
warn!("LSX: connection error: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(mut stream: TcpStream) -> anyhow::Result<()> {
|
||||||
|
// 1. Server speaks first — send greeting
|
||||||
|
let greeting = format!(
|
||||||
|
"<LSX>\r\n <Event sender=\"EALS\">\r\n <Challenge build=\"release\" key=\"{GREETING_KEY}\" version=\"10,5,30,15625\" />\r\n </Event>\r\n</LSX>\0"
|
||||||
|
);
|
||||||
|
debug!("LSX: sending greeting");
|
||||||
|
stream.write_all(greeting.as_bytes()).await?;
|
||||||
|
|
||||||
|
// 2. Receive ChallengeResponse
|
||||||
|
let raw = read_msg(&mut stream).await?;
|
||||||
|
let text = String::from_utf8_lossy(&raw);
|
||||||
|
debug!("LSX: got ChallengeResponse: {}", &text[..text.len().min(300)]);
|
||||||
|
|
||||||
|
let parts: Vec<&str> = text.split('"').collect();
|
||||||
|
let id = parts.get(3).copied().unwrap_or("1");
|
||||||
|
let key = parts.get(7).copied().unwrap_or("");
|
||||||
|
debug!("LSX: challenge id={id} key={key}");
|
||||||
|
|
||||||
|
let our_response = make_challenge_response(key);
|
||||||
|
let seed = compute_seed(&our_response);
|
||||||
|
debug!("LSX: response={our_response} seed={seed}");
|
||||||
|
|
||||||
|
// 3. Send ChallengeAccepted
|
||||||
|
let accepted = format!(
|
||||||
|
"<LSX>\r\n <Response id=\"{id}\" sender=\"EALS\">\r\n <ChallengeAccepted response=\"{our_response}\" />\r\n </Response>\r\n</LSX>\0"
|
||||||
|
);
|
||||||
|
stream.write_all(accepted.as_bytes()).await?;
|
||||||
|
info!("LSX: handshake complete");
|
||||||
|
|
||||||
|
// 4. Session loop — multiplexes request handling with an unsolicited
|
||||||
|
// OnlineStatusEvent push. A single task keeps owning the stream; the
|
||||||
|
// `select!` services reads and lets a timer fire the push, so no socket
|
||||||
|
// split / shared writer is needed.
|
||||||
|
let mut push_timer = tokio::time::interval(ONLINE_PUSH_INTERVAL);
|
||||||
|
// interval fires immediately on the first tick — consume it so the first
|
||||||
|
// push lands after ONLINE_PUSH_INTERVAL (giving FIFA time to subscribe).
|
||||||
|
push_timer.tick().await;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
read = read_msg(&mut stream) => {
|
||||||
|
let raw = match read {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
// Dump the exact wire bytes so the session seed can be brute-forced
|
||||||
|
// offline against this ciphertext (see docs/connection-gate-findings.md).
|
||||||
|
debug!("LSX: session raw ({} bytes): {}", raw.len(), bytes_to_hex(&raw));
|
||||||
|
|
||||||
|
let out = process_frame(&raw, seed);
|
||||||
|
if out.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if stream.write_all(&out).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = push_timer.tick() => {
|
||||||
|
// Push the Login (authenticated session) then OnlineStatusEvent
|
||||||
|
// (connectivity) so FIFA leaves the "connecting" wait and starts the
|
||||||
|
// GetAuthCode→Nucleus→Blaze chain. Each is encrypted + null-terminated
|
||||||
|
// like any session frame. Re-pushed each interval to cover the
|
||||||
|
// subscribe-timing race until FIFA advances (or the socket closes).
|
||||||
|
let mut push_err = false;
|
||||||
|
for ev in ONLINE_PUSH_EVENTS {
|
||||||
|
let mut frame = lsx_encrypt(ev, seed).into_bytes();
|
||||||
|
frame.push(0);
|
||||||
|
debug!("LSX: pushing event: {ev}");
|
||||||
|
if stream.write_all(&frame).await.is_err() {
|
||||||
|
push_err = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if push_err {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("LSX: client disconnected");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one raw socket read, which may contain SEVERAL pipelined LSX messages.
|
||||||
|
///
|
||||||
|
/// FIFA batches multiple LSX messages into a single TCP segment — each a
|
||||||
|
/// null-terminated block of AES-ciphertext-as-ASCII-hex (e.g. `SetPresence`
|
||||||
|
/// immediately followed by a `GetGameInfo GameInfoId="LANGUAGES"` query). The
|
||||||
|
/// old loop decrypted the whole read as one blob and dispatched only the FIRST
|
||||||
|
/// message, silently dropping the rest. A dropped request leaves FIFA waiting on
|
||||||
|
/// a response that never arrives; it times out ~15s later and crashes before the
|
||||||
|
/// loading screen. So we split on the null terminators and answer EVERY message,
|
||||||
|
/// returning the concatenated null-terminated encrypted responses (one per
|
||||||
|
/// request), preserving order.
|
||||||
|
fn process_frame(raw: &[u8], seed: u16) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for chunk in raw.split(|&b| b == 0) {
|
||||||
|
let hex = String::from_utf8_lossy(chunk);
|
||||||
|
let hex = hex.trim();
|
||||||
|
if hex.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let decrypted = lsx_decrypt(hex, seed);
|
||||||
|
let trimmed = decrypted.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
debug!("LSX: request: {}", &trimmed[..trimmed.len().min(300)]);
|
||||||
|
let response_xml = dispatch(trimmed);
|
||||||
|
debug!("LSX: response: {}", &response_xml[..response_xml.len().min(300)]);
|
||||||
|
let encrypted = lsx_encrypt(&response_xml, seed);
|
||||||
|
out.extend_from_slice(encrypted.as_bytes());
|
||||||
|
out.push(0);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a null-terminated or fixed-size LSX message.
|
||||||
|
async fn read_msg(stream: &mut TcpStream) -> anyhow::Result<Vec<u8>> {
|
||||||
|
let mut buf = vec![0u8; 65536];
|
||||||
|
let n = stream.read(&mut buf).await?;
|
||||||
|
if n == 0 { return Err(anyhow::anyhow!("connection closed")); }
|
||||||
|
buf.truncate(n);
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── dispatcher ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn dispatch(xml: &str) -> String {
|
||||||
|
let parts: Vec<&str> = xml.split('"').collect();
|
||||||
|
let id = parts.get(3).copied().unwrap_or("1");
|
||||||
|
let req_type = parts.get(4).copied().unwrap_or("");
|
||||||
|
debug!("LSX: dispatch id={id} type={req_type}");
|
||||||
|
|
||||||
|
match req_type {
|
||||||
|
"><GetConfig version=" => get_config(id),
|
||||||
|
"><GetAuthCode ClientId=" | "><GetAuthCode UserId=" => get_auth_code(id),
|
||||||
|
"><GetInternetConnectedState version=" => get_internet_state(id),
|
||||||
|
"><GetProfile index=" => get_profile(id),
|
||||||
|
"><GetSetting SettingId=" => {
|
||||||
|
let setting = parts.get(5).copied().unwrap_or("");
|
||||||
|
get_setting(id, setting)
|
||||||
|
}
|
||||||
|
"><QueryEntitlements UserId=" => query_entitlements(id),
|
||||||
|
"><RequestLicense UserId=" => request_license(id),
|
||||||
|
"><QueryContent UserId=" => query_content(id),
|
||||||
|
"><GetBlockList version=" => get_block_list(id),
|
||||||
|
"><QueryFriends UserId=" => query_friends(id),
|
||||||
|
"><QueryPresence UserId=" => query_presence(id),
|
||||||
|
"><SetPresence UserId=" => set_presence(id),
|
||||||
|
"><GetPresenceVisibility UserId=" => get_presence_visibility(id),
|
||||||
|
"><GetWalletBalance UserId=" => get_wallet_balance(id),
|
||||||
|
"><GetGameInfo GameInfoId=" => {
|
||||||
|
let game_info_id = parts.get(5).copied().unwrap_or("");
|
||||||
|
get_game_info(id, game_info_id)
|
||||||
|
}
|
||||||
|
"><GetAllGameInfo version=" => get_all_game_info(id),
|
||||||
|
"><IsProgressiveInstallationAvailable ItemId=" => is_progressive_installation_available(id),
|
||||||
|
_ => {
|
||||||
|
warn!("LSX: unknown request type: {req_type}");
|
||||||
|
format!("<LSX><Response id=\"{id}\" sender=\"EbisuSDK\"><Ok /></Response></LSX>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_config(id: &str) -> String {
|
||||||
|
format!(r#"<LSX>
|
||||||
|
<Response id="{id}" sender="EbisuSDK">
|
||||||
|
<GetConfigResponse>
|
||||||
|
<Service Facility="SDK" Name="EbisuSDK" />
|
||||||
|
<Service Facility="PROFILE" Name="EbisuSDK" />
|
||||||
|
<Service Facility="PRESENCE" Name="XMPP" />
|
||||||
|
<Service Facility="FRIENDS" Name="XMPP" />
|
||||||
|
<Service Facility="COMMERCE" Name="Commerce" />
|
||||||
|
<Service Facility="LOGIN" Name="EALS" />
|
||||||
|
<Service Facility="UTILITY" Name="Utility" />
|
||||||
|
<Service Facility="XMPP" Name="XMPP" />
|
||||||
|
<Service Facility="CHAT" Name="XMPP" />
|
||||||
|
<Service Facility="IGO" Name="EbisuSDK" />
|
||||||
|
<Service Facility="MISC" Name="EbisuSDK" />
|
||||||
|
<Service Facility="IGO_EVENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="EALS_EVENTS" Name="EALS" />
|
||||||
|
<Service Facility="LOGIN_EVENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="INVITE_EVENT" Name="XMPP" />
|
||||||
|
<Service Facility="PROFILE_EVENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="PRESENCE_EVENT" Name="XMPP" />
|
||||||
|
<Service Facility="FRIENDS_EVENT" Name="XMPP" />
|
||||||
|
<Service Facility="COMMERCE_EVENT" Name="Commerce" />
|
||||||
|
<Service Facility="CHAT_EVENT" Name="XMPP" />
|
||||||
|
<Service Facility="DOWNLOAD_EVENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="PERMISSION" Name="EbisuSDK" />
|
||||||
|
<Service Facility="RESOURCES" Name="EbisuSDK" />
|
||||||
|
<Service Facility="BLOCKED_USERS" Name="EbisuSDK" />
|
||||||
|
<Service Facility="BLOCKED_USER_EVENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="GET_USERID" Name="EbisuSDK" />
|
||||||
|
<Service Facility="ONLINE_STATUS_EVENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="ACHIEVEMENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="ACHIEVEMENT_EVENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="BROADCAST_EVENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="RECENTPLAYER" Name="EbisuSDK" />
|
||||||
|
<Service Facility="PROGRESSIVE_INSTALLATION" Name="PI" />
|
||||||
|
<Service Facility="PROGRESSIVE_INSTALLATION_EVENT" Name="PI" />
|
||||||
|
<Service Facility="CONTENT" Name="EbisuSDK" />
|
||||||
|
</GetConfigResponse>
|
||||||
|
</Response>
|
||||||
|
</LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_auth_code(id: &str) -> String {
|
||||||
|
format!(r#"<LSX>
|
||||||
|
<Response id="{id}" sender="Utility">
|
||||||
|
<AuthCode value="OpenFUT_fake_auth_code_v1" />
|
||||||
|
</Response>
|
||||||
|
</LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_internet_state(id: &str) -> String {
|
||||||
|
format!(r#"<LSX>
|
||||||
|
<Response id="{id}" sender="Utility">
|
||||||
|
<InternetConnectedState connected="1" />
|
||||||
|
</Response>
|
||||||
|
</LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_profile(id: &str) -> String {
|
||||||
|
format!(r#"<LSX>
|
||||||
|
<Response id="{id}" sender="EbisuSDK">
|
||||||
|
<GetProfileResponse PersonaId="{PERSONA_ID}" Persona="{USERNAME}" Country="US" GeoCountry="US"
|
||||||
|
UserIndex="0" IsTrialSubscriber="false" AvatarId="1"
|
||||||
|
IsUnderAge="false" IsSubscriber="false" IsSteamSubscriber="false" SubscriberLevel="2"
|
||||||
|
CommerceCurrency="USD" UserId="{USER_ID}" CommerceCountry="US" />
|
||||||
|
</Response>
|
||||||
|
</LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_setting(id: &str, setting: &str) -> String {
|
||||||
|
let value = match setting { "ENVIRONMENT" => "production", _ => "false" };
|
||||||
|
format!(r#"<LSX>
|
||||||
|
<Response id="{id}" sender="EbisuSDK">
|
||||||
|
<GetSettingResponse Setting="{value}" />
|
||||||
|
</Response>
|
||||||
|
</LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_entitlements(id: &str) -> String {
|
||||||
|
format!(r#"<LSX>
|
||||||
|
<Response id="{id}" sender="Commerce">
|
||||||
|
<QueryEntitlementsResponse>
|
||||||
|
<Entitlements ItemId="Origin.OFR.50.0004658" Type="ONLINE_ACCESS"
|
||||||
|
EntitlementId="1021747550001" EntitlementTag="ONLINE_ACCESS"
|
||||||
|
Group="FIFA23PC" ResourceId="" UseCount="0"
|
||||||
|
Expiration="0000-00-00T00:00:00" GrantDate="2022-09-30T00:00:00"
|
||||||
|
LastModifiedDate="2022-09-30T00:00:00" Version="0" />
|
||||||
|
<Entitlements ItemId="Origin.OFR.50.0004658" Type="DEFAULT"
|
||||||
|
EntitlementId="1021747550002" EntitlementTag="ONLINE_ACCESS"
|
||||||
|
Group="FIFA23PC" ResourceId="" UseCount="0"
|
||||||
|
Expiration="0000-00-00T00:00:00" GrantDate="2022-09-30T00:00:00"
|
||||||
|
LastModifiedDate="2022-09-30T00:00:00" Version="0" />
|
||||||
|
</QueryEntitlementsResponse>
|
||||||
|
</Response>
|
||||||
|
</LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_license(id: &str) -> String {
|
||||||
|
format!(r#"<LSX>
|
||||||
|
<Response sender="EbisuSDK" id="{id}">
|
||||||
|
<RequestLicenseResponse License="OpenFUT_fake_license_v1" />
|
||||||
|
</Response>
|
||||||
|
</LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_content(id: &str) -> String {
|
||||||
|
format!(r#"<LSX>
|
||||||
|
<Response id="{id}" sender="EbisuSDK">
|
||||||
|
<QueryContentResponse>
|
||||||
|
<Content Gamestate="READY_TO_PLAY" progressValue="0"
|
||||||
|
contentID="Origin.OFR.50.0004658"
|
||||||
|
installedVersion="1.0.0.0" availableVersion="1.0.0.0"
|
||||||
|
displayName="FIFA 23" />
|
||||||
|
</QueryContentResponse>
|
||||||
|
</Response>
|
||||||
|
</LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_block_list(id: &str) -> String {
|
||||||
|
format!(r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetBlockListResponse /></Response></LSX>"#)
|
||||||
|
}
|
||||||
|
fn query_friends(id: &str) -> String {
|
||||||
|
format!(r#"<LSX><Response id="{id}" sender="XMPP"><QueryFriendsResponse /></Response></LSX>"#)
|
||||||
|
}
|
||||||
|
fn query_presence(id: &str) -> String {
|
||||||
|
format!(r#"<LSX><Response id="{id}" sender="XMPP"><QueryPresenceResponse UserId="{USER_ID}" PersonaId="{PERSONA_ID}" /></Response></LSX>"#)
|
||||||
|
}
|
||||||
|
fn set_presence(id: &str) -> String {
|
||||||
|
format!(r#"<LSX><Response id="{id}" sender="XMPP"><SetPresenceResponse /></Response></LSX>"#)
|
||||||
|
}
|
||||||
|
fn get_presence_visibility(id: &str) -> String {
|
||||||
|
format!(r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetPresenceVisibilityResponse Visibility="FRIENDS" /></Response></LSX>"#)
|
||||||
|
}
|
||||||
|
fn get_wallet_balance(id: &str) -> String {
|
||||||
|
format!(r#"<LSX><Response id="{id}" sender="Commerce"><GetWalletBalanceResponse Balance="0" Currency="USD" /></Response></LSX>"#)
|
||||||
|
}
|
||||||
|
fn get_all_game_info(id: &str) -> String {
|
||||||
|
// Populated with the full attribute set anadius emits (see GAME_INFO), plus
|
||||||
|
// the literal `HasExpiration="false"` the disassembly showed. Empty responses
|
||||||
|
// crash FIFA during load.
|
||||||
|
let mut attrs = String::new();
|
||||||
|
for (_key, attr, value) in GAME_INFO {
|
||||||
|
attrs.push_str(&format!(r#"{attr}="{value}" "#));
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetAllGameInfoResponse {attrs}HasExpiration="false" /></Response></LSX>"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GetGameInfo — FIFA queries per-key EbisuSDK "game info" values during the LSX
|
||||||
|
/// session (observed keys: `FREETRIAL`, `LANGUAGES`, `INSTALLED_LANGUAGE`).
|
||||||
|
///
|
||||||
|
/// The response element is `GetGameInfoResponse` with a **single fixed attribute
|
||||||
|
/// literally named `GameInfo`** holding the requested value — NOT a per-key
|
||||||
|
/// attribute (`InstalledLanguage`, `Languages`, …) and NOT a generic `Value`.
|
||||||
|
/// Recovered by disassembling the response builder in `anadius64.dll`: it writes
|
||||||
|
/// `<` + `GetGameInfoResponse` + ` ` + `GameInfo` + `="` + <value> + `"` + ` />`,
|
||||||
|
/// with `GameInfo` a fixed 8-byte string constant regardless of the GameInfoId.
|
||||||
|
/// The value is looked up per key (from `anadius.cfg`); the attribute name never
|
||||||
|
/// changes.
|
||||||
|
///
|
||||||
|
/// This was the loading-screen crash: we were sending an attribute FIFA never
|
||||||
|
/// reads (first `Value=`, then a per-key name), so it always saw a null
|
||||||
|
/// installed-language, re-queried `INSTALLED_LANGUAGE` three times, and crashed.
|
||||||
|
/// That also explains why changing the value string (empty → `en_US`) had zero
|
||||||
|
/// effect — FIFA wasn't reading our attribute at all.
|
||||||
|
fn get_game_info(id: &str, game_info_id: &str) -> String {
|
||||||
|
// Strip surrounding quotes the positional parser can leave on the value.
|
||||||
|
let key = game_info_id.trim_matches('"');
|
||||||
|
debug!("LSX: GetGameInfo GameInfoId={key}");
|
||||||
|
// The value is per-key; the attribute name is always the literal `GameInfo`.
|
||||||
|
let value = GAME_INFO
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _, _)| *k == key)
|
||||||
|
.map(|(_, _, v)| *v)
|
||||||
|
.unwrap_or("");
|
||||||
|
format!(
|
||||||
|
r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetGameInfoResponse GameInfo="{value}" /></Response></LSX>"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// IsProgressiveInstallationAvailable — FIFA asks this early in the LSX session.
|
||||||
|
/// anadius emits `IsProgressiveInstallationAvailableResponse` with an `Available`
|
||||||
|
/// attribute (recovered from `anadius64.dll`); we previously fell through to the
|
||||||
|
/// generic `<Ok/>`. The game is fully installed, so progressive/streaming install
|
||||||
|
/// is not available.
|
||||||
|
///
|
||||||
|
/// The handling service is `PROGRESSIVE_INSTALLATION`, whose Name in anadius's
|
||||||
|
/// service registry is `PI` (see `get_config`), so the response is sent from
|
||||||
|
/// `sender="PI"`. This service MUST also be declared in the `GetConfig` response:
|
||||||
|
/// FIFA builds its service registry from GetConfig and resolves the
|
||||||
|
/// `PROGRESSIVE_INSTALLATION` handle from it. Omitting the service left FIFA with a
|
||||||
|
/// null handle it dereferenced during online-init, crashing at FIFA23.exe+0x133dfc5
|
||||||
|
/// (`mov rax,[r13+0x18]`, r13=NULL) ~2s after LSX — the invariant loading-screen
|
||||||
|
/// crash across every prior run.
|
||||||
|
fn is_progressive_installation_available(id: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<LSX><Response id="{id}" sender="PI"><IsProgressiveInstallationAvailableResponse Available="false" /></Response></LSX>"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── crypto ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// AES-128-ECB encrypt with PKCS#7 padding, backed by the `aes` crate.
|
||||||
|
/// ECB has no per-message state, so we drive the block cipher over each
|
||||||
|
/// 16-byte chunk ourselves.
|
||||||
|
fn aes_ecb_pkcs7_encrypt(key: &[u8; 16], plaintext: &[u8]) -> Vec<u8> {
|
||||||
|
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||||
|
let pad = 16 - (plaintext.len() % 16);
|
||||||
|
let mut padded = plaintext.to_vec();
|
||||||
|
padded.resize(plaintext.len() + pad, pad as u8);
|
||||||
|
for chunk in padded.chunks_mut(16) {
|
||||||
|
cipher.encrypt_block(GenericArray::from_mut_slice(chunk));
|
||||||
|
}
|
||||||
|
padded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AES-128-ECB decrypt of whole blocks, then a *lenient* PKCS#7 strip.
|
||||||
|
/// Kept lenient (won't error on invalid padding) on purpose: a wrong session
|
||||||
|
/// key yields garbage we want to log and move past, not panic on. This is why
|
||||||
|
/// we don't use the crate's strict `Pkcs7` unpadder.
|
||||||
|
fn aes_ecb_decrypt_nopad(key: &[u8; 16], data: &[u8]) -> Vec<u8> {
|
||||||
|
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||||
|
let mut out = Vec::with_capacity(data.len());
|
||||||
|
for chunk in data.chunks(16) {
|
||||||
|
if chunk.len() < 16 { break; }
|
||||||
|
let mut block = *GenericArray::from_slice(chunk);
|
||||||
|
cipher.decrypt_block(&mut block);
|
||||||
|
out.extend_from_slice(&block);
|
||||||
|
}
|
||||||
|
if let Some(&pad) = out.last() {
|
||||||
|
let pad = pad as usize;
|
||||||
|
if pad <= 16 && out.len() >= pad { out.truncate(out.len() - pad); }
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CRandom { seed: u32 }
|
||||||
|
impl CRandom {
|
||||||
|
fn new() -> Self { Self { seed: 0 } }
|
||||||
|
fn seed_with(&mut self, s: u32) { self.seed = s; }
|
||||||
|
fn rand(&mut self) -> u32 { self.seed=self.seed.wrapping_mul(214013).wrapping_add(2531011); (self.seed>>16)&0xFFFF }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_lsx_key(seed: u16) -> [u8; 16] {
|
||||||
|
let mut rng=CRandom::new();
|
||||||
|
rng.seed_with(7);
|
||||||
|
let next=rng.rand();
|
||||||
|
rng.seed_with(next.wrapping_add(seed as u32));
|
||||||
|
let mut k=[0u8;16]; for b in &mut k { *b=rng.rand() as u8; }
|
||||||
|
k
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_to_bytes(s: &str) -> Vec<u8> {
|
||||||
|
let s: String=s.chars().filter(|c|c.is_ascii_hexdigit()).collect();
|
||||||
|
if s.len()%2!=0 { return Vec::new(); }
|
||||||
|
(0..s.len()/2).filter_map(|i|u8::from_str_radix(&s[2*i..2*i+2],16).ok()).collect()
|
||||||
|
}
|
||||||
|
fn bytes_to_hex(b: &[u8]) -> String { b.iter().map(|x|format!("{x:02x}")).collect() }
|
||||||
|
|
||||||
|
fn compute_seed(hex: &str) -> u16 {
|
||||||
|
// The session seed is the first TWO ASCII characters of the ChallengeAccepted
|
||||||
|
// response hex string, taken as their raw byte values — NOT the leading hex
|
||||||
|
// pair parsed into a byte. Confirmed 2026-07-02 by brute-forcing the seed
|
||||||
|
// against a captured FIFA session message (see docs/connection-gate-findings.md):
|
||||||
|
// response "0f73…" → seed 0x3066 ('0','f'), not 0x0F73.
|
||||||
|
let b = hex.as_bytes();
|
||||||
|
let b0 = b.first().copied().unwrap_or(0);
|
||||||
|
let b1 = b.get(1).copied().unwrap_or(0);
|
||||||
|
((b0 as u16) << 8) | (b1 as u16)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_challenge_response(key: &str) -> String {
|
||||||
|
bytes_to_hex(&aes_ecb_pkcs7_encrypt(&AES_KEY, key.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lsx_decrypt(hex_data: &str, seed: u16) -> String {
|
||||||
|
let key=get_lsx_key(seed);
|
||||||
|
let ct=hex_to_bytes(hex_data);
|
||||||
|
if ct.is_empty() { return String::new(); }
|
||||||
|
String::from_utf8_lossy(&aes_ecb_decrypt_nopad(&key, &ct)).trim_matches('\0').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lsx_encrypt(text: &str, seed: u16) -> String {
|
||||||
|
let key=get_lsx_key(seed);
|
||||||
|
bytes_to_hex(&aes_ecb_pkcs7_encrypt(&key, text.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// FIPS-197 known-answer vector. The placeholder `AES_KEY` (00 01 .. 0f) is
|
||||||
|
/// exactly the FIPS-197 example key, so encrypting the FIPS example block
|
||||||
|
/// must yield the published ciphertext. This proves the `aes`-crate swap is
|
||||||
|
/// standard, interop-compatible AES-128 — not merely self-consistent.
|
||||||
|
#[test]
|
||||||
|
fn aes128_matches_fips197_and_round_trips() {
|
||||||
|
// FIPS-197 §C.1: plaintext 00112233445566778899aabbccddeeff
|
||||||
|
let plaintext = [
|
||||||
|
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
|
||||||
|
0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
|
||||||
|
];
|
||||||
|
// ...under key 000102..0f gives ciphertext 69c4e0d86a7b0430d8cdb78070b4c55a
|
||||||
|
let expected_ct = [
|
||||||
|
0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30,
|
||||||
|
0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4, 0xc5, 0x5a,
|
||||||
|
];
|
||||||
|
|
||||||
|
// aes_ecb_pkcs7_encrypt pads a full extra block, so only assert block 0.
|
||||||
|
let out = aes_ecb_pkcs7_encrypt(&AES_KEY, &plaintext);
|
||||||
|
assert_eq!(&out[..16], &expected_ct, "AES-128-ECB block 0 must match FIPS-197");
|
||||||
|
|
||||||
|
// Encrypt→decrypt round-trips back to the exact plaintext (padding stripped).
|
||||||
|
let round_tripped = aes_ecb_decrypt_nopad(&AES_KEY, &out);
|
||||||
|
assert_eq!(round_tripped, plaintext, "decrypt must invert encrypt");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The load-bearing regression test: when FIFA pipelines two LSX messages into
|
||||||
|
/// one frame (as it does with SetPresence + a GetGameInfo LANGUAGES query),
|
||||||
|
/// BOTH must be answered. Dropping the second hangs FIFA into a ~15s timeout
|
||||||
|
/// and a crash before the loading screen (the 2026-07-02 root cause).
|
||||||
|
#[test]
|
||||||
|
fn process_frame_answers_every_pipelined_message() {
|
||||||
|
let seed: u16 = 0x3066;
|
||||||
|
let m1 = lsx_encrypt(
|
||||||
|
r#"<LSX><Request recipient="XMPP" id="1"><SetPresence UserId="1" Presence="INGAME" version="3"/></Request></LSX>"#,
|
||||||
|
seed,
|
||||||
|
);
|
||||||
|
let m2 = lsx_encrypt(
|
||||||
|
r#"<LSX><Request recipient="EbisuSDK" id="2"><GetGameInfo GameInfoId="LANGUAGES" version="3"/></Request></LSX>"#,
|
||||||
|
seed,
|
||||||
|
);
|
||||||
|
// Two null-terminated ciphertext messages in a single read, as FIFA sends.
|
||||||
|
let mut raw = Vec::new();
|
||||||
|
raw.extend_from_slice(m1.as_bytes());
|
||||||
|
raw.push(0);
|
||||||
|
raw.extend_from_slice(m2.as_bytes());
|
||||||
|
raw.push(0);
|
||||||
|
|
||||||
|
let out = process_frame(&raw, seed);
|
||||||
|
let responses: Vec<&[u8]> = out.split(|&b| b == 0).filter(|c| !c.is_empty()).collect();
|
||||||
|
assert_eq!(responses.len(), 2, "both pipelined requests must be answered");
|
||||||
|
|
||||||
|
let r1 = lsx_decrypt(&String::from_utf8_lossy(responses[0]), seed);
|
||||||
|
let r2 = lsx_decrypt(&String::from_utf8_lossy(responses[1]), seed);
|
||||||
|
assert!(r1.contains(r#"id="1""#), "first response is for request id 1, got: {r1}");
|
||||||
|
assert!(r2.contains(r#"id="2""#), "second response is for request id 2, got: {r2}");
|
||||||
|
assert!(r2.contains("GetGameInfoResponse"), "the piggybacked LANGUAGES query must be answered, got: {r2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pins the `split('"')` index parsing that routes GetGameInfo, so the fragile
|
||||||
|
/// positional extraction doesn't silently regress to the generic `<Ok/>` path.
|
||||||
|
/// GetConfig must declare the PROGRESSIVE_INSTALLATION service (Name="PI"),
|
||||||
|
/// recovered from anadius's service-registration table in anadius64.dll. FIFA
|
||||||
|
/// builds its service registry from GetConfig and resolves this handle during
|
||||||
|
/// online-init; omitting it left a null handle that crashed FIFA at
|
||||||
|
/// FIFA23.exe+0x133dfc5 ~2s after LSX in every run (2026-07-02).
|
||||||
|
#[test]
|
||||||
|
fn get_config_declares_progressive_installation_service() {
|
||||||
|
let resp = dispatch(r#"<LSX><Request recipient="EbisuSDK" id="1"><GetConfig version="3"/></Request></LSX>"#);
|
||||||
|
assert!(
|
||||||
|
resp.contains(r#"Facility="PROGRESSIVE_INSTALLATION" Name="PI""#),
|
||||||
|
"GetConfig must declare PROGRESSIVE_INSTALLATION→PI, got: {resp}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
resp.contains(r#"Facility="PROGRESSIVE_INSTALLATION_EVENT" Name="PI""#),
|
||||||
|
"GetConfig must declare PROGRESSIVE_INSTALLATION_EVENT→PI, got: {resp}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_routes_get_game_info() {
|
||||||
|
let req = r#"<LSX><Request recipient="EbisuSDK" id="42"><GetGameInfo GameInfoId="FREETRIAL" /></Request></LSX>"#;
|
||||||
|
let resp = dispatch(req);
|
||||||
|
assert!(resp.contains("<GetGameInfoResponse"), "should hit the GetGameInfo handler, got: {resp}");
|
||||||
|
assert!(resp.contains(r#"id="42""#), "should echo the request id, got: {resp}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GetGameInfo must use anadius's fixed `GameInfo="<value>"` attribute (RE'd
|
||||||
|
/// from the response builder in anadius64.dll), NOT a per-key attribute name
|
||||||
|
/// and NOT a generic `Value` — FIFA reads the value out of the `GameInfo`
|
||||||
|
/// attribute, and the wrong attribute name crashed it before the loading
|
||||||
|
/// screen (2026-07-02).
|
||||||
|
#[test]
|
||||||
|
fn dispatch_get_game_info_uses_fixed_gameinfo_attribute() {
|
||||||
|
let installed = dispatch(r#"<LSX><Request recipient="EbisuSDK" id="7"><GetGameInfo GameInfoId="INSTALLED_LANGUAGE" version="3"/></Request></LSX>"#);
|
||||||
|
assert!(installed.contains(r#"GameInfo="en_US""#), "got: {installed}");
|
||||||
|
|
||||||
|
let languages = dispatch(r#"<LSX><Request recipient="EbisuSDK" id="8"><GetGameInfo GameInfoId="LANGUAGES" version="3"/></Request></LSX>"#);
|
||||||
|
assert!(languages.contains(r#"GameInfo=""#), "LANGUAGES must use the fixed GameInfo attribute, got: {languages}");
|
||||||
|
assert!(languages.contains("en_US") && languages.contains("fr_FR"), "LANGUAGES must be the full locale list, got: {languages}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
-2
@@ -32,11 +32,22 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
std::fs::create_dir_all(&cfg.captures_dir)?;
|
std::fs::create_dir_all(&cfg.captures_dir)?;
|
||||||
|
|
||||||
|
// LSX server (EA App port 3216) — must be a native Linux process so that
|
||||||
|
// accept() is delivered correctly (same-process Wine loopback never fires).
|
||||||
|
let lsx_addr = std::env::var("LSX_ADDR").unwrap_or_else(|_| "127.0.0.1:3216".into());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = openfut_bridge::lsx::start_server(&lsx_addr).await {
|
||||||
|
tracing::error!("LSX server failed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let addr: std::net::SocketAddr = listen_addr.parse()?;
|
let addr: std::net::SocketAddr = listen_addr.parse()?;
|
||||||
|
|
||||||
if tls_enabled {
|
if tls_enabled {
|
||||||
// Generate cert/key before building state so the cert can be served via /_bridge/cert.pem
|
// Reuse a persisted cert so clients only have to trust it once.
|
||||||
let (cert_pem, key_pem) = openfut_bridge::tls::generate_self_signed_cert()?;
|
let cert_path = std::path::Path::new(&cfg.captures_dir).join("bridge_cert.pem");
|
||||||
|
let key_path = std::path::Path::new(&cfg.captures_dir).join("bridge_key.pem");
|
||||||
|
let (cert_pem, key_pem) = openfut_bridge::tls::load_or_generate_cert(&cert_path, &key_path)?;
|
||||||
let state = ProxyState::new(cfg).with_cert(cert_pem.clone());
|
let state = ProxyState::new(cfg).with_cert(cert_pem.clone());
|
||||||
let app = build_router(state);
|
let app = build_router(state);
|
||||||
serve_tls(app, cert_pem, key_pem, addr).await
|
serve_tls(app, cert_pem, key_pem, addr).await
|
||||||
@@ -83,6 +94,59 @@ fn build_router(state: ProxyState) -> Router {
|
|||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract the SNI host_name from a raw TLS ClientHello, if present.
|
||||||
|
///
|
||||||
|
/// This is handshake-independent: we parse the bytes the client sends first, so we
|
||||||
|
/// learn the hostname even when the TLS handshake later fails (e.g. the client's
|
||||||
|
/// ProtoSSL rejects our self-signed cert). All indexing is bounds-checked; on any
|
||||||
|
/// malformed/short input we return None rather than panic.
|
||||||
|
///
|
||||||
|
/// ClientHello layout walked here: TLS record header (type=0x16, version, length) →
|
||||||
|
/// handshake header (type=0x01, length) → client_version, 32-byte random,
|
||||||
|
/// session_id, cipher_suites, compression_methods → extensions. The SNI extension
|
||||||
|
/// (type 0x0000) holds a server_name_list whose first entry (name_type 0x00 =
|
||||||
|
/// host_name) is the hostname.
|
||||||
|
fn parse_sni(buf: &[u8]) -> Option<String> {
|
||||||
|
if buf.len() < 5 || buf[0] != 0x16 {
|
||||||
|
return None; // not a TLS handshake record
|
||||||
|
}
|
||||||
|
let mut p = 5;
|
||||||
|
if buf.len() < p + 4 || buf[p] != 0x01 {
|
||||||
|
return None; // not a ClientHello
|
||||||
|
}
|
||||||
|
p += 4; // handshake msg_type(1) + length(3)
|
||||||
|
p += 2 + 32; // client_version(2) + random(32)
|
||||||
|
let sid_len = *buf.get(p)? as usize;
|
||||||
|
p += 1 + sid_len;
|
||||||
|
let cs_len = u16::from_be_bytes([*buf.get(p)?, *buf.get(p + 1)?]) as usize;
|
||||||
|
p += 2 + cs_len;
|
||||||
|
let cm_len = *buf.get(p)? as usize;
|
||||||
|
p += 1 + cm_len;
|
||||||
|
let ext_total = u16::from_be_bytes([*buf.get(p)?, *buf.get(p + 1)?]) as usize;
|
||||||
|
p += 2;
|
||||||
|
let ext_end = (p + ext_total).min(buf.len());
|
||||||
|
while p + 4 <= ext_end {
|
||||||
|
let etype = u16::from_be_bytes([buf[p], buf[p + 1]]);
|
||||||
|
let elen = u16::from_be_bytes([buf[p + 2], buf[p + 3]]) as usize;
|
||||||
|
p += 4;
|
||||||
|
if p + elen > buf.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if etype == 0x0000 {
|
||||||
|
// SNI ext: server_name_list_len(2), name_type(1), name_len(2), name
|
||||||
|
let d = &buf[p..p + elen];
|
||||||
|
if d.len() >= 5 && d[2] == 0x00 {
|
||||||
|
let nlen = u16::from_be_bytes([d[3], d[4]]) as usize;
|
||||||
|
if 5 + nlen <= d.len() {
|
||||||
|
return String::from_utf8(d[5..5 + nlen].to_vec()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p += elen;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
async fn serve_tls(
|
async fn serve_tls(
|
||||||
app: Router,
|
app: Router,
|
||||||
cert_pem: Vec<u8>,
|
cert_pem: Vec<u8>,
|
||||||
@@ -107,6 +171,23 @@ async fn serve_tls(
|
|||||||
let app = app.clone();
|
let app = app.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
// Peek the ClientHello (without consuming it) and log the SNI up front, so
|
||||||
|
// we capture the hostname even if the handshake below fails on cert pinning.
|
||||||
|
{
|
||||||
|
let mut peek = [0u8; 2048];
|
||||||
|
match tcp.peek(&mut peek).await {
|
||||||
|
Ok(n) if n > 0 => match parse_sni(&peek[..n]) {
|
||||||
|
Some(sni) => tracing::info!("ClientHello SNI (peek): {sni}"),
|
||||||
|
None => tracing::info!(
|
||||||
|
"ClientHello peek: no SNI ({n} bytes, first=0x{:02x})",
|
||||||
|
peek[0]
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Ok(_) => tracing::info!("ClientHello peek: 0 bytes"),
|
||||||
|
Err(e) => tracing::warn!("ClientHello peek failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let tls_stream = match acceptor.accept(tcp).await {
|
let tls_stream = match acceptor.accept(tcp).await {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -115,6 +196,13 @@ async fn serve_tls(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Post-handshake SNI (only fires on success; the peek above is the reliable one).
|
||||||
|
{
|
||||||
|
let (_, server_conn) = tls_stream.get_ref();
|
||||||
|
let sni = server_conn.server_name().unwrap_or("(none)");
|
||||||
|
tracing::info!("TLS accepted — SNI: {sni}");
|
||||||
|
}
|
||||||
|
|
||||||
let io = TokioIo::new(tls_stream);
|
let io = TokioIo::new(tls_stream);
|
||||||
let svc = hyper::service::service_fn(
|
let svc = hyper::service::service_fn(
|
||||||
move |req: hyper::Request<hyper::body::Incoming>| {
|
move |req: hyper::Request<hyper::body::Incoming>| {
|
||||||
|
|||||||
+15
-4
@@ -60,7 +60,7 @@ impl ProxyState {
|
|||||||
/// Returns true if this (method, path) pair was already saved within the dedup window.
|
/// Returns true if this (method, path) pair was already saved within the dedup window.
|
||||||
fn is_duplicate(dedup: &Mutex<HashMap<String, Instant>>, method: &str, path: &str) -> bool {
|
fn is_duplicate(dedup: &Mutex<HashMap<String, Instant>>, method: &str, path: &str) -> bool {
|
||||||
let key = format!("{method} {path}");
|
let key = format!("{method} {path}");
|
||||||
let mut map = dedup.lock().unwrap();
|
let mut map = dedup.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let threshold = std::time::Duration::from_secs(CAPTURE_DEDUP_SECS);
|
let threshold = std::time::Duration::from_secs(CAPTURE_DEDUP_SECS);
|
||||||
if let Some(last) = map.get(&key) {
|
if let Some(last) = map.get(&key) {
|
||||||
if last.elapsed() < threshold {
|
if last.elapsed() < threshold {
|
||||||
@@ -87,9 +87,20 @@ pub async fn catch_all_handler(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let (_parts, body) = req.into_parts();
|
let (_parts, body) = req.into_parts();
|
||||||
let body_bytes: Bytes = axum::body::to_bytes(body, 1024 * 1024)
|
let body_bytes: Bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
|
||||||
.await
|
Ok(b) => b,
|
||||||
.unwrap_or_default();
|
Err(_) => {
|
||||||
|
let json = serde_json::to_vec(
|
||||||
|
&serde_json::json!({ "error": "request body too large (limit: 1MB)" }),
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::PAYLOAD_TOO_LARGE)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(json))
|
||||||
|
.map_err(|e| anyhow::anyhow!("response build error: {e}"))?);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let body_str = if body_bytes.is_empty() {
|
let body_str = if body_bytes.is_empty() {
|
||||||
None
|
None
|
||||||
|
|||||||
+47
-10
@@ -1,19 +1,56 @@
|
|||||||
use std::sync::Arc;
|
use std::{path::Path, sync::Arc};
|
||||||
use tokio_rustls::TlsAcceptor;
|
use tokio_rustls::TlsAcceptor;
|
||||||
|
|
||||||
/// Generate a self-signed certificate covering localhost and EA FUT hostnames.
|
/// Load a previously persisted cert/key pair, or generate and save a new one.
|
||||||
/// Returns (cert_pem, key_pem) as byte vectors.
|
|
||||||
///
|
///
|
||||||
/// For FIFA 23 to connect, the cert must be installed as a trusted CA in the OS
|
/// Reusing the same cert across restarts means clients only need to trust it once.
|
||||||
/// trust store, OR certificate validation must be disabled in the game binary.
|
pub fn load_or_generate_cert(cert_path: &Path, key_path: &Path) -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
|
||||||
|
if cert_path.exists() && key_path.exists() {
|
||||||
|
let cert_pem = std::fs::read(cert_path)?;
|
||||||
|
let key_pem = std::fs::read(key_path)?;
|
||||||
|
tracing::info!("Loaded existing TLS cert from {:?}", cert_path);
|
||||||
|
return Ok((cert_pem, key_pem));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (cert_pem, key_pem) = generate_self_signed_cert()?;
|
||||||
|
if let Some(parent) = cert_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::write(cert_path, &cert_pem)?;
|
||||||
|
std::fs::write(key_path, &key_pem)?;
|
||||||
|
tracing::info!("Generated new TLS cert, saved to {:?}", cert_path);
|
||||||
|
Ok((cert_pem, key_pem))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a self-signed certificate covering EA FUT hostnames.
|
||||||
|
/// CN is set to `fut.ea.com` so ProtoSSL's CN hostname check passes.
|
||||||
|
/// SANs include wildcard entries for all `*.ea.com` and `*.easports.com` subdomains
|
||||||
|
/// so connections to any EA subdomain are covered.
|
||||||
pub fn generate_self_signed_cert() -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
|
pub fn generate_self_signed_cert() -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
|
||||||
let subject_alt_names = vec![
|
use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, SanType};
|
||||||
"localhost".to_string(),
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
"127.0.0.1".to_string(),
|
|
||||||
|
let mut params = CertificateParams::new(vec![
|
||||||
"fut.ea.com".to_string(),
|
"fut.ea.com".to_string(),
|
||||||
"utas.mob.v4.fut.ea.com".to_string(),
|
"utas.mob.v4.fut.ea.com".to_string(),
|
||||||
];
|
"accounts.ea.com".to_string(),
|
||||||
let cert = rcgen::generate_simple_self_signed(subject_alt_names)?;
|
"pin.data.ea.com".to_string(),
|
||||||
|
"gateway.ea.com".to_string(),
|
||||||
|
"localhost".to_string(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Wildcard SANs cover every EA subdomain the game might contact
|
||||||
|
params.subject_alt_names.push(SanType::DnsName("*.ea.com".to_string()));
|
||||||
|
params.subject_alt_names.push(SanType::DnsName("*.easports.com".to_string()));
|
||||||
|
params.subject_alt_names.push(SanType::DnsName("*.ugc.footapi.com".to_string()));
|
||||||
|
params.subject_alt_names.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
||||||
|
|
||||||
|
// CN must match the primary hostname so ProtoSSL's CN check passes
|
||||||
|
let mut dn = DistinguishedName::new();
|
||||||
|
dn.push(DnType::CommonName, "fut.ea.com");
|
||||||
|
params.distinguished_name = dn;
|
||||||
|
|
||||||
|
let cert = Certificate::from_params(params)?;
|
||||||
let cert_pem = cert.serialize_pem()?.into_bytes();
|
let cert_pem = cert.serialize_pem()?.into_bytes();
|
||||||
let key_pem = cert.serialize_private_key_pem().into_bytes();
|
let key_pem = cert.serialize_private_key_pem().into_bytes();
|
||||||
Ok((cert_pem, key_pem))
|
Ok((cert_pem, key_pem))
|
||||||
|
|||||||
+4
-1
@@ -85,7 +85,10 @@ fn build_test_app() -> axum::Router {
|
|||||||
let cfg = Config {
|
let cfg = Config {
|
||||||
listen_addr: "127.0.0.1:0".into(),
|
listen_addr: "127.0.0.1:0".into(),
|
||||||
core_url: "http://127.0.0.1:9999".into(), // won't be reached in placeholder mode
|
core_url: "http://127.0.0.1:9999".into(), // won't be reached in placeholder mode
|
||||||
captures_dir: "/tmp/openfut-test-captures".into(),
|
captures_dir: std::env::temp_dir()
|
||||||
|
.join(format!("openfut-bridge-test-{}", uuid::Uuid::new_v4()))
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned(),
|
||||||
placeholder_mode: true,
|
placeholder_mode: true,
|
||||||
tls_enabled: false,
|
tls_enabled: false,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user