#!/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,  <name>, 0x1f)
        FUN_1800081b0(record+0xb8,  <firstname>, 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 <manifest.json> puts the original bytes back.

Reversibility: the full 0x158-byte record is snapshotted to <backup>.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 <id> --b <id>
    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("<B", value & 0xFF)
    if kind == "u16":
        return struct.pack("<H", value & 0xFFFF)
    if kind == "u32":
        return struct.pack("<I", value & 0xFFFFFFFF)
    if kind == "str":
        b = value.encode("ascii", "replace")
        if len(b) >= 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("<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 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):
        raw = bytes(buf[o:o + n])
        z = raw.find(b"\0")
        return (raw[:z] if z >= 0 else raw).decode("ascii", "replace")

    return {
        "id(+0x08)": struct.unpack_from("<Q", buf, 0x08)[0],
        "resourceId(+0x18)": u32(0x18),
        "assetId(low24)": u32(0x18) & 0xFFFFFF,
        "cardType(+0x4c)": i32(0x4C),
        "league(+0x88)": u32(0x88),
        "teamid(+0x94)": u32(0x94),
        "attrs(+0x98..ac)": [u8(0x98 + 4 * i) for i in range(6)],
        "rating(+0xb4)": u8(0xB4),
        "name(+0xdd)": s(0xDD, NAME_MAX),
        "firstname(+0xb8)": s(0xB8, 0x10),
        "fallback(+0xc8)": s(0xC8, 0x15),
        "position(+0x146)": u8(0x146),
        "nation(+0x148)": u16(0x148),
        "observers(+0x00)": struct.unpack_from("<Q", buf, 0x00)[0],
    }


def is_db_miss(buf):
    """True if this record carries FUN_180135890's players-table MISS stamp."""
    for off, (kind, want) in DB_MISS.items():
        n = KIND_LEN[kind]
        got = int.from_bytes(bytes(buf[off:off + n]), "little")
        if got != want:
            return False
    return True


def backlink_ok(mem, rec):
    """Follow record+0x00 (observer head) -> 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  <record unreadable>" % (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 <id> --b <id> --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 <id> and --b <id>.")
        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 "<another id>"))
        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())
