Files
OpenFUT/fifa17-recon/tools/card_record_poke.py
T
funman300 b0bbc2a07f fifa17-recon: sweep auto-advance + the three-state oracle, live-proven
The oracle is three-valued, and all three fingerprints are now confirmed live
against a running client rather than read out of Ghidra:

  NAMED        our sentinel rating 7 survives and a real name appears. The id is
               real, and teamid/nation/leagueId come back FILLED by the game
               because we send them as zero.
  placeholder  rating 7 survives but the name is 'Jamal Blackman', team 0. The
               players-table row exists and is an empty slot. This is the trap:
               169193 does this and it was in VERIFIED_ASSET_IDS.
  MISS         rating 0x32, teamid 0x78d, nation 0xe, position 2, name ' '. That
               is the binary's miss-fill, byte for byte, and it is exactly the
               blank card photographed in a pack today.

Scale: 5000 candidates per response ingests cleanly; 20000 was served and then
silently not ingested (the map did not change at all), so the ceiling is between
them and auto chunks default to 5000.

Auto-advance: the client PAGES the club, so one visit yields several fetches.
'auto:lo-hi:step' hands out the next chunk per fetch. Item ids derive from the
candidate's offset in the WHOLE range, not its index in the chunk, so chunks never
collide and results accumulate across fetches for a single probe at the end.

sweep_collect.py accumulates into data/players.json and rejects the placeholder
name as a matter of course. Yield in 20000-24999 was 19 real ids per 5000, which
is why auto-advance matters: the real roster clusters in 150000-240000.

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

460 lines
18 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Validate the FUT card record-offset model END TO END, live, with a DATA write.
DO NOT RUN THIS WITHOUT READING THE "WHAT THIS ACTUALLY DOES" SECTION.
It writes to a running FIFA17.exe. It refuses to write unless you pass --fire.
--------------------------------------------------------------------------
WHY THIS IS NOT THE EXPERIMENT docs/CARD_SYSTEM.md ASKED FOR
--------------------------------------------------------------------------
CARD_SYSTEM.md "Option C" says: patch the miss branch of the lookup 0x18011cca0
so a miss emits a fixed real record. That experiment cannot be built as written,
because the premise is wrong. Re-read of the lookup this session (full decompile,
1664 chars; full disassembly, 84 instructions -- both in the session scratchpad):
FUN_18011cca0(CardsDb, item, parsed_record)
key = *(parsed_record + 8) // atom 0x15c = "id"
walk the RB-tree at CardsDb+0x160c0
if MISS: node = FUN_180115c30(...) // <-- INSERTS a fresh node
FUN_1800515e0(node+0x28, parsed_record) // record = parsed_record
FUN_1800419b0(item, node+0x28) // item+0x10 = &record
There is no "blank default record" and no miss-emit path. A miss ALLOCATES a
node (FUN_1801155f0 -> record ctor 0x180041250, zero-init, size 0x158) and the
very next instruction overwrites that record from the parsed item. So the map is
NOT empty offline -- it gains one node per parsed item, keyed by the item's `id`,
and each node's record at +0x28 is the exact buffer the card view-model
0x1800d7920 dereferences through item+0x10.
That is strictly better news: the thing we want to prove is reachable as a plain
DATA WRITE into an existing live buffer. No instruction patching at all.
--------------------------------------------------------------------------
WHAT THIS ACTUALLY DOES
--------------------------------------------------------------------------
1. finds FIFA17.exe and CardsDLL's live base from /proc/PID/maps
2. reads the CardsDb singleton (static 0x1802e6398)
3. walks the std::map at CardsDb+0x160c0 and DECODES every record through the
view-model's own offsets -- this alone is the pre-check that decides the
experiment (see OUTCOMES below); it is read-only and always runs
4. only with --fire --item <id>: writes a BEACON of deliberately unmistakable
values into ONE record, after snapshotting it to a backup file
5. --restore <backup.json> puts the original bytes back
Reversibility: the full 0x158-byte record is snapshotted to <backup>.bin before
any write, and the manifest records every (offset, original bytes, new bytes).
--restore rewrites ONLY the byte ranges we wrote -- never the whole record --
because the record also contains live intrusive-list pointers that legitimately
change between patch and restore, and blindly restoring those would corrupt the
observer list. A FIFA restart also clears everything (live memory only).
Needs ptrace access: tools/root_arm.sh (kernel.yama.ptrace_scope=0).
USAGE
python3 tools/card_record_poke.py # read-only census
python3 tools/card_record_poke.py --item 100000001 --fire
python3 tools/card_record_poke.py --restore /tmp/openfut_cardrec_<...>.json
VERIFIED THIS SESSION (static, cardsdll.dll @ 0x180000000)
0x1802e6398 CardsDb singleton (getter FUN_18011a830 returns DAT_1802e6398)
0x18021c2a0 CardsDb vtable; slot +0xa08 -> 0x18011cca0 (the lookup)
(found by scanning .rdata for the qword 0x18011cca0: exactly one
hit, at 0x18021cca8 = 0x18021c2a0 + 0xa08)
CardsDb+0x160c0 std::map base; +0x160c8 embedded header node;
header+0x10 = +0x160d8 = root; map+0x28 = +0x160e8 = size
(the insert increments *(int*)(mapbase+0x28))
node: child/child +0x00/+0x08, parent +0x10, key(itemId) +0x20, record +0x28
record size 0x158 (ctor 0x180041250 memsets +0xb8..+0x158 and the assignment
operator 0x1800515e0 copies through +0x150)
record field offsets, read straight out of the view-model 0x1800d7920:
+0x18 dword resourceId (its low 24 bits are used separately)
+0x58 dword, +0x88 dword, +0x90 int (>0 -> a bool),
+0x94 dword teamid, +0x98/9c/a0/a4/a8/ac dword attrs,
+0xb4 byte rating, +0xb5/+0xb6 bytes gate a bool,
+0xdd 32-byte name (falls back to +0xc8 when +0xdd is empty),
+0x146 byte position, +0x148 word nation
record+0x00/+0x08 and +0x70/+0x78/+0x80 are POINTERS (the ctor stores
&PTR_LAB_1801eaac0 at +0x70). This script refuses to write them.
"""
import argparse
import glob
import json
import os
import struct
import sys
import time
# ------------------------------------------------------------------ constants
IMG_BASE = 0x180000000
DLL = "CardsDLL"
G_CARDSDB = 0x1802E6398 # CardsDb singleton slot
MAP_BASE = 0x160C0 # std::map object inside CardsDb
MAP_HEADER = 0x160C8 # embedded header node
MAP_ROOT = 0x160D8 # header + 0x10
MAP_SIZE = 0x160E8 # map + 0x28
NODE_L, NODE_R, NODE_KEY, NODE_REC = 0x00, 0x08, 0x20, 0x28
REC_SIZE = 0x158
MAX_NODES = 100000
# Byte ranges inside the record that are POINTERS / intrusive-list links.
# Writing them can corrupt FIFA's heap. Every write is checked against this.
FORBIDDEN = ((0x00, 0x10), (0x70, 0x88))
# ------------------------------------------------------------------- beacon --
# Deliberately unmistakable values. Every one is independently identifiable in a
# screenshot, so a PARTIAL result tells us exactly which field drove which pixel.
# rating 99 -- no real starter card is 99
# attrs 11..66 -- also reveals the on-card ORDER of the six attributes
# nation 38 -- Portugal flag
# teamid 243 -- Real Madrid badge
# resourceId 20801 (version 0) -- Ronaldo; this is what a face/art lookup keys on
# name -- pure ASCII, cannot be mistaken for a dbdata name
BEACON = [
(0x018, "u32", "resourceId (vm field0/1)", 20801),
(0x088, "u32", "vm field6 (league?)", 53),
(0x094, "u32", "teamid", 243),
(0x098, "u32", "attr0", 11),
(0x09C, "u32", "attr1", 22),
(0x0A0, "u32", "attr2", 33),
(0x0A4, "u32", "attr3", 44),
(0x0A8, "u32", "attr4", 55),
(0x0AC, "u32", "attr5", 66),
(0x0B4, "u8", "rating", 99),
(0x146, "u8", "position (enum probe)", 25),
(0x148, "u16", "nation", 38),
(0x0DD, "str32", "name", "OPENFUT PROOF"),
]
KIND_LEN = {"u8": 1, "u16": 2, "u32": 4, "str32": 0x20}
def encode(kind, value):
if kind == "u8":
return struct.pack("<B", value & 0xFF)
if kind == "u16":
return struct.pack("<H", value & 0xFFFF)
if kind == "u32":
return struct.pack("<I", value & 0xFFFFFFFF)
if kind == "str32":
b = value.encode("ascii", "replace")[:0x1F]
return b + b"\0" * (0x20 - len(b))
raise ValueError(kind)
def check_write(off, length):
"""Raise unless [off, off+length) is a safe scalar range in the record."""
if off < 0 or off + length > REC_SIZE:
raise SystemExit("REFUSING: write %#x..%#x is outside the record (size %#x)"
% (off, off + length, REC_SIZE))
for lo, hi in FORBIDDEN:
if off < hi and lo < off + length:
raise SystemExit(
"REFUSING: write %#x..%#x overlaps pointer range %#x..%#x "
"(intrusive list / vtable slot)" % (off, off + length, lo, hi))
# ------------------------------------------------------------------ process --
def find_pid():
for d in glob.glob("/proc/[0-9]*"):
try:
if open(d + "/comm").read().strip() == "FIFA17.exe":
return int(d.rsplit("/", 1)[-1])
except Exception:
pass
return None
def dll_base(pid, name=DLL):
try:
for line in open("/proc/%d/maps" % pid):
if name in line:
return int(line.split("-")[0], 16) # lowest mapping = base
except Exception:
return None
return None
class Mem(object):
def __init__(self, pid, writable=False):
self.pid = pid
self.path = "/proc/%d/mem" % pid
self.f = open(self.path, "r+b" if writable else "rb", buffering=0)
self.writable = writable
def read(self, va, n):
self.f.seek(va)
b = self.f.read(n)
if b is None or len(b) != n:
raise IOError("short read at %#x" % va)
return b
def try_read(self, va, n):
try:
return self.read(va, n)
except Exception:
return None
def write(self, va, data):
if not self.writable:
raise RuntimeError("Mem opened read-only")
self.f.seek(va)
self.f.write(data)
def q(self, va):
b = self.try_read(va, 8)
return struct.unpack("<Q", b)[0] if b else None
def i32(self, va):
b = self.try_read(va, 4)
return struct.unpack("<i", b)[0] if b else None
# ------------------------------------------------------------------- the map --
def walk(mem, cdb):
"""[(node_addr, key)] for every node in CardsDb's item map.
Generic DFS over both child slots with a visited set: the exact left/right
convention does not matter for a census, and a garbage pointer terminates the
walk instead of hanging it.
"""
header = cdb + MAP_HEADER
root = mem.q(cdb + MAP_ROOT)
if root is None:
return None
if root == 0 or root == header:
return []
out, seen, stack = [], set(), [root]
while stack and len(out) < MAX_NODES:
p = stack.pop()
if not p or p == header or p in seen or (p & 7):
continue
seen.add(p)
k = mem.q(p + NODE_KEY)
if k is None:
continue
out.append((p, k))
for slot in (NODE_L, NODE_R):
c = mem.q(p + slot)
if c and c != header and c not in seen:
stack.append(c)
out.sort(key=lambda t: t[1])
return out
def decode_record(buf):
"""Decode a 0x158-byte record through the view-model's own offsets."""
u8 = lambda o: buf[o]
u16 = lambda o: struct.unpack_from("<H", buf, o)[0]
u32 = lambda o: struct.unpack_from("<I", buf, o)[0]
i32 = lambda o: struct.unpack_from("<i", buf, o)[0]
def s(o, n=0x20):
raw = bytes(buf[o:o + n])
z = raw.find(b"\0")
raw = raw[:z] if z >= 0 else raw
return raw.decode("ascii", "replace")
return {
"tradeId(+0x10)": struct.unpack_from("<Q", buf, 0x10)[0],
"resourceId(+0x18)": u32(0x18),
" assetId(low24)": u32(0x18) & 0xFFFFFF,
" version(>>24)": u32(0x18) >> 24,
"vm7(+0x58)": u32(0x58),
"vm6(+0x88)": u32(0x88),
"int(+0x90)": i32(0x90),
"teamid(+0x94)": u32(0x94),
"attrs(+0x98..ac)": [u32(0x98 + 4 * i) for i in range(6)],
"rating(+0xb4)": u8(0xB4),
"flagA(+0xb5)": u8(0xB5),
"flagB(+0xb6)": u8(0xB6),
"name(+0xdd)": s(0xDD),
"nameFallback(+0xc8)": s(0xC8, 0x15),
"position(+0x146)": u8(0x146),
"nation(+0x148)": u16(0x148),
}
def print_census(mem, cdb, nodes):
size = mem.i32(cdb + MAP_SIZE)
print(" CardsDb %#x" % cdb)
print(" map base %#x (header %#x, root %#x)"
% (cdb + MAP_BASE, cdb + MAP_HEADER, mem.q(cdb + MAP_ROOT) or 0))
print(" map size field %s walked nodes %s"
% (size, "unreadable" if nodes is None else len(nodes)))
if nodes is None:
print("\n TREE UNREADABLE. Nothing further can be said.")
return
if size is not None and len(nodes) != size:
print(" !! walk count != size field -- the walk is wrong, not the game.")
if not nodes:
print("\n THE MAP IS EMPTY. No item has been parsed in this session yet.")
print(" Enter the Squads tab (so GET /squad/0 is served) and re-run.")
return
print()
for node, key in nodes:
rec = node + NODE_REC
buf = mem.try_read(rec, REC_SIZE)
print(" item id %-12d node %#x record %#x" % (key, node, rec))
if buf is None:
print(" <record unreadable>")
continue
d = decode_record(buf)
for k in ("resourceId(+0x18)", " assetId(low24)", "rating(+0xb4)",
"teamid(+0x94)", "nation(+0x148)", "position(+0x146)",
"attrs(+0x98..ac)", "name(+0xdd)", "nameFallback(+0xc8)"):
print(" %-22s %s" % (k, d[k]))
print()
# --------------------------------------------------------------------- patch --
def do_patch(mem, cdb, nodes, item_id, backup_path):
match = [(n, k) for n, k in nodes if k == item_id]
if not match:
raise SystemExit(
"item id %d is not in the map. Present: %s"
% (item_id, ", ".join(str(k) for _, k in nodes[:20])))
node, key = match[0]
rec = node + NODE_REC
snap = mem.read(rec, REC_SIZE)
writes = []
for off, kind, name, value in BEACON:
ln = KIND_LEN[kind]
check_write(off, ln)
new = encode(kind, value)
assert len(new) == ln
writes.append({"off": off, "len": ln, "name": name,
"orig_hex": snap[off:off + ln].hex(), "new_hex": new.hex()})
manifest = {
"tool": "card_record_poke.py",
"ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
"pid": mem.pid,
"item_id": key,
"node": node,
"record": rec,
"record_snapshot": os.path.splitext(backup_path)[0] + ".bin",
"writes": writes,
}
with open(manifest["record_snapshot"], "wb") as f:
f.write(snap)
with open(backup_path, "w") as f:
json.dump(manifest, f, indent=2)
print(" backup written: %s" % backup_path)
print(" %s (%d bytes)" % (manifest["record_snapshot"], len(snap)))
print("\n writing beacon into record %#x" % rec)
ok = True
for w in writes:
mem.write(rec + w["off"], bytes.fromhex(w["new_hex"]))
back = mem.read(rec + w["off"], w["len"]).hex()
flag = "OK " if back == w["new_hex"] else "FAIL"
if back != w["new_hex"]:
ok = False
print(" %s +%#05x %-24s %s -> %s" % (flag, w["off"], w["name"],
w["orig_hex"], back))
print()
if not ok:
print(" !! at least one write did not read back. STOP and restore.")
return 1
print(" Beacon in place. Do NOT switch tabs (a tab switch refetches /squad/0")
print(" and the parser will overwrite this record). Move the cursor onto and")
print(" off the card, or open Player Details, to force a redraw.")
print(" Restore with: python3 %s --restore %s" % (sys.argv[0], backup_path))
return 0
def do_restore(path):
with open(path) as f:
m = json.load(f)
pid = m["pid"]
if not os.path.exists("/proc/%d" % pid):
print("pid %d is gone -- FIFA restarted, the patch is already gone with it."
% pid)
return 0
if open("/proc/%d/comm" % pid).read().strip() != "FIFA17.exe":
print("pid %d is no longer FIFA17.exe. REFUSING to write." % pid)
return 1
mem = Mem(pid, writable=True)
rec = m["record"]
print("restoring record %#x in pid %d (%d ranges)" % (rec, pid, len(m["writes"])))
for w in m["writes"]:
cur = mem.read(rec + w["off"], w["len"]).hex()
if cur != w["new_hex"]:
print(" note +%#05x holds %s, not our beacon %s -- the game rewrote "
"it; restoring anyway is WRONG, skipping." % (w["off"], cur, w["new_hex"]))
continue
check_write(w["off"], w["len"])
mem.write(rec + w["off"], bytes.fromhex(w["orig_hex"]))
back = mem.read(rec + w["off"], w["len"]).hex()
print(" %s +%#05x %-24s -> %s"
% ("OK " if back == w["orig_hex"] else "FAIL", w["off"], w["name"], back))
print("done.")
return 0
# ---------------------------------------------------------------------- main --
def main():
ap = argparse.ArgumentParser(
description="Read (and, with --fire, beacon-patch) a live FUT card record.")
ap.add_argument("--item", type=int,
help="item id (map key) of the record to patch")
ap.add_argument("--fire", action="store_true",
help="REQUIRED to write anything. Without it this tool is read-only.")
ap.add_argument("--restore", metavar="BACKUP.json",
help="undo a previous --fire using its backup manifest")
args = ap.parse_args()
if args.restore:
return do_restore(args.restore)
pid = find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = dll_base(pid)
if base is None:
print("pid %d is running but %s is not mapped yet (reach the FUT hub first)."
% (pid, DLL))
return 1
try:
mem = Mem(pid, writable=bool(args.fire))
except Exception as e:
print("cannot open /proc/%d/mem: %s" % (pid, e))
print("Need ptrace access: sudo sysctl -w kernel.yama.ptrace_scope=0"
" (tools/root_arm.sh)")
return 1
print("FIFA pid=%d %s base=%#x (image base %#x)" % (pid, DLL, base, IMG_BASE))
cdb = mem.q(base + (G_CARDSDB - IMG_BASE))
if not cdb:
print(" CardsDb singleton is NULL -- the FUT layer is not constructed yet.")
return 1
nodes = walk(mem, cdb)
print_census(mem, cdb, nodes)
if not args.fire:
print("READ-ONLY. Nothing was written. Add --item <id> --fire to patch.")
return 0
if args.item is None:
print("--fire needs --item <id>. Pick one from the census above.")
return 1
if not nodes:
print("nothing to patch.")
return 1
backup = "/tmp/openfut_cardrec_%d_%d_%d.json" % (pid, args.item, int(time.time()))
return do_patch(mem, cdb, nodes, args.item, backup)
if __name__ == "__main__":
sys.exit(main())