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.
This commit is contained in:
@@ -528,13 +528,6 @@ effects move in the permissive direction. There is also a second escape hatch in
|
||||
that gate -- `svc->0x308()` on service `0xed80ed8` -- that nobody resolved, so if
|
||||
squad submission behaves oddly afterwards, that is where to look.
|
||||
|
||||
**"List on Transfer Market" as a separate menu entry was not found.** The eight
|
||||
flags contain `TO_TRADE_PILE` and no listing action. `FUN_18003e550` publishes
|
||||
`DURATION` / `START_PRICE` / `ASKING_PRICE`, which is the listing panel, but
|
||||
whether it has its own enable predicate was not chased. The likely explanation is
|
||||
that listing is only reachable from the trade pile, so both entries share one root
|
||||
cause, but that is an inference and it is not established.
|
||||
|
||||
### Equipping club items
|
||||
|
||||
`itemState` really is the equip mechanism for the `IS_ACTIVE` tick:
|
||||
@@ -553,12 +546,29 @@ will not change the kit.
|
||||
|
||||
### Needs decompiling only
|
||||
|
||||
**Who writes item `+0x60`.** It gates the kit swap at value 4 and we can produce 1
|
||||
and 6. Both attempts to scan for it drowned: `+0x60` returns 1688 and 4144
|
||||
instructions depending on method. The narrower anchor is the `/club` and
|
||||
`/purchased` response handlers -- find the list-insert that assigns it, read the
|
||||
constants. This is the single blocker between "we can mark a kit equipped" and "we
|
||||
can equip a kit".
|
||||
**Who writes item `+0x60`. ANSWERED 2026-08-21 — NOTHING DOES.** It gates the kit
|
||||
swap at value 4 and we can produce 1 and 6. Both earlier scans drowned (`+0x60`
|
||||
returns 1688 and 4144 instructions) because it is a common struct offset. Two
|
||||
filters cut it to a readable set: only an IMMEDIATE store can introduce a
|
||||
constant, and item-record code is recognisable by touching `+0x4c`/`+0x5c`
|
||||
nearby. Measured with `fifa17-recon/tools/kit_gate_probe.py` against pid 6580:
|
||||
|
||||
| evidence | result |
|
||||
|---|---|
|
||||
| live `+0x60`, all 27 resident records | `{1: 23 players, 0: 4 staff}` — never 4 |
|
||||
| `cmp dword [reg+0x60], imm8` in CardsDLL | 4 sites: `0`, `0`, `1`, `4`; the `4` is the gate and is UNIQUE in the process |
|
||||
| immediate stores to `[reg+0x60]`, CardsDLL | 29; constants `{-2, 0, 1, 908, 0x3f800000}` — no 4 |
|
||||
| immediate stores of 4, FIFA17.exe (79 MB) | 0; also 0 comparisons against 4 |
|
||||
| xrefs to the gate function | 1 (`jmp` from `0x1801a5329`); address never taken |
|
||||
| register stores to `+0x60`, CardsDLL | all struct copies or inits to 0/1/-2 |
|
||||
|
||||
So the blocker is not a wire field we have not learned to send: the value the
|
||||
gate demands is never produced by anything. Every OTHER input to the gate is
|
||||
already served — `+0x4c == 7` (subtype 9), `+0x5c` 101/102
|
||||
(`activeHomeKit`/`activeAwayKit`), `+0x94` teamid — leaving only the `+0xba`
|
||||
variant selector below it. A client-side patch is therefore the only remaining
|
||||
avenue, and a small one; it is not proposed here.
|
||||
|
||||
|
||||
**The kit variant selector.** `FUN_1801bfac0` distinguishes home, away and third
|
||||
kits from `FUN_1801a8800` (`+0xba`, u16) and `FUN_1801a8040` (`+0xbf`, signed
|
||||
|
||||
Executable
+177
@@ -0,0 +1,177 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user