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