# 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).