#!/usr/bin/env python3 """Supervise one hardware-only FIFA17 match-team writer capture. This is the robust fresh-client entry point. It waits for the largest-RSS FIFA17.exe process that has CardsDLL loaded, attaches gdb before FUT navigation can construct match teams, and loads a hardware-only GDB Python payload. The concurrent read-only structural locator proves when the fixture and final match-team records exist. A zero-hit result is trusted only if gdb is still alive, TracerPid is the gdb process, the payload reported `trace_armed`, no records pre-existed the trace, and two final records then appeared. The default payload traces FUN_1800fc500 and derives a 4-byte teamId[1] watchpoint from live RDX. Other payloads trace the final engine writer or its caller; all expose the same `start_trace(log, cards_base)` entry point. No INT3/software breakpoints. No client memory writes. /proc//mem is opened 'rb'. The operator alone drives the game. trace_match_team_writer.py --status /tmp/mt-status.json \ --trace /tmp/mt-trace.jsonl --gdb-log /tmp/mt-gdb.log --fixture-index 0 """ from __future__ import annotations import argparse import json import os import signal import subprocess import sys import tempfile import time from dataclasses import asdict from pathlib import Path from offline_match_locator import find_pids, scan_process CARDS_IMAGE_BASE = 0x180000000 DEFAULT_TIMEOUT = 45 * 60 def cards_base(pid: int) -> int | None: try: with open(f"/proc/{pid}/maps") as maps: for line in maps: if "CardsDLL_Win64_retail.dll" in line: return int(line.split("-", 1)[0], 16) except OSError: pass return None def tracer_pid(pid: int) -> int | None: try: with open(f"/proc/{pid}/status") as status: for line in status: if line.startswith("TracerPid:"): return int(line.split()[1]) except OSError: pass return None def target_state(pid: int) -> str | None: try: with open(f"/proc/{pid}/status") as status: for line in status: if line.startswith("State:"): return line.split()[1] except OSError: pass return None def read_events(path: Path) -> list[dict]: if not path.exists(): return [] events = [] try: with path.open(encoding="utf-8", errors="replace") as handle: for line in handle: try: events.append(json.loads(line)) except json.JSONDecodeError: continue except OSError: return [] return events def event_counts(events: list[dict]) -> dict[str, int]: counts: dict[str, int] = {} for event in events: kind = event.get("event", "unknown") counts[kind] = counts.get(kind, 0) + 1 return counts class Status: def __init__(self, path: Path, monitor_log: Path): self.path = path self.monitor_log = monitor_log self.data: dict = {"started_unix": time.time(), "state": "starting"} self.write() def write(self, **updates): self.data.update(updates) self.data["updated_unix"] = time.time() temporary = self.path.with_suffix(self.path.suffix + ".tmp") temporary.write_text(json.dumps(self.data, indent=2, sort_keys=True) + "\n") os.replace(temporary, self.path) def log(self, message: str, **payload): record = {"time_unix": time.time(), "message": message, **payload} with self.monitor_log.open("a", encoding="utf-8") as handle: handle.write(json.dumps(record, sort_keys=True) + "\n") handle.flush() os.fsync(handle.fileno()) print(message, flush=True) def gdb_commands(pid: int, cards: int, payload: Path, trace: Path) -> str: # Wine uses these signals for thread suspension/runtime plumbing. They must # pass through, or batch gdb stops and silently detaches. signals = ["SIGUSR1", "SIGUSR2", "SIGPIPE", "SIGCHLD"] + [ f"SIG{number}" for number in range(32, 40) ] lines = [ "set confirm off", "set pagination off", "set height 0", "set width 0", f"attach {pid}", ] lines.extend(f"handle {name} nostop noprint pass" for name in signals) lines.extend( [ f"source {payload}", f'python start_trace({json.dumps(str(trace))}, {cards})', "continue", ] ) return "\n".join(lines) + "\n" def serialise_locations(locations: dict) -> dict: return {key: [asdict(value) for value in values] for key, values in locations.items()} def terminate_gdb(process: subprocess.Popen, status: Status, pid: int): if process.poll() is None: process.terminate() try: process.wait(timeout=12) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=5) deadline = time.time() + 8 while time.time() < deadline and tracer_pid(pid): time.sleep(0.25) status.log( "gdb detached", gdb_returncode=process.returncode, tracer_pid=tracer_pid(pid), target_state=target_state(pid), ) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--status", type=Path, required=True) parser.add_argument("--trace", type=Path, required=True) parser.add_argument("--gdb-log", type=Path, required=True) parser.add_argument("--monitor-log", type=Path, default=Path("/tmp/mt-monitor.jsonl")) parser.add_argument("--fixture-index", type=int, default=0) parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT) parser.add_argument("--post-record-wait", type=int, default=12) parser.add_argument( "--arm-check-seconds", type=int, default=0, help="attach, prove hardware breakpoints arm, then detach without claiming a capture", ) parser.add_argument( "--wait-for-record-clear", action="store_true", help="keep tracing through abandon; accept creation only after old records disappear", ) parser.add_argument( "--exclude-pid", action="append", type=int, default=[], help="ignore an existing FIFA process and attach only after process replacement", ) parser.add_argument( "--payload", default="gdb_match_team_writer_trace.py", help="GDB Python payload in this tool directory; must expose start_trace(log, cards_base)", ) args = parser.parse_args() for path in (args.status, args.trace, args.gdb_log, args.monitor_log): path.parent.mkdir(parents=True, exist_ok=True) for path in (args.trace, args.gdb_log, args.monitor_log): path.unlink(missing_ok=True) status = Status(args.status, args.monitor_log) payload = Path(__file__).with_name(args.payload).resolve() if not payload.exists(): status.write(state="failed", error=f"missing gdb payload: {payload}") return 2 deadline = time.time() + args.timeout status.write(state="waiting_for_ready_process", excluded_pids=args.exclude_pid) status.log( "waiting for FIFA17.exe with CardsDLL", excluded_pids=args.exclude_pid, ) pid = None cards = None while time.time() < deadline: # UMU/Proton creates a short-lived small FIFA17.exe before the real # client. Never bind to the first comm match. Require CardsDLL and prefer # the largest-RSS process (find_pids is ordered that way). for candidate in find_pids(): if candidate in args.exclude_pid: continue candidate_cards = cards_base(candidate) if candidate_cards: pid, cards = candidate, candidate_cards break if pid: break time.sleep(0.25) if not pid or not cards: status.write(state="timed_out", phase="ready_process") return 3 status.write(state="ready_process_found", pid=pid, cards_base=cards) status.log("real FIFA17.exe with CardsDLL found", pid=pid, cards_base=cards) command_path = Path(tempfile.gettempdir()) / f"mt-trace-{pid}.gdb" command_path.write_text(gdb_commands(pid, cards, payload, args.trace)) gdb_handle = args.gdb_log.open("w", encoding="utf-8") process = subprocess.Popen( ["gdb", "-q", "-nx", "-x", str(command_path)], stdout=gdb_handle, stderr=subprocess.STDOUT, text=True, ) status.write( state="attaching", pid=pid, cards_base=cards, cards_image_base=CARDS_IMAGE_BASE, gdb_pid=process.pid, gdb_command_file=str(command_path), payload=args.payload, hardware_only=True, client_memory_writes=False, ) status.log("gdb launched", pid=pid, gdb_pid=process.pid, cards_base=cards) armed = False arm_deadline = min(deadline, time.time() + 60) while time.time() < arm_deadline: if process.poll() is not None: break events = read_events(args.trace) if any(event.get("event") == "trace_armed" for event in events): armed = True break time.sleep(0.25) if not armed: gdb_handle.close() status.write( state="failed", phase="arm", gdb_returncode=process.poll(), tracer_pid=tracer_pid(pid), trace_events=event_counts(read_events(args.trace)), ) if process.poll() is None: terminate_gdb(process, status, pid) return 4 attached = tracer_pid(pid) == process.pid status.write( state="armed", tracer_pid=tracer_pid(pid), target_state=target_state(pid), trace_events=event_counts(read_events(args.trace)), execution_breakpoints_armed=True, team1_watchpoint_armed=False, ) status.log("trace armed", attached=attached, tracer_pid=tracer_pid(pid)) if not attached: terminate_gdb(process, status, pid) gdb_handle.close() status.write(state="failed", phase="attach_verification") return 4 if args.arm_check_seconds > 0: time.sleep(args.arm_check_seconds) events = read_events(args.trace) counts = event_counts(events) still_attached = tracer_pid(pid) == process.pid and process.poll() is None terminate_gdb(process, status, pid) gdb_handle.close() passed = ( still_attached and counts.get("trace_armed", 0) == 1 and counts.get("trace_error", 0) == 0 and tracer_pid(pid) == 0 and target_state(pid) != "T" ) status.write( state="arm_check_passed" if passed else "arm_check_failed", trace_events=counts, attached_before_detach=still_attached, tracer_pid_after_detach=tracer_pid(pid), target_state_after_detach=target_state(pid), ) status.log("arm check complete", passed=passed, trace_events=counts) return 0 if passed else 5 # A final record that already exists before arming cannot prove execution # crossed creation under the debugger. Fail closed instead of converting an # already-built match into a trusted zero-hit result. initial_heap = scan_process( pid, args.fixture_index, include_fixture=False, writable_anon_only=True, ) records_preexisting = len(initial_heap["match_teams"]) >= 2 records_cleared = not records_preexisting if records_preexisting and args.wait_for_record_clear: status.write( state="waiting_for_record_clear", locations=serialise_locations(initial_heap), target_crossed_match_team_creation=False, ) status.log( "trace armed; waiting for old match-team records to disappear", team_ids=[team.team_id for team in initial_heap["match_teams"]], ) while time.time() < deadline: if process.poll() is not None or not Path(f"/proc/{pid}").exists(): terminate_gdb(process, status, pid) gdb_handle.close() status.write(state="failed", phase="record_clear") return 5 heap = scan_process( pid, args.fixture_index, include_fixture=False, writable_anon_only=True, ) if not heap["match_teams"]: records_cleared = True status.write( state="records_cleared", cleared_unix=time.time(), tracer_pid=tracer_pid(pid), gdb_alive=process.poll() is None, target_state=target_state(pid), ) status.log( "old match-team records disappeared; next records are a fresh creation", tracer_pid=tracer_pid(pid), ) break time.sleep(2) if not records_cleared: terminate_gdb(process, status, pid) gdb_handle.close() status.write(state="timed_out", phase="record_clear") return 3 elif records_preexisting: counts = event_counts(read_events(args.trace)) terminate_gdb(process, status, pid) gdb_handle.close() status.write( state="armed_too_late", phase="preexisting_records", trace_events=counts, locations=serialise_locations(initial_heap), target_crossed_match_team_creation=False, tracer_pid_after_detach=tracer_pid(pid), target_state_after_detach=target_state(pid), ) status.log( "match-team records pre-existed trace; no writer claim", team_ids=[team.team_id for team in initial_heap["match_teams"]], ) return 6 fixture = None latest_locations = {"fixtures": [], "match_teams": [], "match_configs": []} last_fixture_scan = 0.0 records_seen_at = None record_control = None try: while time.time() < deadline: if process.poll() is not None or not Path(f"/proc/{pid}").exists(): status.write( state="failed", phase="monitor", gdb_returncode=process.poll(), target_exists=Path(f"/proc/{pid}").exists(), ) return 5 now = time.time() if fixture is None and now - last_fixture_scan >= 8: full = scan_process(pid, args.fixture_index, include_fixture=True) last_fixture_scan = now if full["fixtures"]: fixture = full["fixtures"][0] latest_locations["fixtures"] = full["fixtures"] status.log( "fixture located", address=fixture.address, selected_address=fixture.selected_address, selected_index=fixture.selected_index, selected_team_id=fixture.selected_team_id, ) heap = scan_process( pid, args.fixture_index, include_fixture=False, writable_anon_only=True, ) latest_locations["match_teams"] = heap["match_teams"] latest_locations["match_configs"] = heap["match_configs"] events = read_events(args.trace) counts = event_counts(events) is_attached = tracer_pid(pid) == process.pid watch_armed = counts.get("team1_watchpoint_armed", 0) > 0 status.write( state="capturing" if len(heap["match_teams"]) < 2 else "records_observed", tracer_pid=tracer_pid(pid), gdb_alive=process.poll() is None, target_state=target_state(pid), trace_events=counts, team1_watchpoint_armed=watch_armed, locations=serialise_locations(latest_locations), ) if len(heap["match_teams"]) >= 2: if records_seen_at is None: if not is_attached or process.poll() is not None: status.write( state="failed", phase="record_creation_control", tracer_pid=tracer_pid(pid), gdb_alive=process.poll() is None, trace_events=counts, ) return 5 records_seen_at = now record_control = { "gdb_alive": process.poll() is None, "tracer_pid": tracer_pid(pid), "attached": is_attached, "execution_breakpoints_armed": counts.get("trace_armed", 0) == 1, "team1_watchpoint_armed": watch_armed, } status.log( "two match-team records located", team_ids=[team.team_id for team in heap["match_teams"]], trace_events=counts, **record_control, ) if now - records_seen_at >= args.post_record_wait: break time.sleep(3) finally: terminate_gdb(process, status, pid) gdb_handle.close() events = read_events(args.trace) counts = event_counts(events) final = { "state": "captured", "pid": pid, "cards_base": cards, "fixture": asdict(fixture) if fixture else None, "locations": serialise_locations(latest_locations), "trace_events": counts, "record_creation_control": record_control, "gdb_alive_at_record_creation": bool( record_control and record_control["gdb_alive"] and record_control["attached"] ), "target_crossed_match_team_creation": len(latest_locations["match_teams"]) >= 2, "candidate_entry_hit": counts.get("candidate_entry", 0) > 0, "team1_write_hit": counts.get("team1_write_post", 0) > 0, "opponent_lookup_store_hit": counts.get("opponent_lookup_store_pre", 0) > 0, "tracer_pid_after_detach": tracer_pid(pid), "target_state_after_detach": target_state(pid), "records_preexisting": records_preexisting, "records_cleared_before_capture": records_cleared, } status.write(**final) status.log("capture complete", **final) return 0 if __name__ == "__main__": raise SystemExit(main())