tool(fifa17-recon): trace Offline Seasons team assignment hardware-only
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.
This commit is contained in:
+512
@@ -0,0 +1,512 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user