Files
OpenFUT/fifa17-recon/tools/kit_gate_probe.py
T
funman300 a842c5ffb0 tools(fifa17): answer "who writes item +0x60" — nothing does
The plan called this "the single blocker between 'we can mark a kit equipped'
and 'we can equip a kit'", and recorded that two attempts to find the writer
drowned at 1688 and 4144 instructions.

They drowned because +0x60 is a common struct offset. Two filters make it
readable: only an IMMEDIATE store can introduce a constant (a register store
just propagates one), and item-record code is recognisable by touching +0x4c
(cardtype) or +0x5c (itemState) within a few instructions.

Measured read-only against pid 6580:
  - live +0x60 over all 27 resident records: {1: 23 players, 0: 4 staff}, never 4
  - CardsDLL has 4 comparisons of +0x60 (0, 0, 1, 4); the 4 is the kit gate and
    is the ONLY such comparison in the process
  - CardsDLL has 29 immediate stores to +0x60, constants {-2,0,1,908,0x3f800000}
  - FIFA17.exe, across 79 MB of code: ZERO stores of 4, zero comparisons with 4
  - the gate function has one xref (a jmp) and its address is never taken
  - every register store to +0x60 in CardsDLL is a struct copy or an init

So the gate is not a wire field we failed to send: the value it demands is never
produced by anything. Decoding it fully also shows every OTHER input is already
served — cardtype 7, itemState 101/102, teamid — leaving only the +0xba variant
selector beneath it, which makes a client-side patch the only remaining avenue.
2026-08-21 21:10:35 +00:00

178 lines
6.9 KiB
Python
Executable File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Settle the pre-match kit selector gate: who, if anyone, writes item `+0x60`.
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
WHY THIS EXISTS
---------------
`plan-2026-08-06-card-subsystem.md` section 5 calls `+0x60` "the single blocker
between 'we can mark a kit equipped' and 'we can equip a kit'", and records that
two attempts to find its writer drowned: scanning for the offset returned 1688
and 4144 instructions depending on method.
The scan drowns because `+0x60` is a common struct offset. Two cheap filters cut
it to something a person can read:
* only IMMEDIATE stores can introduce a constant (a register store propagates
one from somewhere else), and
* item-record code is recognisable by touching `+0x4c` (cardtype) or `+0x5c`
(itemState) within a few instructions.
WHAT IT REPORTS
---------------
1. The live `+0x60` distribution over every resident CardsDb record.
2. Every `cmp dword [reg+0x60], imm8` in CardsDLL .text -- the readers.
3. Every immediate store to `[reg+0x60]` and the constants they use.
4. Which of those stores sit next to item-record code.
MEASURED 2026-08-21 (pid 6580, 27 resident records):
live +0x60 : {1: 23 (players), 0: 4 (staff)} -- never 4
readers : 4 total; exactly ONE compares against 4, at 0x1801c34f2,
which is the kit gate in FUN_1801c3480
immediate stores: 27 total; constants {-2, 0, 1, 908, 0x3f800000} -- NO 4
FIFA17.exe : 0 immediate stores of 4 to +0x60 across its 79MB of code,
and 0 comparisons against 4
gate xrefs : 1 (a jmp from 0x1801a5329); address never taken
The gate at 0x1801c34f2 decodes as:
cmp [rdi+0x4c], 7 cardtype 7 = kit/stadium/badge <- we produce this
cmp [rdi+0x60], 4 <- THE BLOCKER
mov eax, [rdi+0x5c] itemState
cmp eax, 0x65 / 0x66 101 activeHomeKit / 102 activeAwayKit <- we produce
mov r8d, [rdi+0x94] teamid <- we produce
mov r9d, [rdi+0xba] kit variant selector (unresolved)
So every input EXCEPT `+0x60` is already satisfied by what OpenFUT serves, and
no instruction in either module ever stores the constant 4 there.
Usage: python3 kit_gate_probe.py
"""
import collections
import struct
import sys
import watch_club_model as W
try:
import card_identity_probe as P
except Exception: # pragma: no cover - probe is optional for the static half
P = None
TEXT_START = 0x180001000
FIELD = 0x60
REGS = ["rax", "rcx", "rdx", "rbx", "rsp", "rbp", "rsi", "rdi"]
REC_SIZE = 0x158
F_SUBTYPE = 0x50
def live_distribution(mem, base):
"""(+0x60 histogram, (subtype,+0x60) histogram) over resident records."""
if P is None:
return None, None
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
if not obj:
return None, None
by_value = collections.Counter()
by_pair = collections.Counter()
for node in P.nodes(mem, obj):
buf = mem.read(node + 0x28, REC_SIZE)
if not buf or len(buf) < REC_SIZE:
continue
subtype = struct.unpack_from("<I", buf, F_SUBTYPE)[0]
value = struct.unpack_from("<i", buf, FIELD)[0]
by_value[value] += 1
by_pair[(subtype, value)] += 1
return by_value, by_pair
def scan_text(text):
"""(readers, immediate stores, item-record markers) over a .text image."""
readers, stores, markers = [], [], set()
for i in range(len(text) - 8):
op, modrm = text[i], text[i + 1]
mod, reg, rm = modrm >> 6, (modrm >> 3) & 7, modrm & 7
if mod != 1 or rm == 4:
continue
disp = text[i + 2]
if disp in (0x4C, 0x5C) and op in (0x8B, 0x89, 0x83, 0x39, 0x3B, 0xC7, 0x0F):
markers.add(TEXT_START + i)
if disp != FIELD:
continue
if op == 0x83 and reg == 7: # cmp dword [reg+0x60], imm8
readers.append((TEXT_START + i, REGS[rm], text[i + 3]))
elif op == 0xC7 and reg == 0: # mov dword [reg+0x60], imm32
stores.append((TEXT_START + i, REGS[rm], struct.unpack_from("<i", text, i + 3)[0], "dword"))
elif op == 0xC6 and reg == 0: # mov byte [reg+0x60], imm8
stores.append((TEXT_START + i, REGS[rm], text[i + 3], "byte"))
return readers, stores, markers
def main():
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
print("pid=%d %s base=%#x" % (pid, W.DLL, base))
print()
by_value, by_pair = live_distribution(mem, base)
print("── live records ──")
if by_value is None:
print(" CardsDb is empty (no FUT session loaded); static half still runs.")
else:
print(" +0x60 distribution : %s" % dict(by_value))
print(" (cardsubtypeid, +0x60) : %s" % dict(by_pair))
print(" holds the gate value 4 : %s" % ("YES" if 4 in by_value else "NO"))
print()
# .text is the second CardsDLL mapping; read it whole and scan.
size = 0x1E4000
buf, bad = mem.read_pages(base + 0x1000, size)
if bad:
print(" WARNING: %d unreadable page(s); the scan is incomplete." % len(bad))
text = bytes(buf)
readers, stores, markers = scan_text(text)
print("── readers: cmp dword [reg+0x60], imm8 ──")
for va, reg, imm in readers:
flag = " <-- THE KIT GATE" if imm == 4 else ""
print(" %#x cmp [%s+0x60], %d%s" % (va, reg, imm, flag))
print()
print("── immediate stores to [reg+0x60] ──")
consts = collections.Counter(s[2] for s in stores)
print(" %d store(s); constants %s" % (len(stores), dict(sorted(consts.items()))))
near = [s for s in stores if any(abs(m - s[0]) <= 96 for m in markers)]
print(" %d of them sit within 96B of item-record code (+0x4c/+0x5c):" % len(near))
for va, reg, imm, width in near:
print(" %#x mov %s [%s+0x60], %d" % (va, width, reg, imm))
print()
print("=" * 70)
if any(s[2] == 4 for s in stores):
print("A store of 4 EXISTS -- the gate is reachable. Follow the sites above.")
return 0
print("NO instruction in CardsDLL stores the constant 4 into +0x60.")
print("Combined with the live records (never 4) and the fact that every OTHER")
print("gate input is already served, the pre-match kit selector cannot be")
print("opened by anything the server sends. This is a CLIENT-side dead end,")
print("not a missing wire field.")
print()
print("Scope of the claim: immediate stores, all widths, disp8 form. A value")
print("could still arrive by register copy -- but in CardsDLL every register")
print("store to +0x60 is a field-by-field struct copy or an init to 0/1/-2.")
print("=" * 70)
return 0
if __name__ == "__main__":
sys.exit(main())