From dcd470cddc509d3aab72c5c8a49bac60b44be37e Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 21 Aug 2026 18:55:59 +0000 Subject: [PATCH] tools(fifa17): measure subtype->cardtype and itemState from the running client Two things this project kept carrying as INFERRED are directly observable in the card record, so this reads them instead of trusting the decompile: rec+0x18 resourceId, rec+0x4c cardtype (derived by FUN_1800d8330), rec+0x50 cardsubtypeid (as sent), rec+0x5c itemState (decoded enum value). Measured against the live client (pid 6580, 27 records): subtype 0 -> cardtype 1 (23 records) agrees with FUN_1800d8330 subtype 4 -> cardtype 2 (1) agrees subtype 6 -> cardtype 10 (1) agrees subtype 8 -> cardtype 4 (2) agrees itemState runtime value 1 on all 27, and every one of those was served as "free" So the cardtype map is now runtime-confirmed for every subtype we actually serve, and `free == 1` is an empirical anchor for the itemState enum rather than a reading of the table at 0x180229cc0. The probe prints the Ghidra prediction beside each measurement and says DISAGREES rather than quietly matching, so it stays useful as new families are served. It also states the obvious limit in its own output: a runtime value only appears if the client was actually served an item in that state, so absence is not evidence of absence. The equipped states (activeBadge 100, activeHomeKit 101, activeAwayKit 102, activeBall 103, activeStadium 104) remain table-recovered and un-measured until a kit is fetched by the client. Read-only: /proc/PID/mem is opened 'rb' and there is no write path. --- fifa17-recon/tools/record_vocab_probe.py | 107 +++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100755 fifa17-recon/tools/record_vocab_probe.py diff --git a/fifa17-recon/tools/record_vocab_probe.py b/fifa17-recon/tools/record_vocab_probe.py new file mode 100755 index 0000000..497985e --- /dev/null +++ b/fifa17-recon/tools/record_vocab_probe.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Dump the CLASSIFICATION fields the client stored for every card it holds, so +the subtype->cardtype map and the itemState runtime values are read from the +running game instead of inferred. + +READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file. + +WHY THIS EXISTS +--------------- +Two things this project has repeatedly had to treat as INFERRED: + + 1. `FUN_1800d8330`'s cardsubtypeid -> cardtype map. It is read out of Ghidra + (0..3->1 players, 4->2 manager, 5->3 headcoach, 6->10 gkcoach, 7->5 physio, + 8->4 fitnesscoach, 9..b->7), and the kit selector gate `FUN_1801c3480` + branches on cardtype == 7. Serving a subtype whose cardtype we guessed + wrong fails SILENTLY, because cardtype 9 has no arm in the merge. + 2. The itemState enum. The table at 0x180229d20 gives the tokens; the RUNTIME + values the strings deserialize to (notably activeHomeKit/activeAwayKit -> + 101/102) have been carried as inferred. + +Both are directly observable: the parser writes cardsubtypeid to rec+0x50, the +derived cardtype to rec+0x4c, and the decoded itemState to rec+0x5c. Reading +those back for every record turns the pair into measurements. + + rec+0x18 resourceId + rec+0x4c cardtype (derived by FUN_1800d8330 from cardsubtypeid) + rec+0x50 cardsubtypeid (as sent) + rec+0x5c itemState (decoded enum value) + +Usage: python3 record_vocab_probe.py +""" +import collections +import sys + +import watch_club_model as W +import card_identity_probe as P + +F_RESOURCE = 0x18 +F_CARDTYPE = 0x4C +F_SUBTYPE = 0x50 +F_ITEMSTATE = 0x5C +REC_SIZE = 0x158 + +# What the Ghidra read of FUN_1800d8330 predicts, so a disagreement is loud. +EXPECTED_CARDTYPE = {0: 1, 1: 1, 2: 1, 3: 1, 4: 2, 5: 3, 6: 10, 7: 5, 8: 4, + 9: 7, 10: 7, 11: 7} + + +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 yet." % (pid, W.DLL)) + return 1 + mem = W.Mem(pid) + obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE)) + if not obj: + print("CardsDb singleton is NULL (no FUT session loaded).") + return 1 + + ns = W.nodes(mem, obj) if hasattr(W, "nodes") else P.nodes(mem, obj) + print("pid=%d CardsDb=%#x walked=%d\n" % (pid, obj, len(ns))) + + pairs = collections.Counter() + states = collections.Counter() + rows = [] + for n in ns: + buf = mem.read(n + 0x28, REC_SIZE) + if not buf or len(buf) < REC_SIZE: + continue + resource = P.u32(buf, F_RESOURCE) + cardtype = P.u8(buf, F_CARDTYPE) + subtype = P.u8(buf, F_SUBTYPE) + state = P.u8(buf, F_ITEMSTATE) + pairs[(subtype, cardtype)] += 1 + states[state] += 1 + rows.append((resource, subtype, cardtype, state)) + + print("%-12s %-9s %-9s %s" % ("resource", "subtype", "cardtype", "itemState")) + for r in sorted(rows): + print("%-12d %-9d %-9d %d" % r) + + print("\n--- MEASURED cardsubtypeid -> cardtype ---") + for (sub, ct), n in sorted(pairs.items()): + want = EXPECTED_CARDTYPE.get(sub) + if want is None: + verdict = "no Ghidra prediction for this subtype" + elif want == ct: + verdict = "agrees with FUN_1800d8330" + else: + verdict = "DISAGREES -- Ghidra said %d" % want + print(" subtype %-4d -> cardtype %-4d (%d record(s)) %s" % (sub, ct, n, verdict)) + + print("\n--- MEASURED itemState runtime values ---") + for st, n in sorted(states.items()): + print(" %-5d %d record(s)" % (st, n)) + print("\nNOTE: a runtime value only appears here if the client was actually") + print("served an item in that state. Absence is not evidence of absence.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())