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

14 KiB
Raw Blame History

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 (0x1800b554d0x1800b5571→store [rsi+0x140]), then registers Scaleform events 0x756c0x7574. 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 0x756c0x7574 (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 0x756c0x7574 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).