feat: document and stage FIFA 17 SBC hook workflow

This commit is contained in:
funman300
2026-08-07 11:44:05 -07:00
parent a7e3e43ae9
commit 3d3239bab9
9 changed files with 1458 additions and 1 deletions
+337
View File
@@ -0,0 +1,337 @@
# 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.