diff --git a/fifa17-recon/tools/card_identity_probe.py b/fifa17-recon/tools/card_identity_probe.py new file mode 100644 index 0000000..ddbee0b --- /dev/null +++ b/fifa17-recon/tools/card_identity_probe.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Read the IDENTITY the client resolved for every card it currently holds. + +READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file. + +WHY THIS EXISTS +--------------- +Card identity does not come from us. Every item object in every response is +inserted into the CardsDb map by the item-parser tail (0x18014115b -> registrar +vtable +0xa08 = 0x18011cca0, a find-or-INSERT). Just before registering, the +client runs a LOCAL merge (FUN_180141660 -> FUN_180135890 for player cards) that +queries its own `players` table by + + playerid = resourceId & 0xffffff + +On a HIT it fills the name and face and leaves our rating/position/attributes +alone. On a MISS it hard-writes a fixed generic card. Those MISS constants are a +FINGERPRINT, and that is what makes this probe useful: the resolved record is +sitting in the map, so one read tells us hit-or-miss for EVERY id we have served, +without opening a single card in the UI. + + MISS => rating 0x32 (50), teamid 0x78d (1933), nation 0xe (14), + position 2 (RWB), attributes all 1, name " " + +That is exactly what a player photographed in a pack on 2026-08-04: two named +cards (ids from VERIFIED_ASSET_IDS) beside three blanks at 50 RWB with every +attribute 1. So the fingerprint is confirmed live, not just read out of Ghidra. + +WHAT THIS BUYS +-------------- +A bulk oracle. Serving N candidate playerids and reading this once classifies all +N at a time, instead of one id per screenshot. That is the difference between +validating a 79-card pool and validating a database. + +OFFSETS, AND HOW MUCH TO TRUST THEM +----------------------------------- +Record base = node + 0x28, size 0x158, copied field-by-field by FUN_1800515e0. +Offsets below were derived in Ghidra from the card view-model FUN_1800d7920 and +the parser's stack record, and cross-checked by a second agent. They are NOT yet +confirmed against a live process -- which is precisely what this tool does. Read +the report critically the first time: if `name` is garbage for a card you KNOW +renders correctly in the UI, the offset is wrong, not the game. + +Usage: + python3 card_identity_probe.py # table + summary + python3 card_identity_probe.py --raw # + hexdump of the first record + python3 card_identity_probe.py --json out.json +""" +import argparse +import json +import struct +import sys + +import watch_club_model as W + +REC = 0x28 # node -> record +F_ID = 0x08 # map key: the item's `id` (atom 0x15c) +F_RESOURCE = 0x18 # resourceId (atom 0x287) -- the DB key lives in the low 24 bits +F_PLAYERID = 0x1C # written by FUN_180135890 as resourceId & 0xffffff +F_ASSET = 0x20 # assetId (atom 0x23) -- parsed, then never read by the merge +F_SUBTYPE = 0x50 # cardsubtypeid (atom 0x6c) +F_CARDTYPE = 0x4C # FUN_1800d8330(subtype); 1 = player => the merge runs +F_TEAM = 0x94 # MISS writes 0x78d +F_ATTRS = (0x98, 0x9C, 0xA0, 0xA4, 0xA8, 0xAC) # MISS writes 1 to each +F_RATING = 0xB4 # MISS writes 0x32 +F_NAME_FIRST = 0xB8 +F_NAME_LAST = 0xC8 +F_NAME_KNOWN = 0xDD # 0x1f bytes +F_POSITION = 0x146 # MISS writes 2 +F_NATION = 0x148 # MISS writes 0xe +F_LEAGUE = 0x154 # filled from the DB on a hit +REC_LEN = 0x158 + +MISS = {"rating": 0x32, "teamid": 0x78D, "nation": 0xE, "position": 2} + + +def cstr(buf, off, maxlen=0x1F): + """Inline char array -> str. Names are stored in the record itself, not + interned: three arrays at +0xb8/+0xc8/+0xdd, no string table.""" + if buf is None or off + 1 > len(buf): + return "" + end = min(off + maxlen, len(buf)) + raw = buf[off:end].split(b"\x00", 1)[0] + return raw.decode("utf-8", "replace").strip() + + +def u8(b, o): + return b[o] if b and o < len(b) else None + + +def u16(b, o): + return struct.unpack_from(" cardtype 0 -> FUN_180141660 skips the merge + entirely, so the card shows OUR raw JSON and never consults the DB. That + looks nothing like a MISS and must not be reported as one. + """ + if c["cardtype"] != 1: + return "NO-MERGE" + if all(c[k] == v for k, v in MISS.items()) and all(a == 1 for a in c["attrs"]): + return "MISS" + name = (c["known"] or c["last"] or c["first"]).strip() + return "HIT" if name else "MISS?" + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--raw", action="store_true", help="hexdump the first record") + ap.add_argument("--json", metavar="PATH", help="write the full table as JSON") + ap.add_argument("--limit", type=int, default=W.MAX_NODES) + a = ap.parse_args() + + 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 + size = mem.i32(obj + W.TREE_SIZE) + ns = nodes(mem, obj, a.limit) + print("pid=%d cardsdll=%#x CardsDb=%#x size(+0x160e8)=%s walked=%d" + % (pid, base, obj, size, len(ns))) + if size is not None and size != len(ns): + print(" !! walk disagrees with the size counter -- trust the counter, " + "the walk went wrong") + + cards = [c for c in (read_card(mem, n) for n in ns) if c] + cards.sort(key=lambda c: (c["resourceId"] or 0)) + + print() + print("%-11s %-10s %-8s %-4s %-4s %-6s %-4s %-18s %s" + % ("id", "resource", "playerid", "rat", "pos", "team", "nat", "name", "verdict")) + for c in cards: + nm = (c["known"] or ("%s %s" % (c["first"], c["last"])).strip())[:18] + print("%-11s %-10s %-8s %-4s %-4s %-6s %-4s %-18s %s" + % (c["id"], c["resourceId"], c["playerid"], c["rating"], c["position"], + c["teamid"], c["nation"], nm, c["verdict"])) + + tally = {} + for c in cards: + tally[c["verdict"]] = tally.get(c["verdict"], 0) + 1 + print("\n" + " ".join("%s=%d" % kv for kv in sorted(tally.items()))) + + hits = sorted({c["playerid"] for c in cards if c["verdict"] == "HIT"}) + miss = sorted({c["playerid"] for c in cards if c["verdict"] in ("MISS", "MISS?")}) + if hits: + print("\nplayerids PRESENT in the client's DB (%d): %s" + % (len(hits), ", ".join(str(h) for h in hits))) + if miss: + print("\nplayerids ABSENT (%d): %s" + % (len(miss), ", ".join(str(m) for m in miss))) + + if a.raw and cards: + b = cards[0]["_raw"] + print("\nrecord %#x:" % (cards[0]["node"] + REC)) + for off in range(0, REC_LEN, 16): + row = b[off:off + 16] + print(" +%03x %-47s %s" % ( + off, " ".join("%02x" % x for x in row), + "".join(chr(x) if 32 <= x < 127 else "." for x in row))) + + if a.json: + for c in cards: + c.pop("_raw", None) + with open(a.json, "w") as f: + json.dump(cards, f, indent=1) + print("\nwrote %s" % a.json) + + print("\nfailed reads=%d" % mem.fails) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index bfa9844..0699469 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -676,10 +676,12 @@ MOVE_BODY = os.environ.get("FUT_MOVE_BODY", "ack") # leaving two loaded guns on the table. The fix that replaces them is below. # # FUT_STORE_DISPLAYGROUP: send the ONE key that actually names a tile, -# displayGroup(0xd9) as {"value": ""}. DEFAULT OFF: the parser-side proof -# is airtight but does not cover whether the presence of this key switches FIFA17.exe -# to a different (packed, unreadable) tile render path. See _pack_body. -STORE_DISPLAYGROUP = os.environ.get("FUT_STORE_DISPLAYGROUP") == "1" +# displayGroup(0xd9) as {"value": ""}. DEFAULT ON since 2026-08-04: it was +# shipped off because the parser-side proof did not cover whether the key switches +# FIFA17.exe to a different (packed, unreadable) tile render path. It was then run live +# with FUT_STORE_DISPLAYGROUP=1 and the store tiles rendered their real names instead of +# "unknown", so the live-proven value is now on. See _pack_body. +STORE_DISPLAYGROUP = os.environ.get("FUT_STORE_DISPLAYGROUP", "1") == "1" def quick_sell_route(h):