70a64e3709
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.
591 lines
23 KiB
Python
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())
|