diff --git a/fifa17-recon/tools/manager_coldproof.py b/fifa17-recon/tools/manager_coldproof.py new file mode 100755 index 0000000..992acab --- /dev/null +++ b/fifa17-recon/tools/manager_coldproof.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""Is the squad manager REGISTERED (not merely parsed) in a live FIFA17 client? + +READ-ONLY. Opens /proc//mem for reading and scans. Writes nothing, sends +no input to the game, and never opens 'r+b'. + + manager_coldproof.py [pid] [--manager-wire N] [--manager-resource N] + [--control WIRE:RESOURCE ...] + +Defaults describe the staging profile used to close the manager milestone; pass +the flags for any other profile. + +WHAT THIS DECIDES +----------------- +FIFA17's squad parser (FUN_18013d1f0) reaches the item parser FUN_18013fe00 by +two different routes: + + players : atom 568 -> per-element atoms 355 index / 363 itemData / + 378 kitNumber; the 363 arm at 0x18013d8d9 calls the item parser + on the NESTED itemData object. + manager : atom 424 -> array loop at 0x18013da29 calls that same item parser + DIRECTLY on the array ELEMENT, into squad+0xC0. No itemData step. + +So `squad.manager[]` elements must be BARE ITEM OBJECTS. When they were served +as {id, itemData:{...}, dream} the parser read only the two keys that happen to +be item atoms -- id and dream -- and left resourceId at 0. resourceId is the +merge key, compared RAW against carddbid (fut_staff.py::manager_item, +0x18), +so 0 resolves no manager: no name, no rating, no art, empty slot. Fixed in +OpenFUT b91e707; see Vault "FIFA 17/Squad Manager Wire Shape.md". + +CONTROLS +-------- +manager wire id the instance id. Present even when BROKEN, because `id` is + an item atom the parser reads at element level. Its + presence proves the element was parsed and therefore proves + nothing about registration -- do not use it as the verdict. +manager resourceId THE VERDICT. Resident => the merge key survived the load. +player wire id and positive controls. Players demonstrably render, so if their +player resourceId resourceIds are absent the squad simply is not loaded yet + and the run is INCONCLUSIVE, not a failure. + +RESIDENT-MANAGER HIT +-------------------- +A 4-byte-aligned little-endian i32 equal to the manager resourceId, anywhere in +a readable private mapping. Corroborate with the record context printed below: +a real item record carries resourceId eight words ahead of its wire id, which +is the layout the player controls exhibit. Hits without that shape are usually +id lists or unrelated integers -- the layout, not the raw count, is the proof. + +LAYOUT ASSUMPTION (the only one) +-------------------------------- +Item records place resourceId 0x20 bytes before the wire id. Measured, both +sides: + + before b91e707 (pid 126936) -- manager parsed, merge key absent + player @0xb85dbf48: 83906881 1 0 0 0 0 0 0 | 100002878 0 | 7 + player @0xb85dbd68: 84053575 1 0 0 0 0 0 0 | 100003237 0 | 7 + manager @0xb85dc1b8: 0 0 0 0 0 0 0 0 | 100004870 0 | 7 + + after b91e707 (pid 134118) -- same layout, key present + player @0xb8740fd8: 84053575 1 0 0 0 0 0 0 | 100003237 0 | 7 0 + player @0xb87411b8: 83906881 1 0 0 0 0 0 0 | 100002878 0 | 7 0 + manager @0xb8741428: 1000509 2 0 0 0 0 0 0 | 100004870 0 | 7 0 + +Addresses shift every session and are recorded only as provenance; nothing here +depends on them. The tool re-derives everything by scanning. + +EXIT CODES (fail-closed) +------------------------ + 0 PASS manager resourceId resident, controls present + 1 FAIL controls present, manager resourceId absent + 2 NO PROCESS no FIFA17.exe, or /proc//mem unreadable + 3 INCONCLUSIVE controls absent -- squad not loaded yet; re-run at the + squad screen. Deliberately NOT 0: absent controls mean the + probe proved nothing. +""" +import argparse +import glob +import os +import re +import struct +import sys + +# Staging profile defaults (override on the command line). +DEF_MANAGER_WIRE = 100004870 +DEF_MANAGER_RESOURCE = 1000509 +DEF_CONTROLS = [(100002878, 83906881), (100003237, 84053575)] + +# Item record layout: resourceId sits this far BEFORE the wire id. +RESOURCE_BACK_OFF = 0x20 + + +def find_pid(): + """The Wine process whose comm is FIFA17.exe (same rule as memtool.py).""" + for d in glob.glob("/proc/[0-9]*"): + try: + with open(os.path.join(d, "comm")) as fh: + if fh.read().strip() == "FIFA17.exe": + return int(os.path.basename(d)) + except OSError: + continue + return None + + +def regions(pid): + """Readable private mappings worth scanning. + + Skips device/memfd mappings and anything over 512 MiB (the big reserved + ranges are not where parsed records live and dominate the runtime). + """ + out = [] + with open(f"/proc/{pid}/maps") as fh: + for line in fh: + m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", line) + if not m: + continue + lo, hi = int(m.group(1), 16), int(m.group(2), 16) + perms, path = m.group(3), m.group(4) + if perms[0] != "r" or path.startswith(("/dev", "/memfd")): + continue + if hi - lo > 512 * 1024 * 1024: + continue + out.append((lo, hi)) + return out + + +def scan(pid, needles, ctx_before=0x40, ctx_after=0x40): + """4-byte-aligned little-endian i32 search; keeps a window around each hit.""" + found = {n: [] for n in needles} + pats = {n: struct.pack("= 0: + if i % 4 == 0: + found[n].append( + (lo + i, buf[max(0, i - ctx_before): i + ctx_after], min(i, ctx_before)) + ) + i = buf.find(pat, i + 4) + return found + + +def words(blob, centre, before=8, after=4): + cells = [] + for k in range(-before, after): + o = centre + k * 4 + if 0 <= o <= len(blob) - 4: + cells.append(str(struct.unpack_from(" len(blob) - 4: + return False + return struct.unpack_from("= 2: + break + shaped[tag] = marked + + ctl_keys = sum(len(res[r]) for _w, r in controls) + mgr_keys = len(res[args.manager_resource]) + + print("\n ===== verdict =====") + if ctl_keys == 0: + print(" INCONCLUSIVE: no player resourceId control is resident, so the squad") + print(" is not loaded. Reach the squad screen and re-run. (Nothing proven.)") + return 3 + if mgr_keys == 0: + print(f" FAIL: manager resourceId {args.manager_resource} is absent while " + f"{ctl_keys} player") + print(" resourceId control hit(s) are resident -> PARSED_BUT_NOT_REGISTERED.") + return 1 + print(f" PASS: manager resourceId {args.manager_resource} is resident " + f"({mgr_keys} hits, {shaped['manager']} in item-record layout).") + print(" The merge key survived the load; the broken projection had 0.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())