tool(fifa17-recon): manager_coldproof.py -- read-only manager registration probe

Promotes the throwaway probe used to close the manager cold-load milestone into
fifa17-recon/tools. Read-only (/proc/<pid>/mem opened 'rb', never 'r+b'), pid
optional and overridable, controls overridable via --control WIRE:RESOURCE.

Fail-closed: absent player positive controls exit 3 (INCONCLUSIVE, squad not
loaded) rather than 0, so 'no manager found' can never be reported from a
session that never loaded a squad. Distinguishes real item records from
incidental integer matches by requiring resourceId 0x20 bytes before the wire
id, the layout the player controls exhibit.

Documents the manager wire control, the resourceId control (the actual
verdict), the player positive controls, and what counts as a resident hit.
This commit is contained in:
funman300
2026-08-25 02:58:16 +00:00
parent b91e707a7e
commit 025122ec9a
+252
View File
@@ -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/<pid>/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/<pid>/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("<i", n) for n in needles}
with open(f"/proc/{pid}/mem", "rb", 0) as mem:
for lo, hi in regions(pid):
try:
mem.seek(lo)
buf = mem.read(hi - lo)
except (OSError, ValueError, OverflowError):
continue # torn-down or unreadable mapping; not a failure
for n, pat in pats.items():
i = buf.find(pat)
while i >= 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("<i", blob, o)[0]))
return " ".join(cells)
def record_shaped(blob, centre, resource):
"""True when resourceId sits RESOURCE_BACK_OFF before the id -- the real
item-record layout, as opposed to an incidental integer match."""
o = centre - RESOURCE_BACK_OFF
if o < 0 or o > len(blob) - 4:
return False
return struct.unpack_from("<i", blob, o)[0] == resource
def main():
ap = argparse.ArgumentParser(description="read-only manager registration probe")
ap.add_argument("pid", nargs="?", type=int, help="FIFA17 pid (default: auto)")
ap.add_argument("--manager-wire", type=int, default=DEF_MANAGER_WIRE)
ap.add_argument("--manager-resource", type=int, default=DEF_MANAGER_RESOURCE)
ap.add_argument(
"--control",
action="append",
metavar="WIRE:RESOURCE",
help="player positive control; repeatable (default: the staging pair)",
)
args = ap.parse_args()
controls = DEF_CONTROLS
if args.control:
try:
controls = [tuple(int(x) for x in c.split(":", 1)) for c in args.control]
except ValueError:
print(" --control must be WIRE:RESOURCE", file=sys.stderr)
return 2
pid = args.pid or find_pid()
if not pid:
print(" NO FIFA17 PROCESS (comm == FIFA17.exe) -- is the client running?")
return 2
if not os.access(f"/proc/{pid}/mem", os.R_OK):
print(f" /proc/{pid}/mem is not readable -- wrong user, or the process exited")
return 2
print(f" pid={pid}")
needles = [args.manager_wire, args.manager_resource]
for w, r in controls:
needles += [w, r]
try:
res = scan(pid, sorted(set(needles)))
except OSError as e:
print(f" cannot read /proc/{pid}/mem: {e}")
return 2
print("\n ===== hit counts =====")
print(f" {'manager wire (parsed?)':32} {args.manager_wire:<12} hits={len(res[args.manager_wire])}")
print(f" {'manager resourceId (VERDICT)':32} {args.manager_resource:<12} "
f"hits={len(res[args.manager_resource])}")
for w, r in controls:
print(f" {'player wire (control)':32} {w:<12} hits={len(res[w])}")
print(f" {'player resourceId (control)':32} {r:<12} hits={len(res[r])}")
print("\n ===== record context (8 words before the id, then the id) =====")
shaped = {"manager": 0}
for tag, wire, resource in (
[("manager", args.manager_wire, args.manager_resource)]
+ [(f"player{i}", w, r) for i, (w, r) in enumerate(controls)]
):
marked = 0
for addr, blob, centre in res[wire]:
ok = record_shaped(blob, centre, resource)
if ok:
marked += 1
if marked <= 2 or ok:
print(f" {tag:8} @0x{addr:x}{' <- item-record layout' if ok else ''}: "
f"{words(blob, centre)}")
if marked >= 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())