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())