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

18 KiB
Raw Blame History

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 containing CardsDLL (see fifa17::dump_modules for the pattern).

  • Image base in the PE is 0x180000000. For any static VA in this doc:

    rva            = VA_static - 0x180000000
    VA_runtime     = cards_base + rva
    

    cards_base is the runtime HMODULE of CardsDLL_Win64_retail.dll (its in-memory load address). All the "0x180…" addresses below are static VAs; subtract 0x180000000 to get the RVA, add cards_base to get the live pointer/callable.

  • Slide-proof control (optional sanity, mirrors tools/gate_byte_probe.py): the FNV prologue at VA 0x180180d00 must match the on-disk PE bytes 48 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()                 ; -> &registry (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.

  1. Injection + resolution (read-only). Launch with OPENFUT_SBC_HOOK=1 only. The deferred thread should log: CardsDLL base + slide-control OK, then A=…, 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.
  2. 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).
  3. Tier-1 populate — BLOCKED. Do not enable until the SAX reader contract (§6) is reversed. OPENFUT_SBC_POPULATE=1 currently 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.
  4. 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. 0x18017b2b0 clears M first (0x18015f3a0 before 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-index 0x180160a80 mid-build → OOB/crash. Populate on the game thread with the menu closed, then refresh.
  • C3 — skipping finalizers. Any manual append via 0x18015a770 must be followed by 0x180160e00/0x180160f30/0x180161020 or the +0x9e0/+0xa10/+0xa40 indices go stale and by-index lookups read OOB.
  • C4 — hand-built 0xf0/0x3570 structs. Append's copy-ctor 0x18015a2b0 deep-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 only B+0x28, leave B+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 /proc poke 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.