132a013b39
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
242 lines
9.0 KiB
Python
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())
|