#!/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("