Add post-kit gameplay transition tracers
This commit is contained in:
+231
@@ -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())
|
||||
Reference in New Issue
Block a user