704482be84
Read-only watcher that waits for FIFA17.exe, re-resolves the club-item store
each tick (the manager is reallocated per login), and emits one timestamped line
per CHANGE. Pair it with
journalctl -u openfut-staging-host -o short-iso
to answer "after which response does a resident club item first appear?" by wall
clock, without having to reverse the constructor first.
Re-resolving per tick matters: the store is reached through
[CardsDLL+0x2e6398] -> vtable[0x4e8], and that getter is a `lea`, so the manager
is an embedded subobject whose address moves with the owner. The tool also
re-checks the module base against a known immediate every time it attaches and
refuses to report from a wrong base.
Verified against the currently live client: club=0/5 players=18/23.
Staging can host the session: every bootstrap route probed answers 200
(userMassInfo, squad/active, squad/list, club/stats/*, hub, user, club arms,
item idList, clubUser, season/list, watchList, purchased/items, settings,
clientdata) with the single exception of /statistics/tournament, which 502s
because staging's Python upstream is deliberately dead and which is not on the
club bootstrap path.
226 lines
7.7 KiB
Python
Executable File
226 lines
7.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Watch FIFA 17's resident club-item store and log every change, with timestamps.
|
|
|
|
Read-only. Waits for FIFA17.exe to appear, re-resolves the store each tick (the
|
|
manager is reallocated across logins), and appends one line per CHANGE so the
|
|
output can be aligned against the staging host's route log by wall clock.
|
|
|
|
Purpose: answer "after which response does a resident club item first appear?"
|
|
without reversing the constructor first. Pair with
|
|
|
|
journalctl -u openfut-staging-host --since <start> -o short-iso
|
|
|
|
and compare timestamps.
|
|
|
|
Usage: watch_residency.py [--interval 1.0] [--out /path/log] [--once]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import collections
|
|
import os
|
|
import re
|
|
import struct
|
|
import sys
|
|
import time
|
|
|
|
CARDS_DLL = "CardsDLL_Win64_retail.dll"
|
|
OWNER_GLOBAL = 0x1802E6398 # FUN_18011a830: mov rax,[this]; ret
|
|
SANITY_VA = 0x180026FEA # mov edx,0x7575
|
|
SANITY_BYTES = bytes.fromhex("ba75750000")
|
|
IMAGE_BASE = 0x180000000
|
|
|
|
# item-record offsets, all previously proven (see Vault: Kit Selector APT Decode)
|
|
OFF = {"cardtype": 0x4C, "cardsubtypeid": 0x50, "itemState": 0x5C,
|
|
"category": 0x60, "teamid": 0x94}
|
|
OFF_KITTYPE_U16 = 0xBA
|
|
|
|
|
|
class Target:
|
|
"""One live FIFA17.exe, with the store chain resolved."""
|
|
|
|
def __init__(self, pid: int):
|
|
self.pid = pid
|
|
self.mem = open(f"/proc/{pid}/mem", "rb", buffering=0)
|
|
self.base = self._cards_base()
|
|
if self.base is None:
|
|
raise RuntimeError("CardsDLL mapping not found")
|
|
probe = self.rd(self.live(SANITY_VA), 5)
|
|
if probe != SANITY_BYTES:
|
|
raise RuntimeError(f"base sanity failed: {probe.hex(' ')}")
|
|
owner = self.q(self.live(OWNER_GLOBAL))
|
|
if not owner:
|
|
raise RuntimeError("owner object is null (not logged in yet)")
|
|
vt = self.q(owner)
|
|
getter = self.q(vt + 0x4E8)
|
|
b = self.rd(getter, 8)
|
|
# lea rax,[rcx+imm32] ; ret / lea rax,[rcx+imm8] ; ret
|
|
if b[0:3] == bytes.fromhex("488d81"):
|
|
self.mgr = owner + struct.unpack_from("<I", b, 3)[0]
|
|
elif b[0:3] == bytes.fromhex("488d41"):
|
|
self.mgr = owner + b[3]
|
|
elif b[0:3] == bytes.fromhex("488b81"):
|
|
self.mgr = self.q(owner + struct.unpack_from("<I", b, 3)[0])
|
|
else:
|
|
raise RuntimeError(f"unrecognised getter: {b.hex(' ')}")
|
|
|
|
# -- raw access ------------------------------------------------------
|
|
def rd(self, a: int, n: int) -> bytes:
|
|
self.mem.seek(a)
|
|
return self.mem.read(n)
|
|
|
|
def q(self, a: int) -> int:
|
|
return struct.unpack("<Q", self.rd(a, 8))[0]
|
|
|
|
def live(self, static: int) -> int:
|
|
return self.base + (static - IMAGE_BASE)
|
|
|
|
def _cards_base(self):
|
|
named = []
|
|
for ln in open(f"/proc/{self.pid}/maps"):
|
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
|
if m:
|
|
named.append((int(m.group(1), 16), m.group(3).strip()))
|
|
# NEAREST PRECEDING NAMED mapping: Wine maps PE sections anonymously and
|
|
# the Wine heap is also rwx, so permissions cannot identify a module.
|
|
for start, path in sorted(named):
|
|
if path.endswith(CARDS_DLL):
|
|
return start
|
|
return None
|
|
|
|
# -- the store -------------------------------------------------------
|
|
def vector(self, off_begin: int):
|
|
beg, end = self.q(self.mgr + off_begin), self.q(self.mgr + off_begin + 8)
|
|
if not (0 < beg <= end) or (end - beg) % 24 or (end - beg) > 24 * 200000:
|
|
return None, 0
|
|
return beg, (end - beg) // 24
|
|
|
|
def records(self, off_begin: int):
|
|
beg, n = self.vector(off_begin)
|
|
out = []
|
|
if beg is None:
|
|
return out
|
|
for k in range(n):
|
|
try:
|
|
rec = self.q(beg + k * 24 + 0x10)
|
|
except OSError:
|
|
continue
|
|
if not rec:
|
|
out.append(None)
|
|
continue
|
|
try:
|
|
r = self.rd(rec, 0xC0)
|
|
except OSError:
|
|
out.append(None)
|
|
continue
|
|
if len(r) < 0xC0:
|
|
out.append(None)
|
|
continue
|
|
f = {k2: struct.unpack_from("<i", r, v)[0] for k2, v in OFF.items()}
|
|
f["teamkittypetechid"] = struct.unpack_from("<H", r, OFF_KITTYPE_U16)[0]
|
|
f["ptr"] = rec
|
|
out.append(f)
|
|
return out
|
|
|
|
def snapshot(self) -> dict:
|
|
club = self.records(0x108)
|
|
players = self.records(0xD8)
|
|
hist = collections.Counter(
|
|
(r["cardtype"], r["cardsubtypeid"]) for r in club if r
|
|
)
|
|
return {
|
|
"club_slots": len(club),
|
|
"club_filled": sum(1 for r in club if r),
|
|
"club_hist": dict(hist),
|
|
"club_records": [r for r in club if r],
|
|
"player_slots": len(players),
|
|
"player_filled": sum(1 for r in players if r),
|
|
}
|
|
|
|
|
|
def find_pid() -> int | None:
|
|
for d in os.listdir("/proc"):
|
|
if not d.isdigit():
|
|
continue
|
|
try:
|
|
with open(f"/proc/{d}/comm") as f:
|
|
if f.read().strip() == "FIFA17.exe":
|
|
return int(d)
|
|
except OSError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def fmt(snap: dict) -> str:
|
|
parts = [
|
|
f"club={snap['club_filled']}/{snap['club_slots']}",
|
|
f"players={snap['player_filled']}/{snap['player_slots']}",
|
|
]
|
|
if snap["club_hist"]:
|
|
parts.append("hist=" + ",".join(
|
|
f"(ct{a},st{b})x{c}" for (a, b), c in sorted(snap["club_hist"].items())))
|
|
for r in snap["club_records"]:
|
|
parts.append(
|
|
"KIT[" if (r["cardtype"], r["cardsubtypeid"]) == (7, 9) else "rec[")
|
|
parts[-1] += (f"ptr={r['ptr']:#x} ct={r['cardtype']} st={r['cardsubtypeid']} "
|
|
f"state={r['itemState']} cat={r['category']} "
|
|
f"team={r['teamid']} kittype={r['teamkittypetechid']}]")
|
|
return " ".join(parts)
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--interval", type=float, default=1.0)
|
|
ap.add_argument("--out", default="/home/alex/openfut-live/residency.log")
|
|
ap.add_argument("--once", action="store_true")
|
|
a = ap.parse_args()
|
|
|
|
sink = sys.stdout if a.out == "-" else open(a.out, "a", buffering=1)
|
|
|
|
def emit(msg: str) -> None:
|
|
line = f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} {msg}"
|
|
print(line, file=sink)
|
|
if sink is not sys.stdout:
|
|
print(line, flush=True)
|
|
|
|
emit("watch: start")
|
|
target = None
|
|
last = None
|
|
while True:
|
|
if target is None:
|
|
pid = find_pid()
|
|
if pid is None:
|
|
if a.once:
|
|
emit("watch: no FIFA17.exe"); return 1
|
|
time.sleep(a.interval); continue
|
|
try:
|
|
target = Target(pid)
|
|
emit(f"watch: attached pid={pid} cardsdll={target.base:#x} "
|
|
f"mgr={target.mgr:#x}")
|
|
last = None
|
|
except (OSError, RuntimeError) as e:
|
|
# not logged in yet, or the process died mid-resolve
|
|
if a.once:
|
|
emit(f"watch: not ready: {e}"); return 1
|
|
target = None
|
|
time.sleep(a.interval); continue
|
|
try:
|
|
snap = target.snapshot()
|
|
except (OSError, struct.error) as e:
|
|
emit(f"watch: detached ({e})")
|
|
target = None
|
|
if a.once:
|
|
return 1
|
|
continue
|
|
key = fmt(snap)
|
|
if key != last:
|
|
emit(key)
|
|
last = key
|
|
if a.once:
|
|
return 0
|
|
time.sleep(a.interval)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|