# 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.
