Files
OpenFUT/fifa17-recon/docker/fifa17-python/tools/sbc_hook_poke.py
T
root 70a64e3709 fifa17-python: commit working FUT backend deployment (client/server split)
Freeze the running offline FUT backend into version control as
fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a
fresh checkout:

* OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders
  (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is
  required for remote mode (compose and entrypoint fail without it)
* docker-compose.yml reproducing the frozen baseline container exactly
  (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart)
* .env.example / .env for site config - the LAN IP is never hardcoded in source
* tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10,
  verified byte-identical to the running container at freeze time
* client_arm.sh (the 105 client-side arming counterpart)
* Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying
* docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record,
  restore instructions and rebuild-equivalence procedure

Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored.
The live container is untouched pending the .105 launcher audit.
2026-08-10 23:54:04 +00:00

291 lines
14 KiB
Python

#!/usr/bin/env python3
"""SBC menu render probe + minimal gate-arm poke (FIFA 17 CardsDLL).
WHAT THIS DOES
--------------
READ-ONLY BY DEFAULT. With no flags it opens /proc/<pid>/mem O_RDONLY, proves the
CardsDLL slide against the on-disk FNV prologue, and reports the exact live state of
the SBC data flow so the human can see whether a poke would render anything:
A = FUT root singleton = *(0x1802e6398) (vtable static 0x18021c2a0)
B = SBC request/TTL cache = A + 0x1f9d8 (vtable static 0x1801fae70)
B+0x08 collection ptr, B+0x20 QPC deadline, B+0x28 READY byte (the gate)
B offset 0x1f9d8 is DECODED live from A.vtable[+0x4e8] thunk
(48 8d 81 <disp32> = lea rax,[rcx+disp32]), not taken on faith.
M = SBC categories store = *(A + 0x20a68) (THE RENDER SOURCE)
lazy getter A.vtable[+0x9b0] = 0x18011b7d0; category count = WORD[M+0x50];
category vector M+0x58..M+0x60 (stride 0xf0). The SBC menu draws
WORD[M+0x50] + 2 tiles. M is NULL until the menu is opened (lazily built,
empty offline) or the sbs/sets response is parsed.
HUB = sibling cache = A + 0x1fd70
SBC req-mgr = A + 0x2a0
THE VERIFIED GATE (proven byte-exact against the shipped DLL, isValid 0x180065d40):
call 0x1801642c0 ; online sub-check -- STUBBED `mov al,1; ret`, not the wall
cmp BYTE[rbx+0x28],0 ; je fail ; <-- the READY gate
cmp QWORD[rbx+0x8],0 ; je RET_1 ; <-- SHORT-CIRCUIT: coll==0 => return 1
<QPC deadline compare> ; only reached when B+0x08 != 0
So isValid returns TRUE with B+0x28=1 AND B+0x08=0 (short-circuit). Writing B+0x08
forces the deadline branch; with a stale/past B+0x20 that returns 0 -> the error
modal. That is why this tool NEVER writes B+0x08 or B+0x20 -- doing so can DEFEAT
the fix and is a crash risk if the pointer is not a real EASTL collection.
THE INTERVENTION THIS TOOL CAN APPLY (--apply)
----------------------------------------------
The ONLY write blessed by adversarial verification as non-crashing from a bare
/proc/mem poke is:
BYTE[B+0x28] = 1 (arm the SBC ready gate; leave B+0x08 and B+0x20 alone)
This OPENS the SBC menu (isValid short-circuits to true) instead of the error modal.
It renders EMPTY (2 placeholder tiles) unless M is populated, because tiles come from
WORD[M+0x50], not from B. It is the proven-safe NEGATIVE CONTROL / gate-open step.
WHY A POPULATED MENU NEEDS THE INJECTED DLL, NOT THIS TOOL
----------------------------------------------------------
Populating M means running the client's OWN parser (deser 0x18017b2b0) over a real
sbs/sets response, so it clears+builds M with the correct 0xf0/0x3570 geometry and
rebuilds the indices. That requires executing code IN-PROCESS (the openfut-hook DLL)
or serving GET ut/game/fifa17/sbs/sets through the bridge so the native completion
path populates M and arms B for you. A /proc/mem byte poke cannot build M's nested
EASTL vectors safely (hand-building 0xf0/0x3570 records is the highest-crash option
all three verifiers rejected), and it cannot call the deser with a seated SAX cursor.
Cold-calling the deser with a null cursor WIPES M (clear runs before append) and
parses nothing. So: this tool arms the gate; the DLL (spec printed by --spec) does
the populate. See docs and the openfut-hook integration notes.
RISK / SAFETY
-------------
* Default run = READ ONLY. Nothing here writes unless you pass --apply.
* --apply WRITES LIVE GAME MEMORY (/proc/<pid>/mem O_WRONLY): one byte, B+0x28=1.
Do this only on a client sitting in the FUT hub, ideally with the SBC menu CLOSED
(never mutate while the menu is mid-iterate). Then re-open the SBC menu to render.
* --apply re-proves the slide AND re-verifies B.vtable == static 0x1801fae70 before
writing, and aborts on any mismatch. It refuses to write anything but B+0x28.
* If FIFA17.exe is not running or CardsDLL is not mapped, the tool says so and exits
0 -- static analysis is authoritative; live steps are best-effort.
USAGE
python3 sbc_hook_poke.py # read-only probe + dry-run plan (default)
python3 sbc_hook_poke.py --spec # also print the injected-DLL populate spec
python3 sbc_hook_poke.py --apply # WRITE BYTE[B+0x28]=1 (arm gate) -- HUMAN ONLY
"""
import os, struct, sys
# ---- static VAs (image base 0x180000000; add live slide) --------------------
A_SINGLETON = 0x1802e6398 # slot holding A = FUT root singleton ptr
CTRL_VA = 0x180180d00 # FNV atom-hash prologue used to prove the slide
A_VT_STATIC = 0x18021c2a0 # A.vtable (verify live == this + slide)
B_VT_STATIC = 0x1801fae70 # B.vtable (verify live == this + slide)
A_VT_BGETTER = 0x4e8 # A.vtable slot -> thunk lea rax,[rcx+0x1f9d8]
A_VT_MGETTER = 0x9b0 # A.vtable slot -> M lazy getter 0x18011b7d0
M_CACHE_OFF = 0x20a68 # M cache slot on A (decoded from getter cmp)
HUB_OFF = 0x1fd70
REQMGR_OFF = 0x2a0
ISVALID_VA = 0x180065d40
ONLINE_STUB = 0x1801642c0 # expect b0 01 c3 (mov al,1; ret)
B_READY_OFF = 0x28
PE_PATHS = ['/tmp/fut/cardsdll.dll', '/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll']
def find_pid():
for d in os.listdir('/proc'):
if d.isdigit():
try:
if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
return int(d)
except Exception:
pass
return None
def load_pe():
for p in PE_PATHS:
try:
return open(p, 'rb').read()
except Exception:
continue
return None
def print_spec():
print("""
== INJECTED-DLL POPULATE SPEC (openfut-hook / version.dll) ===================
The poke tool arms the gate; the DLL must POPULATE M. Preferred, lowest-risk,
zero-forged-state path (run ON THE GAME MAIN/UI THREAD, SBC menu CLOSED):
Option 1 (best) -- serve the response, let the native chain do everything:
Route GET ut/game/fifa17/sbs/sets through the bridge/core with real JSON.
The client's own dispatcher builds the response-msg (ctor 0x18017b1c0,
deser slot +0x20 = 0x18017b2b0), seats a genuine SAX cursor, and its own
chain populates M and arms B via the completion callback 0x1800b8c30
(subscribed in svc ctor 0x1800b5765). No memory forging at all. NOTE: the
front-end refuses to ISSUE the fetch offline and the "ut/%s/sbs" template
(0x18021d908) has no native xref, so the DLL must inject the RESPONSE at the
message-receive layer (not rely on the client to send the GET).
Option 2 (fallback) -- drive the real parser from the hook:
1. reg = 0x1800d7170() ; -> &registry 0x1802c2988
2. mgr = 0x180009c80(&out, reg) ; hashes 0xed84b11/0xed84b12
3. build a REAL seated SAX cursor over canned sbs/sets JSON:
ctx = 0x1801c63e0(...) + lexer 0x1801c8060 + an input-source object
whose vtable[+0x8] yields your JSON bytes. A null-source cursor parses
nothing AND the deser clears M first -> do not cold-call with null.
4. 0x18017b2b0(rcx=ignored, rdx=cursor) ; self-locates mgr, clears M,
per-cat ctor 0x180159da0 / cat-deser 0x18017ab80 / finalize 0x180160e50 /
append 0x18015a770, then store finalizers 0x180160e00 + 0x180160f30 +
0x180161020, then commit mgr.vtable[+0x8]. Sets WORD[M+0x50]=N.
5. arm gate: A.vtable[+0x4e8](A) -> B; set ONLY BYTE[B+0x28]=1.
Do NOT write B+0x08 or B+0x20 (short-circuit; see isValid proof).
6. trigger render: re-open the SBC menu, or fire refresh events 0x756c-0x7574
so the controller re-reads WORD[M+0x50] at 0x1800b5eda.
DO NOT: hand-build 0xf0 category / 0x3570 set records for a direct append
(deep-copy ctor 0x18015a2b0 derefs inner EASTL sub-vectors -> heap corruption);
skip the index-rebuild finalizers (by-index getter 0x180160a80 reads OOB);
mutate M while the menu iterates; or run any of this off the main thread.
=============================================================================
""")
def main():
apply = '--apply' in sys.argv
if '--spec' in sys.argv:
print_spec()
pid = find_pid()
if not pid:
print("FIFA17.exe not running -> skipping live steps. Static analysis is "
"authoritative; no write possible. (see --spec for the DLL plan)")
return 0
print("pid %d" % pid)
base = None
for ln in open('/proc/%d/maps' % pid):
if 'CardsDLL' in ln:
base = int(ln.split('-')[0], 16)
break
if not base:
print("CardsDLL not mapped (client not in Ultimate Team yet). Skip live step.")
return 0
slide = base - 0x180000000
print("base %#x slide %#x" % (base, slide))
fdr = os.open('/proc/%d/mem' % pid, os.O_RDONLY)
rd = lambda va, n: os.pread(fdr, n, va)
q = lambda va: struct.unpack('<Q', rd(va, 8))[0]
w = lambda va: struct.unpack('<H', rd(va, 2))[0]
# ---- prove the slide against the on-disk FNV prologue --------------------
pe = load_pe()
if pe is None:
print("on-disk DLL not found (%s); cannot prove slide -> refuse." % PE_PATHS)
os.close(fdr); return 1
f = lambda va: va - 0x180000000 - 0x1000 + 0x400 # .text rva 0x1000 rawptr 0x400
ctl_ok = pe[f(CTRL_VA):f(CTRL_VA)+24] == rd(CTRL_VA + slide, 24)
print("CONTROL FNV %s" % ("MATCH" if ctl_ok else "MISMATCH -> ABORT"))
if not ctl_ok:
os.close(fdr); return 1
# ---- prove the two gate facts from on-disk bytes ------------------------
online_stub = pe[f(ONLINE_STUB):f(ONLINE_STUB)+3]
print("online sub-check 0x1801642c0 on-disk = %s %s"
% (online_stub.hex(), "(stubbed mov al,1;ret -- NOT the wall)"
if online_stub == b'\xb0\x01\xc3' else "(UNEXPECTED)"))
# ---- A root + vtable ----------------------------------------------------
A = q(A_SINGLETON + slide)
A_vt = q(A) - slide
print("A(FUT root) = %#x A.vtable %#x %s"
% (A, A_vt, "(match)" if A_vt == A_VT_STATIC else "(MISMATCH static %#x)" % A_VT_STATIC))
# ---- decode B offset live from A.vtable[+0x4e8] thunk -------------------
bthunk = q((A_vt + slide) + A_VT_BGETTER) # A.vtable slot -> thunk VA (live)
stub = rd(bthunk, 7)
b_off = None
if stub[:3] == b'\x48\x8d\x81': # lea rax,[rcx+disp32]
b_off = struct.unpack('<i', stub[3:7])[0]
print("A.vtable[+0x4e8] -> %#x stub=%s decoded B offset=%s"
% (bthunk - slide, stub.hex(),
hex(b_off) if b_off is not None else "?? (expected 0x1f9d8)"))
if b_off is None:
b_off = 0x1f9d8 # fall back to the model constant, but we warned above
B = A + b_off
# ---- B cache fields -----------------------------------------------------
def show_cache(name, C, expect_vt=None):
vt = q(C) - slide
coll = q(C + 0x08); dl = q(C + 0x20); ready = rd(C + B_READY_OFF, 1)[0]
tag = ""
if expect_vt is not None:
tag = "(match)" if vt == expect_vt else "(MISMATCH static %#x)" % expect_vt
print(" %-4s @%#x vt=%#x %s coll(+8)=%#x deadline(+0x20)=%#x ready(+0x28)=%d"
% (name, C, vt, tag, coll, dl, ready))
return vt, coll, dl, ready
print("live cache state:")
b_vt, b_coll, b_dl, b_ready = show_cache("SBC", B, B_VT_STATIC)
show_cache("HUB", A + HUB_OFF)
print(" reqmgr @%#x +0x08=%#x" % (A + REQMGR_OFF, q(A + REQMGR_OFF + 0x08)))
# ---- M = the render source ---------------------------------------------
M = q(A + M_CACHE_OFF)
if M == 0:
print(" M (render source, *(A+0x20a68)) = 0 -> NOT built yet "
"(SBC menu not opened this session). Empty offline.")
cat_count = 0
else:
cat_count = w(M + 0x50)
print(" M (render source) = %#x WORD[M+0x50] category count = %d "
"(menu would draw %d tiles)" % (M, cat_count, cat_count + 2))
# ---- the plan / dry-run -------------------------------------------------
print("\n-- INTERVENTION PLAN --")
print(" Verified-safe write (this tool, --apply): BYTE @ %#x (B+0x28) = 1"
% (B + B_READY_OFF))
print(" effect: isValid short-circuits TRUE -> SBC menu OPENS instead of modal.")
print(" render: EMPTY unless M is populated (tiles = WORD[M+0x50], not B).")
print(" REFUSED here (footgun): writing B+0x08 or B+0x20 -> deadline branch,")
print(" can return FALSE (modal) and/or crash on a bogus collection ptr.")
print(" Populated render: needs the injected DLL to fill M (run with --spec).")
if not apply:
cur = rd(B + B_READY_OFF, 1)[0]
print("\n[DRY-RUN] default mode -- no memory written. current BYTE[%#x]=%d, "
"would set =1. Pass --apply to write (HUMAN ONLY)."
% (B + B_READY_OFF, cur))
os.close(fdr)
return 0
# ---- --apply: the single blessed byte write -----------------------------
# re-verify EVERYTHING load-bearing before touching live memory.
if not ctl_ok or A_vt != A_VT_STATIC or b_vt != B_VT_STATIC:
print("\n[ABORT] slide/vtable sanity failed at write time -> refusing to write.")
os.close(fdr); return 1
if b_off != 0x1f9d8:
print("\n[ABORT] B offset decoded as %s (expected 0x1f9d8) -> refusing to write."
% hex(b_off))
os.close(fdr); return 1
target = B + B_READY_OFF
before = rd(target, 1)[0]
print("\n[APPLY] target BYTE @ %#x before=%d" % (target, before))
if before == 1:
print("[APPLY] already 1 -> nothing to do (idempotent).")
os.close(fdr); return 0
fdw = os.open('/proc/%d/mem' % pid, os.O_WRONLY)
n = os.pwrite(fdw, b'\x01', target)
os.close(fdw)
after = rd(target, 1)[0]
print("[APPLY] wrote %d byte(s); read-back BYTE @ %#x = %d %s"
% (n, target, after, "(OK)" if after == 1 else "(WRITE FAILED)"))
print("[APPLY] now RE-OPEN the SBC menu. Expect: menu opens (no modal); tiles will")
print(" be EMPTY/placeholder unless M was populated by the DLL first.")
os.close(fdr)
return 0
if __name__ == '__main__':
sys.exit(main())