433a9b22dd
Add a fail-closed, fresh-process native trace workflow for the Offline Seasons
fixture-to-match-team boundary. The supervisor ignores UMU's short-lived
FIFA17.exe process, requires CardsDLL, verifies TracerPid and hardware arming,
rejects pre-existing records, structurally locates fixtures/final records, and
detaches cleanly after capture.
The four payloads reproduce the measured chain without client writes:
FUN_1800fc500
-> actual season vector, fixture index 0 / team 73
-> temporary [73,130000] pair (not the final record)
CardsDLL service -> engine 0x147c652ce
-> live final +0x14 writes at the 0x45c side stride
-> correct [73,130000], then local overwrite [130000,130000]
engine wrapper 0x147ce47e0
<- CardsDLL 0x180031861
<- CardsGameSetupAdapter local `teams` query result already 130000
All execute breakpoints and watchpoints are hardware-only. /proc/PID/mem is
opened rb. No INT3, write_memory, patch, game input, server behavior, or Rust
code. Locator uses zero-based --fixture-index (the live selector is 0 when
season/user.round is 1) and never filters on transient +0x18 handles.
513 lines
19 KiB
Python
Executable File
513 lines
19 KiB
Python
Executable File
#!/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/<pid>/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())
|