From f4fc832acee707d7f62d55773a512bc60835702e Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 27 Aug 2026 23:27:52 +0000 Subject: [PATCH] Trace FIFA17 PMA producer lifecycle --- fifa17-recon/tools/pma_producer_trace.py | 394 +++++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100755 fifa17-recon/tools/pma_producer_trace.py diff --git a/fifa17-recon/tools/pma_producer_trace.py b/fifa17-recon/tools/pma_producer_trace.py new file mode 100755 index 0000000..0f1e660 --- /dev/null +++ b/fifa17-recon/tools/pma_producer_trace.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +"""Trace FIFA17's PMA ScenarioModeStart-to-event-5 producer chain. + +The generated GDB program uses hardware breakpoints, only reads registers and +client memory, logs, and continues. Breakpoints are rotated so no more than four +are enabled. It never calls client functions, writes client memory, emits an +event, or drives input. + + pma_producer_trace.py [pid] [--variant mode0|alternate] [--output PATH] + pma_producer_trace.py --selftest +""" +from __future__ import annotations + +import argparse +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 + +VARIANTS = { + "mode0": { + "scenario_rva": 0x07B1C190, + "writer_rva": 0x07B1C26B, + "register_rva": 0x07B1C282, + "writer_context": "$rsi", + "writer_async_requested": "1", + "arm_condition": "1", + }, + "alternate": { + "scenario_rva": 0x07B1C050, + "writer_rva": 0x07B1C12F, + "register_rva": 0x07B1C146, + "writer_context": "$rbp", + "writer_async_requested": "$sil", + "arm_condition": "$tracked_ctx != 0 && $rcx == $tracked_ctx", + }, +} +PMA_COMPLETION_ARM_RVA = 0x07B1AE60 +PMA_COMPLETION_ARM_WRITER_RVA = 0x07B1AF33 +ASYNC_COMPLETION_RVA = 0x07B046C0 +CALLBACK_DISPATCHER_RVA = 0x07AC87B0 +PMA_INSTRUCTIONS_HANDLER_RVA = 0x07AC91E0 +GAMEPLAY_GLOBAL_RVA = 0x04BFB910 +PMA_INSTRUCTIONS_VTABLE_RVA = 0x03AF2750 + + +def addresses(base: int, variant: str) -> dict[str, int]: + config = VARIANTS[variant] + return { + "scenario": base + config["scenario_rva"], + "writer": base + config["writer_rva"], + "register": base + config["register_rva"], + "arm": base + PMA_COMPLETION_ARM_RVA, + "arm_writer": base + PMA_COMPLETION_ARM_WRITER_RVA, + "completion": base + ASYNC_COMPLETION_RVA, + "dispatcher": base + CALLBACK_DISPATCHER_RVA, + "instructions": base + PMA_INSTRUCTIONS_HANDLER_RVA, + "gameplay_global": base + GAMEPLAY_GLOBAL_RVA, + "instructions_vtable": base + PMA_INSTRUCTIONS_VTABLE_RVA, + } + + +def gdb_prelude(pid: int, output: str) -> str: + if any(character in output for character in "\n\r"): + raise ValueError("output path cannot contain a newline") + return f"""set pagination off +set confirm off +set print thread-events off +set breakpoint always-inserted off +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} +""" + + +def build_script(pid: int, fifa_base: int, output: str, variant: str) -> str: + address = addresses(fifa_base, variant) + config = VARIANTS[variant] + return ( + gdb_prelude(pid, output) + + f"""define snapshot_pma_context + set $snap_ctx = $arg0 + set $snap_flag40 = -1 + set $snap_callback_vtable = 0 + set $snap_callback_owner = 0 + set $snap_dispatcher = 0 + set $snap_dispatcher_vtable = 0 + set $snap_pma = 0 + set $snap_pma_flag18 = -1 + set $snap_pma_parent = 0 + set $snap_pma_machine = 0 + set $snap_pma_current = 0 + if $snap_ctx != 0 + set $snap_flag40 = *(unsigned char*)($snap_ctx+0x40) + set $snap_callback_vtable = *(void**)($snap_ctx+0x48) + set $snap_callback_owner = *(void**)($snap_ctx+0x78) + set $snap_dispatcher = $snap_ctx+0x80 + set $snap_dispatcher_vtable = *(void**)$snap_dispatcher + set $snap_sentinel = $snap_ctx+0x88 + set $snap_node = *(void**)$snap_sentinel + set $snap_scan = 0 + while $snap_node != 0 && $snap_node != $snap_sentinel && $snap_scan < 8 + set $snap_candidate = *(void**)($snap_node+0x10) + if $snap_candidate != 0 + if *(void**)$snap_candidate == 0x{address['instructions_vtable']:x} + set $snap_pma = $snap_candidate + end + end + set $snap_node = *(void**)$snap_node + set $snap_scan = $snap_scan+1 + end + if $snap_pma != 0 + set $snap_pma_flag18 = *(unsigned char*)($snap_pma+0x18) + set $snap_pma_parent = *(void**)($snap_pma+0x8) + if $snap_pma_parent != 0 + set $snap_pma_machine = *(void**)($snap_pma_parent+0x8) + end + if $snap_pma_machine != 0 + set $snap_pma_current = *(void**)($snap_pma_machine+0x10) + end + end + end +end + +define snapshot_gameplay + set $snap_gameplay_global = *(void**)0x{address['gameplay_global']:x} + set $snap_listener_manager = 0 + set $snap_listener_table = 0 + set $snap_listener_index = -1 + set $snap_free_roam = 0 + set $snap_free_roam_state = -1 + set $snap_free_roam_111 = -1 + set $snap_free_roam_112 = -1 + set $snap_free_roam_124 = -1 + set $snap_selected = 0 + set $snap_selected_vtable = 0 + set $snap_selected_mode = -1 + if $snap_gameplay_global != 0 + set $snap_listener_manager = *(void**)($snap_gameplay_global+0x58) + end + if $snap_listener_manager != 0 + set $snap_listener_table = *(void**)$snap_listener_manager + end + if $snap_listener_table != 0 + set $snap_free_roam = *(void**)$snap_listener_table + set $snap_listener_index = *(int*)($snap_listener_table+0x20) + if $snap_listener_index >= 0 && $snap_listener_index < 3 + set $snap_selected = *(void**)($snap_listener_table+$snap_listener_index*8) + end + end + if $snap_free_roam != 0 + set $snap_free_roam_state = *(int*)($snap_free_roam+0x30) + set $snap_free_roam_111 = *(unsigned char*)($snap_free_roam+0x111) + set $snap_free_roam_112 = *(unsigned char*)($snap_free_roam+0x112) + set $snap_free_roam_124 = *(int*)($snap_free_roam+0x124) + end + if $snap_selected != 0 + set $snap_selected_vtable = *(void**)$snap_selected + set $snap_selected_mode = *(int*)($snap_selected+0x18) + end +end +set $tracked_ctx = 0 + + +hbreak *0x{address['scenario']:x} +commands + silent + set $ctx = $rcx + set $tracked_ctx = $ctx + snapshot_pma_context $ctx + snapshot_gameplay + python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d SCENARIO_MODE_START" % (time.time_ns(), time.monotonic_ns()), end=" ") + printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p arg_descriptor=%p arg_scenario=%p async_requested=%d flag40=%d callback_vtable=%p callback_owner=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_parent=%p pma_machine=%p pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $rdx, $r8, $r9b, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_parent, $snap_pma_machine, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode + disable 1 + enable 2 + continue +end + +hbreak *0x{address['writer']:x} +condition 2 $tracked_ctx != 0 && {config['writer_context']} == $tracked_ctx +disable 2 +commands + silent + set $ctx = {config['writer_context']} + snapshot_pma_context $ctx + snapshot_gameplay + python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d CONTEXT_ARM_WRITER" % (time.time_ns(), time.monotonic_ns()), end=" ") + printf "thread=%d instruction=%p caller_return=%p ctx=%p original_async_requested=%d flag40_before=%d callback_vtable=%p callback_owner_before=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, {config['writer_async_requested']}, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode + disable 2 + enable 3 + continue +end + +hbreak *0x{address['register']:x} +condition 3 $tracked_ctx != 0 && $rdx == $tracked_ctx+0x48 +disable 3 +commands + silent + set $callback = $rdx + set $ctx = $callback-0x48 + snapshot_pma_context $ctx + python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d ASYNC_REGISTER_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ") + printf "thread=%d callsite=%p caller_return=%p service=%p service_vtable=%p callback=%p callback_vtable=%p ctx=%p flag40=%d callback_owner=%p dispatcher=%p dispatcher_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $rcx, *(void**)$rcx, $callback, *(void**)$callback, $ctx, $snap_flag40, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable + disable 3 + continue +end + +hbreak *0x{address['arm']:x} +condition 4 {config['arm_condition']} +commands + silent + set $ctx = $rcx + if $tracked_ctx == 0 + set $tracked_ctx = $ctx + end + snapshot_pma_context $ctx + snapshot_gameplay + python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d COMPLETION_ARM_ENTRY" % (time.time_ns(), time.monotonic_ns()), end=" ") + printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p flag40_before=%d callback_vtable=%p callback_owner=%p dispatcher=%p dispatcher_vtable=%p result_source=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, *(void**)($ctx+0xa8), $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode + disable 4 + enable 5 + continue +end + +hbreak *0x{address['arm_writer']:x} +condition 5 $tracked_ctx != 0 && $rsi == $tracked_ctx +disable 5 +commands + silent + set $ctx = $rsi + snapshot_pma_context $ctx + snapshot_gameplay + python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d COMPLETION_ARM_WRITER" % (time.time_ns(), time.monotonic_ns()), end=" ") + printf "thread=%d instruction=%p caller_return=%p ctx=%p flag40_before=%d result_object=%p result_state28=%d callback_owner=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, $snap_flag40, $rax, *(int*)($rax+0x28), $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode + disable 5 + continue +end + +hbreak *0x{address['completion']:x} +condition 6 $tracked_ctx != 0 && $rcx == $tracked_ctx+0x48 +commands + silent + set $callback = $rcx + set $ctx = *(void**)($callback+0x30) + snapshot_pma_context $ctx + snapshot_gameplay + python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d ASYNC_COMPLETION" % (time.time_ns(), time.monotonic_ns()), end=" ") + printf "thread=%d function=%p caller_return=%p callback=%p callback_vtable=%p ctx=%p callback_matches_ctx48=%d flag40_before=%d arg_rdx=%p arg_r8=%p arg_r9=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $callback, *(void**)$callback, $ctx, $callback == $ctx+0x48, $snap_flag40, $rdx, $r8, $r9, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode + continue +end + +hbreak *0x{address['dispatcher']:x} +condition 7 $tracked_ctx != 0 && $rcx == $tracked_ctx+0x80 && $edx == 5 +commands + silent + set $ctx = $rcx-0x80 + snapshot_pma_context $ctx + snapshot_gameplay + python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d DISPATCHER_EVENT_5" % (time.time_ns(), time.monotonic_ns()), end=" ") + printf "thread=%d function=%p caller_return=%p dispatcher=%p event=%d arg_r8=%p arg_r9=%p ctx=%p flag40=%d callback_owner=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $rcx, $edx, $r8, $r9, $ctx, $snap_flag40, $snap_callback_owner, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode + disable 7 + enable 8 + continue +end + +hbreak *0x{address['instructions']:x} +disable 8 +commands + silent + set $listener = $rcx + set $parent = *(void**)($listener+0x8) + set $machine = 0 + set $current = 0 + if $parent != 0 + set $machine = *(void**)($parent+0x8) + end + if $machine != 0 + set $current = *(void**)($machine+0x10) + end + snapshot_gameplay + python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d INSTRUCTIONS_AFTER_EVENT_5" % (time.time_ns(), time.monotonic_ns()), end=" ") + printf "thread=%d handler=%p caller_return=%p listener=%p listener_vtable=%p event=%d flag18=%d parent=%p machine=%p current=%p current_vtable=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $listener, *(void**)$listener, $edx, *(unsigned char*)($listener+0x18), $parent, $machine, $current, $current ? *(void**)$current : 0, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode + disable 6 + continue +end + +printf "PMAPRODUCER ARMED pid={pid} variant={variant} scenario=0x{address['scenario']:x} writer=0x{address['writer']:x} register=0x{address['register']:x} arm=0x{address['arm']:x} arm_writer=0x{address['arm_writer']:x} completion=0x{address['completion']:x} dispatcher=0x{address['dispatcher']:x} instructions=0x{address['instructions']:x}\\n" +continue +""" + ) + + +def effective_environment(pid: int) -> dict[str, str]: + values: dict[str, str] = {} + for item in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0"): + if not item.startswith(b"OPENFUT_FIFA17_"): + continue + key, _, value = item.decode("utf-8", errors="replace").partition("=") + values[key] = value + return values + + +def selftest() -> None: + mode0 = addresses(0x140000000, "mode0") + alternate = addresses(0x140000000, "alternate") + script = build_script(1234, 0x140000000, "/tmp/pma-producer.log", "mode0") + assert mode0["scenario"] == 0x147B1C190 + assert mode0["writer"] == 0x147B1C26B + assert mode0["register"] == 0x147B1C282 + assert alternate["scenario"] == 0x147B1C050 + assert alternate["writer"] == 0x147B1C12F + assert alternate["register"] == 0x147B1C146 + assert mode0["completion"] == 0x147B046C0 + assert mode0["dispatcher"] == 0x147AC87B0 + assert mode0["instructions"] == 0x147AC91E0 + assert mode0["arm"] == 0x147B1AE60 + assert mode0["arm_writer"] == 0x147B1AF33 + assert script.count("hbreak *") == 8 + assert "condition 7 $tracked_ctx != 0" in script + assert "disable 2" in script and "enable 2" in script + assert "disable 3" in script and "enable 3" in script + assert "disable 5" in script and "enable 5" in script + assert "disable 8" in script and "enable 8" in script + assert "set *(" not in script + print("pma_producer_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("--variant", choices=tuple(VARIANTS), default="mode0") + 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: + fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE) + advance.validate_file( + fifa_path, + advance.PINNED_FIFA_SHA256, + advance.FIFA_MODULE, + ) + cards_base, cards_path = transition.cards_mapping(pid) + transition.validate_cards(cards_path) + output = args.output or f"/tmp/fifa17-pma-producer-{args.variant}-{pid}.log" + script = build_script(pid, fifa_base, output, args.variant) + environment = effective_environment(pid) + print( + "PMAPRODUCER PREPARED " + f"pid={pid} variant={args.variant} fifa_base={fifa_base:#x} cards_base={cards_base:#x} " + f"team_compat={environment.get('OPENFUT_FIFA17_SEASON_TEAM_COMPAT', '')} " + f"pma_fix={environment.get('OPENFUT_FIFA17_OFFLINE_SEASONS_PMA_FIX', '')}" + ) + 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-pma-producer-{args.variant}-{pid}.gdb" + Path(script_path).write_text(script, encoding="utf-8") + import os + + os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path]) + return 127 + + +if __name__ == "__main__": + raise SystemExit(main())