15 Commits

Author SHA1 Message Date
funman300 e14d3cd063 Trace FIFA17 command 0x128 lifecycle 2026-08-28 01:12:37 +00:00
funman300 f4fc832ace Trace FIFA17 PMA producer lifecycle 2026-08-27 23:27:52 +00:00
funman300 6aab279244 Wire FIFA17 PMA repair candidate 2026-08-27 22:40:04 +00:00
funman300 d3451be17b Trace FIFA17 PMA completion divergence 2026-08-27 21:43:48 +00:00
funman300 0300af3333 Add FIFA17 Kick Off control trace profile 2026-08-26 17:34:43 +00:00
funman300 2c572e918f tools: trace FIFA17 scenario start source chain 2026-08-26 04:46:42 +00:00
funman300 f608dbc438 Add post-kit gameplay transition tracers 2026-08-26 01:40:41 +00:00
funman300 43c460741b trace FIFA17 provider lifecycle 2026-08-25 23:18:08 +00:00
funman300 5eed124b85 Add FIFA17 match transition tracers 2026-08-25 21:37:59 +00:00
funman300 e3ed8c298e fix(fifa17): advance season team compatibility 2026-08-25 20:14:57 +00:00
funman300 5181e103dc Add FIFA17 game-setup context tracers 2026-08-25 18:29:55 +00:00
funman300 433a9b22dd 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.
2026-08-25 17:25:41 +00:00
funman300 0701ac94e1 tool(fifa17-recon): live native-RE toolkit (disasm, xref, immediate-store, vtable)
Read-only probes for resolving FIFA17 code paths against a running client
without Ghidra, per the live-disassembly method (/proc/<pid>/mem + objdump).
All open /proc/<pid>/mem 'rb' only.

  ldis.py           image-VA disassembler/hexdump for CardsDLL and FIFA17.exe;
                    recomputes the module base from the NAMED PE-header mapping
                    every run, because Wine maps PE sections anonymously and the
                    mapping that merely CONTAINS an address is not the module.
  xref.py           references to an image VA: call/jmp rel32, rip-relative lea,
                    and absolute pointer slots. An absolute-only hit means the
                    function is virtual and reachable solely via its vtable.
  immstore.py       immediate stores (C7 /0) of a constant to a struct offset.
                    Only an immediate store can INTRODUCE a constant; a register
                    store merely propagates one. Zero hits is a real result: it
                    proves the constant arrives from a call, not a literal.
  classify_calls.py splits call sites of a constant-returning stub into STORE
                    (can assign) vs compare (predicate). Turned 81 call sites of
                    the 130000 provider into 17 assignments.
  vtab.py           dumps a vtable as image VAs and looks for sibling vtables
                    holding a different function in the same slot, which is how
                    a type/mode dispatch shows up.
  scan_mt.py        match-team records by the invariant header (11,7,0,0,76).
                    Never filters on +0x18: that word is a per-session handle
                    (-1 on 2026-08-24, 0x54001/0x54000 on 2026-08-25) and
                    filtering on it previously produced a false negative.

Workflow note: dump .text once and cache the objdump output, then query the
cached listing; a full CardsDLL .text linear disassembly is ~563k lines and
re-disassembling per question is wasteful.
2026-08-25 04:23:22 +00:00
funman300 025122ec9a tool(fifa17-recon): manager_coldproof.py -- read-only manager registration probe
Promotes the throwaway probe used to close the manager cold-load milestone into
fifa17-recon/tools. Read-only (/proc/<pid>/mem opened 'rb', never 'r+b'), pid
optional and overridable, controls overridable via --control WIRE:RESOURCE.

Fail-closed: absent player positive controls exit 3 (INCONCLUSIVE, squad not
loaded) rather than 0, so 'no manager found' can never be reported from a
session that never loaded a squad. Distinguishes real item records from
incidental integer matches by requiring resourceId 0x20 bytes before the wire
id, the layout the player controls exhibit.

Documents the manager wire control, the resourceId control (the actual
verdict), the player positive controls, and what counts as a resident hit.
2026-08-25 02:58:16 +00:00
funman300 b91e707a7e fix(fifa17): squad.manager elements are bare item objects, not itemData wrappers
An owned manager assigned in Core was present everywhere on the server -- in
/club/manager, in club?type=staff, and in userMassInfo -- but the squad UI
showed no manager after a cold client load.

The 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 a manager element IS an item. We were nesting the fields one level deeper,
so the parser read only the two keys that happen to be item atoms -- `id` and
`dream` -- and left everything else at its default. 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. resourceId is the
merge key compared RAW against carddbid, so 0 resolves no manager: no name, no
rating, no art, empty slot.

The client's own save corroborates the shape: it PUTs
`"manager":[{"id":...,"dream":false}]` -- flat, and both keys are item atoms.

Flatten the element to the item plus `dream`. Cold-load proven on staging: the
manager record now carries resourceId 1000509 in the same layout as its player
siblings (83906881, 84053575) in the same array region, and the operator
confirms a manager is assigned in the squad management screen.

Two earlier shapes are now both explained and covered by tests: `{id, dream}`
carries no merge key, and `{id, itemData, dream}` hides it from this path.
2026-08-25 02:50:22 +00:00
29 changed files with 6483 additions and 47 deletions
+55
View File
@@ -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}")
+332
View File
@@ -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,
)
+95
View File
@@ -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 ""))
+94
View File
@@ -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()
+252
View File
@@ -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())
+189
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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())
+185
View File
@@ -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
View File
@@ -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())
+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())
+306
View File
@@ -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())
+394
View File
@@ -0,0 +1,394 @@
#!/usr/bin/env python3
"""Trace FIFA17's PMA ScenarioModeStart-to-event-5 producer chain.
The generated GDB program uses hardware breakpoints, only reads registers and
client memory, logs, and continues. Breakpoints are rotated so no more than four
are enabled. It never calls client functions, writes client memory, emits an
event, or drives input.
pma_producer_trace.py [pid] [--variant mode0|alternate] [--output PATH]
pma_producer_trace.py --selftest
"""
from __future__ import annotations
import argparse
from pathlib import Path
import shutil
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import match_advance_trace as advance
import match_transition_trace as transition
VARIANTS = {
"mode0": {
"scenario_rva": 0x07B1C190,
"writer_rva": 0x07B1C26B,
"register_rva": 0x07B1C282,
"writer_context": "$rsi",
"writer_async_requested": "1",
"arm_condition": "1",
},
"alternate": {
"scenario_rva": 0x07B1C050,
"writer_rva": 0x07B1C12F,
"register_rva": 0x07B1C146,
"writer_context": "$rbp",
"writer_async_requested": "$sil",
"arm_condition": "$tracked_ctx != 0 && $rcx == $tracked_ctx",
},
}
PMA_COMPLETION_ARM_RVA = 0x07B1AE60
PMA_COMPLETION_ARM_WRITER_RVA = 0x07B1AF33
ASYNC_COMPLETION_RVA = 0x07B046C0
CALLBACK_DISPATCHER_RVA = 0x07AC87B0
PMA_INSTRUCTIONS_HANDLER_RVA = 0x07AC91E0
GAMEPLAY_GLOBAL_RVA = 0x04BFB910
PMA_INSTRUCTIONS_VTABLE_RVA = 0x03AF2750
def addresses(base: int, variant: str) -> dict[str, int]:
config = VARIANTS[variant]
return {
"scenario": base + config["scenario_rva"],
"writer": base + config["writer_rva"],
"register": base + config["register_rva"],
"arm": base + PMA_COMPLETION_ARM_RVA,
"arm_writer": base + PMA_COMPLETION_ARM_WRITER_RVA,
"completion": base + ASYNC_COMPLETION_RVA,
"dispatcher": base + CALLBACK_DISPATCHER_RVA,
"instructions": base + PMA_INSTRUCTIONS_HANDLER_RVA,
"gameplay_global": base + GAMEPLAY_GLOBAL_RVA,
"instructions_vtable": base + PMA_INSTRUCTIONS_VTABLE_RVA,
}
def gdb_prelude(pid: int, output: str) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted off
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
"""
def build_script(pid: int, fifa_base: int, output: str, variant: str) -> str:
address = addresses(fifa_base, variant)
config = VARIANTS[variant]
return (
gdb_prelude(pid, output)
+ f"""define snapshot_pma_context
set $snap_ctx = $arg0
set $snap_flag40 = -1
set $snap_callback_vtable = 0
set $snap_callback_owner = 0
set $snap_dispatcher = 0
set $snap_dispatcher_vtable = 0
set $snap_pma = 0
set $snap_pma_flag18 = -1
set $snap_pma_parent = 0
set $snap_pma_machine = 0
set $snap_pma_current = 0
if $snap_ctx != 0
set $snap_flag40 = *(unsigned char*)($snap_ctx+0x40)
set $snap_callback_vtable = *(void**)($snap_ctx+0x48)
set $snap_callback_owner = *(void**)($snap_ctx+0x78)
set $snap_dispatcher = $snap_ctx+0x80
set $snap_dispatcher_vtable = *(void**)$snap_dispatcher
set $snap_sentinel = $snap_ctx+0x88
set $snap_node = *(void**)$snap_sentinel
set $snap_scan = 0
while $snap_node != 0 && $snap_node != $snap_sentinel && $snap_scan < 8
set $snap_candidate = *(void**)($snap_node+0x10)
if $snap_candidate != 0
if *(void**)$snap_candidate == 0x{address['instructions_vtable']:x}
set $snap_pma = $snap_candidate
end
end
set $snap_node = *(void**)$snap_node
set $snap_scan = $snap_scan+1
end
if $snap_pma != 0
set $snap_pma_flag18 = *(unsigned char*)($snap_pma+0x18)
set $snap_pma_parent = *(void**)($snap_pma+0x8)
if $snap_pma_parent != 0
set $snap_pma_machine = *(void**)($snap_pma_parent+0x8)
end
if $snap_pma_machine != 0
set $snap_pma_current = *(void**)($snap_pma_machine+0x10)
end
end
end
end
define snapshot_gameplay
set $snap_gameplay_global = *(void**)0x{address['gameplay_global']:x}
set $snap_listener_manager = 0
set $snap_listener_table = 0
set $snap_listener_index = -1
set $snap_free_roam = 0
set $snap_free_roam_state = -1
set $snap_free_roam_111 = -1
set $snap_free_roam_112 = -1
set $snap_free_roam_124 = -1
set $snap_selected = 0
set $snap_selected_vtable = 0
set $snap_selected_mode = -1
if $snap_gameplay_global != 0
set $snap_listener_manager = *(void**)($snap_gameplay_global+0x58)
end
if $snap_listener_manager != 0
set $snap_listener_table = *(void**)$snap_listener_manager
end
if $snap_listener_table != 0
set $snap_free_roam = *(void**)$snap_listener_table
set $snap_listener_index = *(int*)($snap_listener_table+0x20)
if $snap_listener_index >= 0 && $snap_listener_index < 3
set $snap_selected = *(void**)($snap_listener_table+$snap_listener_index*8)
end
end
if $snap_free_roam != 0
set $snap_free_roam_state = *(int*)($snap_free_roam+0x30)
set $snap_free_roam_111 = *(unsigned char*)($snap_free_roam+0x111)
set $snap_free_roam_112 = *(unsigned char*)($snap_free_roam+0x112)
set $snap_free_roam_124 = *(int*)($snap_free_roam+0x124)
end
if $snap_selected != 0
set $snap_selected_vtable = *(void**)$snap_selected
set $snap_selected_mode = *(int*)($snap_selected+0x18)
end
end
set $tracked_ctx = 0
hbreak *0x{address['scenario']:x}
commands
silent
set $ctx = $rcx
set $tracked_ctx = $ctx
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d SCENARIO_MODE_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p arg_descriptor=%p arg_scenario=%p async_requested=%d flag40=%d callback_vtable=%p callback_owner=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_parent=%p pma_machine=%p pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $rdx, $r8, $r9b, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_parent, $snap_pma_machine, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 1
enable 2
continue
end
hbreak *0x{address['writer']:x}
condition 2 $tracked_ctx != 0 && {config['writer_context']} == $tracked_ctx
disable 2
commands
silent
set $ctx = {config['writer_context']}
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d CONTEXT_ARM_WRITER" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d instruction=%p caller_return=%p ctx=%p original_async_requested=%d flag40_before=%d callback_vtable=%p callback_owner_before=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, {config['writer_async_requested']}, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 2
enable 3
continue
end
hbreak *0x{address['register']:x}
condition 3 $tracked_ctx != 0 && $rdx == $tracked_ctx+0x48
disable 3
commands
silent
set $callback = $rdx
set $ctx = $callback-0x48
snapshot_pma_context $ctx
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d ASYNC_REGISTER_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d callsite=%p caller_return=%p service=%p service_vtable=%p callback=%p callback_vtable=%p ctx=%p flag40=%d callback_owner=%p dispatcher=%p dispatcher_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $rcx, *(void**)$rcx, $callback, *(void**)$callback, $ctx, $snap_flag40, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable
disable 3
continue
end
hbreak *0x{address['arm']:x}
condition 4 {config['arm_condition']}
commands
silent
set $ctx = $rcx
if $tracked_ctx == 0
set $tracked_ctx = $ctx
end
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d COMPLETION_ARM_ENTRY" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p flag40_before=%d callback_vtable=%p callback_owner=%p dispatcher=%p dispatcher_vtable=%p result_source=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, *(void**)($ctx+0xa8), $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 4
enable 5
continue
end
hbreak *0x{address['arm_writer']:x}
condition 5 $tracked_ctx != 0 && $rsi == $tracked_ctx
disable 5
commands
silent
set $ctx = $rsi
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d COMPLETION_ARM_WRITER" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d instruction=%p caller_return=%p ctx=%p flag40_before=%d result_object=%p result_state28=%d callback_owner=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, $snap_flag40, $rax, *(int*)($rax+0x28), $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 5
continue
end
hbreak *0x{address['completion']:x}
condition 6 $tracked_ctx != 0 && $rcx == $tracked_ctx+0x48
commands
silent
set $callback = $rcx
set $ctx = *(void**)($callback+0x30)
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d ASYNC_COMPLETION" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p callback=%p callback_vtable=%p ctx=%p callback_matches_ctx48=%d flag40_before=%d arg_rdx=%p arg_r8=%p arg_r9=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $callback, *(void**)$callback, $ctx, $callback == $ctx+0x48, $snap_flag40, $rdx, $r8, $r9, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
continue
end
hbreak *0x{address['dispatcher']:x}
condition 7 $tracked_ctx != 0 && $rcx == $tracked_ctx+0x80 && $edx == 5
commands
silent
set $ctx = $rcx-0x80
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d DISPATCHER_EVENT_5" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p dispatcher=%p event=%d arg_r8=%p arg_r9=%p ctx=%p flag40=%d callback_owner=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $rcx, $edx, $r8, $r9, $ctx, $snap_flag40, $snap_callback_owner, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 7
enable 8
continue
end
hbreak *0x{address['instructions']:x}
disable 8
commands
silent
set $listener = $rcx
set $parent = *(void**)($listener+0x8)
set $machine = 0
set $current = 0
if $parent != 0
set $machine = *(void**)($parent+0x8)
end
if $machine != 0
set $current = *(void**)($machine+0x10)
end
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d INSTRUCTIONS_AFTER_EVENT_5" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d handler=%p caller_return=%p listener=%p listener_vtable=%p event=%d flag18=%d parent=%p machine=%p current=%p current_vtable=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $listener, *(void**)$listener, $edx, *(unsigned char*)($listener+0x18), $parent, $machine, $current, $current ? *(void**)$current : 0, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 6
continue
end
printf "PMAPRODUCER ARMED pid={pid} variant={variant} scenario=0x{address['scenario']:x} writer=0x{address['writer']:x} register=0x{address['register']:x} arm=0x{address['arm']:x} arm_writer=0x{address['arm_writer']:x} completion=0x{address['completion']:x} dispatcher=0x{address['dispatcher']:x} instructions=0x{address['instructions']:x}\\n"
continue
"""
)
def effective_environment(pid: int) -> dict[str, str]:
values: dict[str, str] = {}
for item in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0"):
if not item.startswith(b"OPENFUT_FIFA17_"):
continue
key, _, value = item.decode("utf-8", errors="replace").partition("=")
values[key] = value
return values
def selftest() -> None:
mode0 = addresses(0x140000000, "mode0")
alternate = addresses(0x140000000, "alternate")
script = build_script(1234, 0x140000000, "/tmp/pma-producer.log", "mode0")
assert mode0["scenario"] == 0x147B1C190
assert mode0["writer"] == 0x147B1C26B
assert mode0["register"] == 0x147B1C282
assert alternate["scenario"] == 0x147B1C050
assert alternate["writer"] == 0x147B1C12F
assert alternate["register"] == 0x147B1C146
assert mode0["completion"] == 0x147B046C0
assert mode0["dispatcher"] == 0x147AC87B0
assert mode0["instructions"] == 0x147AC91E0
assert mode0["arm"] == 0x147B1AE60
assert mode0["arm_writer"] == 0x147B1AF33
assert script.count("hbreak *") == 8
assert "condition 7 $tracked_ctx != 0" in script
assert "disable 2" in script and "enable 2" in script
assert "disable 3" in script and "enable 3" in script
assert "disable 5" in script and "enable 5" in script
assert "disable 8" in script and "enable 8" in script
assert "set *(" not in script
print("pma_producer_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--variant", choices=tuple(VARIANTS), default="mode0")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or transition.find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
advance.validate_file(
fifa_path,
advance.PINNED_FIFA_SHA256,
advance.FIFA_MODULE,
)
cards_base, cards_path = transition.cards_mapping(pid)
transition.validate_cards(cards_path)
output = args.output or f"/tmp/fifa17-pma-producer-{args.variant}-{pid}.log"
script = build_script(pid, fifa_base, output, args.variant)
environment = effective_environment(pid)
print(
"PMAPRODUCER PREPARED "
f"pid={pid} variant={args.variant} fifa_base={fifa_base:#x} cards_base={cards_base:#x} "
f"team_compat={environment.get('OPENFUT_FIFA17_SEASON_TEAM_COMPAT', '<absent>')} "
f"pma_fix={environment.get('OPENFUT_FIFA17_OFFLINE_SEASONS_PMA_FIX', '<absent>')}"
)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-pma-producer-{args.variant}-{pid}.gdb"
Path(script_path).write_text(script, encoding="utf-8")
import os
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(main())
+67
View File
@@ -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})}")
File diff suppressed because it is too large Load Diff
+512
View File
@@ -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())
+84
View File
@@ -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)")
+111
View File
@@ -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
// AND carrying its item, as `[{id, itemData, dream}]`.
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref and
// 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
// debugging round: the operator picked a manager in the hub, the save
// 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.
// This is a wire-shape contract, recovered from the client rather than
// guessed, after two earlier shapes both failed:
//
// The squad response is self-contained for players: `players[].itemData`
// carries the whole card rather than an id the client resolves out of band.
// 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
// squad) emits `id` BESIDE `itemData` exactly like this. Note the element
// shape differs from a player slot: `{index, itemData, kitNumber}` there,
// `{id, itemData, dream}` here.
// `[{id, dream}]` — no merge key, so nothing resolves.
// `[{id, itemData, dream}]` — `itemData` is never read on this path.
//
// The squad parser FUN_18013d1f0 treats the two slots differently, and that
// is the whole point:
//
// 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
// (non-fatal, like /club dropping an unrenderable card) rather than emitted
@@ -214,11 +228,13 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
.as_ref()
.and_then(|m| ident.resolve_staff(m).map(|id| (m, id)))
{
Some((mgr, id)) => json!([{
"id": id.item_id,
"itemData": shape_staff_item(id, mgr.contract_matches.unwrap_or(STAFF_CONTRACT)),
"dream": false,
}]),
Some((mgr, id)) => {
let mut item = shape_staff_item(id, mgr.contract_matches.unwrap_or(STAFF_CONTRACT));
if let Some(obj) = item.as_object_mut() {
obj.insert("dream".to_string(), json!(false));
}
json!([item])
}
None => 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
/// (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
/// `squad.manager[].itemData` — an id reference alone installs nothing, because
/// the manager installer looks its id up in that same map and does nothing when
/// it misses.
/// a `squad.manager[]` element — which reaches this same deserializer the same
/// way, called directly on the array element with no `itemData` step. An id
/// 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
/// parse nothing, which leaves all five slots null. Every later consumer then
@@ -588,14 +605,13 @@ mod tests {
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!("expected Projected");
};
// The item must ride ALONG with the ref: a bare `{id, dream}` left the
// pre-match squad with no manager even though the assignment had been
// saved, because nothing in the response described the card.
// The element IS the item: the squad parser's manager branch calls the
// item parser on the array element itself, with no `itemData` step, so
// 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!(
v["manager"],
json!([{
"id": 100000427,
"itemData": {
"id": 100000427,
"resourceId": 1_000_509,
"cardsubtypeid": 4,
@@ -607,10 +623,20 @@ mod tests {
"itemState": "free",
"owners": 1,
"untradeable": 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.
assert_eq!(projected["custom"], oracle["custom"]);
// The manager REF round-trips; the item now rides with it. The capture this
// oracle came from carried a bare `{id, dream}`, but its manager was the
// dangling one every retail capture has, so it never showed that a populated
// ref renders on its own — and in practice it did not.
// The manager REF round-trips; the item now rides AT ELEMENT LEVEL. The
// capture this oracle came from carried a bare `{id, dream}`, but its
// manager was the dangling one every retail capture has, so it never showed
// 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!(
projected["manager"][0]["id"], oracle["manager"][0]["id"],
"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"],
oracle["manager"][0]["dream"]
);
let mgr_item = &projected["manager"][0]["itemData"];
assert_eq!(mgr_item["id"], oracle["manager"][0]["id"]);
let mgr_item = &projected["manager"][0];
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["resourceId"], 1_000_509);
assert_eq!(