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.
This commit is contained in:
@@ -0,0 +1,590 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user