Files
OpenFUT/fifa17-recon/tools/card_identity_probe.py
T
funman300 132a013b39 fifa17-recon: card identity is a DATA problem -- live probe proves the record model
Adds tools/card_identity_probe.py, a read-only /proc/PID/mem walk of the CardsDb
card map that reports the identity the CLIENT resolved for every card it holds.

Why it matters: identity never comes from us. The item-parser tail registers every
parsed item into the map, and immediately before that FUN_180141660 -> FUN_180135890
queries the client's own local players table by resourceId & 0xffffff. On a hit it
fills name/face and leaves our rating/position/attributes alone; on a miss it writes
a fixed generic card (rating 0x32, teamid 0x78d, nation 0xe, position 2, attrs 1,
name ' '). That miss fingerprint is exactly the blank card photographed in a pack
today, so the chain is confirmed by live evidence and not only in Ghidra.

First live run, 11 nodes, 0 failed reads, size counter agrees with the walk:
Ronaldo/Messi/Suarez/Kroos/Hazard all resolve with real names, so every record
offset derived statically (+0x18 resourceId, +0xb4 rating, +0x94 teamid, +0x148
nation, +0x146 position, names inline at +0xb8/+0xc8/+0xdd) is correct live.

This makes card identity a pure DATA problem: serve real playerids. The probe is
the bulk oracle for finding them -- N candidate ids served, one read classifies all N.

Also flips FUT_STORE_DISPLAYGROUP to default on; it shipped off pending proof that
the key does not switch FIFA17.exe to another tile render path, and it was then run
live and the store tiles showed their real names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 20:29:27 -07:00

242 lines
9.0 KiB
Python

#!/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("<H", b, o)[0] if b and o + 2 <= len(b) else None
def u32(b, o):
return struct.unpack_from("<I", b, o)[0] if b and o + 4 <= len(b) else None
def nodes(mem, obj, limit=W.MAX_NODES):
"""[node_addr] for every node in the card tree. Same defensive DFS as
watch_club_model.walk_tree -- both child slots, visited set, bounded."""
root = mem.q(obj + W.TREE_ROOT)
end = obj + W.TREE_END
if root is None or root == 0 or root == end:
return []
out, seen, stack = [], set(), [root]
while stack and len(out) < limit:
p = stack.pop()
if not p or p == end or p in seen or (p & 7):
continue
seen.add(p)
out.append(p)
for slot in (W.NODE_A, W.NODE_B):
c = mem.q(p + slot)
if c and c != end and c not in seen:
stack.append(c)
return out
def read_card(mem, node):
buf = mem.read(node + REC, REC_LEN)
if buf is None or len(buf) < REC_LEN:
return None
res = u32(buf, F_RESOURCE)
c = {
"node": node,
"id": u32(buf, F_ID),
"resourceId": res,
"playerid": res & 0xFFFFFF if res is not None else None,
"playerid_field": u32(buf, F_PLAYERID),
"assetId": u32(buf, F_ASSET),
"cardtype": u32(buf, F_CARDTYPE),
"subtype": u32(buf, F_SUBTYPE),
"teamid": u32(buf, F_TEAM),
"rating": u8(buf, F_RATING),
"position": u8(buf, F_POSITION),
"nation": u16(buf, F_NATION),
"league": u32(buf, F_LEAGUE),
"attrs": [u8(buf, o) for o in F_ATTRS],
"first": cstr(buf, F_NAME_FIRST, 0x10),
"last": cstr(buf, F_NAME_LAST, 0x15),
"known": cstr(buf, F_NAME_KNOWN, 0x1F),
"_raw": buf,
}
c["verdict"] = classify(c)
return c
def classify(c):
"""HIT / MISS / NO-MERGE, from the fingerprint the binary writes.
NO-MERGE matters as much as the other two: if cardsubtypeid is absent the
record defaults to 0x156 -> 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())