From f608dbc438ceede3cb32cb1001e042708658a098 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 26 Aug 2026 01:40:41 +0000 Subject: [PATCH] Add post-kit gameplay transition tracers --- .../tools/match_drill_transition_trace.py | 231 ++++++++++++++++++ fifa17-recon/tools/match_post_kit_trace.py | 185 ++++++++++++++ 2 files changed, 416 insertions(+) create mode 100755 fifa17-recon/tools/match_drill_transition_trace.py create mode 100755 fifa17-recon/tools/match_post_kit_trace.py diff --git a/fifa17-recon/tools/match_drill_transition_trace.py b/fifa17-recon/tools/match_drill_transition_trace.py new file mode 100755 index 0000000..a7ce665 --- /dev/null +++ b/fifa17-recon/tools/match_drill_transition_trace.py @@ -0,0 +1,231 @@ +#!/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()) diff --git a/fifa17-recon/tools/match_post_kit_trace.py b/fifa17-recon/tools/match_post_kit_trace.py new file mode 100755 index 0000000..cdb3f93 --- /dev/null +++ b/fifa17-recon/tools/match_post_kit_trace.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Trace FIFA17's post-kit boundary without changing client behavior. + +The probe anchors both ACTION_SAVE_MATCH_KIT (0x7576) actions, their concrete +native save call, the action-handler return, and select-team provider teardown. +The second 0x7576 action is the temporal boundary for later drill/game-loader +instrumentation. + +The generated GDB program uses four hardware-assisted execution breakpoints. It +never writes client memory, calls client functions, drives input, emits actions, +or alters timing deliberately. + + match_post_kit_trace.py [pid] [--output PATH] + match_post_kit_trace.py --print-script [pid] + match_post_kit_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 +ACTION_HANDLER_RVA = 0x0BFCC0 +SAVE_CALL_RVA = 0x0BFF25 +ACTION_RETURN_RVA = 0x0C00AE +SELECT_TEAM_DESTRUCTOR_RVA = 0x0BDEC0 + + +def trace_addresses(cards_base: int) -> dict[str, int]: + return { + "action": cards_base + ACTION_HANDLER_RVA, + "save_call": cards_base + SAVE_CALL_RVA, + "action_return": cards_base + ACTION_RETURN_RVA, + "destructor": cards_base + SELECT_TEAM_DESTRUCTOR_RVA, + } + + +def build_gdb_script(pid: int, cards_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) + 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_action = 0 +set $current_controller = 0 +set $current_payload = 0 +set $second_save_epoch = 0 + +hbreak *0x{address['action']:x} +condition 1 $edx == 0x{SAVE_KIT_ACTION:x} +commands + silent + set $save_count = $save_count + 1 + set $current_action = $edx + set $current_controller = $rcx + set $current_payload = $r8 + python import time, gdb; now = time.time_ns(); gdb.set_convenience_variable("event_epoch", now); print("POSTKIT epoch_ns=%d mono_ns=%d SAVE_ACTION" % (now, time.monotonic_ns()), end=" ") + if $save_count == 2 + set $second_save_epoch = $event_epoch + end + printf "thread=%d ordinal=%d action=%#x controller=%p payload=%p payload_vtable=%p mode=%#x flags_150=%#x flags_151=%#x flags_152=%#x flags_155=%#x second_boundary=%d\\n", $_thread, $save_count, $edx, $rcx, $r8, *(void**)$r8, *(unsigned int*)($rcx+0x140), *(unsigned char*)($rcx+0x150), *(unsigned char*)($rcx+0x151), *(unsigned char*)($rcx+0x152), *(unsigned char*)($rcx+0x155), $save_count==2 + bt 10 + continue +end + +hbreak *0x{address['save_call']:x} +condition 2 $current_action == 0x{SAVE_KIT_ACTION:x} +commands + silent + set $save_target = *(void**)(*(void**)$rcx+0x1d0) + python import time; print("POSTKIT epoch_ns=%d mono_ns=%d NATIVE_SAVE_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ") + printf "thread=%d ordinal=%d central=%p central_vtable=%p target=%p side=%#x request=%p payload=%p\\n", $_thread, $save_count, $rcx, *(void**)$rcx, $save_target, $r8d, $rdx, $current_payload + x/12gx $rdx + bt 10 + continue +end + +hbreak *0x{address['action_return']:x} +condition 3 $current_action == 0x{SAVE_KIT_ACTION:x} && $rsi == $current_controller +commands + silent + python import time; print("POSTKIT epoch_ns=%d mono_ns=%d ACTION_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ") + printf "thread=%d ordinal=%d controller=%p handled=%#x mode=%#x flags_150=%#x flags_151=%#x flags_152=%#x flags_155=%#x\\n", $_thread, $save_count, $rsi, $al, *(unsigned int*)($rsi+0x140), *(unsigned char*)($rsi+0x150), *(unsigned char*)($rsi+0x151), *(unsigned char*)($rsi+0x152), *(unsigned char*)($rsi+0x155) + set $current_action = 0 + bt 10 + continue +end + +hbreak *0x{address['destructor']:x} +condition 4 $save_count >= 2 && $rcx == $current_controller +commands + silent + python import time; print("POSTKIT epoch_ns=%d mono_ns=%d SELECT_TEAM_DESTRUCTOR" % (time.time_ns(), time.monotonic_ns()), end=" ") + printf "thread=%d controller=%p save_count=%d second_save_epoch=%lld vtable=%p mode=%#x\\n", $_thread, $rcx, $save_count, $second_save_epoch, *(void**)$rcx, *(unsigned int*)($rcx+0x140) + bt 12 + continue +end + +printf "POSTKIT ARMED pid={pid} action=0x{address['action']:x} save_call=0x{address['save_call']:x} action_return=0x{address['action_return']:x} destructor=0x{address['destructor']:x}\\n" +continue +""" + + +def selftest() -> None: + address = trace_addresses(0x180000000) + assert address == { + "action": 0x1800BFCC0, + "save_call": 0x1800BFF25, + "action_return": 0x1800C00AE, + "destructor": 0x1800BDEC0, + } + script = build_gdb_script(47872, 0x180000000, "/tmp/post-kit.log") + assert script.count("hbreak *") == 4 + assert f"$edx == 0x{SAVE_KIT_ACTION:x}" in script + assert "second_boundary" in script + assert "NATIVE_SAVE_CALL" in script + assert "SELECT_TEAM_DESTRUCTOR" in script + assert "set *(" not in script + print("match_post_kit_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-post-kit-{pid}.log" + script = build_gdb_script(pid, cards_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-post-kit-{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())