#!/usr/bin/env python3 # -*- coding: utf-8 -*- """THE CARD RECORD PROOF -- a reversible, differential, live DATA write. *** THIS WRITES TO A RUNNING FIFA17.exe. IT REFUSES TO WRITE WITHOUT --fire. *** *** A HUMAN DECIDES WHEN TO FIRE IT. Read OUTCOMES at the bottom first. *** ========================================================================== 1. WHY THIS IS NOT "PATCH THE MISS PATH", WHICH IS WHAT WAS ASKED FOR ========================================================================== docs/CARD_SYSTEM.md "Option C" says: patch the miss branch of the lookup 0x18011cca0 so that a miss emits a fixed real record. That experiment cannot be built, because BOTH halves of its premise are false. Verified this session against /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll (freshly imported and analysed; image base 0x180000000): FUN_18011cca0 -- FULL decompile 1664 chars, FULL disassembly 84 of 84 instructions, complete coverage, both written to disk and read end to end: FUN_18011cca0(CardsDb, item, parsed_record) key = *(parsed_record + 8) if key == 0: unlink `item` from the observer list at item+0x10; return walk the RB-tree at CardsDb+0x160c0 (root +0x160d8, header +0x160c8, node key +0x20, record +0x28) 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) and the very next call overwrites that record wholesale from the parsed item. The map is therefore NOT empty offline: it gains one node per parsed item. And the actual blank card does not come from this lookup at all -- see 2. That is better news than the plan assumed: the thing we want to prove is reachable as a plain DATA write into an existing live buffer. No instruction patching anywhere, and the restore is just writing the old bytes back. ========================================================================== 2. WHERE THE BLANK CARD REALLY COMES FROM (this is the real find) ========================================================================== The item parser FUN_18013fe00 builds a record on its own stack at RBP+0x160, then does two things in this order: 0x180141020 CALL 0x180141660 <-- LOCAL-DB MERGE (first) 0x180141176 CALL [R9+0xa08] <-- the map lookup (second) FUN_180141660 switches on record+0x4c (the card TYPE) and queries FIFA's OWN local card database: case 1 -> FUN_180135890 players case 2 -> FUN_1801356c0 case 3 -> "headcoachcards" case 4 -> "fitnesscoachcards" case 5 -> "gkcoachcards" case 10 -> "physiocards" FUN_180135890 is the player path (decompile 7425 chars). It reads assetId = *(record + 0x18) & 0xFFFFFF and runs SELECT ... FROM players WHERE playerid = assetId. ON A DATABASE MISS it stamps, verbatim: record+0xb4 = 0x32 (= 50) <-- "rating 50" record+0x98..+0xac = 1,1,1,1,1,1 <-- "every attribute 1" record+0x146 = 2 <-- the position rendered as RWB record+0x148 = 0xe (nation 14) record+0x94 = 0x78d (team 1933) record+0xdd = DAT_1801eaf98 = " " <-- a single space: no name record+0xb8 = " " (firstname buffer) ON A HIT it writes the name from the local DB: FUN_1800081b0(record+0xdd, , 0x1f) FUN_1800081b0(record+0xb8, , 0x10) and it fills nation/team/league ONLY IF they are still zero. It does NOT touch record+0xb4 or record+0x98..+0xac on a hit. That single fact explains the whole 2026-08-04 REFUTED observation in docs/CARD_SYSTEM.md, exactly, with no residue: * SILVA / NOWAK resolved because their assetIds ARE in the local players table -> name came from FIFA's DB, and rating + the six hand-invented attributes survived from OUR JSON because the HIT path never overwrites them. That is why invented numbers appeared on screen. * The three blanks were assetIds NOT in the players table -> the MISS path stamped 50 / all-ones / RWB / no name over everything we sent. It also answers the "TODO/CONFIRM" left at the end of that document (does a resolved card's rating come from our JSON?) statically: YES on a DB hit, because the merge's hit path contains no write to record+0xb4. ========================================================================== 3. WHAT THIS SCRIPT ACTUALLY DOES ========================================================================== 1. finds FIFA17.exe and CardsDLL's live base from /proc/PID/maps 2. reads the CardsDb singleton (static slot 0x1802e6398) 3. walks the std::map at CardsDb+0x160c0 and decodes every record through the card view-model's OWN offsets, flagging which records carry the DB-MISS signature from section 2. Read-only; always runs; this census alone is worth the trip. 4. self-check: for every record, follow record+0x00 (the observer-list head) to an item and confirm item+0x10 points back at that record. This is what makes a null result interpretable instead of ambiguous. 5. with --fire: writes a DIFFERENTIAL beacon into TWO records -- flavour A into one, flavour B into another -- and deliberately leaves every other record alone as a negative control. Backups are written to disk BEFORE any process memory is touched. 6. --restore puts the original bytes back. Reversibility: the full 0x158-byte record is snapshotted to .bin, and the manifest records every (offset, original bytes, new bytes). --restore rewrites ONLY the ranges we wrote, and only where our beacon is still present -- never the whole record, because the record also carries live intrusive-list pointers that legitimately change between patch and restore. Killing FIFA also clears everything: this is live memory only, nothing is persisted in the game. Needs ptrace access: tools/root_arm.sh (kernel.yama.ptrace_scope=0) USAGE python3 tools/card_proof.py # read-only census python3 tools/card_proof.py --fire --a --b python3 tools/card_proof.py --restore /tmp/openfut_cardproof_<...>.json ========================================================================== 4. EVERY ADDRESS BELOW WAS RESOLVED THIS SESSION ========================================================================== 0x1802e6398 CardsDb singleton slot. Getter FUN_18011a830 is 2 instructions and returns DAT_1802e6398. Six xrefs total to the slot. 0x18021c2a0 CardsDb vtable; the qword 0x18011cca0 occurs EXACTLY ONCE in the whole image, at 0x18021cca8 = 0x18021c2a0 + 0xa08. CardsDb+0x160c0 std::map; +0x160c8 header node, +0x160d8 root, +0x160e8 size node: left +0x00, right +0x08, parent +0x10, colour +0x18, key(item id) +0x20, record +0x28 record size 0x158. ctor 0x180041250 zeroes explicitly through +0xb7 then memset(+0xb8, 0, 0xa0) -> 0xb8+0xa0 = 0x158. Its non-zero defaults are +0x48 word 0x100, +0x4c dword 0xffffffff, +0x50 qword 0x156, +0x70 = &PTR_LAB_1801eaac0. Rating/attrs/position/nation default to ZERO -- the 50/1/RWB blank is the DB-miss stamp, not the ctor. assignment operator 0x1800515e0 copies +0x08 .. +0x157 and re-splices the intrusive list at +0x70/+0x78/+0x80. It never touches +0x00. record+0x00 is the observer-list head; item+0x08 is the intrusive next; item+0x10 is the record pointer (FUN_1800419b0). So the view-model's *(item+0x10) is exactly node+0x28. Card view-model FUN_1800d7920 -- full decompile 1843 chars, 77 instructions, complete coverage. rec = *(item+0x10): out[0] = dword rec+0x18 (resourceId) out[1] = dword rec+0x18 & 0xFFFFFF (assetId) out[2] = dword rec+0x94 (teamid) out[3] = word rec+0x148 (nation) out[4] = byte rec+0xb4 (RATING) out[5] = byte rec+0x146 (position) out[6] = dword rec+0x88 (league) out[7] = dword rec+0x58 out+0x20 bool = (rec+0xb5 != 0) && (rec+0xb6 == 0) out+0x21 bool = (int)rec+0x90 > 0 out+0x22 = 0x20 bytes from rec+0xdd, BUT if strlen(rec+0xdd)==0 it takes them from rec+0xc8 instead out+0x42..47 = the six attributes read as SINGLE BYTES from the dword slots rec+0x98,+0x9c,+0xa0,+0xa4,+0xa8,+0xac Note the last one: the attributes are byte-truncated on read, so any value over 255 wraps. Earlier notes described these as dwords, which is true of the storage but not of the render. The THREE name fields, all written by FUN_180135890 from the local players table (Ghidra prints 0xc8 as decimal 200, which is why an earlier grep missed the middle one): FUN_1800081b0(rec+0xb8, firstname, 0x10) FUN_1800081b0(rec+0xc8, lastname, 0x15) FUN_1800081b0(rec+0xdd, knownAs, 0x1f) and on a DB miss all three get DAT_1801eaf98 = " " (one space). Because a space is not an empty string, the view-model's strlen(rec+0xdd) test passes and a blank card renders a SPACE rather than falling back to +0xc8. ========================================================================== 5. OUTCOMES -- what each result proves. READ THIS BEFORE FIRING. ========================================================================== Confirmed read-only, live, before any write was contemplated: 164 records, map size field agreed with the walk, and all 164 backlinks resolved (record+0x00 -> item -> +0x10 -> that same record). Records held real data: assetId 20801, rating 94, attrs [90,93,82,91,33,80], firstname "Cristiano", lastname "Ronaldo", knownAs "". So the record-offset model and the address chain are already confirmed as a MEMORY model. What the write still buys is the RENDER link: proof that the view-model re-reads this buffer and that what it reads reaches the screen. BOTH cards change, each to its own flavour, third card unchanged Total success. The record is the single source of truth for the card face, the view-model re-reads it per redraw, and per-record resolution works. Combined with section 2 this closes the card problem entirely: it is a DATA problem (ship assetIds that exist in FIFA's players table) and no injection is ever needed in production. Numbers change but the NAME does not The most likely partial, and the informative one. It means the record layout is right and the view-model re-read it, but the name string was resolved once and cached above the view-model -- the 0x20 bytes it copies to out+0x22 land in a struct that a hover-redraw does not rebuild. Verdict: rating/attrs/nation/team are live-patchable, the name is not, and any name work must go through the assetId -> players table route rather than through this buffer. The NAME changes but the numbers do not Would mean we patched a record that is not the one driving those pixels, i.e. two records exist for one card. Re-run the census and check for a second node with the same assetId. Do not conclude anything about layout from this; conclude the target was wrong. NEITHER card changes, but the backlink check passed The redraw did not call FUN_1800d7920 at all. This is a redraw problem, not a model problem. Escalate the redraw (open Player Details) but NOT by switching tabs -- a tab switch refetches, the parser rebuilds the record, the merge re-stamps it and the copy inside 0x18011cca0 overwrites the beacon, which would look identical to a failure and would be a false negative. Both cards change to the SAME values The view-model is reading one shared record for every card. That would falsify per-record resolution and is the one outcome that would send us back to the resolve path. This is precisely why the beacon is differential and why a third card is left untouched. A field changes on screen to something OTHER than the beacon Read it as an enum decode, not as a failure: position 0 and 1 and nation 38 and 14 are deliberately valid values, so the on-screen label tells us the enum mapping. Position 2 is already known to render as RWB, from the DB-miss stamp. """ import argparse import glob import json import os import struct import sys import time # ------------------------------------------------------------------ constants IMG_BASE = 0x180000000 DLL = "CardsDLL" G_CARDSDB = 0x1802E6398 MAP_BASE = 0x160C0 MAP_HEADER = 0x160C8 MAP_ROOT = 0x160D8 MAP_SIZE = 0x160E8 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 or the map key. # 0x00..0x08 observer-list head (FUN_1800419b0 splices items onto it) # 0x08..0x10 the map key; changing it desynchronises node+0x20 and the tree # 0x70..0x88 the embedded intrusive-list node the assignment operator # re-splices rather than copies # Every write is checked against this and the script dies rather than proceed. FORBIDDEN = ((0x00, 0x10), (0x70, 0x88)) # The exact stamp FUN_180135890 writes when `players WHERE playerid=assetId` # returns nothing. A record matching this is a card that rendered blank. DB_MISS = { 0x0B4: ("u8", 0x32), 0x098: ("u32", 1), 0x09C: ("u32", 1), 0x0A0: ("u32", 1), 0x0A4: ("u32", 1), 0x0A8: ("u32", 1), 0x0AC: ("u32", 1), 0x146: ("u8", 2), 0x148: ("u16", 0xE), 0x094: ("u32", 0x78D), } # ------------------------------------------------------------------- beacons -- # TWO flavours, deliberately different in EVERY field. One record gets A, a # second gets B, and every other record is left untouched as a negative # control. A single fixed beacon cannot distinguish "the view-model re-read # our record" from "that card already looked like that"; two different ones, # plus an untouched third, can. # # resourceId (rec+0x18) is deliberately NOT in the beacon. It is the key the # local DB query and any art/face lookup use, so changing it would confound # the very thing we are measuring. --also-resourceid is a separate, later # pass, one variable at a time. # # Values are chosen so each is unmistakable in a screenshot: # rating 99 / 11 -- no card in our pool is either # attrs 11..66 / 66..11 -- also reveals the on-card ORDER of the six # name pure ASCII, cannot come from FIFA's own player DB BEACONS = { "A": [ (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", 0), (0x148, "u16", "nation", 38), (0x0DD, "str", "NAME (vm source)", "OPENFUT-A"), ], "B": [ (0x094, "u32", "teamid", 9), (0x098, "u32", "attr0", 66), (0x09C, "u32", "attr1", 55), (0x0A0, "u32", "attr2", 44), (0x0A4, "u32", "attr3", 33), (0x0A8, "u32", "attr4", 22), (0x0AC, "u32", "attr5", 11), (0x0B4, "u8", "RATING", 11), (0x146, "u8", "position enum", 1), (0x148, "u16", "nation", 14), (0x0DD, "str", "NAME (vm source)", "OPENFUT-B"), ], } # The game itself writes the name with FUN_1800081b0(rec+0xdd, src, 0x1f), so # 0x1f is the length the record is built for. We never exceed it. NAME_MAX = 0x1F KIND_LEN = {"u8": 1, "u16": 2, "u32": 4} def encode(kind, value): if kind == "u8": return struct.pack("= NAME_MAX: raise SystemExit("REFUSING: name %r is %d bytes, max %d" % (value, len(b), NAME_MAX - 1)) return b + b"\0" raise ValueError(kind) def check_write(off, length): """Die unless [off, off+length) is a safe scalar range inside 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/key range %#x..%#x" % (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): lo = None try: for line in open("/proc/%d/maps" % pid): if name in line: a = int(line.split("-")[0], 16) lo = a if lo is None else min(lo, a) except Exception: return None return lo class Mem(object): def __init__(self, pid, writable=False): self.pid = pid self.f = open("/proc/%d/mem" % pid, "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).decode("ascii", "replace") return { "id(+0x08)": struct.unpack_from(" item, check item+0x10 == rec. This is the check that makes a null result interpretable: if it holds, the record we are about to patch really is the one the view-model dereferences. Returns (verdict_string, item_addr_or_None). """ head = mem.q(rec + 0x00) if head is None: return ("record unreadable", None) if head == 0: return ("no observer -- this record is not bound to a rendered item", None) back = mem.q(head + 0x10) if back is None: return ("observer %#x unreadable" % head, head) if back == rec: return ("OK item %#x -> +0x10 -> this record" % head, head) return ("MISMATCH item %#x +0x10 = %#x, expected %#x" % (head, back, rec), head) 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 (GET /squad/0) or open the club, then re-run.") return [] blanks = [] print() for node, key in nodes: rec = node + NODE_REC buf = mem.try_read(rec, REC_SIZE) if buf is None: print(" item id %-12d node %#x " % (key, node)) continue d = decode_record(buf) miss = is_db_miss(buf) if miss: blanks.append((key, node)) print(" item id %-12d node %#x record %#x %s" % (key, node, rec, "<< DB-MISS BLANK" if miss else "")) print(" assetId %-9d rating %-4d pos %-4d nation %-5d team %-6d" % (d["assetId(low24)"], d["rating(+0xb4)"], d["position(+0x146)"], d["nation(+0x148)"], d["teamid(+0x94)"])) print(" attrs %-24s cardType %s" % (d["attrs(+0x98..ac)"], d["cardType(+0x4c)"])) print(" name(+0xdd) %-14r first(+0xb8) %-14r fallback(+0xc8) %r" % (d["name(+0xdd)"], d["firstname(+0xb8)"], d["fallback(+0xc8)"])) if not d["name(+0xdd)"]: print(" -> +0xdd is EMPTY, so the view-model renders the " "+0xc8 fallback instead") verdict, _ = backlink_ok(mem, rec) print(" backlink: %s" % verdict) print() print(" %d of %d records carry the players-table DB-MISS stamp" % (len(blanks), len(nodes))) if blanks: print(" blank item ids: %s" % ", ".join(str(k) for k, _ in blanks)) print(" Those are the best patch targets: they are the cards that") print(" currently render generic, so ANY change is unambiguous.") else: print(" Every assetId in play resolved against FIFA's local players") print(" table, so there is no generic card to patch right now. Patch") print(" two RESOLVED cards instead: the beacon values are chosen so") print(" they cannot be confused with real ones, and doing it on a") print(" resolved card additionally answers the name question, because") print(" a resolved card is exactly the case where +0xdd is empty and") print(" the +0xc8 fallback is being rendered.") return blanks # --------------------------------------------------------------------- patch -- def plan_writes(snap, flavour): writes = [] for off, kind, name, value in BEACONS[flavour]: new = encode(kind, value) check_write(off, len(new)) writes.append({"off": off, "len": len(new), "name": name, "flavour": flavour, "orig_hex": snap[off:off + len(new)].hex(), "new_hex": new.hex()}) return writes def do_patch(mem, nodes, targets, backup_path): """targets = [(item_id, flavour), ...]""" chosen = [] for item_id, flavour in targets: 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] chosen.append((node, key, flavour)) manifest = {"tool": "card_proof.py", "ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "pid": mem.pid, "records": []} # --- snapshot EVERYTHING before touching the process --------------------- for node, key, flavour in chosen: rec = node + NODE_REC snap = mem.read(rec, REC_SIZE) binpath = "%s.item%d.bin" % (os.path.splitext(backup_path)[0], key) with open(binpath, "wb") as f: f.write(snap) manifest["records"].append({ "item_id": key, "node": node, "record": rec, "flavour": flavour, "snapshot": binpath, "writes": plan_writes(snap, flavour), }) with open(backup_path, "w") as f: json.dump(manifest, f, indent=2) print(" backup manifest: %s" % backup_path) for r in manifest["records"]: print(" %s (%d bytes)" % (r["snapshot"], REC_SIZE)) # --- only now do we write ------------------------------------------------ ok = True for r in manifest["records"]: print("\n flavour %s -> item %d, record %#x" % (r["flavour"], r["item_id"], r["record"])) verdict, _ = backlink_ok(mem, r["record"]) print(" backlink before write: %s" % verdict) for w in r["writes"]: mem.write(r["record"] + w["off"], bytes.fromhex(w["new_hex"])) back = mem.read(r["record"] + w["off"], w["len"]).hex() if back != w["new_hex"]: ok = False print(" %s +%#05x %-18s %s -> %s" % ("OK " if back == w["new_hex"] else "FAIL", 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(" Beacons in place. DO NOT switch tabs: a tab switch refetches") print(" /squad/0 or /club, the parser rebuilds the record, the local-DB") print(" merge re-stamps it and the copy at 0x18011cca0 overwrites ours.") print(" Force a REDRAW only: move the cursor onto and off the card, or") print(" open and close Player Details.") 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 went with it " "(live memory only)." % 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) for r in m["records"]: rec = r["record"] print("restoring item %d, record %#x (%d ranges)" % (r["item_id"], rec, len(r["writes"]))) for w in r["writes"]: cur = mem.read(rec + w["off"], w["len"]).hex() if cur != w["new_hex"]: print(" SKIP +%#05x holds %s, not our beacon %s -- the game " "rewrote it; restoring would be wrong." % (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 %-18s -> %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) live FUT card records.") ap.add_argument("--a", type=int, metavar="ITEMID", help="item id to receive beacon flavour A") ap.add_argument("--b", type=int, metavar="ITEMID", help="item id to receive beacon flavour B (differential)") ap.add_argument("--fire", action="store_true", help="REQUIRED to write anything. Without it this is read-only.") ap.add_argument("--restore", metavar="MANIFEST.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 (static 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) blanks = print_census(mem, cdb, nodes) if not args.fire: print("\nREAD-ONLY. Nothing was written.") print("To run the experiment pick two ids from above (ideally two") print("DB-MISS blanks) and add: --a --b --fire") return 0 if not nodes: print("nothing to patch.") return 1 if args.a is None or args.b is None: print("--fire needs BOTH --a and --b .") print("The differential is the point: one beacon cannot distinguish a") print("re-read from a coincidence, and a third untouched card is the") print("negative control.") if blanks: print("Suggested: --a %d --b %s" % (blanks[0][0], blanks[1][0] if len(blanks) > 1 else "")) return 1 if args.a == args.b: print("--a and --b must be different records.") return 1 backup = "/tmp/openfut_cardproof_%d_%d.json" % (pid, int(time.time())) return do_patch(mem, nodes, [(args.a, "A"), (args.b, "B")], backup) if __name__ == "__main__": sys.exit(main())