Compare commits
15 Commits
c3d0e56f69
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e14d3cd063 | |||
| f4fc832ace | |||
| 6aab279244 | |||
| d3451be17b | |||
| 0300af3333 | |||
| 2c572e918f | |||
| f608dbc438 | |||
| 43c460741b | |||
| 5eed124b85 | |||
| e3ed8c298e | |||
| 5181e103dc | |||
| 433a9b22dd | |||
| 0701ac94e1 | |||
| 025122ec9a | |||
| b91e707a7e |
Executable
+55
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Classify call sites of the 130000/130001 provider stubs.
|
||||||
|
|
||||||
|
A call whose result is COMPARED implements a predicate ("is this the FUT custom
|
||||||
|
club?"). Only a call whose result is STORED can assign a team id. This turns an
|
||||||
|
unreadable 81-site list into the handful that could actually introduce 130000
|
||||||
|
into a struct.
|
||||||
|
|
||||||
|
classify_calls.py <asmfile> <target_va_hex> [more_targets...]
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
asm = sys.argv[1]
|
||||||
|
targets = [t.lower().lstrip("0x") for t in sys.argv[2:]]
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for l in open(asm, errors="replace"):
|
||||||
|
m = re.match(r"\s*([0-9a-f]+):\s+((?:[0-9a-f]{2} )+)\s*(.*)", l)
|
||||||
|
if m:
|
||||||
|
lines.append((int(m.group(1), 16), m.group(3).strip()))
|
||||||
|
idx = {a: i for i, (a, _t) in enumerate(lines)}
|
||||||
|
|
||||||
|
STORE = re.compile(r"^mov\s+(?:DWORD PTR |QWORD PTR )?\[[^\]]+\],(eax|rax)\b")
|
||||||
|
CMP = re.compile(r"^(cmp|sub|test)\b.*\b(eax|rax)\b")
|
||||||
|
MOVREG = re.compile(r"^mov\s+(e[a-z]{2}|r\d+d|r[a-z]{2}),(eax|rax)\b")
|
||||||
|
|
||||||
|
for tgt in targets:
|
||||||
|
print(f"\n ===== callers of 0x{tgt} =====")
|
||||||
|
stores, cmps, other = [], [], []
|
||||||
|
for i, (a, txt) in enumerate(lines):
|
||||||
|
if not txt.startswith("call") or tgt not in txt:
|
||||||
|
continue
|
||||||
|
# look at the next few instructions for the fate of eax
|
||||||
|
window = [lines[j][1] for j in range(i + 1, min(i + 7, len(lines)))]
|
||||||
|
verdict, detail = "other", window[0] if window else ""
|
||||||
|
for w in window:
|
||||||
|
if STORE.match(w):
|
||||||
|
verdict, detail = "STORE", w
|
||||||
|
break
|
||||||
|
if CMP.match(w):
|
||||||
|
verdict, detail = "compare", w
|
||||||
|
break
|
||||||
|
if MOVREG.match(w):
|
||||||
|
verdict, detail = "movreg", w
|
||||||
|
break
|
||||||
|
rec = (a, detail)
|
||||||
|
(stores if verdict == "STORE" else cmps if verdict == "compare" else other).append(rec)
|
||||||
|
print(f" STORE (can assign) : {len(stores)}")
|
||||||
|
for a, d in stores:
|
||||||
|
print(f" 0x{a:x} {d}")
|
||||||
|
print(f" compare (predicate) : {len(cmps)}")
|
||||||
|
print(f" other/moved to reg : {len(other)}")
|
||||||
|
for a, d in other[:14]:
|
||||||
|
print(f" 0x{a:x} {d}")
|
||||||
Executable
+332
@@ -0,0 +1,332 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Trace FIFA17 Screen event 0x30 through command 0x128 and ScenarioModeStart.
|
||||||
|
|
||||||
|
The generated GDB program uses hardware breakpoints, only reads registers and
|
||||||
|
client memory, logs, and continues. Seven breakpoints are rotated so no more
|
||||||
|
than four are enabled. It never calls client functions, writes client memory,
|
||||||
|
emits events, or drives input.
|
||||||
|
|
||||||
|
command_128_trace.py [pid] [--output PATH]
|
||||||
|
command_128_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
|
||||||
|
|
||||||
|
MANAGER_SELECT_ACTION_SOURCE_RVA = 0x0705A620
|
||||||
|
MANAGER_SELECT_ACTION_RESULT_RVA = 0x07CDC4A6
|
||||||
|
SCREEN_EVENT_CHANNEL_ROUTER_RVA = 0x080CE230
|
||||||
|
SCREEN_EVENT_DISPATCH_RVA = 0x080CF790
|
||||||
|
SKILL_INSTRUCTIONS_SCREEN_RVA = 0x07DCA400
|
||||||
|
GAMEPLAY_COMMAND_DISPATCH_RVA = 0x07A8F6C0
|
||||||
|
FREE_ROAM_COMMAND_128_RVA = 0x07A92B0F
|
||||||
|
SCENARIO_SCHEDULER_RVA = 0x07AC3A40
|
||||||
|
SCENARIO_MANAGER_START_RVA = 0x07B1C2B0
|
||||||
|
MODE_ZERO_SCENARIO_START_RVA = 0x07B1C190
|
||||||
|
GAMEPLAY_GLOBAL_RVA = 0x04BFB910
|
||||||
|
SCREEN_VTABLE_RVA = 0x03B3ECC0
|
||||||
|
FREE_ROAM_VTABLE_RVA = 0x03AEDF58
|
||||||
|
MODE_ZERO_CHILD_VTABLE_RVA = 0x03AE9C00
|
||||||
|
|
||||||
|
|
||||||
|
def addresses(base: int) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"manager_select_source": base + MANAGER_SELECT_ACTION_SOURCE_RVA,
|
||||||
|
"manager_select_action": base + MANAGER_SELECT_ACTION_RESULT_RVA,
|
||||||
|
"screen_event_router": base + SCREEN_EVENT_CHANNEL_ROUTER_RVA,
|
||||||
|
"screen_event_dispatch": base + SCREEN_EVENT_DISPATCH_RVA,
|
||||||
|
"instructions_screen": base + SKILL_INSTRUCTIONS_SCREEN_RVA,
|
||||||
|
"command_dispatch": base + GAMEPLAY_COMMAND_DISPATCH_RVA,
|
||||||
|
"free_roam_case": base + FREE_ROAM_COMMAND_128_RVA,
|
||||||
|
"scheduler": base + SCENARIO_SCHEDULER_RVA,
|
||||||
|
"manager_start": base + SCENARIO_MANAGER_START_RVA,
|
||||||
|
"scenario_start": base + MODE_ZERO_SCENARIO_START_RVA,
|
||||||
|
"gameplay_global": base + GAMEPLAY_GLOBAL_RVA,
|
||||||
|
"screen_vtable": base + SCREEN_VTABLE_RVA,
|
||||||
|
"free_roam_vtable": base + FREE_ROAM_VTABLE_RVA,
|
||||||
|
"mode_zero_child_vtable": base + MODE_ZERO_CHILD_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) -> str:
|
||||||
|
address = addresses(fifa_base)
|
||||||
|
return (
|
||||||
|
gdb_prelude(pid, output)
|
||||||
|
+ f"""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_state = -1
|
||||||
|
set $snap_free_111 = -1
|
||||||
|
set $snap_free_112 = -1
|
||||||
|
set $snap_free_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_state = *(int*)($snap_free_roam+0x30)
|
||||||
|
set $snap_free_111 = *(unsigned char*)($snap_free_roam+0x111)
|
||||||
|
set $snap_free_112 = *(unsigned char*)($snap_free_roam+0x112)
|
||||||
|
set $snap_free_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 $action_count = 0
|
||||||
|
hbreak *0x{address['manager_select_action']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $action_count = $action_count+1
|
||||||
|
set $provider = $rbx
|
||||||
|
snapshot_gameplay
|
||||||
|
if $action_count <= 128
|
||||||
|
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d MANAGER_SELECT_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d ordinal=%d instruction=%p caller_return=%p provider=%p provider_vtable=%p action_id=%#x 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, $action_count, $pc, *(void**)($rsp+0x58), $provider, *(void**)$provider, $eax, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||||
|
end
|
||||||
|
if $eax == 0x30
|
||||||
|
bt 16
|
||||||
|
end
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['instructions_screen']:x}
|
||||||
|
condition 2 $edx == 0x30 && *(void**)$rcx == 0x{address['screen_vtable']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $screen = $rcx
|
||||||
|
set $screen_owner = *(void**)($screen+0x140)
|
||||||
|
set $screen_owner_vtable = 0
|
||||||
|
if $screen_owner != 0
|
||||||
|
set $screen_owner_vtable = *(void**)$screen_owner
|
||||||
|
end
|
||||||
|
snapshot_gameplay
|
||||||
|
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d INSTRUCTIONS_SCREEN_EVENT_30" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d handler=%p caller_return=%p screen=%p screen_vtable=%p event=%#x payload=%p allow_advance138=%d owner140=%p owner_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, $screen, *(void**)$screen, $edx, $r8, *(int*)($screen+0x138), $screen_owner, $screen_owner_vtable, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||||
|
bt 16
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['command_dispatch']:x}
|
||||||
|
condition 3 $edx == 0x128
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $command_dispatcher = $rcx
|
||||||
|
set $command_table = *(void**)$command_dispatcher
|
||||||
|
snapshot_gameplay
|
||||||
|
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d GAMEPLAY_COMMAND_128" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d function=%p caller_return=%p dispatcher=%p command=%#x payload=%p arg_r9=%p table=%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, $command_dispatcher, $edx, $r8, $r9, $command_table, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||||
|
disable 1
|
||||||
|
disable 2
|
||||||
|
disable 3
|
||||||
|
enable 5
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['free_roam_case']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $owner = $rbx
|
||||||
|
snapshot_gameplay
|
||||||
|
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d FREE_ROAM_COMMAND_128" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d callsite=%p caller_return=%p owner=%p owner_vtable=%p command=%#x payload=%p state=%d previous=%d free111=%d free112=%d free124=%d manager=%p selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $owner, *(void**)$owner, $esi, $rdi, *(int*)($owner+0x30), *(int*)($owner+0x34), *(unsigned char*)($owner+0x111), *(unsigned char*)($owner+0x112), *(int*)($owner+0x124), *(void**)($owner+0x168), $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['scheduler']:x}
|
||||||
|
disable 5
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $owner = $rcx
|
||||||
|
set $manager = *(void**)($owner+0x168)
|
||||||
|
set $manager_vtable = 0
|
||||||
|
set $manager_mode = -1
|
||||||
|
set $child = 0
|
||||||
|
set $child_vtable = 0
|
||||||
|
if $manager != 0
|
||||||
|
set $manager_vtable = *(void**)$manager
|
||||||
|
set $manager_mode = *(int*)($manager+0x50)
|
||||||
|
set $child = *(void**)($manager+0x8)
|
||||||
|
end
|
||||||
|
if $child != 0
|
||||||
|
set $child_vtable = *(void**)$child
|
||||||
|
end
|
||||||
|
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d SCENARIO_SCHEDULER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d function=%p caller_return=%p owner=%p owner_vtable=%p free124=%d command=%#x payload=%p manager=%p manager_vtable=%p manager_mode=%d child=%p child_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $owner, *(void**)$owner, *(int*)($owner+0x124), $edx, $r8, $manager, $manager_vtable, $manager_mode, $child, $child_vtable
|
||||||
|
disable 4
|
||||||
|
disable 5
|
||||||
|
enable 6
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['manager_start']:x}
|
||||||
|
disable 6
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $manager = $rcx
|
||||||
|
set $child = *(void**)($manager+0x8)
|
||||||
|
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d SCENARIO_MANAGER_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d function=%p caller_return=%p manager=%p manager_vtable=%p requested_countdown=%d mode=%d child=%p child_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $manager, *(void**)$manager, $rdx & 0xff, *(int*)($manager+0x50), $child, $child ? *(void**)$child : 0
|
||||||
|
disable 6
|
||||||
|
enable 7
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['scenario_start']:x}
|
||||||
|
disable 7
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $ctx = $rcx
|
||||||
|
snapshot_gameplay
|
||||||
|
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d MODE_ZERO_SCENARIO_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p descriptor=%p scenario_index=%d requested_countdown=%d flag40_before=%d callback_owner78=%p callback_vtable48=%p dispatcher_vtable80=%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, $r8d, $r9 & 0xff, *(unsigned char*)($ctx+0x40), *(void**)($ctx+0x78), *(void**)($ctx+0x48), *(void**)($ctx+0x80), $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||||
|
disable 7
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
printf "COMMAND128 ARMED pid={pid} action_id=0x{address['manager_select_action']:x} screen_handler=0x{address['instructions_screen']:x} command_dispatch=0x{address['command_dispatch']:x} free_roam=0x{address['free_roam_case']:x} scheduler=0x{address['scheduler']:x} manager=0x{address['manager_start']:x} scenario=0x{address['scenario_start']: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:
|
||||||
|
address = addresses(0x140000000)
|
||||||
|
script = build_script(1234, 0x140000000, "/tmp/command-128.log")
|
||||||
|
assert address["manager_select_source"] == 0x14705A620
|
||||||
|
assert address["manager_select_action"] == 0x147CDC4A6
|
||||||
|
assert address["instructions_screen"] == 0x147DCA400
|
||||||
|
assert address["command_dispatch"] == 0x147A8F6C0
|
||||||
|
assert address["free_roam_case"] == 0x147A92B0F
|
||||||
|
assert address["scheduler"] == 0x147AC3A40
|
||||||
|
assert address["manager_start"] == 0x147B1C2B0
|
||||||
|
assert address["scenario_start"] == 0x147B1C190
|
||||||
|
assert script.count("hbreak *") == 7
|
||||||
|
assert "set $action_count = 0" in script
|
||||||
|
assert "MANAGER_SELECT_ACTION" in script
|
||||||
|
assert "condition 2 $edx == 0x30" in script
|
||||||
|
assert "condition 3 $edx == 0x128" in script
|
||||||
|
assert "disable 4" in script
|
||||||
|
assert "disable 5" in script and "enable 5" in script
|
||||||
|
assert "disable 6" in script and "enable 6" in script
|
||||||
|
assert "disable 7" in script and "enable 7" in script
|
||||||
|
assert "set *(" not in script
|
||||||
|
print("command_128_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:
|
||||||
|
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 = 0
|
||||||
|
cards_path = "<not-loaded>"
|
||||||
|
try:
|
||||||
|
cards_base, cards_path = transition.cards_mapping(pid)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
transition.validate_cards(cards_path)
|
||||||
|
output = args.output or f"/tmp/fifa17-command-128-{pid}.log"
|
||||||
|
script = build_script(pid, fifa_base, output)
|
||||||
|
environment = effective_environment(pid)
|
||||||
|
print(
|
||||||
|
"COMMAND128 PREPARED "
|
||||||
|
f"pid={pid} fifa_base={fifa_base:#x} cards_base={cards_base:#x} "
|
||||||
|
f"cards_path={cards_path} "
|
||||||
|
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-command-128-{pid}.gdb"
|
||||||
|
Path(script_path).write_text(script, encoding="utf-8")
|
||||||
|
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||||
|
return 127
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Hardware-only trace of the engine-local overwrite wrapper entry.
|
||||||
|
|
||||||
|
Breaks before the prologue of FUN_147ce47e0, where [rsp] is the exact direct
|
||||||
|
caller return address and R8D is the team ID later written to the final match
|
||||||
|
record. This closes the one frame Wine PE unwinding could not recover.
|
||||||
|
|
||||||
|
No INT3/software breakpoints. No client memory writes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import gdb
|
||||||
|
|
||||||
|
WRAPPER_VA = 0x147CE47E0
|
||||||
|
_STATE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _reg(name: str) -> int:
|
||||||
|
return int(gdb.parse_and_eval(f"${name}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _read(address: int, size: int) -> bytes | None:
|
||||||
|
if not address or address < 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||||
|
except gdb.error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _u64(address: int) -> int | None:
|
||||||
|
data = _read(address, 8)
|
||||||
|
return struct.unpack("<Q", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _thread() -> dict:
|
||||||
|
thread = gdb.selected_thread()
|
||||||
|
if thread is None:
|
||||||
|
return {}
|
||||||
|
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||||
|
|
||||||
|
|
||||||
|
def _registers() -> dict:
|
||||||
|
names = (
|
||||||
|
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||||
|
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||||
|
)
|
||||||
|
return {name: _reg(name) for name in names}
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self, path: str):
|
||||||
|
self.path = path
|
||||||
|
self.index = 0
|
||||||
|
|
||||||
|
def log(self, kind: str, **payload):
|
||||||
|
self.index += 1
|
||||||
|
thread = _thread()
|
||||||
|
event = {
|
||||||
|
"event": kind,
|
||||||
|
"event_index": self.index,
|
||||||
|
"time_unix": time.time(),
|
||||||
|
"thread": thread,
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
with open(self.path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
|
||||||
|
class WrapperBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State):
|
||||||
|
self.state = state
|
||||||
|
super().__init__(
|
||||||
|
f"*0x{WRAPPER_VA:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||||
|
)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
stack = _reg("rsp")
|
||||||
|
caller_return = _u64(stack)
|
||||||
|
self.state.log(
|
||||||
|
"engine_overwrite_wrapper_entry",
|
||||||
|
wrapper_va=WRAPPER_VA,
|
||||||
|
caller_return_address=caller_return,
|
||||||
|
source_team_id=_reg("r8") & 0xFFFFFFFF,
|
||||||
|
side_argument=_reg("rdx") & 0xFFFFFFFF,
|
||||||
|
registers=_registers(),
|
||||||
|
caller_disassembly=(
|
||||||
|
gdb.execute(f"x/12i 0x{caller_return - 32:x}", to_string=True)
|
||||||
|
if caller_return else None
|
||||||
|
),
|
||||||
|
backtrace=gdb.execute("bt 32", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log(
|
||||||
|
"trace_error", where="engine_overwrite_wrapper", error=str(exc),
|
||||||
|
traceback=traceback.format_exc()
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _on_exit(event):
|
||||||
|
if _STATE is not None:
|
||||||
|
_STATE.log("inferior_exited", detail=str(event))
|
||||||
|
|
||||||
|
|
||||||
|
def start_trace(log_path: str, _cards_base: int):
|
||||||
|
global _STATE
|
||||||
|
open(log_path, "w", encoding="utf-8").close()
|
||||||
|
_STATE = State(log_path)
|
||||||
|
breakpoint = WrapperBreakpoint(_STATE)
|
||||||
|
gdb.events.exited.connect(_on_exit)
|
||||||
|
_STATE.log(
|
||||||
|
"trace_armed",
|
||||||
|
breakpoints={"engine_overwrite_wrapper": {"number": breakpoint.number, "va": WRAPPER_VA}},
|
||||||
|
hardware_only=True,
|
||||||
|
client_memory_writes=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""GDB payload for the LIVE-PROVEN engine match-team +0x14 writer.
|
||||||
|
|
||||||
|
READ-ONLY hardware debug only:
|
||||||
|
|
||||||
|
0x147c652ce mov dword [rdx + rcx + 0x44], r8d
|
||||||
|
|
||||||
|
At the first team-like source value, derives both fixed-stride record fields
|
||||||
|
from live RCX and arms 4-byte WRITE watchpoints on:
|
||||||
|
|
||||||
|
teamId A = rcx + 0x44
|
||||||
|
teamId B = rcx + 0x44 + 0x45c
|
||||||
|
|
||||||
|
The execute breakpoint records the intended source value before every call. The
|
||||||
|
watchpoints then capture both the expected write and any later overwrite, even
|
||||||
|
if the overwrite comes from a different function.
|
||||||
|
|
||||||
|
No INT3/software breakpoints. No client memory writes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import gdb
|
||||||
|
|
||||||
|
WRITER_VA = 0x147C652CE
|
||||||
|
POST_WRITER_VA = 0x147C652D3
|
||||||
|
SIDE_STRIDE = 0x45C
|
||||||
|
TEAM_FIELD_OFF = 0x44
|
||||||
|
RECORD_FIELD_OFF = 0x14
|
||||||
|
TEAM_LIKE = {73, 240, 241, 243, 130000, 130001}
|
||||||
|
|
||||||
|
_STATE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _reg(name: str) -> int:
|
||||||
|
return int(gdb.parse_and_eval(f"${name}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _thread() -> dict:
|
||||||
|
thread = gdb.selected_thread()
|
||||||
|
if thread is None:
|
||||||
|
return {}
|
||||||
|
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||||
|
|
||||||
|
|
||||||
|
def _read(address: int, size: int) -> bytes | None:
|
||||||
|
if not address or address < 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||||
|
except gdb.error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _i32(address: int) -> int | None:
|
||||||
|
data = _read(address, 4)
|
||||||
|
return struct.unpack("<i", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _registers() -> dict:
|
||||||
|
names = (
|
||||||
|
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||||
|
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||||
|
)
|
||||||
|
return {name: _reg(name) for name in names}
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self, log_path: str):
|
||||||
|
self.log_path = log_path
|
||||||
|
self.event_index = 0
|
||||||
|
self.engine_base = None
|
||||||
|
self.watch_a = None
|
||||||
|
self.watch_b = None
|
||||||
|
|
||||||
|
def log(self, kind: str, **payload):
|
||||||
|
self.event_index += 1
|
||||||
|
event = {
|
||||||
|
"event": kind,
|
||||||
|
"event_index": self.event_index,
|
||||||
|
"time_unix": time.time(),
|
||||||
|
"thread": _thread(),
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
with open(self.log_path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
def arm_fields(self, engine_base: int):
|
||||||
|
if self.engine_base == engine_base and self.watch_a and self.watch_b:
|
||||||
|
return
|
||||||
|
for watchpoint in (self.watch_a, self.watch_b):
|
||||||
|
if watchpoint is not None:
|
||||||
|
try:
|
||||||
|
watchpoint.delete()
|
||||||
|
except gdb.error:
|
||||||
|
pass
|
||||||
|
self.engine_base = engine_base
|
||||||
|
self.watch_a = TeamFieldWatchpoint(self, 0, engine_base + TEAM_FIELD_OFF)
|
||||||
|
self.watch_b = TeamFieldWatchpoint(
|
||||||
|
self, 1, engine_base + TEAM_FIELD_OFF + SIDE_STRIDE
|
||||||
|
)
|
||||||
|
self.log(
|
||||||
|
"team_field_watchpoints_armed",
|
||||||
|
engine_base=engine_base,
|
||||||
|
team_id_a_address=self.watch_a.address,
|
||||||
|
team_id_b_address=self.watch_b.address,
|
||||||
|
watchpoint_a=self.watch_a.number,
|
||||||
|
watchpoint_b=self.watch_b.number,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TeamFieldWatchpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State, side: int, address: int):
|
||||||
|
self.state = state
|
||||||
|
self.side = side
|
||||||
|
self.address = address
|
||||||
|
super().__init__(
|
||||||
|
f"*(int*)0x{address:x}",
|
||||||
|
type=gdb.BP_WATCHPOINT,
|
||||||
|
wp_class=gdb.WP_WRITE,
|
||||||
|
internal=False,
|
||||||
|
)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
pc = _reg("rip")
|
||||||
|
writer = WRITER_VA if pc == POST_WRITER_VA else None
|
||||||
|
record_start = self.address - RECORD_FIELD_OFF
|
||||||
|
record = _read(record_start, 0x7C)
|
||||||
|
self.state.log(
|
||||||
|
"final_team_field_write_post",
|
||||||
|
side=self.side,
|
||||||
|
watch_address=self.address,
|
||||||
|
value=_i32(self.address),
|
||||||
|
stopped_pc=pc,
|
||||||
|
writer_va=writer,
|
||||||
|
record_start=record_start,
|
||||||
|
record_hex=record.hex() if record else None,
|
||||||
|
registers=_registers(),
|
||||||
|
disassembly=gdb.execute("x/12i $pc-32", to_string=True),
|
||||||
|
backtrace=gdb.execute("bt 32", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log(
|
||||||
|
"trace_error",
|
||||||
|
where="team_field_watchpoint",
|
||||||
|
error=str(exc),
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class FinalWriterBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State):
|
||||||
|
self.state = state
|
||||||
|
super().__init__(
|
||||||
|
f"*0x{WRITER_VA:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||||
|
)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
engine_base = _reg("rcx")
|
||||||
|
side_offset = _reg("rdx")
|
||||||
|
source_value = _reg("r8") & 0xFFFFFFFF
|
||||||
|
if source_value in TEAM_LIKE:
|
||||||
|
self.state.arm_fields(engine_base)
|
||||||
|
destination = engine_base + side_offset + TEAM_FIELD_OFF
|
||||||
|
side = side_offset // SIDE_STRIDE if side_offset in (0, SIDE_STRIDE) else None
|
||||||
|
self.state.log(
|
||||||
|
"final_writer_pre",
|
||||||
|
instruction_va=WRITER_VA,
|
||||||
|
engine_base=engine_base,
|
||||||
|
side_offset=side_offset,
|
||||||
|
side=side,
|
||||||
|
destination=destination,
|
||||||
|
record_start=destination - RECORD_FIELD_OFF,
|
||||||
|
source_register="r8d",
|
||||||
|
source_value=source_value,
|
||||||
|
prior_value=_i32(destination),
|
||||||
|
team_id_a_address=engine_base + TEAM_FIELD_OFF,
|
||||||
|
team_id_b_address=engine_base + TEAM_FIELD_OFF + SIDE_STRIDE,
|
||||||
|
team_id_a_before=_i32(engine_base + TEAM_FIELD_OFF),
|
||||||
|
team_id_b_before=_i32(engine_base + TEAM_FIELD_OFF + SIDE_STRIDE),
|
||||||
|
registers=_registers(),
|
||||||
|
disassembly=gdb.execute("x/6i $pc", to_string=True),
|
||||||
|
backtrace=gdb.execute("bt 32", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log(
|
||||||
|
"trace_error",
|
||||||
|
where="final_writer",
|
||||||
|
error=str(exc),
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _on_exit(event):
|
||||||
|
if _STATE is not None:
|
||||||
|
_STATE.log("inferior_exited", detail=str(event))
|
||||||
|
|
||||||
|
|
||||||
|
def start_trace(log_path: str, _cards_base: int):
|
||||||
|
global _STATE
|
||||||
|
open(log_path, "w", encoding="utf-8").close()
|
||||||
|
_STATE = State(log_path)
|
||||||
|
writer = FinalWriterBreakpoint(_STATE)
|
||||||
|
gdb.events.exited.connect(_on_exit)
|
||||||
|
_STATE.log(
|
||||||
|
"trace_armed",
|
||||||
|
breakpoints={
|
||||||
|
"final_writer": {"number": writer.number, "va": WRITER_VA},
|
||||||
|
},
|
||||||
|
side_stride=SIDE_STRIDE,
|
||||||
|
team_field_offset=TEAM_FIELD_OFF,
|
||||||
|
hardware_only=True,
|
||||||
|
client_memory_writes=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Hardware-only origin trace for the exact SetTeam team context.
|
||||||
|
|
||||||
|
Matches the typed integer context pointer selected by SetTeam to the constructor
|
||||||
|
invocation that produced it. No client memory writes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import gdb
|
||||||
|
|
||||||
|
CONTEXT_REUSE = 0x1477C17FC
|
||||||
|
CONTEXT_ALLOCATED = 0x1477C18C1
|
||||||
|
SET_TEAM_STUB = 0x147060A80
|
||||||
|
LOCKED_SETTER_RETURN = 0x1477C2415
|
||||||
|
CONTEXT_STACK_COUNT = 0x144BCEDA0
|
||||||
|
CONTEXT_STACK_ARRAY = 0x144BCEDA8
|
||||||
|
INTERESTING = {73, 130000, 130001}
|
||||||
|
_STATE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _reg(name):
|
||||||
|
return int(gdb.parse_and_eval(f"${name}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _read(address, size):
|
||||||
|
if not address or address < 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||||
|
except gdb.error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _u64(address):
|
||||||
|
data = _read(address, 8)
|
||||||
|
return struct.unpack("<Q", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _i32(address):
|
||||||
|
data = _read(address, 4)
|
||||||
|
return struct.unpack("<i", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _thread():
|
||||||
|
thread = gdb.selected_thread()
|
||||||
|
if thread is None:
|
||||||
|
return {}
|
||||||
|
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self, path):
|
||||||
|
self.path = path
|
||||||
|
self.index = 0
|
||||||
|
self.total_constructor_hits = 0
|
||||||
|
self.interesting_constructor_hits = 0
|
||||||
|
self.pending_allocations = {}
|
||||||
|
self.origins = deque(maxlen=4096)
|
||||||
|
|
||||||
|
def log(self, kind, **payload):
|
||||||
|
self.index += 1
|
||||||
|
event = {
|
||||||
|
"event": kind,
|
||||||
|
"event_index": self.index,
|
||||||
|
"time_unix": time.time(),
|
||||||
|
"thread": _thread(),
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
with open(self.path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
def thread_key(self):
|
||||||
|
return tuple(_thread().get("ptid", ()))
|
||||||
|
|
||||||
|
def remember_origin(self, context, origin):
|
||||||
|
if context:
|
||||||
|
self.origins.append({**origin, "context": context})
|
||||||
|
|
||||||
|
def find_origin(self, context):
|
||||||
|
return next((origin for origin in reversed(self.origins)
|
||||||
|
if origin["context"] == context), None)
|
||||||
|
|
||||||
|
|
||||||
|
class HardwareBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state, address):
|
||||||
|
self.state = state
|
||||||
|
self.address = address
|
||||||
|
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
|
||||||
|
class ContextReuseBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
self.state.total_constructor_hits += 1
|
||||||
|
try:
|
||||||
|
value = _reg("rcx") & 0xFFFFFFFF
|
||||||
|
if value not in INTERESTING:
|
||||||
|
return False
|
||||||
|
self.state.interesting_constructor_hits += 1
|
||||||
|
rsp = _reg("rsp")
|
||||||
|
direct_return = _u64(rsp + 0x28)
|
||||||
|
origin = {
|
||||||
|
"value": value,
|
||||||
|
"direct_return_address": direct_return,
|
||||||
|
"upstream_return_address": (
|
||||||
|
_u64(rsp + 0x68)
|
||||||
|
if direct_return == LOCKED_SETTER_RETURN
|
||||||
|
else direct_return
|
||||||
|
),
|
||||||
|
"constructor_stack_hex": (_read(rsp, 0x100) or b"").hex(),
|
||||||
|
"constructor_hit": self.state.total_constructor_hits,
|
||||||
|
}
|
||||||
|
context = _reg("rax")
|
||||||
|
if context:
|
||||||
|
self.state.remember_origin(context, origin)
|
||||||
|
else:
|
||||||
|
self.state.pending_allocations[self.state.thread_key()] = origin
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log(
|
||||||
|
"trace_error",
|
||||||
|
where="context_reuse",
|
||||||
|
error=str(exc),
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ContextAllocatedBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
origin = self.state.pending_allocations.pop(self.state.thread_key(), None)
|
||||||
|
if origin is not None:
|
||||||
|
self.state.remember_origin(_reg("rdx"), origin)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log(
|
||||||
|
"trace_error",
|
||||||
|
where="context_allocated",
|
||||||
|
error=str(exc),
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class SetTeamStubBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
count = _i32(CONTEXT_STACK_COUNT)
|
||||||
|
array = _u64(CONTEXT_STACK_ARRAY)
|
||||||
|
team_context = (
|
||||||
|
_u64(array + (count - 2) * 8)
|
||||||
|
if array and count is not None and count >= 2
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
side_context = (
|
||||||
|
_u64(array + (count - 1) * 8)
|
||||||
|
if array and count is not None and count >= 1
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
rsp = _reg("rsp")
|
||||||
|
self.state.log(
|
||||||
|
"set_team_stub_entry",
|
||||||
|
context_stack_count=count,
|
||||||
|
team_context=team_context,
|
||||||
|
team_context_hex=(_read(team_context, 0x40) or b"").hex(),
|
||||||
|
team_value=_i32(team_context + 0x10) if team_context else None,
|
||||||
|
side_context=side_context,
|
||||||
|
side_value=_i32(side_context + 0x10) if side_context else None,
|
||||||
|
matched_origin=self.state.find_origin(team_context),
|
||||||
|
caller_return_address=_u64(rsp),
|
||||||
|
entry_registers={
|
||||||
|
name: _reg(name)
|
||||||
|
for name in ("rcx", "rdx", "r8", "r9")
|
||||||
|
},
|
||||||
|
backtrace=gdb.execute("bt 32", to_string=True),
|
||||||
|
total_constructor_hits=self.state.total_constructor_hits,
|
||||||
|
interesting_constructor_hits=self.state.interesting_constructor_hits,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log(
|
||||||
|
"trace_error",
|
||||||
|
where="set_team_stub",
|
||||||
|
error=str(exc),
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _on_exit(event):
|
||||||
|
if _STATE is not None:
|
||||||
|
_STATE.log(
|
||||||
|
"inferior_exited",
|
||||||
|
detail=str(event),
|
||||||
|
total_constructor_hits=_STATE.total_constructor_hits,
|
||||||
|
interesting_constructor_hits=_STATE.interesting_constructor_hits,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start_trace(log_path, _cards_base):
|
||||||
|
global _STATE
|
||||||
|
open(log_path, "w", encoding="utf-8").close()
|
||||||
|
_STATE = State(log_path)
|
||||||
|
points = {
|
||||||
|
"context_reuse": ContextReuseBreakpoint(_STATE, CONTEXT_REUSE),
|
||||||
|
"context_allocated": ContextAllocatedBreakpoint(_STATE, CONTEXT_ALLOCATED),
|
||||||
|
"set_team_stub": SetTeamStubBreakpoint(_STATE, SET_TEAM_STUB),
|
||||||
|
}
|
||||||
|
gdb.events.exited.connect(_on_exit)
|
||||||
|
_STATE.log(
|
||||||
|
"trace_armed",
|
||||||
|
breakpoints={
|
||||||
|
name: {"number": point.number, "va": point.address}
|
||||||
|
for name, point in points.items()
|
||||||
|
},
|
||||||
|
hardware_only=True,
|
||||||
|
client_memory_writes=False,
|
||||||
|
matching="exact_context_pointer",
|
||||||
|
)
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Hardware-only trace of engine game-setup context selection.
|
||||||
|
|
||||||
|
Captures the function that requests team/side, selector indices 1/0, selected
|
||||||
|
transient context objects, and the typed value getter. No client writes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import gdb
|
||||||
|
|
||||||
|
DISPATCH = 0x147060D00
|
||||||
|
SELECT_VALUE = 0x147572C50
|
||||||
|
CONTEXT_SELECTED = 0x1477C845D
|
||||||
|
_STATE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _reg(name: str) -> int:
|
||||||
|
return int(gdb.parse_and_eval(f"${name}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _read(address: int, size: int) -> bytes | None:
|
||||||
|
if not address or address < 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||||
|
except gdb.error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _u64(address: int) -> int | None:
|
||||||
|
data = _read(address, 8)
|
||||||
|
return struct.unpack("<Q", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _i32(address: int) -> int | None:
|
||||||
|
data = _read(address, 4)
|
||||||
|
return struct.unpack("<i", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _thread() -> dict:
|
||||||
|
thread = gdb.selected_thread()
|
||||||
|
if thread is None:
|
||||||
|
return {}
|
||||||
|
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||||
|
|
||||||
|
|
||||||
|
def _printable_pointers(address: int, data: bytes) -> dict:
|
||||||
|
found = {}
|
||||||
|
for offset in range(0, len(data) - 7, 8):
|
||||||
|
pointer = struct.unpack_from("<Q", data, offset)[0]
|
||||||
|
raw = _read(pointer, 128)
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
value = raw.split(b"\0", 1)[0]
|
||||||
|
try:
|
||||||
|
text = value.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
if len(text) >= 3 and all(char.isprintable() for char in text):
|
||||||
|
found[hex(offset)] = {"pointer": pointer, "text": text}
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self, path: str):
|
||||||
|
self.path = path
|
||||||
|
self.index = 0
|
||||||
|
self.requested_indices = {}
|
||||||
|
|
||||||
|
def log(self, kind: str, **payload):
|
||||||
|
self.index += 1
|
||||||
|
event = {"event": kind, "event_index": self.index, "time_unix": time.time(),
|
||||||
|
"thread": _thread(), **payload}
|
||||||
|
with open(self.path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||||
|
handle.flush(); os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
def key(self):
|
||||||
|
return tuple(_thread().get("ptid", ()))
|
||||||
|
|
||||||
|
|
||||||
|
class HardwareBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State, address: int):
|
||||||
|
self.state = state
|
||||||
|
self.address = address
|
||||||
|
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
rsp = _reg("rsp")
|
||||||
|
caller = _u64(rsp)
|
||||||
|
self.state.log(
|
||||||
|
"game_setup_dispatch_entry",
|
||||||
|
caller_return_address=caller,
|
||||||
|
caller_disassembly=(gdb.execute(f"x/12i 0x{caller-32:x}", to_string=True)
|
||||||
|
if caller else None),
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="dispatch", error=str(exc), traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class SelectValueBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
index = _reg("rcx") & 0xFFFFFFFF
|
||||||
|
self.state.requested_indices[self.state.key()] = index
|
||||||
|
self.state.log("context_value_request", index=index)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="select_value", error=str(exc), traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ContextSelectedBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
index = _reg("rdi") & 0xFFFFFFFF
|
||||||
|
context = _reg("rbx")
|
||||||
|
data = _read(context, 0x80) or b""
|
||||||
|
self.state.log(
|
||||||
|
"context_selected",
|
||||||
|
requested_index=self.state.requested_indices.get(self.state.key()),
|
||||||
|
selector_index=index,
|
||||||
|
context=context,
|
||||||
|
type_flags=_i32(context + 8),
|
||||||
|
value_i32=_i32(context + 0x10),
|
||||||
|
value_qword=_u64(context + 0x10),
|
||||||
|
context_hex=data.hex(),
|
||||||
|
printable_pointers=_printable_pointers(context, data),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="context_selected", error=str(exc), traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _on_exit(event):
|
||||||
|
if _STATE is not None:
|
||||||
|
_STATE.log("inferior_exited", detail=str(event))
|
||||||
|
|
||||||
|
|
||||||
|
def start_trace(log_path: str, _cards_base: int):
|
||||||
|
global _STATE
|
||||||
|
open(log_path, "w", encoding="utf-8").close()
|
||||||
|
_STATE = State(log_path)
|
||||||
|
points = {
|
||||||
|
"dispatch": DispatchBreakpoint(_STATE, DISPATCH),
|
||||||
|
"select_value": SelectValueBreakpoint(_STATE, SELECT_VALUE),
|
||||||
|
"context_selected": ContextSelectedBreakpoint(_STATE, CONTEXT_SELECTED),
|
||||||
|
}
|
||||||
|
gdb.events.exited.connect(_on_exit)
|
||||||
|
_STATE.log(
|
||||||
|
"trace_armed",
|
||||||
|
breakpoints={name: {"number": bp.number, "va": bp.address} for name, bp in points.items()},
|
||||||
|
hardware_only=True,
|
||||||
|
client_memory_writes=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""Hardware-only trace of CardsGameSetupAdapter query 13 and overwrite input.
|
||||||
|
|
||||||
|
Breakpoints:
|
||||||
|
|
||||||
|
FUN_180031340 entry incoming teamId/side/context
|
||||||
|
0x18003148f pre-call query id, selector, output/count pointers
|
||||||
|
0x180031495 post-call complete 48-byte records and count
|
||||||
|
0x180031861 submit original incoming teamId sent to engine
|
||||||
|
|
||||||
|
This proves whether query 13 influences the overwrite. No INT3/software
|
||||||
|
breakpoints, client writes, or game input.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import gdb
|
||||||
|
|
||||||
|
CARDS_IMAGE_BASE = 0x180000000
|
||||||
|
ENTRY = 0x180031340
|
||||||
|
QUERY_PRE = 0x18003148F
|
||||||
|
QUERY_POST = 0x180031495
|
||||||
|
SUBMIT = 0x180031861
|
||||||
|
MAX_RECORDS = 100
|
||||||
|
RECORD_SIZE = 48
|
||||||
|
_STATE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _reg(name: str) -> int:
|
||||||
|
return int(gdb.parse_and_eval(f"${name}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _read(address: int, size: int) -> bytes | None:
|
||||||
|
if not address or address < 0 or size < 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||||
|
except gdb.error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _u64(address: int) -> int | None:
|
||||||
|
data = _read(address, 8)
|
||||||
|
return struct.unpack("<Q", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _i32(address: int) -> int | None:
|
||||||
|
data = _read(address, 4)
|
||||||
|
return struct.unpack("<i", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _thread() -> dict:
|
||||||
|
thread = gdb.selected_thread()
|
||||||
|
if thread is None:
|
||||||
|
return {}
|
||||||
|
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||||
|
|
||||||
|
|
||||||
|
def _registers() -> dict:
|
||||||
|
names = (
|
||||||
|
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||||
|
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||||
|
)
|
||||||
|
return {name: _reg(name) for name in names}
|
||||||
|
|
||||||
|
|
||||||
|
def _printable_pointer(pointer: int) -> str | None:
|
||||||
|
data = _read(pointer, 96)
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
raw = data.split(b"\0", 1)[0]
|
||||||
|
if len(raw) < 3:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
text = raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return None
|
||||||
|
return text if all(char.isprintable() for char in text) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_record(data: bytes, address: int) -> dict:
|
||||||
|
words = list(struct.unpack("<12i", data))
|
||||||
|
qwords = list(struct.unpack("<6Q", data))
|
||||||
|
strings = {}
|
||||||
|
for index, pointer in enumerate(qwords):
|
||||||
|
text = _printable_pointer(pointer)
|
||||||
|
if text:
|
||||||
|
strings[f"qword_{index}"] = {"pointer": pointer, "text": text}
|
||||||
|
interesting = {
|
||||||
|
str(value): [index * 4 for index, word in enumerate(words) if word == value]
|
||||||
|
for value in (73, 240, 241, 243, 130000, 130001)
|
||||||
|
if value in words
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"address": address,
|
||||||
|
"hex": data.hex(),
|
||||||
|
"i32": words,
|
||||||
|
"u32": [value & 0xFFFFFFFF for value in words],
|
||||||
|
"f32": list(struct.unpack("<12f", data)),
|
||||||
|
"qwords": qwords,
|
||||||
|
"strings": strings,
|
||||||
|
"interesting_values": interesting,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self, path: str, cards_base: int):
|
||||||
|
self.path = path
|
||||||
|
self.cards_base = cards_base
|
||||||
|
self.index = 0
|
||||||
|
self.calls = {}
|
||||||
|
|
||||||
|
def log(self, kind: str, **payload):
|
||||||
|
self.index += 1
|
||||||
|
event = {
|
||||||
|
"event": kind,
|
||||||
|
"event_index": self.index,
|
||||||
|
"time_unix": time.time(),
|
||||||
|
"thread": _thread(),
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
with open(self.path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
def thread_key(self):
|
||||||
|
return tuple(_thread().get("ptid", ()))
|
||||||
|
|
||||||
|
|
||||||
|
class HardwareBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State, image_va: int):
|
||||||
|
self.state = state
|
||||||
|
self.image_va = image_va
|
||||||
|
address = state.cards_base + (image_va - CARDS_IMAGE_BASE)
|
||||||
|
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
|
||||||
|
class EntryBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
self.state.log(
|
||||||
|
"game_setup_entry",
|
||||||
|
incoming_context=_reg("rcx"),
|
||||||
|
incoming_side=_reg("rdx") & 0xFFFFFFFF,
|
||||||
|
incoming_team_id=_reg("r8") & 0xFFFFFFFF,
|
||||||
|
incoming_r9=_reg("r9"),
|
||||||
|
registers=_registers(),
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="entry", error=str(exc), traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class QueryPreBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
rsp = _reg("rsp")
|
||||||
|
adapter = _reg("rcx")
|
||||||
|
vtable = _u64(adapter)
|
||||||
|
count_pointer = _u64(rsp + 0x20)
|
||||||
|
state = {
|
||||||
|
"adapter": adapter,
|
||||||
|
"adapter_vtable": vtable,
|
||||||
|
"query_target": _u64(vtable + 0xE0) if vtable else None,
|
||||||
|
"query_id": _reg("rdx") & 0xFFFFFFFF,
|
||||||
|
"selector": _reg("r8") & 0xFFFFFFFF,
|
||||||
|
"output_buffer": _reg("r9"),
|
||||||
|
"count_pointer": count_pointer,
|
||||||
|
"sixth_argument": _u64(rsp + 0x28),
|
||||||
|
"count_before": _i32(count_pointer) if count_pointer else None,
|
||||||
|
"saved_incoming_team_id": _i32(rsp + 0x34),
|
||||||
|
"saved_side": _i32(rsp + 0x50),
|
||||||
|
"saved_engine_context": _u64(rsp + 0x68),
|
||||||
|
"adapter_prefix_hex": (_read(adapter, 0x100) or b"").hex(),
|
||||||
|
}
|
||||||
|
self.state.calls[self.state.thread_key()] = state
|
||||||
|
self.state.log(
|
||||||
|
"query13_pre",
|
||||||
|
**state,
|
||||||
|
registers=_registers(),
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="query_pre", error=str(exc), traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class QueryPostBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
state = self.state.calls.get(self.state.thread_key(), {})
|
||||||
|
count_pointer = state.get("count_pointer")
|
||||||
|
output = state.get("output_buffer")
|
||||||
|
count = _i32(count_pointer) if count_pointer else None
|
||||||
|
safe_count = min(max(count or 0, 0), MAX_RECORDS)
|
||||||
|
records = []
|
||||||
|
for index in range(safe_count):
|
||||||
|
address = output + index * RECORD_SIZE
|
||||||
|
data = _read(address, RECORD_SIZE)
|
||||||
|
if data and len(data) == RECORD_SIZE:
|
||||||
|
records.append(_decode_record(data, address))
|
||||||
|
self.state.log(
|
||||||
|
"query13_post",
|
||||||
|
query_state=state,
|
||||||
|
count_after=count,
|
||||||
|
records=records,
|
||||||
|
saved_incoming_team_id_after=_i32(_reg("rsp") + 0x34),
|
||||||
|
saved_side_after=_i32(_reg("rsp") + 0x50),
|
||||||
|
registers=_registers(),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="query_post", error=str(exc), traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class SubmitBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
rsp = _reg("rsp")
|
||||||
|
self.state.log(
|
||||||
|
"game_setup_submit",
|
||||||
|
submitted_team_id=_reg("r8") & 0xFFFFFFFF,
|
||||||
|
submitted_side=_reg("rdx") & 0xFFFFFFFF,
|
||||||
|
engine_context=_reg("rcx"),
|
||||||
|
saved_incoming_team_id=_i32(rsp + 0x34),
|
||||||
|
saved_side=_i32(rsp + 0x50),
|
||||||
|
registers=_registers(),
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="submit", error=str(exc), traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _on_exit(event):
|
||||||
|
if _STATE is not None:
|
||||||
|
_STATE.log("inferior_exited", detail=str(event))
|
||||||
|
|
||||||
|
|
||||||
|
def start_trace(log_path: str, cards_base: int):
|
||||||
|
global _STATE
|
||||||
|
open(log_path, "w", encoding="utf-8").close()
|
||||||
|
_STATE = State(log_path, cards_base)
|
||||||
|
points = {
|
||||||
|
"entry": EntryBreakpoint(_STATE, ENTRY),
|
||||||
|
"query_pre": QueryPreBreakpoint(_STATE, QUERY_PRE),
|
||||||
|
"query_post": QueryPostBreakpoint(_STATE, QUERY_POST),
|
||||||
|
"submit": SubmitBreakpoint(_STATE, SUBMIT),
|
||||||
|
}
|
||||||
|
gdb.events.exited.connect(_on_exit)
|
||||||
|
_STATE.log(
|
||||||
|
"trace_armed",
|
||||||
|
breakpoints={name: {"number": bp.number, "image_va": bp.image_va} for name, bp in points.items()},
|
||||||
|
record_size=RECORD_SIZE,
|
||||||
|
hardware_only=True,
|
||||||
|
client_memory_writes=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
"""GDB Python payload for read-only FIFA17 match-team writer tracing.
|
||||||
|
|
||||||
|
Loaded by trace_match_team_writer.py. Uses hardware execute breakpoints and a
|
||||||
|
4-byte hardware WRITE watchpoint only; never inserts INT3 and never writes game
|
||||||
|
memory.
|
||||||
|
|
||||||
|
Breakpoints (CardsDLL image VAs):
|
||||||
|
|
||||||
|
* FUN_1800fc500 entry -- derives output pair from RDX and arms *(int*)(rdx+4).
|
||||||
|
* 0x1800fc595 -- pre-write opponent lookup into pair[1].
|
||||||
|
* 0x1800fc5b8 -- mirrored pre-write opponent lookup into pair[0].
|
||||||
|
|
||||||
|
The dynamic watchpoint catches the exact write establishing pair[1], whether it
|
||||||
|
is the opponent lookup at 0x1800fc595 or the own-club store at 0x1800fc5a0.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import gdb
|
||||||
|
|
||||||
|
CARDS_IMAGE_BASE = 0x180000000
|
||||||
|
ENTRY_RVA = 0x0FC500
|
||||||
|
LOOKUP_TO_TEAM1_RVA = 0x0FC595
|
||||||
|
LOOKUP_TO_TEAM0_RVA = 0x0FC5B8
|
||||||
|
TEAM1_POST_PC_TO_WRITER = {
|
||||||
|
0x1800FC599: 0x1800FC595, # mov [r14+4],ecx
|
||||||
|
0x1800FC5A4: 0x1800FC5A0, # mov [r14+4],eax
|
||||||
|
}
|
||||||
|
|
||||||
|
_STATE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _reg(name: str) -> int:
|
||||||
|
return int(gdb.parse_and_eval(f"${name}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _thread() -> dict:
|
||||||
|
thread = gdb.selected_thread()
|
||||||
|
if thread is None:
|
||||||
|
return {}
|
||||||
|
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||||
|
|
||||||
|
|
||||||
|
def _read(address: int, size: int) -> bytes | None:
|
||||||
|
if not address or address < 0 or size < 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||||
|
except gdb.error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _u8(address: int) -> int | None:
|
||||||
|
data = _read(address, 1)
|
||||||
|
return data[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _u32(address: int) -> int | None:
|
||||||
|
data = _read(address, 4)
|
||||||
|
return struct.unpack("<I", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _i32(address: int) -> int | None:
|
||||||
|
data = _read(address, 4)
|
||||||
|
return struct.unpack("<i", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _u64(address: int) -> int | None:
|
||||||
|
data = _read(address, 8)
|
||||||
|
return struct.unpack("<Q", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _cstring(address: int, maximum: int = 256) -> str | None:
|
||||||
|
data = _read(address, maximum)
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
return data.split(b"\0", 1)[0].decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _rtti_name(vtable: int, cards_base: int) -> str | None:
|
||||||
|
"""MSVC x64 RTTI name from vtable[-1] CompleteObjectLocator.
|
||||||
|
|
||||||
|
PE RVAs in the locator are module-relative. Failure is evidence-free and is
|
||||||
|
logged as null; no pointer is named from an offset coincidence.
|
||||||
|
"""
|
||||||
|
locator = _u64(vtable - 8) if vtable else None
|
||||||
|
if not locator:
|
||||||
|
return None
|
||||||
|
raw = _read(locator, 24)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
_signature, _offset, _cd_offset, type_rva, _hier_rva, self_rva = struct.unpack(
|
||||||
|
"<IIIiii", raw
|
||||||
|
)
|
||||||
|
if not (0 <= type_rva < 0x10000000 and 0 <= self_rva < 0x10000000):
|
||||||
|
return None
|
||||||
|
image_base = locator - self_rva
|
||||||
|
if abs(image_base - cards_base) > 0x100000:
|
||||||
|
return None
|
||||||
|
return _cstring(image_base + type_rva + 16)
|
||||||
|
|
||||||
|
|
||||||
|
def _object(address: int, cards_base: int) -> dict:
|
||||||
|
vtable = _u64(address) if address else None
|
||||||
|
return {
|
||||||
|
"address": address,
|
||||||
|
"vtable": vtable,
|
||||||
|
"vtable_image_va": (
|
||||||
|
CARDS_IMAGE_BASE + (vtable - cards_base)
|
||||||
|
if vtable and cards_base <= vtable < cards_base + 0x400000
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"rtti": _rtti_name(vtable, cards_base) if vtable else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _registers() -> dict:
|
||||||
|
names = (
|
||||||
|
"rax",
|
||||||
|
"rbx",
|
||||||
|
"rcx",
|
||||||
|
"rdx",
|
||||||
|
"rsi",
|
||||||
|
"rdi",
|
||||||
|
"rbp",
|
||||||
|
"rsp",
|
||||||
|
"r8",
|
||||||
|
"r9",
|
||||||
|
"r10",
|
||||||
|
"r11",
|
||||||
|
"r12",
|
||||||
|
"r13",
|
||||||
|
"r14",
|
||||||
|
"r15",
|
||||||
|
"rip",
|
||||||
|
)
|
||||||
|
return {name: _reg(name) for name in names}
|
||||||
|
|
||||||
|
|
||||||
|
def _provenance(state, destination: int | None = None) -> dict:
|
||||||
|
"""Recover the candidate's live input chain without naming the objects."""
|
||||||
|
regs = _registers()
|
||||||
|
context = regs["rbx"]
|
||||||
|
output_pair = regs["r14"]
|
||||||
|
obj = regs["rbp"]
|
||||||
|
nested = _u64(obj + 0xB0) if obj else None
|
||||||
|
field_2e8 = nested + 0x2E8 if nested else None
|
||||||
|
source_base = _u64(field_2e8) if field_2e8 else None
|
||||||
|
participant_holder = regs["r12"]
|
||||||
|
participant = _u64(participant_holder) if participant_holder else None
|
||||||
|
index_70 = _u8(participant + 0x70) if participant else None
|
||||||
|
source_address = (
|
||||||
|
source_base + index_70 * 16
|
||||||
|
if source_base is not None and index_70 is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
source_bytes = _read(source_address, 16) if source_address else None
|
||||||
|
decoded = None
|
||||||
|
if source_bytes and len(source_bytes) == 16:
|
||||||
|
team_id, byte4, byte5, pad, word8, wordc = struct.unpack("<iBBHii", source_bytes)
|
||||||
|
decoded = {
|
||||||
|
"team_id": team_id,
|
||||||
|
"byte_4": byte4,
|
||||||
|
"byte_5": byte5,
|
||||||
|
"pad_6": pad,
|
||||||
|
"word_8": word8,
|
||||||
|
"word_c": wordc,
|
||||||
|
}
|
||||||
|
pair_bytes = _read(output_pair, 8) if output_pair else None
|
||||||
|
return {
|
||||||
|
"destination": destination,
|
||||||
|
"context": _object(context, state.cards_base),
|
||||||
|
"entry_context": _object(state.current_entry.get("context", 0), state.cards_base),
|
||||||
|
"output_pair": output_pair,
|
||||||
|
"entry_output_pair": state.current_entry.get("output_pair"),
|
||||||
|
"output_pair_bytes": pair_bytes.hex() if pair_bytes else None,
|
||||||
|
"output_team_id_0": _i32(output_pair) if output_pair else None,
|
||||||
|
"output_team_id_1": _i32(output_pair + 4) if output_pair else None,
|
||||||
|
"obj": _object(obj, state.cards_base),
|
||||||
|
"nested_at_obj_plus_b0": _object(nested or 0, state.cards_base),
|
||||||
|
"field_plus_2e8_address": field_2e8,
|
||||||
|
"source_array_base": source_base,
|
||||||
|
"participant_holder": participant_holder,
|
||||||
|
"participant": _object(participant or 0, state.cards_base),
|
||||||
|
"participant_plus_70": index_70,
|
||||||
|
"source_record_address": source_address,
|
||||||
|
"source_record_hex": source_bytes.hex() if source_bytes else None,
|
||||||
|
"source_record": decoded,
|
||||||
|
"registers": regs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self, log_path: str, cards_base: int):
|
||||||
|
self.log_path = log_path
|
||||||
|
self.cards_base = cards_base
|
||||||
|
self.current_entry: dict = {}
|
||||||
|
self.watchpoint = None
|
||||||
|
self.event_index = 0
|
||||||
|
|
||||||
|
def log(self, kind: str, **payload):
|
||||||
|
self.event_index += 1
|
||||||
|
event = {
|
||||||
|
"event": kind,
|
||||||
|
"event_index": self.event_index,
|
||||||
|
"time_unix": time.time(),
|
||||||
|
"thread": _thread(),
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
with open(self.log_path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
|
||||||
|
class Team1Watchpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State, address: int):
|
||||||
|
self.state = state
|
||||||
|
self.address = address
|
||||||
|
super().__init__(
|
||||||
|
f"*(int*)0x{address:x}",
|
||||||
|
type=gdb.BP_WATCHPOINT,
|
||||||
|
wp_class=gdb.WP_WRITE,
|
||||||
|
internal=False,
|
||||||
|
)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
pc = _reg("rip")
|
||||||
|
image_pc = CARDS_IMAGE_BASE + (pc - self.state.cards_base)
|
||||||
|
writer = TEAM1_POST_PC_TO_WRITER.get(image_pc)
|
||||||
|
source_value = None
|
||||||
|
if writer == 0x1800FC595:
|
||||||
|
source_value = _reg("rcx") & 0xFFFFFFFF
|
||||||
|
elif writer == 0x1800FC5A0:
|
||||||
|
source_value = _reg("rax") & 0xFFFFFFFF
|
||||||
|
self.state.log(
|
||||||
|
"team1_write_post",
|
||||||
|
watch_address=self.address,
|
||||||
|
value=_i32(self.address),
|
||||||
|
stopped_pc=pc,
|
||||||
|
stopped_image_va=image_pc,
|
||||||
|
writer_image_va=writer,
|
||||||
|
source_value=source_value,
|
||||||
|
disassembly=gdb.execute("x/10i $pc-32", to_string=True),
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
provenance=_provenance(self.state, self.address),
|
||||||
|
)
|
||||||
|
if writer is not None:
|
||||||
|
# The output pair is a short-lived stack buffer. Leaving the
|
||||||
|
# watchpoint active after the candidate's exact write produced
|
||||||
|
# 114k unrelated events when that stack memory was reused.
|
||||||
|
# The two hardware lookup breakpoints remain armed, so disabling
|
||||||
|
# only this completed one-shot watch loses no provenance.
|
||||||
|
self.enabled = False
|
||||||
|
self.state.log(
|
||||||
|
"team1_watchpoint_disabled",
|
||||||
|
watch_address=self.address,
|
||||||
|
reason="candidate exact write captured",
|
||||||
|
)
|
||||||
|
except Exception as exc: # GDB must continue even if evidence rendering fails.
|
||||||
|
self.state.log("trace_error", where="team1_watchpoint", error=str(exc),
|
||||||
|
traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class EntryBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State, address: int):
|
||||||
|
self.state = state
|
||||||
|
super().__init__(
|
||||||
|
f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||||
|
)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
context, output_pair = _reg("rcx"), _reg("rdx")
|
||||||
|
self.state.current_entry = {
|
||||||
|
"context": context,
|
||||||
|
"output_pair": output_pair,
|
||||||
|
"entry_thread": _thread(),
|
||||||
|
}
|
||||||
|
if self.state.watchpoint is not None:
|
||||||
|
try:
|
||||||
|
self.state.watchpoint.delete()
|
||||||
|
except gdb.error:
|
||||||
|
pass
|
||||||
|
initial = _i32(output_pair + 4)
|
||||||
|
self.state.watchpoint = Team1Watchpoint(self.state, output_pair + 4)
|
||||||
|
self.state.log(
|
||||||
|
"candidate_entry",
|
||||||
|
entry_image_va=0x1800FC500,
|
||||||
|
context=_object(context, self.state.cards_base),
|
||||||
|
output_pair=output_pair,
|
||||||
|
team_id_1_address=output_pair + 4,
|
||||||
|
team_id_1_initial=initial,
|
||||||
|
watchpoint_number=self.state.watchpoint.number,
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
registers=_registers(),
|
||||||
|
)
|
||||||
|
self.state.log(
|
||||||
|
"team1_watchpoint_armed",
|
||||||
|
watch_address=output_pair + 4,
|
||||||
|
watchpoint_number=self.state.watchpoint.number,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="candidate_entry", error=str(exc),
|
||||||
|
traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class LookupStoreBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State, address: int, image_va: int, destination_offset: int):
|
||||||
|
self.state = state
|
||||||
|
self.image_va = image_va
|
||||||
|
self.destination_offset = destination_offset
|
||||||
|
super().__init__(
|
||||||
|
f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||||
|
)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
destination = _reg("r14") + self.destination_offset
|
||||||
|
self.state.log(
|
||||||
|
"opponent_lookup_store_pre",
|
||||||
|
writer_image_va=self.image_va,
|
||||||
|
destination=destination,
|
||||||
|
destination_offset=self.destination_offset,
|
||||||
|
source_register="ecx",
|
||||||
|
source_value=_reg("rcx") & 0xFFFFFFFF,
|
||||||
|
disassembly=gdb.execute("x/5i $pc", to_string=True),
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
provenance=_provenance(self.state, destination),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="lookup_store", error=str(exc),
|
||||||
|
traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _on_exit(event):
|
||||||
|
if _STATE is not None:
|
||||||
|
_STATE.log("inferior_exited", detail=str(event))
|
||||||
|
|
||||||
|
|
||||||
|
def start_trace(log_path: str, cards_base: int):
|
||||||
|
"""Called from the supervisor's gdb command file after attach."""
|
||||||
|
global _STATE
|
||||||
|
open(log_path, "w", encoding="utf-8").close()
|
||||||
|
_STATE = State(log_path, cards_base)
|
||||||
|
entry = EntryBreakpoint(_STATE, cards_base + ENTRY_RVA)
|
||||||
|
lookup_team1 = LookupStoreBreakpoint(
|
||||||
|
_STATE,
|
||||||
|
cards_base + LOOKUP_TO_TEAM1_RVA,
|
||||||
|
0x1800FC595,
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
lookup_team0 = LookupStoreBreakpoint(
|
||||||
|
_STATE,
|
||||||
|
cards_base + LOOKUP_TO_TEAM0_RVA,
|
||||||
|
0x1800FC5B8,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
gdb.events.exited.connect(_on_exit)
|
||||||
|
_STATE.log(
|
||||||
|
"trace_armed",
|
||||||
|
cards_base=cards_base,
|
||||||
|
breakpoints={
|
||||||
|
"candidate_entry": {"number": entry.number, "image_va": 0x1800FC500},
|
||||||
|
"lookup_to_team1": {
|
||||||
|
"number": lookup_team1.number,
|
||||||
|
"image_va": 0x1800FC595,
|
||||||
|
},
|
||||||
|
"lookup_to_team0": {
|
||||||
|
"number": lookup_team0.number,
|
||||||
|
"image_va": 0x1800FC5B8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hardware_only=True,
|
||||||
|
client_memory_writes=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Hardware-only origin trace for CardsDLL team-pair submissions.
|
||||||
|
|
||||||
|
Distinguishes the three callers of the engine team-id service that can submit a
|
||||||
|
full two-team pair, plus the mode-76 builder that prepares its pair:
|
||||||
|
|
||||||
|
0x1800c7583 correct fixture pair control
|
||||||
|
0x1800c6c23 generic pair submitter
|
||||||
|
0x1800c8dc1 mode-76 pair submitter
|
||||||
|
0x1800c8bf0 mode-76 pair builder entry
|
||||||
|
|
||||||
|
No INT3/software breakpoints. No client memory writes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import gdb
|
||||||
|
|
||||||
|
CARDS_IMAGE_BASE = 0x180000000
|
||||||
|
SITES = {
|
||||||
|
0x1800C7583: ("fixture_pair_submit", "r14", "rsi"),
|
||||||
|
0x1800C6C23: ("generic_pair_submit", "r14", "rsi"),
|
||||||
|
0x1800C8DC1: ("mode76_pair_submit", "r15", "rbp"),
|
||||||
|
}
|
||||||
|
MODE76_BUILDER = 0x1800C8BF0
|
||||||
|
_STATE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _reg(name: str) -> int:
|
||||||
|
return int(gdb.parse_and_eval(f"${name}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _thread() -> dict:
|
||||||
|
thread = gdb.selected_thread()
|
||||||
|
if thread is None:
|
||||||
|
return {}
|
||||||
|
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||||
|
|
||||||
|
|
||||||
|
def _read(address: int, size: int) -> bytes | None:
|
||||||
|
if not address or address < 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||||
|
except gdb.error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pair(address: int) -> list[int] | None:
|
||||||
|
data = _read(address, 8)
|
||||||
|
return list(struct.unpack("<2i", data)) if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _registers() -> dict:
|
||||||
|
names = (
|
||||||
|
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||||
|
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||||
|
)
|
||||||
|
return {name: _reg(name) for name in names}
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self, log_path: str, cards_base: int):
|
||||||
|
self.log_path = log_path
|
||||||
|
self.cards_base = cards_base
|
||||||
|
self.event_index = 0
|
||||||
|
|
||||||
|
def log(self, kind: str, **payload):
|
||||||
|
self.event_index += 1
|
||||||
|
event = {
|
||||||
|
"event": kind,
|
||||||
|
"event_index": self.event_index,
|
||||||
|
"time_unix": time.time(),
|
||||||
|
"thread": _thread(),
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
with open(self.log_path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
|
||||||
|
class PairSubmitBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State, image_va: int, name: str, pointer_reg: str, index_reg: str):
|
||||||
|
self.state = state
|
||||||
|
self.image_va = image_va
|
||||||
|
self.name = name
|
||||||
|
self.pointer_reg = pointer_reg
|
||||||
|
self.index_reg = index_reg
|
||||||
|
address = state.cards_base + (image_va - CARDS_IMAGE_BASE)
|
||||||
|
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
pointer = _reg(self.pointer_reg)
|
||||||
|
index = _reg(self.index_reg) & 0xFFFFFFFF
|
||||||
|
pair_base = pointer - index * 4
|
||||||
|
self.state.log(
|
||||||
|
self.name,
|
||||||
|
instruction_image_va=self.image_va,
|
||||||
|
source_value=_reg("r8") & 0xFFFFFFFF,
|
||||||
|
side=_reg("rdx") & 0xFF,
|
||||||
|
engine_base=_reg("rcx"),
|
||||||
|
pair_pointer=pointer,
|
||||||
|
pair_index=index,
|
||||||
|
pair_base=pair_base,
|
||||||
|
pair=_pair(pair_base),
|
||||||
|
registers=_registers(),
|
||||||
|
disassembly=gdb.execute("x/5i $pc", to_string=True),
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log(
|
||||||
|
"trace_error", where=self.name, error=str(exc),
|
||||||
|
traceback=traceback.format_exc()
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Mode76BuilderBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State):
|
||||||
|
self.state = state
|
||||||
|
address = state.cards_base + (MODE76_BUILDER - CARDS_IMAGE_BASE)
|
||||||
|
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
self.state.log(
|
||||||
|
"mode76_builder_entry",
|
||||||
|
instruction_image_va=MODE76_BUILDER,
|
||||||
|
object=_reg("rcx"),
|
||||||
|
registers=_registers(),
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log(
|
||||||
|
"trace_error", where="mode76_builder", error=str(exc),
|
||||||
|
traceback=traceback.format_exc()
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _on_exit(event):
|
||||||
|
if _STATE is not None:
|
||||||
|
_STATE.log("inferior_exited", detail=str(event))
|
||||||
|
|
||||||
|
|
||||||
|
def start_trace(log_path: str, cards_base: int):
|
||||||
|
global _STATE
|
||||||
|
open(log_path, "w", encoding="utf-8").close()
|
||||||
|
_STATE = State(log_path, cards_base)
|
||||||
|
breakpoints = {}
|
||||||
|
for image_va, (name, pointer_reg, index_reg) in SITES.items():
|
||||||
|
bp = PairSubmitBreakpoint(_STATE, image_va, name, pointer_reg, index_reg)
|
||||||
|
breakpoints[name] = {"number": bp.number, "image_va": image_va}
|
||||||
|
builder = Mode76BuilderBreakpoint(_STATE)
|
||||||
|
breakpoints["mode76_builder"] = {"number": builder.number, "image_va": MODE76_BUILDER}
|
||||||
|
gdb.events.exited.connect(_on_exit)
|
||||||
|
_STATE.log(
|
||||||
|
"trace_armed",
|
||||||
|
breakpoints=breakpoints,
|
||||||
|
hardware_only=True,
|
||||||
|
client_memory_writes=False,
|
||||||
|
)
|
||||||
Executable
+95
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Find IMMEDIATE stores of a constant to a struct offset, in a live module.
|
||||||
|
|
||||||
|
immstore.py <imm_dec> [disp_hex|any] [--exe]
|
||||||
|
|
||||||
|
Only `C7 /0` (mov dword [reg+disp], imm32) can INTRODUCE a constant into a
|
||||||
|
field; `89 /r` merely propagates one. Emits image VAs so they can be fed to
|
||||||
|
ldis.py. Read-only.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
CARDS_IMG = 0x180000000
|
||||||
|
EXE_IMG = 0x140000000
|
||||||
|
|
||||||
|
|
||||||
|
def pid():
|
||||||
|
for d in glob.glob("/proc/[0-9]*"):
|
||||||
|
try:
|
||||||
|
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||||
|
return int(os.path.basename(d))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise SystemExit("FIFA17.exe not running")
|
||||||
|
|
||||||
|
|
||||||
|
P = pid()
|
||||||
|
|
||||||
|
|
||||||
|
def module_base(n):
|
||||||
|
for l in open(f"/proc/{P}/maps"):
|
||||||
|
if n.lower() in l.lower():
|
||||||
|
return int(l.split("-")[0], 16)
|
||||||
|
raise SystemExit(f"{n} not mapped")
|
||||||
|
|
||||||
|
|
||||||
|
def text_spans(base):
|
||||||
|
out = []
|
||||||
|
started = False
|
||||||
|
for l in open(f"/proc/{P}/maps"):
|
||||||
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", l)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)
|
||||||
|
if lo == base:
|
||||||
|
started = True
|
||||||
|
continue
|
||||||
|
if started:
|
||||||
|
if not path.strip() and "x" in perms:
|
||||||
|
out.append((lo, hi))
|
||||||
|
elif out:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
args = [a for a in sys.argv[1:] if a != "--exe"]
|
||||||
|
exe = "--exe" in sys.argv
|
||||||
|
imm = int(args[0], 0)
|
||||||
|
want_disp = None if len(args) < 2 or args[1] == "any" else int(args[1], 16)
|
||||||
|
img = EXE_IMG if exe else CARDS_IMG
|
||||||
|
base = module_base("FIFA17.exe" if exe else "CardsDLL")
|
||||||
|
|
||||||
|
mem = open(f"/proc/{P}/mem", "rb", 0)
|
||||||
|
immb = struct.pack("<i", imm)
|
||||||
|
hits = 0
|
||||||
|
for lo, hi in text_spans(base):
|
||||||
|
mem.seek(lo)
|
||||||
|
buf = mem.read(hi - lo)
|
||||||
|
img_lo = img + (lo - base)
|
||||||
|
i = buf.find(b"\xc7", 0)
|
||||||
|
while i >= 0:
|
||||||
|
modrm = buf[i + 1] if i + 1 < len(buf) else 0
|
||||||
|
if (modrm & 0x38) == 0: # /0
|
||||||
|
mod, rm = modrm >> 6, modrm & 7
|
||||||
|
if mod == 1 and i + 7 <= len(buf): # disp8
|
||||||
|
disp, ib = buf[i + 2], i + 3
|
||||||
|
sz = 7
|
||||||
|
elif mod == 2 and i + 10 <= len(buf): # disp32
|
||||||
|
disp, ib = struct.unpack_from("<i", buf, i + 2)[0], i + 6
|
||||||
|
sz = 10
|
||||||
|
elif mod == 0 and rm not in (4, 5) and i + 6 <= len(buf):
|
||||||
|
disp, ib = 0, i + 2
|
||||||
|
sz = 6
|
||||||
|
else:
|
||||||
|
disp = None
|
||||||
|
if disp is not None and buf[ib:ib + 4] == immb:
|
||||||
|
if want_disp is None or disp == want_disp:
|
||||||
|
print(f" image 0x{img_lo+i:x} mov dword [reg+0x{disp:x}], {imm} ({sz}B)")
|
||||||
|
hits += 1
|
||||||
|
i = buf.find(b"\xc7", i + 1)
|
||||||
|
print(f" {hits} immediate store(s) of {imm}"
|
||||||
|
+ (f" at +0x{want_disp:x}" if want_disp is not None else ""))
|
||||||
Executable
+94
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read-only live disassembler for the FIFA17 client (CardsDLL / FIFA17.exe).
|
||||||
|
|
||||||
|
ldis.py <image_va_hex> [nbytes] [--exe] disassemble
|
||||||
|
ldis.py --bytes <image_va_hex> [nbytes] hexdump
|
||||||
|
ldis.py --map show module bases
|
||||||
|
|
||||||
|
CardsDLL image base 0x180000000; FIFA17.exe image base 0x140000000.
|
||||||
|
Live address = module_base + (image_va - img_base). Sections map 1:1 for both,
|
||||||
|
but this is recomputed and printed so the offset trap stays visible.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
PID = None
|
||||||
|
CARDS_IMG = 0x180000000
|
||||||
|
EXE_IMG = 0x140000000
|
||||||
|
|
||||||
|
|
||||||
|
def pid():
|
||||||
|
global PID
|
||||||
|
if PID is None:
|
||||||
|
import glob, os
|
||||||
|
for d in glob.glob("/proc/[0-9]*"):
|
||||||
|
try:
|
||||||
|
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||||
|
PID = int(os.path.basename(d))
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if PID is None:
|
||||||
|
raise SystemExit("FIFA17.exe not running")
|
||||||
|
return PID
|
||||||
|
|
||||||
|
|
||||||
|
def module_base(needle):
|
||||||
|
"""Base = the NAMED PE-header mapping for the module (Wine maps the rest
|
||||||
|
anonymously, so never trust the mapping that merely CONTAINS an address)."""
|
||||||
|
for l in open(f"/proc/{pid()}/maps"):
|
||||||
|
if needle.lower() in l.lower():
|
||||||
|
return int(l.split("-")[0], 16)
|
||||||
|
raise SystemExit(f"module {needle} not mapped")
|
||||||
|
|
||||||
|
|
||||||
|
def live(va, exe=False):
|
||||||
|
if exe:
|
||||||
|
return module_base("FIFA17.exe") + (va - EXE_IMG)
|
||||||
|
return module_base("CardsDLL") + (va - CARDS_IMG)
|
||||||
|
|
||||||
|
|
||||||
|
def read(va, n, exe=False):
|
||||||
|
la = live(va, exe)
|
||||||
|
with open(f"/proc/{pid()}/mem", "rb", 0) as m:
|
||||||
|
m.seek(la)
|
||||||
|
return la, m.read(n)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
a = sys.argv[1:]
|
||||||
|
if not a or a[0] == "--map":
|
||||||
|
print(f" pid = {pid()}")
|
||||||
|
print(f" CardsDLL = 0x{module_base('CardsDLL'):x} (image 0x{CARDS_IMG:x})")
|
||||||
|
print(f" FIFA17.exe = 0x{module_base('FIFA17.exe'):x} (image 0x{EXE_IMG:x})")
|
||||||
|
return
|
||||||
|
hexdump = a[0] == "--bytes"
|
||||||
|
if hexdump:
|
||||||
|
a = a[1:]
|
||||||
|
exe = "--exe" in a
|
||||||
|
a = [x for x in a if x != "--exe"]
|
||||||
|
va = int(a[0], 16)
|
||||||
|
n = int(a[1]) if len(a) > 1 else 160
|
||||||
|
la, buf = read(va, n, exe)
|
||||||
|
print(f" image 0x{va:x} -> live 0x{la:x} ({len(buf)} bytes)")
|
||||||
|
if hexdump:
|
||||||
|
for i in range(0, len(buf), 16):
|
||||||
|
c = buf[i:i + 16]
|
||||||
|
print(f" 0x{va+i:x}: {' '.join(f'{b:02x}' for b in c):<47} "
|
||||||
|
+ "".join(chr(b) if 32 <= b < 127 else "." for b in c))
|
||||||
|
return
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".bin") as f:
|
||||||
|
f.write(buf)
|
||||||
|
f.flush()
|
||||||
|
out = subprocess.run(
|
||||||
|
["objdump", "-D", "-b", "binary", "-m", "i386:x86-64", "-M", "intel",
|
||||||
|
f"--adjust-vma=0x{va:x}", f.name],
|
||||||
|
capture_output=True, text=True).stdout
|
||||||
|
for line in out.splitlines():
|
||||||
|
if re.match(r"\s+[0-9a-f]+:", line):
|
||||||
|
print(" " + line.strip())
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
||||||
Executable
+252
@@ -0,0 +1,252 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Is the squad manager REGISTERED (not merely parsed) in a live FIFA17 client?
|
||||||
|
|
||||||
|
READ-ONLY. Opens /proc/<pid>/mem for reading and scans. Writes nothing, sends
|
||||||
|
no input to the game, and never opens 'r+b'.
|
||||||
|
|
||||||
|
manager_coldproof.py [pid] [--manager-wire N] [--manager-resource N]
|
||||||
|
[--control WIRE:RESOURCE ...]
|
||||||
|
|
||||||
|
Defaults describe the staging profile used to close the manager milestone; pass
|
||||||
|
the flags for any other profile.
|
||||||
|
|
||||||
|
WHAT THIS DECIDES
|
||||||
|
-----------------
|
||||||
|
FIFA17's squad parser (FUN_18013d1f0) reaches the item parser FUN_18013fe00 by
|
||||||
|
two different routes:
|
||||||
|
|
||||||
|
players : atom 568 -> per-element atoms 355 index / 363 itemData /
|
||||||
|
378 kitNumber; the 363 arm at 0x18013d8d9 calls the item parser
|
||||||
|
on the NESTED itemData object.
|
||||||
|
manager : atom 424 -> array loop at 0x18013da29 calls that same item parser
|
||||||
|
DIRECTLY on the array ELEMENT, into squad+0xC0. No itemData step.
|
||||||
|
|
||||||
|
So `squad.manager[]` elements must be BARE ITEM OBJECTS. When they were served
|
||||||
|
as {id, itemData:{...}, dream} the parser read only the two keys that happen to
|
||||||
|
be item atoms -- id and dream -- and left resourceId at 0. resourceId is the
|
||||||
|
merge key, compared RAW against carddbid (fut_staff.py::manager_item, +0x18),
|
||||||
|
so 0 resolves no manager: no name, no rating, no art, empty slot. Fixed in
|
||||||
|
OpenFUT b91e707; see Vault "FIFA 17/Squad Manager Wire Shape.md".
|
||||||
|
|
||||||
|
CONTROLS
|
||||||
|
--------
|
||||||
|
manager wire id the instance id. Present even when BROKEN, because `id` is
|
||||||
|
an item atom the parser reads at element level. Its
|
||||||
|
presence proves the element was parsed and therefore proves
|
||||||
|
nothing about registration -- do not use it as the verdict.
|
||||||
|
manager resourceId THE VERDICT. Resident => the merge key survived the load.
|
||||||
|
player wire id and positive controls. Players demonstrably render, so if their
|
||||||
|
player resourceId resourceIds are absent the squad simply is not loaded yet
|
||||||
|
and the run is INCONCLUSIVE, not a failure.
|
||||||
|
|
||||||
|
RESIDENT-MANAGER HIT
|
||||||
|
--------------------
|
||||||
|
A 4-byte-aligned little-endian i32 equal to the manager resourceId, anywhere in
|
||||||
|
a readable private mapping. Corroborate with the record context printed below:
|
||||||
|
a real item record carries resourceId eight words ahead of its wire id, which
|
||||||
|
is the layout the player controls exhibit. Hits without that shape are usually
|
||||||
|
id lists or unrelated integers -- the layout, not the raw count, is the proof.
|
||||||
|
|
||||||
|
LAYOUT ASSUMPTION (the only one)
|
||||||
|
--------------------------------
|
||||||
|
Item records place resourceId 0x20 bytes before the wire id. Measured, both
|
||||||
|
sides:
|
||||||
|
|
||||||
|
before b91e707 (pid 126936) -- manager parsed, merge key absent
|
||||||
|
player @0xb85dbf48: 83906881 1 0 0 0 0 0 0 | 100002878 0 | 7
|
||||||
|
player @0xb85dbd68: 84053575 1 0 0 0 0 0 0 | 100003237 0 | 7
|
||||||
|
manager @0xb85dc1b8: 0 0 0 0 0 0 0 0 | 100004870 0 | 7
|
||||||
|
|
||||||
|
after b91e707 (pid 134118) -- same layout, key present
|
||||||
|
player @0xb8740fd8: 84053575 1 0 0 0 0 0 0 | 100003237 0 | 7 0
|
||||||
|
player @0xb87411b8: 83906881 1 0 0 0 0 0 0 | 100002878 0 | 7 0
|
||||||
|
manager @0xb8741428: 1000509 2 0 0 0 0 0 0 | 100004870 0 | 7 0
|
||||||
|
|
||||||
|
Addresses shift every session and are recorded only as provenance; nothing here
|
||||||
|
depends on them. The tool re-derives everything by scanning.
|
||||||
|
|
||||||
|
EXIT CODES (fail-closed)
|
||||||
|
------------------------
|
||||||
|
0 PASS manager resourceId resident, controls present
|
||||||
|
1 FAIL controls present, manager resourceId absent
|
||||||
|
2 NO PROCESS no FIFA17.exe, or /proc/<pid>/mem unreadable
|
||||||
|
3 INCONCLUSIVE controls absent -- squad not loaded yet; re-run at the
|
||||||
|
squad screen. Deliberately NOT 0: absent controls mean the
|
||||||
|
probe proved nothing.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Staging profile defaults (override on the command line).
|
||||||
|
DEF_MANAGER_WIRE = 100004870
|
||||||
|
DEF_MANAGER_RESOURCE = 1000509
|
||||||
|
DEF_CONTROLS = [(100002878, 83906881), (100003237, 84053575)]
|
||||||
|
|
||||||
|
# Item record layout: resourceId sits this far BEFORE the wire id.
|
||||||
|
RESOURCE_BACK_OFF = 0x20
|
||||||
|
|
||||||
|
|
||||||
|
def find_pid():
|
||||||
|
"""The Wine process whose comm is FIFA17.exe (same rule as memtool.py)."""
|
||||||
|
for d in glob.glob("/proc/[0-9]*"):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(d, "comm")) as fh:
|
||||||
|
if fh.read().strip() == "FIFA17.exe":
|
||||||
|
return int(os.path.basename(d))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def regions(pid):
|
||||||
|
"""Readable private mappings worth scanning.
|
||||||
|
|
||||||
|
Skips device/memfd mappings and anything over 512 MiB (the big reserved
|
||||||
|
ranges are not where parsed records live and dominate the runtime).
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
with open(f"/proc/{pid}/maps") as fh:
|
||||||
|
for line in fh:
|
||||||
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
lo, hi = int(m.group(1), 16), int(m.group(2), 16)
|
||||||
|
perms, path = m.group(3), m.group(4)
|
||||||
|
if perms[0] != "r" or path.startswith(("/dev", "/memfd")):
|
||||||
|
continue
|
||||||
|
if hi - lo > 512 * 1024 * 1024:
|
||||||
|
continue
|
||||||
|
out.append((lo, hi))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def scan(pid, needles, ctx_before=0x40, ctx_after=0x40):
|
||||||
|
"""4-byte-aligned little-endian i32 search; keeps a window around each hit."""
|
||||||
|
found = {n: [] for n in needles}
|
||||||
|
pats = {n: struct.pack("<i", n) for n in needles}
|
||||||
|
with open(f"/proc/{pid}/mem", "rb", 0) as mem:
|
||||||
|
for lo, hi in regions(pid):
|
||||||
|
try:
|
||||||
|
mem.seek(lo)
|
||||||
|
buf = mem.read(hi - lo)
|
||||||
|
except (OSError, ValueError, OverflowError):
|
||||||
|
continue # torn-down or unreadable mapping; not a failure
|
||||||
|
for n, pat in pats.items():
|
||||||
|
i = buf.find(pat)
|
||||||
|
while i >= 0:
|
||||||
|
if i % 4 == 0:
|
||||||
|
found[n].append(
|
||||||
|
(lo + i, buf[max(0, i - ctx_before): i + ctx_after], min(i, ctx_before))
|
||||||
|
)
|
||||||
|
i = buf.find(pat, i + 4)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def words(blob, centre, before=8, after=4):
|
||||||
|
cells = []
|
||||||
|
for k in range(-before, after):
|
||||||
|
o = centre + k * 4
|
||||||
|
if 0 <= o <= len(blob) - 4:
|
||||||
|
cells.append(str(struct.unpack_from("<i", blob, o)[0]))
|
||||||
|
return " ".join(cells)
|
||||||
|
|
||||||
|
|
||||||
|
def record_shaped(blob, centre, resource):
|
||||||
|
"""True when resourceId sits RESOURCE_BACK_OFF before the id -- the real
|
||||||
|
item-record layout, as opposed to an incidental integer match."""
|
||||||
|
o = centre - RESOURCE_BACK_OFF
|
||||||
|
if o < 0 or o > len(blob) - 4:
|
||||||
|
return False
|
||||||
|
return struct.unpack_from("<i", blob, o)[0] == resource
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="read-only manager registration probe")
|
||||||
|
ap.add_argument("pid", nargs="?", type=int, help="FIFA17 pid (default: auto)")
|
||||||
|
ap.add_argument("--manager-wire", type=int, default=DEF_MANAGER_WIRE)
|
||||||
|
ap.add_argument("--manager-resource", type=int, default=DEF_MANAGER_RESOURCE)
|
||||||
|
ap.add_argument(
|
||||||
|
"--control",
|
||||||
|
action="append",
|
||||||
|
metavar="WIRE:RESOURCE",
|
||||||
|
help="player positive control; repeatable (default: the staging pair)",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
controls = DEF_CONTROLS
|
||||||
|
if args.control:
|
||||||
|
try:
|
||||||
|
controls = [tuple(int(x) for x in c.split(":", 1)) for c in args.control]
|
||||||
|
except ValueError:
|
||||||
|
print(" --control must be WIRE:RESOURCE", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
pid = args.pid or find_pid()
|
||||||
|
if not pid:
|
||||||
|
print(" NO FIFA17 PROCESS (comm == FIFA17.exe) -- is the client running?")
|
||||||
|
return 2
|
||||||
|
if not os.access(f"/proc/{pid}/mem", os.R_OK):
|
||||||
|
print(f" /proc/{pid}/mem is not readable -- wrong user, or the process exited")
|
||||||
|
return 2
|
||||||
|
print(f" pid={pid}")
|
||||||
|
|
||||||
|
needles = [args.manager_wire, args.manager_resource]
|
||||||
|
for w, r in controls:
|
||||||
|
needles += [w, r]
|
||||||
|
try:
|
||||||
|
res = scan(pid, sorted(set(needles)))
|
||||||
|
except OSError as e:
|
||||||
|
print(f" cannot read /proc/{pid}/mem: {e}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
print("\n ===== hit counts =====")
|
||||||
|
print(f" {'manager wire (parsed?)':32} {args.manager_wire:<12} hits={len(res[args.manager_wire])}")
|
||||||
|
print(f" {'manager resourceId (VERDICT)':32} {args.manager_resource:<12} "
|
||||||
|
f"hits={len(res[args.manager_resource])}")
|
||||||
|
for w, r in controls:
|
||||||
|
print(f" {'player wire (control)':32} {w:<12} hits={len(res[w])}")
|
||||||
|
print(f" {'player resourceId (control)':32} {r:<12} hits={len(res[r])}")
|
||||||
|
|
||||||
|
print("\n ===== record context (8 words before the id, then the id) =====")
|
||||||
|
shaped = {"manager": 0}
|
||||||
|
for tag, wire, resource in (
|
||||||
|
[("manager", args.manager_wire, args.manager_resource)]
|
||||||
|
+ [(f"player{i}", w, r) for i, (w, r) in enumerate(controls)]
|
||||||
|
):
|
||||||
|
marked = 0
|
||||||
|
for addr, blob, centre in res[wire]:
|
||||||
|
ok = record_shaped(blob, centre, resource)
|
||||||
|
if ok:
|
||||||
|
marked += 1
|
||||||
|
if marked <= 2 or ok:
|
||||||
|
print(f" {tag:8} @0x{addr:x}{' <- item-record layout' if ok else ''}: "
|
||||||
|
f"{words(blob, centre)}")
|
||||||
|
if marked >= 2:
|
||||||
|
break
|
||||||
|
shaped[tag] = marked
|
||||||
|
|
||||||
|
ctl_keys = sum(len(res[r]) for _w, r in controls)
|
||||||
|
mgr_keys = len(res[args.manager_resource])
|
||||||
|
|
||||||
|
print("\n ===== verdict =====")
|
||||||
|
if ctl_keys == 0:
|
||||||
|
print(" INCONCLUSIVE: no player resourceId control is resident, so the squad")
|
||||||
|
print(" is not loaded. Reach the squad screen and re-run. (Nothing proven.)")
|
||||||
|
return 3
|
||||||
|
if mgr_keys == 0:
|
||||||
|
print(f" FAIL: manager resourceId {args.manager_resource} is absent while "
|
||||||
|
f"{ctl_keys} player")
|
||||||
|
print(" resourceId control hit(s) are resident -> PARSED_BUT_NOT_REGISTERED.")
|
||||||
|
return 1
|
||||||
|
print(f" PASS: manager resourceId {args.manager_resource} is resident "
|
||||||
|
f"({mgr_keys} hits, {shaped['manager']} in item-record layout).")
|
||||||
|
print(" The merge key survived the load; the broken projection had 0.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+189
@@ -0,0 +1,189 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Trace FIFA17 provider dispatch and ACTION_ADVANCE delivery boundaries.
|
||||||
|
|
||||||
|
This probe correlates the global UI dispatch of FUT_CREATE_MATCH_DP and
|
||||||
|
FUT_GET_MATCH_KITS_DP, the subscribed CardsDLL provider, the internal 0x7546
|
||||||
|
create-response callback that can replay FUT_CREATE_MATCH_DP, and the final
|
||||||
|
native-to-UI bridge. At global dispatch, r8d is the provider ID and rdx is the
|
||||||
|
payload; neither register is a screen key.
|
||||||
|
|
||||||
|
The generated GDB program uses hardware-assisted execution breakpoints only.
|
||||||
|
It never writes client memory and never drives game input.
|
||||||
|
|
||||||
|
match_advance_trace.py [pid] [--output PATH]
|
||||||
|
match_advance_trace.py --print-script [pid]
|
||||||
|
match_advance_trace.py --selftest
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import match_transition_trace as transition
|
||||||
|
|
||||||
|
FIFA_MODULE = "FIFA17.exe"
|
||||||
|
PINNED_FIFA_SHA256 = "29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899"
|
||||||
|
GLOBAL_UI_DISPATCH_RVA = 0x80D1070
|
||||||
|
CREATE_MATCH_CONTROLLER_RVA = 0xBF950
|
||||||
|
PROVIDER_BRIDGE_CALL_RVA = 0x1A4D41
|
||||||
|
|
||||||
|
|
||||||
|
def module_mapping(pid: int, module: str) -> tuple[int, str]:
|
||||||
|
with open(f"/proc/{pid}/maps", encoding="utf-8") as handle:
|
||||||
|
for line in handle:
|
||||||
|
fields = line.split(maxsplit=5)
|
||||||
|
path = fields[5].rstrip() if len(fields) == 6 else ""
|
||||||
|
if not path.endswith(module):
|
||||||
|
continue
|
||||||
|
return int(fields[0].split("-", 1)[0], 16), path
|
||||||
|
raise RuntimeError(f"{module} is not mapped in PID {pid}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_file(path: str, expected: str, label: str) -> None:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
actual = digest.hexdigest()
|
||||||
|
if actual != expected:
|
||||||
|
raise RuntimeError(f"unsupported {label}: sha256={actual}; expected={expected}")
|
||||||
|
|
||||||
|
|
||||||
|
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"provider": cards_base + transition.PROVIDER_DISPATCH_RVA,
|
||||||
|
"global_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
|
||||||
|
"controller": cards_base + CREATE_MATCH_CONTROLLER_RVA,
|
||||||
|
"bridge": cards_base + PROVIDER_BRIDGE_CALL_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}
|
||||||
|
|
||||||
|
hbreak *0x{address['provider']:x}
|
||||||
|
condition 1 $edx == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edx == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d PROVIDER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d provider=%#x payload=%p controller=%p caller=%p\\n", $_thread, $edx, $r8, $rcx, *(void**)$rsp
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['global_dispatch']:x}
|
||||||
|
condition 2 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d GLOBAL_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d provider=%#x payload=%p manager=%p caller=%p\\n", $_thread, $r8d, $rdx, $rcx, *(void**)$rsp
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['controller']:x}
|
||||||
|
condition 3 $edx == 0x7546
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d CREATE_MATCH_CONTROLLER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d event=%#x controller=%p caller=%p\\n", $_thread, $edx, $rcx, *(void**)$rsp
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['bridge']:x}
|
||||||
|
condition 4 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d PROVIDER_BRIDGE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d provider=%#x target=%p bridge=%p callback=%p\\n", $_thread, $edi, $rsi, $rbx, *(void**)(*(void**)$rbx+0x48)
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
printf "ADVTRACE ARMED pid={pid} provider=0x{address['provider']:x} global=0x{address['global_dispatch']:x} controller=0x{address['controller']:x} bridge=0x{address['bridge']:x}\\n"
|
||||||
|
continue
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> None:
|
||||||
|
address = trace_addresses(0x180000000, 0x140000000)
|
||||||
|
assert address == {
|
||||||
|
"provider": 0x1801A4CD0,
|
||||||
|
"global_dispatch": 0x1480D1070,
|
||||||
|
"controller": 0x1800BF950,
|
||||||
|
"bridge": 0x1801A4D41,
|
||||||
|
}
|
||||||
|
script = build_gdb_script(28804, 0x180000000, 0x140000000, "/tmp/advance.log")
|
||||||
|
assert script.count("hbreak *") == 4
|
||||||
|
assert f"$edx == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||||
|
assert f"$edx == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||||
|
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||||
|
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||||
|
assert "CREATE_MATCH_CONTROLLER" in script
|
||||||
|
assert "PROVIDER_BRIDGE" in script
|
||||||
|
assert "set *(" not in script
|
||||||
|
print("match_advance_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 = module_mapping(pid, FIFA_MODULE)
|
||||||
|
validate_file(fifa_path, PINNED_FIFA_SHA256, FIFA_MODULE)
|
||||||
|
output = args.output or f"/tmp/fifa17-match-advance-{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-advance-{pid}.gdb"
|
||||||
|
with open(script_path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(script)
|
||||||
|
os.execvp("gdb", ["gdb", "-q", "-nx", "-x", script_path])
|
||||||
|
return 127
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+208
@@ -0,0 +1,208 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Trace the FIFA17 ACTION_CREATE_MATCH-to-provider lifecycle.
|
||||||
|
|
||||||
|
The probe correlates:
|
||||||
|
|
||||||
|
* the select-team action handler for UIF action IDs 0x7574..0x757b;
|
||||||
|
* DataManager's request dispatch for FutCreateMatchServerResponse (0x7546);
|
||||||
|
* the concrete FutCreateMatchServerResponse data-source request method;
|
||||||
|
* FIFA's global UI dispatch of providers 0x7563 and 0x7565.
|
||||||
|
|
||||||
|
Static decoding identifies action 0x7577 as the branch that constructs the
|
||||||
|
create-match request and calls DataManager for source 0x7546. The trace proves
|
||||||
|
whether that authentic trigger executes in the failing flow. It uses four
|
||||||
|
hardware-assisted execution breakpoints, never writes client memory, and never
|
||||||
|
drives game input.
|
||||||
|
|
||||||
|
match_create_action_trace.py [pid] [--output PATH]
|
||||||
|
match_create_action_trace.py --print-script [pid]
|
||||||
|
match_create_action_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
|
||||||
|
|
||||||
|
SELECT_TEAM_ACTION_HANDLER_RVA = 0x0BFCC0
|
||||||
|
DATA_MANAGER_REQUEST_RVA = 0x80D2340
|
||||||
|
DATA_SOURCE_REQUEST_RVA = 0x120270
|
||||||
|
GLOBAL_UI_DISPATCH_RVA = advance.GLOBAL_UI_DISPATCH_RVA
|
||||||
|
FIRST_SELECT_TEAM_ACTION = 0x7574
|
||||||
|
LAST_SELECT_TEAM_ACTION = 0x757B
|
||||||
|
ACTION_CREATE_MATCH = 0x7577
|
||||||
|
CREATE_DATA_SOURCE = 0x7546
|
||||||
|
|
||||||
|
|
||||||
|
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"action_handler": cards_base + SELECT_TEAM_ACTION_HANDLER_RVA,
|
||||||
|
"manager_request": fifa_base + DATA_MANAGER_REQUEST_RVA,
|
||||||
|
"data_source_request": cards_base + DATA_SOURCE_REQUEST_RVA,
|
||||||
|
"ui_dispatch": fifa_base + GLOBAL_UI_DISPATCH_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 $create_action_seen = 0
|
||||||
|
set $manager_request_seen = 0
|
||||||
|
set $data_source_request_seen = 0
|
||||||
|
|
||||||
|
hbreak *0x{address['action_handler']:x}
|
||||||
|
condition 1 $edx >= 0x{FIRST_SELECT_TEAM_ACTION:x} && $edx <= 0x{LAST_SELECT_TEAM_ACTION:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
if $edx == 0x{ACTION_CREATE_MATCH:x}
|
||||||
|
set $create_action_seen = 1
|
||||||
|
end
|
||||||
|
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d SELECT_TEAM_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d action=%#x is_create=%d controller=%p payload=%p create_seen=%d\\n", $_thread, $edx, $edx==0x{ACTION_CREATE_MATCH:x}, $rcx, $r8, $create_action_seen
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['manager_request']:x}
|
||||||
|
condition 2 $edx == 0x{CREATE_DATA_SOURCE:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $manager_request_seen = 1
|
||||||
|
set $tree_sentinel = $rcx + 0x10
|
||||||
|
set $tree_cursor = *(void**)($rcx+0x20)
|
||||||
|
set $data_node = $tree_sentinel
|
||||||
|
while $tree_cursor != 0 && $tree_cursor != $tree_sentinel
|
||||||
|
if *(unsigned int*)($tree_cursor+0x20) >= 0x{CREATE_DATA_SOURCE:x}
|
||||||
|
set $data_node = $tree_cursor
|
||||||
|
set $tree_cursor = *(void**)($tree_cursor+0x08)
|
||||||
|
else
|
||||||
|
set $tree_cursor = *(void**)$tree_cursor
|
||||||
|
end
|
||||||
|
end
|
||||||
|
set $data_source = 0
|
||||||
|
set $request_method = 0
|
||||||
|
if $data_node != $tree_sentinel && *(unsigned int*)($data_node+0x20) == 0x{CREATE_DATA_SOURCE:x}
|
||||||
|
set $data_source = *(void**)($data_node+0x28)
|
||||||
|
if $data_source != 0
|
||||||
|
set $request_method = *(void**)(*(void**)$data_source+0x18)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d MANAGER_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d source=%#x manager=%p request=%p node=%p data_source=%p request_method=%p create_seen=%d\\n", $_thread, $edx, $rcx, $r8, $data_node, $data_source, $request_method, $create_action_seen
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['data_source_request']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $data_source_request_seen = 1
|
||||||
|
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d DATA_SOURCE_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d response=%p data_source=%p request=%p ready_before=%#x create_seen=%d manager_seen=%d\\n", $_thread, $rcx-0x50, $rcx, $rdx, *(unsigned char*)($rcx+0x38), $create_action_seen, $manager_request_seen
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['ui_dispatch']:x}
|
||||||
|
condition 4 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d UI_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d provider=%#x payload=%p ui_manager=%p create_seen=%d manager_seen=%d data_source_seen=%d\\n", $_thread, $r8d, $rdx, $rcx, $create_action_seen, $manager_request_seen, $data_source_request_seen
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
printf "ACTIONTRACE ARMED pid={pid} action_handler=0x{address['action_handler']:x} manager_request=0x{address['manager_request']:x} data_source_request=0x{address['data_source_request']:x} ui_dispatch=0x{address['ui_dispatch']:x}\\n"
|
||||||
|
continue
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> None:
|
||||||
|
address = trace_addresses(0x180000000, 0x140000000)
|
||||||
|
assert address == {
|
||||||
|
"action_handler": 0x1800BFCC0,
|
||||||
|
"manager_request": 0x1480D2340,
|
||||||
|
"data_source_request": 0x180120270,
|
||||||
|
"ui_dispatch": 0x1480D1070,
|
||||||
|
}
|
||||||
|
script = build_gdb_script(
|
||||||
|
45949, 0x180000000, 0x140000000, "/tmp/create-action.log"
|
||||||
|
)
|
||||||
|
assert script.count("hbreak *") == 4
|
||||||
|
assert f"$edx == 0x{ACTION_CREATE_MATCH:x}" in script
|
||||||
|
assert f"$edx == 0x{CREATE_DATA_SOURCE:x}" in script
|
||||||
|
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||||
|
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||||
|
assert "request_method" in script
|
||||||
|
assert "set *(" not in script
|
||||||
|
print("match_create_action_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-create-action-{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-create-action-{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())
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Trace FIFA17 provider delivery lookup without heap-address assumptions.
|
||||||
|
|
||||||
|
The probe anchors the real CardsDLL call sequence in FUN_1801a4cd0 and the
|
||||||
|
provider-specific FUT_CREATE_MATCH_DP readiness check in FUN_1800be500:
|
||||||
|
|
||||||
|
vslot +0x38 call -> create gate return -> returned target -> UI bridge
|
||||||
|
|
||||||
|
For FUT_CREATE_MATCH_DP and FUT_GET_MATCH_KITS_DP it records the live controller
|
||||||
|
vtable, concrete lookup function, event service, readiness-gate implementation,
|
||||||
|
every register input, returned target, and whether the native-to-UI bridge
|
||||||
|
executes. No post-event object identity is used.
|
||||||
|
|
||||||
|
The generated GDB program uses hardware-assisted execution breakpoints only.
|
||||||
|
It never writes client memory and never drives game input.
|
||||||
|
|
||||||
|
match_delivery_lifecycle_trace.py [pid] [--output PATH]
|
||||||
|
match_delivery_lifecycle_trace.py --print-script [pid]
|
||||||
|
match_delivery_lifecycle_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
|
||||||
|
|
||||||
|
LOOKUP_CALL_RVA = transition.PROVIDER_DISPATCH_RVA + 0x2C
|
||||||
|
LOOKUP_RETURN_RVA = transition.PROVIDER_DISPATCH_RVA + 0x2F
|
||||||
|
BRIDGE_CALL_RVA = transition.PROVIDER_DISPATCH_RVA + 0x71
|
||||||
|
CREATE_GATE_RETURN_RVA = 0x0BE647
|
||||||
|
|
||||||
|
|
||||||
|
def trace_addresses(cards_base: int) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"lookup_call": cards_base + LOOKUP_CALL_RVA,
|
||||||
|
"gate_return": cards_base + CREATE_GATE_RETURN_RVA,
|
||||||
|
"lookup_return": cards_base + LOOKUP_RETURN_RVA,
|
||||||
|
"bridge": cards_base + BRIDGE_CALL_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 $current_provider = 0
|
||||||
|
set $current_payload = 0
|
||||||
|
set $current_controller = 0
|
||||||
|
set $current_vtable = 0
|
||||||
|
set $current_lookup = 0
|
||||||
|
set $current_service = 0
|
||||||
|
set $current_service_vtable = 0
|
||||||
|
set $current_gate = 0
|
||||||
|
|
||||||
|
hbreak *0x{address['lookup_call']:x}
|
||||||
|
condition 1 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $current_provider = $edi
|
||||||
|
set $current_payload = $rbp
|
||||||
|
set $current_controller = $rcx
|
||||||
|
set $current_vtable = *(void**)$rcx
|
||||||
|
set $current_lookup = *(void**)(*(void**)$rcx+0x38)
|
||||||
|
set $current_service = *(void**)($rcx+0x18)
|
||||||
|
set $current_service_vtable = *(void**)$current_service
|
||||||
|
set $current_gate = *(void**)($current_service_vtable+0x58)
|
||||||
|
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d LOOKUP_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d provider=%#x this=%p outer_controller=%p payload=%p vtable=%p lookup_fn=%p service=%p service_vtable=%p gate_fn=%p controller_mode=%#x controller_flag=%#x rdx=%p r8=%p r9=%p state_rbx=%p state_rbp=%p\\n", $_thread, $edi, $rcx, $rbx, $rbp, $current_vtable, $current_lookup, $current_service, $current_service_vtable, $current_gate, *(unsigned int*)($rcx+0x140), *(unsigned char*)($rcx+0x152), $rdx, $r8, $r9, $rbx, $rbp
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['gate_return']:x}
|
||||||
|
condition 2 $current_provider == 0x{transition.FUT_CREATE_MATCH_DP:x} && $rbx == $current_controller
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d CREATE_GATE_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d provider=%#x controller=%p service=%p service_vtable=%p gate_fn=%p selector=0x7546 result_al=%#x\\n", $_thread, $current_provider, $current_controller, $current_service, $current_service_vtable, $current_gate, $al
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['lookup_return']:x}
|
||||||
|
condition 3 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d LOOKUP_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d provider=%#x controller=%p payload=%p vtable=%p lookup_fn=%p result=%p\\n", $_thread, $edi, $rbx, $rbp, $current_vtable, $current_lookup, $rax
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['bridge']:x}
|
||||||
|
condition 4 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d BRIDGE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d provider=%#x controller=%p payload=%p target=%p bridge=%p callback=%p\\n", $_thread, $edi, $current_controller, $current_payload, $rsi, $rbx, *(void**)(*(void**)$rbx+0x48)
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
printf "LOOKUPTRACE ARMED pid={pid} lookup_call=0x{address['lookup_call']:x} gate_return=0x{address['gate_return']:x} lookup_return=0x{address['lookup_return']:x} bridge=0x{address['bridge']:x}\\n"
|
||||||
|
continue
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> None:
|
||||||
|
address = trace_addresses(0x180000000)
|
||||||
|
assert address == {
|
||||||
|
"lookup_call": 0x1801A4CFC,
|
||||||
|
"gate_return": 0x1800BE647,
|
||||||
|
"lookup_return": 0x1801A4CFF,
|
||||||
|
"bridge": 0x1801A4D41,
|
||||||
|
}
|
||||||
|
script = build_gdb_script(35632, 0x180000000, "/tmp/lookup.log")
|
||||||
|
assert script.count("hbreak *") == 4
|
||||||
|
assert f"$edi == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||||
|
assert f"$edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||||
|
assert "LOOKUP_CALL" in script
|
||||||
|
assert "CREATE_GATE_RETURN" in script
|
||||||
|
assert "LOOKUP_RETURN" in script
|
||||||
|
assert "gate_fn" in script
|
||||||
|
assert "BRIDGE" in script
|
||||||
|
assert "set *(" not in script
|
||||||
|
print("match_delivery_lifecycle_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-provider-lookup-{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-provider-lookup-{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())
|
||||||
+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())
|
||||||
Executable
+185
@@ -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())
|
||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Trace FIFA17 create-response readiness versus UI provider dispatch.
|
||||||
|
|
||||||
|
The probe correlates four concrete lifecycle boundaries:
|
||||||
|
|
||||||
|
* FutCreateMatchServerResponse data-source request;
|
||||||
|
* the POST /match network response callback;
|
||||||
|
* the response readiness/completion callback;
|
||||||
|
* FIFA's global UI dispatch of providers 0x7563 and 0x7565.
|
||||||
|
|
||||||
|
This distinguishes network completion from the separate DataManager readiness
|
||||||
|
lifecycle without assuming any screen or heap-object identity. The generated GDB
|
||||||
|
program uses hardware-assisted execution breakpoints only. It never writes
|
||||||
|
client memory and never drives game input.
|
||||||
|
|
||||||
|
match_provider_producer_trace.py [pid] [--output PATH]
|
||||||
|
match_provider_producer_trace.py --print-script [pid]
|
||||||
|
match_provider_producer_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
|
||||||
|
|
||||||
|
DATA_SOURCE_REQUEST_RVA = 0x120270
|
||||||
|
NETWORK_RESPONSE_RVA = transition.RESPONSE_CALLBACK_RVA
|
||||||
|
CREATE_COMPLETE_RVA = 0x120000
|
||||||
|
GLOBAL_UI_DISPATCH_RVA = advance.GLOBAL_UI_DISPATCH_RVA
|
||||||
|
CREATE_RESPONSE_OFFSET = 0xA0
|
||||||
|
CREATE_DATA_SOURCE_OFFSET = CREATE_RESPONSE_OFFSET + 0x50
|
||||||
|
CREATE_READY_OFFSET = CREATE_RESPONSE_OFFSET + 0x88
|
||||||
|
ACTIVE_CALLBACK_OFFSET = 0x47D0
|
||||||
|
|
||||||
|
|
||||||
|
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"data_source_request": cards_base + DATA_SOURCE_REQUEST_RVA,
|
||||||
|
"network_response": cards_base + NETWORK_RESPONSE_RVA,
|
||||||
|
"create_complete": cards_base + CREATE_COMPLETE_RVA,
|
||||||
|
"ui_dispatch": fifa_base + GLOBAL_UI_DISPATCH_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 $last_central = 0
|
||||||
|
set $last_response = 0
|
||||||
|
set $last_data_source = 0
|
||||||
|
set $last_descriptor = 0
|
||||||
|
|
||||||
|
hbreak *0x{address['data_source_request']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $request_data_source = $rcx
|
||||||
|
set $request_response = $rcx - 0x50
|
||||||
|
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d DATA_SOURCE_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d response=%p data_source=%p request=%p ready_before=%#x callback_adapter=%p callback_context=%p callback_target=%p\\n", $_thread, $request_response, $request_data_source, $rdx, *(unsigned char*)($request_data_source+0x38), *(void**)($request_response+0x90), *(void**)($request_response+0x98), *(void**)($request_response+0xa0)
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['network_response']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $last_central = $rcx
|
||||||
|
set $last_response = $rcx + 0x{CREATE_RESPONSE_OFFSET:x}
|
||||||
|
set $last_data_source = $rcx + 0x{CREATE_DATA_SOURCE_OFFSET:x}
|
||||||
|
set $last_descriptor = $rdx
|
||||||
|
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d NETWORK_RESPONSE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
if $rdx == 0
|
||||||
|
printf "thread=%d central=%p descriptor=(nil) status=UNKNOWN wire_payload=(nil) response=%p data_source=%p ready=%#x active_adapter=%p active_context=%p active_target=%p\\n", $_thread, $last_central, $last_response, $last_data_source, *(unsigned char*)($last_central+0x{CREATE_READY_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 8:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 16:x})
|
||||||
|
else
|
||||||
|
printf "thread=%d central=%p descriptor=%p status=%#x wire_payload=%p response=%p data_source=%p ready=%#x active_adapter=%p active_context=%p active_target=%p\\n", $_thread, $last_central, $rdx, *(unsigned int*)($rdx+0x1c), *(void**)($rdx+0x28), $last_response, $last_data_source, *(unsigned char*)($last_central+0x{CREATE_READY_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 8:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 16:x})
|
||||||
|
end
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['create_complete']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d CREATE_COMPLETE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
if $rdx == 0
|
||||||
|
printf "thread=%d response=%p data_source=%p ready_before=%#x descriptor=(nil) status=UNKNOWN last_response=%p same_response=%d\\n", $_thread, $rcx, $rcx+0x50, *(unsigned char*)($rcx+0x88), $last_response, $rcx==$last_response
|
||||||
|
else
|
||||||
|
printf "thread=%d response=%p data_source=%p ready_before=%#x descriptor=%p status=%#x last_response=%p same_response=%d\\n", $_thread, $rcx, $rcx+0x50, *(unsigned char*)($rcx+0x88), $rdx, *(unsigned int*)($rdx+0x1c), $last_response, $rcx==$last_response
|
||||||
|
end
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['ui_dispatch']:x}
|
||||||
|
condition 4 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d UI_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
if $last_response == 0
|
||||||
|
printf "thread=%d provider=%#x payload=%p ui_manager=%p last_response=(nil) ready=UNKNOWN\\n", $_thread, $r8d, $rdx, $rcx
|
||||||
|
else
|
||||||
|
printf "thread=%d provider=%#x payload=%p ui_manager=%p last_response=%p data_source=%p ready=%#x descriptor=%p\\n", $_thread, $r8d, $rdx, $rcx, $last_response, $last_data_source, *(unsigned char*)($last_response+0x88), $last_descriptor
|
||||||
|
end
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
printf "RESPTRACE ARMED pid={pid} data_source_request=0x{address['data_source_request']:x} network_response=0x{address['network_response']:x} create_complete=0x{address['create_complete']:x} ui_dispatch=0x{address['ui_dispatch']:x}\\n"
|
||||||
|
continue
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> None:
|
||||||
|
address = trace_addresses(0x180000000, 0x140000000)
|
||||||
|
assert address == {
|
||||||
|
"data_source_request": 0x180120270,
|
||||||
|
"network_response": 0x180114D90,
|
||||||
|
"create_complete": 0x180120000,
|
||||||
|
"ui_dispatch": 0x1480D1070,
|
||||||
|
}
|
||||||
|
script = build_gdb_script(
|
||||||
|
38872, 0x180000000, 0x140000000, "/tmp/response-lifecycle.log"
|
||||||
|
)
|
||||||
|
assert script.count("hbreak *") == 4
|
||||||
|
assert "DATA_SOURCE_REQUEST" in script
|
||||||
|
assert "NETWORK_RESPONSE" in script
|
||||||
|
assert "CREATE_COMPLETE" in script
|
||||||
|
assert "UI_DISPATCH" in script
|
||||||
|
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||||
|
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||||
|
assert "set *(" not in script
|
||||||
|
print("match_provider_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("--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-response-lifecycle-{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-response-lifecycle-{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())
|
||||||
Executable
+233
@@ -0,0 +1,233 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Trace the FIFA17 create-match publish boundary with hardware breakpoints.
|
||||||
|
|
||||||
|
The tracer covers the client-local path after POST /match:
|
||||||
|
|
||||||
|
response callback -> deserializer -> controller event 0x7546
|
||||||
|
-> FUT_CREATE_MATCH_DP 0x7563
|
||||||
|
|
||||||
|
FUT_GET_MATCH_KITS_DP 0x7565 is captured as the positive control through the
|
||||||
|
same native dispatcher. The generated GDB program uses only hardware-assisted
|
||||||
|
execution breakpoints. It never writes client memory and never drives game
|
||||||
|
input.
|
||||||
|
|
||||||
|
match_transition_trace.py [pid] [--output PATH]
|
||||||
|
match_transition_trace.py --print-script [pid]
|
||||||
|
match_transition_trace.py --selftest
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
CARDS_MODULE = "CardsDLL_Win64_retail.dll"
|
||||||
|
PINNED_CARDS_SHA256 = "4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c"
|
||||||
|
RESPONSE_CALLBACK_RVA = 0x114D90
|
||||||
|
DESERIALIZE_SUCCESS_RVA = 0x118940
|
||||||
|
CREATE_MATCH_CONTROLLER_RVA = 0xBF950
|
||||||
|
PROVIDER_DISPATCH_RVA = 0x1A4CD0
|
||||||
|
CREATE_MATCH_CONTROLLER_EVENT = 0x7546
|
||||||
|
FUT_CREATE_MATCH_DP = 0x7563
|
||||||
|
FUT_GET_MATCH_KITS_DP = 0x7565
|
||||||
|
|
||||||
|
|
||||||
|
def find_pid() -> int | None:
|
||||||
|
found = []
|
||||||
|
for directory in glob.glob("/proc/[0-9]*"):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(directory, "comm"), encoding="utf-8") as handle:
|
||||||
|
if handle.read().strip() != "FIFA17.exe":
|
||||||
|
continue
|
||||||
|
pid = int(os.path.basename(directory))
|
||||||
|
with open(os.path.join(directory, "statm"), encoding="utf-8") as handle:
|
||||||
|
resident_pages = int(handle.read().split()[1])
|
||||||
|
found.append((resident_pages, pid))
|
||||||
|
except (OSError, ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
return max(found)[1] if found else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cards_mapping(lines) -> tuple[int, str]:
|
||||||
|
for line in lines:
|
||||||
|
fields = line.split(maxsplit=5)
|
||||||
|
path = fields[5].rstrip() if len(fields) == 6 else ""
|
||||||
|
if not path.endswith(CARDS_MODULE):
|
||||||
|
continue
|
||||||
|
start = int(fields[0].split("-", 1)[0], 16)
|
||||||
|
return start, path
|
||||||
|
raise RuntimeError(f"{CARDS_MODULE} is not mapped")
|
||||||
|
|
||||||
|
|
||||||
|
def cards_mapping(pid: int) -> tuple[int, str]:
|
||||||
|
with open(f"/proc/{pid}/maps", encoding="utf-8") as handle:
|
||||||
|
try:
|
||||||
|
return parse_cards_mapping(handle)
|
||||||
|
except RuntimeError as error:
|
||||||
|
raise RuntimeError(f"{error} in PID {pid}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: str) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_cards(path: str) -> None:
|
||||||
|
actual = sha256_file(path)
|
||||||
|
if actual != PINNED_CARDS_SHA256:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"unsupported {CARDS_MODULE}: sha256={actual}; expected={PINNED_CARDS_SHA256}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def trace_addresses(base: int) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"response": base + RESPONSE_CALLBACK_RVA,
|
||||||
|
"deserialize": base + DESERIALIZE_SUCCESS_RVA,
|
||||||
|
"controller": base + CREATE_MATCH_CONTROLLER_RVA,
|
||||||
|
"provider": base + PROVIDER_DISPATCH_RVA,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_gdb_script(pid: int, 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(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}
|
||||||
|
|
||||||
|
hbreak *0x{address['response']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T3_RESPONSE_CALLBACK" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
if $rdx != 0
|
||||||
|
printf "thread=%d manager=%p status_obj=%p status=%u wire_payload=%p caller=%p\\n", $_thread, $rcx, $rdx, *(unsigned int*)($rdx+0x1c), *(void**)($rdx+0x28), *(void**)$rsp
|
||||||
|
else
|
||||||
|
printf "thread=%d manager=%p status_obj=0 caller=%p\\n", $_thread, $rcx, *(void**)$rsp
|
||||||
|
end
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['deserialize']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T4_DESERIALIZE_SUCCESS" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d manager=%p payload=%p caller=%p\\n", $_thread, $rcx, $rdx, *(void**)$rsp
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['controller']:x}
|
||||||
|
condition 3 $edx == 0x{CREATE_MATCH_CONTROLLER_EVENT:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T5_CREATE_MATCH_CONTROLLER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d controller_subobject=%p event=%#x caller=%p\\n", $_thread, $rcx, $edx, *(void**)$rsp
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['provider']:x}
|
||||||
|
condition 4 $edx == 0x{FUT_CREATE_MATCH_DP:x} || $edx == 0x{FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T6_PROVIDER_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d controller=%p provider=%#x payload=%p callback=%p\\n", $_thread, $rcx, $edx, $r8, *(void**)$rsp
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
printf "HWTRACE ARMED pid={pid} response=0x{address['response']:x} deserialize=0x{address['deserialize']:x} controller=0x{address['controller']:x} provider=0x{address['provider']:x}\\n"
|
||||||
|
continue
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> None:
|
||||||
|
base = 0x180000000
|
||||||
|
address = trace_addresses(base)
|
||||||
|
assert address == {
|
||||||
|
"response": 0x180114D90,
|
||||||
|
"deserialize": 0x180118940,
|
||||||
|
"controller": 0x1800BF950,
|
||||||
|
"provider": 0x1801A4CD0,
|
||||||
|
}
|
||||||
|
mapping = parse_cards_mapping(
|
||||||
|
[
|
||||||
|
"6ffffc0f0000-6ffffc0f1000 r--p 00000000 00:37 2941670 "
|
||||||
|
"/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll\n"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert mapping == (
|
||||||
|
0x6FFFFC0F0000,
|
||||||
|
"/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll",
|
||||||
|
)
|
||||||
|
script = build_gdb_script(25718, base, "/tmp/match-transition.log")
|
||||||
|
assert script.count("hbreak *") == 4
|
||||||
|
assert f"$edx == 0x{CREATE_MATCH_CONTROLLER_EVENT:x}" in script
|
||||||
|
assert f"$edx == 0x{FUT_CREATE_MATCH_DP:x}" in script
|
||||||
|
assert f"$edx == 0x{FUT_GET_MATCH_KITS_DP:x}" in script
|
||||||
|
assert "T3_RESPONSE_CALLBACK" in script
|
||||||
|
assert "T4_DESERIALIZE_SUCCESS" in script
|
||||||
|
assert "T5_CREATE_MATCH_CONTROLLER" in script
|
||||||
|
assert "T6_PROVIDER_DISPATCH" in script
|
||||||
|
assert "set *(" not in script
|
||||||
|
print("match_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 find_pid()
|
||||||
|
if not pid:
|
||||||
|
print("FIFA17.exe not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
base, cards_path = cards_mapping(pid)
|
||||||
|
validate_cards(cards_path)
|
||||||
|
output = args.output or f"/tmp/fifa17-match-transition-{pid}.log"
|
||||||
|
script = build_gdb_script(pid, 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-transition-{pid}.gdb"
|
||||||
|
with open(script_path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(script)
|
||||||
|
os.execvp("gdb", ["gdb", "-q", "-nx", "-x", script_path])
|
||||||
|
return 127
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+306
@@ -0,0 +1,306 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read-only dynamic locator for FIFA17 Offline Seasons match state.
|
||||||
|
|
||||||
|
Never relies on heap addresses or allocator handles. It identifies:
|
||||||
|
|
||||||
|
* the 10 x 16-byte parsed fixture array from its complete wire-derived record
|
||||||
|
sequence (teamId/difficulty/roundId/rewardMult/coins),
|
||||||
|
* match-team records from the corrected invariant prefix (11,7,0,0,76), never
|
||||||
|
from the transient +0x18 handle,
|
||||||
|
* the match-config team pair from structural fields around it, not its team ids.
|
||||||
|
|
||||||
|
offline_match_locator.py [pid] [--fixture-index 0] [--json]
|
||||||
|
offline_match_locator.py --selftest
|
||||||
|
|
||||||
|
READ-ONLY: /proc/<pid>/mem is opened 'rb'. No debugger and no game input.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
|
||||||
|
DEFAULT_TEAMS = (73, 240, 241, 243, 73, 240, 241, 243, 73, 240)
|
||||||
|
MATCH_HEADER = struct.pack("<5i", 11, 7, 0, 0, 76)
|
||||||
|
PARTICIPANT_PREFIX = struct.pack("<8i", -1, -2, -1, -2, -1, -2, -1, -2)
|
||||||
|
F01 = 0x3DCCCCCD
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Fixture:
|
||||||
|
address: int
|
||||||
|
selected_address: int
|
||||||
|
selected_index: int
|
||||||
|
selected_team_id: int
|
||||||
|
records: list[dict[str, int]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatchTeam:
|
||||||
|
address: int
|
||||||
|
team_id: int
|
||||||
|
marker_18: int
|
||||||
|
marker_1c: int
|
||||||
|
xi: list[int]
|
||||||
|
substitutes: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatchConfig:
|
||||||
|
pair_address: int
|
||||||
|
team_id_0: int
|
||||||
|
team_id_1: int
|
||||||
|
player_count_0: int
|
||||||
|
player_count_1: int
|
||||||
|
|
||||||
|
|
||||||
|
def find_pids() -> list[int]:
|
||||||
|
"""All live FIFA17.exe processes, largest resident set first.
|
||||||
|
|
||||||
|
The UMU/Proton launch chain briefly creates a small process with the same
|
||||||
|
comm before the real game. Returning the first /proc glob match attached
|
||||||
|
the trace supervisor to that short-lived process and missed the match.
|
||||||
|
"""
|
||||||
|
found = []
|
||||||
|
for directory in glob.glob("/proc/[0-9]*"):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(directory, "comm")) as handle:
|
||||||
|
if handle.read().strip() != "FIFA17.exe":
|
||||||
|
continue
|
||||||
|
pid = int(os.path.basename(directory))
|
||||||
|
with open(os.path.join(directory, "statm")) as handle:
|
||||||
|
resident_pages = int(handle.read().split()[1])
|
||||||
|
found.append((resident_pages, pid))
|
||||||
|
except (OSError, ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
return [pid for _resident, pid in sorted(found, reverse=True)]
|
||||||
|
|
||||||
|
|
||||||
|
def find_pid() -> int | None:
|
||||||
|
pids = find_pids()
|
||||||
|
return pids[0] if pids else None
|
||||||
|
|
||||||
|
|
||||||
|
def fixture_bytes(teams: tuple[int, ...] = DEFAULT_TEAMS) -> bytes:
|
||||||
|
return b"".join(
|
||||||
|
struct.pack("<iBBHii", team_id, 1, round_id, 0, 1, 400)
|
||||||
|
for round_id, team_id in enumerate(teams)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def readable_regions(pid: int, *, writable_anon_only: bool = False):
|
||||||
|
with open(f"/proc/{pid}/maps") as maps:
|
||||||
|
for line in maps:
|
||||||
|
match = re.match(
|
||||||
|
r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)",
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
lo, hi = int(match.group(1), 16), int(match.group(2), 16)
|
||||||
|
perms, path = match.group(3), match.group(4).strip()
|
||||||
|
if perms[0] != "r" or path.startswith(("/dev", "/memfd")):
|
||||||
|
continue
|
||||||
|
if hi - lo > 512 * 1024 * 1024:
|
||||||
|
continue
|
||||||
|
if writable_anon_only and (perms[1] != "w" or path):
|
||||||
|
continue
|
||||||
|
yield lo, hi, perms, path
|
||||||
|
|
||||||
|
|
||||||
|
def _i32(buf: bytes, offset: int) -> int:
|
||||||
|
return struct.unpack_from("<i", buf, offset)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def scan_fixture_buffer(buf: bytes, base: int, selected_index: int) -> list[Fixture]:
|
||||||
|
pattern = fixture_bytes()
|
||||||
|
found = []
|
||||||
|
offset = buf.find(pattern)
|
||||||
|
while offset >= 0:
|
||||||
|
records = []
|
||||||
|
for round_id in range(len(DEFAULT_TEAMS)):
|
||||||
|
at = offset + round_id * 16
|
||||||
|
team_id, difficulty, parsed_round, _pad, reward_mult, coins = struct.unpack_from(
|
||||||
|
"<iBBHii", buf, at
|
||||||
|
)
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"team_id": team_id,
|
||||||
|
"difficulty": difficulty,
|
||||||
|
"round_id": parsed_round,
|
||||||
|
"reward_mult": reward_mult,
|
||||||
|
"coins": coins,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
found.append(
|
||||||
|
Fixture(
|
||||||
|
address=base + offset,
|
||||||
|
selected_address=base + offset + selected_index * 16,
|
||||||
|
selected_index=selected_index,
|
||||||
|
selected_team_id=records[selected_index]["team_id"],
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
offset = buf.find(pattern, offset + 4)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def scan_match_team_buffer(buf: bytes, base: int) -> list[MatchTeam]:
|
||||||
|
found = []
|
||||||
|
offset = buf.find(MATCH_HEADER)
|
||||||
|
while offset >= 0:
|
||||||
|
if offset + 0x7C <= len(buf):
|
||||||
|
found.append(
|
||||||
|
MatchTeam(
|
||||||
|
address=base + offset,
|
||||||
|
team_id=_i32(buf, offset + 0x14),
|
||||||
|
marker_18=_i32(buf, offset + 0x18),
|
||||||
|
marker_1c=_i32(buf, offset + 0x1C),
|
||||||
|
xi=list(struct.unpack_from("<11i", buf, offset + 0x20)),
|
||||||
|
substitutes=list(struct.unpack_from("<12i", buf, offset + 0x4C)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
offset = buf.find(MATCH_HEADER, offset + 4)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_config(buf: bytes, pair: int) -> bool:
|
||||||
|
required = pair + 0x50
|
||||||
|
if pair < 0 or required > len(buf):
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
tuple(struct.unpack_from("<4I", buf, pair + 0x1C)) == (F01, F01, F01, F01)
|
||||||
|
and _i32(buf, pair + 0x38) == 11
|
||||||
|
and _i32(buf, pair + 0x3C) == 11
|
||||||
|
and _i32(buf, pair + 0x40) == 0
|
||||||
|
and _i32(buf, pair + 0x44) == 5
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_match_config_buffer(buf: bytes, base: int) -> list[MatchConfig]:
|
||||||
|
found = []
|
||||||
|
offset = buf.find(PARTICIPANT_PREFIX)
|
||||||
|
while offset >= 0:
|
||||||
|
pair = offset + len(PARTICIPANT_PREFIX)
|
||||||
|
if _valid_config(buf, pair):
|
||||||
|
found.append(
|
||||||
|
MatchConfig(
|
||||||
|
pair_address=base + pair,
|
||||||
|
team_id_0=_i32(buf, pair),
|
||||||
|
team_id_1=_i32(buf, pair + 4),
|
||||||
|
player_count_0=_i32(buf, pair + 0x38),
|
||||||
|
player_count_1=_i32(buf, pair + 0x3C),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
offset = buf.find(PARTICIPANT_PREFIX, offset + 4)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def scan_process(
|
||||||
|
pid: int,
|
||||||
|
selected_index: int,
|
||||||
|
*,
|
||||||
|
include_fixture: bool = True,
|
||||||
|
writable_anon_only: bool = False,
|
||||||
|
) -> dict[str, list]:
|
||||||
|
result: dict[str, list] = {"fixtures": [], "match_teams": [], "match_configs": []}
|
||||||
|
with open(f"/proc/{pid}/mem", "rb", 0) as memory:
|
||||||
|
for lo, hi, _perms, _path in readable_regions(
|
||||||
|
pid, writable_anon_only=writable_anon_only
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
memory.seek(lo)
|
||||||
|
buf = memory.read(hi - lo)
|
||||||
|
except (OSError, ValueError, OverflowError):
|
||||||
|
continue
|
||||||
|
if include_fixture:
|
||||||
|
result["fixtures"].extend(scan_fixture_buffer(buf, lo, selected_index))
|
||||||
|
result["match_teams"].extend(scan_match_team_buffer(buf, lo))
|
||||||
|
result["match_configs"].extend(scan_match_config_buffer(buf, lo))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> None:
|
||||||
|
fixture = fixture_bytes()
|
||||||
|
team = bytearray(0x7C)
|
||||||
|
team[:20] = MATCH_HEADER
|
||||||
|
struct.pack_into("<iii", team, 0x14, 130000, 0x54001, 0x54002)
|
||||||
|
struct.pack_into("<11i", team, 0x20, *range(11))
|
||||||
|
struct.pack_into("<12i", team, 0x4C, *range(20, 32))
|
||||||
|
config = bytearray(0x20 + 0x50)
|
||||||
|
config[:0x20] = PARTICIPANT_PREFIX
|
||||||
|
pair = 0x20
|
||||||
|
struct.pack_into("<ii", config, pair, 130000, 130000)
|
||||||
|
struct.pack_into("<4I", config, pair + 0x1C, F01, F01, F01, F01)
|
||||||
|
struct.pack_into("<iiii", config, pair + 0x38, 11, 11, 0, 5)
|
||||||
|
buf = b"X" * 32 + fixture + b"Y" * 32 + team + b"Z" * 32 + config
|
||||||
|
fixtures = scan_fixture_buffer(buf, 0x1000, 0)
|
||||||
|
teams = scan_match_team_buffer(buf, 0x1000)
|
||||||
|
configs = scan_match_config_buffer(buf, 0x1000)
|
||||||
|
assert len(fixtures) == 1 and fixtures[0].selected_team_id == 73
|
||||||
|
assert len(teams) == 1 and teams[0].team_id == 130000
|
||||||
|
assert len(configs) == 1 and configs[0].team_id_1 == 130000
|
||||||
|
# The transient handle is never part of the anchor.
|
||||||
|
struct.pack_into("<i", team, 0x18, -1)
|
||||||
|
assert len(scan_match_team_buffer(bytes(team), 0)) == 1
|
||||||
|
print("offline_match_locator selftest: PASS")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("pid", nargs="?", type=int)
|
||||||
|
parser.add_argument("--fixture-index", type=int, default=0)
|
||||||
|
parser.add_argument("--json", action="store_true")
|
||||||
|
parser.add_argument("--selftest", action="store_true")
|
||||||
|
parser.add_argument("--writable-anon-only", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.selftest:
|
||||||
|
selftest()
|
||||||
|
return 0
|
||||||
|
pid = args.pid or find_pid()
|
||||||
|
if not pid:
|
||||||
|
print("FIFA17.exe not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not 0 <= args.fixture_index < len(DEFAULT_TEAMS):
|
||||||
|
print("--fixture-index must be 0..9", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
result = scan_process(
|
||||||
|
pid,
|
||||||
|
args.fixture_index,
|
||||||
|
writable_anon_only=args.writable_anon_only,
|
||||||
|
)
|
||||||
|
serial = {key: [asdict(value) for value in values] for key, values in result.items()}
|
||||||
|
serial["pid"] = pid
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(serial, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
print(f"pid={pid}")
|
||||||
|
for fixture in result["fixtures"]:
|
||||||
|
print(
|
||||||
|
f"fixture @0x{fixture.address:x}; selected index {fixture.selected_index} "
|
||||||
|
f"@0x{fixture.selected_address:x} teamId={fixture.selected_team_id}"
|
||||||
|
)
|
||||||
|
for config in result["match_configs"]:
|
||||||
|
print(
|
||||||
|
f"match config pair @0x{config.pair_address:x}: "
|
||||||
|
f"[{config.team_id_0}, {config.team_id_1}]"
|
||||||
|
)
|
||||||
|
for team in result["match_teams"]:
|
||||||
|
print(
|
||||||
|
f"match team @0x{team.address:x}: teamId={team.team_id} "
|
||||||
|
f"handles=[{team.marker_18}, {team.marker_1c}]"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"counts: fixtures={len(result['fixtures'])} "
|
||||||
|
f"configs={len(result['match_configs'])} teams={len(result['match_teams'])}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+394
@@ -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())
|
||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Scan for FIFA17 match-team records by the invariant header prefix.
|
||||||
|
|
||||||
|
Anchors ONLY on (11,7,0,0,76) at +0x00..+0x10. Never filter on +0x18: it is a
|
||||||
|
per-record marker whose value varies between sessions (-1 on 2026-08-24,
|
||||||
|
344065/344064 on 2026-08-25), and filtering on it produced a false negative.
|
||||||
|
|
||||||
|
scan_mt.py [pid]
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PAT = struct.pack("<5i", 11, 7, 0, 0, 76)
|
||||||
|
|
||||||
|
|
||||||
|
def find_pid():
|
||||||
|
for d in glob.glob("/proc/[0-9]*"):
|
||||||
|
try:
|
||||||
|
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||||
|
return int(os.path.basename(d))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
pid = int(sys.argv[1]) if len(sys.argv) > 1 else find_pid()
|
||||||
|
if not pid:
|
||||||
|
print(" no FIFA17.exe")
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
mem = open(f"/proc/{pid}/mem", "rb", 0)
|
||||||
|
found = []
|
||||||
|
for line in open(f"/proc/{pid}/maps"):
|
||||||
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", line)
|
||||||
|
if not m or m.group(3)[0] != "r":
|
||||||
|
continue
|
||||||
|
lo, hi, path = int(m.group(1), 16), int(m.group(2), 16), m.group(4)
|
||||||
|
if path.startswith(("/dev", "/memfd")) or hi - lo > 512 * 1024 * 1024:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mem.seek(lo)
|
||||||
|
buf = mem.read(hi - lo)
|
||||||
|
except (OSError, ValueError, OverflowError):
|
||||||
|
continue
|
||||||
|
i = buf.find(PAT)
|
||||||
|
while i >= 0:
|
||||||
|
rec = buf[i:i + 0x80]
|
||||||
|
if len(rec) >= 0x80:
|
||||||
|
tid = struct.unpack_from("<i", rec, 0x14)[0]
|
||||||
|
m18 = struct.unpack_from("<i", rec, 0x18)[0]
|
||||||
|
m1c = struct.unpack_from("<i", rec, 0x1c)[0]
|
||||||
|
xi = list(struct.unpack_from("<11i", rec, 0x20))
|
||||||
|
subs = list(struct.unpack_from("<12i", rec, 0x4c))
|
||||||
|
found.append((lo + i, tid, m18, m1c, xi, subs))
|
||||||
|
i = buf.find(PAT, i + 4)
|
||||||
|
|
||||||
|
print(f" pid={pid} {len(found)} match-team record(s)")
|
||||||
|
for addr, tid, m18, m1c, xi, subs in found:
|
||||||
|
print(f"\n @0x{addr:x}")
|
||||||
|
print(f" +0x14 teamId = {tid}")
|
||||||
|
print(f" +0x18 marker = {m18} +0x1c marker = {m1c}")
|
||||||
|
print(f" XI = {xi}")
|
||||||
|
print(f" subs = {subs}")
|
||||||
|
print(f"\n distinct teamIds: {sorted({t for _a, t, *_r in found})}")
|
||||||
+1101
File diff suppressed because it is too large
Load Diff
+512
@@ -0,0 +1,512 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Supervise one hardware-only FIFA17 match-team writer capture.
|
||||||
|
|
||||||
|
This is the robust fresh-client entry point. It waits for the largest-RSS
|
||||||
|
FIFA17.exe process that has CardsDLL loaded, attaches gdb before FUT navigation
|
||||||
|
can construct match teams, and loads a hardware-only GDB Python payload.
|
||||||
|
|
||||||
|
The concurrent read-only structural locator proves when the fixture and final
|
||||||
|
match-team records exist. A zero-hit result is trusted only if gdb is still
|
||||||
|
alive, TracerPid is the gdb process, the payload reported `trace_armed`, no
|
||||||
|
records pre-existed the trace, and two final records then appeared.
|
||||||
|
|
||||||
|
The default payload traces FUN_1800fc500 and derives a 4-byte teamId[1]
|
||||||
|
watchpoint from live RDX. Other payloads trace the final engine writer or its
|
||||||
|
caller; all expose the same `start_trace(log, cards_base)` entry point.
|
||||||
|
|
||||||
|
No INT3/software breakpoints. No client memory writes. /proc/<pid>/mem is opened
|
||||||
|
'rb'. The operator alone drives the game.
|
||||||
|
|
||||||
|
trace_match_team_writer.py --status /tmp/mt-status.json \
|
||||||
|
--trace /tmp/mt-trace.jsonl --gdb-log /tmp/mt-gdb.log --fixture-index 0
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from dataclasses import asdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from offline_match_locator import find_pids, scan_process
|
||||||
|
|
||||||
|
CARDS_IMAGE_BASE = 0x180000000
|
||||||
|
DEFAULT_TIMEOUT = 45 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def cards_base(pid: int) -> int | None:
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/maps") as maps:
|
||||||
|
for line in maps:
|
||||||
|
if "CardsDLL_Win64_retail.dll" in line:
|
||||||
|
return int(line.split("-", 1)[0], 16)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def tracer_pid(pid: int) -> int | None:
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/status") as status:
|
||||||
|
for line in status:
|
||||||
|
if line.startswith("TracerPid:"):
|
||||||
|
return int(line.split()[1])
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def target_state(pid: int) -> str | None:
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/status") as status:
|
||||||
|
for line in status:
|
||||||
|
if line.startswith("State:"):
|
||||||
|
return line.split()[1]
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_events(path: Path) -> list[dict]:
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
events = []
|
||||||
|
try:
|
||||||
|
with path.open(encoding="utf-8", errors="replace") as handle:
|
||||||
|
for line in handle:
|
||||||
|
try:
|
||||||
|
events.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def event_counts(events: list[dict]) -> dict[str, int]:
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for event in events:
|
||||||
|
kind = event.get("event", "unknown")
|
||||||
|
counts[kind] = counts.get(kind, 0) + 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
class Status:
|
||||||
|
def __init__(self, path: Path, monitor_log: Path):
|
||||||
|
self.path = path
|
||||||
|
self.monitor_log = monitor_log
|
||||||
|
self.data: dict = {"started_unix": time.time(), "state": "starting"}
|
||||||
|
self.write()
|
||||||
|
|
||||||
|
def write(self, **updates):
|
||||||
|
self.data.update(updates)
|
||||||
|
self.data["updated_unix"] = time.time()
|
||||||
|
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||||
|
temporary.write_text(json.dumps(self.data, indent=2, sort_keys=True) + "\n")
|
||||||
|
os.replace(temporary, self.path)
|
||||||
|
|
||||||
|
def log(self, message: str, **payload):
|
||||||
|
record = {"time_unix": time.time(), "message": message, **payload}
|
||||||
|
with self.monitor_log.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(record, sort_keys=True) + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
print(message, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def gdb_commands(pid: int, cards: int, payload: Path, trace: Path) -> str:
|
||||||
|
# Wine uses these signals for thread suspension/runtime plumbing. They must
|
||||||
|
# pass through, or batch gdb stops and silently detaches.
|
||||||
|
signals = ["SIGUSR1", "SIGUSR2", "SIGPIPE", "SIGCHLD"] + [
|
||||||
|
f"SIG{number}" for number in range(32, 40)
|
||||||
|
]
|
||||||
|
lines = [
|
||||||
|
"set confirm off",
|
||||||
|
"set pagination off",
|
||||||
|
"set height 0",
|
||||||
|
"set width 0",
|
||||||
|
f"attach {pid}",
|
||||||
|
]
|
||||||
|
lines.extend(f"handle {name} nostop noprint pass" for name in signals)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"source {payload}",
|
||||||
|
f'python start_trace({json.dumps(str(trace))}, {cards})',
|
||||||
|
"continue",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def serialise_locations(locations: dict) -> dict:
|
||||||
|
return {key: [asdict(value) for value in values] for key, values in locations.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def terminate_gdb(process: subprocess.Popen, status: Status, pid: int):
|
||||||
|
if process.poll() is None:
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=12)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
deadline = time.time() + 8
|
||||||
|
while time.time() < deadline and tracer_pid(pid):
|
||||||
|
time.sleep(0.25)
|
||||||
|
status.log(
|
||||||
|
"gdb detached",
|
||||||
|
gdb_returncode=process.returncode,
|
||||||
|
tracer_pid=tracer_pid(pid),
|
||||||
|
target_state=target_state(pid),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--status", type=Path, required=True)
|
||||||
|
parser.add_argument("--trace", type=Path, required=True)
|
||||||
|
parser.add_argument("--gdb-log", type=Path, required=True)
|
||||||
|
parser.add_argument("--monitor-log", type=Path, default=Path("/tmp/mt-monitor.jsonl"))
|
||||||
|
parser.add_argument("--fixture-index", type=int, default=0)
|
||||||
|
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
|
||||||
|
parser.add_argument("--post-record-wait", type=int, default=12)
|
||||||
|
parser.add_argument(
|
||||||
|
"--arm-check-seconds",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help="attach, prove hardware breakpoints arm, then detach without claiming a capture",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--wait-for-record-clear",
|
||||||
|
action="store_true",
|
||||||
|
help="keep tracing through abandon; accept creation only after old records disappear",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--exclude-pid",
|
||||||
|
action="append",
|
||||||
|
type=int,
|
||||||
|
default=[],
|
||||||
|
help="ignore an existing FIFA process and attach only after process replacement",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--payload",
|
||||||
|
default="gdb_match_team_writer_trace.py",
|
||||||
|
help="GDB Python payload in this tool directory; must expose start_trace(log, cards_base)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
for path in (args.status, args.trace, args.gdb_log, args.monitor_log):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
for path in (args.trace, args.gdb_log, args.monitor_log):
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
status = Status(args.status, args.monitor_log)
|
||||||
|
payload = Path(__file__).with_name(args.payload).resolve()
|
||||||
|
if not payload.exists():
|
||||||
|
status.write(state="failed", error=f"missing gdb payload: {payload}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
deadline = time.time() + args.timeout
|
||||||
|
status.write(state="waiting_for_ready_process", excluded_pids=args.exclude_pid)
|
||||||
|
status.log(
|
||||||
|
"waiting for FIFA17.exe with CardsDLL",
|
||||||
|
excluded_pids=args.exclude_pid,
|
||||||
|
)
|
||||||
|
pid = None
|
||||||
|
cards = None
|
||||||
|
while time.time() < deadline:
|
||||||
|
# UMU/Proton creates a short-lived small FIFA17.exe before the real
|
||||||
|
# client. Never bind to the first comm match. Require CardsDLL and prefer
|
||||||
|
# the largest-RSS process (find_pids is ordered that way).
|
||||||
|
for candidate in find_pids():
|
||||||
|
if candidate in args.exclude_pid:
|
||||||
|
continue
|
||||||
|
candidate_cards = cards_base(candidate)
|
||||||
|
if candidate_cards:
|
||||||
|
pid, cards = candidate, candidate_cards
|
||||||
|
break
|
||||||
|
if pid:
|
||||||
|
break
|
||||||
|
time.sleep(0.25)
|
||||||
|
if not pid or not cards:
|
||||||
|
status.write(state="timed_out", phase="ready_process")
|
||||||
|
return 3
|
||||||
|
|
||||||
|
status.write(state="ready_process_found", pid=pid, cards_base=cards)
|
||||||
|
status.log("real FIFA17.exe with CardsDLL found", pid=pid, cards_base=cards)
|
||||||
|
|
||||||
|
command_path = Path(tempfile.gettempdir()) / f"mt-trace-{pid}.gdb"
|
||||||
|
command_path.write_text(gdb_commands(pid, cards, payload, args.trace))
|
||||||
|
gdb_handle = args.gdb_log.open("w", encoding="utf-8")
|
||||||
|
process = subprocess.Popen(
|
||||||
|
["gdb", "-q", "-nx", "-x", str(command_path)],
|
||||||
|
stdout=gdb_handle,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
status.write(
|
||||||
|
state="attaching",
|
||||||
|
pid=pid,
|
||||||
|
cards_base=cards,
|
||||||
|
cards_image_base=CARDS_IMAGE_BASE,
|
||||||
|
gdb_pid=process.pid,
|
||||||
|
gdb_command_file=str(command_path),
|
||||||
|
payload=args.payload,
|
||||||
|
hardware_only=True,
|
||||||
|
client_memory_writes=False,
|
||||||
|
)
|
||||||
|
status.log("gdb launched", pid=pid, gdb_pid=process.pid, cards_base=cards)
|
||||||
|
|
||||||
|
armed = False
|
||||||
|
arm_deadline = min(deadline, time.time() + 60)
|
||||||
|
while time.time() < arm_deadline:
|
||||||
|
if process.poll() is not None:
|
||||||
|
break
|
||||||
|
events = read_events(args.trace)
|
||||||
|
if any(event.get("event") == "trace_armed" for event in events):
|
||||||
|
armed = True
|
||||||
|
break
|
||||||
|
time.sleep(0.25)
|
||||||
|
if not armed:
|
||||||
|
gdb_handle.close()
|
||||||
|
status.write(
|
||||||
|
state="failed",
|
||||||
|
phase="arm",
|
||||||
|
gdb_returncode=process.poll(),
|
||||||
|
tracer_pid=tracer_pid(pid),
|
||||||
|
trace_events=event_counts(read_events(args.trace)),
|
||||||
|
)
|
||||||
|
if process.poll() is None:
|
||||||
|
terminate_gdb(process, status, pid)
|
||||||
|
return 4
|
||||||
|
|
||||||
|
attached = tracer_pid(pid) == process.pid
|
||||||
|
status.write(
|
||||||
|
state="armed",
|
||||||
|
tracer_pid=tracer_pid(pid),
|
||||||
|
target_state=target_state(pid),
|
||||||
|
trace_events=event_counts(read_events(args.trace)),
|
||||||
|
execution_breakpoints_armed=True,
|
||||||
|
team1_watchpoint_armed=False,
|
||||||
|
)
|
||||||
|
status.log("trace armed", attached=attached, tracer_pid=tracer_pid(pid))
|
||||||
|
if not attached:
|
||||||
|
terminate_gdb(process, status, pid)
|
||||||
|
gdb_handle.close()
|
||||||
|
status.write(state="failed", phase="attach_verification")
|
||||||
|
return 4
|
||||||
|
if args.arm_check_seconds > 0:
|
||||||
|
time.sleep(args.arm_check_seconds)
|
||||||
|
events = read_events(args.trace)
|
||||||
|
counts = event_counts(events)
|
||||||
|
still_attached = tracer_pid(pid) == process.pid and process.poll() is None
|
||||||
|
terminate_gdb(process, status, pid)
|
||||||
|
gdb_handle.close()
|
||||||
|
passed = (
|
||||||
|
still_attached
|
||||||
|
and counts.get("trace_armed", 0) == 1
|
||||||
|
and counts.get("trace_error", 0) == 0
|
||||||
|
and tracer_pid(pid) == 0
|
||||||
|
and target_state(pid) != "T"
|
||||||
|
)
|
||||||
|
status.write(
|
||||||
|
state="arm_check_passed" if passed else "arm_check_failed",
|
||||||
|
trace_events=counts,
|
||||||
|
attached_before_detach=still_attached,
|
||||||
|
tracer_pid_after_detach=tracer_pid(pid),
|
||||||
|
target_state_after_detach=target_state(pid),
|
||||||
|
)
|
||||||
|
status.log("arm check complete", passed=passed, trace_events=counts)
|
||||||
|
return 0 if passed else 5
|
||||||
|
|
||||||
|
# A final record that already exists before arming cannot prove execution
|
||||||
|
# crossed creation under the debugger. Fail closed instead of converting an
|
||||||
|
# already-built match into a trusted zero-hit result.
|
||||||
|
initial_heap = scan_process(
|
||||||
|
pid,
|
||||||
|
args.fixture_index,
|
||||||
|
include_fixture=False,
|
||||||
|
writable_anon_only=True,
|
||||||
|
)
|
||||||
|
records_preexisting = len(initial_heap["match_teams"]) >= 2
|
||||||
|
records_cleared = not records_preexisting
|
||||||
|
if records_preexisting and args.wait_for_record_clear:
|
||||||
|
status.write(
|
||||||
|
state="waiting_for_record_clear",
|
||||||
|
locations=serialise_locations(initial_heap),
|
||||||
|
target_crossed_match_team_creation=False,
|
||||||
|
)
|
||||||
|
status.log(
|
||||||
|
"trace armed; waiting for old match-team records to disappear",
|
||||||
|
team_ids=[team.team_id for team in initial_heap["match_teams"]],
|
||||||
|
)
|
||||||
|
while time.time() < deadline:
|
||||||
|
if process.poll() is not None or not Path(f"/proc/{pid}").exists():
|
||||||
|
terminate_gdb(process, status, pid)
|
||||||
|
gdb_handle.close()
|
||||||
|
status.write(state="failed", phase="record_clear")
|
||||||
|
return 5
|
||||||
|
heap = scan_process(
|
||||||
|
pid,
|
||||||
|
args.fixture_index,
|
||||||
|
include_fixture=False,
|
||||||
|
writable_anon_only=True,
|
||||||
|
)
|
||||||
|
if not heap["match_teams"]:
|
||||||
|
records_cleared = True
|
||||||
|
status.write(
|
||||||
|
state="records_cleared",
|
||||||
|
cleared_unix=time.time(),
|
||||||
|
tracer_pid=tracer_pid(pid),
|
||||||
|
gdb_alive=process.poll() is None,
|
||||||
|
target_state=target_state(pid),
|
||||||
|
)
|
||||||
|
status.log(
|
||||||
|
"old match-team records disappeared; next records are a fresh creation",
|
||||||
|
tracer_pid=tracer_pid(pid),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
time.sleep(2)
|
||||||
|
if not records_cleared:
|
||||||
|
terminate_gdb(process, status, pid)
|
||||||
|
gdb_handle.close()
|
||||||
|
status.write(state="timed_out", phase="record_clear")
|
||||||
|
return 3
|
||||||
|
elif records_preexisting:
|
||||||
|
counts = event_counts(read_events(args.trace))
|
||||||
|
terminate_gdb(process, status, pid)
|
||||||
|
gdb_handle.close()
|
||||||
|
status.write(
|
||||||
|
state="armed_too_late",
|
||||||
|
phase="preexisting_records",
|
||||||
|
trace_events=counts,
|
||||||
|
locations=serialise_locations(initial_heap),
|
||||||
|
target_crossed_match_team_creation=False,
|
||||||
|
tracer_pid_after_detach=tracer_pid(pid),
|
||||||
|
target_state_after_detach=target_state(pid),
|
||||||
|
)
|
||||||
|
status.log(
|
||||||
|
"match-team records pre-existed trace; no writer claim",
|
||||||
|
team_ids=[team.team_id for team in initial_heap["match_teams"]],
|
||||||
|
)
|
||||||
|
return 6
|
||||||
|
|
||||||
|
|
||||||
|
fixture = None
|
||||||
|
latest_locations = {"fixtures": [], "match_teams": [], "match_configs": []}
|
||||||
|
last_fixture_scan = 0.0
|
||||||
|
records_seen_at = None
|
||||||
|
record_control = None
|
||||||
|
try:
|
||||||
|
while time.time() < deadline:
|
||||||
|
if process.poll() is not None or not Path(f"/proc/{pid}").exists():
|
||||||
|
status.write(
|
||||||
|
state="failed",
|
||||||
|
phase="monitor",
|
||||||
|
gdb_returncode=process.poll(),
|
||||||
|
target_exists=Path(f"/proc/{pid}").exists(),
|
||||||
|
)
|
||||||
|
return 5
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if fixture is None and now - last_fixture_scan >= 8:
|
||||||
|
full = scan_process(pid, args.fixture_index, include_fixture=True)
|
||||||
|
last_fixture_scan = now
|
||||||
|
if full["fixtures"]:
|
||||||
|
fixture = full["fixtures"][0]
|
||||||
|
latest_locations["fixtures"] = full["fixtures"]
|
||||||
|
status.log(
|
||||||
|
"fixture located",
|
||||||
|
address=fixture.address,
|
||||||
|
selected_address=fixture.selected_address,
|
||||||
|
selected_index=fixture.selected_index,
|
||||||
|
selected_team_id=fixture.selected_team_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
heap = scan_process(
|
||||||
|
pid,
|
||||||
|
args.fixture_index,
|
||||||
|
include_fixture=False,
|
||||||
|
writable_anon_only=True,
|
||||||
|
)
|
||||||
|
latest_locations["match_teams"] = heap["match_teams"]
|
||||||
|
latest_locations["match_configs"] = heap["match_configs"]
|
||||||
|
events = read_events(args.trace)
|
||||||
|
counts = event_counts(events)
|
||||||
|
is_attached = tracer_pid(pid) == process.pid
|
||||||
|
watch_armed = counts.get("team1_watchpoint_armed", 0) > 0
|
||||||
|
status.write(
|
||||||
|
state="capturing" if len(heap["match_teams"]) < 2 else "records_observed",
|
||||||
|
tracer_pid=tracer_pid(pid),
|
||||||
|
gdb_alive=process.poll() is None,
|
||||||
|
target_state=target_state(pid),
|
||||||
|
trace_events=counts,
|
||||||
|
team1_watchpoint_armed=watch_armed,
|
||||||
|
locations=serialise_locations(latest_locations),
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(heap["match_teams"]) >= 2:
|
||||||
|
if records_seen_at is None:
|
||||||
|
if not is_attached or process.poll() is not None:
|
||||||
|
status.write(
|
||||||
|
state="failed",
|
||||||
|
phase="record_creation_control",
|
||||||
|
tracer_pid=tracer_pid(pid),
|
||||||
|
gdb_alive=process.poll() is None,
|
||||||
|
trace_events=counts,
|
||||||
|
)
|
||||||
|
return 5
|
||||||
|
records_seen_at = now
|
||||||
|
record_control = {
|
||||||
|
"gdb_alive": process.poll() is None,
|
||||||
|
"tracer_pid": tracer_pid(pid),
|
||||||
|
"attached": is_attached,
|
||||||
|
"execution_breakpoints_armed": counts.get("trace_armed", 0) == 1,
|
||||||
|
"team1_watchpoint_armed": watch_armed,
|
||||||
|
}
|
||||||
|
status.log(
|
||||||
|
"two match-team records located",
|
||||||
|
team_ids=[team.team_id for team in heap["match_teams"]],
|
||||||
|
trace_events=counts,
|
||||||
|
**record_control,
|
||||||
|
)
|
||||||
|
if now - records_seen_at >= args.post_record_wait:
|
||||||
|
break
|
||||||
|
time.sleep(3)
|
||||||
|
finally:
|
||||||
|
terminate_gdb(process, status, pid)
|
||||||
|
gdb_handle.close()
|
||||||
|
|
||||||
|
events = read_events(args.trace)
|
||||||
|
counts = event_counts(events)
|
||||||
|
final = {
|
||||||
|
"state": "captured",
|
||||||
|
"pid": pid,
|
||||||
|
"cards_base": cards,
|
||||||
|
"fixture": asdict(fixture) if fixture else None,
|
||||||
|
"locations": serialise_locations(latest_locations),
|
||||||
|
"trace_events": counts,
|
||||||
|
"record_creation_control": record_control,
|
||||||
|
"gdb_alive_at_record_creation": bool(
|
||||||
|
record_control and record_control["gdb_alive"] and record_control["attached"]
|
||||||
|
),
|
||||||
|
"target_crossed_match_team_creation": len(latest_locations["match_teams"]) >= 2,
|
||||||
|
"candidate_entry_hit": counts.get("candidate_entry", 0) > 0,
|
||||||
|
"team1_write_hit": counts.get("team1_write_post", 0) > 0,
|
||||||
|
"opponent_lookup_store_hit": counts.get("opponent_lookup_store_pre", 0) > 0,
|
||||||
|
"tracer_pid_after_detach": tracer_pid(pid),
|
||||||
|
"target_state_after_detach": target_state(pid),
|
||||||
|
"records_preexisting": records_preexisting,
|
||||||
|
"records_cleared_before_capture": records_cleared,
|
||||||
|
}
|
||||||
|
status.write(**final)
|
||||||
|
status.log("capture complete", **final)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+84
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Dump a CardsDLL vtable as image VAs, and find sibling vtables that hold a
|
||||||
|
different function in the same slot (a type/mode dispatch).
|
||||||
|
|
||||||
|
vtab.py <slot_image_va_hex> [before] [after]
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
CARDS_IMG = 0x180000000
|
||||||
|
|
||||||
|
|
||||||
|
def pid():
|
||||||
|
for d in glob.glob("/proc/[0-9]*"):
|
||||||
|
try:
|
||||||
|
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||||
|
return int(os.path.basename(d))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise SystemExit("no FIFA17.exe")
|
||||||
|
|
||||||
|
|
||||||
|
P = pid()
|
||||||
|
BASE = [int(l.split("-")[0], 16) for l in open(f"/proc/{P}/maps") if "CardsDLL" in l][0]
|
||||||
|
|
||||||
|
|
||||||
|
def img2live(va):
|
||||||
|
return BASE + (va - CARDS_IMG)
|
||||||
|
|
||||||
|
|
||||||
|
def live2img(la):
|
||||||
|
return CARDS_IMG + (la - BASE)
|
||||||
|
|
||||||
|
|
||||||
|
slot = int(sys.argv[1], 16)
|
||||||
|
before = int(sys.argv[2]) if len(sys.argv) > 2 else 10
|
||||||
|
after = int(sys.argv[3]) if len(sys.argv) > 3 else 10
|
||||||
|
|
||||||
|
mem = open(f"/proc/{P}/mem", "rb", 0)
|
||||||
|
start = slot - before * 8
|
||||||
|
mem.seek(img2live(start))
|
||||||
|
buf = mem.read((before + after) * 8)
|
||||||
|
print(f" vtable neighbourhood of image 0x{slot:x}")
|
||||||
|
target = None
|
||||||
|
for k in range(0, len(buf) - 7, 8):
|
||||||
|
a = start + k
|
||||||
|
p = struct.unpack_from("<Q", buf, k)[0]
|
||||||
|
ivа = live2img(p) if BASE <= p < BASE + 0x400000 else None
|
||||||
|
mark = " <== the team-pair assigner" if a == slot else ""
|
||||||
|
if a == slot:
|
||||||
|
target = ivа
|
||||||
|
print(f" 0x{a:x} [{a-slot:+#5x}] -> "
|
||||||
|
+ (f"image 0x{ivа:x}" if ivа else f"raw 0x{p:x}") + mark)
|
||||||
|
|
||||||
|
# Find every other .rdata slot pointing at a DIFFERENT function but whose
|
||||||
|
# neighbours overlap this vtable -> sibling implementations of the same slot.
|
||||||
|
print("\n === sibling vtables: same neighbour, different slot function ===")
|
||||||
|
mem.seek(img2live(0x1801e5000))
|
||||||
|
rdata = mem.read(0x28a000 - 0x1e5000)
|
||||||
|
# take the two neighbours around the slot as a signature
|
||||||
|
sig_prev = struct.unpack_from("<Q", buf, (before - 1) * 8)[0]
|
||||||
|
sig_next = struct.unpack_from("<Q", buf, (before + 1) * 8)[0]
|
||||||
|
found = 0
|
||||||
|
for name, sig in (("preceding", sig_prev), ("following", sig_next)):
|
||||||
|
pat = struct.pack("<Q", sig)
|
||||||
|
i = rdata.find(pat)
|
||||||
|
while i >= 0:
|
||||||
|
if i % 8 == 0:
|
||||||
|
here = 0x1801e5000 + i
|
||||||
|
# the slot in THIS vtable at the same relative position
|
||||||
|
off = i + (8 if name == "preceding" else -8)
|
||||||
|
if 0 <= off <= len(rdata) - 8:
|
||||||
|
fn = struct.unpack_from("<Q", rdata, off)[0]
|
||||||
|
if BASE <= fn < BASE + 0x400000:
|
||||||
|
fimg = live2img(fn)
|
||||||
|
if fimg != target:
|
||||||
|
print(f" vtable @image 0x{here:x} ({name} matches) "
|
||||||
|
f"slot -> image 0x{fimg:x} DIFFERENT")
|
||||||
|
found += 1
|
||||||
|
i = rdata.find(pat, i + 1)
|
||||||
|
print(f" {found} sibling implementation(s)")
|
||||||
Executable
+111
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Find references to an image VA inside a live module's .text/.rdata/.data.
|
||||||
|
|
||||||
|
xref.py <target_image_va_hex> [--exe]
|
||||||
|
|
||||||
|
Reports:
|
||||||
|
call rel32 (e8) / jmp rel32 (e9) -- direct callers
|
||||||
|
lea rip-rel (48 8d 0x) -- address-taken
|
||||||
|
absolute 8-byte pointer -- vtable / table slot
|
||||||
|
|
||||||
|
Read-only. Section ranges are recomputed from /proc/<pid>/maps every run.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
CARDS_IMG = 0x180000000
|
||||||
|
EXE_IMG = 0x140000000
|
||||||
|
|
||||||
|
|
||||||
|
def pid():
|
||||||
|
for d in glob.glob("/proc/[0-9]*"):
|
||||||
|
try:
|
||||||
|
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||||
|
return int(os.path.basename(d))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise SystemExit("FIFA17.exe not running")
|
||||||
|
|
||||||
|
|
||||||
|
P = pid()
|
||||||
|
|
||||||
|
|
||||||
|
def module_base(needle):
|
||||||
|
for l in open(f"/proc/{P}/maps"):
|
||||||
|
if needle.lower() in l.lower():
|
||||||
|
return int(l.split("-")[0], 16)
|
||||||
|
raise SystemExit(f"{needle} not mapped")
|
||||||
|
|
||||||
|
|
||||||
|
def spans(base, limit=0x400000):
|
||||||
|
"""Contiguous mappings belonging to this module, as (live_lo, live_hi, perms)."""
|
||||||
|
out = []
|
||||||
|
for l in open(f"/proc/{P}/maps"):
|
||||||
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", l)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)
|
||||||
|
if lo == base:
|
||||||
|
out.append((lo, hi, perms))
|
||||||
|
continue
|
||||||
|
if out and lo == out[-1][1] and not path.strip():
|
||||||
|
out.append((lo, hi, perms))
|
||||||
|
elif out and lo > out[-1][1]:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
a = [x for x in sys.argv[1:] if x != "--exe"]
|
||||||
|
exe = "--exe" in sys.argv
|
||||||
|
target = int(a[0], 16)
|
||||||
|
img = EXE_IMG if exe else CARDS_IMG
|
||||||
|
base = module_base("FIFA17.exe" if exe else "CardsDLL")
|
||||||
|
tgt_live = base + (target - img)
|
||||||
|
|
||||||
|
mem = open(f"/proc/{P}/mem", "rb", 0)
|
||||||
|
print(f" pid={P} module_base=0x{base:x} target image 0x{target:x} live 0x{tgt_live:x}")
|
||||||
|
hits = 0
|
||||||
|
for lo, hi, perms in spans(base):
|
||||||
|
try:
|
||||||
|
mem.seek(lo)
|
||||||
|
buf = mem.read(hi - lo)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
img_lo = img + (lo - base)
|
||||||
|
# rel32 call/jmp
|
||||||
|
for op, name in ((0xE8, "call"), (0xE9, "jmp ")):
|
||||||
|
i = buf.find(bytes([op]))
|
||||||
|
while i >= 0:
|
||||||
|
if i + 5 <= len(buf):
|
||||||
|
rel = struct.unpack_from("<i", buf, i + 1)[0]
|
||||||
|
if img_lo + i + 5 + rel == target:
|
||||||
|
print(f" {name} rel32 from image 0x{img_lo+i:x} [{perms}]")
|
||||||
|
hits += 1
|
||||||
|
i = buf.find(bytes([op]), i + 1)
|
||||||
|
# lea reg,[rip+rel32] (48 8d /r with mod=00 rm=101)
|
||||||
|
i = buf.find(b"\x48\x8d")
|
||||||
|
while i >= 0:
|
||||||
|
if i + 7 <= len(buf):
|
||||||
|
modrm = buf[i + 2]
|
||||||
|
if (modrm & 0xC7) == 0x05:
|
||||||
|
rel = struct.unpack_from("<i", buf, i + 3)[0]
|
||||||
|
if img_lo + i + 7 + rel == target:
|
||||||
|
print(f" lea rip-rel from image 0x{img_lo+i:x} [{perms}]")
|
||||||
|
hits += 1
|
||||||
|
i = buf.find(b"\x48\x8d", i + 1)
|
||||||
|
# absolute pointer (live address stored in a table)
|
||||||
|
pat = struct.pack("<Q", tgt_live)
|
||||||
|
i = buf.find(pat)
|
||||||
|
while i >= 0:
|
||||||
|
if i % 8 == 0:
|
||||||
|
print(f" abs ptr slot at image 0x{img_lo+i:x} [{perms}]")
|
||||||
|
hits += 1
|
||||||
|
i = buf.find(pat, i + 1)
|
||||||
|
print(f" {hits} reference(s)")
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -188,23 +188,37 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref
|
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref and
|
||||||
// AND carrying its item, as `[{id, itemData, dream}]`.
|
// emitted as a BARE ITEM OBJECT with `dream` beside the item's own fields —
|
||||||
|
// NOT wrapped in `itemData`.
|
||||||
//
|
//
|
||||||
// The bare `[{id, dream}]` form is NOT sufficient, which cost a real
|
// This is a wire-shape contract, recovered from the client rather than
|
||||||
// debugging round: the operator picked a manager in the hub, the save
|
// guessed, after two earlier shapes both failed:
|
||||||
// persisted (Core `squad_managers` row written, `outcome=ok`, no unresolved
|
|
||||||
// ref), and the pre-match squad still showed no manager. Every retail
|
|
||||||
// capture that shows the bare form has `id: 0` — an EMPTY manager — so none
|
|
||||||
// of them ever demonstrated that a POPULATED ref resolves without its item.
|
|
||||||
//
|
//
|
||||||
// The squad response is self-contained for players: `players[].itemData`
|
// `[{id, dream}]` — no merge key, so nothing resolves.
|
||||||
// carries the whole card rather than an id the client resolves out of band.
|
// `[{id, itemData, dream}]` — `itemData` is never read on this path.
|
||||||
// The manager is the same kind of slot in the same object, and the one
|
//
|
||||||
// implementation that ever drove a working manager (the Python oracle's
|
// The squad parser FUN_18013d1f0 treats the two slots differently, and that
|
||||||
// squad) emits `id` BESIDE `itemData` exactly like this. Note the element
|
// is the whole point:
|
||||||
// shape differs from a player slot: `{index, itemData, kitNumber}` there,
|
//
|
||||||
// `{id, itemData, dream}` here.
|
// players: atom 568 -> per-element atoms 355 `index`, 363 `itemData`,
|
||||||
|
// 378 `kitNumber`; the 363 arm (0x18013d8d9) calls the ITEM
|
||||||
|
// parser FUN_18013fe00 on the NESTED itemData object.
|
||||||
|
// manager: atom 424 -> array loop at 0x18013da29 calls that same item
|
||||||
|
// parser DIRECTLY on the array ELEMENT, into squad+0xC0. There is
|
||||||
|
// no `itemData` step at all.
|
||||||
|
//
|
||||||
|
// So a manager element IS an item. Nesting the fields one level deeper left
|
||||||
|
// the parser reading only the two keys that happen to be item atoms — `id`
|
||||||
|
// (0x14c) and `dream` (0xe7) — and leaving `resourceId` at 0. Measured on a
|
||||||
|
// cold client: the manager record existed at squad+0xC0 with the correct id
|
||||||
|
// and `resourceId == 0`, while sibling players in the same response carried
|
||||||
|
// theirs (83906881, 84053575). `resourceId` is the merge key compared RAW
|
||||||
|
// against `carddbid`, so zero can never hit the managercards table: no name,
|
||||||
|
// no rating, no art, and an empty manager slot in the UI.
|
||||||
|
//
|
||||||
|
// The client's own save corroborates the shape: it PUTs
|
||||||
|
// `"manager":[{"id":…,"dream":false}]` — flat, and both keys are item atoms.
|
||||||
//
|
//
|
||||||
// An owned manager with no resolvable FIFA staff identity is omitted
|
// An owned manager with no resolvable FIFA staff identity is omitted
|
||||||
// (non-fatal, like /club dropping an unrenderable card) rather than emitted
|
// (non-fatal, like /club dropping an unrenderable card) rather than emitted
|
||||||
@@ -214,11 +228,13 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|m| ident.resolve_staff(m).map(|id| (m, id)))
|
.and_then(|m| ident.resolve_staff(m).map(|id| (m, id)))
|
||||||
{
|
{
|
||||||
Some((mgr, id)) => json!([{
|
Some((mgr, id)) => {
|
||||||
"id": id.item_id,
|
let mut item = shape_staff_item(id, mgr.contract_matches.unwrap_or(STAFF_CONTRACT));
|
||||||
"itemData": shape_staff_item(id, mgr.contract_matches.unwrap_or(STAFF_CONTRACT)),
|
if let Some(obj) = item.as_object_mut() {
|
||||||
"dream": false,
|
obj.insert("dream".to_string(), json!(false));
|
||||||
}]),
|
}
|
||||||
|
json!([item])
|
||||||
|
}
|
||||||
None => json!([]),
|
None => json!([]),
|
||||||
};
|
};
|
||||||
let squad = json!({
|
let squad = json!({
|
||||||
@@ -258,9 +274,10 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
|||||||
/// That deserializer inserts the record into the client's resident item map
|
/// That deserializer inserts the record into the client's resident item map
|
||||||
/// (keyed by wire instance id, and its only gate is a non-zero id) and binds the
|
/// (keyed by wire instance id, and its only gate is a non-zero id) and binds the
|
||||||
/// slot handle to it. So each element must be a FULL item object, exactly like
|
/// slot handle to it. So each element must be a FULL item object, exactly like
|
||||||
/// `squad.manager[].itemData` — an id reference alone installs nothing, because
|
/// a `squad.manager[]` element — which reaches this same deserializer the same
|
||||||
/// the manager installer looks its id up in that same map and does nothing when
|
/// way, called directly on the array element with no `itemData` step. An id
|
||||||
/// it misses.
|
/// reference alone installs nothing, because the installer looks its id up in
|
||||||
|
/// that same map and does nothing when it misses.
|
||||||
///
|
///
|
||||||
/// An empty array makes the client read the array-end token immediately and
|
/// An empty array makes the client read the array-end token immediately and
|
||||||
/// parse nothing, which leaves all five slots null. Every later consumer then
|
/// parse nothing, which leaves all five slots null. Every later consumer then
|
||||||
@@ -588,29 +605,38 @@ mod tests {
|
|||||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||||
panic!("expected Projected");
|
panic!("expected Projected");
|
||||||
};
|
};
|
||||||
// The item must ride ALONG with the ref: a bare `{id, dream}` left the
|
// The element IS the item: the squad parser's manager branch calls the
|
||||||
// pre-match squad with no manager even though the assignment had been
|
// item parser on the array element itself, with no `itemData` step, so
|
||||||
// saved, because nothing in the response described the card.
|
// the fields must be flat. Nesting them left `resourceId` — the merge
|
||||||
|
// key — at 0 on a cold client and the slot rendered empty.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
v["manager"],
|
v["manager"],
|
||||||
json!([{
|
json!([{
|
||||||
"id": 100000427,
|
"id": 100000427,
|
||||||
"itemData": {
|
"resourceId": 1_000_509,
|
||||||
"id": 100000427,
|
"cardsubtypeid": 4,
|
||||||
"resourceId": 1_000_509,
|
"itemType": "staff",
|
||||||
"cardsubtypeid": 4,
|
"nation": 45,
|
||||||
"itemType": "staff",
|
"leagueId": 53,
|
||||||
"nation": 45,
|
"teamid": 241,
|
||||||
"leagueId": 53,
|
"contract": 12,
|
||||||
"teamid": 241,
|
"itemState": "free",
|
||||||
"contract": 12,
|
"owners": 1,
|
||||||
"itemState": "free",
|
"untradeable": false,
|
||||||
"owners": 1,
|
|
||||||
"untradeable": false,
|
|
||||||
},
|
|
||||||
"dream": false,
|
"dream": false,
|
||||||
}]),
|
}]),
|
||||||
"manager is the ownership-backed wire ref WITH its item"
|
"manager element is a bare item object carrying `dream`"
|
||||||
|
);
|
||||||
|
let element = &v["manager"][0];
|
||||||
|
assert!(
|
||||||
|
element.get("itemData").is_none(),
|
||||||
|
"an `itemData` wrapper is never descended into on the manager path, \
|
||||||
|
so its presence means the merge key is invisible to the client"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
element["resourceId"], 1_000_509,
|
||||||
|
"resourceId must be readable at element level: it is the merge key \
|
||||||
|
compared RAW against carddbid, and 0 resolves no manager"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -410,10 +410,13 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
|||||||
}
|
}
|
||||||
// EXTENSION + SHADOW: sourced from the read, so they round-trip identically.
|
// EXTENSION + SHADOW: sourced from the read, so they round-trip identically.
|
||||||
assert_eq!(projected["custom"], oracle["custom"]);
|
assert_eq!(projected["custom"], oracle["custom"]);
|
||||||
// The manager REF round-trips; the item now rides with it. The capture this
|
// The manager REF round-trips; the item now rides AT ELEMENT LEVEL. The
|
||||||
// oracle came from carried a bare `{id, dream}`, but its manager was the
|
// capture this oracle came from carried a bare `{id, dream}`, but its
|
||||||
// dangling one every retail capture has, so it never showed that a populated
|
// manager was the dangling one every retail capture has, so it never showed
|
||||||
// ref renders on its own — and in practice it did not.
|
// that a populated ref renders on its own — and in practice it did not.
|
||||||
|
// Wrapping the fields in `itemData` did not work either: the squad parser's
|
||||||
|
// manager branch calls the item parser on the element itself, so a nested
|
||||||
|
// item is never read and the merge key stays 0.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
projected["manager"][0]["id"], oracle["manager"][0]["id"],
|
projected["manager"][0]["id"], oracle["manager"][0]["id"],
|
||||||
"the manager wire ref itself must still round-trip"
|
"the manager wire ref itself must still round-trip"
|
||||||
@@ -422,8 +425,11 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
|||||||
projected["manager"][0]["dream"],
|
projected["manager"][0]["dream"],
|
||||||
oracle["manager"][0]["dream"]
|
oracle["manager"][0]["dream"]
|
||||||
);
|
);
|
||||||
let mgr_item = &projected["manager"][0]["itemData"];
|
let mgr_item = &projected["manager"][0];
|
||||||
assert_eq!(mgr_item["id"], oracle["manager"][0]["id"]);
|
assert!(
|
||||||
|
mgr_item.get("itemData").is_none(),
|
||||||
|
"the manager element IS the item; a wrapper hides the merge key"
|
||||||
|
);
|
||||||
assert_eq!(mgr_item["cardsubtypeid"], 4);
|
assert_eq!(mgr_item["cardsubtypeid"], 4);
|
||||||
assert_eq!(mgr_item["resourceId"], 1_000_509);
|
assert_eq!(mgr_item["resourceId"], 1_000_509);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+1
-1
Submodule openfut-launcher updated: d7641175be...bee97055db
Reference in New Issue
Block a user