18 KiB
SBC render intervention — injected-DLL integration spec
Goal: make the FIFA 17 FUT SBC menu render real SBC data from inside the
process (client-side), proven not server-fixable. The DLL is the existing
openfut-hook (version.dll, cross-compiled x86_64-pc-windows-gnu, feature
fifa17). In-process calls to client functions are safe here (unlike /proc/mem
writes), because we run on the game's own threads with the real allocator.
Binary of record (clean-room): /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll
(on-disk copy /tmp/fut/cardsdll.dll), PE image base 0x180000000. Every address
below was re-verified byte-exact against this PE in this pass (vtable slots read from
.rdata, prologues from .text). Do not build/deploy from this spec without the
staged morning test (§9).
1. Module base + RVA math
CardsDLL is not present at DllMain/worker time — the boot module dump
(C:\openfut_hook.log) has no CardsDLL* entry. It is loaded lazily only when the
user first enters Ultimate Team. Therefore the hook must defer and poll for it,
exactly like probe::install_probes_deferred polls for anadius64.dll.
-
Loaded module name (Wine keeps the on-disk filename):
CardsDLL_Win64_retail.dll.GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0"). Fallback: ToolHelp module walk matching a name containingCardsDLL(seefifa17::dump_modulesfor the pattern). -
Image base in the PE is
0x180000000. For any static VA in this doc:rva = VA_static - 0x180000000 VA_runtime = cards_base + rvacards_baseis the runtimeHMODULEofCardsDLL_Win64_retail.dll(its in-memory load address). All the "0x180…" addresses below are static VAs; subtract0x180000000to get the RVA, addcards_baseto get the live pointer/callable. -
Slide-proof control (optional sanity, mirrors
tools/gate_byte_probe.py): the FNV prologue at VA0x180180d00must match the on-disk PE bytes48 83 ec 28 48 85 c9 74 50 45 33 c0 ba c5 9d 1c 81 …. If it does not, abort — the module map moved and the offsets are untrustworthy.
2. Verified object graph
A = FUT root singleton = *(0x1802e6398) getter thunk 0x18011a830 = { mov rax,[rip→0x1802e6398]; ret }
A.vtable (live [A]) = static 0x18021c2a0
A.vtable[+0x4e8] = 0x18011c1f0 = { lea rax,[rcx+0x1f9d8]; ret } -> B getter
A.vtable[+0x9b0] = 0x18011b7d0 = M lazy getter (see §3) -> M getter
A.vtable[+0x18] = 0x180113f50 = service-id 0xed84b12 -> returns `this` (proves manager == A)
B = SBC request/ready TTL cache = A + 0x1f9d8 vtable static 0x1801fae70
B+0x08 collection ptr (live 0 offline)
B+0x20 QPC deadline
B+0x28 ready byte (== A+0x1fa00 alias) <- the isValid gate byte
B.vtable[+0x00] dtor = 0x180063040
B.vtable[+0x08] isValid = 0x180065d40 (see §4)
B.vtable[+0x10] clear = 0x180065d20
M = SBC categories/sets store = *(A + 0x20a68) <- THE RENDER SOURCE (see §3, §5)
M+0x50 WORD category count
M+0x58 cat-vector begin (element stride 0xf0)
M+0x60 cat-vector end
M+0xa10 secondary/featured vec begin (8-byte elems) (emptiness-checked at render)
M+0xa18 secondary vec end
per category (+0xf0 stride):
cat+0xb8 WORD set count
cat+0xc0 set-vector begin (element stride 0x3570)
set+0x1c9 byte per-set flag
HUB cache (works online) is the same class at A + 0x1fd70 (vtable 0x18021c1e0)
— reference only.
Manager fetch used by BOTH the deser and the render controller (so populate-target == render-source):
reg = 0x1800d7170() ; -> ®istry (static 0x1802c2988)
mgr = 0x180009c80(&out, reg) ; out = manager (hashes 0xed84b11 / 0xed84b12)
M = mgr.vtable[+0x9b0](mgr) ; 0x18011b7d0, lazily creates/returns *(A+0x20a68)
Because svc-id 0xed84b12 resolves to A (A.vtable[+0x18] returns this),
mgr == A and mgr.vtable[+0x9b0] == A.vtable[+0x9b0] == 0x18011b7d0. The hook may
therefore fetch M the short way — A = *(0x1802e6398); M = (*(void***)A)[0x9b0/8](A) —
or the long way (registry) — they return the identical object.
3. M lazy getter — 0x18011b7d0 (verified disassembly)
18011b7d0 push rbx; push rdi; sub rsp,0x38
18011b7e0 mov rdi,rcx ; rcx = A (this)
18011b7e3 cmp QWORD [rcx+0x20a68],0 ; M already built?
18011b7eb jne 18011b873 ; yes -> return it
18011b7f1 call 0x18019e3c0 ; factory: allocate an EMPTY M (type-id 0x13f0)
… … ; init fields, cache at A+0x20a68, return
Cold-calling this alone creates an EMPTY M (WORD[M+0x50]==0) → the menu draws
2 placeholder tiles (count+2). It does not populate. Populating is §5.
4. The gate — isValid 0x180065d40 (verified disassembly)
180065d40 push rbx; sub rsp,0x20; mov rbx,rcx ; rcx = B
180065d49 call 0x1801642c0 ; online sub-check — STUBBED `mov al,1;ret`
180065d4e test al,al ; je fail ; never the wall
180065d52 cmp BYTE [rbx+0x28],0 ; je fail ; <-- READY BYTE gate
180065d58 cmp QWORD [rbx+0x8],0 ; je 0x180065d75 ; <-- if collection==0 -> RETURN 1 (short-circuit)
180065d5f lea rcx,[rsp+0x38]; call [rip→0x1801e50c0]; QueryPerformanceCounter
180065d6a mov rax,[rbx+0x20]; sub rax,[rsp+0x38] ; deadline - now
180065d73 js fail ; past deadline -> fail
180065d75 mov al,1 ; …; ret ; success
Load-bearing correction (adversarially confirmed, verified in this pass): arm
only BYTE[B+0x28]=1 and leave QWORD[B+0x08]=0. With B+0x08==0 the function
takes the je 0x180065d75 short-circuit and returns 1 immediately. If you instead
write B+0x08 (pointing it at the collection), isValid falls into the QPC-deadline
branch; with the live-stale deadline (B+0x20 = 0xf10fb8cb9, already in the past) it
returns 0 → modal → gate SHUTS. So never manually write B+0x08 or B+0x20.
Rendering reads M (§5), not B+0x08, so nothing needs B+0x08 set.
5. Render source — M, not B (verified disassembly)
Controller ctor caches M into controller+0x140:
1800b554d call 0x1800d7170 ; reg
1800b555d call 0x180009c80 ; mgr = out
1800b556b mov rax,[rbx] ; mgr.vtable
1800b5571 call [rax+0x9b0] ; M = 0x18011b7d0(mgr)
1800b5577 mov [rsi+0x140], rax ; controller+0x140 = M
…then registers Scaleform events 0x756c-0x7574 via 0x1801a4a70
Tile-count emit (each menu build):
1800b5eda mov rax,[r13+0x140] ; rax = M
1800b5ee1 movzx ebx,WORD [rax+0x50] ; ebx = category count
1800b5ee5 add bx,0x2 ; +2 placeholder tiles
1800b5ee9 mov rax,[r15] ; Scaleform model vtable
call [rax+0x58](count) ; push (category_count + 2) list tiles
Helper thunks (verified): 0x18015fff0 = lea rax,[rcx+0x58] (&M cat-vector),
0x1801607e0 = lea rax,[rcx+0xa10] (&M secondary vector). Zero reads of
B/A+0x1f9d8/A+0x1fa00 exist in the tile-build region — B is purely the entry
gate. Populate M ⇒ tiles appear.
6. Populate path — reuse the real parser (deser 0x18017b2b0)
The category rows are appended only by the sbs/sets deserializer. Its geometry and
finalizers are the correct way to fill M (hand-building 0xf0/0x3570 structs is
brittle and rejected — §8).
18017b2b0 (rcx = this, IGNORED) (rdx = a PRIMED SAX reader over the token stream)
18017b2ef mov rdi,rdx ; keeps the incoming reader in rdi (the byte source)
18017b2fb call 0x1801c63e0(&localctx, 0, 0) ; builds a SECONDARY ctx with a NULL source
18017b309 call 0x1800d7170 ; reg
18017b316 call 0x180009c80 ; mgr
18017b327 call [mgr.vtable+0x9b0] ; M (0x18011b7d0)
… clear 0x18015f3a0(M) ; ALWAYS clears M first (see crash risk C1)
… loop atom 0x6f "categories":
0x180159da0(&tmp) ; cat ctor (0xf0, vtable 0x18021b520)
0x18017ab80(&tmp, reader) ; cat deser (needs the reader)
0x180160e50(&tmp) ; cat finalize (set index)
0x18015a770(M, &tmp) ; APPEND (copy-in; copy-ctor 0x18015a2b0)
0x1801105d0(&tmp) ; cat dtor
… 0x180160e00(M); 0x180160f30(M); 0x180161020(M) ; rebuild M indices (+0x9e0/+0xa10/+0xa40)
… commit mgr.vtable[+0x8](mgr)
18017b751 ret (always true)
The reader (rdx) is the crux. The deser does not ingest rdx through the
0x1801c63e0 ctx it builds (that one is created with a NULL source, rdx=0/r8=0);
instead it keeps the incoming rdx in rdi and scans its bytes directly (e.g. the
NUL-terminated backslash-unescape at ~0x18017b353 does mov rdi,[rdi]). So rdx
must be a fully-constructed, already-primed SAX reader/cursor object seated over
your canned sbs/sets JSON — the same object type the message framework produces on a
real response. Building that reader from scratch is the one remaining un-reversed
contract (its vtable, and specifically the [+0x8] byte-yield slot, are not yet
pinned). Until it is, the fully-offline parser-reuse call is not turnkey — see the
three tiers in §7.
SAX primitives already known (for when the reader is reconstructed): ctx init
0x1801c63e0(rcx=ctx,rdx=source,r8=flags), lexer 0x1801c8060, next-token
0x1801c7f10, begin-object 0x1801c8270, INT 0x1801c79d0, STR 0x1801c7aa0,
BOOL 0x1801c7620, SKIP 0x180135ff0.
Response-msg object (for the message-layer tier): ctor 0x18017b1c0 installs vtable
0x18022e598; slot [+0x20] == 0x18017b2b0 (deser) — verified. Constructing this
object alone still does not seat the reader (the framework does that from received
bytes), so it doesn't remove the reader gap.
7. Three intervention tiers (implement in this order)
Tier 0 — arm-only negative control (SAFE, non-crash, renders EMPTY).
Resolve A→B, write BYTE[B+0x28]=1, leave B+0x08=0. isValid short-circuits true, the
menu opens and draws 2 placeholder tiles (M empty/null). Proves the gate model live
without any populate. This is the first morning step and the baseline. Implemented and
env-gated in sbc_hook.rs (OPENFUT_SBC_ARM_ONLY=1).
Tier 1 — parser-reuse populate (the intended fix, BLOCKED on the reader).
On the game thread: build a primed SAX reader over canned sbs/sets JSON served by the
bridge/core, call 0x18017b2b0(rcx=0, rdx=reader) (self-locates mgr, clears, appends,
finalizes, commits → fills M), then Tier-0 arm (BYTE[B+0x28]=1 only), then trigger a
menu refresh (§ below). Cannot be enabled until the reader contract (§6) is
reversed. sbc_hook.rs contains the guarded scaffold that logs the blocker and returns
— it does not call the deser with a fabricated reader (that would clear M and/or
crash — C1/C6).
Tier 2 — message-layer injection (cleanest long-term, feasibility unproven).
Push a canned sbs/sets response through the real receive path so the framework builds
the response-msg (0x18017b1c0), seats the reader itself, runs 0x18017b2b0, fires the
completion callback (0x1800b8c30, subscribed in svc ctor 0x1800b5765 via
mgr.vtable[+0xa90]), and arms B natively (generic copy-assign 0x1800c21a0) — zero
forged state. Requires reconstructing the message-receive entry + response-msg wiring;
treat as the target, not the default.
Refresh trigger (Tier 1/2): the controller re-reads WORD[M+0x50] at 0x1800b5eda
on every build, so re-opening the SBC menu suffices. Programmatic alternative: fire
Scaleform refresh events 0x756c-0x7574 via 0x1801a4a70. If M is populated but no
refresh fires and the controller already cached an empty M at ctrl+0x140, you still see
2 placeholder tiles (no crash, just no data) — see C7.
8. Function signatures (Win64 extern "system"; rcx, rdx, r8, r9 → rax)
| Purpose | Static VA | Signature (Rust unsafe extern "system") |
|---|---|---|
| A getter thunk | 0x18011a830 | fn() -> *mut u8 (returns *(0x1802e6398)) |
| B getter (via A vtable +0x4e8) | 0x18011c1f0 | fn(a: *mut u8) -> *mut u8 (a+0x1f9d8) |
| M lazy getter (A vtable +0x9b0) | 0x18011b7d0 | fn(mgr: *mut u8) -> *mut u8 (*(mgr+0x20a68), lazily built) |
| isValid (B vtable +0x08) | 0x180065d40 | fn(b: *mut u8) -> bool |
| registry getter | 0x1800d7170 | fn() -> *mut u8 |
| manager getter | 0x180009c80 | fn(out: *mut *mut u8, reg: *mut u8) -> *mut u8 |
| sbs/sets deser (whole) | 0x18017b2b0 | fn(this_ignored: *mut u8, reader: *mut u8) -> bool |
| SAX ctx init | 0x1801c63e0 | fn(ctx: *mut u8, source: *mut u8, flags: u64) -> *mut u8 |
| clear M | 0x18015f3a0 | fn(m: *mut u8) |
| cat ctor (0xf0) | 0x180159da0 | fn(tmp: *mut u8) -> *mut u8 |
| cat deser | 0x18017ab80 | fn(tmp: *mut u8, reader: *mut u8) -> bool |
| cat finalize | 0x180160e50 | fn(tmp: *mut u8) |
| append into M | 0x18015a770 | fn(m: *mut u8, tmp: *mut u8) |
| cat dtor | 0x1801105d0 | fn(tmp: *mut u8) |
| M index rebuild ×3 | 0x180160e00 / 0x180160f30 / 0x180161020 | fn(m: *mut u8) each |
| QueryPerformanceCounter thunk | 0x1801e50c0 | (indirect; not needed if B+0x08 left 0) |
| Scaleform refresh dispatch | 0x1801a4a70 | fn(ctrl: *mut u8, event_id: u32, …) (event ids 0x756c-0x7574) |
M is per-session heap — never hardcode its address; always go A → A.vtable[+0x9b0].
9. Staged morning test plan (human, live)
Preconditions: FIFA 17 at the FUT hub (so CardsDLL is loaded). One env var flips each
tier; all default off/inert. Watch C:\openfut_hook.log.
- Injection + resolution (read-only). Launch with
OPENFUT_SBC_HOOK=1only. The deferred thread should log: CardsDLL base + slide-control OK, thenA=…,B=…,B+0x28=0,M=*(A+0x20a68)=…(0 until the SBC menu is opened once). No writes. Pass: addresses match the model; control FNV OK. - Tier-0 arm-only (negative control). Add
OPENFUT_SBC_ARM_ONLY=1. Open the SBC menu. Expected: menu opens, draws ~2 empty placeholder tiles, no modal, no crash. Confirms the gate byte and short-circuit live. If it crashes → stop (means B resolution is wrong; recheck slide). - Tier-1 populate — BLOCKED. Do not enable until the SAX reader contract (§6)
is reversed.
OPENFUT_SBC_POPULATE=1currently only logs the blocker and returns. Next RE session: pin the reader vtable ([+0x8]byte-yield) and the reader ctor, then wire the §6 sequence and re-test on the game thread with the menu closed, then re-open to refresh. - Revert env vars to unset when done.
10. Crash-risk register (verified against the PE + prior adversarial passes)
- C1 — cold-calling deser without a real reader.
0x18017b2b0clears M first (0x18015f3a0before any append). A null/garbage reader → parses nothing but wipes M (renders empty, destroys prior state), and the byte-scan at~0x18017b353(mov rdi,[rdi]) segfaults on a bad pointer. This is exactly why Tier 1 is gated off. - C2 — clear/finalize race. Deser clears then rebuilds M's vectors+indices; if the
render thread reads
WORD[M+0x50](0x1800b5eda) or by-index0x180160a80mid-build → OOB/crash. Populate on the game thread with the menu closed, then refresh. - C3 — skipping finalizers. Any manual append via
0x18015a770must be followed by0x180160e00/0x180160f30/0x180161020or the+0x9e0/+0xa10/+0xa40indices go stale and by-index lookups read OOB. - C4 — hand-built
0xf0/0x3570structs. Append's copy-ctor0x18015a2b0deep-copies EASTL sub-vectors; a bad begin/end/cap → heap corruption. Rejected (§8): drive the real parser instead. - C5 — writing
B+0x08/B+0x20. Forces isValid into the deadline branch; the live-stale deadline shuts the gate → modal. Set onlyB+0x28, leaveB+0x08=0. - C6 — null manager/M. Deser does
mov rax,[mgr]; if the registry lookup returned null it's a null-deref. Live registry*(0x1802c2988)is non-null offline, but the hook must null-check A, mgr, M before any use. - C7 — no refresh (non-crash). Populate without firing refresh / re-open → controller keeps its cached empty M → still 2 placeholder tiles. Fails the goal, not a crash.
- C8 — foreign-thread allocation. The lazy getter and appenders allocate on / mutate
the game heap; running them off the main/render thread races the allocator. Execute the
populate on a game thread (message-pump / a game-thread detour), not a bg thread. The
Tier-0 single-byte arm is tolerant of a bg write (it's what the
/procpoke does), but populate is not.
11. Live-probe baseline (this pass, read-only O_RDONLY, zero writes)
FIFA17.exe was running at spec time (pid 12201), CardsDLL mapped. Fresh live reads this pass match the static model 1:1:
slide 0x6ffe7c140000 CONTROL FNV OK
A 0xb83e2b60 (= *(0x1802e6398))
B 0xb8402538 vt=0x1801fae70 (matches static) B+0x08(coll)=0 B+0x20=0xf10fb8cb9 B+0x28(ready)=0
HUB 0xb84028d0 vt=0x18021c1e0 coll=0 ready=0 (reference only)
M *(A+0x20a68)=0 (SBC menu not opened this session -> M not yet built)
So live: gate SHUT (B+0x28=0), collection null, M null — Tier-0 arm alone would
render empty (matches the model). All §2–§6 addresses + all vtable slots were
re-verified byte-exact against the on-disk PE in this pass.