Add FIFA17 match transition tracers

This commit is contained in:
funman300
2026-08-25 21:37:59 +00:00
parent e3ed8c298e
commit 5eed124b85
2 changed files with 427 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Trace FIFA17 ACTION_ADVANCE dispatch after MATCH_CREATED delivery.
Stage one (`match_transition_trace.py`) proves the HTTP response, deserializer,
and FUT_CREATE_MATCH_DP delivery. This stage uses that provider callback to
capture the screen key, then records every nested global UI dispatch for that
same screen until the positive-control FUT_GET_MATCH_KITS_DP arrives. It also
captures the low-level create event and final native-to-UI provider bridge.
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}
set $screen_key = 0
set $target_seen = 0
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
if $edx == 0x{transition.FUT_CREATE_MATCH_DP:x}
set $screen_key = $r8
set $target_seen = 1
end
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d PROVIDER" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d provider=%#x screen_key=%p controller=%p caller=%p\\n", $_thread, $edx, $r8, $rcx, *(void**)$rsp
continue
end
hbreak *0x{address['global_dispatch']:x}
condition 2 $target_seen != 0 && $r8 == $screen_key
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 id=%#x screen_key=%p registry=%p caller=%p\\n", $_thread, $rdx, $r8, $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 "$r8 == $screen_key" 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())
+233
View File
@@ -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())