#!/usr/bin/env python3 """Trace FIFA17's post-kit handoff into the gameplay loading state. The probe anchors the second ACTION_SAVE_MATCH_KIT (0x7576), captures the select-team deleting destructor with its real caller, records entry to the Gameplay::ScenarioModeStart consumer with the state it would advance, and identifies the first TestingGame update after the boundary. The generated GDB program uses four hardware-assisted execution breakpoints. It never writes client memory, calls client functions, drives input, emits actions, or changes timing deliberately. match_drill_transition_trace.py [pid] [--output PATH] match_drill_transition_trace.py --print-script [pid] match_drill_transition_trace.py --selftest """ from __future__ import annotations import argparse import os from pathlib import Path import shutil import sys sys.path.insert(0, str(Path(__file__).resolve().parent)) import match_advance_trace as advance import match_transition_trace as transition SAVE_KIT_ACTION = 0x7576 SAVE_ACTION_RVA = 0x0BFCC0 SELECT_TEAM_DELETING_DESTRUCTOR_RVA = 0x0BE020 TESTING_GAME_UPDATE_RVA = 0x05A410C8 SCENARIO_MODE_START_HANDLER_RVA = 0x05A58EC0 TESTING_GAME_VTABLE_RVA = 0x035C58A8 TESTING_GAME_STATE_VTABLE_RVA = 0x035C2EE0 OWNER_STATE_OFFSET = 0x1958 STATE_GAME_DATABASE_OFFSET = 0x17450 STATE_PHASE_OFFSET = 0x27BEC STATE_SCENARIO_MODE_START_GATE_OFFSET = 0x359E8 DATABASE_IS_SKILL_GAME_OFFSET = 0x7382 DATABASE_TEAM_PAIR_OFFSET = 0x73C4 def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]: return { "save_action": cards_base + SAVE_ACTION_RVA, "deleting_destructor": cards_base + SELECT_TEAM_DELETING_DESTRUCTOR_RVA, "testing_game_update": fifa_base + TESTING_GAME_UPDATE_RVA, "scenario_mode_start_handler": fifa_base + SCENARIO_MODE_START_HANDLER_RVA, "testing_game_vtable": fifa_base + TESTING_GAME_VTABLE_RVA, "testing_game_state_vtable": fifa_base + TESTING_GAME_STATE_VTABLE_RVA, } def build_gdb_script( pid: int, cards_base: int, fifa_base: int, output: str, ) -> str: if any(character in output for character in "\n\r"): raise ValueError("output path cannot contain a newline") address = trace_addresses(cards_base, fifa_base) return f"""set pagination off set confirm off set print thread-events off set breakpoint always-inserted on set logging file {output} set logging overwrite on set logging redirect off set logging enabled on handle SIGSEGV nostop noprint pass handle SIGILL nostop noprint pass handle SIGFPE nostop noprint pass handle SIGPIPE nostop noprint pass handle SIGALRM nostop noprint pass handle SIGUSR1 nostop noprint pass handle SIGUSR2 nostop noprint pass attach {pid} set $save_count = 0 set $current_controller = 0 set $engine_seen = 0 hbreak *0x{address['save_action']:x} commands silent if $edx == 0x{SAVE_KIT_ACTION:x} set $save_count = $save_count + 1 set $current_controller = $rcx python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SAVE_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ") printf "thread=%d ordinal=%d action=%#x controller=%p payload=%p second_boundary=%d\\n", $_thread, $save_count, $edx, $rcx, $r8, $save_count==2 if $save_count == 2 disable 1 end end continue end hbreak *0x{address['deleting_destructor']:x} condition 2 $save_count >= 2 && $rcx == $current_controller commands silent python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SELECT_TEAM_DELETING_DESTRUCTOR" % (time.time_ns(), time.monotonic_ns()), end=" ") printf "thread=%d controller=%p delete_flags=%#x caller_return=%p vtable=%p\\n", $_thread, $rcx, $edx, *(void**)$rsp, *(void**)$rcx x/16gx $rsp bt 12 disable 2 continue end hbreak *0x{address['scenario_mode_start_handler']:x} commands silent set $scenario_wrapper = $rcx set $scenario_state = *(void**)($scenario_wrapper+0x30) set $scenario_payload = $r9 python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SCENARIO_MODE_START" % (time.time_ns(), time.monotonic_ns()), end=" ") if $scenario_state != 0 set $scenario_database = *(void**)($scenario_state+0x{STATE_GAME_DATABASE_OFFSET:x}) if $scenario_database != 0 printf "thread=%d wrapper=%p state=%p payload=%p phase=%d alternate_gate=%d is_skill_game=%d caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_state, $scenario_payload, *(unsigned int*)($scenario_state+0x{STATE_PHASE_OFFSET:x}), *(unsigned char*)($scenario_state+0x{STATE_SCENARIO_MODE_START_GATE_OFFSET:x}), *(unsigned char*)($scenario_database+0x{DATABASE_IS_SKILL_GAME_OFFSET:x}), *(void**)$rsp else printf "thread=%d wrapper=%p state=%p payload=%p database=0 caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_state, $scenario_payload, *(void**)$rsp end else printf "thread=%d wrapper=%p state=0 payload=%p caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_payload, *(void**)$rsp end bt 12 disable 3 continue end hbreak *0x{address['testing_game_update']:x} condition 4 $save_count >= 2 && $engine_seen == 0 commands silent set $owner = $rsi set $state = *(void**)($owner+0x{OWNER_STATE_OFFSET:x}) if $state != 0 && *(void**)$owner == 0x{address['testing_game_vtable']:x} && *(void**)$state == 0x{address['testing_game_state_vtable']:x} set $database = *(void**)($state+0x{STATE_GAME_DATABASE_OFFSET:x}) if $database != 0 set $engine_seen = 1 python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d ENGINE_HANDOFF" % (time.time_ns(), time.monotonic_ns()), end=" ") printf "thread=%d owner=%p owner_vtable=%p state=%p state_vtable=%p database=%p phase=%d is_skill_game=%d teams=%d,%d\\n", $_thread, $owner, *(void**)$owner, $state, *(void**)$state, $database, *(unsigned int*)($state+0x{STATE_PHASE_OFFSET:x}), *(unsigned char*)($database+0x{DATABASE_IS_SKILL_GAME_OFFSET:x}), *(unsigned int*)($database+0x{DATABASE_TEAM_PAIR_OFFSET:x}), *(unsigned int*)($database+0x{DATABASE_TEAM_PAIR_OFFSET + 4:x}) bt 12 disable 4 end end continue end printf "DRILLTRACE ARMED pid={pid} save_action=0x{address['save_action']:x} deleting_destructor=0x{address['deleting_destructor']:x} scenario_mode_start_handler=0x{address['scenario_mode_start_handler']:x} testing_game_update=0x{address['testing_game_update']:x}\\n" continue """ def selftest() -> None: address = trace_addresses(0x180000000, 0x140000000) assert address == { "save_action": 0x1800BFCC0, "deleting_destructor": 0x1800BE020, "testing_game_update": 0x145A410C8, "scenario_mode_start_handler": 0x145A58EC0, "testing_game_vtable": 0x1435C58A8, "testing_game_state_vtable": 0x1435C2EE0, } script = build_gdb_script( 49938, 0x180000000, 0x140000000, "/tmp/drill-transition.log", ) assert script.count("hbreak *") == 4 assert "SELECT_TEAM_DELETING_DESTRUCTOR" in script assert "SCENARIO_MODE_START" in script assert "wrapper=%p state=%p payload=%p" in script assert "alternate_gate=%d" in script assert "skill_game_start_constructor" not in script assert "ENGINE_HANDOFF" in script assert "GameplayGameDatabase.IsSkillGame" not in script assert "set *(" not in script print("match_drill_transition_trace selftest: PASS") def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("pid", nargs="?", type=int) parser.add_argument("--output") parser.add_argument("--print-script", action="store_true") parser.add_argument("--selftest", action="store_true") args = parser.parse_args() if args.selftest: selftest() return 0 pid = args.pid or transition.find_pid() if not pid: print("FIFA17.exe not found", file=sys.stderr) return 2 try: cards_base, cards_path = transition.cards_mapping(pid) transition.validate_cards(cards_path) fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE) advance.validate_file( fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE, ) output = args.output or f"/tmp/fifa17-match-drill-transition-{pid}.log" script = build_gdb_script(pid, cards_base, fifa_base, output) except (OSError, RuntimeError, ValueError) as error: print(error, file=sys.stderr) return 2 if args.print_script: print(script, end="") return 0 if not shutil.which("gdb"): print("gdb not found", file=sys.stderr) return 2 script_path = f"/tmp/fifa17-match-drill-transition-{pid}.gdb" with open(script_path, "w", encoding="utf-8") as handle: handle.write(script) os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path]) return 127 if __name__ == "__main__": raise SystemExit(main())