diff --git a/fifa17-recon/docs/plan-2026-08-06-card-subsystem.md b/fifa17-recon/docs/plan-2026-08-06-card-subsystem.md index 60ac2f9..aa9dac4 100644 --- a/fifa17-recon/docs/plan-2026-08-06-card-subsystem.md +++ b/fifa17-recon/docs/plan-2026-08-06-card-subsystem.md @@ -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 diff --git a/fifa17-recon/tools/kit_gate_probe.py b/fifa17-recon/tools/kit_gate_probe.py new file mode 100755 index 0000000..8c5f1cb --- /dev/null +++ b/fifa17-recon/tools/kit_gate_probe.py @@ -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("> 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("