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:
Executable
+252
@@ -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())
|
||||
Reference in New Issue
Block a user