338 lines
18 KiB
Markdown
338 lines
18 KiB
Markdown
# 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.
|