14 KiB
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]. Getter0x18011a830. A.vtable static0x18021c2a0. Live A =0xb83e2b60(vtable matches static — CONFIRMED). - B = SBC request/TTL gate cache =
A + 0x1f9d8. B-getter = A.vtable[+0x4e8] = thunk0x18011c1f0(lea rax,[rcx+0x1f9d8]; ret). B.vtable static0x1801fae70(3 slots: dtor0x180063040, isValid0x180065d40, clear0x180065d20). 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 getter0x18011b7d0(ifA[+0x20a68]==0it factory-creates an EMPTY M, type-id0x13f0, 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
0xed84b12resolver A.vtable[+0x18] =0x180113f50returnsthis, somanager.vtable[+0x9b0] == A.vtable[+0x9b0] == 0x18011b7d0. The old lead0x1801e9010is DEBUNKED — it is an.rdatafunction 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 writeB+0x08orB+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/setsJSON response at the message-receive layer so the game builds the response-msg (ctor0x18017b1c0, vtable0x18022e598, deser slot +0x20 =0x18017b2b0), seats a genuine SAX cursor, its OWN chain populates M, and the native completion callback0x1800b8c30arms 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+ lexer0x1801c8060+ an input-source whosevtable[+0x8]yields bytes), call deser0x18017b2b0(rcx=ignored, rdx=cursor), then arm ONLYBYTE[B+0x28]=1. Blocker: the input-sourcevtable[+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.
- Preferred (Option 1, cleanest, zero forged state): inject a canned
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
- Cold-calling
0x18017b2b0without a real seated cursor — CLEARS M (0x18015f3a0) first, thenmov rdi,[rdi]byte-scan on a garbage ptr → wipes state + segfault. HIGHEST. Tier-1 code refuses this until the reader is reversed. - 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 ONLYB+0x28. - 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.
- Skipping the index-rebuild finalizers (
0x180160e00/0x180160f30/0x180161020) after append → stale+0x9e0/+0xa10/+0xa40indices → by-index getter0x180160a80reads OOB → crash/garbage tiles. WORD[M+0x50]> actual 0xf0-stride entries → tile loop walks past vector end (OOB read).- Hand-built 0xf0/0x3570 structs fed to
0x18015a770— copy-ctor0x18015a2b0deep-copies inner EASTL sub-vectors; bad begin/end/cap → heap corruption. Avoid. - No refresh after populate (non-crash) — controller keeps the cached empty M at
ctrl+0x140;0x1800b5edawon't re-run → still 2 placeholder tiles. Fire0x756c–0x7574or re-open. - Manager/store null — deser does
mov rax,[rbx]on the manager; registry lookup (hashes0xed84b11/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= ONLYBYTE[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 vialib.rs#[cfg(feature="fifa17")] mod sbc_hook;+fifa17.rscrate::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/setsJSON) 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/sbsunreferenced). 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], callback0x1800b8c30/commit0x1800c21a0) — 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).