#!/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 : writes a BEACON of deliberately unmistakable values into ONE record, after snapshotting it to a backup file 5. --restore puts the original bytes back Reversibility: the full 0x158-byte record is snapshotted to .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(" 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("= 0 else raw return raw.decode("ascii", "replace") return { "tradeId(+0x10)": struct.unpack_from(">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(" ") 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 --fire to patch.") return 0 if args.item is None: print("--fire needs --item . 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())