Files
OpenFUT/fifa17-recon/docker/fifa17-python/tools/watch_club_model.py
T
root 70a64e3709 fifa17-python: commit working FUT backend deployment (client/server split)
Freeze the running offline FUT backend into version control as
fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a
fresh checkout:

* OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders
  (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is
  required for remote mode (compose and entrypoint fail without it)
* docker-compose.yml reproducing the frozen baseline container exactly
  (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart)
* .env.example / .env for site config - the LAN IP is never hardcoded in source
* tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10,
  verified byte-identical to the running container at freeze time
* client_arm.sh (the 105 client-side arming counterpart)
* Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying
* docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record,
  restore instructions and rebuild-equivalence procedure

Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored.
The live container is untouched pending the .105 launcher audit.
2026-08-10 23:54:04 +00:00

591 lines
23 KiB
Python

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