tool(fifa17-recon): trace Offline Seasons team assignment hardware-only
Add a fail-closed, fresh-process native trace workflow for the Offline Seasons
fixture-to-match-team boundary. The supervisor ignores UMU's short-lived
FIFA17.exe process, requires CardsDLL, verifies TracerPid and hardware arming,
rejects pre-existing records, structurally locates fixtures/final records, and
detaches cleanly after capture.
The four payloads reproduce the measured chain without client writes:
FUN_1800fc500
-> actual season vector, fixture index 0 / team 73
-> temporary [73,130000] pair (not the final record)
CardsDLL service -> engine 0x147c652ce
-> live final +0x14 writes at the 0x45c side stride
-> correct [73,130000], then local overwrite [130000,130000]
engine wrapper 0x147ce47e0
<- CardsDLL 0x180031861
<- CardsGameSetupAdapter local `teams` query result already 130000
All execute breakpoints and watchpoints are hardware-only. /proc/PID/mem is
opened rb. No INT3, write_memory, patch, game input, server behavior, or Rust
code. Locator uses zero-based --fixture-index (the live selector is 0 when
season/user.round is 1) and never filters on transient +0x18 handles.
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user