Trace FIFA17 PMA producer lifecycle

This commit is contained in:
funman300
2026-08-27 23:27:52 +00:00
parent 6aab279244
commit f4fc832ace
+394
View File
@@ -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', '<absent>')} "
f"pma_fix={environment.get('OPENFUT_FIFA17_OFFLINE_SEASONS_PMA_FIX', '<absent>')}"
)
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())