#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Watch FIFA 17's FUT club / pile model live -- READ-ONLY, no patching.

WHY THIS EXISTS
---------------
Two problems resisted static analysis because the deciding logic may live in the
Denuvo-packed FIFA17.exe (no code on disk):

  (1) the FUT hub tab bar shows "MY CLUB  0" while the club holds ~99 items;
  (2) "Send to Club" (PUT ut/%s/item) kills the FUT session.

Static RE (this session) located the structures below inside CardsDLL. What it
could NOT locate is the exact field the "MY CLUB" badge reads. So this tool does
two things at once:

  * WATCH the fields we DID identify (CardsDb card map, FUT session state), and
  * DIFF-SCAN the whole CardsDb object so the unknown counter reveals ITSELF when
    the human performs a labelled action in game.

It is the same pattern as tools/watch_online_mode.py: poll a CardsDLL singleton
through /proc/PID/mem while the game runs.

READ-ONLY GUARANTEE
-------------------
/proc/PID/mem is opened 'rb' and only ever seek()/read(). There is no write path
in this file. It cannot corrupt a save or a running process.

WHAT IS VERIFIED AND WHAT IS NOT
--------------------------------
VERIFIED STATICALLY (Ghidra, CardsDLL_Win64_retail.dll @ 0x180000000):
  * 0x1802e6398  CardsDb singleton pointer   (getter FUN_18011a830, vtable 0x18021c2a0)
  * CardsDb+0x160c0..0x160e8  card item tree: root = *(obj+0x160d8),
    sentinel/end = obj+0x160c8, node {childA+0x00, childB+0x08, parent+0x10,
    key(itemId)+0x20, record+0x28}. Walked by the resolve FUN_18011cca0
    (vtable +0xa08) and by FUN_18011cf40 (vtable +0xa30, which clears
    record+0x10 = tradeId).
  * 0x1802df338  ION_CardInventory adapter object pointer (registration
    FUN_18003d070; bindings GetUserCardIDs / GetCardIDsForPile / GetListItemData
    dispatch through its vtable slots +0x08 / +0x38 / +0x30).
  * 0x1802e6328  FUT CompetitionManager (already proven by watch_online_mode.py).

VERIFIED LIVE (read-only probe of a running FIFA17.exe, 2026-08-04, main menu,
FUT session already torn down):
  * all four globals resolve to non-NULL objects; the whole 0x22000 window reads.
  * the tree walk returned 11 nodes and CardsDb+0x160e8 read 11 -- so +0x160e8 is
    the tree's SIZE field and the walk agrees with it. Both are reported; a
    mismatch between them means the walk went wrong, not the game.
  * node keys were 100000001..100000025 -- OUR seeded item ids. This tree is the
    client's ITEM store, keyed by item id (not by resourceId).
  * session.phase / ready / stackIdx were all -1 (FUT session dead), consistent
    with the morning's kill.

STILL UNVERIFIED:
  * whether the "MY CLUB" counter lives inside the CardsDb object at all. If the
    diff scan reports nothing during the MY CLUB phase, that is itself the
    finding: the counter is NOT in CardsDb and lives in FIFA17.exe's own model.
  * the `pile` field offset inside a node record. The 11 nodes seen live were all
    the same pile, so nothing varied and no offset could be pinned. `items.count`
    below is therefore a TOTAL, not a per-pile figure.
  * every value observed during actual gameplay (nobody has run this while
    opening MY CLUB, opening a pack, or pressing Send to Club).

USAGE
-----
  python3 tools/watch_club_model.py                 # everything, default filters
  python3 tools/watch_club_model.py --all           # no value filtering (noisy)
  python3 tools/watch_club_model.py --no-scan       # session + tree only
  python3 tools/watch_club_model.py --offsets 0x160c8,0x1234   # lock on candidates
  python3 tools/watch_club_model.py --calib 15      # longer idle calibration

While it runs, TYPE A LABEL + ENTER to mark what you are about to do, e.g.
    hub<Enter>   myclub<Enter>   pack<Enter>   send<Enter>
Every later line is tagged with that label. Ctrl-C prints a SHORT SUMMARY --
paste the summary, not the stream.
"""

import argparse
import glob
import os
import select
import struct
import sys
import time
from array import array

# ---------------------------------------------------------------- constants --
IMG_BASE = 0x180000000
DLL = "CardsDLL"

# Globals inside CardsDLL (static VAs; rebased to the live mapping at runtime).
G_CARDSDB = 0x1802E6398   # CardsDb singleton  (FUN_18011a830 returns this)
G_CARDINV = 0x1802DF338   # ION_CardInventory adapter object
G_CARDINV2 = 0x1802DF348  # second slot written by FUN_18003d360
G_COMPMGR = 0x1802E6328   # FUT::CompetitionManager (see watch_online_mode.py)

# CompetitionManager fields, proven by watch_online_mode.py.
CM_PHASE, CM_READY, CM_STKIDX = 0x218, 0x6D4, 0x214
CM_READY_OK = 0x1FBD0

# CardsDb card/definition tree (see module docstring).
TREE_BASE = 0x160C0      # tree object base (passed to the inserter FUN_180115c30)
TREE_END = 0x160C8       # sentinel node address == obj + this
TREE_P1 = 0x160D0        # anchor slot 1 (iteration start in FUN_18011cf40)
TREE_ROOT = 0x160D8      # root (walk start in FUN_18011cca0)
TREE_SIZE = 0x160E8      # node count -- LIVE-VERIFIED: read 11 while the walk found 11

NODE_A, NODE_B, NODE_KEY = 0x00, 0x08, 0x20  # node children + key(itemId)
NODE_TRADEID = 0x38      # record+0x10; FUN_18011cf40 zeroes it on a successful move

# The scan window over the CardsDb object. 0x20d10 is the highest offset any
# decompiled CardsDb method touches, so 0x22000 is a safe upper bound; the tool
# probes downward if the tail is not mapped.
SCAN_LEN_DEFAULT = 0x22000
PAGE = 0x1000

MAX_NODES = 200000       # hard cap so a corrupt/garbage tree can never hang us
MAX_REPORTS_PER_POLL = 40


# ------------------------------------------------------------------ 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)
    except Exception:
        return None
    return None


class Mem(object):
    """Read-only /proc/PID/mem accessor. Every failure is reported, never raised."""

    def __init__(self, pid):
        self.pid = pid
        self.fails = 0
        self.f = open("/proc/%d/mem" % pid, "rb")   # 'rb' -- read-only, by design

    def read(self, va, n):
        try:
            self.f.seek(va)
            b = self.f.read(n)
            if b is None or len(b) != n:
                self.fails += 1
                return None
            return b
        except Exception:
            self.fails += 1
            return None

    def read_pages(self, va, n):
        """Read n bytes, page by page. Returns (bytearray, set_of_bad_page_idx)."""
        buf = bytearray(n)
        bad = set()
        for off in range(0, n, PAGE):
            ln = min(PAGE, n - off)
            b = self.read(va + off, ln)
            if b is None:
                bad.add(off // PAGE)
            else:
                buf[off:off + ln] = b
        return buf, bad

    def q(self, va):
        b = self.read(va, 8)
        return struct.unpack("<Q", b)[0] if b else None

    def i32(self, va):
        b = self.read(va, 4)
        return struct.unpack("<i", b)[0] if b else None

    def alive(self):
        return os.path.exists("/proc/%d" % self.pid)


# --------------------------------------------------------------- structures --
def walk_tree(mem, obj):
    """Count nodes in the CardsDb card/definition tree, bounded and defensive.

    Returns (count, note). count is None when the tree could not be walked.
    Generic DFS over BOTH child slots with a visited set -- the exact
    left/right convention does not matter for a count, and a cycle or a garbage
    pointer terminates the walk instead of hanging it.
    """
    root = mem.q(obj + TREE_ROOT)
    end = obj + TREE_END
    if root is None:
        return None, "root unreadable"
    if root == 0 or root == end:
        return 0, "empty"
    seen = set()
    stack = [root]
    n = 0
    truncated = False
    while stack:
        p = stack.pop()
        if p == 0 or p == end or p in seen:
            continue
        if p & 7:                      # nodes are 8-byte aligned; anything else is garbage
            continue
        if n >= MAX_NODES:
            truncated = True
            break
        seen.add(p)
        n += 1
        for slot in (NODE_A, NODE_B):
            c = mem.q(p + slot)
            if c is None:
                truncated = True
                continue
            if c and c != end and c not in seen:
                stack.append(c)
    return n, ("TRUNCATED at %d" % MAX_NODES) if truncated else "ok"


def tree_items(mem, obj, limit=MAX_NODES):
    """{itemId: tradeId} for every node in the tree. Bounded and defensive.

    Returns None when the tree could not be read at all. This is the answer to
    "does the client actually hold all 99 club items, or only the squad?" --
    if the count stays at ~11 while MY CLUB displays 99 players, the client is
    rendering a fetch result it never ingested into this store.
    """
    root = mem.q(obj + TREE_ROOT)
    end = obj + TREE_END
    if root is None:
        return None
    if root == 0 or root == end:
        return {}
    out, seen, stack = {}, set(), [root]
    while stack and len(out) < limit:
        p = stack.pop()
        if not p or p == end or p in seen or (p & 7):
            continue
        seen.add(p)
        k = mem.q(p + NODE_KEY)
        if k is not None:
            out[k] = mem.q(p + NODE_TRADEID)
        for slot in (NODE_A, NODE_B):
            c = mem.q(p + slot)
            if c and c != end and c not in seen:
                stack.append(c)
    return out


def snapshot_named(mem, base):
    """The identified fields, as a labelled dict. Missing/failed reads -> None."""
    s = {}
    cdb = mem.q(base + (G_CARDSDB - IMG_BASE))
    inv = mem.q(base + (G_CARDINV - IMG_BASE))
    inv2 = mem.q(base + (G_CARDINV2 - IMG_BASE))
    cm = mem.q(base + (G_COMPMGR - IMG_BASE))
    s["CardsDb.ptr"] = cdb
    s["CardInventory.ptr"] = inv
    s["CardInventory2.ptr"] = inv2
    s["CompetitionMgr.ptr"] = cm

    if cm:
        s["session.phase"] = mem.i32(cm + CM_PHASE)
        s["session.ready"] = mem.i32(cm + CM_READY)
        s["session.stackIdx"] = mem.i32(cm + CM_STKIDX)

    if cdb:
        for off, nm in ((TREE_BASE, "tree.base"), (TREE_END, "tree.anchor0"),
                        (TREE_P1, "tree.anchor1"), (TREE_ROOT, "tree.root")):
            s["cdb+%#x %s" % (off, nm)] = mem.q(cdb + off)
        size = mem.q(cdb + TREE_SIZE)
        s["items.size(+0x160e8)"] = size
        n, note = walk_tree(mem, cdb)
        s["items.walkCount"] = n
        if n is not None and size is not None and n != size:
            s["items.walkNote"] = "%s  MISMATCH vs size field" % note
        elif note != "ok":
            s["items.walkNote"] = note
    return s


# -------------------------------------------------------------- diff engine --
def probe_scan_len(mem, obj, want):
    """Largest readable window <= want, rounded to pages."""
    n = want
    while n >= PAGE:
        if mem.read(obj + n - PAGE, PAGE) is not None:
            return n
        n -= PAGE
    return 0


class Differ(object):
    """Dword-level differ over one memory window, with hot-offset suppression."""

    def __init__(self, base_va, length, value_filter=True):
        self.va = base_va
        self.len = length
        self.prev = None
        self.hot = set()          # offsets that churn while idle -> ignored
        self.changes = {}         # offset -> [values seen]
        self.value_filter = value_filter

    def _interesting(self, old, new):
        if not self.value_filter:
            return True
        # Counter-like: small signed ints on both sides.
        if -1 <= old <= 100000 and -1 <= new <= 100000:
            return True
        # A field going to/from zero (pointer or count clear) is worth seeing.
        return old == 0 or new == 0

    def poll(self, mem, calibrating):
        buf, bad = mem.read_pages(self.va, self.len)
        cur = array("i")
        cur.frombytes(bytes(buf))
        if self.prev is None:
            self.prev = cur
            return [], bad
        out = []
        prev = self.prev
        n = len(cur)
        for i in range(n):
            a = prev[i]
            b = cur[i]
            if a == b:
                continue
            off = i * 4
            if (off >> 12) in bad:
                continue
            if calibrating:
                self.hot.add(off)
                continue
            if off in self.hot:
                continue
            if not self._interesting(a, b):
                self.hot.add(off)          # noisy pointer-ish churn, drop it for good
                continue
            out.append((off, a, b))
            self.changes.setdefault(off, [a]).append(b)
        self.prev = cur
        return out, bad


# -------------------------------------------------------------------- marks --
def read_marker():
    """Non-blocking read of a phase label from stdin. Returns str or None."""
    try:
        r, _, _ = select.select([sys.stdin], [], [], 0)
    except Exception:
        return None
    if not r:
        return None
    line = sys.stdin.readline()
    if not line:
        return None
    return line.strip() or "(blank)"


def fmt(v):
    if v is None:
        return "UNREADABLE"
    if isinstance(v, str):
        return v
    if isinstance(v, int) and abs(v) > 0xFFFF:
        return "%#x" % v
    return str(v)


# --------------------------------------------------------------------- main --
def main():
    ap = argparse.ArgumentParser(description="Read-only live watch of FIFA 17's FUT club/pile model")
    ap.add_argument("--interval", type=float, default=0.5, help="poll seconds (default 0.5)")
    ap.add_argument("--calib", type=float, default=10.0,
                    help="idle calibration seconds; offsets that churn during this "
                         "window are suppressed forever (default 10)")
    ap.add_argument("--scan-len", type=lambda s: int(s, 0), default=SCAN_LEN_DEFAULT,
                    help="bytes of the CardsDb object to diff (default 0x22000)")
    ap.add_argument("--no-scan", action="store_true", help="named fields only, no diff scan")
    ap.add_argument("--all", action="store_true", help="report every changed dword (noisy)")
    ap.add_argument("--offsets", default="",
                    help="comma-separated CardsDb offsets to always report, e.g. 0x160c8,0x1f00")
    args = ap.parse_args()

    pid = find_pid()
    if pid is None:
        print("FIFA17.exe is not running. Start the game, reach the FUT hub, then run this.")
        return 1
    base = dll_base(pid)
    if base is None:
        print("FIFA17.exe (pid %d) is running but %s is not mapped yet." % (pid, DLL))
        print("Wait until FIFA reaches the main menu / FUT hub and run again.")
        return 1
    try:
        mem = Mem(pid)
    except Exception as e:
        print("cannot open /proc/%d/mem: %s" % (pid, e))
        print("Need ptrace_scope=0:  sudo sysctl -w kernel.yama.ptrace_scope=0")
        return 1

    print("FIFA pid=%d  %s base=%#x" % (pid, DLL, base))
    named = snapshot_named(mem, base)
    for k in ("CardsDb.ptr", "CardInventory.ptr", "CardInventory2.ptr", "CompetitionMgr.ptr"):
        print("  %-22s %s" % (k, fmt(named.get(k))))
    cdb = named.get("CardsDb.ptr")
    if not cdb:
        print("\nCardsDb singleton is NULL -- the FUT layer has not been constructed yet.")
        print("Enter Ultimate Team first, then re-run. (Watching anyway.)")
    if not named.get("CardInventory.ptr"):
        print("  note: ION_CardInventory adapter is NULL -- the UI card model is not "
              "bound yet (expected outside the FUT hub).")

    differ = None
    if cdb and not args.no_scan:
        length = probe_scan_len(mem, cdb, args.scan_len)
        if length == 0:
            print("  CardsDb object not readable -- diff scan disabled.")
        else:
            if length != args.scan_len:
                print("  CardsDb readable window shrunk to %#x (tail unmapped)." % length)
            differ = Differ(cdb, length, value_filter=not args.all)
            print("  diff scan over CardsDb[0 .. %#x) (%d dwords)" % (length, length // 4))
    watch_offsets = []
    for tok in args.offsets.split(","):
        tok = tok.strip()
        if tok:
            try:
                watch_offsets.append(int(tok, 0))
            except ValueError:
                print("  bad --offsets value: %r (ignored)" % tok)

    print("\nCalibrating for %.0fs -- LEAVE THE GAME IDLE ON THE FUT HUB." % args.calib)
    print("After that, type a label + Enter before each action (hub / myclub / pack / send).")
    print("Ctrl-C prints the summary.\n")

    t0 = time.time()
    phase = "boot"
    last_named = {}
    last_ids = None
    item_hist = []
    marks = []
    poll_n = 0
    calib_done = False

    try:
        while True:
            if not mem.alive():
                print("[%s] FIFA17.exe exited." % time.strftime("%H:%M:%S"))
                break
            m = read_marker()
            if m is not None:
                phase = m
                marks.append((time.strftime("%H:%M:%S"), m))
                print("\n=========== PHASE: %s  (%s) ===========" % (m, time.strftime("%H:%M:%S")))

            calibrating = (time.time() - t0) < args.calib
            if calibrating and poll_n and poll_n % 4 == 0:
                sys.stdout.write("\r  calibrating... %.0fs left   "
                                 % (args.calib - (time.time() - t0)))
                sys.stdout.flush()
            poll_n += 1

            cur = snapshot_named(mem, base)
            for k, v in cur.items():
                if k in last_named and last_named[k] == v:
                    continue
                if k in last_named:
                    line = "[%s][%s] %-28s %s -> %s" % (
                        time.strftime("%H:%M:%S"), phase, k,
                        fmt(last_named[k]), fmt(v))
                    if k == "session.ready" and v == CM_READY_OK:
                        line += "   <== FUT SERVICE READY"
                    if k == "session.phase" and isinstance(v, int) and v < 0:
                        line += "   <== FUT SESSION TORN DOWN"
                    if k == "CardsDb.ptr" and not v:
                        line += "   <== CardsDb DESTROYED"
                    print(line)
                last_named[k] = v

            # --- item store membership: the direct answer to "does the client
            # --- actually hold the club, or only the squad?"
            if cdb:
                items = tree_items(mem, cdb)
                if items is None:
                    if last_ids is not None:
                        print("[%s][%s] item store unreadable" % (time.strftime("%H:%M:%S"), phase))
                    last_ids = None
                else:
                    ids = set(items)
                    if last_ids is None or ids != last_ids:
                        added = sorted(ids - (last_ids or set()))
                        gone = sorted((last_ids or set()) - ids)
                        print("[%s][%s] ITEM STORE  count=%d  (+%d / -%d)%s%s"
                              % (time.strftime("%H:%M:%S"), phase, len(ids),
                                 len(added), len(gone),
                                 "  added=%s" % added[:8] if added else "",
                                 "  removed=%s" % gone[:8] if gone else ""))
                        traded = [i for i, t in items.items() if t]
                        if traded:
                            print("            %d item(s) carry a tradeId (on the trade pile)"
                                  % len(traded))
                        item_hist.append((time.strftime("%H:%M:%S"), phase, len(ids)))
                        last_ids = ids

            if differ is not None:
                hits, bad = differ.poll(mem, calibrating)
                if bad and not calibrating:
                    print("[%s][%s] %d page(s) of the CardsDb window unreadable this poll"
                          % (time.strftime("%H:%M:%S"), phase, len(bad)))
                if hits and not calibrating:
                    shown = hits[:MAX_REPORTS_PER_POLL]
                    for off, a, b in shown:
                        tag = ""
                        if b == a + 1:
                            tag = "  (+1)"
                        elif b == a - 1:
                            tag = "  (-1)"
                        elif a == 0:
                            tag = "  (0 -> %d)" % b
                        elif b == 0:
                            tag = "  (%d -> 0)" % a
                        print("[%s][%s] cdb+%#07x  %d -> %d%s"
                              % (time.strftime("%H:%M:%S"), phase, off, a, b, tag))
                    if len(hits) > len(shown):
                        print("[%s][%s] ... and %d more changed dwords (use --offsets to lock on)"
                              % (time.strftime("%H:%M:%S"), phase, len(hits) - len(shown)))

            for off in watch_offsets:
                if not cdb:
                    break
                v = mem.i32(cdb + off)
                k = "watch cdb+%#x" % off
                if k in last_named and last_named[k] == v:
                    continue
                if k in last_named:
                    print("[%s][%s] %-28s %s -> %s" % (time.strftime("%H:%M:%S"), phase,
                                                       k, fmt(last_named[k]), fmt(v)))
                last_named[k] = v

            if not calibrating and not calib_done:
                calib_done = True
                print("\r  calibration done -- %d idle-churn offset(s) suppressed. "
                      "Label your actions now.        " % (len(differ.hot) if differ else 0))
            time.sleep(args.interval)
    except KeyboardInterrupt:
        print("\n\nstopped")

    # ------------------------------------------------------------- summary --
    print("\n" + "=" * 62)
    print("SUMMARY  (paste this)")
    print("=" * 62)
    print("pid=%d %s base=%#x   failed reads=%d" % (pid, DLL, base, mem.fails))
    print("phases marked: %s" % (", ".join("%s@%s" % (m, t) for t, m in marks) or "(none)"))
    print("\nitem-store count over time (the MY CLUB question):")
    if item_hist:
        for t, ph, n in item_hist:
            print("  %s  [%s]  %d items" % (t, ph, n))
    else:
        print("  (never read)")

    print("\nfinal named fields:")
    for k in sorted(last_named):
        print("  %-28s %s" % (k, fmt(last_named[k])))
    if differ is not None:
        print("\nsuppressed as idle-churn: %d offsets" % len(differ.hot))
        if differ.changes:
            print("candidate fields (changed only AFTER calibration), "
                  "most-changed last:")
            for off in sorted(differ.changes, key=lambda o: len(differ.changes[o])):
                vals = differ.changes[off]
                seq = " -> ".join(str(v) for v in vals[:12])
                if len(vals) > 12:
                    seq += " -> ... (%d values)" % len(vals)
                print("  cdb+%#07x : %s" % (off, seq))
        else:
            print("NO candidate fields changed after calibration.")
            print("If that held across the MY CLUB phase, the counter is NOT in the")
            print("CardsDb object -- it lives in FIFA17.exe's own view model.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
