a842c5ffb0
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.
178 lines
6.9 KiB
Python
Executable File
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())
|