#!/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 myclub pack send 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("= 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())