Files
OpenFUT/fifa17-recon/docs/plan-2026-08-07-sbc-hook.md
T
2026-08-07 11:44:05 -07:00

238 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SBC Menu Render Intervention — Plan (2026-08-07)
**STATUS (one line): YES, WITH CAVEATS — a populated SBC menu is achievable via a
client-side hook, but ONLY by making the game's own parser fill its store; a
/proc/mem byte poke alone can open the menu (negative control) but renders EMPTY, and
the one remaining un-reversed item (the SAX input-source `vtable[+0x8]` byte-yield
contract) blocks the fully-offline populate until a served /sbs/sets response or a
completed reader is wired.**
All addresses are on-disk RVAs against CardsDLL image base `0x180000000`
(`/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll`, working copy `/tmp/fut/cardsdll.dll`).
Live slide this session = `0x6ffe7c140000` (mapped base `0x6ffffc140000`), proven via
FNV prologue at `0x180180d00`. Live values below are from read-only `/proc/12201/mem`.
---
## 1. Definitive SBC data-flow
### Object graph
- **A** = FUT root singleton = `*[0x1802e6398]`. Getter `0x18011a830`. A.vtable static
`0x18021c2a0`. Live A = `0xb83e2b60` (vtable matches static — CONFIRMED).
- **B** = SBC request/TTL gate cache = `A + 0x1f9d8`. B-getter = A.vtable[+0x4e8] =
thunk `0x18011c1f0` (`lea rax,[rcx+0x1f9d8]; ret`). B.vtable static `0x1801fae70`
(3 slots: dtor `0x180063040`, isValid `0x180065d40`, clear `0x180065d20`). Live B =
`0xb8402538` (vtable matches). **B is the GATE, not the render source.**
- **M** = SBC categories/sets store = `*(A + 0x20a68)`. Reached via A.vtable[+0x9b0] =
lazy getter `0x18011b7d0` (if `A[+0x20a68]==0` it factory-creates an EMPTY M, type-id
`0x13f0`, and caches it). Live M = `0x0` (never built this session — SBC menu not
opened). **M IS the render source.**
- The "SBC manager" is **A itself**: service-id `0xed84b12` resolver A.vtable[+0x18] =
`0x180113f50` returns `this`, so `manager.vtable[+0x9b0] == A.vtable[+0x9b0] ==
0x18011b7d0`. The old lead `0x1801e9010` is DEBUNKED — it is an `.rdata` function
pointer slot (`->0x18018577a`), not a manager global.
### Render source (CLIENT authority)
The SBC hub/squads controller (ctor `0x1800b5267`) caches M into `controller+0x140`
by calling A.vtable[+0x9b0] once (`0x1800b554d``0x1800b5571`→store `[rsi+0x140]`),
then registers Scaleform events `0x756c``0x7574`. The tile-build method (`0x1800b5e00`
region) reads `[ctrl+0x140]=M` and at **`0x1800b5eda`** does
`movzx ebx,WORD[M+0x50]; add bx,0x2; call [scaleform.vtable+0x58](count)` → emits
**(category_count + 2) tiles**. This region reads `[ctrl+0x140]` seven times and reads
B/`A+0x1fa00` **zero** times. M layout: cat count `WORD[M+0x50]`; cat vector
`[M+0x58]..[M+0x60]` stride `0xf0`; per-cat set count `WORD[cat+0xb8]`, set vector
`[cat+0xc0]` stride `0x3570`; secondary/featured vec `[M+0xa10]..[M+0xa18]`;
indices at `+0x9e0/+0xa10/+0xa40`. **Correction on record:** earlier passes that
called `B[+0x08]` the render source conflated the gate with the data source — the empty
render was because M was null/empty, NOT because `B[+0x08]` was null.
### Populate path (CLIENT authority)
The sbs/sets deserializer **`0x18017b2b0`** (rcx=this IGNORED; rdx=SAX cursor is the
only live input) does the whole populate: fetch manager → get store M via
`[manager.vtable+0x9b0]` (at `0x18017b327`) → clear `0x18015f3a0` → loop atom `0x6f`
"categories": per item ctor `0x180159da0` (0xf0, vtable `0x18021b520`), cat-deser
`0x18017ab80`, cat-finalize `0x180160e50`, APPEND `0x18015a770` (copy-ctor
`0x18015a2b0`), dtor `0x1801105d0` → after loop rebuild indices `0x180160e00` +
`0x180160f30` + `0x180161020` → commit `manager.vtable[+0x8]`. Always returns true.
Set-row deser `0x18017ad60`. **Populate-target == render-source (both are M).**
### Prefetch gate (SERVER/front-end authority — THE WALL)
There is **no native flag** to flip. The only native online check `0x1801642c0`
(inside isValid) is stubbed `mov al,1; ret` — NOT the wall. The block is upstream in
the Flash/ActionScript FUT front-end (FNV-name-hash bound; `RequestChallengeData` =
`0x1801f9b30`, `futsbchubviewmodel` = `0x1801ee0a0` — no native xref), which refuses to
issue `GET ut/game/fifa17/sbs/sets` offline, so deser `0x18017b2b0` never runs.
**Newly proven:** the URL template `"ut/%s/sbs"` (`0x18021d908`) has ZERO references
in the image (siblings `ut/%s/tournament`, `ut/%s/season` ARE referenced) — so
**CardsDLL has no native code that self-builds/issues the sbs GET.** This kills any
"force the req-mgr at A+0x2a0 to fetch on its own" idea. This is why the fix must be
client-side and must FORCE the populate.
### Ready-arm (CLIENT authority)
isValid `0x180065d40(B)` verified: `if !0x1801642c0() ret0` (stub→always passes);
`cmp [rbx+0x28],0; je fail`; **`cmp QWORD[rbx+0x8],0; je 0x180065d75` → returns 1
immediately (short-circuit)**; else QueryPerformanceCounter (`0x1801e50c0`) and compare
`[rbx+0x20]` deadline. Normally B is armed by the completion callback `0x1800b8c30`
(subscribed in svc ctor `0x1800b5765` via `manager.vtable[+0xa90]`) through the generic
cache copy-assign `0x1800c21a0` (sets B+0x08=collection, B+0x20=deadline, B+0x28=1).
Offline that callback never fires (no response). Live: `B[+0x08]=0`, `B[+0x28]=0`.
---
## 2. Chosen minimal intervention and WHY
**Reuse the client's own parser; do NOT hand-build structs; arm ONLY `B[+0x28]`.**
Two tiers, safest-first:
- **Tier-0 (negative control — proves the gate):** write ONLY `BYTE[B+0x28]=1`.
isValid short-circuits (B+0x08==0 branch) → menu OPENS instead of the error modal
(`0x18016c330`), but renders EMPTY (M is null/empty). Do NOT write `B+0x08` or
`B+0x20` — pointing B+0x08 at a collection forces isValid into the QPC-deadline
branch, and with the live-stale deadline (`0xf10fb8cb9`) the gate SHUTS → modal, i.e.
it DEFEATS the fix. This is the load-bearing correction from adversarial verification.
- **Tier-1 (real fix — populates M):**
- **Preferred (Option 1, cleanest, zero forged state):** inject a canned
`/sbs/sets` JSON response at the message-receive layer so the game builds the
response-msg (ctor `0x18017b1c0`, vtable `0x18022e598`, deser slot +0x20 =
`0x18017b2b0`), seats a genuine SAX cursor, its OWN chain populates M, and the
native completion callback `0x1800b8c30` arms B for you. The bridge/core serves the
JSON. Nothing forged.
- **Fallback (Option 2):** from the hook, stand up a real SAX cursor over canned JSON
(ctx `0x1801c63e0` + lexer `0x1801c8060` + an input-source whose `vtable[+0x8]`
yields bytes), call deser `0x18017b2b0(rcx=ignored, rdx=cursor)`, then arm ONLY
`BYTE[B+0x28]=1`. **Blocker:** the input-source `vtable[+0x8]` byte-yield contract
is the ONE un-reversed item — a cold call with a null-source cursor CLEARS M
(`0x18015f3a0`) then byte-scans a garbage pointer (`mov rdi,[rdi]` ~`0x18017b353`)
→ wipes state + segfault. So Option 2 is NOT safe to run until the reader is
reversed.
**Why not hand-build:** feeding `0x18015a770` a hand-built 0xf0 category (with nested
0x3570 set records / EASTL sub-vectors) is the highest crash risk — the copy-ctor
`0x18015a2b0` deep-copies inner sub-vectors; any bad begin/end/cap → heap corruption.
The parser writes the correct geometry AND runs the index-rebuild finalizers that
hand-built appends get wrong. Ruled out.
**Refresh:** after M is populated, fire refresh events `0x756c``0x7574` (or re-open the
menu) so `0x1800b5eda` re-reads `WORD[M+0x50]`.
---
## 3. STAGED MORNING TEST PLAN (safest-first)
Precondition: FIFA at the FUT hub with CardsDLL loaded. Rollback for EVERY step =
**relaunch FIFA** (all effects are volatile — single-byte poke or in-session hook state,
cleared on restart). NEVER run `--apply` while the SBC menu is open/mid-iterate.
### Step 1 — Dry-run read confirm (ZERO writes)
```
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py
```
Expect: CONTROL FNV MATCH; A vtable match; B offset decoded live = `0x1f9d8`; B/A vtables
match statics; `B+0x28=0`; `M=*(A+0x20a68)=0` (until SBC menu opened once).
PASS = addresses match the model. Rollback: none needed (read-only).
### Step 2 — Review the DLL populate spec (ZERO writes)
```
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py --spec
```
Expect: printed injected-DLL spec (Option 1 preferred, Option 2 fallback). Read-only.
### Step 3 — Negative control (Tier-0, ONE byte write) — proves the GATE
With the SBC menu **CLOSED**:
```
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py --apply
```
Writes exactly `BYTE[B+0x28]=1` (re-proves slide+vtables at write time; aborts on any
mismatch; hard-refuses to write B+0x08/B+0x20). Then re-open the SBC menu.
Expect: menu OPENS, no error modal, ~2 empty/placeholder tiles. This proves the gate +
isValid short-circuit LIVE — it does NOT prove data. If it CRASHES: stop — B
resolution/slide is wrong. Rollback: relaunch FIFA (byte clears on restart).
### Step 4 — Real fix (Tier-1) — proves the DATA (NOT for a blind run)
Do this only after the DLL populate is implemented. Preferred: bring up the bridge/core
`/sbs/sets` responder and let Option 1 (message-layer injection) drive the native chain;
the completion callback arms B and M fills. Then the same gate opens a POPULATED menu
(N+2 tiles). The hook module scaffold is `openfut-hook/src/sbc_hook.rs` — Tier-1
`populate_m()` is present but deliberately refuses to call the deser until the SAX
input-source reader is reversed (else it clears M and crashes). Build (when ready):
```
cd /home/alex/Documents/OpenFUT/openfut-launcher/openfut-hook && \
cargo build --release --features fifa17 --target x86_64-pc-windows-gnu
```
Deploy as `version.dll` per launcher setup. Env gates (all default OFF):
`OPENFUT_SBC_HOOK=1` (read-only resolve+log), `OPENFUT_SBC_ARM_ONLY=1` (Tier-0),
`OPENFUT_SBC_POPULATE=1` (Tier-1, currently logs the blocker and returns).
Rollback: unset env vars and relaunch FIFA.
### Step 5 — Cleanup
Unset all `OPENFUT_SBC_*` env vars; relaunch FIFA to a clean state.
---
## 4. Crash-risk assessment
1. **Cold-calling `0x18017b2b0` without a real seated cursor** — CLEARS M
(`0x18015f3a0`) first, then `mov rdi,[rdi]` byte-scan on a garbage ptr → wipes
state + segfault. HIGHEST. Tier-1 code refuses this until the reader is reversed.
2. **Writing `B+0x08`/`B+0x20`** — forces isValid into the QPC-deadline branch; stale
deadline → gate SHUTS (modal), or garbage-ptr iterate crash. Self-defeating.
Tool/code write ONLY `B+0x28`.
3. **Populate off the game thread / mid-iterate** — lazy getter allocates on game heap,
appender mutates EASTL vectors; a foreign thread races the allocator/menu iterate →
heap corruption. Tier-1 must run on the game/message-pump thread with the menu closed.
4. **Skipping the index-rebuild finalizers** (`0x180160e00/0x180160f30/0x180161020`)
after append → stale `+0x9e0/+0xa10/+0xa40` indices → by-index getter `0x180160a80`
reads OOB → crash/garbage tiles.
5. **`WORD[M+0x50]` > actual 0xf0-stride entries** → tile loop walks past vector end
(OOB read).
6. **Hand-built 0xf0/0x3570 structs fed to `0x18015a770`** — copy-ctor `0x18015a2b0`
deep-copies inner EASTL sub-vectors; bad begin/end/cap → heap corruption. Avoid.
7. **No refresh after populate** (non-crash) — controller keeps the cached empty M at
`ctrl+0x140`; `0x1800b5eda` won't re-run → still 2 placeholder tiles. Fire
`0x756c``0x7574` or re-open.
8. **Manager/store null** — deser does `mov rax,[rbx]` on the manager; registry lookup
(hashes `0xed84b11`/`0xed84b12`) returning null → null-deref. Live registry
`*[0x1802c2988]` non-null, so low risk; hook must still null-check M/store.
Tier-0 (single `B+0x28=1` write, B+0x08 left 0) is the verified-SAFE case: isValid
short-circuits to 1, renders empty, no crash; bg-thread-tolerant like the /proc poke.
---
## 5. Poke tool + DLL-spec locations
- Poke tool (read-only default; `--spec`; `--apply` = ONLY `BYTE[B+0x28]=1`):
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py`
- Negative-control byte poke (older, triple-guarded):
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_populate_poke.py`
- Slide/read template + FNV control proof:
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/gate_byte_probe.py`
- DLL integration spec (RVA math, object graph, gate disasm, function-signature table,
3 intervention tiers, 8-item crash register, staged test plan):
`/home/alex/Documents/OpenFUT/fifa17-recon/docs/sbc-hook-dll-spec.md`
- Injected-DLL module (fifa17-only; Tier-0 live, Tier-1 scaffolded/refusing):
`/home/alex/Documents/OpenFUT/openfut-launcher/openfut-hook/src/sbc_hook.rs`
(wired via `lib.rs` `#[cfg(feature="fifa17")] mod sbc_hook;` + `fifa17.rs`
`crate::sbc_hook::install();`)
- Atoms table: `/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv`
---
## Client-vs-server authority boundaries (flagged)
- **RENDER (M, tiles at `0x1800b5eda`)** — CLIENT. The client draws tiles solely from
M; the server never touches this. Fix is client-side.
- **POPULATE (deser `0x18017b2b0` → M)** — CLIENT parser, SERVER-fed data. The parser
is native and reusable; the DATA it needs (`/sbs/sets` JSON) is a server response.
Preferred fix has the bridge/core supply that JSON so the client parses it natively.
- **PREFETCH GATE (issue `GET sbs/sets`)** — SERVER/front-end. THE WALL. No native
flag; the SWF/ActionScript front-end refuses to request offline, and CardsDLL has no
native code that issues the GET (`ut/%s/sbs` unreferenced). This cannot be fixed
server-side by responding — the request is never sent. The hook must force the
populate (inject the response at the message layer or drive the parser).
- **READY-ARM (`B[+0x28]`, callback `0x1800b8c30`/commit `0x1800c21a0`)** — CLIENT.
Normally armed by the completion callback (server-response-driven); offline the hook
arms it (Tier-0 byte, or Option 1 lets the native callback arm it).