238 lines
14 KiB
Markdown
238 lines
14 KiB
Markdown
# SBC Menu Render Intervention — Plan (2026-08-07)
|
||
|
||
**STATUS (one line): YES, WITH CAVEATS — a populated SBC menu is achievable via a
|
||
client-side hook, but ONLY by making the game's own parser fill its store; a
|
||
/proc/mem byte poke alone can open the menu (negative control) but renders EMPTY, and
|
||
the one remaining un-reversed item (the SAX input-source `vtable[+0x8]` byte-yield
|
||
contract) blocks the fully-offline populate until a served /sbs/sets response or a
|
||
completed reader is wired.**
|
||
|
||
All addresses are on-disk RVAs against CardsDLL image base `0x180000000`
|
||
(`/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll`, working copy `/tmp/fut/cardsdll.dll`).
|
||
Live slide this session = `0x6ffe7c140000` (mapped base `0x6ffffc140000`), proven via
|
||
FNV prologue at `0x180180d00`. Live values below are from read-only `/proc/12201/mem`.
|
||
|
||
---
|
||
|
||
## 1. Definitive SBC data-flow
|
||
|
||
### Object graph
|
||
- **A** = FUT root singleton = `*[0x1802e6398]`. Getter `0x18011a830`. A.vtable static
|
||
`0x18021c2a0`. Live A = `0xb83e2b60` (vtable matches static — CONFIRMED).
|
||
- **B** = SBC request/TTL gate cache = `A + 0x1f9d8`. B-getter = A.vtable[+0x4e8] =
|
||
thunk `0x18011c1f0` (`lea rax,[rcx+0x1f9d8]; ret`). B.vtable static `0x1801fae70`
|
||
(3 slots: dtor `0x180063040`, isValid `0x180065d40`, clear `0x180065d20`). Live B =
|
||
`0xb8402538` (vtable matches). **B is the GATE, not the render source.**
|
||
- **M** = SBC categories/sets store = `*(A + 0x20a68)`. Reached via A.vtable[+0x9b0] =
|
||
lazy getter `0x18011b7d0` (if `A[+0x20a68]==0` it factory-creates an EMPTY M, type-id
|
||
`0x13f0`, and caches it). Live M = `0x0` (never built this session — SBC menu not
|
||
opened). **M IS the render source.**
|
||
- The "SBC manager" is **A itself**: service-id `0xed84b12` resolver A.vtable[+0x18] =
|
||
`0x180113f50` returns `this`, so `manager.vtable[+0x9b0] == A.vtable[+0x9b0] ==
|
||
0x18011b7d0`. The old lead `0x1801e9010` is DEBUNKED — it is an `.rdata` function
|
||
pointer slot (`->0x18018577a`), not a manager global.
|
||
|
||
### Render source (CLIENT authority)
|
||
The SBC hub/squads controller (ctor `0x1800b5267`) caches M into `controller+0x140`
|
||
by calling A.vtable[+0x9b0] once (`0x1800b554d`→`0x1800b5571`→store `[rsi+0x140]`),
|
||
then registers Scaleform events `0x756c`–`0x7574`. The tile-build method (`0x1800b5e00`
|
||
region) reads `[ctrl+0x140]=M` and at **`0x1800b5eda`** does
|
||
`movzx ebx,WORD[M+0x50]; add bx,0x2; call [scaleform.vtable+0x58](count)` → emits
|
||
**(category_count + 2) tiles**. This region reads `[ctrl+0x140]` seven times and reads
|
||
B/`A+0x1fa00` **zero** times. M layout: cat count `WORD[M+0x50]`; cat vector
|
||
`[M+0x58]..[M+0x60]` stride `0xf0`; per-cat set count `WORD[cat+0xb8]`, set vector
|
||
`[cat+0xc0]` stride `0x3570`; secondary/featured vec `[M+0xa10]..[M+0xa18]`;
|
||
indices at `+0x9e0/+0xa10/+0xa40`. **Correction on record:** earlier passes that
|
||
called `B[+0x08]` the render source conflated the gate with the data source — the empty
|
||
render was because M was null/empty, NOT because `B[+0x08]` was null.
|
||
|
||
### Populate path (CLIENT authority)
|
||
The sbs/sets deserializer **`0x18017b2b0`** (rcx=this IGNORED; rdx=SAX cursor is the
|
||
only live input) does the whole populate: fetch manager → get store M via
|
||
`[manager.vtable+0x9b0]` (at `0x18017b327`) → clear `0x18015f3a0` → loop atom `0x6f`
|
||
"categories": per item ctor `0x180159da0` (0xf0, vtable `0x18021b520`), cat-deser
|
||
`0x18017ab80`, cat-finalize `0x180160e50`, APPEND `0x18015a770` (copy-ctor
|
||
`0x18015a2b0`), dtor `0x1801105d0` → after loop rebuild indices `0x180160e00` +
|
||
`0x180160f30` + `0x180161020` → commit `manager.vtable[+0x8]`. Always returns true.
|
||
Set-row deser `0x18017ad60`. **Populate-target == render-source (both are M).**
|
||
|
||
### Prefetch gate (SERVER/front-end authority — THE WALL)
|
||
There is **no native flag** to flip. The only native online check `0x1801642c0`
|
||
(inside isValid) is stubbed `mov al,1; ret` — NOT the wall. The block is upstream in
|
||
the Flash/ActionScript FUT front-end (FNV-name-hash bound; `RequestChallengeData` =
|
||
`0x1801f9b30`, `futsbchubviewmodel` = `0x1801ee0a0` — no native xref), which refuses to
|
||
issue `GET ut/game/fifa17/sbs/sets` offline, so deser `0x18017b2b0` never runs.
|
||
**Newly proven:** the URL template `"ut/%s/sbs"` (`0x18021d908`) has ZERO references
|
||
in the image (siblings `ut/%s/tournament`, `ut/%s/season` ARE referenced) — so
|
||
**CardsDLL has no native code that self-builds/issues the sbs GET.** This kills any
|
||
"force the req-mgr at A+0x2a0 to fetch on its own" idea. This is why the fix must be
|
||
client-side and must FORCE the populate.
|
||
|
||
### Ready-arm (CLIENT authority)
|
||
isValid `0x180065d40(B)` verified: `if !0x1801642c0() ret0` (stub→always passes);
|
||
`cmp [rbx+0x28],0; je fail`; **`cmp QWORD[rbx+0x8],0; je 0x180065d75` → returns 1
|
||
immediately (short-circuit)**; else QueryPerformanceCounter (`0x1801e50c0`) and compare
|
||
`[rbx+0x20]` deadline. Normally B is armed by the completion callback `0x1800b8c30`
|
||
(subscribed in svc ctor `0x1800b5765` via `manager.vtable[+0xa90]`) through the generic
|
||
cache copy-assign `0x1800c21a0` (sets B+0x08=collection, B+0x20=deadline, B+0x28=1).
|
||
Offline that callback never fires (no response). Live: `B[+0x08]=0`, `B[+0x28]=0`.
|
||
|
||
---
|
||
|
||
## 2. Chosen minimal intervention and WHY
|
||
|
||
**Reuse the client's own parser; do NOT hand-build structs; arm ONLY `B[+0x28]`.**
|
||
|
||
Two tiers, safest-first:
|
||
|
||
- **Tier-0 (negative control — proves the gate):** write ONLY `BYTE[B+0x28]=1`.
|
||
isValid short-circuits (B+0x08==0 branch) → menu OPENS instead of the error modal
|
||
(`0x18016c330`), but renders EMPTY (M is null/empty). Do NOT write `B+0x08` or
|
||
`B+0x20` — pointing B+0x08 at a collection forces isValid into the QPC-deadline
|
||
branch, and with the live-stale deadline (`0xf10fb8cb9`) the gate SHUTS → modal, i.e.
|
||
it DEFEATS the fix. This is the load-bearing correction from adversarial verification.
|
||
|
||
- **Tier-1 (real fix — populates M):**
|
||
- **Preferred (Option 1, cleanest, zero forged state):** inject a canned
|
||
`/sbs/sets` JSON response at the message-receive layer so the game builds the
|
||
response-msg (ctor `0x18017b1c0`, vtable `0x18022e598`, deser slot +0x20 =
|
||
`0x18017b2b0`), seats a genuine SAX cursor, its OWN chain populates M, and the
|
||
native completion callback `0x1800b8c30` arms B for you. The bridge/core serves the
|
||
JSON. Nothing forged.
|
||
- **Fallback (Option 2):** from the hook, stand up a real SAX cursor over canned JSON
|
||
(ctx `0x1801c63e0` + lexer `0x1801c8060` + an input-source whose `vtable[+0x8]`
|
||
yields bytes), call deser `0x18017b2b0(rcx=ignored, rdx=cursor)`, then arm ONLY
|
||
`BYTE[B+0x28]=1`. **Blocker:** the input-source `vtable[+0x8]` byte-yield contract
|
||
is the ONE un-reversed item — a cold call with a null-source cursor CLEARS M
|
||
(`0x18015f3a0`) then byte-scans a garbage pointer (`mov rdi,[rdi]` ~`0x18017b353`)
|
||
→ wipes state + segfault. So Option 2 is NOT safe to run until the reader is
|
||
reversed.
|
||
|
||
**Why not hand-build:** feeding `0x18015a770` a hand-built 0xf0 category (with nested
|
||
0x3570 set records / EASTL sub-vectors) is the highest crash risk — the copy-ctor
|
||
`0x18015a2b0` deep-copies inner sub-vectors; any bad begin/end/cap → heap corruption.
|
||
The parser writes the correct geometry AND runs the index-rebuild finalizers that
|
||
hand-built appends get wrong. Ruled out.
|
||
|
||
**Refresh:** after M is populated, fire refresh events `0x756c`–`0x7574` (or re-open the
|
||
menu) so `0x1800b5eda` re-reads `WORD[M+0x50]`.
|
||
|
||
---
|
||
|
||
## 3. STAGED MORNING TEST PLAN (safest-first)
|
||
|
||
Precondition: FIFA at the FUT hub with CardsDLL loaded. Rollback for EVERY step =
|
||
**relaunch FIFA** (all effects are volatile — single-byte poke or in-session hook state,
|
||
cleared on restart). NEVER run `--apply` while the SBC menu is open/mid-iterate.
|
||
|
||
### Step 1 — Dry-run read confirm (ZERO writes)
|
||
```
|
||
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py
|
||
```
|
||
Expect: CONTROL FNV MATCH; A vtable match; B offset decoded live = `0x1f9d8`; B/A vtables
|
||
match statics; `B+0x28=0`; `M=*(A+0x20a68)=0` (until SBC menu opened once).
|
||
PASS = addresses match the model. Rollback: none needed (read-only).
|
||
|
||
### Step 2 — Review the DLL populate spec (ZERO writes)
|
||
```
|
||
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py --spec
|
||
```
|
||
Expect: printed injected-DLL spec (Option 1 preferred, Option 2 fallback). Read-only.
|
||
|
||
### Step 3 — Negative control (Tier-0, ONE byte write) — proves the GATE
|
||
With the SBC menu **CLOSED**:
|
||
```
|
||
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py --apply
|
||
```
|
||
Writes exactly `BYTE[B+0x28]=1` (re-proves slide+vtables at write time; aborts on any
|
||
mismatch; hard-refuses to write B+0x08/B+0x20). Then re-open the SBC menu.
|
||
Expect: menu OPENS, no error modal, ~2 empty/placeholder tiles. This proves the gate +
|
||
isValid short-circuit LIVE — it does NOT prove data. If it CRASHES: stop — B
|
||
resolution/slide is wrong. Rollback: relaunch FIFA (byte clears on restart).
|
||
|
||
### Step 4 — Real fix (Tier-1) — proves the DATA (NOT for a blind run)
|
||
Do this only after the DLL populate is implemented. Preferred: bring up the bridge/core
|
||
`/sbs/sets` responder and let Option 1 (message-layer injection) drive the native chain;
|
||
the completion callback arms B and M fills. Then the same gate opens a POPULATED menu
|
||
(N+2 tiles). The hook module scaffold is `openfut-hook/src/sbc_hook.rs` — Tier-1
|
||
`populate_m()` is present but deliberately refuses to call the deser until the SAX
|
||
input-source reader is reversed (else it clears M and crashes). Build (when ready):
|
||
```
|
||
cd /home/alex/Documents/OpenFUT/openfut-launcher/openfut-hook && \
|
||
cargo build --release --features fifa17 --target x86_64-pc-windows-gnu
|
||
```
|
||
Deploy as `version.dll` per launcher setup. Env gates (all default OFF):
|
||
`OPENFUT_SBC_HOOK=1` (read-only resolve+log), `OPENFUT_SBC_ARM_ONLY=1` (Tier-0),
|
||
`OPENFUT_SBC_POPULATE=1` (Tier-1, currently logs the blocker and returns).
|
||
Rollback: unset env vars and relaunch FIFA.
|
||
|
||
### Step 5 — Cleanup
|
||
Unset all `OPENFUT_SBC_*` env vars; relaunch FIFA to a clean state.
|
||
|
||
---
|
||
|
||
## 4. Crash-risk assessment
|
||
|
||
1. **Cold-calling `0x18017b2b0` without a real seated cursor** — CLEARS M
|
||
(`0x18015f3a0`) first, then `mov rdi,[rdi]` byte-scan on a garbage ptr → wipes
|
||
state + segfault. HIGHEST. Tier-1 code refuses this until the reader is reversed.
|
||
2. **Writing `B+0x08`/`B+0x20`** — forces isValid into the QPC-deadline branch; stale
|
||
deadline → gate SHUTS (modal), or garbage-ptr iterate crash. Self-defeating.
|
||
Tool/code write ONLY `B+0x28`.
|
||
3. **Populate off the game thread / mid-iterate** — lazy getter allocates on game heap,
|
||
appender mutates EASTL vectors; a foreign thread races the allocator/menu iterate →
|
||
heap corruption. Tier-1 must run on the game/message-pump thread with the menu closed.
|
||
4. **Skipping the index-rebuild finalizers** (`0x180160e00/0x180160f30/0x180161020`)
|
||
after append → stale `+0x9e0/+0xa10/+0xa40` indices → by-index getter `0x180160a80`
|
||
reads OOB → crash/garbage tiles.
|
||
5. **`WORD[M+0x50]` > actual 0xf0-stride entries** → tile loop walks past vector end
|
||
(OOB read).
|
||
6. **Hand-built 0xf0/0x3570 structs fed to `0x18015a770`** — copy-ctor `0x18015a2b0`
|
||
deep-copies inner EASTL sub-vectors; bad begin/end/cap → heap corruption. Avoid.
|
||
7. **No refresh after populate** (non-crash) — controller keeps the cached empty M at
|
||
`ctrl+0x140`; `0x1800b5eda` won't re-run → still 2 placeholder tiles. Fire
|
||
`0x756c`–`0x7574` or re-open.
|
||
8. **Manager/store null** — deser does `mov rax,[rbx]` on the manager; registry lookup
|
||
(hashes `0xed84b11`/`0xed84b12`) returning null → null-deref. Live registry
|
||
`*[0x1802c2988]` non-null, so low risk; hook must still null-check M/store.
|
||
|
||
Tier-0 (single `B+0x28=1` write, B+0x08 left 0) is the verified-SAFE case: isValid
|
||
short-circuits to 1, renders empty, no crash; bg-thread-tolerant like the /proc poke.
|
||
|
||
---
|
||
|
||
## 5. Poke tool + DLL-spec locations
|
||
|
||
- Poke tool (read-only default; `--spec`; `--apply` = ONLY `BYTE[B+0x28]=1`):
|
||
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py`
|
||
- Negative-control byte poke (older, triple-guarded):
|
||
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_populate_poke.py`
|
||
- Slide/read template + FNV control proof:
|
||
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/gate_byte_probe.py`
|
||
- DLL integration spec (RVA math, object graph, gate disasm, function-signature table,
|
||
3 intervention tiers, 8-item crash register, staged test plan):
|
||
`/home/alex/Documents/OpenFUT/fifa17-recon/docs/sbc-hook-dll-spec.md`
|
||
- Injected-DLL module (fifa17-only; Tier-0 live, Tier-1 scaffolded/refusing):
|
||
`/home/alex/Documents/OpenFUT/openfut-launcher/openfut-hook/src/sbc_hook.rs`
|
||
(wired via `lib.rs` `#[cfg(feature="fifa17")] mod sbc_hook;` + `fifa17.rs`
|
||
`crate::sbc_hook::install();`)
|
||
- Atoms table: `/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv`
|
||
|
||
---
|
||
|
||
## Client-vs-server authority boundaries (flagged)
|
||
|
||
- **RENDER (M, tiles at `0x1800b5eda`)** — CLIENT. The client draws tiles solely from
|
||
M; the server never touches this. Fix is client-side.
|
||
- **POPULATE (deser `0x18017b2b0` → M)** — CLIENT parser, SERVER-fed data. The parser
|
||
is native and reusable; the DATA it needs (`/sbs/sets` JSON) is a server response.
|
||
Preferred fix has the bridge/core supply that JSON so the client parses it natively.
|
||
- **PREFETCH GATE (issue `GET sbs/sets`)** — SERVER/front-end. THE WALL. No native
|
||
flag; the SWF/ActionScript front-end refuses to request offline, and CardsDLL has no
|
||
native code that issues the GET (`ut/%s/sbs` unreferenced). This cannot be fixed
|
||
server-side by responding — the request is never sent. The hook must force the
|
||
populate (inject the response at the message layer or drive the parser).
|
||
- **READY-ARM (`B[+0x28]`, callback `0x1800b8c30`/commit `0x1800c21a0`)** — CLIENT.
|
||
Normally armed by the completion callback (server-response-driven); offline the hook
|
||
arms it (Tier-0 byte, or Option 1 lets the native callback arm it).
|