# 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() ; -> ®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`. 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.