#!/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())
