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,
|
||||
)
|
||||
@@ -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,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
+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())
|
||||
+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())
|
||||
Reference in New Issue
Block a user