Compare commits
30 Commits
2fc335c37d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e14d3cd063 | |||
| f4fc832ace | |||
| 6aab279244 | |||
| d3451be17b | |||
| 0300af3333 | |||
| 2c572e918f | |||
| f608dbc438 | |||
| 43c460741b | |||
| 5eed124b85 | |||
| e3ed8c298e | |||
| 5181e103dc | |||
| 433a9b22dd | |||
| 0701ac94e1 | |||
| 025122ec9a | |||
| b91e707a7e | |||
| c3d0e56f69 | |||
| e7893a0162 | |||
| a2b0c32a70 | |||
| e49c1f211c | |||
| 96f24e799a | |||
| f315f16e8e | |||
| ead0426ea0 | |||
| 74768693ec | |||
| 6bbc0eaf4f | |||
| 09bb2dc306 | |||
| 42229e5782 | |||
| d929efdffe | |||
| 98f30931a0 | |||
| a5e5628039 | |||
| 0e200758f0 |
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Classify call sites of the 130000/130001 provider stubs.
|
||||
|
||||
A call whose result is COMPARED implements a predicate ("is this the FUT custom
|
||||
club?"). Only a call whose result is STORED can assign a team id. This turns an
|
||||
unreadable 81-site list into the handful that could actually introduce 130000
|
||||
into a struct.
|
||||
|
||||
classify_calls.py <asmfile> <target_va_hex> [more_targets...]
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
|
||||
asm = sys.argv[1]
|
||||
targets = [t.lower().lstrip("0x") for t in sys.argv[2:]]
|
||||
|
||||
lines = []
|
||||
for l in open(asm, errors="replace"):
|
||||
m = re.match(r"\s*([0-9a-f]+):\s+((?:[0-9a-f]{2} )+)\s*(.*)", l)
|
||||
if m:
|
||||
lines.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
idx = {a: i for i, (a, _t) in enumerate(lines)}
|
||||
|
||||
STORE = re.compile(r"^mov\s+(?:DWORD PTR |QWORD PTR )?\[[^\]]+\],(eax|rax)\b")
|
||||
CMP = re.compile(r"^(cmp|sub|test)\b.*\b(eax|rax)\b")
|
||||
MOVREG = re.compile(r"^mov\s+(e[a-z]{2}|r\d+d|r[a-z]{2}),(eax|rax)\b")
|
||||
|
||||
for tgt in targets:
|
||||
print(f"\n ===== callers of 0x{tgt} =====")
|
||||
stores, cmps, other = [], [], []
|
||||
for i, (a, txt) in enumerate(lines):
|
||||
if not txt.startswith("call") or tgt not in txt:
|
||||
continue
|
||||
# look at the next few instructions for the fate of eax
|
||||
window = [lines[j][1] for j in range(i + 1, min(i + 7, len(lines)))]
|
||||
verdict, detail = "other", window[0] if window else ""
|
||||
for w in window:
|
||||
if STORE.match(w):
|
||||
verdict, detail = "STORE", w
|
||||
break
|
||||
if CMP.match(w):
|
||||
verdict, detail = "compare", w
|
||||
break
|
||||
if MOVREG.match(w):
|
||||
verdict, detail = "movreg", w
|
||||
break
|
||||
rec = (a, detail)
|
||||
(stores if verdict == "STORE" else cmps if verdict == "compare" else other).append(rec)
|
||||
print(f" STORE (can assign) : {len(stores)}")
|
||||
for a, d in stores:
|
||||
print(f" 0x{a:x} {d}")
|
||||
print(f" compare (predicate) : {len(cmps)}")
|
||||
print(f" other/moved to reg : {len(other)}")
|
||||
for a, d in other[:14]:
|
||||
print(f" 0x{a:x} {d}")
|
||||
Executable
+332
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17 Screen event 0x30 through command 0x128 and ScenarioModeStart.
|
||||
|
||||
The generated GDB program uses hardware breakpoints, only reads registers and
|
||||
client memory, logs, and continues. Seven breakpoints are rotated so no more
|
||||
than four are enabled. It never calls client functions, writes client memory,
|
||||
emits events, or drives input.
|
||||
|
||||
command_128_trace.py [pid] [--output PATH]
|
||||
command_128_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
MANAGER_SELECT_ACTION_SOURCE_RVA = 0x0705A620
|
||||
MANAGER_SELECT_ACTION_RESULT_RVA = 0x07CDC4A6
|
||||
SCREEN_EVENT_CHANNEL_ROUTER_RVA = 0x080CE230
|
||||
SCREEN_EVENT_DISPATCH_RVA = 0x080CF790
|
||||
SKILL_INSTRUCTIONS_SCREEN_RVA = 0x07DCA400
|
||||
GAMEPLAY_COMMAND_DISPATCH_RVA = 0x07A8F6C0
|
||||
FREE_ROAM_COMMAND_128_RVA = 0x07A92B0F
|
||||
SCENARIO_SCHEDULER_RVA = 0x07AC3A40
|
||||
SCENARIO_MANAGER_START_RVA = 0x07B1C2B0
|
||||
MODE_ZERO_SCENARIO_START_RVA = 0x07B1C190
|
||||
GAMEPLAY_GLOBAL_RVA = 0x04BFB910
|
||||
SCREEN_VTABLE_RVA = 0x03B3ECC0
|
||||
FREE_ROAM_VTABLE_RVA = 0x03AEDF58
|
||||
MODE_ZERO_CHILD_VTABLE_RVA = 0x03AE9C00
|
||||
|
||||
|
||||
def addresses(base: int) -> dict[str, int]:
|
||||
return {
|
||||
"manager_select_source": base + MANAGER_SELECT_ACTION_SOURCE_RVA,
|
||||
"manager_select_action": base + MANAGER_SELECT_ACTION_RESULT_RVA,
|
||||
"screen_event_router": base + SCREEN_EVENT_CHANNEL_ROUTER_RVA,
|
||||
"screen_event_dispatch": base + SCREEN_EVENT_DISPATCH_RVA,
|
||||
"instructions_screen": base + SKILL_INSTRUCTIONS_SCREEN_RVA,
|
||||
"command_dispatch": base + GAMEPLAY_COMMAND_DISPATCH_RVA,
|
||||
"free_roam_case": base + FREE_ROAM_COMMAND_128_RVA,
|
||||
"scheduler": base + SCENARIO_SCHEDULER_RVA,
|
||||
"manager_start": base + SCENARIO_MANAGER_START_RVA,
|
||||
"scenario_start": base + MODE_ZERO_SCENARIO_START_RVA,
|
||||
"gameplay_global": base + GAMEPLAY_GLOBAL_RVA,
|
||||
"screen_vtable": base + SCREEN_VTABLE_RVA,
|
||||
"free_roam_vtable": base + FREE_ROAM_VTABLE_RVA,
|
||||
"mode_zero_child_vtable": base + MODE_ZERO_CHILD_VTABLE_RVA,
|
||||
}
|
||||
|
||||
|
||||
def gdb_prelude(pid: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted off
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
"""
|
||||
|
||||
|
||||
def build_script(pid: int, fifa_base: int, output: str) -> str:
|
||||
address = addresses(fifa_base)
|
||||
return (
|
||||
gdb_prelude(pid, output)
|
||||
+ f"""define snapshot_gameplay
|
||||
set $snap_gameplay_global = *(void**)0x{address['gameplay_global']:x}
|
||||
set $snap_listener_manager = 0
|
||||
set $snap_listener_table = 0
|
||||
set $snap_listener_index = -1
|
||||
set $snap_free_roam = 0
|
||||
set $snap_free_state = -1
|
||||
set $snap_free_111 = -1
|
||||
set $snap_free_112 = -1
|
||||
set $snap_free_124 = -1
|
||||
set $snap_selected = 0
|
||||
set $snap_selected_vtable = 0
|
||||
set $snap_selected_mode = -1
|
||||
if $snap_gameplay_global != 0
|
||||
set $snap_listener_manager = *(void**)($snap_gameplay_global+0x58)
|
||||
end
|
||||
if $snap_listener_manager != 0
|
||||
set $snap_listener_table = *(void**)$snap_listener_manager
|
||||
end
|
||||
if $snap_listener_table != 0
|
||||
set $snap_free_roam = *(void**)$snap_listener_table
|
||||
set $snap_listener_index = *(int*)($snap_listener_table+0x20)
|
||||
if $snap_listener_index >= 0 && $snap_listener_index < 3
|
||||
set $snap_selected = *(void**)($snap_listener_table+$snap_listener_index*8)
|
||||
end
|
||||
end
|
||||
if $snap_free_roam != 0
|
||||
set $snap_free_state = *(int*)($snap_free_roam+0x30)
|
||||
set $snap_free_111 = *(unsigned char*)($snap_free_roam+0x111)
|
||||
set $snap_free_112 = *(unsigned char*)($snap_free_roam+0x112)
|
||||
set $snap_free_124 = *(int*)($snap_free_roam+0x124)
|
||||
end
|
||||
if $snap_selected != 0
|
||||
set $snap_selected_vtable = *(void**)$snap_selected
|
||||
set $snap_selected_mode = *(int*)($snap_selected+0x18)
|
||||
end
|
||||
end
|
||||
|
||||
set $action_count = 0
|
||||
hbreak *0x{address['manager_select_action']:x}
|
||||
commands
|
||||
silent
|
||||
set $action_count = $action_count+1
|
||||
set $provider = $rbx
|
||||
snapshot_gameplay
|
||||
if $action_count <= 128
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d MANAGER_SELECT_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d ordinal=%d instruction=%p caller_return=%p provider=%p provider_vtable=%p action_id=%#x free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $action_count, $pc, *(void**)($rsp+0x58), $provider, *(void**)$provider, $eax, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
end
|
||||
if $eax == 0x30
|
||||
bt 16
|
||||
end
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['instructions_screen']:x}
|
||||
condition 2 $edx == 0x30 && *(void**)$rcx == 0x{address['screen_vtable']:x}
|
||||
commands
|
||||
silent
|
||||
set $screen = $rcx
|
||||
set $screen_owner = *(void**)($screen+0x140)
|
||||
set $screen_owner_vtable = 0
|
||||
if $screen_owner != 0
|
||||
set $screen_owner_vtable = *(void**)$screen_owner
|
||||
end
|
||||
snapshot_gameplay
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d INSTRUCTIONS_SCREEN_EVENT_30" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d handler=%p caller_return=%p screen=%p screen_vtable=%p event=%#x payload=%p allow_advance138=%d owner140=%p owner_vtable=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $screen, *(void**)$screen, $edx, $r8, *(int*)($screen+0x138), $screen_owner, $screen_owner_vtable, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
bt 16
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['command_dispatch']:x}
|
||||
condition 3 $edx == 0x128
|
||||
commands
|
||||
silent
|
||||
set $command_dispatcher = $rcx
|
||||
set $command_table = *(void**)$command_dispatcher
|
||||
snapshot_gameplay
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d GAMEPLAY_COMMAND_128" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p dispatcher=%p command=%#x payload=%p arg_r9=%p table=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $command_dispatcher, $edx, $r8, $r9, $command_table, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 1
|
||||
disable 2
|
||||
disable 3
|
||||
enable 5
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['free_roam_case']:x}
|
||||
commands
|
||||
silent
|
||||
set $owner = $rbx
|
||||
snapshot_gameplay
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d FREE_ROAM_COMMAND_128" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d callsite=%p caller_return=%p owner=%p owner_vtable=%p command=%#x payload=%p state=%d previous=%d free111=%d free112=%d free124=%d manager=%p selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $owner, *(void**)$owner, $esi, $rdi, *(int*)($owner+0x30), *(int*)($owner+0x34), *(unsigned char*)($owner+0x111), *(unsigned char*)($owner+0x112), *(int*)($owner+0x124), *(void**)($owner+0x168), $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['scheduler']:x}
|
||||
disable 5
|
||||
commands
|
||||
silent
|
||||
set $owner = $rcx
|
||||
set $manager = *(void**)($owner+0x168)
|
||||
set $manager_vtable = 0
|
||||
set $manager_mode = -1
|
||||
set $child = 0
|
||||
set $child_vtable = 0
|
||||
if $manager != 0
|
||||
set $manager_vtable = *(void**)$manager
|
||||
set $manager_mode = *(int*)($manager+0x50)
|
||||
set $child = *(void**)($manager+0x8)
|
||||
end
|
||||
if $child != 0
|
||||
set $child_vtable = *(void**)$child
|
||||
end
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d SCENARIO_SCHEDULER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p owner=%p owner_vtable=%p free124=%d command=%#x payload=%p manager=%p manager_vtable=%p manager_mode=%d child=%p child_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $owner, *(void**)$owner, *(int*)($owner+0x124), $edx, $r8, $manager, $manager_vtable, $manager_mode, $child, $child_vtable
|
||||
disable 4
|
||||
disable 5
|
||||
enable 6
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['manager_start']:x}
|
||||
disable 6
|
||||
commands
|
||||
silent
|
||||
set $manager = $rcx
|
||||
set $child = *(void**)($manager+0x8)
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d SCENARIO_MANAGER_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p manager=%p manager_vtable=%p requested_countdown=%d mode=%d child=%p child_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $manager, *(void**)$manager, $rdx & 0xff, *(int*)($manager+0x50), $child, $child ? *(void**)$child : 0
|
||||
disable 6
|
||||
enable 7
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['scenario_start']:x}
|
||||
disable 7
|
||||
commands
|
||||
silent
|
||||
set $ctx = $rcx
|
||||
snapshot_gameplay
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d MODE_ZERO_SCENARIO_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p descriptor=%p scenario_index=%d requested_countdown=%d flag40_before=%d callback_owner78=%p callback_vtable48=%p dispatcher_vtable80=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $rdx, $r8d, $r9 & 0xff, *(unsigned char*)($ctx+0x40), *(void**)($ctx+0x78), *(void**)($ctx+0x48), *(void**)($ctx+0x80), $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 7
|
||||
continue
|
||||
end
|
||||
|
||||
printf "COMMAND128 ARMED pid={pid} action_id=0x{address['manager_select_action']:x} screen_handler=0x{address['instructions_screen']:x} command_dispatch=0x{address['command_dispatch']:x} free_roam=0x{address['free_roam_case']:x} scheduler=0x{address['scheduler']:x} manager=0x{address['manager_start']:x} scenario=0x{address['scenario_start']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def effective_environment(pid: int) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for item in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0"):
|
||||
if not item.startswith(b"OPENFUT_FIFA17_"):
|
||||
continue
|
||||
key, _, value = item.decode("utf-8", errors="replace").partition("=")
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = addresses(0x140000000)
|
||||
script = build_script(1234, 0x140000000, "/tmp/command-128.log")
|
||||
assert address["manager_select_source"] == 0x14705A620
|
||||
assert address["manager_select_action"] == 0x147CDC4A6
|
||||
assert address["instructions_screen"] == 0x147DCA400
|
||||
assert address["command_dispatch"] == 0x147A8F6C0
|
||||
assert address["free_roam_case"] == 0x147A92B0F
|
||||
assert address["scheduler"] == 0x147AC3A40
|
||||
assert address["manager_start"] == 0x147B1C2B0
|
||||
assert address["scenario_start"] == 0x147B1C190
|
||||
assert script.count("hbreak *") == 7
|
||||
assert "set $action_count = 0" in script
|
||||
assert "MANAGER_SELECT_ACTION" in script
|
||||
assert "condition 2 $edx == 0x30" in script
|
||||
assert "condition 3 $edx == 0x128" in script
|
||||
assert "disable 4" in script
|
||||
assert "disable 5" in script and "enable 5" in script
|
||||
assert "disable 6" in script and "enable 6" in script
|
||||
assert "disable 7" in script and "enable 7" in script
|
||||
assert "set *(" not in script
|
||||
print("command_128_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(
|
||||
fifa_path,
|
||||
advance.PINNED_FIFA_SHA256,
|
||||
advance.FIFA_MODULE,
|
||||
)
|
||||
cards_base = 0
|
||||
cards_path = "<not-loaded>"
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
transition.validate_cards(cards_path)
|
||||
output = args.output or f"/tmp/fifa17-command-128-{pid}.log"
|
||||
script = build_script(pid, fifa_base, output)
|
||||
environment = effective_environment(pid)
|
||||
print(
|
||||
"COMMAND128 PREPARED "
|
||||
f"pid={pid} fifa_base={fifa_base:#x} cards_base={cards_base:#x} "
|
||||
f"cards_path={cards_path} "
|
||||
f"team_compat={environment.get('OPENFUT_FIFA17_SEASON_TEAM_COMPAT', '<absent>')} "
|
||||
f"pma_fix={environment.get('OPENFUT_FIFA17_OFFLINE_SEASONS_PMA_FIX', '<absent>')}"
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-command-128-{pid}.gdb"
|
||||
Path(script_path).write_text(script, encoding="utf-8")
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Hardware-only trace of the engine-local overwrite wrapper entry.
|
||||
|
||||
Breaks before the prologue of FUN_147ce47e0, where [rsp] is the exact direct
|
||||
caller return address and R8D is the team ID later written to the final match
|
||||
record. This closes the one frame Wine PE unwinding could not recover.
|
||||
|
||||
No INT3/software breakpoints. No client memory writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
WRAPPER_VA = 0x147CE47E0
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _u64(address: int) -> int | None:
|
||||
data = _read(address, 8)
|
||||
return struct.unpack("<Q", data)[0] if data else None
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _registers() -> dict:
|
||||
names = (
|
||||
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||
)
|
||||
return {name: _reg(name) for name in names}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
self.index = 0
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.index += 1
|
||||
thread = _thread()
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.index,
|
||||
"time_unix": time.time(),
|
||||
"thread": thread,
|
||||
**payload,
|
||||
}
|
||||
with open(self.path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
|
||||
class WrapperBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State):
|
||||
self.state = state
|
||||
super().__init__(
|
||||
f"*0x{WRAPPER_VA:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
stack = _reg("rsp")
|
||||
caller_return = _u64(stack)
|
||||
self.state.log(
|
||||
"engine_overwrite_wrapper_entry",
|
||||
wrapper_va=WRAPPER_VA,
|
||||
caller_return_address=caller_return,
|
||||
source_team_id=_reg("r8") & 0xFFFFFFFF,
|
||||
side_argument=_reg("rdx") & 0xFFFFFFFF,
|
||||
registers=_registers(),
|
||||
caller_disassembly=(
|
||||
gdb.execute(f"x/12i 0x{caller_return - 32:x}", to_string=True)
|
||||
if caller_return else None
|
||||
),
|
||||
backtrace=gdb.execute("bt 32", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error", where="engine_overwrite_wrapper", error=str(exc),
|
||||
traceback=traceback.format_exc()
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, _cards_base: int):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path)
|
||||
breakpoint = WrapperBreakpoint(_STATE)
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints={"engine_overwrite_wrapper": {"number": breakpoint.number, "va": WRAPPER_VA}},
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""GDB payload for the LIVE-PROVEN engine match-team +0x14 writer.
|
||||
|
||||
READ-ONLY hardware debug only:
|
||||
|
||||
0x147c652ce mov dword [rdx + rcx + 0x44], r8d
|
||||
|
||||
At the first team-like source value, derives both fixed-stride record fields
|
||||
from live RCX and arms 4-byte WRITE watchpoints on:
|
||||
|
||||
teamId A = rcx + 0x44
|
||||
teamId B = rcx + 0x44 + 0x45c
|
||||
|
||||
The execute breakpoint records the intended source value before every call. The
|
||||
watchpoints then capture both the expected write and any later overwrite, even
|
||||
if the overwrite comes from a different function.
|
||||
|
||||
No INT3/software breakpoints. No client memory writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
WRITER_VA = 0x147C652CE
|
||||
POST_WRITER_VA = 0x147C652D3
|
||||
SIDE_STRIDE = 0x45C
|
||||
TEAM_FIELD_OFF = 0x44
|
||||
RECORD_FIELD_OFF = 0x14
|
||||
TEAM_LIKE = {73, 240, 241, 243, 130000, 130001}
|
||||
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _i32(address: int) -> int | None:
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<i", data)[0] if data else None
|
||||
|
||||
|
||||
def _registers() -> dict:
|
||||
names = (
|
||||
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||
)
|
||||
return {name: _reg(name) for name in names}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, log_path: str):
|
||||
self.log_path = log_path
|
||||
self.event_index = 0
|
||||
self.engine_base = None
|
||||
self.watch_a = None
|
||||
self.watch_b = None
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.event_index += 1
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.event_index,
|
||||
"time_unix": time.time(),
|
||||
"thread": _thread(),
|
||||
**payload,
|
||||
}
|
||||
with open(self.log_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
def arm_fields(self, engine_base: int):
|
||||
if self.engine_base == engine_base and self.watch_a and self.watch_b:
|
||||
return
|
||||
for watchpoint in (self.watch_a, self.watch_b):
|
||||
if watchpoint is not None:
|
||||
try:
|
||||
watchpoint.delete()
|
||||
except gdb.error:
|
||||
pass
|
||||
self.engine_base = engine_base
|
||||
self.watch_a = TeamFieldWatchpoint(self, 0, engine_base + TEAM_FIELD_OFF)
|
||||
self.watch_b = TeamFieldWatchpoint(
|
||||
self, 1, engine_base + TEAM_FIELD_OFF + SIDE_STRIDE
|
||||
)
|
||||
self.log(
|
||||
"team_field_watchpoints_armed",
|
||||
engine_base=engine_base,
|
||||
team_id_a_address=self.watch_a.address,
|
||||
team_id_b_address=self.watch_b.address,
|
||||
watchpoint_a=self.watch_a.number,
|
||||
watchpoint_b=self.watch_b.number,
|
||||
)
|
||||
|
||||
|
||||
class TeamFieldWatchpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, side: int, address: int):
|
||||
self.state = state
|
||||
self.side = side
|
||||
self.address = address
|
||||
super().__init__(
|
||||
f"*(int*)0x{address:x}",
|
||||
type=gdb.BP_WATCHPOINT,
|
||||
wp_class=gdb.WP_WRITE,
|
||||
internal=False,
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
pc = _reg("rip")
|
||||
writer = WRITER_VA if pc == POST_WRITER_VA else None
|
||||
record_start = self.address - RECORD_FIELD_OFF
|
||||
record = _read(record_start, 0x7C)
|
||||
self.state.log(
|
||||
"final_team_field_write_post",
|
||||
side=self.side,
|
||||
watch_address=self.address,
|
||||
value=_i32(self.address),
|
||||
stopped_pc=pc,
|
||||
writer_va=writer,
|
||||
record_start=record_start,
|
||||
record_hex=record.hex() if record else None,
|
||||
registers=_registers(),
|
||||
disassembly=gdb.execute("x/12i $pc-32", to_string=True),
|
||||
backtrace=gdb.execute("bt 32", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error",
|
||||
where="team_field_watchpoint",
|
||||
error=str(exc),
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class FinalWriterBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State):
|
||||
self.state = state
|
||||
super().__init__(
|
||||
f"*0x{WRITER_VA:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
engine_base = _reg("rcx")
|
||||
side_offset = _reg("rdx")
|
||||
source_value = _reg("r8") & 0xFFFFFFFF
|
||||
if source_value in TEAM_LIKE:
|
||||
self.state.arm_fields(engine_base)
|
||||
destination = engine_base + side_offset + TEAM_FIELD_OFF
|
||||
side = side_offset // SIDE_STRIDE if side_offset in (0, SIDE_STRIDE) else None
|
||||
self.state.log(
|
||||
"final_writer_pre",
|
||||
instruction_va=WRITER_VA,
|
||||
engine_base=engine_base,
|
||||
side_offset=side_offset,
|
||||
side=side,
|
||||
destination=destination,
|
||||
record_start=destination - RECORD_FIELD_OFF,
|
||||
source_register="r8d",
|
||||
source_value=source_value,
|
||||
prior_value=_i32(destination),
|
||||
team_id_a_address=engine_base + TEAM_FIELD_OFF,
|
||||
team_id_b_address=engine_base + TEAM_FIELD_OFF + SIDE_STRIDE,
|
||||
team_id_a_before=_i32(engine_base + TEAM_FIELD_OFF),
|
||||
team_id_b_before=_i32(engine_base + TEAM_FIELD_OFF + SIDE_STRIDE),
|
||||
registers=_registers(),
|
||||
disassembly=gdb.execute("x/6i $pc", to_string=True),
|
||||
backtrace=gdb.execute("bt 32", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error",
|
||||
where="final_writer",
|
||||
error=str(exc),
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, _cards_base: int):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path)
|
||||
writer = FinalWriterBreakpoint(_STATE)
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints={
|
||||
"final_writer": {"number": writer.number, "va": WRITER_VA},
|
||||
},
|
||||
side_stride=SIDE_STRIDE,
|
||||
team_field_offset=TEAM_FIELD_OFF,
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Hardware-only origin trace for the exact SetTeam team context.
|
||||
|
||||
Matches the typed integer context pointer selected by SetTeam to the constructor
|
||||
invocation that produced it. No client memory writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
CONTEXT_REUSE = 0x1477C17FC
|
||||
CONTEXT_ALLOCATED = 0x1477C18C1
|
||||
SET_TEAM_STUB = 0x147060A80
|
||||
LOCKED_SETTER_RETURN = 0x1477C2415
|
||||
CONTEXT_STACK_COUNT = 0x144BCEDA0
|
||||
CONTEXT_STACK_ARRAY = 0x144BCEDA8
|
||||
INTERESTING = {73, 130000, 130001}
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name):
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _read(address, size):
|
||||
if not address or address < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _u64(address):
|
||||
data = _read(address, 8)
|
||||
return struct.unpack("<Q", data)[0] if data else None
|
||||
|
||||
|
||||
def _i32(address):
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<i", data)[0] if data else None
|
||||
|
||||
|
||||
def _thread():
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.index = 0
|
||||
self.total_constructor_hits = 0
|
||||
self.interesting_constructor_hits = 0
|
||||
self.pending_allocations = {}
|
||||
self.origins = deque(maxlen=4096)
|
||||
|
||||
def log(self, kind, **payload):
|
||||
self.index += 1
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.index,
|
||||
"time_unix": time.time(),
|
||||
"thread": _thread(),
|
||||
**payload,
|
||||
}
|
||||
with open(self.path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
def thread_key(self):
|
||||
return tuple(_thread().get("ptid", ()))
|
||||
|
||||
def remember_origin(self, context, origin):
|
||||
if context:
|
||||
self.origins.append({**origin, "context": context})
|
||||
|
||||
def find_origin(self, context):
|
||||
return next((origin for origin in reversed(self.origins)
|
||||
if origin["context"] == context), None)
|
||||
|
||||
|
||||
class HardwareBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state, address):
|
||||
self.state = state
|
||||
self.address = address
|
||||
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||
self.silent = True
|
||||
|
||||
|
||||
class ContextReuseBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
self.state.total_constructor_hits += 1
|
||||
try:
|
||||
value = _reg("rcx") & 0xFFFFFFFF
|
||||
if value not in INTERESTING:
|
||||
return False
|
||||
self.state.interesting_constructor_hits += 1
|
||||
rsp = _reg("rsp")
|
||||
direct_return = _u64(rsp + 0x28)
|
||||
origin = {
|
||||
"value": value,
|
||||
"direct_return_address": direct_return,
|
||||
"upstream_return_address": (
|
||||
_u64(rsp + 0x68)
|
||||
if direct_return == LOCKED_SETTER_RETURN
|
||||
else direct_return
|
||||
),
|
||||
"constructor_stack_hex": (_read(rsp, 0x100) or b"").hex(),
|
||||
"constructor_hit": self.state.total_constructor_hits,
|
||||
}
|
||||
context = _reg("rax")
|
||||
if context:
|
||||
self.state.remember_origin(context, origin)
|
||||
else:
|
||||
self.state.pending_allocations[self.state.thread_key()] = origin
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error",
|
||||
where="context_reuse",
|
||||
error=str(exc),
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class ContextAllocatedBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
origin = self.state.pending_allocations.pop(self.state.thread_key(), None)
|
||||
if origin is not None:
|
||||
self.state.remember_origin(_reg("rdx"), origin)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error",
|
||||
where="context_allocated",
|
||||
error=str(exc),
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class SetTeamStubBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
count = _i32(CONTEXT_STACK_COUNT)
|
||||
array = _u64(CONTEXT_STACK_ARRAY)
|
||||
team_context = (
|
||||
_u64(array + (count - 2) * 8)
|
||||
if array and count is not None and count >= 2
|
||||
else None
|
||||
)
|
||||
side_context = (
|
||||
_u64(array + (count - 1) * 8)
|
||||
if array and count is not None and count >= 1
|
||||
else None
|
||||
)
|
||||
rsp = _reg("rsp")
|
||||
self.state.log(
|
||||
"set_team_stub_entry",
|
||||
context_stack_count=count,
|
||||
team_context=team_context,
|
||||
team_context_hex=(_read(team_context, 0x40) or b"").hex(),
|
||||
team_value=_i32(team_context + 0x10) if team_context else None,
|
||||
side_context=side_context,
|
||||
side_value=_i32(side_context + 0x10) if side_context else None,
|
||||
matched_origin=self.state.find_origin(team_context),
|
||||
caller_return_address=_u64(rsp),
|
||||
entry_registers={
|
||||
name: _reg(name)
|
||||
for name in ("rcx", "rdx", "r8", "r9")
|
||||
},
|
||||
backtrace=gdb.execute("bt 32", to_string=True),
|
||||
total_constructor_hits=self.state.total_constructor_hits,
|
||||
interesting_constructor_hits=self.state.interesting_constructor_hits,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error",
|
||||
where="set_team_stub",
|
||||
error=str(exc),
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log(
|
||||
"inferior_exited",
|
||||
detail=str(event),
|
||||
total_constructor_hits=_STATE.total_constructor_hits,
|
||||
interesting_constructor_hits=_STATE.interesting_constructor_hits,
|
||||
)
|
||||
|
||||
|
||||
def start_trace(log_path, _cards_base):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path)
|
||||
points = {
|
||||
"context_reuse": ContextReuseBreakpoint(_STATE, CONTEXT_REUSE),
|
||||
"context_allocated": ContextAllocatedBreakpoint(_STATE, CONTEXT_ALLOCATED),
|
||||
"set_team_stub": SetTeamStubBreakpoint(_STATE, SET_TEAM_STUB),
|
||||
}
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints={
|
||||
name: {"number": point.number, "va": point.address}
|
||||
for name, point in points.items()
|
||||
},
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
matching="exact_context_pointer",
|
||||
)
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Hardware-only trace of engine game-setup context selection.
|
||||
|
||||
Captures the function that requests team/side, selector indices 1/0, selected
|
||||
transient context objects, and the typed value getter. No client writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
DISPATCH = 0x147060D00
|
||||
SELECT_VALUE = 0x147572C50
|
||||
CONTEXT_SELECTED = 0x1477C845D
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _u64(address: int) -> int | None:
|
||||
data = _read(address, 8)
|
||||
return struct.unpack("<Q", data)[0] if data else None
|
||||
|
||||
|
||||
def _i32(address: int) -> int | None:
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<i", data)[0] if data else None
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _printable_pointers(address: int, data: bytes) -> dict:
|
||||
found = {}
|
||||
for offset in range(0, len(data) - 7, 8):
|
||||
pointer = struct.unpack_from("<Q", data, offset)[0]
|
||||
raw = _read(pointer, 128)
|
||||
if not raw:
|
||||
continue
|
||||
value = raw.split(b"\0", 1)[0]
|
||||
try:
|
||||
text = value.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if len(text) >= 3 and all(char.isprintable() for char in text):
|
||||
found[hex(offset)] = {"pointer": pointer, "text": text}
|
||||
return found
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
self.index = 0
|
||||
self.requested_indices = {}
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.index += 1
|
||||
event = {"event": kind, "event_index": self.index, "time_unix": time.time(),
|
||||
"thread": _thread(), **payload}
|
||||
with open(self.path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush(); os.fsync(handle.fileno())
|
||||
|
||||
def key(self):
|
||||
return tuple(_thread().get("ptid", ()))
|
||||
|
||||
|
||||
class HardwareBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, address: int):
|
||||
self.state = state
|
||||
self.address = address
|
||||
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||
self.silent = True
|
||||
|
||||
|
||||
class DispatchBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
rsp = _reg("rsp")
|
||||
caller = _u64(rsp)
|
||||
self.state.log(
|
||||
"game_setup_dispatch_entry",
|
||||
caller_return_address=caller,
|
||||
caller_disassembly=(gdb.execute(f"x/12i 0x{caller-32:x}", to_string=True)
|
||||
if caller else None),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="dispatch", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class SelectValueBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
index = _reg("rcx") & 0xFFFFFFFF
|
||||
self.state.requested_indices[self.state.key()] = index
|
||||
self.state.log("context_value_request", index=index)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="select_value", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class ContextSelectedBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
index = _reg("rdi") & 0xFFFFFFFF
|
||||
context = _reg("rbx")
|
||||
data = _read(context, 0x80) or b""
|
||||
self.state.log(
|
||||
"context_selected",
|
||||
requested_index=self.state.requested_indices.get(self.state.key()),
|
||||
selector_index=index,
|
||||
context=context,
|
||||
type_flags=_i32(context + 8),
|
||||
value_i32=_i32(context + 0x10),
|
||||
value_qword=_u64(context + 0x10),
|
||||
context_hex=data.hex(),
|
||||
printable_pointers=_printable_pointers(context, data),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="context_selected", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, _cards_base: int):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path)
|
||||
points = {
|
||||
"dispatch": DispatchBreakpoint(_STATE, DISPATCH),
|
||||
"select_value": SelectValueBreakpoint(_STATE, SELECT_VALUE),
|
||||
"context_selected": ContextSelectedBreakpoint(_STATE, CONTEXT_SELECTED),
|
||||
}
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints={name: {"number": bp.number, "va": bp.address} for name, bp in points.items()},
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Hardware-only trace of CardsGameSetupAdapter query 13 and overwrite input.
|
||||
|
||||
Breakpoints:
|
||||
|
||||
FUN_180031340 entry incoming teamId/side/context
|
||||
0x18003148f pre-call query id, selector, output/count pointers
|
||||
0x180031495 post-call complete 48-byte records and count
|
||||
0x180031861 submit original incoming teamId sent to engine
|
||||
|
||||
This proves whether query 13 influences the overwrite. No INT3/software
|
||||
breakpoints, client writes, or game input.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
CARDS_IMAGE_BASE = 0x180000000
|
||||
ENTRY = 0x180031340
|
||||
QUERY_PRE = 0x18003148F
|
||||
QUERY_POST = 0x180031495
|
||||
SUBMIT = 0x180031861
|
||||
MAX_RECORDS = 100
|
||||
RECORD_SIZE = 48
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0 or size < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _u64(address: int) -> int | None:
|
||||
data = _read(address, 8)
|
||||
return struct.unpack("<Q", data)[0] if data else None
|
||||
|
||||
|
||||
def _i32(address: int) -> int | None:
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<i", data)[0] if data else None
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _registers() -> dict:
|
||||
names = (
|
||||
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||
)
|
||||
return {name: _reg(name) for name in names}
|
||||
|
||||
|
||||
def _printable_pointer(pointer: int) -> str | None:
|
||||
data = _read(pointer, 96)
|
||||
if not data:
|
||||
return None
|
||||
raw = data.split(b"\0", 1)[0]
|
||||
if len(raw) < 3:
|
||||
return None
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
return text if all(char.isprintable() for char in text) else None
|
||||
|
||||
|
||||
def _decode_record(data: bytes, address: int) -> dict:
|
||||
words = list(struct.unpack("<12i", data))
|
||||
qwords = list(struct.unpack("<6Q", data))
|
||||
strings = {}
|
||||
for index, pointer in enumerate(qwords):
|
||||
text = _printable_pointer(pointer)
|
||||
if text:
|
||||
strings[f"qword_{index}"] = {"pointer": pointer, "text": text}
|
||||
interesting = {
|
||||
str(value): [index * 4 for index, word in enumerate(words) if word == value]
|
||||
for value in (73, 240, 241, 243, 130000, 130001)
|
||||
if value in words
|
||||
}
|
||||
return {
|
||||
"address": address,
|
||||
"hex": data.hex(),
|
||||
"i32": words,
|
||||
"u32": [value & 0xFFFFFFFF for value in words],
|
||||
"f32": list(struct.unpack("<12f", data)),
|
||||
"qwords": qwords,
|
||||
"strings": strings,
|
||||
"interesting_values": interesting,
|
||||
}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, path: str, cards_base: int):
|
||||
self.path = path
|
||||
self.cards_base = cards_base
|
||||
self.index = 0
|
||||
self.calls = {}
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.index += 1
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.index,
|
||||
"time_unix": time.time(),
|
||||
"thread": _thread(),
|
||||
**payload,
|
||||
}
|
||||
with open(self.path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
def thread_key(self):
|
||||
return tuple(_thread().get("ptid", ()))
|
||||
|
||||
|
||||
class HardwareBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, image_va: int):
|
||||
self.state = state
|
||||
self.image_va = image_va
|
||||
address = state.cards_base + (image_va - CARDS_IMAGE_BASE)
|
||||
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||
self.silent = True
|
||||
|
||||
|
||||
class EntryBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
self.state.log(
|
||||
"game_setup_entry",
|
||||
incoming_context=_reg("rcx"),
|
||||
incoming_side=_reg("rdx") & 0xFFFFFFFF,
|
||||
incoming_team_id=_reg("r8") & 0xFFFFFFFF,
|
||||
incoming_r9=_reg("r9"),
|
||||
registers=_registers(),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="entry", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class QueryPreBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
rsp = _reg("rsp")
|
||||
adapter = _reg("rcx")
|
||||
vtable = _u64(adapter)
|
||||
count_pointer = _u64(rsp + 0x20)
|
||||
state = {
|
||||
"adapter": adapter,
|
||||
"adapter_vtable": vtable,
|
||||
"query_target": _u64(vtable + 0xE0) if vtable else None,
|
||||
"query_id": _reg("rdx") & 0xFFFFFFFF,
|
||||
"selector": _reg("r8") & 0xFFFFFFFF,
|
||||
"output_buffer": _reg("r9"),
|
||||
"count_pointer": count_pointer,
|
||||
"sixth_argument": _u64(rsp + 0x28),
|
||||
"count_before": _i32(count_pointer) if count_pointer else None,
|
||||
"saved_incoming_team_id": _i32(rsp + 0x34),
|
||||
"saved_side": _i32(rsp + 0x50),
|
||||
"saved_engine_context": _u64(rsp + 0x68),
|
||||
"adapter_prefix_hex": (_read(adapter, 0x100) or b"").hex(),
|
||||
}
|
||||
self.state.calls[self.state.thread_key()] = state
|
||||
self.state.log(
|
||||
"query13_pre",
|
||||
**state,
|
||||
registers=_registers(),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="query_pre", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class QueryPostBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
state = self.state.calls.get(self.state.thread_key(), {})
|
||||
count_pointer = state.get("count_pointer")
|
||||
output = state.get("output_buffer")
|
||||
count = _i32(count_pointer) if count_pointer else None
|
||||
safe_count = min(max(count or 0, 0), MAX_RECORDS)
|
||||
records = []
|
||||
for index in range(safe_count):
|
||||
address = output + index * RECORD_SIZE
|
||||
data = _read(address, RECORD_SIZE)
|
||||
if data and len(data) == RECORD_SIZE:
|
||||
records.append(_decode_record(data, address))
|
||||
self.state.log(
|
||||
"query13_post",
|
||||
query_state=state,
|
||||
count_after=count,
|
||||
records=records,
|
||||
saved_incoming_team_id_after=_i32(_reg("rsp") + 0x34),
|
||||
saved_side_after=_i32(_reg("rsp") + 0x50),
|
||||
registers=_registers(),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="query_post", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class SubmitBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
rsp = _reg("rsp")
|
||||
self.state.log(
|
||||
"game_setup_submit",
|
||||
submitted_team_id=_reg("r8") & 0xFFFFFFFF,
|
||||
submitted_side=_reg("rdx") & 0xFFFFFFFF,
|
||||
engine_context=_reg("rcx"),
|
||||
saved_incoming_team_id=_i32(rsp + 0x34),
|
||||
saved_side=_i32(rsp + 0x50),
|
||||
registers=_registers(),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="submit", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, cards_base: int):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path, cards_base)
|
||||
points = {
|
||||
"entry": EntryBreakpoint(_STATE, ENTRY),
|
||||
"query_pre": QueryPreBreakpoint(_STATE, QUERY_PRE),
|
||||
"query_post": QueryPostBreakpoint(_STATE, QUERY_POST),
|
||||
"submit": SubmitBreakpoint(_STATE, SUBMIT),
|
||||
}
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints={name: {"number": bp.number, "image_va": bp.image_va} for name, bp in points.items()},
|
||||
record_size=RECORD_SIZE,
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
@@ -0,0 +1,388 @@
|
||||
"""GDB Python payload for read-only FIFA17 match-team writer tracing.
|
||||
|
||||
Loaded by trace_match_team_writer.py. Uses hardware execute breakpoints and a
|
||||
4-byte hardware WRITE watchpoint only; never inserts INT3 and never writes game
|
||||
memory.
|
||||
|
||||
Breakpoints (CardsDLL image VAs):
|
||||
|
||||
* FUN_1800fc500 entry -- derives output pair from RDX and arms *(int*)(rdx+4).
|
||||
* 0x1800fc595 -- pre-write opponent lookup into pair[1].
|
||||
* 0x1800fc5b8 -- mirrored pre-write opponent lookup into pair[0].
|
||||
|
||||
The dynamic watchpoint catches the exact write establishing pair[1], whether it
|
||||
is the opponent lookup at 0x1800fc595 or the own-club store at 0x1800fc5a0.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
CARDS_IMAGE_BASE = 0x180000000
|
||||
ENTRY_RVA = 0x0FC500
|
||||
LOOKUP_TO_TEAM1_RVA = 0x0FC595
|
||||
LOOKUP_TO_TEAM0_RVA = 0x0FC5B8
|
||||
TEAM1_POST_PC_TO_WRITER = {
|
||||
0x1800FC599: 0x1800FC595, # mov [r14+4],ecx
|
||||
0x1800FC5A4: 0x1800FC5A0, # mov [r14+4],eax
|
||||
}
|
||||
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0 or size < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _u8(address: int) -> int | None:
|
||||
data = _read(address, 1)
|
||||
return data[0] if data else None
|
||||
|
||||
|
||||
def _u32(address: int) -> int | None:
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<I", data)[0] if data else None
|
||||
|
||||
|
||||
def _i32(address: int) -> int | None:
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<i", data)[0] if data else None
|
||||
|
||||
|
||||
def _u64(address: int) -> int | None:
|
||||
data = _read(address, 8)
|
||||
return struct.unpack("<Q", data)[0] if data else None
|
||||
|
||||
|
||||
def _cstring(address: int, maximum: int = 256) -> str | None:
|
||||
data = _read(address, maximum)
|
||||
if not data:
|
||||
return None
|
||||
return data.split(b"\0", 1)[0].decode("utf-8", "replace")
|
||||
|
||||
|
||||
def _rtti_name(vtable: int, cards_base: int) -> str | None:
|
||||
"""MSVC x64 RTTI name from vtable[-1] CompleteObjectLocator.
|
||||
|
||||
PE RVAs in the locator are module-relative. Failure is evidence-free and is
|
||||
logged as null; no pointer is named from an offset coincidence.
|
||||
"""
|
||||
locator = _u64(vtable - 8) if vtable else None
|
||||
if not locator:
|
||||
return None
|
||||
raw = _read(locator, 24)
|
||||
if not raw:
|
||||
return None
|
||||
_signature, _offset, _cd_offset, type_rva, _hier_rva, self_rva = struct.unpack(
|
||||
"<IIIiii", raw
|
||||
)
|
||||
if not (0 <= type_rva < 0x10000000 and 0 <= self_rva < 0x10000000):
|
||||
return None
|
||||
image_base = locator - self_rva
|
||||
if abs(image_base - cards_base) > 0x100000:
|
||||
return None
|
||||
return _cstring(image_base + type_rva + 16)
|
||||
|
||||
|
||||
def _object(address: int, cards_base: int) -> dict:
|
||||
vtable = _u64(address) if address else None
|
||||
return {
|
||||
"address": address,
|
||||
"vtable": vtable,
|
||||
"vtable_image_va": (
|
||||
CARDS_IMAGE_BASE + (vtable - cards_base)
|
||||
if vtable and cards_base <= vtable < cards_base + 0x400000
|
||||
else None
|
||||
),
|
||||
"rtti": _rtti_name(vtable, cards_base) if vtable else None,
|
||||
}
|
||||
|
||||
|
||||
def _registers() -> dict:
|
||||
names = (
|
||||
"rax",
|
||||
"rbx",
|
||||
"rcx",
|
||||
"rdx",
|
||||
"rsi",
|
||||
"rdi",
|
||||
"rbp",
|
||||
"rsp",
|
||||
"r8",
|
||||
"r9",
|
||||
"r10",
|
||||
"r11",
|
||||
"r12",
|
||||
"r13",
|
||||
"r14",
|
||||
"r15",
|
||||
"rip",
|
||||
)
|
||||
return {name: _reg(name) for name in names}
|
||||
|
||||
|
||||
def _provenance(state, destination: int | None = None) -> dict:
|
||||
"""Recover the candidate's live input chain without naming the objects."""
|
||||
regs = _registers()
|
||||
context = regs["rbx"]
|
||||
output_pair = regs["r14"]
|
||||
obj = regs["rbp"]
|
||||
nested = _u64(obj + 0xB0) if obj else None
|
||||
field_2e8 = nested + 0x2E8 if nested else None
|
||||
source_base = _u64(field_2e8) if field_2e8 else None
|
||||
participant_holder = regs["r12"]
|
||||
participant = _u64(participant_holder) if participant_holder else None
|
||||
index_70 = _u8(participant + 0x70) if participant else None
|
||||
source_address = (
|
||||
source_base + index_70 * 16
|
||||
if source_base is not None and index_70 is not None
|
||||
else None
|
||||
)
|
||||
source_bytes = _read(source_address, 16) if source_address else None
|
||||
decoded = None
|
||||
if source_bytes and len(source_bytes) == 16:
|
||||
team_id, byte4, byte5, pad, word8, wordc = struct.unpack("<iBBHii", source_bytes)
|
||||
decoded = {
|
||||
"team_id": team_id,
|
||||
"byte_4": byte4,
|
||||
"byte_5": byte5,
|
||||
"pad_6": pad,
|
||||
"word_8": word8,
|
||||
"word_c": wordc,
|
||||
}
|
||||
pair_bytes = _read(output_pair, 8) if output_pair else None
|
||||
return {
|
||||
"destination": destination,
|
||||
"context": _object(context, state.cards_base),
|
||||
"entry_context": _object(state.current_entry.get("context", 0), state.cards_base),
|
||||
"output_pair": output_pair,
|
||||
"entry_output_pair": state.current_entry.get("output_pair"),
|
||||
"output_pair_bytes": pair_bytes.hex() if pair_bytes else None,
|
||||
"output_team_id_0": _i32(output_pair) if output_pair else None,
|
||||
"output_team_id_1": _i32(output_pair + 4) if output_pair else None,
|
||||
"obj": _object(obj, state.cards_base),
|
||||
"nested_at_obj_plus_b0": _object(nested or 0, state.cards_base),
|
||||
"field_plus_2e8_address": field_2e8,
|
||||
"source_array_base": source_base,
|
||||
"participant_holder": participant_holder,
|
||||
"participant": _object(participant or 0, state.cards_base),
|
||||
"participant_plus_70": index_70,
|
||||
"source_record_address": source_address,
|
||||
"source_record_hex": source_bytes.hex() if source_bytes else None,
|
||||
"source_record": decoded,
|
||||
"registers": regs,
|
||||
}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, log_path: str, cards_base: int):
|
||||
self.log_path = log_path
|
||||
self.cards_base = cards_base
|
||||
self.current_entry: dict = {}
|
||||
self.watchpoint = None
|
||||
self.event_index = 0
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.event_index += 1
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.event_index,
|
||||
"time_unix": time.time(),
|
||||
"thread": _thread(),
|
||||
**payload,
|
||||
}
|
||||
with open(self.log_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
|
||||
class Team1Watchpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, address: int):
|
||||
self.state = state
|
||||
self.address = address
|
||||
super().__init__(
|
||||
f"*(int*)0x{address:x}",
|
||||
type=gdb.BP_WATCHPOINT,
|
||||
wp_class=gdb.WP_WRITE,
|
||||
internal=False,
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
pc = _reg("rip")
|
||||
image_pc = CARDS_IMAGE_BASE + (pc - self.state.cards_base)
|
||||
writer = TEAM1_POST_PC_TO_WRITER.get(image_pc)
|
||||
source_value = None
|
||||
if writer == 0x1800FC595:
|
||||
source_value = _reg("rcx") & 0xFFFFFFFF
|
||||
elif writer == 0x1800FC5A0:
|
||||
source_value = _reg("rax") & 0xFFFFFFFF
|
||||
self.state.log(
|
||||
"team1_write_post",
|
||||
watch_address=self.address,
|
||||
value=_i32(self.address),
|
||||
stopped_pc=pc,
|
||||
stopped_image_va=image_pc,
|
||||
writer_image_va=writer,
|
||||
source_value=source_value,
|
||||
disassembly=gdb.execute("x/10i $pc-32", to_string=True),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
provenance=_provenance(self.state, self.address),
|
||||
)
|
||||
if writer is not None:
|
||||
# The output pair is a short-lived stack buffer. Leaving the
|
||||
# watchpoint active after the candidate's exact write produced
|
||||
# 114k unrelated events when that stack memory was reused.
|
||||
# The two hardware lookup breakpoints remain armed, so disabling
|
||||
# only this completed one-shot watch loses no provenance.
|
||||
self.enabled = False
|
||||
self.state.log(
|
||||
"team1_watchpoint_disabled",
|
||||
watch_address=self.address,
|
||||
reason="candidate exact write captured",
|
||||
)
|
||||
except Exception as exc: # GDB must continue even if evidence rendering fails.
|
||||
self.state.log("trace_error", where="team1_watchpoint", error=str(exc),
|
||||
traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class EntryBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, address: int):
|
||||
self.state = state
|
||||
super().__init__(
|
||||
f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
context, output_pair = _reg("rcx"), _reg("rdx")
|
||||
self.state.current_entry = {
|
||||
"context": context,
|
||||
"output_pair": output_pair,
|
||||
"entry_thread": _thread(),
|
||||
}
|
||||
if self.state.watchpoint is not None:
|
||||
try:
|
||||
self.state.watchpoint.delete()
|
||||
except gdb.error:
|
||||
pass
|
||||
initial = _i32(output_pair + 4)
|
||||
self.state.watchpoint = Team1Watchpoint(self.state, output_pair + 4)
|
||||
self.state.log(
|
||||
"candidate_entry",
|
||||
entry_image_va=0x1800FC500,
|
||||
context=_object(context, self.state.cards_base),
|
||||
output_pair=output_pair,
|
||||
team_id_1_address=output_pair + 4,
|
||||
team_id_1_initial=initial,
|
||||
watchpoint_number=self.state.watchpoint.number,
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
registers=_registers(),
|
||||
)
|
||||
self.state.log(
|
||||
"team1_watchpoint_armed",
|
||||
watch_address=output_pair + 4,
|
||||
watchpoint_number=self.state.watchpoint.number,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="candidate_entry", error=str(exc),
|
||||
traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class LookupStoreBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, address: int, image_va: int, destination_offset: int):
|
||||
self.state = state
|
||||
self.image_va = image_va
|
||||
self.destination_offset = destination_offset
|
||||
super().__init__(
|
||||
f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
destination = _reg("r14") + self.destination_offset
|
||||
self.state.log(
|
||||
"opponent_lookup_store_pre",
|
||||
writer_image_va=self.image_va,
|
||||
destination=destination,
|
||||
destination_offset=self.destination_offset,
|
||||
source_register="ecx",
|
||||
source_value=_reg("rcx") & 0xFFFFFFFF,
|
||||
disassembly=gdb.execute("x/5i $pc", to_string=True),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
provenance=_provenance(self.state, destination),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="lookup_store", error=str(exc),
|
||||
traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, cards_base: int):
|
||||
"""Called from the supervisor's gdb command file after attach."""
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path, cards_base)
|
||||
entry = EntryBreakpoint(_STATE, cards_base + ENTRY_RVA)
|
||||
lookup_team1 = LookupStoreBreakpoint(
|
||||
_STATE,
|
||||
cards_base + LOOKUP_TO_TEAM1_RVA,
|
||||
0x1800FC595,
|
||||
4,
|
||||
)
|
||||
lookup_team0 = LookupStoreBreakpoint(
|
||||
_STATE,
|
||||
cards_base + LOOKUP_TO_TEAM0_RVA,
|
||||
0x1800FC5B8,
|
||||
0,
|
||||
)
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
cards_base=cards_base,
|
||||
breakpoints={
|
||||
"candidate_entry": {"number": entry.number, "image_va": 0x1800FC500},
|
||||
"lookup_to_team1": {
|
||||
"number": lookup_team1.number,
|
||||
"image_va": 0x1800FC595,
|
||||
},
|
||||
"lookup_to_team0": {
|
||||
"number": lookup_team0.number,
|
||||
"image_va": 0x1800FC5B8,
|
||||
},
|
||||
},
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Hardware-only origin trace for CardsDLL team-pair submissions.
|
||||
|
||||
Distinguishes the three callers of the engine team-id service that can submit a
|
||||
full two-team pair, plus the mode-76 builder that prepares its pair:
|
||||
|
||||
0x1800c7583 correct fixture pair control
|
||||
0x1800c6c23 generic pair submitter
|
||||
0x1800c8dc1 mode-76 pair submitter
|
||||
0x1800c8bf0 mode-76 pair builder entry
|
||||
|
||||
No INT3/software breakpoints. No client memory writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
CARDS_IMAGE_BASE = 0x180000000
|
||||
SITES = {
|
||||
0x1800C7583: ("fixture_pair_submit", "r14", "rsi"),
|
||||
0x1800C6C23: ("generic_pair_submit", "r14", "rsi"),
|
||||
0x1800C8DC1: ("mode76_pair_submit", "r15", "rbp"),
|
||||
}
|
||||
MODE76_BUILDER = 0x1800C8BF0
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _pair(address: int) -> list[int] | None:
|
||||
data = _read(address, 8)
|
||||
return list(struct.unpack("<2i", data)) if data else None
|
||||
|
||||
|
||||
def _registers() -> dict:
|
||||
names = (
|
||||
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||
)
|
||||
return {name: _reg(name) for name in names}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, log_path: str, cards_base: int):
|
||||
self.log_path = log_path
|
||||
self.cards_base = cards_base
|
||||
self.event_index = 0
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.event_index += 1
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.event_index,
|
||||
"time_unix": time.time(),
|
||||
"thread": _thread(),
|
||||
**payload,
|
||||
}
|
||||
with open(self.log_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
|
||||
class PairSubmitBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, image_va: int, name: str, pointer_reg: str, index_reg: str):
|
||||
self.state = state
|
||||
self.image_va = image_va
|
||||
self.name = name
|
||||
self.pointer_reg = pointer_reg
|
||||
self.index_reg = index_reg
|
||||
address = state.cards_base + (image_va - CARDS_IMAGE_BASE)
|
||||
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
pointer = _reg(self.pointer_reg)
|
||||
index = _reg(self.index_reg) & 0xFFFFFFFF
|
||||
pair_base = pointer - index * 4
|
||||
self.state.log(
|
||||
self.name,
|
||||
instruction_image_va=self.image_va,
|
||||
source_value=_reg("r8") & 0xFFFFFFFF,
|
||||
side=_reg("rdx") & 0xFF,
|
||||
engine_base=_reg("rcx"),
|
||||
pair_pointer=pointer,
|
||||
pair_index=index,
|
||||
pair_base=pair_base,
|
||||
pair=_pair(pair_base),
|
||||
registers=_registers(),
|
||||
disassembly=gdb.execute("x/5i $pc", to_string=True),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error", where=self.name, error=str(exc),
|
||||
traceback=traceback.format_exc()
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class Mode76BuilderBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State):
|
||||
self.state = state
|
||||
address = state.cards_base + (MODE76_BUILDER - CARDS_IMAGE_BASE)
|
||||
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
self.state.log(
|
||||
"mode76_builder_entry",
|
||||
instruction_image_va=MODE76_BUILDER,
|
||||
object=_reg("rcx"),
|
||||
registers=_registers(),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error", where="mode76_builder", error=str(exc),
|
||||
traceback=traceback.format_exc()
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, cards_base: int):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path, cards_base)
|
||||
breakpoints = {}
|
||||
for image_va, (name, pointer_reg, index_reg) in SITES.items():
|
||||
bp = PairSubmitBreakpoint(_STATE, image_va, name, pointer_reg, index_reg)
|
||||
breakpoints[name] = {"number": bp.number, "image_va": image_va}
|
||||
builder = Mode76BuilderBreakpoint(_STATE)
|
||||
breakpoints["mode76_builder"] = {"number": builder.number, "image_va": MODE76_BUILDER}
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints=breakpoints,
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Find IMMEDIATE stores of a constant to a struct offset, in a live module.
|
||||
|
||||
immstore.py <imm_dec> [disp_hex|any] [--exe]
|
||||
|
||||
Only `C7 /0` (mov dword [reg+disp], imm32) can INTRODUCE a constant into a
|
||||
field; `89 /r` merely propagates one. Emits image VAs so they can be fed to
|
||||
ldis.py. Read-only.
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
CARDS_IMG = 0x180000000
|
||||
EXE_IMG = 0x140000000
|
||||
|
||||
|
||||
def pid():
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||
return int(os.path.basename(d))
|
||||
except OSError:
|
||||
pass
|
||||
raise SystemExit("FIFA17.exe not running")
|
||||
|
||||
|
||||
P = pid()
|
||||
|
||||
|
||||
def module_base(n):
|
||||
for l in open(f"/proc/{P}/maps"):
|
||||
if n.lower() in l.lower():
|
||||
return int(l.split("-")[0], 16)
|
||||
raise SystemExit(f"{n} not mapped")
|
||||
|
||||
|
||||
def text_spans(base):
|
||||
out = []
|
||||
started = False
|
||||
for l in open(f"/proc/{P}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", l)
|
||||
if not m:
|
||||
continue
|
||||
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)
|
||||
if lo == base:
|
||||
started = True
|
||||
continue
|
||||
if started:
|
||||
if not path.strip() and "x" in perms:
|
||||
out.append((lo, hi))
|
||||
elif out:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
args = [a for a in sys.argv[1:] if a != "--exe"]
|
||||
exe = "--exe" in sys.argv
|
||||
imm = int(args[0], 0)
|
||||
want_disp = None if len(args) < 2 or args[1] == "any" else int(args[1], 16)
|
||||
img = EXE_IMG if exe else CARDS_IMG
|
||||
base = module_base("FIFA17.exe" if exe else "CardsDLL")
|
||||
|
||||
mem = open(f"/proc/{P}/mem", "rb", 0)
|
||||
immb = struct.pack("<i", imm)
|
||||
hits = 0
|
||||
for lo, hi in text_spans(base):
|
||||
mem.seek(lo)
|
||||
buf = mem.read(hi - lo)
|
||||
img_lo = img + (lo - base)
|
||||
i = buf.find(b"\xc7", 0)
|
||||
while i >= 0:
|
||||
modrm = buf[i + 1] if i + 1 < len(buf) else 0
|
||||
if (modrm & 0x38) == 0: # /0
|
||||
mod, rm = modrm >> 6, modrm & 7
|
||||
if mod == 1 and i + 7 <= len(buf): # disp8
|
||||
disp, ib = buf[i + 2], i + 3
|
||||
sz = 7
|
||||
elif mod == 2 and i + 10 <= len(buf): # disp32
|
||||
disp, ib = struct.unpack_from("<i", buf, i + 2)[0], i + 6
|
||||
sz = 10
|
||||
elif mod == 0 and rm not in (4, 5) and i + 6 <= len(buf):
|
||||
disp, ib = 0, i + 2
|
||||
sz = 6
|
||||
else:
|
||||
disp = None
|
||||
if disp is not None and buf[ib:ib + 4] == immb:
|
||||
if want_disp is None or disp == want_disp:
|
||||
print(f" image 0x{img_lo+i:x} mov dword [reg+0x{disp:x}], {imm} ({sz}B)")
|
||||
hits += 1
|
||||
i = buf.find(b"\xc7", i + 1)
|
||||
print(f" {hits} immediate store(s) of {imm}"
|
||||
+ (f" at +0x{want_disp:x}" if want_disp is not None else ""))
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only live disassembler for the FIFA17 client (CardsDLL / FIFA17.exe).
|
||||
|
||||
ldis.py <image_va_hex> [nbytes] [--exe] disassemble
|
||||
ldis.py --bytes <image_va_hex> [nbytes] hexdump
|
||||
ldis.py --map show module bases
|
||||
|
||||
CardsDLL image base 0x180000000; FIFA17.exe image base 0x140000000.
|
||||
Live address = module_base + (image_va - img_base). Sections map 1:1 for both,
|
||||
but this is recomputed and printed so the offset trap stays visible.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
PID = None
|
||||
CARDS_IMG = 0x180000000
|
||||
EXE_IMG = 0x140000000
|
||||
|
||||
|
||||
def pid():
|
||||
global PID
|
||||
if PID is None:
|
||||
import glob, os
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||
PID = int(os.path.basename(d))
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
if PID is None:
|
||||
raise SystemExit("FIFA17.exe not running")
|
||||
return PID
|
||||
|
||||
|
||||
def module_base(needle):
|
||||
"""Base = the NAMED PE-header mapping for the module (Wine maps the rest
|
||||
anonymously, so never trust the mapping that merely CONTAINS an address)."""
|
||||
for l in open(f"/proc/{pid()}/maps"):
|
||||
if needle.lower() in l.lower():
|
||||
return int(l.split("-")[0], 16)
|
||||
raise SystemExit(f"module {needle} not mapped")
|
||||
|
||||
|
||||
def live(va, exe=False):
|
||||
if exe:
|
||||
return module_base("FIFA17.exe") + (va - EXE_IMG)
|
||||
return module_base("CardsDLL") + (va - CARDS_IMG)
|
||||
|
||||
|
||||
def read(va, n, exe=False):
|
||||
la = live(va, exe)
|
||||
with open(f"/proc/{pid()}/mem", "rb", 0) as m:
|
||||
m.seek(la)
|
||||
return la, m.read(n)
|
||||
|
||||
|
||||
def main():
|
||||
a = sys.argv[1:]
|
||||
if not a or a[0] == "--map":
|
||||
print(f" pid = {pid()}")
|
||||
print(f" CardsDLL = 0x{module_base('CardsDLL'):x} (image 0x{CARDS_IMG:x})")
|
||||
print(f" FIFA17.exe = 0x{module_base('FIFA17.exe'):x} (image 0x{EXE_IMG:x})")
|
||||
return
|
||||
hexdump = a[0] == "--bytes"
|
||||
if hexdump:
|
||||
a = a[1:]
|
||||
exe = "--exe" in a
|
||||
a = [x for x in a if x != "--exe"]
|
||||
va = int(a[0], 16)
|
||||
n = int(a[1]) if len(a) > 1 else 160
|
||||
la, buf = read(va, n, exe)
|
||||
print(f" image 0x{va:x} -> live 0x{la:x} ({len(buf)} bytes)")
|
||||
if hexdump:
|
||||
for i in range(0, len(buf), 16):
|
||||
c = buf[i:i + 16]
|
||||
print(f" 0x{va+i:x}: {' '.join(f'{b:02x}' for b in c):<47} "
|
||||
+ "".join(chr(b) if 32 <= b < 127 else "." for b in c))
|
||||
return
|
||||
with tempfile.NamedTemporaryFile(suffix=".bin") as f:
|
||||
f.write(buf)
|
||||
f.flush()
|
||||
out = subprocess.run(
|
||||
["objdump", "-D", "-b", "binary", "-m", "i386:x86-64", "-M", "intel",
|
||||
f"--adjust-vma=0x{va:x}", f.name],
|
||||
capture_output=True, text=True).stdout
|
||||
for line in out.splitlines():
|
||||
if re.match(r"\s+[0-9a-f]+:", line):
|
||||
print(" " + line.strip())
|
||||
|
||||
|
||||
main()
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Byte-level diff of the two resident kit records in a live FIFA17 client.
|
||||
|
||||
The pre-match selector draws each kit from a clone query keyed on the record's
|
||||
own fields, so if both tiles render identically the question is precisely: which
|
||||
bytes of the home record differ from the away record? This prints every differing
|
||||
offset with the known field names attached, and dumps the fields the decoded
|
||||
clone query consumes.
|
||||
|
||||
Read-only. Never writes to the process.
|
||||
|
||||
Decoded query (FUN_1801c3480 -> FUN_1801c44b0):
|
||||
teamtechid == record+0x94
|
||||
teamkittypetechid == derived from itemState (101 -> 0 home, 102 -> 1 away)
|
||||
year == record+0xba
|
||||
"""
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
PID = int(sys.argv[1])
|
||||
WANT = [int(a) for a in sys.argv[2:]] or [100004874, 100004873]
|
||||
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
|
||||
def rd(a, n):
|
||||
mem.seek(a)
|
||||
return mem.read(n)
|
||||
|
||||
|
||||
def q(a):
|
||||
return struct.unpack("<Q", rd(a, 8))[0]
|
||||
|
||||
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m:
|
||||
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = next((s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll")), None)
|
||||
if base is None:
|
||||
sys.exit("CardsDLL mapping not found")
|
||||
|
||||
|
||||
def live(static):
|
||||
return base + (static - 0x180000000)
|
||||
|
||||
|
||||
if rd(live(0x180026FEA), 5) != bytes.fromhex("ba75750000"):
|
||||
sys.exit("SANITY FAILED - wrong base")
|
||||
print(f" CardsDLL base = {base:#x} (sanity ok)")
|
||||
|
||||
owner = q(live(0x1802E6398))
|
||||
sentinel = owner + 0x160C8
|
||||
root = q(owner + 0x160D8)
|
||||
|
||||
# Known record fields, offset -> (name, width)
|
||||
FIELDS = {
|
||||
0x08: ("id", 8),
|
||||
0x18: ("resourceId/definitionId", 4),
|
||||
# Offsets per club_items.json `_record_map`, which is authoritative:
|
||||
# cardassetid is +0x1c and assetId is +0x20 — NOT the other way round.
|
||||
0x1C: ("cardassetid", 4),
|
||||
0x20: ("assetId", 4),
|
||||
0x38: ("discardValue", 4),
|
||||
0x4C: ("cardtype", 4),
|
||||
0x50: ("cardsubtypeid", 4),
|
||||
0x5C: ("itemState", 4),
|
||||
0x60: ("category(club slot)", 4),
|
||||
0x8C: ("contract", 4),
|
||||
0x94: ("teamid", 4),
|
||||
0xB4: ("rating", 4),
|
||||
0xB8: ("wire category", 1),
|
||||
0xBA: ("year", 2),
|
||||
0x148: ("nation", 4),
|
||||
0x154: ("leagueId", 4),
|
||||
}
|
||||
|
||||
|
||||
def walk(node, out):
|
||||
if not node or node == sentinel:
|
||||
return
|
||||
walk(q(node + 0x00), out)
|
||||
# The record is EMBEDDED at node+0x28 — NOT a pointer stored there.
|
||||
out.append((struct.unpack("<q", rd(node + 0x20, 8))[0], node + 0x28))
|
||||
walk(q(node + 0x08), out)
|
||||
|
||||
|
||||
nodes = []
|
||||
walk(root, nodes)
|
||||
recs = {k: v for k, v in nodes}
|
||||
|
||||
found = [(w, recs[w]) for w in WANT if w in recs]
|
||||
if len(found) < 2:
|
||||
sys.exit(f" need two resident kit records, found {[w for w, _ in found]}")
|
||||
|
||||
(id_a, ptr_a), (id_b, ptr_b) = found[0], found[1]
|
||||
a = rd(ptr_a, 0x180)
|
||||
b = rd(ptr_b, 0x180)
|
||||
print(f" A = {id_a} @ {ptr_a:#x}")
|
||||
print(f" B = {id_b} @ {ptr_b:#x}")
|
||||
|
||||
print("\n --- fields the clone query consumes ---")
|
||||
for off in (0x94, 0x5C, 0xBA):
|
||||
name = FIELDS[off][0]
|
||||
w = FIELDS[off][1]
|
||||
va = int.from_bytes(a[off : off + w], "little")
|
||||
vb = int.from_bytes(b[off : off + w], "little")
|
||||
flag = "" if va != vb else " <== IDENTICAL"
|
||||
print(f" +{off:#05x} {name:24} A={va:<12} B={vb:<12}{flag}")
|
||||
|
||||
print("\n --- every differing byte range ---")
|
||||
diffs = [i for i in range(0x180) if a[i] != b[i]]
|
||||
runs = []
|
||||
for i in diffs:
|
||||
if runs and i == runs[-1][1] + 1:
|
||||
runs[-1][1] = i
|
||||
else:
|
||||
runs.append([i, i])
|
||||
for s, e in runs:
|
||||
named_field = next(
|
||||
(n for o, (n, w) in FIELDS.items() if o <= s < o + w), "(unmapped)"
|
||||
)
|
||||
va = int.from_bytes(a[s : e + 1], "little")
|
||||
vb = int.from_bytes(b[s : e + 1], "little")
|
||||
print(f" +{s:#05x}..{e:#05x} {named_field:24} A={va:<12} B={vb}")
|
||||
print(f"\n {len(diffs)} differing bytes in {len(runs)} runs")
|
||||
|
||||
print("\n --- known fields, side by side ---")
|
||||
for off in sorted(FIELDS):
|
||||
name, w = FIELDS[off]
|
||||
va = int.from_bytes(a[off : off + w], "little")
|
||||
vb = int.from_bytes(b[off : off + w], "little")
|
||||
mark = " DIFFERS" if va != vb else ""
|
||||
print(f" +{off:#05x} {name:24} A={va:<12} B={vb:<12}{mark}")
|
||||
@@ -77,7 +77,7 @@ for label, vbeg, vend in (("players (cardtype 1)", mgr + 0xd8, mgr + 0xe0),
|
||||
if span % stride:
|
||||
continue
|
||||
n = span // stride
|
||||
recs, nulls = [], 0
|
||||
recs, nulls = [], []
|
||||
ok = True
|
||||
for k in range(n):
|
||||
try:
|
||||
@@ -85,23 +85,29 @@ for label, vbeg, vend in (("players (cardtype 1)", mgr + 0xd8, mgr + 0xe0),
|
||||
except OSError:
|
||||
ok = False; break
|
||||
if not rec:
|
||||
nulls += 1; continue
|
||||
nulls.append(k); continue
|
||||
d = decode(rec)
|
||||
if d is None:
|
||||
ok = False; break
|
||||
recs.append((rec, d))
|
||||
recs.append((k, rec, d))
|
||||
if not ok:
|
||||
continue
|
||||
print(f" stride {stride} (ptr at +{ptr_off:#x}): {n} slots, {len(recs)} populated, {nulls} null")
|
||||
if not recs and nulls != n:
|
||||
print(f" stride {stride} (ptr at +{ptr_off:#x}): {n} slots, {len(recs)} populated, {len(nulls)} null")
|
||||
if not recs and len(nulls) != n:
|
||||
continue
|
||||
hist = collections.Counter(d[0:2] for _, d in recs)
|
||||
hist = collections.Counter(d[0:2] for _, _, d in recs)
|
||||
for key, c in sorted(hist.items(), key=lambda x: -x[1]):
|
||||
print(f" (cardtype,subtype)={key} x{c}")
|
||||
print(f" {'ptr':>14} " + " ".join(f"{f:>8}" for f in FIELDS))
|
||||
for rec, d in recs[:8]:
|
||||
print(f" {rec:#14x} " + " ".join(f"{v:>8}" for v in d))
|
||||
cats = collections.Counter(d[3] for _, d in recs)
|
||||
# The SLOT INDEX is load-bearing evidence: the squad parser's `actives`
|
||||
# arm writes element i to slot `r15d + i`, and r15d is shared scratch
|
||||
# that other atom handlers clobber. Which slots are filled therefore
|
||||
# reveals the index the parse actually started from.
|
||||
print(f" {'slot':>4} {'ptr':>14} " + " ".join(f"{f:>8}" for f in FIELDS))
|
||||
for k, rec, d in recs[:8]:
|
||||
print(f" {k:>4} {rec:#14x} " + " ".join(f"{v:>8}" for v in d))
|
||||
if nulls:
|
||||
print(f" empty slots: {nulls[:16]}")
|
||||
cats = collections.Counter(d[3] for _, _, d in recs)
|
||||
if cats:
|
||||
print(f" CATEGORY (+0x60) distribution: {dict(cats)}")
|
||||
break
|
||||
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
# Native proof for the FIFA 17 kit milestone: does the client now hold resident
|
||||
# cardtype-7 records, and are the served kit ids among them?
|
||||
#
|
||||
# Auto-detects the live FIFA17.exe pid and walks the resident item map at
|
||||
# owner+0x160c8 (root +0x160d8, key = wire instance id at node+0x20, record at
|
||||
# node+0x28, count at owner+0x160e8). Read-only; never writes to the process.
|
||||
#
|
||||
# BEFORE this fix the map held 22 records with cardtype histogram {1:18, 2:1,
|
||||
# 4:2, 10:1} and both kit ids ABSENT.
|
||||
set -u
|
||||
|
||||
PID=$(for p in /proc/[0-9]*; do
|
||||
[ "$(cat "$p/comm" 2>/dev/null)" = "FIFA17.exe" ] && echo "${p#/proc/}"
|
||||
done | head -1)
|
||||
|
||||
if [ -z "$PID" ]; then
|
||||
echo " FIFA17.exe is not running - launch the game and enter FUT first"
|
||||
exit 1
|
||||
fi
|
||||
echo " live FIFA17 pid = $PID"
|
||||
echo
|
||||
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
python3 probe_map2.py "$PID" 100004873 100004874 100004870
|
||||
|
||||
echo
|
||||
echo " ================ squad survival + slot indices ================"
|
||||
# The kit milestone is only real if the REST of the squad survives with it.
|
||||
# A populated `squad.actives` was once seen to leave the map holding just the
|
||||
# 2 kits with a fully null 23-slot player vector and an empty starting 11, so
|
||||
# the player-vector fill below is a PASS/FAIL gate, not decoration.
|
||||
#
|
||||
# The club-item slot indices are the other half: the parser writes element i to
|
||||
# slot r15d+i, and r15d is scratch other atom handlers clobber. Kits landing
|
||||
# somewhere other than slots 0 and 1 means the index did not start at zero.
|
||||
python3 probe_resident_fields.py "$PID"
|
||||
Executable
+252
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Is the squad manager REGISTERED (not merely parsed) in a live FIFA17 client?
|
||||
|
||||
READ-ONLY. Opens /proc/<pid>/mem for reading and scans. Writes nothing, sends
|
||||
no input to the game, and never opens 'r+b'.
|
||||
|
||||
manager_coldproof.py [pid] [--manager-wire N] [--manager-resource N]
|
||||
[--control WIRE:RESOURCE ...]
|
||||
|
||||
Defaults describe the staging profile used to close the manager milestone; pass
|
||||
the flags for any other profile.
|
||||
|
||||
WHAT THIS DECIDES
|
||||
-----------------
|
||||
FIFA17's squad parser (FUN_18013d1f0) reaches the item parser FUN_18013fe00 by
|
||||
two different routes:
|
||||
|
||||
players : atom 568 -> per-element atoms 355 index / 363 itemData /
|
||||
378 kitNumber; the 363 arm at 0x18013d8d9 calls the item parser
|
||||
on the NESTED itemData object.
|
||||
manager : atom 424 -> array loop at 0x18013da29 calls that same item parser
|
||||
DIRECTLY on the array ELEMENT, into squad+0xC0. No itemData step.
|
||||
|
||||
So `squad.manager[]` elements must be BARE ITEM OBJECTS. When they were served
|
||||
as {id, itemData:{...}, dream} the parser read only the two keys that happen to
|
||||
be item atoms -- id and dream -- and left resourceId at 0. resourceId is the
|
||||
merge key, compared RAW against carddbid (fut_staff.py::manager_item, +0x18),
|
||||
so 0 resolves no manager: no name, no rating, no art, empty slot. Fixed in
|
||||
OpenFUT b91e707; see Vault "FIFA 17/Squad Manager Wire Shape.md".
|
||||
|
||||
CONTROLS
|
||||
--------
|
||||
manager wire id the instance id. Present even when BROKEN, because `id` is
|
||||
an item atom the parser reads at element level. Its
|
||||
presence proves the element was parsed and therefore proves
|
||||
nothing about registration -- do not use it as the verdict.
|
||||
manager resourceId THE VERDICT. Resident => the merge key survived the load.
|
||||
player wire id and positive controls. Players demonstrably render, so if their
|
||||
player resourceId resourceIds are absent the squad simply is not loaded yet
|
||||
and the run is INCONCLUSIVE, not a failure.
|
||||
|
||||
RESIDENT-MANAGER HIT
|
||||
--------------------
|
||||
A 4-byte-aligned little-endian i32 equal to the manager resourceId, anywhere in
|
||||
a readable private mapping. Corroborate with the record context printed below:
|
||||
a real item record carries resourceId eight words ahead of its wire id, which
|
||||
is the layout the player controls exhibit. Hits without that shape are usually
|
||||
id lists or unrelated integers -- the layout, not the raw count, is the proof.
|
||||
|
||||
LAYOUT ASSUMPTION (the only one)
|
||||
--------------------------------
|
||||
Item records place resourceId 0x20 bytes before the wire id. Measured, both
|
||||
sides:
|
||||
|
||||
before b91e707 (pid 126936) -- manager parsed, merge key absent
|
||||
player @0xb85dbf48: 83906881 1 0 0 0 0 0 0 | 100002878 0 | 7
|
||||
player @0xb85dbd68: 84053575 1 0 0 0 0 0 0 | 100003237 0 | 7
|
||||
manager @0xb85dc1b8: 0 0 0 0 0 0 0 0 | 100004870 0 | 7
|
||||
|
||||
after b91e707 (pid 134118) -- same layout, key present
|
||||
player @0xb8740fd8: 84053575 1 0 0 0 0 0 0 | 100003237 0 | 7 0
|
||||
player @0xb87411b8: 83906881 1 0 0 0 0 0 0 | 100002878 0 | 7 0
|
||||
manager @0xb8741428: 1000509 2 0 0 0 0 0 0 | 100004870 0 | 7 0
|
||||
|
||||
Addresses shift every session and are recorded only as provenance; nothing here
|
||||
depends on them. The tool re-derives everything by scanning.
|
||||
|
||||
EXIT CODES (fail-closed)
|
||||
------------------------
|
||||
0 PASS manager resourceId resident, controls present
|
||||
1 FAIL controls present, manager resourceId absent
|
||||
2 NO PROCESS no FIFA17.exe, or /proc/<pid>/mem unreadable
|
||||
3 INCONCLUSIVE controls absent -- squad not loaded yet; re-run at the
|
||||
squad screen. Deliberately NOT 0: absent controls mean the
|
||||
probe proved nothing.
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
# Staging profile defaults (override on the command line).
|
||||
DEF_MANAGER_WIRE = 100004870
|
||||
DEF_MANAGER_RESOURCE = 1000509
|
||||
DEF_CONTROLS = [(100002878, 83906881), (100003237, 84053575)]
|
||||
|
||||
# Item record layout: resourceId sits this far BEFORE the wire id.
|
||||
RESOURCE_BACK_OFF = 0x20
|
||||
|
||||
|
||||
def find_pid():
|
||||
"""The Wine process whose comm is FIFA17.exe (same rule as memtool.py)."""
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
with open(os.path.join(d, "comm")) as fh:
|
||||
if fh.read().strip() == "FIFA17.exe":
|
||||
return int(os.path.basename(d))
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def regions(pid):
|
||||
"""Readable private mappings worth scanning.
|
||||
|
||||
Skips device/memfd mappings and anything over 512 MiB (the big reserved
|
||||
ranges are not where parsed records live and dominate the runtime).
|
||||
"""
|
||||
out = []
|
||||
with open(f"/proc/{pid}/maps") as fh:
|
||||
for line in fh:
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", line)
|
||||
if not m:
|
||||
continue
|
||||
lo, hi = int(m.group(1), 16), int(m.group(2), 16)
|
||||
perms, path = m.group(3), m.group(4)
|
||||
if perms[0] != "r" or path.startswith(("/dev", "/memfd")):
|
||||
continue
|
||||
if hi - lo > 512 * 1024 * 1024:
|
||||
continue
|
||||
out.append((lo, hi))
|
||||
return out
|
||||
|
||||
|
||||
def scan(pid, needles, ctx_before=0x40, ctx_after=0x40):
|
||||
"""4-byte-aligned little-endian i32 search; keeps a window around each hit."""
|
||||
found = {n: [] for n in needles}
|
||||
pats = {n: struct.pack("<i", n) for n in needles}
|
||||
with open(f"/proc/{pid}/mem", "rb", 0) as mem:
|
||||
for lo, hi in regions(pid):
|
||||
try:
|
||||
mem.seek(lo)
|
||||
buf = mem.read(hi - lo)
|
||||
except (OSError, ValueError, OverflowError):
|
||||
continue # torn-down or unreadable mapping; not a failure
|
||||
for n, pat in pats.items():
|
||||
i = buf.find(pat)
|
||||
while i >= 0:
|
||||
if i % 4 == 0:
|
||||
found[n].append(
|
||||
(lo + i, buf[max(0, i - ctx_before): i + ctx_after], min(i, ctx_before))
|
||||
)
|
||||
i = buf.find(pat, i + 4)
|
||||
return found
|
||||
|
||||
|
||||
def words(blob, centre, before=8, after=4):
|
||||
cells = []
|
||||
for k in range(-before, after):
|
||||
o = centre + k * 4
|
||||
if 0 <= o <= len(blob) - 4:
|
||||
cells.append(str(struct.unpack_from("<i", blob, o)[0]))
|
||||
return " ".join(cells)
|
||||
|
||||
|
||||
def record_shaped(blob, centre, resource):
|
||||
"""True when resourceId sits RESOURCE_BACK_OFF before the id -- the real
|
||||
item-record layout, as opposed to an incidental integer match."""
|
||||
o = centre - RESOURCE_BACK_OFF
|
||||
if o < 0 or o > len(blob) - 4:
|
||||
return False
|
||||
return struct.unpack_from("<i", blob, o)[0] == resource
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="read-only manager registration probe")
|
||||
ap.add_argument("pid", nargs="?", type=int, help="FIFA17 pid (default: auto)")
|
||||
ap.add_argument("--manager-wire", type=int, default=DEF_MANAGER_WIRE)
|
||||
ap.add_argument("--manager-resource", type=int, default=DEF_MANAGER_RESOURCE)
|
||||
ap.add_argument(
|
||||
"--control",
|
||||
action="append",
|
||||
metavar="WIRE:RESOURCE",
|
||||
help="player positive control; repeatable (default: the staging pair)",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
controls = DEF_CONTROLS
|
||||
if args.control:
|
||||
try:
|
||||
controls = [tuple(int(x) for x in c.split(":", 1)) for c in args.control]
|
||||
except ValueError:
|
||||
print(" --control must be WIRE:RESOURCE", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
pid = args.pid or find_pid()
|
||||
if not pid:
|
||||
print(" NO FIFA17 PROCESS (comm == FIFA17.exe) -- is the client running?")
|
||||
return 2
|
||||
if not os.access(f"/proc/{pid}/mem", os.R_OK):
|
||||
print(f" /proc/{pid}/mem is not readable -- wrong user, or the process exited")
|
||||
return 2
|
||||
print(f" pid={pid}")
|
||||
|
||||
needles = [args.manager_wire, args.manager_resource]
|
||||
for w, r in controls:
|
||||
needles += [w, r]
|
||||
try:
|
||||
res = scan(pid, sorted(set(needles)))
|
||||
except OSError as e:
|
||||
print(f" cannot read /proc/{pid}/mem: {e}")
|
||||
return 2
|
||||
|
||||
print("\n ===== hit counts =====")
|
||||
print(f" {'manager wire (parsed?)':32} {args.manager_wire:<12} hits={len(res[args.manager_wire])}")
|
||||
print(f" {'manager resourceId (VERDICT)':32} {args.manager_resource:<12} "
|
||||
f"hits={len(res[args.manager_resource])}")
|
||||
for w, r in controls:
|
||||
print(f" {'player wire (control)':32} {w:<12} hits={len(res[w])}")
|
||||
print(f" {'player resourceId (control)':32} {r:<12} hits={len(res[r])}")
|
||||
|
||||
print("\n ===== record context (8 words before the id, then the id) =====")
|
||||
shaped = {"manager": 0}
|
||||
for tag, wire, resource in (
|
||||
[("manager", args.manager_wire, args.manager_resource)]
|
||||
+ [(f"player{i}", w, r) for i, (w, r) in enumerate(controls)]
|
||||
):
|
||||
marked = 0
|
||||
for addr, blob, centre in res[wire]:
|
||||
ok = record_shaped(blob, centre, resource)
|
||||
if ok:
|
||||
marked += 1
|
||||
if marked <= 2 or ok:
|
||||
print(f" {tag:8} @0x{addr:x}{' <- item-record layout' if ok else ''}: "
|
||||
f"{words(blob, centre)}")
|
||||
if marked >= 2:
|
||||
break
|
||||
shaped[tag] = marked
|
||||
|
||||
ctl_keys = sum(len(res[r]) for _w, r in controls)
|
||||
mgr_keys = len(res[args.manager_resource])
|
||||
|
||||
print("\n ===== verdict =====")
|
||||
if ctl_keys == 0:
|
||||
print(" INCONCLUSIVE: no player resourceId control is resident, so the squad")
|
||||
print(" is not loaded. Reach the squad screen and re-run. (Nothing proven.)")
|
||||
return 3
|
||||
if mgr_keys == 0:
|
||||
print(f" FAIL: manager resourceId {args.manager_resource} is absent while "
|
||||
f"{ctl_keys} player")
|
||||
print(" resourceId control hit(s) are resident -> PARSED_BUT_NOT_REGISTERED.")
|
||||
return 1
|
||||
print(f" PASS: manager resourceId {args.manager_resource} is resident "
|
||||
f"({mgr_keys} hits, {shaped['manager']} in item-record layout).")
|
||||
print(" The merge key survived the load; the broken projection had 0.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17 provider dispatch and ACTION_ADVANCE delivery boundaries.
|
||||
|
||||
This probe correlates the global UI dispatch of FUT_CREATE_MATCH_DP and
|
||||
FUT_GET_MATCH_KITS_DP, the subscribed CardsDLL provider, the internal 0x7546
|
||||
create-response callback that can replay FUT_CREATE_MATCH_DP, and the final
|
||||
native-to-UI bridge. At global dispatch, r8d is the provider ID and rdx is the
|
||||
payload; neither register is a screen key.
|
||||
|
||||
The generated GDB program uses hardware-assisted execution breakpoints only.
|
||||
It never writes client memory and never drives game input.
|
||||
|
||||
match_advance_trace.py [pid] [--output PATH]
|
||||
match_advance_trace.py --print-script [pid]
|
||||
match_advance_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_transition_trace as transition
|
||||
|
||||
FIFA_MODULE = "FIFA17.exe"
|
||||
PINNED_FIFA_SHA256 = "29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899"
|
||||
GLOBAL_UI_DISPATCH_RVA = 0x80D1070
|
||||
CREATE_MATCH_CONTROLLER_RVA = 0xBF950
|
||||
PROVIDER_BRIDGE_CALL_RVA = 0x1A4D41
|
||||
|
||||
|
||||
def module_mapping(pid: int, module: str) -> tuple[int, str]:
|
||||
with open(f"/proc/{pid}/maps", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
fields = line.split(maxsplit=5)
|
||||
path = fields[5].rstrip() if len(fields) == 6 else ""
|
||||
if not path.endswith(module):
|
||||
continue
|
||||
return int(fields[0].split("-", 1)[0], 16), path
|
||||
raise RuntimeError(f"{module} is not mapped in PID {pid}")
|
||||
|
||||
|
||||
def validate_file(path: str, expected: str, label: str) -> None:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
actual = digest.hexdigest()
|
||||
if actual != expected:
|
||||
raise RuntimeError(f"unsupported {label}: sha256={actual}; expected={expected}")
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"provider": cards_base + transition.PROVIDER_DISPATCH_RVA,
|
||||
"global_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
|
||||
"controller": cards_base + CREATE_MATCH_CONTROLLER_RVA,
|
||||
"bridge": cards_base + PROVIDER_BRIDGE_CALL_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(pid: int, cards_base: int, fifa_base: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base, fifa_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
|
||||
hbreak *0x{address['provider']:x}
|
||||
condition 1 $edx == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edx == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d PROVIDER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x payload=%p controller=%p caller=%p\\n", $_thread, $edx, $r8, $rcx, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['global_dispatch']:x}
|
||||
condition 2 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d GLOBAL_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x payload=%p manager=%p caller=%p\\n", $_thread, $r8d, $rdx, $rcx, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['controller']:x}
|
||||
condition 3 $edx == 0x7546
|
||||
commands
|
||||
silent
|
||||
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d CREATE_MATCH_CONTROLLER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d event=%#x controller=%p caller=%p\\n", $_thread, $edx, $rcx, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['bridge']:x}
|
||||
condition 4 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d PROVIDER_BRIDGE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x target=%p bridge=%p callback=%p\\n", $_thread, $edi, $rsi, $rbx, *(void**)(*(void**)$rbx+0x48)
|
||||
continue
|
||||
end
|
||||
|
||||
printf "ADVTRACE ARMED pid={pid} provider=0x{address['provider']:x} global=0x{address['global_dispatch']:x} controller=0x{address['controller']:x} bridge=0x{address['bridge']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000, 0x140000000)
|
||||
assert address == {
|
||||
"provider": 0x1801A4CD0,
|
||||
"global_dispatch": 0x1480D1070,
|
||||
"controller": 0x1800BF950,
|
||||
"bridge": 0x1801A4D41,
|
||||
}
|
||||
script = build_gdb_script(28804, 0x180000000, 0x140000000, "/tmp/advance.log")
|
||||
assert script.count("hbreak *") == 4
|
||||
assert f"$edx == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$edx == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert "CREATE_MATCH_CONTROLLER" in script
|
||||
assert "PROVIDER_BRIDGE" in script
|
||||
assert "set *(" not in script
|
||||
print("match_advance_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
fifa_base, fifa_path = module_mapping(pid, FIFA_MODULE)
|
||||
validate_file(fifa_path, PINNED_FIFA_SHA256, FIFA_MODULE)
|
||||
output = args.output or f"/tmp/fifa17-match-advance-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, fifa_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-advance-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace the FIFA17 ACTION_CREATE_MATCH-to-provider lifecycle.
|
||||
|
||||
The probe correlates:
|
||||
|
||||
* the select-team action handler for UIF action IDs 0x7574..0x757b;
|
||||
* DataManager's request dispatch for FutCreateMatchServerResponse (0x7546);
|
||||
* the concrete FutCreateMatchServerResponse data-source request method;
|
||||
* FIFA's global UI dispatch of providers 0x7563 and 0x7565.
|
||||
|
||||
Static decoding identifies action 0x7577 as the branch that constructs the
|
||||
create-match request and calls DataManager for source 0x7546. The trace proves
|
||||
whether that authentic trigger executes in the failing flow. It uses four
|
||||
hardware-assisted execution breakpoints, never writes client memory, and never
|
||||
drives game input.
|
||||
|
||||
match_create_action_trace.py [pid] [--output PATH]
|
||||
match_create_action_trace.py --print-script [pid]
|
||||
match_create_action_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
SELECT_TEAM_ACTION_HANDLER_RVA = 0x0BFCC0
|
||||
DATA_MANAGER_REQUEST_RVA = 0x80D2340
|
||||
DATA_SOURCE_REQUEST_RVA = 0x120270
|
||||
GLOBAL_UI_DISPATCH_RVA = advance.GLOBAL_UI_DISPATCH_RVA
|
||||
FIRST_SELECT_TEAM_ACTION = 0x7574
|
||||
LAST_SELECT_TEAM_ACTION = 0x757B
|
||||
ACTION_CREATE_MATCH = 0x7577
|
||||
CREATE_DATA_SOURCE = 0x7546
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"action_handler": cards_base + SELECT_TEAM_ACTION_HANDLER_RVA,
|
||||
"manager_request": fifa_base + DATA_MANAGER_REQUEST_RVA,
|
||||
"data_source_request": cards_base + DATA_SOURCE_REQUEST_RVA,
|
||||
"ui_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(
|
||||
pid: int, cards_base: int, fifa_base: int, output: str
|
||||
) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base, fifa_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
set $create_action_seen = 0
|
||||
set $manager_request_seen = 0
|
||||
set $data_source_request_seen = 0
|
||||
|
||||
hbreak *0x{address['action_handler']:x}
|
||||
condition 1 $edx >= 0x{FIRST_SELECT_TEAM_ACTION:x} && $edx <= 0x{LAST_SELECT_TEAM_ACTION:x}
|
||||
commands
|
||||
silent
|
||||
if $edx == 0x{ACTION_CREATE_MATCH:x}
|
||||
set $create_action_seen = 1
|
||||
end
|
||||
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d SELECT_TEAM_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d action=%#x is_create=%d controller=%p payload=%p create_seen=%d\\n", $_thread, $edx, $edx==0x{ACTION_CREATE_MATCH:x}, $rcx, $r8, $create_action_seen
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['manager_request']:x}
|
||||
condition 2 $edx == 0x{CREATE_DATA_SOURCE:x}
|
||||
commands
|
||||
silent
|
||||
set $manager_request_seen = 1
|
||||
set $tree_sentinel = $rcx + 0x10
|
||||
set $tree_cursor = *(void**)($rcx+0x20)
|
||||
set $data_node = $tree_sentinel
|
||||
while $tree_cursor != 0 && $tree_cursor != $tree_sentinel
|
||||
if *(unsigned int*)($tree_cursor+0x20) >= 0x{CREATE_DATA_SOURCE:x}
|
||||
set $data_node = $tree_cursor
|
||||
set $tree_cursor = *(void**)($tree_cursor+0x08)
|
||||
else
|
||||
set $tree_cursor = *(void**)$tree_cursor
|
||||
end
|
||||
end
|
||||
set $data_source = 0
|
||||
set $request_method = 0
|
||||
if $data_node != $tree_sentinel && *(unsigned int*)($data_node+0x20) == 0x{CREATE_DATA_SOURCE:x}
|
||||
set $data_source = *(void**)($data_node+0x28)
|
||||
if $data_source != 0
|
||||
set $request_method = *(void**)(*(void**)$data_source+0x18)
|
||||
end
|
||||
end
|
||||
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d MANAGER_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d source=%#x manager=%p request=%p node=%p data_source=%p request_method=%p create_seen=%d\\n", $_thread, $edx, $rcx, $r8, $data_node, $data_source, $request_method, $create_action_seen
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['data_source_request']:x}
|
||||
commands
|
||||
silent
|
||||
set $data_source_request_seen = 1
|
||||
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d DATA_SOURCE_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d response=%p data_source=%p request=%p ready_before=%#x create_seen=%d manager_seen=%d\\n", $_thread, $rcx-0x50, $rcx, $rdx, *(unsigned char*)($rcx+0x38), $create_action_seen, $manager_request_seen
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['ui_dispatch']:x}
|
||||
condition 4 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d UI_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x payload=%p ui_manager=%p create_seen=%d manager_seen=%d data_source_seen=%d\\n", $_thread, $r8d, $rdx, $rcx, $create_action_seen, $manager_request_seen, $data_source_request_seen
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
printf "ACTIONTRACE ARMED pid={pid} action_handler=0x{address['action_handler']:x} manager_request=0x{address['manager_request']:x} data_source_request=0x{address['data_source_request']:x} ui_dispatch=0x{address['ui_dispatch']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000, 0x140000000)
|
||||
assert address == {
|
||||
"action_handler": 0x1800BFCC0,
|
||||
"manager_request": 0x1480D2340,
|
||||
"data_source_request": 0x180120270,
|
||||
"ui_dispatch": 0x1480D1070,
|
||||
}
|
||||
script = build_gdb_script(
|
||||
45949, 0x180000000, 0x140000000, "/tmp/create-action.log"
|
||||
)
|
||||
assert script.count("hbreak *") == 4
|
||||
assert f"$edx == 0x{ACTION_CREATE_MATCH:x}" in script
|
||||
assert f"$edx == 0x{CREATE_DATA_SOURCE:x}" in script
|
||||
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert "request_method" in script
|
||||
assert "set *(" not in script
|
||||
print("match_create_action_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
|
||||
output = args.output or f"/tmp/fifa17-match-create-action-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, fifa_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-create-action-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17 provider delivery lookup without heap-address assumptions.
|
||||
|
||||
The probe anchors the real CardsDLL call sequence in FUN_1801a4cd0 and the
|
||||
provider-specific FUT_CREATE_MATCH_DP readiness check in FUN_1800be500:
|
||||
|
||||
vslot +0x38 call -> create gate return -> returned target -> UI bridge
|
||||
|
||||
For FUT_CREATE_MATCH_DP and FUT_GET_MATCH_KITS_DP it records the live controller
|
||||
vtable, concrete lookup function, event service, readiness-gate implementation,
|
||||
every register input, returned target, and whether the native-to-UI bridge
|
||||
executes. No post-event object identity is used.
|
||||
|
||||
The generated GDB program uses hardware-assisted execution breakpoints only.
|
||||
It never writes client memory and never drives game input.
|
||||
|
||||
match_delivery_lifecycle_trace.py [pid] [--output PATH]
|
||||
match_delivery_lifecycle_trace.py --print-script [pid]
|
||||
match_delivery_lifecycle_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
LOOKUP_CALL_RVA = transition.PROVIDER_DISPATCH_RVA + 0x2C
|
||||
LOOKUP_RETURN_RVA = transition.PROVIDER_DISPATCH_RVA + 0x2F
|
||||
BRIDGE_CALL_RVA = transition.PROVIDER_DISPATCH_RVA + 0x71
|
||||
CREATE_GATE_RETURN_RVA = 0x0BE647
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"lookup_call": cards_base + LOOKUP_CALL_RVA,
|
||||
"gate_return": cards_base + CREATE_GATE_RETURN_RVA,
|
||||
"lookup_return": cards_base + LOOKUP_RETURN_RVA,
|
||||
"bridge": cards_base + BRIDGE_CALL_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(pid: int, cards_base: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
set $current_provider = 0
|
||||
set $current_payload = 0
|
||||
set $current_controller = 0
|
||||
set $current_vtable = 0
|
||||
set $current_lookup = 0
|
||||
set $current_service = 0
|
||||
set $current_service_vtable = 0
|
||||
set $current_gate = 0
|
||||
|
||||
hbreak *0x{address['lookup_call']:x}
|
||||
condition 1 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
set $current_provider = $edi
|
||||
set $current_payload = $rbp
|
||||
set $current_controller = $rcx
|
||||
set $current_vtable = *(void**)$rcx
|
||||
set $current_lookup = *(void**)(*(void**)$rcx+0x38)
|
||||
set $current_service = *(void**)($rcx+0x18)
|
||||
set $current_service_vtable = *(void**)$current_service
|
||||
set $current_gate = *(void**)($current_service_vtable+0x58)
|
||||
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d LOOKUP_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x this=%p outer_controller=%p payload=%p vtable=%p lookup_fn=%p service=%p service_vtable=%p gate_fn=%p controller_mode=%#x controller_flag=%#x rdx=%p r8=%p r9=%p state_rbx=%p state_rbp=%p\\n", $_thread, $edi, $rcx, $rbx, $rbp, $current_vtable, $current_lookup, $current_service, $current_service_vtable, $current_gate, *(unsigned int*)($rcx+0x140), *(unsigned char*)($rcx+0x152), $rdx, $r8, $r9, $rbx, $rbp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['gate_return']:x}
|
||||
condition 2 $current_provider == 0x{transition.FUT_CREATE_MATCH_DP:x} && $rbx == $current_controller
|
||||
commands
|
||||
silent
|
||||
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d CREATE_GATE_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x controller=%p service=%p service_vtable=%p gate_fn=%p selector=0x7546 result_al=%#x\\n", $_thread, $current_provider, $current_controller, $current_service, $current_service_vtable, $current_gate, $al
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['lookup_return']:x}
|
||||
condition 3 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d LOOKUP_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x controller=%p payload=%p vtable=%p lookup_fn=%p result=%p\\n", $_thread, $edi, $rbx, $rbp, $current_vtable, $current_lookup, $rax
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['bridge']:x}
|
||||
condition 4 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d BRIDGE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x controller=%p payload=%p target=%p bridge=%p callback=%p\\n", $_thread, $edi, $current_controller, $current_payload, $rsi, $rbx, *(void**)(*(void**)$rbx+0x48)
|
||||
continue
|
||||
end
|
||||
|
||||
printf "LOOKUPTRACE ARMED pid={pid} lookup_call=0x{address['lookup_call']:x} gate_return=0x{address['gate_return']:x} lookup_return=0x{address['lookup_return']:x} bridge=0x{address['bridge']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000)
|
||||
assert address == {
|
||||
"lookup_call": 0x1801A4CFC,
|
||||
"gate_return": 0x1800BE647,
|
||||
"lookup_return": 0x1801A4CFF,
|
||||
"bridge": 0x1801A4D41,
|
||||
}
|
||||
script = build_gdb_script(35632, 0x180000000, "/tmp/lookup.log")
|
||||
assert script.count("hbreak *") == 4
|
||||
assert f"$edi == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert "LOOKUP_CALL" in script
|
||||
assert "CREATE_GATE_RETURN" in script
|
||||
assert "LOOKUP_RETURN" in script
|
||||
assert "gate_fn" in script
|
||||
assert "BRIDGE" in script
|
||||
assert "set *(" not in script
|
||||
print("match_delivery_lifecycle_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
_fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
|
||||
output = args.output or f"/tmp/fifa17-match-provider-lookup-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-provider-lookup-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17's post-kit handoff into the gameplay loading state.
|
||||
|
||||
The probe anchors the second ACTION_SAVE_MATCH_KIT (0x7576), captures the
|
||||
select-team deleting destructor with its real caller, records entry to the
|
||||
Gameplay::ScenarioModeStart consumer with the state it would advance, and
|
||||
identifies the first TestingGame update after the boundary.
|
||||
|
||||
The generated GDB program uses four hardware-assisted execution breakpoints.
|
||||
It never writes client memory, calls client functions, drives input, emits
|
||||
actions, or changes timing deliberately.
|
||||
|
||||
match_drill_transition_trace.py [pid] [--output PATH]
|
||||
match_drill_transition_trace.py --print-script [pid]
|
||||
match_drill_transition_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
SAVE_KIT_ACTION = 0x7576
|
||||
SAVE_ACTION_RVA = 0x0BFCC0
|
||||
SELECT_TEAM_DELETING_DESTRUCTOR_RVA = 0x0BE020
|
||||
TESTING_GAME_UPDATE_RVA = 0x05A410C8
|
||||
SCENARIO_MODE_START_HANDLER_RVA = 0x05A58EC0
|
||||
TESTING_GAME_VTABLE_RVA = 0x035C58A8
|
||||
TESTING_GAME_STATE_VTABLE_RVA = 0x035C2EE0
|
||||
|
||||
OWNER_STATE_OFFSET = 0x1958
|
||||
STATE_GAME_DATABASE_OFFSET = 0x17450
|
||||
STATE_PHASE_OFFSET = 0x27BEC
|
||||
STATE_SCENARIO_MODE_START_GATE_OFFSET = 0x359E8
|
||||
DATABASE_IS_SKILL_GAME_OFFSET = 0x7382
|
||||
DATABASE_TEAM_PAIR_OFFSET = 0x73C4
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"save_action": cards_base + SAVE_ACTION_RVA,
|
||||
"deleting_destructor": cards_base + SELECT_TEAM_DELETING_DESTRUCTOR_RVA,
|
||||
"testing_game_update": fifa_base + TESTING_GAME_UPDATE_RVA,
|
||||
"scenario_mode_start_handler": fifa_base + SCENARIO_MODE_START_HANDLER_RVA,
|
||||
"testing_game_vtable": fifa_base + TESTING_GAME_VTABLE_RVA,
|
||||
"testing_game_state_vtable": fifa_base + TESTING_GAME_STATE_VTABLE_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(
|
||||
pid: int,
|
||||
cards_base: int,
|
||||
fifa_base: int,
|
||||
output: str,
|
||||
) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base, fifa_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
set $save_count = 0
|
||||
set $current_controller = 0
|
||||
set $engine_seen = 0
|
||||
|
||||
hbreak *0x{address['save_action']:x}
|
||||
commands
|
||||
silent
|
||||
if $edx == 0x{SAVE_KIT_ACTION:x}
|
||||
set $save_count = $save_count + 1
|
||||
set $current_controller = $rcx
|
||||
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SAVE_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d ordinal=%d action=%#x controller=%p payload=%p second_boundary=%d\\n", $_thread, $save_count, $edx, $rcx, $r8, $save_count==2
|
||||
if $save_count == 2
|
||||
disable 1
|
||||
end
|
||||
end
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['deleting_destructor']:x}
|
||||
condition 2 $save_count >= 2 && $rcx == $current_controller
|
||||
commands
|
||||
silent
|
||||
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SELECT_TEAM_DELETING_DESTRUCTOR" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d controller=%p delete_flags=%#x caller_return=%p vtable=%p\\n", $_thread, $rcx, $edx, *(void**)$rsp, *(void**)$rcx
|
||||
x/16gx $rsp
|
||||
bt 12
|
||||
disable 2
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['scenario_mode_start_handler']:x}
|
||||
commands
|
||||
silent
|
||||
set $scenario_wrapper = $rcx
|
||||
set $scenario_state = *(void**)($scenario_wrapper+0x30)
|
||||
set $scenario_payload = $r9
|
||||
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SCENARIO_MODE_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
if $scenario_state != 0
|
||||
set $scenario_database = *(void**)($scenario_state+0x{STATE_GAME_DATABASE_OFFSET:x})
|
||||
if $scenario_database != 0
|
||||
printf "thread=%d wrapper=%p state=%p payload=%p phase=%d alternate_gate=%d is_skill_game=%d caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_state, $scenario_payload, *(unsigned int*)($scenario_state+0x{STATE_PHASE_OFFSET:x}), *(unsigned char*)($scenario_state+0x{STATE_SCENARIO_MODE_START_GATE_OFFSET:x}), *(unsigned char*)($scenario_database+0x{DATABASE_IS_SKILL_GAME_OFFSET:x}), *(void**)$rsp
|
||||
else
|
||||
printf "thread=%d wrapper=%p state=%p payload=%p database=0 caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_state, $scenario_payload, *(void**)$rsp
|
||||
end
|
||||
else
|
||||
printf "thread=%d wrapper=%p state=0 payload=%p caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_payload, *(void**)$rsp
|
||||
end
|
||||
bt 12
|
||||
disable 3
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['testing_game_update']:x}
|
||||
condition 4 $save_count >= 2 && $engine_seen == 0
|
||||
commands
|
||||
silent
|
||||
set $owner = $rsi
|
||||
set $state = *(void**)($owner+0x{OWNER_STATE_OFFSET:x})
|
||||
if $state != 0 && *(void**)$owner == 0x{address['testing_game_vtable']:x} && *(void**)$state == 0x{address['testing_game_state_vtable']:x}
|
||||
set $database = *(void**)($state+0x{STATE_GAME_DATABASE_OFFSET:x})
|
||||
if $database != 0
|
||||
set $engine_seen = 1
|
||||
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d ENGINE_HANDOFF" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d owner=%p owner_vtable=%p state=%p state_vtable=%p database=%p phase=%d is_skill_game=%d teams=%d,%d\\n", $_thread, $owner, *(void**)$owner, $state, *(void**)$state, $database, *(unsigned int*)($state+0x{STATE_PHASE_OFFSET:x}), *(unsigned char*)($database+0x{DATABASE_IS_SKILL_GAME_OFFSET:x}), *(unsigned int*)($database+0x{DATABASE_TEAM_PAIR_OFFSET:x}), *(unsigned int*)($database+0x{DATABASE_TEAM_PAIR_OFFSET + 4:x})
|
||||
bt 12
|
||||
disable 4
|
||||
end
|
||||
end
|
||||
continue
|
||||
end
|
||||
|
||||
printf "DRILLTRACE ARMED pid={pid} save_action=0x{address['save_action']:x} deleting_destructor=0x{address['deleting_destructor']:x} scenario_mode_start_handler=0x{address['scenario_mode_start_handler']:x} testing_game_update=0x{address['testing_game_update']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000, 0x140000000)
|
||||
assert address == {
|
||||
"save_action": 0x1800BFCC0,
|
||||
"deleting_destructor": 0x1800BE020,
|
||||
"testing_game_update": 0x145A410C8,
|
||||
"scenario_mode_start_handler": 0x145A58EC0,
|
||||
"testing_game_vtable": 0x1435C58A8,
|
||||
"testing_game_state_vtable": 0x1435C2EE0,
|
||||
}
|
||||
script = build_gdb_script(
|
||||
49938,
|
||||
0x180000000,
|
||||
0x140000000,
|
||||
"/tmp/drill-transition.log",
|
||||
)
|
||||
assert script.count("hbreak *") == 4
|
||||
assert "SELECT_TEAM_DELETING_DESTRUCTOR" in script
|
||||
assert "SCENARIO_MODE_START" in script
|
||||
assert "wrapper=%p state=%p payload=%p" in script
|
||||
assert "alternate_gate=%d" in script
|
||||
assert "skill_game_start_constructor" not in script
|
||||
assert "ENGINE_HANDOFF" in script
|
||||
assert "GameplayGameDatabase.IsSkillGame" not in script
|
||||
assert "set *(" not in script
|
||||
print("match_drill_transition_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(
|
||||
fifa_path,
|
||||
advance.PINNED_FIFA_SHA256,
|
||||
advance.FIFA_MODULE,
|
||||
)
|
||||
output = args.output or f"/tmp/fifa17-match-drill-transition-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, fifa_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-drill-transition-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+185
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17's post-kit boundary without changing client behavior.
|
||||
|
||||
The probe anchors both ACTION_SAVE_MATCH_KIT (0x7576) actions, their concrete
|
||||
native save call, the action-handler return, and select-team provider teardown.
|
||||
The second 0x7576 action is the temporal boundary for later drill/game-loader
|
||||
instrumentation.
|
||||
|
||||
The generated GDB program uses four hardware-assisted execution breakpoints. It
|
||||
never writes client memory, calls client functions, drives input, emits actions,
|
||||
or alters timing deliberately.
|
||||
|
||||
match_post_kit_trace.py [pid] [--output PATH]
|
||||
match_post_kit_trace.py --print-script [pid]
|
||||
match_post_kit_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
SAVE_KIT_ACTION = 0x7576
|
||||
ACTION_HANDLER_RVA = 0x0BFCC0
|
||||
SAVE_CALL_RVA = 0x0BFF25
|
||||
ACTION_RETURN_RVA = 0x0C00AE
|
||||
SELECT_TEAM_DESTRUCTOR_RVA = 0x0BDEC0
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"action": cards_base + ACTION_HANDLER_RVA,
|
||||
"save_call": cards_base + SAVE_CALL_RVA,
|
||||
"action_return": cards_base + ACTION_RETURN_RVA,
|
||||
"destructor": cards_base + SELECT_TEAM_DESTRUCTOR_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(pid: int, cards_base: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
set $save_count = 0
|
||||
set $current_action = 0
|
||||
set $current_controller = 0
|
||||
set $current_payload = 0
|
||||
set $second_save_epoch = 0
|
||||
|
||||
hbreak *0x{address['action']:x}
|
||||
condition 1 $edx == 0x{SAVE_KIT_ACTION:x}
|
||||
commands
|
||||
silent
|
||||
set $save_count = $save_count + 1
|
||||
set $current_action = $edx
|
||||
set $current_controller = $rcx
|
||||
set $current_payload = $r8
|
||||
python import time, gdb; now = time.time_ns(); gdb.set_convenience_variable("event_epoch", now); print("POSTKIT epoch_ns=%d mono_ns=%d SAVE_ACTION" % (now, time.monotonic_ns()), end=" ")
|
||||
if $save_count == 2
|
||||
set $second_save_epoch = $event_epoch
|
||||
end
|
||||
printf "thread=%d ordinal=%d action=%#x controller=%p payload=%p payload_vtable=%p mode=%#x flags_150=%#x flags_151=%#x flags_152=%#x flags_155=%#x second_boundary=%d\\n", $_thread, $save_count, $edx, $rcx, $r8, *(void**)$r8, *(unsigned int*)($rcx+0x140), *(unsigned char*)($rcx+0x150), *(unsigned char*)($rcx+0x151), *(unsigned char*)($rcx+0x152), *(unsigned char*)($rcx+0x155), $save_count==2
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['save_call']:x}
|
||||
condition 2 $current_action == 0x{SAVE_KIT_ACTION:x}
|
||||
commands
|
||||
silent
|
||||
set $save_target = *(void**)(*(void**)$rcx+0x1d0)
|
||||
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d NATIVE_SAVE_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d ordinal=%d central=%p central_vtable=%p target=%p side=%#x request=%p payload=%p\\n", $_thread, $save_count, $rcx, *(void**)$rcx, $save_target, $r8d, $rdx, $current_payload
|
||||
x/12gx $rdx
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['action_return']:x}
|
||||
condition 3 $current_action == 0x{SAVE_KIT_ACTION:x} && $rsi == $current_controller
|
||||
commands
|
||||
silent
|
||||
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d ACTION_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d ordinal=%d controller=%p handled=%#x mode=%#x flags_150=%#x flags_151=%#x flags_152=%#x flags_155=%#x\\n", $_thread, $save_count, $rsi, $al, *(unsigned int*)($rsi+0x140), *(unsigned char*)($rsi+0x150), *(unsigned char*)($rsi+0x151), *(unsigned char*)($rsi+0x152), *(unsigned char*)($rsi+0x155)
|
||||
set $current_action = 0
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['destructor']:x}
|
||||
condition 4 $save_count >= 2 && $rcx == $current_controller
|
||||
commands
|
||||
silent
|
||||
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d SELECT_TEAM_DESTRUCTOR" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d controller=%p save_count=%d second_save_epoch=%lld vtable=%p mode=%#x\\n", $_thread, $rcx, $save_count, $second_save_epoch, *(void**)$rcx, *(unsigned int*)($rcx+0x140)
|
||||
bt 12
|
||||
continue
|
||||
end
|
||||
|
||||
printf "POSTKIT ARMED pid={pid} action=0x{address['action']:x} save_call=0x{address['save_call']:x} action_return=0x{address['action_return']:x} destructor=0x{address['destructor']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000)
|
||||
assert address == {
|
||||
"action": 0x1800BFCC0,
|
||||
"save_call": 0x1800BFF25,
|
||||
"action_return": 0x1800C00AE,
|
||||
"destructor": 0x1800BDEC0,
|
||||
}
|
||||
script = build_gdb_script(47872, 0x180000000, "/tmp/post-kit.log")
|
||||
assert script.count("hbreak *") == 4
|
||||
assert f"$edx == 0x{SAVE_KIT_ACTION:x}" in script
|
||||
assert "second_boundary" in script
|
||||
assert "NATIVE_SAVE_CALL" in script
|
||||
assert "SELECT_TEAM_DESTRUCTOR" in script
|
||||
assert "set *(" not in script
|
||||
print("match_post_kit_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
_fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
|
||||
output = args.output or f"/tmp/fifa17-match-post-kit-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-post-kit-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17 create-response readiness versus UI provider dispatch.
|
||||
|
||||
The probe correlates four concrete lifecycle boundaries:
|
||||
|
||||
* FutCreateMatchServerResponse data-source request;
|
||||
* the POST /match network response callback;
|
||||
* the response readiness/completion callback;
|
||||
* FIFA's global UI dispatch of providers 0x7563 and 0x7565.
|
||||
|
||||
This distinguishes network completion from the separate DataManager readiness
|
||||
lifecycle without assuming any screen or heap-object identity. The generated GDB
|
||||
program uses hardware-assisted execution breakpoints only. It never writes
|
||||
client memory and never drives game input.
|
||||
|
||||
match_provider_producer_trace.py [pid] [--output PATH]
|
||||
match_provider_producer_trace.py --print-script [pid]
|
||||
match_provider_producer_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
DATA_SOURCE_REQUEST_RVA = 0x120270
|
||||
NETWORK_RESPONSE_RVA = transition.RESPONSE_CALLBACK_RVA
|
||||
CREATE_COMPLETE_RVA = 0x120000
|
||||
GLOBAL_UI_DISPATCH_RVA = advance.GLOBAL_UI_DISPATCH_RVA
|
||||
CREATE_RESPONSE_OFFSET = 0xA0
|
||||
CREATE_DATA_SOURCE_OFFSET = CREATE_RESPONSE_OFFSET + 0x50
|
||||
CREATE_READY_OFFSET = CREATE_RESPONSE_OFFSET + 0x88
|
||||
ACTIVE_CALLBACK_OFFSET = 0x47D0
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"data_source_request": cards_base + DATA_SOURCE_REQUEST_RVA,
|
||||
"network_response": cards_base + NETWORK_RESPONSE_RVA,
|
||||
"create_complete": cards_base + CREATE_COMPLETE_RVA,
|
||||
"ui_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(
|
||||
pid: int, cards_base: int, fifa_base: int, output: str
|
||||
) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base, fifa_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
set $last_central = 0
|
||||
set $last_response = 0
|
||||
set $last_data_source = 0
|
||||
set $last_descriptor = 0
|
||||
|
||||
hbreak *0x{address['data_source_request']:x}
|
||||
commands
|
||||
silent
|
||||
set $request_data_source = $rcx
|
||||
set $request_response = $rcx - 0x50
|
||||
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d DATA_SOURCE_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d response=%p data_source=%p request=%p ready_before=%#x callback_adapter=%p callback_context=%p callback_target=%p\\n", $_thread, $request_response, $request_data_source, $rdx, *(unsigned char*)($request_data_source+0x38), *(void**)($request_response+0x90), *(void**)($request_response+0x98), *(void**)($request_response+0xa0)
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['network_response']:x}
|
||||
commands
|
||||
silent
|
||||
set $last_central = $rcx
|
||||
set $last_response = $rcx + 0x{CREATE_RESPONSE_OFFSET:x}
|
||||
set $last_data_source = $rcx + 0x{CREATE_DATA_SOURCE_OFFSET:x}
|
||||
set $last_descriptor = $rdx
|
||||
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d NETWORK_RESPONSE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
if $rdx == 0
|
||||
printf "thread=%d central=%p descriptor=(nil) status=UNKNOWN wire_payload=(nil) response=%p data_source=%p ready=%#x active_adapter=%p active_context=%p active_target=%p\\n", $_thread, $last_central, $last_response, $last_data_source, *(unsigned char*)($last_central+0x{CREATE_READY_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 8:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 16:x})
|
||||
else
|
||||
printf "thread=%d central=%p descriptor=%p status=%#x wire_payload=%p response=%p data_source=%p ready=%#x active_adapter=%p active_context=%p active_target=%p\\n", $_thread, $last_central, $rdx, *(unsigned int*)($rdx+0x1c), *(void**)($rdx+0x28), $last_response, $last_data_source, *(unsigned char*)($last_central+0x{CREATE_READY_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 8:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 16:x})
|
||||
end
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['create_complete']:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d CREATE_COMPLETE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
if $rdx == 0
|
||||
printf "thread=%d response=%p data_source=%p ready_before=%#x descriptor=(nil) status=UNKNOWN last_response=%p same_response=%d\\n", $_thread, $rcx, $rcx+0x50, *(unsigned char*)($rcx+0x88), $last_response, $rcx==$last_response
|
||||
else
|
||||
printf "thread=%d response=%p data_source=%p ready_before=%#x descriptor=%p status=%#x last_response=%p same_response=%d\\n", $_thread, $rcx, $rcx+0x50, *(unsigned char*)($rcx+0x88), $rdx, *(unsigned int*)($rdx+0x1c), $last_response, $rcx==$last_response
|
||||
end
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['ui_dispatch']:x}
|
||||
condition 4 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d UI_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
if $last_response == 0
|
||||
printf "thread=%d provider=%#x payload=%p ui_manager=%p last_response=(nil) ready=UNKNOWN\\n", $_thread, $r8d, $rdx, $rcx
|
||||
else
|
||||
printf "thread=%d provider=%#x payload=%p ui_manager=%p last_response=%p data_source=%p ready=%#x descriptor=%p\\n", $_thread, $r8d, $rdx, $rcx, $last_response, $last_data_source, *(unsigned char*)($last_response+0x88), $last_descriptor
|
||||
end
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
printf "RESPTRACE ARMED pid={pid} data_source_request=0x{address['data_source_request']:x} network_response=0x{address['network_response']:x} create_complete=0x{address['create_complete']:x} ui_dispatch=0x{address['ui_dispatch']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000, 0x140000000)
|
||||
assert address == {
|
||||
"data_source_request": 0x180120270,
|
||||
"network_response": 0x180114D90,
|
||||
"create_complete": 0x180120000,
|
||||
"ui_dispatch": 0x1480D1070,
|
||||
}
|
||||
script = build_gdb_script(
|
||||
38872, 0x180000000, 0x140000000, "/tmp/response-lifecycle.log"
|
||||
)
|
||||
assert script.count("hbreak *") == 4
|
||||
assert "DATA_SOURCE_REQUEST" in script
|
||||
assert "NETWORK_RESPONSE" in script
|
||||
assert "CREATE_COMPLETE" in script
|
||||
assert "UI_DISPATCH" in script
|
||||
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert "set *(" not in script
|
||||
print("match_provider_producer_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
|
||||
output = args.output or f"/tmp/fifa17-match-response-lifecycle-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, fifa_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-response-lifecycle-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+233
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace the FIFA17 create-match publish boundary with hardware breakpoints.
|
||||
|
||||
The tracer covers the client-local path after POST /match:
|
||||
|
||||
response callback -> deserializer -> controller event 0x7546
|
||||
-> FUT_CREATE_MATCH_DP 0x7563
|
||||
|
||||
FUT_GET_MATCH_KITS_DP 0x7565 is captured as the positive control through the
|
||||
same native dispatcher. The generated GDB program uses only hardware-assisted
|
||||
execution breakpoints. It never writes client memory and never drives game
|
||||
input.
|
||||
|
||||
match_transition_trace.py [pid] [--output PATH]
|
||||
match_transition_trace.py --print-script [pid]
|
||||
match_transition_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
CARDS_MODULE = "CardsDLL_Win64_retail.dll"
|
||||
PINNED_CARDS_SHA256 = "4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c"
|
||||
RESPONSE_CALLBACK_RVA = 0x114D90
|
||||
DESERIALIZE_SUCCESS_RVA = 0x118940
|
||||
CREATE_MATCH_CONTROLLER_RVA = 0xBF950
|
||||
PROVIDER_DISPATCH_RVA = 0x1A4CD0
|
||||
CREATE_MATCH_CONTROLLER_EVENT = 0x7546
|
||||
FUT_CREATE_MATCH_DP = 0x7563
|
||||
FUT_GET_MATCH_KITS_DP = 0x7565
|
||||
|
||||
|
||||
def find_pid() -> int | None:
|
||||
found = []
|
||||
for directory in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
with open(os.path.join(directory, "comm"), encoding="utf-8") as handle:
|
||||
if handle.read().strip() != "FIFA17.exe":
|
||||
continue
|
||||
pid = int(os.path.basename(directory))
|
||||
with open(os.path.join(directory, "statm"), encoding="utf-8") as handle:
|
||||
resident_pages = int(handle.read().split()[1])
|
||||
found.append((resident_pages, pid))
|
||||
except (OSError, ValueError, IndexError):
|
||||
continue
|
||||
return max(found)[1] if found else None
|
||||
|
||||
|
||||
def parse_cards_mapping(lines) -> tuple[int, str]:
|
||||
for line in lines:
|
||||
fields = line.split(maxsplit=5)
|
||||
path = fields[5].rstrip() if len(fields) == 6 else ""
|
||||
if not path.endswith(CARDS_MODULE):
|
||||
continue
|
||||
start = int(fields[0].split("-", 1)[0], 16)
|
||||
return start, path
|
||||
raise RuntimeError(f"{CARDS_MODULE} is not mapped")
|
||||
|
||||
|
||||
def cards_mapping(pid: int) -> tuple[int, str]:
|
||||
with open(f"/proc/{pid}/maps", encoding="utf-8") as handle:
|
||||
try:
|
||||
return parse_cards_mapping(handle)
|
||||
except RuntimeError as error:
|
||||
raise RuntimeError(f"{error} in PID {pid}") from error
|
||||
|
||||
|
||||
def sha256_file(path: str) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def validate_cards(path: str) -> None:
|
||||
actual = sha256_file(path)
|
||||
if actual != PINNED_CARDS_SHA256:
|
||||
raise RuntimeError(
|
||||
f"unsupported {CARDS_MODULE}: sha256={actual}; expected={PINNED_CARDS_SHA256}"
|
||||
)
|
||||
|
||||
|
||||
def trace_addresses(base: int) -> dict[str, int]:
|
||||
return {
|
||||
"response": base + RESPONSE_CALLBACK_RVA,
|
||||
"deserialize": base + DESERIALIZE_SUCCESS_RVA,
|
||||
"controller": base + CREATE_MATCH_CONTROLLER_RVA,
|
||||
"provider": base + PROVIDER_DISPATCH_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(pid: int, base: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
|
||||
hbreak *0x{address['response']:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T3_RESPONSE_CALLBACK" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
if $rdx != 0
|
||||
printf "thread=%d manager=%p status_obj=%p status=%u wire_payload=%p caller=%p\\n", $_thread, $rcx, $rdx, *(unsigned int*)($rdx+0x1c), *(void**)($rdx+0x28), *(void**)$rsp
|
||||
else
|
||||
printf "thread=%d manager=%p status_obj=0 caller=%p\\n", $_thread, $rcx, *(void**)$rsp
|
||||
end
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['deserialize']:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T4_DESERIALIZE_SUCCESS" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d manager=%p payload=%p caller=%p\\n", $_thread, $rcx, $rdx, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['controller']:x}
|
||||
condition 3 $edx == 0x{CREATE_MATCH_CONTROLLER_EVENT:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T5_CREATE_MATCH_CONTROLLER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d controller_subobject=%p event=%#x caller=%p\\n", $_thread, $rcx, $edx, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['provider']:x}
|
||||
condition 4 $edx == 0x{FUT_CREATE_MATCH_DP:x} || $edx == 0x{FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T6_PROVIDER_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d controller=%p provider=%#x payload=%p callback=%p\\n", $_thread, $rcx, $edx, $r8, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
printf "HWTRACE ARMED pid={pid} response=0x{address['response']:x} deserialize=0x{address['deserialize']:x} controller=0x{address['controller']:x} provider=0x{address['provider']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
base = 0x180000000
|
||||
address = trace_addresses(base)
|
||||
assert address == {
|
||||
"response": 0x180114D90,
|
||||
"deserialize": 0x180118940,
|
||||
"controller": 0x1800BF950,
|
||||
"provider": 0x1801A4CD0,
|
||||
}
|
||||
mapping = parse_cards_mapping(
|
||||
[
|
||||
"6ffffc0f0000-6ffffc0f1000 r--p 00000000 00:37 2941670 "
|
||||
"/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll\n"
|
||||
]
|
||||
)
|
||||
assert mapping == (
|
||||
0x6FFFFC0F0000,
|
||||
"/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll",
|
||||
)
|
||||
script = build_gdb_script(25718, base, "/tmp/match-transition.log")
|
||||
assert script.count("hbreak *") == 4
|
||||
assert f"$edx == 0x{CREATE_MATCH_CONTROLLER_EVENT:x}" in script
|
||||
assert f"$edx == 0x{FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$edx == 0x{FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert "T3_RESPONSE_CALLBACK" in script
|
||||
assert "T4_DESERIALIZE_SUCCESS" in script
|
||||
assert "T5_CREATE_MATCH_CONTROLLER" in script
|
||||
assert "T6_PROVIDER_DISPATCH" in script
|
||||
assert "set *(" not in script
|
||||
print("match_transition_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
base, cards_path = cards_mapping(pid)
|
||||
validate_cards(cards_path)
|
||||
output = args.output or f"/tmp/fifa17-match-transition-{pid}.log"
|
||||
script = build_gdb_script(pid, base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-transition-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+306
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only dynamic locator for FIFA17 Offline Seasons match state.
|
||||
|
||||
Never relies on heap addresses or allocator handles. It identifies:
|
||||
|
||||
* the 10 x 16-byte parsed fixture array from its complete wire-derived record
|
||||
sequence (teamId/difficulty/roundId/rewardMult/coins),
|
||||
* match-team records from the corrected invariant prefix (11,7,0,0,76), never
|
||||
from the transient +0x18 handle,
|
||||
* the match-config team pair from structural fields around it, not its team ids.
|
||||
|
||||
offline_match_locator.py [pid] [--fixture-index 0] [--json]
|
||||
offline_match_locator.py --selftest
|
||||
|
||||
READ-ONLY: /proc/<pid>/mem is opened 'rb'. No debugger and no game input.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
DEFAULT_TEAMS = (73, 240, 241, 243, 73, 240, 241, 243, 73, 240)
|
||||
MATCH_HEADER = struct.pack("<5i", 11, 7, 0, 0, 76)
|
||||
PARTICIPANT_PREFIX = struct.pack("<8i", -1, -2, -1, -2, -1, -2, -1, -2)
|
||||
F01 = 0x3DCCCCCD
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fixture:
|
||||
address: int
|
||||
selected_address: int
|
||||
selected_index: int
|
||||
selected_team_id: int
|
||||
records: list[dict[str, int]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchTeam:
|
||||
address: int
|
||||
team_id: int
|
||||
marker_18: int
|
||||
marker_1c: int
|
||||
xi: list[int]
|
||||
substitutes: list[int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchConfig:
|
||||
pair_address: int
|
||||
team_id_0: int
|
||||
team_id_1: int
|
||||
player_count_0: int
|
||||
player_count_1: int
|
||||
|
||||
|
||||
def find_pids() -> list[int]:
|
||||
"""All live FIFA17.exe processes, largest resident set first.
|
||||
|
||||
The UMU/Proton launch chain briefly creates a small process with the same
|
||||
comm before the real game. Returning the first /proc glob match attached
|
||||
the trace supervisor to that short-lived process and missed the match.
|
||||
"""
|
||||
found = []
|
||||
for directory in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
with open(os.path.join(directory, "comm")) as handle:
|
||||
if handle.read().strip() != "FIFA17.exe":
|
||||
continue
|
||||
pid = int(os.path.basename(directory))
|
||||
with open(os.path.join(directory, "statm")) as handle:
|
||||
resident_pages = int(handle.read().split()[1])
|
||||
found.append((resident_pages, pid))
|
||||
except (OSError, ValueError, IndexError):
|
||||
continue
|
||||
return [pid for _resident, pid in sorted(found, reverse=True)]
|
||||
|
||||
|
||||
def find_pid() -> int | None:
|
||||
pids = find_pids()
|
||||
return pids[0] if pids else None
|
||||
|
||||
|
||||
def fixture_bytes(teams: tuple[int, ...] = DEFAULT_TEAMS) -> bytes:
|
||||
return b"".join(
|
||||
struct.pack("<iBBHii", team_id, 1, round_id, 0, 1, 400)
|
||||
for round_id, team_id in enumerate(teams)
|
||||
)
|
||||
|
||||
|
||||
def readable_regions(pid: int, *, writable_anon_only: bool = False):
|
||||
with open(f"/proc/{pid}/maps") as maps:
|
||||
for line in maps:
|
||||
match = re.match(
|
||||
r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)",
|
||||
line,
|
||||
)
|
||||
if not match:
|
||||
continue
|
||||
lo, hi = int(match.group(1), 16), int(match.group(2), 16)
|
||||
perms, path = match.group(3), match.group(4).strip()
|
||||
if perms[0] != "r" or path.startswith(("/dev", "/memfd")):
|
||||
continue
|
||||
if hi - lo > 512 * 1024 * 1024:
|
||||
continue
|
||||
if writable_anon_only and (perms[1] != "w" or path):
|
||||
continue
|
||||
yield lo, hi, perms, path
|
||||
|
||||
|
||||
def _i32(buf: bytes, offset: int) -> int:
|
||||
return struct.unpack_from("<i", buf, offset)[0]
|
||||
|
||||
|
||||
def scan_fixture_buffer(buf: bytes, base: int, selected_index: int) -> list[Fixture]:
|
||||
pattern = fixture_bytes()
|
||||
found = []
|
||||
offset = buf.find(pattern)
|
||||
while offset >= 0:
|
||||
records = []
|
||||
for round_id in range(len(DEFAULT_TEAMS)):
|
||||
at = offset + round_id * 16
|
||||
team_id, difficulty, parsed_round, _pad, reward_mult, coins = struct.unpack_from(
|
||||
"<iBBHii", buf, at
|
||||
)
|
||||
records.append(
|
||||
{
|
||||
"team_id": team_id,
|
||||
"difficulty": difficulty,
|
||||
"round_id": parsed_round,
|
||||
"reward_mult": reward_mult,
|
||||
"coins": coins,
|
||||
}
|
||||
)
|
||||
found.append(
|
||||
Fixture(
|
||||
address=base + offset,
|
||||
selected_address=base + offset + selected_index * 16,
|
||||
selected_index=selected_index,
|
||||
selected_team_id=records[selected_index]["team_id"],
|
||||
records=records,
|
||||
)
|
||||
)
|
||||
offset = buf.find(pattern, offset + 4)
|
||||
return found
|
||||
|
||||
|
||||
def scan_match_team_buffer(buf: bytes, base: int) -> list[MatchTeam]:
|
||||
found = []
|
||||
offset = buf.find(MATCH_HEADER)
|
||||
while offset >= 0:
|
||||
if offset + 0x7C <= len(buf):
|
||||
found.append(
|
||||
MatchTeam(
|
||||
address=base + offset,
|
||||
team_id=_i32(buf, offset + 0x14),
|
||||
marker_18=_i32(buf, offset + 0x18),
|
||||
marker_1c=_i32(buf, offset + 0x1C),
|
||||
xi=list(struct.unpack_from("<11i", buf, offset + 0x20)),
|
||||
substitutes=list(struct.unpack_from("<12i", buf, offset + 0x4C)),
|
||||
)
|
||||
)
|
||||
offset = buf.find(MATCH_HEADER, offset + 4)
|
||||
return found
|
||||
|
||||
|
||||
def _valid_config(buf: bytes, pair: int) -> bool:
|
||||
required = pair + 0x50
|
||||
if pair < 0 or required > len(buf):
|
||||
return False
|
||||
return (
|
||||
tuple(struct.unpack_from("<4I", buf, pair + 0x1C)) == (F01, F01, F01, F01)
|
||||
and _i32(buf, pair + 0x38) == 11
|
||||
and _i32(buf, pair + 0x3C) == 11
|
||||
and _i32(buf, pair + 0x40) == 0
|
||||
and _i32(buf, pair + 0x44) == 5
|
||||
)
|
||||
|
||||
|
||||
def scan_match_config_buffer(buf: bytes, base: int) -> list[MatchConfig]:
|
||||
found = []
|
||||
offset = buf.find(PARTICIPANT_PREFIX)
|
||||
while offset >= 0:
|
||||
pair = offset + len(PARTICIPANT_PREFIX)
|
||||
if _valid_config(buf, pair):
|
||||
found.append(
|
||||
MatchConfig(
|
||||
pair_address=base + pair,
|
||||
team_id_0=_i32(buf, pair),
|
||||
team_id_1=_i32(buf, pair + 4),
|
||||
player_count_0=_i32(buf, pair + 0x38),
|
||||
player_count_1=_i32(buf, pair + 0x3C),
|
||||
)
|
||||
)
|
||||
offset = buf.find(PARTICIPANT_PREFIX, offset + 4)
|
||||
return found
|
||||
|
||||
|
||||
def scan_process(
|
||||
pid: int,
|
||||
selected_index: int,
|
||||
*,
|
||||
include_fixture: bool = True,
|
||||
writable_anon_only: bool = False,
|
||||
) -> dict[str, list]:
|
||||
result: dict[str, list] = {"fixtures": [], "match_teams": [], "match_configs": []}
|
||||
with open(f"/proc/{pid}/mem", "rb", 0) as memory:
|
||||
for lo, hi, _perms, _path in readable_regions(
|
||||
pid, writable_anon_only=writable_anon_only
|
||||
):
|
||||
try:
|
||||
memory.seek(lo)
|
||||
buf = memory.read(hi - lo)
|
||||
except (OSError, ValueError, OverflowError):
|
||||
continue
|
||||
if include_fixture:
|
||||
result["fixtures"].extend(scan_fixture_buffer(buf, lo, selected_index))
|
||||
result["match_teams"].extend(scan_match_team_buffer(buf, lo))
|
||||
result["match_configs"].extend(scan_match_config_buffer(buf, lo))
|
||||
return result
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
fixture = fixture_bytes()
|
||||
team = bytearray(0x7C)
|
||||
team[:20] = MATCH_HEADER
|
||||
struct.pack_into("<iii", team, 0x14, 130000, 0x54001, 0x54002)
|
||||
struct.pack_into("<11i", team, 0x20, *range(11))
|
||||
struct.pack_into("<12i", team, 0x4C, *range(20, 32))
|
||||
config = bytearray(0x20 + 0x50)
|
||||
config[:0x20] = PARTICIPANT_PREFIX
|
||||
pair = 0x20
|
||||
struct.pack_into("<ii", config, pair, 130000, 130000)
|
||||
struct.pack_into("<4I", config, pair + 0x1C, F01, F01, F01, F01)
|
||||
struct.pack_into("<iiii", config, pair + 0x38, 11, 11, 0, 5)
|
||||
buf = b"X" * 32 + fixture + b"Y" * 32 + team + b"Z" * 32 + config
|
||||
fixtures = scan_fixture_buffer(buf, 0x1000, 0)
|
||||
teams = scan_match_team_buffer(buf, 0x1000)
|
||||
configs = scan_match_config_buffer(buf, 0x1000)
|
||||
assert len(fixtures) == 1 and fixtures[0].selected_team_id == 73
|
||||
assert len(teams) == 1 and teams[0].team_id == 130000
|
||||
assert len(configs) == 1 and configs[0].team_id_1 == 130000
|
||||
# The transient handle is never part of the anchor.
|
||||
struct.pack_into("<i", team, 0x18, -1)
|
||||
assert len(scan_match_team_buffer(bytes(team), 0)) == 1
|
||||
print("offline_match_locator selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--fixture-index", type=int, default=0)
|
||||
parser.add_argument("--json", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
parser.add_argument("--writable-anon-only", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
pid = args.pid or find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
if not 0 <= args.fixture_index < len(DEFAULT_TEAMS):
|
||||
print("--fixture-index must be 0..9", file=sys.stderr)
|
||||
return 2
|
||||
result = scan_process(
|
||||
pid,
|
||||
args.fixture_index,
|
||||
writable_anon_only=args.writable_anon_only,
|
||||
)
|
||||
serial = {key: [asdict(value) for value in values] for key, values in result.items()}
|
||||
serial["pid"] = pid
|
||||
if args.json:
|
||||
print(json.dumps(serial, sort_keys=True))
|
||||
return 0
|
||||
print(f"pid={pid}")
|
||||
for fixture in result["fixtures"]:
|
||||
print(
|
||||
f"fixture @0x{fixture.address:x}; selected index {fixture.selected_index} "
|
||||
f"@0x{fixture.selected_address:x} teamId={fixture.selected_team_id}"
|
||||
)
|
||||
for config in result["match_configs"]:
|
||||
print(
|
||||
f"match config pair @0x{config.pair_address:x}: "
|
||||
f"[{config.team_id_0}, {config.team_id_1}]"
|
||||
)
|
||||
for team in result["match_teams"]:
|
||||
print(
|
||||
f"match team @0x{team.address:x}: teamId={team.team_id} "
|
||||
f"handles=[{team.marker_18}, {team.marker_1c}]"
|
||||
)
|
||||
print(
|
||||
f"counts: fixtures={len(result['fixtures'])} "
|
||||
f"configs={len(result['match_configs'])} teams={len(result['match_teams'])}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+394
@@ -0,0 +1,394 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17's PMA ScenarioModeStart-to-event-5 producer chain.
|
||||
|
||||
The generated GDB program uses hardware breakpoints, only reads registers and
|
||||
client memory, logs, and continues. Breakpoints are rotated so no more than four
|
||||
are enabled. It never calls client functions, writes client memory, emits an
|
||||
event, or drives input.
|
||||
|
||||
pma_producer_trace.py [pid] [--variant mode0|alternate] [--output PATH]
|
||||
pma_producer_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
VARIANTS = {
|
||||
"mode0": {
|
||||
"scenario_rva": 0x07B1C190,
|
||||
"writer_rva": 0x07B1C26B,
|
||||
"register_rva": 0x07B1C282,
|
||||
"writer_context": "$rsi",
|
||||
"writer_async_requested": "1",
|
||||
"arm_condition": "1",
|
||||
},
|
||||
"alternate": {
|
||||
"scenario_rva": 0x07B1C050,
|
||||
"writer_rva": 0x07B1C12F,
|
||||
"register_rva": 0x07B1C146,
|
||||
"writer_context": "$rbp",
|
||||
"writer_async_requested": "$sil",
|
||||
"arm_condition": "$tracked_ctx != 0 && $rcx == $tracked_ctx",
|
||||
},
|
||||
}
|
||||
PMA_COMPLETION_ARM_RVA = 0x07B1AE60
|
||||
PMA_COMPLETION_ARM_WRITER_RVA = 0x07B1AF33
|
||||
ASYNC_COMPLETION_RVA = 0x07B046C0
|
||||
CALLBACK_DISPATCHER_RVA = 0x07AC87B0
|
||||
PMA_INSTRUCTIONS_HANDLER_RVA = 0x07AC91E0
|
||||
GAMEPLAY_GLOBAL_RVA = 0x04BFB910
|
||||
PMA_INSTRUCTIONS_VTABLE_RVA = 0x03AF2750
|
||||
|
||||
|
||||
def addresses(base: int, variant: str) -> dict[str, int]:
|
||||
config = VARIANTS[variant]
|
||||
return {
|
||||
"scenario": base + config["scenario_rva"],
|
||||
"writer": base + config["writer_rva"],
|
||||
"register": base + config["register_rva"],
|
||||
"arm": base + PMA_COMPLETION_ARM_RVA,
|
||||
"arm_writer": base + PMA_COMPLETION_ARM_WRITER_RVA,
|
||||
"completion": base + ASYNC_COMPLETION_RVA,
|
||||
"dispatcher": base + CALLBACK_DISPATCHER_RVA,
|
||||
"instructions": base + PMA_INSTRUCTIONS_HANDLER_RVA,
|
||||
"gameplay_global": base + GAMEPLAY_GLOBAL_RVA,
|
||||
"instructions_vtable": base + PMA_INSTRUCTIONS_VTABLE_RVA,
|
||||
}
|
||||
|
||||
|
||||
def gdb_prelude(pid: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted off
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
"""
|
||||
|
||||
|
||||
def build_script(pid: int, fifa_base: int, output: str, variant: str) -> str:
|
||||
address = addresses(fifa_base, variant)
|
||||
config = VARIANTS[variant]
|
||||
return (
|
||||
gdb_prelude(pid, output)
|
||||
+ f"""define snapshot_pma_context
|
||||
set $snap_ctx = $arg0
|
||||
set $snap_flag40 = -1
|
||||
set $snap_callback_vtable = 0
|
||||
set $snap_callback_owner = 0
|
||||
set $snap_dispatcher = 0
|
||||
set $snap_dispatcher_vtable = 0
|
||||
set $snap_pma = 0
|
||||
set $snap_pma_flag18 = -1
|
||||
set $snap_pma_parent = 0
|
||||
set $snap_pma_machine = 0
|
||||
set $snap_pma_current = 0
|
||||
if $snap_ctx != 0
|
||||
set $snap_flag40 = *(unsigned char*)($snap_ctx+0x40)
|
||||
set $snap_callback_vtable = *(void**)($snap_ctx+0x48)
|
||||
set $snap_callback_owner = *(void**)($snap_ctx+0x78)
|
||||
set $snap_dispatcher = $snap_ctx+0x80
|
||||
set $snap_dispatcher_vtable = *(void**)$snap_dispatcher
|
||||
set $snap_sentinel = $snap_ctx+0x88
|
||||
set $snap_node = *(void**)$snap_sentinel
|
||||
set $snap_scan = 0
|
||||
while $snap_node != 0 && $snap_node != $snap_sentinel && $snap_scan < 8
|
||||
set $snap_candidate = *(void**)($snap_node+0x10)
|
||||
if $snap_candidate != 0
|
||||
if *(void**)$snap_candidate == 0x{address['instructions_vtable']:x}
|
||||
set $snap_pma = $snap_candidate
|
||||
end
|
||||
end
|
||||
set $snap_node = *(void**)$snap_node
|
||||
set $snap_scan = $snap_scan+1
|
||||
end
|
||||
if $snap_pma != 0
|
||||
set $snap_pma_flag18 = *(unsigned char*)($snap_pma+0x18)
|
||||
set $snap_pma_parent = *(void**)($snap_pma+0x8)
|
||||
if $snap_pma_parent != 0
|
||||
set $snap_pma_machine = *(void**)($snap_pma_parent+0x8)
|
||||
end
|
||||
if $snap_pma_machine != 0
|
||||
set $snap_pma_current = *(void**)($snap_pma_machine+0x10)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
define snapshot_gameplay
|
||||
set $snap_gameplay_global = *(void**)0x{address['gameplay_global']:x}
|
||||
set $snap_listener_manager = 0
|
||||
set $snap_listener_table = 0
|
||||
set $snap_listener_index = -1
|
||||
set $snap_free_roam = 0
|
||||
set $snap_free_roam_state = -1
|
||||
set $snap_free_roam_111 = -1
|
||||
set $snap_free_roam_112 = -1
|
||||
set $snap_free_roam_124 = -1
|
||||
set $snap_selected = 0
|
||||
set $snap_selected_vtable = 0
|
||||
set $snap_selected_mode = -1
|
||||
if $snap_gameplay_global != 0
|
||||
set $snap_listener_manager = *(void**)($snap_gameplay_global+0x58)
|
||||
end
|
||||
if $snap_listener_manager != 0
|
||||
set $snap_listener_table = *(void**)$snap_listener_manager
|
||||
end
|
||||
if $snap_listener_table != 0
|
||||
set $snap_free_roam = *(void**)$snap_listener_table
|
||||
set $snap_listener_index = *(int*)($snap_listener_table+0x20)
|
||||
if $snap_listener_index >= 0 && $snap_listener_index < 3
|
||||
set $snap_selected = *(void**)($snap_listener_table+$snap_listener_index*8)
|
||||
end
|
||||
end
|
||||
if $snap_free_roam != 0
|
||||
set $snap_free_roam_state = *(int*)($snap_free_roam+0x30)
|
||||
set $snap_free_roam_111 = *(unsigned char*)($snap_free_roam+0x111)
|
||||
set $snap_free_roam_112 = *(unsigned char*)($snap_free_roam+0x112)
|
||||
set $snap_free_roam_124 = *(int*)($snap_free_roam+0x124)
|
||||
end
|
||||
if $snap_selected != 0
|
||||
set $snap_selected_vtable = *(void**)$snap_selected
|
||||
set $snap_selected_mode = *(int*)($snap_selected+0x18)
|
||||
end
|
||||
end
|
||||
set $tracked_ctx = 0
|
||||
|
||||
|
||||
hbreak *0x{address['scenario']:x}
|
||||
commands
|
||||
silent
|
||||
set $ctx = $rcx
|
||||
set $tracked_ctx = $ctx
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d SCENARIO_MODE_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p arg_descriptor=%p arg_scenario=%p async_requested=%d flag40=%d callback_vtable=%p callback_owner=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_parent=%p pma_machine=%p pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $rdx, $r8, $r9b, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_parent, $snap_pma_machine, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 1
|
||||
enable 2
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['writer']:x}
|
||||
condition 2 $tracked_ctx != 0 && {config['writer_context']} == $tracked_ctx
|
||||
disable 2
|
||||
commands
|
||||
silent
|
||||
set $ctx = {config['writer_context']}
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d CONTEXT_ARM_WRITER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d instruction=%p caller_return=%p ctx=%p original_async_requested=%d flag40_before=%d callback_vtable=%p callback_owner_before=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, {config['writer_async_requested']}, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 2
|
||||
enable 3
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['register']:x}
|
||||
condition 3 $tracked_ctx != 0 && $rdx == $tracked_ctx+0x48
|
||||
disable 3
|
||||
commands
|
||||
silent
|
||||
set $callback = $rdx
|
||||
set $ctx = $callback-0x48
|
||||
snapshot_pma_context $ctx
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d ASYNC_REGISTER_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d callsite=%p caller_return=%p service=%p service_vtable=%p callback=%p callback_vtable=%p ctx=%p flag40=%d callback_owner=%p dispatcher=%p dispatcher_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $rcx, *(void**)$rcx, $callback, *(void**)$callback, $ctx, $snap_flag40, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable
|
||||
disable 3
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['arm']:x}
|
||||
condition 4 {config['arm_condition']}
|
||||
commands
|
||||
silent
|
||||
set $ctx = $rcx
|
||||
if $tracked_ctx == 0
|
||||
set $tracked_ctx = $ctx
|
||||
end
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d COMPLETION_ARM_ENTRY" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p flag40_before=%d callback_vtable=%p callback_owner=%p dispatcher=%p dispatcher_vtable=%p result_source=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, *(void**)($ctx+0xa8), $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 4
|
||||
enable 5
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['arm_writer']:x}
|
||||
condition 5 $tracked_ctx != 0 && $rsi == $tracked_ctx
|
||||
disable 5
|
||||
commands
|
||||
silent
|
||||
set $ctx = $rsi
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d COMPLETION_ARM_WRITER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d instruction=%p caller_return=%p ctx=%p flag40_before=%d result_object=%p result_state28=%d callback_owner=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, $snap_flag40, $rax, *(int*)($rax+0x28), $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 5
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['completion']:x}
|
||||
condition 6 $tracked_ctx != 0 && $rcx == $tracked_ctx+0x48
|
||||
commands
|
||||
silent
|
||||
set $callback = $rcx
|
||||
set $ctx = *(void**)($callback+0x30)
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d ASYNC_COMPLETION" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p callback=%p callback_vtable=%p ctx=%p callback_matches_ctx48=%d flag40_before=%d arg_rdx=%p arg_r8=%p arg_r9=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $callback, *(void**)$callback, $ctx, $callback == $ctx+0x48, $snap_flag40, $rdx, $r8, $r9, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['dispatcher']:x}
|
||||
condition 7 $tracked_ctx != 0 && $rcx == $tracked_ctx+0x80 && $edx == 5
|
||||
commands
|
||||
silent
|
||||
set $ctx = $rcx-0x80
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d DISPATCHER_EVENT_5" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p dispatcher=%p event=%d arg_r8=%p arg_r9=%p ctx=%p flag40=%d callback_owner=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $rcx, $edx, $r8, $r9, $ctx, $snap_flag40, $snap_callback_owner, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 7
|
||||
enable 8
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['instructions']:x}
|
||||
disable 8
|
||||
commands
|
||||
silent
|
||||
set $listener = $rcx
|
||||
set $parent = *(void**)($listener+0x8)
|
||||
set $machine = 0
|
||||
set $current = 0
|
||||
if $parent != 0
|
||||
set $machine = *(void**)($parent+0x8)
|
||||
end
|
||||
if $machine != 0
|
||||
set $current = *(void**)($machine+0x10)
|
||||
end
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d INSTRUCTIONS_AFTER_EVENT_5" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d handler=%p caller_return=%p listener=%p listener_vtable=%p event=%d flag18=%d parent=%p machine=%p current=%p current_vtable=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $listener, *(void**)$listener, $edx, *(unsigned char*)($listener+0x18), $parent, $machine, $current, $current ? *(void**)$current : 0, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 6
|
||||
continue
|
||||
end
|
||||
|
||||
printf "PMAPRODUCER ARMED pid={pid} variant={variant} scenario=0x{address['scenario']:x} writer=0x{address['writer']:x} register=0x{address['register']:x} arm=0x{address['arm']:x} arm_writer=0x{address['arm_writer']:x} completion=0x{address['completion']:x} dispatcher=0x{address['dispatcher']:x} instructions=0x{address['instructions']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def effective_environment(pid: int) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for item in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0"):
|
||||
if not item.startswith(b"OPENFUT_FIFA17_"):
|
||||
continue
|
||||
key, _, value = item.decode("utf-8", errors="replace").partition("=")
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
mode0 = addresses(0x140000000, "mode0")
|
||||
alternate = addresses(0x140000000, "alternate")
|
||||
script = build_script(1234, 0x140000000, "/tmp/pma-producer.log", "mode0")
|
||||
assert mode0["scenario"] == 0x147B1C190
|
||||
assert mode0["writer"] == 0x147B1C26B
|
||||
assert mode0["register"] == 0x147B1C282
|
||||
assert alternate["scenario"] == 0x147B1C050
|
||||
assert alternate["writer"] == 0x147B1C12F
|
||||
assert alternate["register"] == 0x147B1C146
|
||||
assert mode0["completion"] == 0x147B046C0
|
||||
assert mode0["dispatcher"] == 0x147AC87B0
|
||||
assert mode0["instructions"] == 0x147AC91E0
|
||||
assert mode0["arm"] == 0x147B1AE60
|
||||
assert mode0["arm_writer"] == 0x147B1AF33
|
||||
assert script.count("hbreak *") == 8
|
||||
assert "condition 7 $tracked_ctx != 0" in script
|
||||
assert "disable 2" in script and "enable 2" in script
|
||||
assert "disable 3" in script and "enable 3" in script
|
||||
assert "disable 5" in script and "enable 5" in script
|
||||
assert "disable 8" in script and "enable 8" in script
|
||||
assert "set *(" not in script
|
||||
print("pma_producer_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--variant", choices=tuple(VARIANTS), default="mode0")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(
|
||||
fifa_path,
|
||||
advance.PINNED_FIFA_SHA256,
|
||||
advance.FIFA_MODULE,
|
||||
)
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
output = args.output or f"/tmp/fifa17-pma-producer-{args.variant}-{pid}.log"
|
||||
script = build_script(pid, fifa_base, output, args.variant)
|
||||
environment = effective_environment(pid)
|
||||
print(
|
||||
"PMAPRODUCER PREPARED "
|
||||
f"pid={pid} variant={args.variant} fifa_base={fifa_base:#x} cards_base={cards_base:#x} "
|
||||
f"team_compat={environment.get('OPENFUT_FIFA17_SEASON_TEAM_COMPAT', '<absent>')} "
|
||||
f"pma_fix={environment.get('OPENFUT_FIFA17_OFFLINE_SEASONS_PMA_FIX', '<absent>')}"
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-pma-producer-{args.variant}-{pid}.gdb"
|
||||
Path(script_path).write_text(script, encoding="utf-8")
|
||||
import os
|
||||
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scan for FIFA17 match-team records by the invariant header prefix.
|
||||
|
||||
Anchors ONLY on (11,7,0,0,76) at +0x00..+0x10. Never filter on +0x18: it is a
|
||||
per-record marker whose value varies between sessions (-1 on 2026-08-24,
|
||||
344065/344064 on 2026-08-25), and filtering on it produced a false negative.
|
||||
|
||||
scan_mt.py [pid]
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
PAT = struct.pack("<5i", 11, 7, 0, 0, 76)
|
||||
|
||||
|
||||
def find_pid():
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||
return int(os.path.basename(d))
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
pid = int(sys.argv[1]) if len(sys.argv) > 1 else find_pid()
|
||||
if not pid:
|
||||
print(" no FIFA17.exe")
|
||||
raise SystemExit(2)
|
||||
|
||||
mem = open(f"/proc/{pid}/mem", "rb", 0)
|
||||
found = []
|
||||
for line in open(f"/proc/{pid}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", line)
|
||||
if not m or m.group(3)[0] != "r":
|
||||
continue
|
||||
lo, hi, path = int(m.group(1), 16), int(m.group(2), 16), m.group(4)
|
||||
if path.startswith(("/dev", "/memfd")) or hi - lo > 512 * 1024 * 1024:
|
||||
continue
|
||||
try:
|
||||
mem.seek(lo)
|
||||
buf = mem.read(hi - lo)
|
||||
except (OSError, ValueError, OverflowError):
|
||||
continue
|
||||
i = buf.find(PAT)
|
||||
while i >= 0:
|
||||
rec = buf[i:i + 0x80]
|
||||
if len(rec) >= 0x80:
|
||||
tid = struct.unpack_from("<i", rec, 0x14)[0]
|
||||
m18 = struct.unpack_from("<i", rec, 0x18)[0]
|
||||
m1c = struct.unpack_from("<i", rec, 0x1c)[0]
|
||||
xi = list(struct.unpack_from("<11i", rec, 0x20))
|
||||
subs = list(struct.unpack_from("<12i", rec, 0x4c))
|
||||
found.append((lo + i, tid, m18, m1c, xi, subs))
|
||||
i = buf.find(PAT, i + 4)
|
||||
|
||||
print(f" pid={pid} {len(found)} match-team record(s)")
|
||||
for addr, tid, m18, m1c, xi, subs in found:
|
||||
print(f"\n @0x{addr:x}")
|
||||
print(f" +0x14 teamId = {tid}")
|
||||
print(f" +0x18 marker = {m18} +0x1c marker = {m1c}")
|
||||
print(f" XI = {xi}")
|
||||
print(f" subs = {subs}")
|
||||
print(f"\n distinct teamIds: {sorted({t for _a, t, *_r in found})}")
|
||||
+1101
File diff suppressed because it is too large
Load Diff
+512
@@ -0,0 +1,512 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Supervise one hardware-only FIFA17 match-team writer capture.
|
||||
|
||||
This is the robust fresh-client entry point. It waits for the largest-RSS
|
||||
FIFA17.exe process that has CardsDLL loaded, attaches gdb before FUT navigation
|
||||
can construct match teams, and loads a hardware-only GDB Python payload.
|
||||
|
||||
The concurrent read-only structural locator proves when the fixture and final
|
||||
match-team records exist. A zero-hit result is trusted only if gdb is still
|
||||
alive, TracerPid is the gdb process, the payload reported `trace_armed`, no
|
||||
records pre-existed the trace, and two final records then appeared.
|
||||
|
||||
The default payload traces FUN_1800fc500 and derives a 4-byte teamId[1]
|
||||
watchpoint from live RDX. Other payloads trace the final engine writer or its
|
||||
caller; all expose the same `start_trace(log, cards_base)` entry point.
|
||||
|
||||
No INT3/software breakpoints. No client memory writes. /proc/<pid>/mem is opened
|
||||
'rb'. The operator alone drives the game.
|
||||
|
||||
trace_match_team_writer.py --status /tmp/mt-status.json \
|
||||
--trace /tmp/mt-trace.jsonl --gdb-log /tmp/mt-gdb.log --fixture-index 0
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
from offline_match_locator import find_pids, scan_process
|
||||
|
||||
CARDS_IMAGE_BASE = 0x180000000
|
||||
DEFAULT_TIMEOUT = 45 * 60
|
||||
|
||||
|
||||
def cards_base(pid: int) -> int | None:
|
||||
try:
|
||||
with open(f"/proc/{pid}/maps") as maps:
|
||||
for line in maps:
|
||||
if "CardsDLL_Win64_retail.dll" in line:
|
||||
return int(line.split("-", 1)[0], 16)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def tracer_pid(pid: int) -> int | None:
|
||||
try:
|
||||
with open(f"/proc/{pid}/status") as status:
|
||||
for line in status:
|
||||
if line.startswith("TracerPid:"):
|
||||
return int(line.split()[1])
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def target_state(pid: int) -> str | None:
|
||||
try:
|
||||
with open(f"/proc/{pid}/status") as status:
|
||||
for line in status:
|
||||
if line.startswith("State:"):
|
||||
return line.split()[1]
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def read_events(path: Path) -> list[dict]:
|
||||
if not path.exists():
|
||||
return []
|
||||
events = []
|
||||
try:
|
||||
with path.open(encoding="utf-8", errors="replace") as handle:
|
||||
for line in handle:
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except OSError:
|
||||
return []
|
||||
return events
|
||||
|
||||
|
||||
def event_counts(events: list[dict]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for event in events:
|
||||
kind = event.get("event", "unknown")
|
||||
counts[kind] = counts.get(kind, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
class Status:
|
||||
def __init__(self, path: Path, monitor_log: Path):
|
||||
self.path = path
|
||||
self.monitor_log = monitor_log
|
||||
self.data: dict = {"started_unix": time.time(), "state": "starting"}
|
||||
self.write()
|
||||
|
||||
def write(self, **updates):
|
||||
self.data.update(updates)
|
||||
self.data["updated_unix"] = time.time()
|
||||
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
temporary.write_text(json.dumps(self.data, indent=2, sort_keys=True) + "\n")
|
||||
os.replace(temporary, self.path)
|
||||
|
||||
def log(self, message: str, **payload):
|
||||
record = {"time_unix": time.time(), "message": message, **payload}
|
||||
with self.monitor_log.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(record, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
print(message, flush=True)
|
||||
|
||||
|
||||
def gdb_commands(pid: int, cards: int, payload: Path, trace: Path) -> str:
|
||||
# Wine uses these signals for thread suspension/runtime plumbing. They must
|
||||
# pass through, or batch gdb stops and silently detaches.
|
||||
signals = ["SIGUSR1", "SIGUSR2", "SIGPIPE", "SIGCHLD"] + [
|
||||
f"SIG{number}" for number in range(32, 40)
|
||||
]
|
||||
lines = [
|
||||
"set confirm off",
|
||||
"set pagination off",
|
||||
"set height 0",
|
||||
"set width 0",
|
||||
f"attach {pid}",
|
||||
]
|
||||
lines.extend(f"handle {name} nostop noprint pass" for name in signals)
|
||||
lines.extend(
|
||||
[
|
||||
f"source {payload}",
|
||||
f'python start_trace({json.dumps(str(trace))}, {cards})',
|
||||
"continue",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def serialise_locations(locations: dict) -> dict:
|
||||
return {key: [asdict(value) for value in values] for key, values in locations.items()}
|
||||
|
||||
|
||||
def terminate_gdb(process: subprocess.Popen, status: Status, pid: int):
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=12)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
deadline = time.time() + 8
|
||||
while time.time() < deadline and tracer_pid(pid):
|
||||
time.sleep(0.25)
|
||||
status.log(
|
||||
"gdb detached",
|
||||
gdb_returncode=process.returncode,
|
||||
tracer_pid=tracer_pid(pid),
|
||||
target_state=target_state(pid),
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--status", type=Path, required=True)
|
||||
parser.add_argument("--trace", type=Path, required=True)
|
||||
parser.add_argument("--gdb-log", type=Path, required=True)
|
||||
parser.add_argument("--monitor-log", type=Path, default=Path("/tmp/mt-monitor.jsonl"))
|
||||
parser.add_argument("--fixture-index", type=int, default=0)
|
||||
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
|
||||
parser.add_argument("--post-record-wait", type=int, default=12)
|
||||
parser.add_argument(
|
||||
"--arm-check-seconds",
|
||||
type=int,
|
||||
default=0,
|
||||
help="attach, prove hardware breakpoints arm, then detach without claiming a capture",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wait-for-record-clear",
|
||||
action="store_true",
|
||||
help="keep tracing through abandon; accept creation only after old records disappear",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exclude-pid",
|
||||
action="append",
|
||||
type=int,
|
||||
default=[],
|
||||
help="ignore an existing FIFA process and attach only after process replacement",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--payload",
|
||||
default="gdb_match_team_writer_trace.py",
|
||||
help="GDB Python payload in this tool directory; must expose start_trace(log, cards_base)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
for path in (args.status, args.trace, args.gdb_log, args.monitor_log):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
for path in (args.trace, args.gdb_log, args.monitor_log):
|
||||
path.unlink(missing_ok=True)
|
||||
status = Status(args.status, args.monitor_log)
|
||||
payload = Path(__file__).with_name(args.payload).resolve()
|
||||
if not payload.exists():
|
||||
status.write(state="failed", error=f"missing gdb payload: {payload}")
|
||||
return 2
|
||||
|
||||
deadline = time.time() + args.timeout
|
||||
status.write(state="waiting_for_ready_process", excluded_pids=args.exclude_pid)
|
||||
status.log(
|
||||
"waiting for FIFA17.exe with CardsDLL",
|
||||
excluded_pids=args.exclude_pid,
|
||||
)
|
||||
pid = None
|
||||
cards = None
|
||||
while time.time() < deadline:
|
||||
# UMU/Proton creates a short-lived small FIFA17.exe before the real
|
||||
# client. Never bind to the first comm match. Require CardsDLL and prefer
|
||||
# the largest-RSS process (find_pids is ordered that way).
|
||||
for candidate in find_pids():
|
||||
if candidate in args.exclude_pid:
|
||||
continue
|
||||
candidate_cards = cards_base(candidate)
|
||||
if candidate_cards:
|
||||
pid, cards = candidate, candidate_cards
|
||||
break
|
||||
if pid:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
if not pid or not cards:
|
||||
status.write(state="timed_out", phase="ready_process")
|
||||
return 3
|
||||
|
||||
status.write(state="ready_process_found", pid=pid, cards_base=cards)
|
||||
status.log("real FIFA17.exe with CardsDLL found", pid=pid, cards_base=cards)
|
||||
|
||||
command_path = Path(tempfile.gettempdir()) / f"mt-trace-{pid}.gdb"
|
||||
command_path.write_text(gdb_commands(pid, cards, payload, args.trace))
|
||||
gdb_handle = args.gdb_log.open("w", encoding="utf-8")
|
||||
process = subprocess.Popen(
|
||||
["gdb", "-q", "-nx", "-x", str(command_path)],
|
||||
stdout=gdb_handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
status.write(
|
||||
state="attaching",
|
||||
pid=pid,
|
||||
cards_base=cards,
|
||||
cards_image_base=CARDS_IMAGE_BASE,
|
||||
gdb_pid=process.pid,
|
||||
gdb_command_file=str(command_path),
|
||||
payload=args.payload,
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
status.log("gdb launched", pid=pid, gdb_pid=process.pid, cards_base=cards)
|
||||
|
||||
armed = False
|
||||
arm_deadline = min(deadline, time.time() + 60)
|
||||
while time.time() < arm_deadline:
|
||||
if process.poll() is not None:
|
||||
break
|
||||
events = read_events(args.trace)
|
||||
if any(event.get("event") == "trace_armed" for event in events):
|
||||
armed = True
|
||||
break
|
||||
time.sleep(0.25)
|
||||
if not armed:
|
||||
gdb_handle.close()
|
||||
status.write(
|
||||
state="failed",
|
||||
phase="arm",
|
||||
gdb_returncode=process.poll(),
|
||||
tracer_pid=tracer_pid(pid),
|
||||
trace_events=event_counts(read_events(args.trace)),
|
||||
)
|
||||
if process.poll() is None:
|
||||
terminate_gdb(process, status, pid)
|
||||
return 4
|
||||
|
||||
attached = tracer_pid(pid) == process.pid
|
||||
status.write(
|
||||
state="armed",
|
||||
tracer_pid=tracer_pid(pid),
|
||||
target_state=target_state(pid),
|
||||
trace_events=event_counts(read_events(args.trace)),
|
||||
execution_breakpoints_armed=True,
|
||||
team1_watchpoint_armed=False,
|
||||
)
|
||||
status.log("trace armed", attached=attached, tracer_pid=tracer_pid(pid))
|
||||
if not attached:
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
status.write(state="failed", phase="attach_verification")
|
||||
return 4
|
||||
if args.arm_check_seconds > 0:
|
||||
time.sleep(args.arm_check_seconds)
|
||||
events = read_events(args.trace)
|
||||
counts = event_counts(events)
|
||||
still_attached = tracer_pid(pid) == process.pid and process.poll() is None
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
passed = (
|
||||
still_attached
|
||||
and counts.get("trace_armed", 0) == 1
|
||||
and counts.get("trace_error", 0) == 0
|
||||
and tracer_pid(pid) == 0
|
||||
and target_state(pid) != "T"
|
||||
)
|
||||
status.write(
|
||||
state="arm_check_passed" if passed else "arm_check_failed",
|
||||
trace_events=counts,
|
||||
attached_before_detach=still_attached,
|
||||
tracer_pid_after_detach=tracer_pid(pid),
|
||||
target_state_after_detach=target_state(pid),
|
||||
)
|
||||
status.log("arm check complete", passed=passed, trace_events=counts)
|
||||
return 0 if passed else 5
|
||||
|
||||
# A final record that already exists before arming cannot prove execution
|
||||
# crossed creation under the debugger. Fail closed instead of converting an
|
||||
# already-built match into a trusted zero-hit result.
|
||||
initial_heap = scan_process(
|
||||
pid,
|
||||
args.fixture_index,
|
||||
include_fixture=False,
|
||||
writable_anon_only=True,
|
||||
)
|
||||
records_preexisting = len(initial_heap["match_teams"]) >= 2
|
||||
records_cleared = not records_preexisting
|
||||
if records_preexisting and args.wait_for_record_clear:
|
||||
status.write(
|
||||
state="waiting_for_record_clear",
|
||||
locations=serialise_locations(initial_heap),
|
||||
target_crossed_match_team_creation=False,
|
||||
)
|
||||
status.log(
|
||||
"trace armed; waiting for old match-team records to disappear",
|
||||
team_ids=[team.team_id for team in initial_heap["match_teams"]],
|
||||
)
|
||||
while time.time() < deadline:
|
||||
if process.poll() is not None or not Path(f"/proc/{pid}").exists():
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
status.write(state="failed", phase="record_clear")
|
||||
return 5
|
||||
heap = scan_process(
|
||||
pid,
|
||||
args.fixture_index,
|
||||
include_fixture=False,
|
||||
writable_anon_only=True,
|
||||
)
|
||||
if not heap["match_teams"]:
|
||||
records_cleared = True
|
||||
status.write(
|
||||
state="records_cleared",
|
||||
cleared_unix=time.time(),
|
||||
tracer_pid=tracer_pid(pid),
|
||||
gdb_alive=process.poll() is None,
|
||||
target_state=target_state(pid),
|
||||
)
|
||||
status.log(
|
||||
"old match-team records disappeared; next records are a fresh creation",
|
||||
tracer_pid=tracer_pid(pid),
|
||||
)
|
||||
break
|
||||
time.sleep(2)
|
||||
if not records_cleared:
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
status.write(state="timed_out", phase="record_clear")
|
||||
return 3
|
||||
elif records_preexisting:
|
||||
counts = event_counts(read_events(args.trace))
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
status.write(
|
||||
state="armed_too_late",
|
||||
phase="preexisting_records",
|
||||
trace_events=counts,
|
||||
locations=serialise_locations(initial_heap),
|
||||
target_crossed_match_team_creation=False,
|
||||
tracer_pid_after_detach=tracer_pid(pid),
|
||||
target_state_after_detach=target_state(pid),
|
||||
)
|
||||
status.log(
|
||||
"match-team records pre-existed trace; no writer claim",
|
||||
team_ids=[team.team_id for team in initial_heap["match_teams"]],
|
||||
)
|
||||
return 6
|
||||
|
||||
|
||||
fixture = None
|
||||
latest_locations = {"fixtures": [], "match_teams": [], "match_configs": []}
|
||||
last_fixture_scan = 0.0
|
||||
records_seen_at = None
|
||||
record_control = None
|
||||
try:
|
||||
while time.time() < deadline:
|
||||
if process.poll() is not None or not Path(f"/proc/{pid}").exists():
|
||||
status.write(
|
||||
state="failed",
|
||||
phase="monitor",
|
||||
gdb_returncode=process.poll(),
|
||||
target_exists=Path(f"/proc/{pid}").exists(),
|
||||
)
|
||||
return 5
|
||||
|
||||
now = time.time()
|
||||
if fixture is None and now - last_fixture_scan >= 8:
|
||||
full = scan_process(pid, args.fixture_index, include_fixture=True)
|
||||
last_fixture_scan = now
|
||||
if full["fixtures"]:
|
||||
fixture = full["fixtures"][0]
|
||||
latest_locations["fixtures"] = full["fixtures"]
|
||||
status.log(
|
||||
"fixture located",
|
||||
address=fixture.address,
|
||||
selected_address=fixture.selected_address,
|
||||
selected_index=fixture.selected_index,
|
||||
selected_team_id=fixture.selected_team_id,
|
||||
)
|
||||
|
||||
heap = scan_process(
|
||||
pid,
|
||||
args.fixture_index,
|
||||
include_fixture=False,
|
||||
writable_anon_only=True,
|
||||
)
|
||||
latest_locations["match_teams"] = heap["match_teams"]
|
||||
latest_locations["match_configs"] = heap["match_configs"]
|
||||
events = read_events(args.trace)
|
||||
counts = event_counts(events)
|
||||
is_attached = tracer_pid(pid) == process.pid
|
||||
watch_armed = counts.get("team1_watchpoint_armed", 0) > 0
|
||||
status.write(
|
||||
state="capturing" if len(heap["match_teams"]) < 2 else "records_observed",
|
||||
tracer_pid=tracer_pid(pid),
|
||||
gdb_alive=process.poll() is None,
|
||||
target_state=target_state(pid),
|
||||
trace_events=counts,
|
||||
team1_watchpoint_armed=watch_armed,
|
||||
locations=serialise_locations(latest_locations),
|
||||
)
|
||||
|
||||
if len(heap["match_teams"]) >= 2:
|
||||
if records_seen_at is None:
|
||||
if not is_attached or process.poll() is not None:
|
||||
status.write(
|
||||
state="failed",
|
||||
phase="record_creation_control",
|
||||
tracer_pid=tracer_pid(pid),
|
||||
gdb_alive=process.poll() is None,
|
||||
trace_events=counts,
|
||||
)
|
||||
return 5
|
||||
records_seen_at = now
|
||||
record_control = {
|
||||
"gdb_alive": process.poll() is None,
|
||||
"tracer_pid": tracer_pid(pid),
|
||||
"attached": is_attached,
|
||||
"execution_breakpoints_armed": counts.get("trace_armed", 0) == 1,
|
||||
"team1_watchpoint_armed": watch_armed,
|
||||
}
|
||||
status.log(
|
||||
"two match-team records located",
|
||||
team_ids=[team.team_id for team in heap["match_teams"]],
|
||||
trace_events=counts,
|
||||
**record_control,
|
||||
)
|
||||
if now - records_seen_at >= args.post_record_wait:
|
||||
break
|
||||
time.sleep(3)
|
||||
finally:
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
|
||||
events = read_events(args.trace)
|
||||
counts = event_counts(events)
|
||||
final = {
|
||||
"state": "captured",
|
||||
"pid": pid,
|
||||
"cards_base": cards,
|
||||
"fixture": asdict(fixture) if fixture else None,
|
||||
"locations": serialise_locations(latest_locations),
|
||||
"trace_events": counts,
|
||||
"record_creation_control": record_control,
|
||||
"gdb_alive_at_record_creation": bool(
|
||||
record_control and record_control["gdb_alive"] and record_control["attached"]
|
||||
),
|
||||
"target_crossed_match_team_creation": len(latest_locations["match_teams"]) >= 2,
|
||||
"candidate_entry_hit": counts.get("candidate_entry", 0) > 0,
|
||||
"team1_write_hit": counts.get("team1_write_post", 0) > 0,
|
||||
"opponent_lookup_store_hit": counts.get("opponent_lookup_store_pre", 0) > 0,
|
||||
"tracer_pid_after_detach": tracer_pid(pid),
|
||||
"target_state_after_detach": target_state(pid),
|
||||
"records_preexisting": records_preexisting,
|
||||
"records_cleared_before_capture": records_cleared,
|
||||
}
|
||||
status.write(**final)
|
||||
status.log("capture complete", **final)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dump a CardsDLL vtable as image VAs, and find sibling vtables that hold a
|
||||
different function in the same slot (a type/mode dispatch).
|
||||
|
||||
vtab.py <slot_image_va_hex> [before] [after]
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
CARDS_IMG = 0x180000000
|
||||
|
||||
|
||||
def pid():
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||
return int(os.path.basename(d))
|
||||
except OSError:
|
||||
pass
|
||||
raise SystemExit("no FIFA17.exe")
|
||||
|
||||
|
||||
P = pid()
|
||||
BASE = [int(l.split("-")[0], 16) for l in open(f"/proc/{P}/maps") if "CardsDLL" in l][0]
|
||||
|
||||
|
||||
def img2live(va):
|
||||
return BASE + (va - CARDS_IMG)
|
||||
|
||||
|
||||
def live2img(la):
|
||||
return CARDS_IMG + (la - BASE)
|
||||
|
||||
|
||||
slot = int(sys.argv[1], 16)
|
||||
before = int(sys.argv[2]) if len(sys.argv) > 2 else 10
|
||||
after = int(sys.argv[3]) if len(sys.argv) > 3 else 10
|
||||
|
||||
mem = open(f"/proc/{P}/mem", "rb", 0)
|
||||
start = slot - before * 8
|
||||
mem.seek(img2live(start))
|
||||
buf = mem.read((before + after) * 8)
|
||||
print(f" vtable neighbourhood of image 0x{slot:x}")
|
||||
target = None
|
||||
for k in range(0, len(buf) - 7, 8):
|
||||
a = start + k
|
||||
p = struct.unpack_from("<Q", buf, k)[0]
|
||||
ivа = live2img(p) if BASE <= p < BASE + 0x400000 else None
|
||||
mark = " <== the team-pair assigner" if a == slot else ""
|
||||
if a == slot:
|
||||
target = ivа
|
||||
print(f" 0x{a:x} [{a-slot:+#5x}] -> "
|
||||
+ (f"image 0x{ivа:x}" if ivа else f"raw 0x{p:x}") + mark)
|
||||
|
||||
# Find every other .rdata slot pointing at a DIFFERENT function but whose
|
||||
# neighbours overlap this vtable -> sibling implementations of the same slot.
|
||||
print("\n === sibling vtables: same neighbour, different slot function ===")
|
||||
mem.seek(img2live(0x1801e5000))
|
||||
rdata = mem.read(0x28a000 - 0x1e5000)
|
||||
# take the two neighbours around the slot as a signature
|
||||
sig_prev = struct.unpack_from("<Q", buf, (before - 1) * 8)[0]
|
||||
sig_next = struct.unpack_from("<Q", buf, (before + 1) * 8)[0]
|
||||
found = 0
|
||||
for name, sig in (("preceding", sig_prev), ("following", sig_next)):
|
||||
pat = struct.pack("<Q", sig)
|
||||
i = rdata.find(pat)
|
||||
while i >= 0:
|
||||
if i % 8 == 0:
|
||||
here = 0x1801e5000 + i
|
||||
# the slot in THIS vtable at the same relative position
|
||||
off = i + (8 if name == "preceding" else -8)
|
||||
if 0 <= off <= len(rdata) - 8:
|
||||
fn = struct.unpack_from("<Q", rdata, off)[0]
|
||||
if BASE <= fn < BASE + 0x400000:
|
||||
fimg = live2img(fn)
|
||||
if fimg != target:
|
||||
print(f" vtable @image 0x{here:x} ({name} matches) "
|
||||
f"slot -> image 0x{fimg:x} DIFFERENT")
|
||||
found += 1
|
||||
i = rdata.find(pat, i + 1)
|
||||
print(f" {found} sibling implementation(s)")
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Find references to an image VA inside a live module's .text/.rdata/.data.
|
||||
|
||||
xref.py <target_image_va_hex> [--exe]
|
||||
|
||||
Reports:
|
||||
call rel32 (e8) / jmp rel32 (e9) -- direct callers
|
||||
lea rip-rel (48 8d 0x) -- address-taken
|
||||
absolute 8-byte pointer -- vtable / table slot
|
||||
|
||||
Read-only. Section ranges are recomputed from /proc/<pid>/maps every run.
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
CARDS_IMG = 0x180000000
|
||||
EXE_IMG = 0x140000000
|
||||
|
||||
|
||||
def pid():
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||
return int(os.path.basename(d))
|
||||
except OSError:
|
||||
pass
|
||||
raise SystemExit("FIFA17.exe not running")
|
||||
|
||||
|
||||
P = pid()
|
||||
|
||||
|
||||
def module_base(needle):
|
||||
for l in open(f"/proc/{P}/maps"):
|
||||
if needle.lower() in l.lower():
|
||||
return int(l.split("-")[0], 16)
|
||||
raise SystemExit(f"{needle} not mapped")
|
||||
|
||||
|
||||
def spans(base, limit=0x400000):
|
||||
"""Contiguous mappings belonging to this module, as (live_lo, live_hi, perms)."""
|
||||
out = []
|
||||
for l in open(f"/proc/{P}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", l)
|
||||
if not m:
|
||||
continue
|
||||
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)
|
||||
if lo == base:
|
||||
out.append((lo, hi, perms))
|
||||
continue
|
||||
if out and lo == out[-1][1] and not path.strip():
|
||||
out.append((lo, hi, perms))
|
||||
elif out and lo > out[-1][1]:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
a = [x for x in sys.argv[1:] if x != "--exe"]
|
||||
exe = "--exe" in sys.argv
|
||||
target = int(a[0], 16)
|
||||
img = EXE_IMG if exe else CARDS_IMG
|
||||
base = module_base("FIFA17.exe" if exe else "CardsDLL")
|
||||
tgt_live = base + (target - img)
|
||||
|
||||
mem = open(f"/proc/{P}/mem", "rb", 0)
|
||||
print(f" pid={P} module_base=0x{base:x} target image 0x{target:x} live 0x{tgt_live:x}")
|
||||
hits = 0
|
||||
for lo, hi, perms in spans(base):
|
||||
try:
|
||||
mem.seek(lo)
|
||||
buf = mem.read(hi - lo)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
img_lo = img + (lo - base)
|
||||
# rel32 call/jmp
|
||||
for op, name in ((0xE8, "call"), (0xE9, "jmp ")):
|
||||
i = buf.find(bytes([op]))
|
||||
while i >= 0:
|
||||
if i + 5 <= len(buf):
|
||||
rel = struct.unpack_from("<i", buf, i + 1)[0]
|
||||
if img_lo + i + 5 + rel == target:
|
||||
print(f" {name} rel32 from image 0x{img_lo+i:x} [{perms}]")
|
||||
hits += 1
|
||||
i = buf.find(bytes([op]), i + 1)
|
||||
# lea reg,[rip+rel32] (48 8d /r with mod=00 rm=101)
|
||||
i = buf.find(b"\x48\x8d")
|
||||
while i >= 0:
|
||||
if i + 7 <= len(buf):
|
||||
modrm = buf[i + 2]
|
||||
if (modrm & 0xC7) == 0x05:
|
||||
rel = struct.unpack_from("<i", buf, i + 3)[0]
|
||||
if img_lo + i + 7 + rel == target:
|
||||
print(f" lea rip-rel from image 0x{img_lo+i:x} [{perms}]")
|
||||
hits += 1
|
||||
i = buf.find(b"\x48\x8d", i + 1)
|
||||
# absolute pointer (live address stored in a table)
|
||||
pat = struct.pack("<Q", tgt_live)
|
||||
i = buf.find(pat)
|
||||
while i >= 0:
|
||||
if i % 8 == 0:
|
||||
print(f" abs ptr slot at image 0x{img_lo+i:x} [{perms}]")
|
||||
hits += 1
|
||||
i = buf.find(pat, i + 1)
|
||||
print(f" {hits} reference(s)")
|
||||
|
||||
|
||||
main()
|
||||
@@ -41,6 +41,20 @@ pub struct Fifa17CardIdentity {
|
||||
/// FIFA card-art class. Players default to `asset_id`; kit definitions carry
|
||||
/// the verified `fcc_kitcards.cardassetid` value (`35`).
|
||||
pub card_asset_id: u32,
|
||||
/// The wire `assetId` for a CLUB item (record `+0x20`), which is family
|
||||
/// specific and is NOT the carddbid: a kit carries the art class from
|
||||
/// `fcc_kitcards.assetid` (`14` home/third band, `15` away band), a badge
|
||||
/// carries its team id, a stadium and a ball their own asset number.
|
||||
///
|
||||
/// Distinct from [`Self::asset_id`], which for these definitions is the
|
||||
/// carddbid and is what `resource_id` is derived from — so the two cannot be
|
||||
/// the same field. Shipping the carddbid here is what left the client
|
||||
/// holding `assetId 6300006` at record `+0x20` where its own table says
|
||||
/// `14`, with both pre-match kit tiles rendering identically.
|
||||
///
|
||||
/// Defaults to `asset_id` when a catalog does not specify it, which is the
|
||||
/// pre-existing behaviour and is correct for every non-club kind.
|
||||
pub club_asset_id: u32,
|
||||
/// Source team id for a club kit, or a manager's real club. Zero for content
|
||||
/// kinds that do not use it.
|
||||
pub team_id: i64,
|
||||
@@ -206,6 +220,9 @@ struct RawCard {
|
||||
/// Separate card-art id for non-player definitions; absent → `asset_id`.
|
||||
#[serde(default)]
|
||||
card_asset_id: Option<u32>,
|
||||
/// Wire `assetId` for a club item; defaults to `asset_id`. See
|
||||
/// [`Fifa17CardIdentity::club_asset_id`].
|
||||
club_asset_id: Option<u32>,
|
||||
/// Source team id for a kit or manager definition; absent → `0`.
|
||||
#[serde(default)]
|
||||
team_id: Option<i64>,
|
||||
@@ -287,6 +304,7 @@ impl Fifa17CardCatalog {
|
||||
kind: ContentKind::from_str(&rc.kind),
|
||||
subtype: rc.subtype,
|
||||
card_asset_id: rc.card_asset_id.unwrap_or(rc.asset_id),
|
||||
club_asset_id: rc.club_asset_id.unwrap_or(rc.asset_id),
|
||||
team_id: rc.team_id.unwrap_or(0),
|
||||
category: rc.category.unwrap_or(0),
|
||||
year: rc.year.unwrap_or(0),
|
||||
@@ -380,6 +398,49 @@ mod tests {
|
||||
assert_eq!(cat.lookup("card_missing"), None);
|
||||
}
|
||||
|
||||
/// A club item's wire `assetId` is family specific and is NOT the carddbid.
|
||||
///
|
||||
/// Regression: the catalog shipped `asset_id` (the carddbid) as the wire
|
||||
/// `assetId`, so the client held `assetId 6300006` at record `+0x20` where
|
||||
/// its own `fcc_kitcards` says `14`, and both pre-match kit tiles rendered
|
||||
/// identically. `resource_id` is derived from `asset_id`, and every home kit
|
||||
/// shares art class 14, so the two genuinely cannot be one field.
|
||||
#[test]
|
||||
fn club_items_carry_their_own_wire_asset_id_distinct_from_the_carddbid() {
|
||||
let cat = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"fifa17_6300006":{"asset_id":6300006,"kind":"kit","subtype":9,
|
||||
"card_asset_id":35,"club_asset_id":14,"team_id":21,"category":2,"year":0},
|
||||
"fifa17_6400003":{"asset_id":6400003,"kind":"kit","subtype":9,
|
||||
"card_asset_id":35,"club_asset_id":15,"team_id":21,"category":3,"year":0},
|
||||
"fifa17_20801":{"asset_id":20801}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let home = cat.lookup("fifa17_6300006").unwrap();
|
||||
let away = cat.lookup("fifa17_6400003").unwrap();
|
||||
|
||||
// resourceId stays the carddbid — it is what the staff/kit merge keys on.
|
||||
assert_eq!(home.resource_id, 6300006);
|
||||
assert_eq!(away.resource_id, 6400003);
|
||||
// The card frame art is shared by the whole kit family.
|
||||
assert_eq!(home.card_asset_id, 35);
|
||||
assert_eq!(away.card_asset_id, 35);
|
||||
// The art class is what distinguishes home from away on the wire.
|
||||
assert_eq!(home.club_asset_id, 14);
|
||||
assert_eq!(away.club_asset_id, 15);
|
||||
assert_ne!(
|
||||
home.club_asset_id, away.club_asset_id,
|
||||
"home and away must not present the same assetId"
|
||||
);
|
||||
|
||||
// Absent: defaults to asset_id, which is correct for every non-club kind
|
||||
// and preserves the behaviour of a catalog that predates the field.
|
||||
let player = cat.lookup("fifa17_20801").unwrap();
|
||||
assert_eq!(player.club_asset_id, 20801);
|
||||
}
|
||||
|
||||
/// The non-player definition fields a consumable needs, and the ABSENCE that
|
||||
/// must stay an absence: a defaulted `amount` would draw "-1" on the card and
|
||||
/// a defaulted `contract` would invent the number of matches a card grants.
|
||||
|
||||
@@ -42,6 +42,10 @@ pub const SEASON_ROUNDS: i64 = 10;
|
||||
/// resolves to a team the client can actually render. They are cycled rather
|
||||
/// than randomised so a season's schedule is stable across reloads — the client
|
||||
/// re-reads `season/list` and a shifting schedule would renumber fixtures.
|
||||
///
|
||||
/// The club's OWN kit team is filtered out at schedule time — see
|
||||
/// [`season_list_body`]. Fixing this list to exclude one id would not do, because
|
||||
/// which team the club wears is ownership state, not a constant.
|
||||
const OPPONENT_TEAM_IDS: &[i64] = &[21, 73, 240, 241, 243];
|
||||
|
||||
/// One scheduled offline-season round.
|
||||
@@ -90,9 +94,9 @@ pub struct SeasonUser {
|
||||
pub data: &'static str,
|
||||
}
|
||||
|
||||
fn round(index: i64) -> SeasonMatch {
|
||||
fn round(index: i64, opponents: &[i64]) -> SeasonMatch {
|
||||
SeasonMatch {
|
||||
team_id: OPPONENT_TEAM_IDS[(index as usize) % OPPONENT_TEAM_IDS.len()],
|
||||
team_id: opponents[(index as usize) % opponents.len()],
|
||||
// Difficulty and reward multiplier are per-round bytes; a flat schedule
|
||||
// is the honest default until the retail ladder is captured.
|
||||
difficulty: 1,
|
||||
@@ -104,13 +108,35 @@ fn round(index: i64) -> SeasonMatch {
|
||||
|
||||
/// `GET …/season/list` — the offline competitions the club can enter, as wire
|
||||
/// text (see the module note on key order).
|
||||
pub fn season_list_body(season_id: i64, division_id: i64) -> String {
|
||||
///
|
||||
/// `own_kit_team_id` is the team whose kit the club wears, taken from its active
|
||||
/// kit items. That team is EXCLUDED from the schedule, because the pre-match kit
|
||||
/// clone resolves both sides out of the same `teamkits` table keyed on
|
||||
/// `teamtechid`: drawing your own kit team makes the opponent render your kit, so
|
||||
/// both sides appear in identical strips. It is also simply wrong data — a club
|
||||
/// would be playing itself.
|
||||
///
|
||||
/// Passing `None` (or a team not in the rotation) keeps the full schedule.
|
||||
pub fn season_list_body(season_id: i64, division_id: i64, own_kit_team_id: Option<i64>) -> String {
|
||||
let opponents: Vec<i64> = OPPONENT_TEAM_IDS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| Some(*id) != own_kit_team_id)
|
||||
.collect();
|
||||
// Never emit an empty rotation: `matches` must be non-empty or StartSeason
|
||||
// dereferences NULL (see the module note), so an exclusion that would empty
|
||||
// the list is ignored rather than allowed to crash the client.
|
||||
let opponents: &[i64] = if opponents.is_empty() {
|
||||
OPPONENT_TEAM_IDS
|
||||
} else {
|
||||
&opponents
|
||||
};
|
||||
let list = SeasonList {
|
||||
seasons: vec![SeasonElement {
|
||||
kind: "OFFLINE",
|
||||
id: season_id,
|
||||
division_id,
|
||||
matches: (0..SEASON_ROUNDS).map(round).collect(),
|
||||
matches: (0..SEASON_ROUNDS).map(|i| round(i, opponents)).collect(),
|
||||
}],
|
||||
};
|
||||
serde_json::to_string(&list).expect("season list serialises")
|
||||
@@ -146,7 +172,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn list_emits_a_full_round_schedule() {
|
||||
let body = parsed(&season_list_body(1, 10));
|
||||
let body = parsed(&season_list_body(1, 10, None));
|
||||
let season = &body["seasons"][0];
|
||||
assert_eq!(season["type"], "OFFLINE");
|
||||
assert_eq!(season["id"], 1);
|
||||
@@ -161,7 +187,7 @@ mod tests {
|
||||
/// (CardsDLL+0xfc5b5), so the schedule can never be empty.
|
||||
#[test]
|
||||
fn matches_are_never_empty_and_every_round_has_a_team() {
|
||||
let body = parsed(&season_list_body(3, 7));
|
||||
let body = parsed(&season_list_body(3, 7, None));
|
||||
let matches = body["seasons"][0]["matches"].as_array().unwrap();
|
||||
assert!(!matches.is_empty());
|
||||
for (i, m) in matches.iter().enumerate() {
|
||||
@@ -181,7 +207,7 @@ mod tests {
|
||||
/// order them alphabetically and break this.
|
||||
#[test]
|
||||
fn type_is_serialised_before_division_id() {
|
||||
let text = season_list_body(1, 10);
|
||||
let text = season_list_body(1, 10, None);
|
||||
let type_at = text.find("\"type\"").expect("type key");
|
||||
let division_at = text.find("\"divisionId\"").expect("divisionId key");
|
||||
assert!(
|
||||
@@ -190,6 +216,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The pre-match kit clone resolves both sides out of the same `teamkits`
|
||||
/// table keyed on `teamtechid`, so drawing the club's own kit team puts the
|
||||
/// opponent in the club's strip. It is also a club playing itself.
|
||||
#[test]
|
||||
fn own_kit_team_is_never_scheduled_as_an_opponent() {
|
||||
let own = OPPONENT_TEAM_IDS[0];
|
||||
let body = parsed(&season_list_body(1, 10, Some(own)));
|
||||
let matches = body["seasons"][0]["matches"].as_array().unwrap();
|
||||
assert_eq!(matches.len(), SEASON_ROUNDS as usize, "still a full ladder");
|
||||
for m in matches {
|
||||
assert_ne!(
|
||||
m["teamId"].as_i64().unwrap(),
|
||||
own,
|
||||
"the club's own kit team must not be an opponent: {m}"
|
||||
);
|
||||
}
|
||||
// The remaining teams are still cycled, so the schedule stays stable and
|
||||
// every round names a renderable team.
|
||||
for (i, m) in matches.iter().enumerate() {
|
||||
assert_eq!(m["roundId"], i as i64);
|
||||
assert!(m["teamId"].as_i64().is_some_and(|t| t > 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Excluding a team that would empty the rotation must NOT produce an empty
|
||||
/// `matches` array, because that crashes StartSeason.
|
||||
#[test]
|
||||
fn an_exclusion_that_would_empty_the_rotation_is_ignored() {
|
||||
// Stand-in for the degenerate case: pretend every id is the own team by
|
||||
// excluding each in turn and asserting the ladder is always full.
|
||||
for own in OPPONENT_TEAM_IDS {
|
||||
let body = parsed(&season_list_body(1, 10, Some(*own)));
|
||||
let matches = body["seasons"][0]["matches"].as_array().unwrap();
|
||||
assert_eq!(matches.len(), SEASON_ROUNDS as usize);
|
||||
assert!(!matches.is_empty(), "matches must never be empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_state_carries_the_season_position() {
|
||||
let body = parsed(&season_user_body(1, 10, 3, 6));
|
||||
|
||||
@@ -200,6 +200,67 @@ pub fn parse_squad_put(body: &[u8]) -> Result<Fifa17SquadPut, SquadError> {
|
||||
serde_json::from_slice(body).map_err(|e| SquadError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
/// A role-only squad update: the client changed the captain and/or the
|
||||
/// kick-taker assignments without touching the squad itself.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Fifa17SquadRolePatch {
|
||||
#[serde(default)]
|
||||
pub id: i64,
|
||||
/// Opaque 33-int array, verbatim. Differs from the replacement's `custom`
|
||||
/// in the captures (the role screen writes per-slot values into it), so it
|
||||
/// MUST be carried through rather than preserved from the stored copy.
|
||||
#[serde(default)]
|
||||
pub custom: Option<String>,
|
||||
#[serde(default)]
|
||||
pub captain: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub kicktakers: Vec<SquadKicktaker>,
|
||||
}
|
||||
|
||||
/// Which mutation a `PUT …/squad/<id>` body actually expresses.
|
||||
///
|
||||
/// FIFA 17 sends two different operations down one path, so the BODY SHAPE is
|
||||
/// the operation discriminator. Across 73 captured squad PUTs spanning five
|
||||
/// captures there are exactly two shapes:
|
||||
///
|
||||
/// * 68x with `players` — a full replacement, also carrying `squadName`,
|
||||
/// `formation`, `squadType`, `manager`, `chemistry`/`rating`/`starRating`,
|
||||
/// and (redundantly) `captain`/`kicktakers`.
|
||||
/// * 5x without `players` — `{id, custom, captain, kicktakers}` only, emitted
|
||||
/// by the captain/kick-taker screen.
|
||||
///
|
||||
/// `players` is therefore the discriminator: its PRESENCE means "this body
|
||||
/// describes the whole squad". Its ABSENCE means the squad was not part of the
|
||||
/// edit at all and must be left alone — which is NOT the same as an empty
|
||||
/// `players` array, and that distinction is the whole point. Serde's
|
||||
/// `#[serde(default)]` collapses both to an empty vec, so key presence is
|
||||
/// tested on the raw JSON before deserialising.
|
||||
///
|
||||
/// An explicit `"players": []` still classifies as a replacement, so the
|
||||
/// empty-replacement guard in Core keeps seeing it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SquadMutation {
|
||||
/// Full replacement of the squad's slots and metadata.
|
||||
Replace(Box<Fifa17SquadPut>),
|
||||
/// Role-only patch: captain and/or kick-takers, nothing else.
|
||||
PatchRoles(Fifa17SquadRolePatch),
|
||||
}
|
||||
|
||||
/// Classify a squad PUT body. See [`SquadMutation`] for the discriminator and
|
||||
/// the capture evidence behind it.
|
||||
pub fn classify_squad_put(body: &[u8]) -> Result<SquadMutation, SquadError> {
|
||||
let raw: serde_json::Value =
|
||||
serde_json::from_slice(body).map_err(|e| SquadError::Parse(e.to_string()))?;
|
||||
let has_players = raw.as_object().is_some_and(|o| o.contains_key("players"));
|
||||
if has_players {
|
||||
return parse_squad_put(body).map(|p| SquadMutation::Replace(Box::new(p)));
|
||||
}
|
||||
serde_json::from_slice(body)
|
||||
.map(SquadMutation::PatchRoles)
|
||||
.map_err(|e| SquadError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
/// Resolve a parsed save into a **canonical** [`ProposedSquad`]: drop empty
|
||||
/// (`id == 0`) slots, reverse-map each occupied slot's wire id to a Core
|
||||
/// `owned_card_id`, flag the captain, derive the bench split from the fixed
|
||||
|
||||
@@ -61,7 +61,7 @@ pub struct KicktakerRef {
|
||||
}
|
||||
|
||||
/// FIFA 17 Squad Extension, version 1. Serialized to the opaque payload Core stores.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Fifa17SquadExtensionV1 {
|
||||
/// Opaque 33-int array as a JSON-encoded string, verbatim. Never decoded.
|
||||
#[serde(default)]
|
||||
|
||||
@@ -35,11 +35,14 @@ use std::collections::HashMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::club_response::ActiveKitAssignments;
|
||||
use crate::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item::{
|
||||
shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver, STAFF_CONTRACT,
|
||||
shape_club_item, shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver,
|
||||
STAFF_CONTRACT,
|
||||
};
|
||||
use crate::fut::item_state;
|
||||
use crate::fut::squad::FIFA17_SQUAD_SLOTS;
|
||||
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
|
||||
|
||||
@@ -185,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
|
||||
@@ -211,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!({
|
||||
@@ -235,15 +254,76 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
Ok(SquadProjection::Projected(squad))
|
||||
}
|
||||
|
||||
/// The active club items for `squad.actives`, in slot order.
|
||||
///
|
||||
/// ## Why this exists, and why it is NOT `[]`
|
||||
///
|
||||
/// `actives` is the ONLY carrier that makes a club item resident in FIFA 17.
|
||||
/// The squad parser's arm for atom 11 (`actives`) computes the address of the
|
||||
/// i-th element of the client's five-element club-item array and hands it to the
|
||||
/// item deserializer as the out-handle:
|
||||
///
|
||||
/// ```text
|
||||
/// cmp edi,0x5 ; at most five entries are read
|
||||
/// jge <skip>
|
||||
/// mov rax,QWORD PTR [r13+0x108] ; the club-item array
|
||||
/// lea rcx,[rax+rcx*8] ; &array[edi] (edi * 24)
|
||||
/// call 0x18013fe00 ; the item deserializer, writing that slot
|
||||
/// ```
|
||||
///
|
||||
/// 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
|
||||
/// 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
|
||||
/// resolves to the client's static not-found sentinel, whose item pointer is
|
||||
/// NULL — which is exactly why the pre-match kit selector had no kits.
|
||||
///
|
||||
/// Elements are shaped by the shared [`shape_club_item`], the same primitive
|
||||
/// `/club?type=kit` uses, so the two routes cannot drift. Ordering is positional
|
||||
/// on the wire but not semantic: both client consumers (the activate path and
|
||||
/// the store lookup) search the five slots by content — itemState, or
|
||||
/// cardtype/cardsubtypeid — never by index.
|
||||
///
|
||||
/// A designated kit whose owned row or FIFA kit identity cannot be resolved is
|
||||
/// omitted rather than emitted with a fabricated id, matching `/club`.
|
||||
pub fn squad_actives<I: ItemIdentityResolver + ?Sized>(
|
||||
owned: &HashMap<String, CoreOwnedItem>,
|
||||
ident: &I,
|
||||
active_kits: ActiveKitAssignments<'_>,
|
||||
) -> Value {
|
||||
let mut out = Vec::new();
|
||||
for (owned_card_id, state) in [
|
||||
(active_kits.home, item_state::ACTIVE_HOME_KIT),
|
||||
(active_kits.away, item_state::ACTIVE_AWAY_KIT),
|
||||
] {
|
||||
let Some(owned_card_id) = owned_card_id else {
|
||||
continue;
|
||||
};
|
||||
let Some(item) = owned.get(owned_card_id) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(id) = ident.resolve_kit(item) {
|
||||
out.push(shape_club_item(id, state));
|
||||
}
|
||||
}
|
||||
Value::Array(out)
|
||||
}
|
||||
|
||||
/// Wrap a projected squad object into the `userMassInfo.squad` shape, injecting
|
||||
/// the session-envelope fields the projector does not own (`personaId`, plus the
|
||||
/// observed constants `changed: 0`, `actives: []`).
|
||||
pub fn user_mass_info_squad(projected: Value, persona_id: i64) -> Value {
|
||||
/// the session-envelope fields the projector does not own: `personaId`, the
|
||||
/// observed constant `changed: 0`, and `actives` from [`squad_actives`].
|
||||
pub fn user_mass_info_squad(projected: Value, persona_id: i64, actives: Value) -> Value {
|
||||
let mut obj = projected;
|
||||
if let Value::Object(map) = &mut obj {
|
||||
map.insert("personaId".into(), json!(persona_id));
|
||||
map.insert("changed".into(), json!(0));
|
||||
map.insert("actives".into(), json!([]));
|
||||
map.insert("actives".into(), actives);
|
||||
}
|
||||
obj
|
||||
}
|
||||
@@ -525,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,
|
||||
@@ -544,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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,16 +24,18 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use openfut_adapter_fifa17::fut::club_response::ActiveKitAssignments;
|
||||
use openfut_adapter_fifa17::fut::item::{
|
||||
CoreOwnedItem, Fifa17Identity, Fifa17StaffIdentity, ItemIdentityResolver, STAFF_CONTRACT,
|
||||
CoreOwnedItem, Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver,
|
||||
STAFF_CONTRACT,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, Fifa17SquadPut, SquadWireResolver};
|
||||
use openfut_adapter_fifa17::fut::squad_ext::{build_squad_write, SquadWriteBuild};
|
||||
use openfut_adapter_fifa17::fut::squad_projection::{
|
||||
project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
|
||||
project_squad, squad_actives, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
|
||||
SquadProjection, SquadProjectionInput,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const PUT_BASELINE: &str = include_str!("../fixtures/utas/squad_put_f442.json");
|
||||
const PUT_SWAP: &str = include_str!("../fixtures/utas/squad_put_swap_f442.json");
|
||||
@@ -408,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"
|
||||
@@ -420,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!(
|
||||
@@ -503,11 +511,17 @@ fn one_projector_serves_every_endpoint_no_divergence() {
|
||||
&ident,
|
||||
);
|
||||
|
||||
// userMassInfo.squad = the projected object + session envelope.
|
||||
let ummi = user_mass_info_squad(projected.clone(), 33068179);
|
||||
// userMassInfo.squad = the projected object + session envelope. `actives` is
|
||||
// supplied by the caller now, so the envelope must carry it through verbatim
|
||||
// rather than hardcoding an empty array.
|
||||
let actives = json!([{ "id": 100004874, "itemState": "activeHomeKit" }]);
|
||||
let ummi = user_mass_info_squad(projected.clone(), 33068179, actives.clone());
|
||||
assert_eq!(ummi["personaId"], 33068179);
|
||||
assert_eq!(ummi["changed"], 0);
|
||||
assert!(ummi["actives"].is_array());
|
||||
assert_eq!(
|
||||
ummi["actives"], actives,
|
||||
"the envelope must pass actives through, not replace it"
|
||||
);
|
||||
assert_eq!(ummi["players"], projected["players"], "same projected body");
|
||||
assert_eq!(ummi["formation"], projected["formation"]);
|
||||
|
||||
@@ -530,3 +544,153 @@ fn one_projector_serves_every_endpoint_no_divergence() {
|
||||
// The summary carries only those six keys — no divergent squad shape.
|
||||
assert_eq!(entry.as_object().unwrap().len(), 6);
|
||||
}
|
||||
|
||||
// ---- squad.actives: the only carrier that makes a club item resident --------
|
||||
|
||||
/// A resolver that can answer `resolve_kit`, which the default trait method
|
||||
/// cannot (it returns `None` for player-only resolvers).
|
||||
struct KitIdentity(HashMap<String, Fifa17KitIdentity>);
|
||||
impl ItemIdentityResolver for KitIdentity {
|
||||
fn resolve(&self, _it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_kit(&self, it: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
|
||||
self.0.get(&it.owned_card_id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
fn kit_owned(owned_card_id: &str) -> CoreOwnedItem {
|
||||
CoreOwnedItem {
|
||||
owned_card_id: owned_card_id.to_string(),
|
||||
card_id: format!("def-{owned_card_id}"),
|
||||
rating: 0,
|
||||
position: String::new(),
|
||||
nation: String::new(),
|
||||
league: String::new(),
|
||||
club: String::new(),
|
||||
attributes: [0; 6],
|
||||
contract_matches: None,
|
||||
source_rating: None,
|
||||
core_content_kind: Some("kit".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn home_away_fixture() -> (HashMap<String, CoreOwnedItem>, KitIdentity) {
|
||||
let owned = HashMap::from([
|
||||
("oc-home".to_string(), kit_owned("oc-home")),
|
||||
("oc-away".to_string(), kit_owned("oc-away")),
|
||||
]);
|
||||
// Real `fcc_kitcards` rows for team 21: 6300006 is the home card (category 2,
|
||||
// assetid 14) and 6400003 the away card (category 3, assetid 15).
|
||||
let ident = KitIdentity(HashMap::from([
|
||||
(
|
||||
"oc-home".to_string(),
|
||||
Fifa17KitIdentity {
|
||||
item_id: 100004874,
|
||||
asset_id: 14,
|
||||
resource_id: 6300006,
|
||||
card_asset_id: 35,
|
||||
subtype: 9,
|
||||
team_id: 21,
|
||||
category: 2,
|
||||
year: 0,
|
||||
},
|
||||
),
|
||||
(
|
||||
"oc-away".to_string(),
|
||||
Fifa17KitIdentity {
|
||||
item_id: 100004873,
|
||||
asset_id: 15,
|
||||
resource_id: 6400003,
|
||||
card_asset_id: 35,
|
||||
subtype: 9,
|
||||
team_id: 21,
|
||||
category: 3,
|
||||
year: 0,
|
||||
},
|
||||
),
|
||||
]));
|
||||
(owned, ident)
|
||||
}
|
||||
|
||||
/// The client's squad parser reads at most five `actives` entries and parses each
|
||||
/// one straight into a slot of its five-element club-item array, so each element
|
||||
/// must be a full item object carrying a non-zero `id` — an id reference alone
|
||||
/// installs nothing.
|
||||
#[test]
|
||||
fn squad_actives_emits_full_items_for_the_designated_kits() {
|
||||
let (owned, ident) = home_away_fixture();
|
||||
let actives = squad_actives(
|
||||
&owned,
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("oc-home"),
|
||||
away: Some("oc-away"),
|
||||
},
|
||||
);
|
||||
let arr = actives.as_array().expect("actives is an array");
|
||||
assert_eq!(arr.len(), 2, "one entry per designated kit");
|
||||
|
||||
assert_eq!(arr[0]["id"], 100004874);
|
||||
assert_eq!(arr[0]["itemState"], "activeHomeKit");
|
||||
assert_eq!(arr[0]["resourceId"], 6300006);
|
||||
assert_eq!(arr[1]["id"], 100004873);
|
||||
assert_eq!(arr[1]["itemState"], "activeAwayKit");
|
||||
assert_eq!(arr[1]["resourceId"], 6400003);
|
||||
|
||||
for entry in arr {
|
||||
assert_eq!(entry["itemType"], "kit");
|
||||
assert_eq!(
|
||||
entry["cardsubtypeid"], 9,
|
||||
"cardsubtypeid 9 derives cardtype 7"
|
||||
);
|
||||
assert_eq!(entry["teamid"], 21, "the clone path keys kit art on teamid");
|
||||
assert_ne!(entry["id"], 0, "a zero id is never made resident");
|
||||
}
|
||||
}
|
||||
|
||||
/// Undesignated slots contribute nothing, and an unresolvable designation is
|
||||
/// omitted rather than emitted with a fabricated id — the same policy `/club`
|
||||
/// applies when a card has no FIFA identity.
|
||||
#[test]
|
||||
fn squad_actives_omits_absent_and_unresolvable_designations() {
|
||||
let (owned, ident) = home_away_fixture();
|
||||
|
||||
let home_only = squad_actives(
|
||||
&owned,
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("oc-home"),
|
||||
away: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(home_only.as_array().unwrap().len(), 1);
|
||||
assert_eq!(home_only[0]["itemState"], "activeHomeKit");
|
||||
|
||||
// Designated but not present in the owned collection.
|
||||
let dangling = squad_actives(
|
||||
&owned,
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("oc-missing"),
|
||||
away: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(dangling.as_array().unwrap().len(), 0);
|
||||
|
||||
// Present and designated, but with no resolvable FIFA kit identity.
|
||||
let unresolvable = squad_actives(
|
||||
&HashMap::from([("oc-x".to_string(), kit_owned("oc-x"))]),
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("oc-x"),
|
||||
away: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(unresolvable.as_array().unwrap().len(), 0);
|
||||
|
||||
// Nothing designated at all is an empty array, which is what left every
|
||||
// club-item slot null before this projector existed.
|
||||
let none = squad_actives(&owned, &ident, ActiveKitAssignments::default());
|
||||
assert_eq!(none.as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
+1
-1
Submodule openfut-core updated: 1df03d4287...20e281e0cf
+1
-1
Submodule openfut-launcher updated: d7641175be...bee97055db
@@ -59,6 +59,30 @@ pub struct HostConfig {
|
||||
/// Disabled by default. Non-off values require three explicit staging guards;
|
||||
/// see [`parse_sbc_post_commit_fault`].
|
||||
pub sbc_post_commit_fault: SbcPostCommitFault,
|
||||
/// Emit the club's active club items in `squad.actives`. **Enabled by
|
||||
/// default** — this is normal, correct FIFA 17 behaviour, not an experiment.
|
||||
///
|
||||
/// `actives` is the carrier that makes a club item resident: the squad
|
||||
/// parser writes each element straight into a native club-item slot. With it
|
||||
/// populated the pre-match kit selector works, proven end to end on a retail
|
||||
/// client — 29 resident nodes with both cardtype-7 kits in club slots 0 and 1
|
||||
/// (`itemState` 101/102, category 4) alongside 23/23 players and the manager,
|
||||
/// and the selector rendering the correct distinct home and away kits.
|
||||
///
|
||||
/// Scope is deliberately narrow: [`squad_actives`] emits ONLY the home and
|
||||
/// away kit, both resolved from Core's own active designations and required
|
||||
/// to be genuinely owned. Badge, ball and stadium are never emitted, so
|
||||
/// enabling this cannot surface an unproven active family.
|
||||
///
|
||||
/// History, so the default is not naively flipped back: an early build was
|
||||
/// once seen to empty the squad when this array was populated (resident map
|
||||
/// down to the 2 kits, player vector fully null). That never reproduced, and
|
||||
/// it can no longer cause durable damage — both squad write-back paths are
|
||||
/// guarded in Core (`refuse to empty a populated squad`, and an absent
|
||||
/// manager field no longer meaning "clear").
|
||||
///
|
||||
/// Set `OPENFUT_FIFA17_SQUAD_ACTIVES=0` to force it off for diagnostics.
|
||||
pub squad_actives: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -91,6 +115,17 @@ fn required_i64_nonzero(key: &str) -> Result<i64, ConfigError> {
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
/// Whether to emit `squad.actives`. Correct FIFA 17 behaviour is ON, so an
|
||||
/// absent or unrecognised value means ON; only an explicit `0`/`false`/`off`/`no`
|
||||
/// turns it off, which exists for diagnostics. See
|
||||
/// [`HostConfig::squad_actives`].
|
||||
fn parse_squad_actives(raw: Option<&str>) -> bool {
|
||||
!matches!(
|
||||
raw.unwrap_or_default().trim().to_ascii_lowercase().as_str(),
|
||||
"0" | "false" | "off" | "no"
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_sbc_post_commit_fault(
|
||||
raw: Option<&str>,
|
||||
environment: Option<&str>,
|
||||
@@ -177,6 +212,9 @@ impl HostConfig {
|
||||
clientdata_path,
|
||||
account_path,
|
||||
sbc_post_commit_fault,
|
||||
squad_actives: parse_squad_actives(
|
||||
env::var("OPENFUT_FIFA17_SQUAD_ACTIVES").ok().as_deref(),
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -205,6 +243,34 @@ fn default_account_path(identity_store_path: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `squad.actives` is ON without any environment variable.
|
||||
///
|
||||
/// Emitting the club's active home/away kit is normal FIFA 17 behaviour —
|
||||
/// it is what lets the client make the kits resident and render the
|
||||
/// pre-match selector — so a correct deployment must not have to opt in.
|
||||
/// The off switch survives only as a diagnostic.
|
||||
#[test]
|
||||
fn squad_actives_is_on_by_default_and_only_explicitly_disabled() {
|
||||
// The case that matters: nothing configured at all.
|
||||
assert!(
|
||||
parse_squad_actives(None),
|
||||
"a deployment that sets nothing must still emit squad.actives"
|
||||
);
|
||||
assert!(parse_squad_actives(Some("")));
|
||||
assert!(parse_squad_actives(Some(" ")));
|
||||
|
||||
// Explicit disable, for diagnostics.
|
||||
for off in ["0", "false", "off", "no", "OFF", " False "] {
|
||||
assert!(!parse_squad_actives(Some(off)), "{off} must disable");
|
||||
}
|
||||
|
||||
// Explicit enable stays valid, and anything unrecognised stays ON
|
||||
// rather than silently disabling the feature.
|
||||
for on in ["1", "true", "on", "yes", "TRUE", "banana"] {
|
||||
assert!(parse_squad_actives(Some(on)), "{on} must leave it enabled");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sbc_post_commit_faults_require_all_staging_guards() {
|
||||
assert_eq!(
|
||||
|
||||
+386
-43
@@ -76,12 +76,16 @@ use openfut_adapter_fifa17::fut::owned_query::{
|
||||
use openfut_adapter_fifa17::fut::pack_content::GeneratedCandidate;
|
||||
use openfut_adapter_fifa17::fut::sbc as fifa17_sbc;
|
||||
use openfut_adapter_fifa17::fut::season_wire;
|
||||
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, save_ack, SquadWireResolver};
|
||||
use openfut_adapter_fifa17::fut::squad::{
|
||||
classify_squad_put, save_ack, Fifa17SquadPut, Fifa17SquadRolePatch, SquadMutation,
|
||||
SquadWireResolver,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::squad_ext::{
|
||||
build_squad_write, Fifa17SquadExtensionV1, SquadBuildError, EXT_NAMESPACE, EXT_SCHEMA_VERSION,
|
||||
build_squad_write, Fifa17SquadExtensionV1, KicktakerRef, SquadBuildError, WireItemRef,
|
||||
EXT_NAMESPACE, EXT_SCHEMA_VERSION,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::squad_projection::{
|
||||
project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
|
||||
project_squad, squad_actives, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
|
||||
SquadProjection, SquadProjectionInput,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::store_catalog::{
|
||||
@@ -206,8 +210,8 @@ pub enum Route {
|
||||
|
||||
/// Classify a request ONCE, before execution. Rust owns complete route families;
|
||||
/// there is no "try Rust then Python", so a mutation can never be double-applied.
|
||||
/// Numeric `GET …/squad/<n>` follows the oracle's single-current-squad behavior:
|
||||
/// every numeric id returns the one Core-backed active squad.
|
||||
/// Numeric `…/squad/<n>` resolves to the one Core-backed squad. That is SAFE
|
||||
/// rather than authentic — see [`is_numeric_squad_tail`].
|
||||
pub fn classify(method: &str, path: &str) -> Route {
|
||||
let get = method.eq_ignore_ascii_case("GET");
|
||||
let put = method.eq_ignore_ascii_case("PUT");
|
||||
@@ -237,7 +241,15 @@ pub fn classify(method: &str, path: &str) -> Route {
|
||||
Some("squad/list") if get => Route::SquadList,
|
||||
Some("squad/active") if get => Route::SquadActive,
|
||||
Some(tail) if get && is_numeric_squad_tail(tail) => Route::SquadActive,
|
||||
Some("userMassInfo") if get => Route::UserMassInfo,
|
||||
// The retail client sends BOTH casings: a lowercase `usermassinfo`
|
||||
// followed immediately by the canonical `userMassInfo`. Matching the
|
||||
// literal only meant the lowercase one fell through to the Python
|
||||
// upstream — a 502 here, but on a deployment with Python alive it would
|
||||
// be ANSWERED there, silently splitting authority away from Rust for a
|
||||
// route Core owns. Matched case-insensitively for this tail only; the
|
||||
// rest of the table stays exact, since no other route has shown a
|
||||
// casing variant.
|
||||
Some(t) if get && t.eq_ignore_ascii_case("usermassinfo") => Route::UserMassInfo,
|
||||
Some("user/club") if put || post => Route::ClubRename,
|
||||
Some(tail) if tail.starts_with("clientdata/") => Route::ClientData,
|
||||
Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup,
|
||||
@@ -395,9 +407,35 @@ fn is_exact_club_path(path: &str) -> bool {
|
||||
ut_tail(path) == Some("club")
|
||||
}
|
||||
|
||||
/// `squad/<digits>` — the numeric full-squad target used by PUT and GET. Core
|
||||
/// stores one current squad, matching the oracle: every numeric GET returns that
|
||||
/// same squad regardless of the requested id.
|
||||
/// `squad/<digits>` — the numeric full-squad target used by PUT and GET.
|
||||
///
|
||||
/// Every numeric id resolves to the one Core-backed squad. Be precise about why
|
||||
/// that is acceptable: it is SAFE, not authentic.
|
||||
///
|
||||
/// FIFA 17 genuinely has multi-squad semantics. The client ships
|
||||
/// `SelectSquadById`, `RetrieveSquad` as an action DISTINCT from
|
||||
/// `LoadActiveSquad`, plus `CreateSquadWithName`, `RenameSquad`, `DeleteSquad`,
|
||||
/// `CopySquad`, indexed `SQUAD_ID-%d` list entries and a
|
||||
/// `FUT_MAX_NUM_SQUAD_REACHED` string. The number is therefore a real squad
|
||||
/// identifier, not a placeholder.
|
||||
///
|
||||
/// It is unreachable here because we advertise exactly ONE squad:
|
||||
/// [`ACTIVE_SQUAD_WIRE_ID`] is a constant `0`, `/squad/list` returns a
|
||||
/// single-element array carrying that id, and no create/rename/delete/copy
|
||||
/// route exists. The client can only ever echo back the id we gave it — every
|
||||
/// numeric path observed in retained captures is `squad/0`, all of them PUTs
|
||||
/// whose body `id` also reads 0, and no numeric GET has ever been recorded.
|
||||
///
|
||||
/// So collapsing the id costs nothing TODAY and is pinned by
|
||||
/// `numeric_squad_routing_is_safe_only_while_one_squad_is_advertised`. The
|
||||
/// moment a second squad becomes addressable, that test must fail and this
|
||||
/// routing must gain a real lookup. Do NOT widen squad support without
|
||||
/// revisiting it.
|
||||
///
|
||||
/// Unknown-id behaviour is deliberately NOT invented: no FIFA 17 evidence shows
|
||||
/// what the client expects for an id it was never given. (The FIFA 14 oracle
|
||||
/// Impulsum14 returns an empty squad carrying that id, never the active one —
|
||||
/// hypothesis only, not FIFA 17 truth.)
|
||||
fn is_numeric_squad_tail(tail: &str) -> bool {
|
||||
match tail.strip_prefix("squad/") {
|
||||
Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),
|
||||
@@ -776,6 +814,21 @@ pub struct CoreReplaceRequest {
|
||||
pub ext_payload: String,
|
||||
}
|
||||
|
||||
/// A role-only squad patch: captain plus the opaque extension, nothing else.
|
||||
///
|
||||
/// Deliberately has no `slots`, `name`, `formation` or manager field — the
|
||||
/// absence is structural, so this request cannot express a squad replacement
|
||||
/// even by mistake.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoreRolePatchRequest {
|
||||
/// Owned instance to flag as captain. `None` leaves the captain unchanged;
|
||||
/// it is never a request to clear it.
|
||||
pub captain_owned_card_id: Option<String>,
|
||||
pub ext_namespace: String,
|
||||
pub ext_schema_version: i64,
|
||||
pub ext_payload: String,
|
||||
}
|
||||
|
||||
/// Client-reported shadow evaluation carried through to Core (never Core's
|
||||
/// authoritative evaluation).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -844,6 +897,18 @@ pub trait CoreAccess: Send + Sync {
|
||||
/// Replace the active squad's canonical slots + opaque extension atomically.
|
||||
fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError>;
|
||||
|
||||
/// Patch ONLY the active squad's role assignments (captain) + opaque
|
||||
/// extension. Never touches player assignments, the manager, or actives.
|
||||
///
|
||||
/// Separate from [`Self::replace_squad`] because they are different
|
||||
/// operations: a role-only client update carries no slot array, and sending
|
||||
/// it as a replacement presents zero slots to Core's empty-replacement
|
||||
/// guard. Default is `Status(501)` so a transport that has not implemented
|
||||
/// it fails loudly instead of silently degrading into a replacement.
|
||||
fn patch_squad_roles(&self, _req: &CoreRolePatchRequest) -> Result<(), CoreError> {
|
||||
Err(CoreError::Status(501))
|
||||
}
|
||||
|
||||
/// The owned instance id assigned as the active squad's **manager**, or
|
||||
/// `None` (`GET /club/manager`). Default: `None` — a transport without the
|
||||
/// endpoint simply projects no manager (non-fatal, like an absent
|
||||
@@ -852,11 +917,19 @@ pub trait CoreAccess: Send + Sync {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Assign (`Some`) or clear (`None`) the active squad's **manager**
|
||||
/// (`PUT /club/manager`). Default: unimplemented — the production
|
||||
/// `HttpCoreClient` overrides it; a transport that cannot persist the
|
||||
/// assignment MUST fail loudly rather than silently drop it.
|
||||
fn set_squad_manager(&self, _owned_card_id: Option<&str>) -> Result<(), CoreError> {
|
||||
/// ASSIGN the active squad's **manager** (`PUT /club/manager`). Default:
|
||||
/// unimplemented — the production `HttpCoreClient` overrides it; a transport
|
||||
/// that cannot persist the assignment MUST fail loudly rather than silently
|
||||
/// drop it.
|
||||
///
|
||||
/// Deliberately takes `&str`, NOT `Option<&str>`: there is no FIFA 17 wire
|
||||
/// shape that removes a manager. The client always sends a manager ref, so
|
||||
/// "no ownership-backed manager in this save" means the ref was missing or
|
||||
/// unmappable — never that the user cleared the slot. Passing `None` here
|
||||
/// used to be forwarded as an explicit null and DELETED the assignment; that
|
||||
/// is how a client with a destroyed squad model wiped a real manager row.
|
||||
/// The capability is gone at the type level so the host cannot express it.
|
||||
fn set_squad_manager(&self, _owned_card_id: &str) -> Result<(), CoreError> {
|
||||
Err(CoreError::Parse(
|
||||
"Core squad manager write is not implemented".into(),
|
||||
))
|
||||
@@ -1008,6 +1081,29 @@ impl CoreAccess for HttpCoreClient {
|
||||
})
|
||||
}
|
||||
|
||||
fn patch_squad_roles(&self, req: &CoreRolePatchRequest) -> Result<(), CoreError> {
|
||||
let url = format!("{}/squad/roles", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.put(&url)
|
||||
.header("X-OpenFUT-Game", &self.game)
|
||||
.json(&json!({
|
||||
"captain_owned_card_id": req.captain_owned_card_id,
|
||||
"extension": {
|
||||
"namespace": req.ext_namespace,
|
||||
"schema_version": req.ext_schema_version,
|
||||
"payload": req.ext_payload,
|
||||
},
|
||||
}))
|
||||
.send()
|
||||
.map_err(|e| CoreError::Http(e.to_string()))?;
|
||||
let status = resp.status().as_u16();
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(CoreError::Status(status));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
|
||||
let url = format!("{}/club/manager", self.base_url);
|
||||
let resp = self
|
||||
@@ -1039,7 +1135,7 @@ impl CoreAccess for HttpCoreClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> {
|
||||
fn set_squad_manager(&self, owned_card_id: &str) -> Result<(), CoreError> {
|
||||
let url = format!("{}/club/manager", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
@@ -2120,7 +2216,9 @@ impl ItemIdentityResolver for Fifa17IdentityResolver {
|
||||
}
|
||||
Some(Fifa17KitIdentity {
|
||||
item_id: self.wire_for(item)?,
|
||||
asset_id: ident.asset_id,
|
||||
// The club-item wire `assetId` is family specific and is NOT the
|
||||
// carddbid — see `Fifa17CardIdentity::club_asset_id`.
|
||||
asset_id: ident.club_asset_id,
|
||||
resource_id: ident.resource_id,
|
||||
card_asset_id: ident.card_asset_id,
|
||||
subtype: ident.subtype,
|
||||
@@ -2652,6 +2750,8 @@ pub struct SquadDeps<'a> {
|
||||
pub core: &'a dyn CoreAccess,
|
||||
pub resolver: &'a Fifa17IdentityResolver,
|
||||
pub entities: &'a Fifa17Entities,
|
||||
/// Emit `squad.actives`. Default OFF — see [`crate::config::HostConfig`].
|
||||
pub squad_actives: bool,
|
||||
}
|
||||
|
||||
/// Secret-free structured log line for a handled squad request.
|
||||
@@ -2663,8 +2763,10 @@ pub struct SquadLog {
|
||||
|
||||
/// The active squad projected from Core, with the freshness policy applied.
|
||||
enum HostProjection {
|
||||
/// Fresh: the projected FIFA squad object (before any endpoint envelope).
|
||||
Squad(Value),
|
||||
/// Fresh: the projected FIFA squad object (before any endpoint envelope),
|
||||
/// plus the active club items for its `actives` array. They travel together
|
||||
/// because both are derived from the SAME bounded Core fetch.
|
||||
Squad { squad: Value, actives: Value },
|
||||
/// Stored extension is stale vs the canonical squad — NEVER applied.
|
||||
Stale,
|
||||
/// No extension stored — nothing fabricated.
|
||||
@@ -2747,8 +2849,38 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection {
|
||||
owned: &owned_by_id,
|
||||
manager,
|
||||
};
|
||||
// The active club designations Core already owns (`/club/active-items`), shaped
|
||||
// into the `actives` array that makes a club item resident.
|
||||
//
|
||||
// DEFAULT OFF (`HostConfig::squad_actives`). Populating this array does make
|
||||
// the kits resident and the pre-match selector work, but it was also observed
|
||||
// to cost the rest of the squad: the resident item map fell from 24 entries to
|
||||
// just the 2 kits, the 23-slot player vector came back fully null, and the
|
||||
// starting-11 screen was empty. `actives` sorts first in the squad object, so
|
||||
// everything after it is lost. Until that is understood an empty squad is far
|
||||
// worse than a missing kit.
|
||||
let actives = if !deps.squad_actives {
|
||||
json!([])
|
||||
} else {
|
||||
match deps.core.get_active_kits() {
|
||||
Ok(assignments) => squad_actives(
|
||||
&owned_by_id,
|
||||
deps.resolver,
|
||||
ActiveKitAssignments {
|
||||
home: assignments.home_owned_card_id.as_deref(),
|
||||
away: assignments.away_owned_card_id.as_deref(),
|
||||
},
|
||||
),
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"utas-host WARN active club items unavailable, squad.actives empty: {error}"
|
||||
);
|
||||
json!([])
|
||||
}
|
||||
}
|
||||
};
|
||||
match project_squad(&input, deps.resolver, deps.entities) {
|
||||
Ok(SquadProjection::Projected(v)) => HostProjection::Squad(v),
|
||||
Ok(SquadProjection::Projected(squad)) => HostProjection::Squad { squad, actives },
|
||||
Ok(SquadProjection::Stale) => HostProjection::Stale,
|
||||
Ok(SquadProjection::Missing) => HostProjection::Missing,
|
||||
Err(e) => HostProjection::Error(e.to_string()),
|
||||
@@ -2766,26 +2898,35 @@ fn error_response(status: u16, code: &str) -> WireResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUT …/squad/<n>` — parse the full replacement, reverse-resolve every wire id,
|
||||
/// AUTHORIZE every resolved item against the active club, then commit the
|
||||
/// canonical squad + FIFA extension to Core in one transaction. On any failure
|
||||
/// it returns an error and NEVER falls back to Python (no double mutation).
|
||||
/// `PUT …/squad/<n>` — dispatch on which mutation the body actually expresses.
|
||||
///
|
||||
/// FIFA 17 sends two operations down this one path and distinguishes them only
|
||||
/// by body shape (see [`SquadMutation`]). Classifying FIRST is the whole fix: a
|
||||
/// role-only update carries no `players`, and routing it into the replacement
|
||||
/// path presents zero slots to Core's empty-replacement guard, which correctly
|
||||
/// refuses it — silently losing the user's captain/kick-taker change.
|
||||
pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
|
||||
let put = match parse_squad_put(body) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return (
|
||||
match classify_squad_put(body) {
|
||||
Ok(SquadMutation::Replace(put)) => handle_squad_replace(&put, deps),
|
||||
Ok(SquadMutation::PatchRoles(patch)) => handle_squad_role_patch(&patch, deps),
|
||||
Err(e) => (
|
||||
error_response(400, "parse_error"),
|
||||
SquadLog {
|
||||
outcome: "parse_error",
|
||||
detail: e.to_string(),
|
||||
},
|
||||
)
|
||||
),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// The full-replacement path: reverse-resolve every wire id, AUTHORIZE every
|
||||
/// resolved item against the active club, then commit the canonical squad +
|
||||
/// FIFA extension to Core in one transaction. On any failure it returns an
|
||||
/// error and NEVER falls back to Python (no double mutation).
|
||||
fn handle_squad_replace(put: &Fifa17SquadPut, deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
|
||||
// Reverse-resolve wire→owned and shape canonical + extension. Refuses on an
|
||||
// unresolved wire id or the same owned item placed twice.
|
||||
let build = match build_squad_write(&put, deps.resolver) {
|
||||
let build = match build_squad_write(put, deps.resolver) {
|
||||
Ok(b) => b,
|
||||
Err(SquadBuildError::UnresolvedWireIds(ids)) => {
|
||||
return (
|
||||
@@ -2891,10 +3032,18 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
|
||||
// Persist the ownership-backed manager assignment (migration 0023). It was
|
||||
// authorized above and Core re-validates club ownership; fail loudly on a
|
||||
// transport error rather than silently dropping the manager.
|
||||
if let Err(e) = deps
|
||||
.core
|
||||
.set_squad_manager(build.canonical.manager_owned_card_id.as_deref())
|
||||
{
|
||||
// Only written when this save actually carried an ownership-backed manager.
|
||||
//
|
||||
// A save with no resolvable manager is NOT a request to remove the current
|
||||
// one. FIFA 17 has no "remove manager" wire shape — the client always sends a
|
||||
// ref — so `None` here means the ref was absent, zero, or unmappable, i.e.
|
||||
// this save says nothing about the manager. Forwarding that absence as an
|
||||
// explicit clear is exactly how a client whose squad model had been destroyed
|
||||
// deleted a real manager row (WAL commit 468, squad_managers 1 -> 0), so the
|
||||
// assignment is left untouched instead.
|
||||
match build.canonical.manager_owned_card_id.as_deref() {
|
||||
Some(mgr) => {
|
||||
if let Err(e) = deps.core.set_squad_manager(mgr) {
|
||||
return (
|
||||
error_response(502, "core_error"),
|
||||
SquadLog {
|
||||
@@ -2903,6 +3052,12 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
None => eprintln!(
|
||||
"utas-host owner=RUST route=squad-replace manager_write_skipped \
|
||||
(save carried no ownership-backed manager; existing assignment left unchanged)"
|
||||
),
|
||||
}
|
||||
(
|
||||
json_response(&save_ack(put.id)),
|
||||
SquadLog {
|
||||
@@ -2912,13 +3067,160 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
|
||||
)
|
||||
}
|
||||
|
||||
/// `PUT …/squad/<n>` carrying NO `players` — the role-only patch path.
|
||||
///
|
||||
/// Observed real-client shape: `{id, custom, captain, kicktakers[5]}`, emitted
|
||||
/// by the captain/kick-taker screen. Omission is the contract here: the absent
|
||||
/// `players`, `manager` and actives mean UNCHANGED, never cleared. That is
|
||||
/// enforced structurally — [`CoreRolePatchRequest`] has no field able to express
|
||||
/// any of them, and Core's patch issues no statement that can touch them.
|
||||
///
|
||||
/// The extension is MERGED, not overwritten: the patch body carries only
|
||||
/// `custom` and `kicktakers`, so kit numbers, squad type and the client-reported
|
||||
/// evaluation are preserved from the stored copy. Overwriting would silently
|
||||
/// drop every kit number in the squad.
|
||||
fn handle_squad_role_patch(
|
||||
patch: &Fifa17SquadRolePatch,
|
||||
deps: &SquadDeps<'_>,
|
||||
) -> (WireResponse, SquadLog) {
|
||||
// Start from the stored extension so omitted FIFA-only state survives. A
|
||||
// stale extension is still the right base: its kit numbers belong to the
|
||||
// same squad, and this patch re-anchors the fingerprint on commit.
|
||||
let read = match deps.core.read_squad_ext(EXT_NAMESPACE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return (
|
||||
error_response(502, "core_error"),
|
||||
SquadLog {
|
||||
outcome: "core_error",
|
||||
detail: e.to_string(),
|
||||
},
|
||||
)
|
||||
}
|
||||
};
|
||||
let mut ext = match &read.ext {
|
||||
CoreExtState::Fresh {
|
||||
schema_version,
|
||||
payload,
|
||||
}
|
||||
| CoreExtState::Stale {
|
||||
schema_version,
|
||||
payload,
|
||||
} => Fifa17SquadExtensionV1::from_payload(*schema_version, payload).unwrap_or_default(),
|
||||
CoreExtState::Missing => Fifa17SquadExtensionV1::default(),
|
||||
};
|
||||
|
||||
// Carry the patch's own fields through. `custom` genuinely differs between
|
||||
// the two shapes -- the role screen writes per-slot values into it -- so it
|
||||
// is taken from the patch, not preserved.
|
||||
if patch.custom.is_some() {
|
||||
ext.custom = patch.custom.clone();
|
||||
}
|
||||
ext.kicktakers = patch
|
||||
.kicktakers
|
||||
.iter()
|
||||
.map(|k| KicktakerRef {
|
||||
index: k.index,
|
||||
item: WireItemRef {
|
||||
id: k.id,
|
||||
dream: k.dream,
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Resolve + AUTHORIZE the captain before any write. Identity resolution is
|
||||
// not authorization: a globally-valid wire id belonging to another profile
|
||||
// must not become this club's captain.
|
||||
let mut captain_owned_card_id = None;
|
||||
if let Some(wire) = patch.captain.filter(|c| *c != 0) {
|
||||
match deps.resolver.owned_id_for_wire(wire) {
|
||||
Some(owned) => {
|
||||
let owned_set: std::collections::HashSet<String> = match deps.core.all_owned() {
|
||||
Ok(v) => v.into_iter().map(|i| i.owned_card_id).collect(),
|
||||
Err(e) => {
|
||||
return (
|
||||
error_response(502, "core_error"),
|
||||
SquadLog {
|
||||
outcome: "core_error",
|
||||
detail: e.to_string(),
|
||||
},
|
||||
)
|
||||
}
|
||||
};
|
||||
if !owned_set.contains(&owned) {
|
||||
return (
|
||||
error_response(403, "not_owned"),
|
||||
SquadLog {
|
||||
outcome: "unauthorized_captain",
|
||||
detail: owned,
|
||||
},
|
||||
);
|
||||
}
|
||||
captain_owned_card_id = Some(owned);
|
||||
}
|
||||
None => {
|
||||
// Refuse rather than silently patch the kicktakers alone: the
|
||||
// user asked for a captain and would otherwise be told it
|
||||
// succeeded while the captain never moved.
|
||||
return (
|
||||
error_response(400, "unresolved_captain"),
|
||||
SquadLog {
|
||||
outcome: "unresolved_captain",
|
||||
detail: wire.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let req = CoreRolePatchRequest {
|
||||
captain_owned_card_id,
|
||||
ext_namespace: EXT_NAMESPACE.to_string(),
|
||||
ext_schema_version: EXT_SCHEMA_VERSION,
|
||||
ext_payload: ext.to_payload(),
|
||||
};
|
||||
if let Err(e) = deps.core.patch_squad_roles(&req) {
|
||||
// A Core 400 means the REQUEST was invalid (e.g. a captain not fielded
|
||||
// in this squad). Reporting that as 502 would blame the server for a
|
||||
// client error and hide it behind "upstream unavailable".
|
||||
let (status, code) = match e {
|
||||
CoreError::Status(400) => (400, "invalid_role_patch"),
|
||||
_ => (502, "core_error"),
|
||||
};
|
||||
return (
|
||||
error_response(status, code),
|
||||
SquadLog {
|
||||
outcome: if status == 400 {
|
||||
"invalid_role_patch"
|
||||
} else {
|
||||
"core_error"
|
||||
},
|
||||
detail: e.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=squad-roles captain={:?} kicktakers={} \
|
||||
(players/manager/actives untouched)",
|
||||
req.captain_owned_card_id,
|
||||
ext.kicktakers.len()
|
||||
);
|
||||
(
|
||||
json_response(&save_ack(patch.id)),
|
||||
SquadLog {
|
||||
outcome: "ok",
|
||||
detail: String::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// `GET …/squad/list` — served from Core + the ONE projector. Stale/Missing are
|
||||
/// integrity failures for the migrated dev profile: logged prominently, degraded
|
||||
/// to an empty list, NEVER served from Python and NEVER projected from stale ext.
|
||||
pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
|
||||
match project_active_squad(deps) {
|
||||
HostProjection::Squad(v) => (
|
||||
json_response(&squad_list(&v)),
|
||||
HostProjection::Squad { squad, .. } => (
|
||||
json_response(&squad_list(&squad)),
|
||||
SquadLog {
|
||||
outcome: "ok",
|
||||
detail: String::new(),
|
||||
@@ -2955,8 +3257,8 @@ pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
|
||||
/// (never 401/403, never a Python fallback that could mask split authority).
|
||||
pub fn handle_squad_active(deps: &SquadDeps<'_>, persona_id: i64) -> (WireResponse, SquadLog) {
|
||||
match project_active_squad(deps) {
|
||||
HostProjection::Squad(v) => (
|
||||
json_response(&user_mass_info_squad(v, persona_id)),
|
||||
HostProjection::Squad { squad, actives } => (
|
||||
json_response(&user_mass_info_squad(squad, persona_id, actives)),
|
||||
SquadLog {
|
||||
outcome: "ok",
|
||||
detail: String::new(),
|
||||
@@ -3081,8 +3383,8 @@ pub fn handle_user_mass_info(
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let (squad_val, log) = match project_active_squad(deps) {
|
||||
HostProjection::Squad(v) => (
|
||||
user_mass_info_squad(v, persona),
|
||||
HostProjection::Squad { squad, actives } => (
|
||||
user_mass_info_squad(squad, persona, actives),
|
||||
SquadLog {
|
||||
outcome: "ok",
|
||||
detail: String::new(),
|
||||
@@ -3535,6 +3837,8 @@ pub struct Server {
|
||||
economy_gate: Arc<Mutex<()>>,
|
||||
/// Staging-only simulation of losing the successful SBC submit receipt.
|
||||
sbc_post_commit_fault: SbcPostCommitFault,
|
||||
/// Emit `squad.actives`. Default OFF; see `HostConfig::squad_actives`.
|
||||
squad_actives: bool,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
@@ -3559,6 +3863,7 @@ impl Server {
|
||||
clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())),
|
||||
economy_gate: Arc::new(Mutex::new(())),
|
||||
sbc_post_commit_fault: SbcPostCommitFault::Off,
|
||||
squad_actives: false,
|
||||
current_match: Arc::new(PlMutex::new(None)),
|
||||
}
|
||||
}
|
||||
@@ -3626,6 +3931,10 @@ impl Server {
|
||||
eprintln!(
|
||||
"utas-host sbc_post_commit_fault={:?}",
|
||||
cfg.sbc_post_commit_fault
|
||||
);
|
||||
eprintln!(
|
||||
"utas-host squad_actives={} (ON emits the club's active home/away kit so the client can make them resident; set OPENFUT_FIFA17_SQUAD_ACTIVES=0 to disable)",
|
||||
cfg.squad_actives
|
||||
);
|
||||
Ok(Server::new(
|
||||
core,
|
||||
@@ -3637,7 +3946,8 @@ impl Server {
|
||||
.with_economy(economy)
|
||||
.with_clientdata(clientdata)
|
||||
.with_account(account)
|
||||
.with_sbc_post_commit_fault(cfg.sbc_post_commit_fault))
|
||||
.with_sbc_post_commit_fault(cfg.sbc_post_commit_fault)
|
||||
.with_squad_actives(cfg.squad_actives))
|
||||
}
|
||||
|
||||
/// Assemble the shared squad dependencies (Core access + the one production
|
||||
@@ -3647,6 +3957,7 @@ impl Server {
|
||||
core: self.core.as_ref(),
|
||||
resolver: self.resolver.as_ref(),
|
||||
entities: self.entities.as_ref(),
|
||||
squad_actives: self.squad_actives,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3672,6 +3983,14 @@ impl Server {
|
||||
self
|
||||
}
|
||||
|
||||
/// Emit `squad.actives`. Default OFF: a populated array makes the kits
|
||||
/// resident but was observed to cost the rest of the squad (see
|
||||
/// `HostConfig::squad_actives`).
|
||||
fn with_squad_actives(mut self, on: bool) -> Self {
|
||||
self.squad_actives = on;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_sbc_post_commit_fault(mut self, fault: SbcPostCommitFault) -> Self {
|
||||
self.sbc_post_commit_fault = fault;
|
||||
self
|
||||
@@ -4556,7 +4875,9 @@ impl Server {
|
||||
};
|
||||
let deps = self.squad_deps();
|
||||
let (squad, squad_outcome) = match project_active_squad(&deps) {
|
||||
HostProjection::Squad(v) => (user_mass_info_squad(v, self.persona_id), "ok"),
|
||||
HostProjection::Squad { squad, actives } => {
|
||||
(user_mass_info_squad(squad, self.persona_id, actives), "ok")
|
||||
}
|
||||
HostProjection::Stale => (empty_squad_overlay(self.persona_id), "stale_integrity"),
|
||||
HostProjection::Missing => (empty_squad_overlay(self.persona_id), "missing_integrity"),
|
||||
HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"),
|
||||
@@ -4940,6 +5261,28 @@ impl Server {
|
||||
json_status(200, &non_economy::feature_off_body())
|
||||
}
|
||||
|
||||
/// The team whose kit this club currently wears, from its active kit items.
|
||||
///
|
||||
/// The pre-match kit clone resolves BOTH sides out of the client's own
|
||||
/// `teamkits` table keyed on `teamtechid`, with only `teamkittypetechid`
|
||||
/// distinguishing home from away. So if a season fixture names the club's own
|
||||
/// kit team, the opponent renders the club's kit and both sides appear in
|
||||
/// identical strips — which is also just wrong data, a club playing itself.
|
||||
/// [`season_wire::season_list_body`] excludes this team from the schedule.
|
||||
///
|
||||
/// Best-effort: any Core hiccup yields `None` and the full rotation, which is
|
||||
/// the pre-existing behaviour rather than a failed request.
|
||||
fn own_kit_team_id(&self) -> Option<i64> {
|
||||
let active = self.core.get_active_kits().ok()?;
|
||||
let designated = active.home_owned_card_id.or(active.away_owned_card_id)?;
|
||||
let page = self.core.query_owned(&[]).ok()?;
|
||||
let item = page
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.owned_card_id == designated)?;
|
||||
self.resolver.resolve_kit(item).map(|kit| kit.team_id)
|
||||
}
|
||||
|
||||
/// `…/season…` — FIFA 17 offline Seasons.
|
||||
///
|
||||
/// The client will not open the mode until it has a schedule: `season/list`
|
||||
@@ -4963,7 +5306,7 @@ impl Server {
|
||||
let (kind, body) = match sub {
|
||||
"list" => (
|
||||
"list",
|
||||
season_wire::season_list_body(SEASON_ID, DIVISION_ID),
|
||||
season_wire::season_list_body(SEASON_ID, DIVISION_ID, self.own_kit_team_id()),
|
||||
),
|
||||
"user" => (
|
||||
"user",
|
||||
|
||||
@@ -902,6 +902,7 @@ fn from_config(base: &str, dir: &std::path::Path) -> openfut_utas_host::config::
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
sbc_post_commit_fault: openfut_utas_host::config::SbcPostCommitFault::Off,
|
||||
squad_actives: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@ use openfut_utas_host::account_store::AccountStore;
|
||||
use openfut_utas_host::{
|
||||
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
|
||||
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState,
|
||||
CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreSquadRead,
|
||||
CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route, Server, SquadDeps,
|
||||
CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreRolePatchRequest,
|
||||
CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route,
|
||||
Server, SquadDeps,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
@@ -51,6 +52,10 @@ struct FakeCore {
|
||||
/// full squad replacement writes it (or clears it with `None`).
|
||||
manager: Mutex<Option<String>>,
|
||||
replaced: Mutex<Vec<StoredReplace>>,
|
||||
/// Recorded role-only patches: (captain_owned_card_id, ext_payload). Kept
|
||||
/// separate from `replaced` so a test can assert a patch NEVER went through
|
||||
/// the replacement path.
|
||||
role_patches: Mutex<Vec<(Option<String>, String)>>,
|
||||
panic_if_called: bool,
|
||||
return_err: bool,
|
||||
}
|
||||
@@ -95,6 +100,9 @@ impl FakeCore {
|
||||
fn last(&self) -> Vec<(String, String)> {
|
||||
self.last_params.lock().clone()
|
||||
}
|
||||
fn role_patches(&self) -> Vec<(Option<String>, String)> {
|
||||
self.role_patches.lock().clone()
|
||||
}
|
||||
fn manager(&self) -> Option<String> {
|
||||
self.manager.lock().clone()
|
||||
}
|
||||
@@ -132,6 +140,45 @@ impl CoreAccess for FakeCore {
|
||||
self.squad.lock().clone().ok_or(CoreError::Status(404))
|
||||
}
|
||||
|
||||
fn patch_squad_roles(&self, req: &CoreRolePatchRequest) -> Result<(), CoreError> {
|
||||
assert!(
|
||||
!self.panic_if_called,
|
||||
"Core must NOT be called on this path"
|
||||
);
|
||||
if self.return_err {
|
||||
return Err(CoreError::Status(500));
|
||||
}
|
||||
// Mirror Core: the captain must already be fielded, otherwise 400.
|
||||
if let Some(captain) = &req.captain_owned_card_id {
|
||||
let fielded = self
|
||||
.squad
|
||||
.lock()
|
||||
.as_ref()
|
||||
.map(|s| s.slots.iter().any(|sl| &sl.owned_card_id == captain))
|
||||
.unwrap_or(false);
|
||||
if !fielded {
|
||||
return Err(CoreError::Status(400));
|
||||
}
|
||||
}
|
||||
self.role_patches
|
||||
.lock()
|
||||
.push((req.captain_owned_card_id.clone(), req.ext_payload.clone()));
|
||||
// Apply to the stored squad WITHOUT touching slots or the manager, so a
|
||||
// read-after-patch shows exactly what Core would show.
|
||||
if let Some(sq) = self.squad.lock().as_mut() {
|
||||
if let Some(captain) = &req.captain_owned_card_id {
|
||||
for slot in sq.slots.iter_mut() {
|
||||
slot.is_captain = &slot.owned_card_id == captain;
|
||||
}
|
||||
}
|
||||
sq.ext = CoreExtState::Fresh {
|
||||
schema_version: req.ext_schema_version,
|
||||
payload: req.ext_payload.clone(),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError> {
|
||||
assert!(
|
||||
!self.panic_if_called,
|
||||
@@ -198,11 +245,11 @@ impl CoreAccess for FakeCore {
|
||||
Ok(self.manager.lock().clone())
|
||||
}
|
||||
|
||||
fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> {
|
||||
fn set_squad_manager(&self, owned_card_id: &str) -> Result<(), CoreError> {
|
||||
if self.return_err {
|
||||
return Err(CoreError::Status(500));
|
||||
}
|
||||
*self.manager.lock() = owned_card_id.map(str::to_string);
|
||||
*self.manager.lock() = Some(owned_card_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1079,8 +1126,41 @@ fn classify_squad_and_usermassinfo_routes() {
|
||||
classify("GET", "/ut/game/fifa17/userMassInfo"),
|
||||
Route::UserMassInfo
|
||||
);
|
||||
// GET /squad/active and every numeric GET are the one Core-backed current
|
||||
// squad, matching the oracle's single-squad response regardless of URL id.
|
||||
// The retail client sends the lowercase tail too, immediately before the
|
||||
// canonical one. It must reach the SAME Rust-owned handler: falling through
|
||||
// to Python is a 502 here and, with Python alive, an authority split.
|
||||
assert_eq!(
|
||||
classify("GET", "/ut/game/fifa17/usermassinfo"),
|
||||
Route::UserMassInfo,
|
||||
"lowercase usermassinfo is a real retail request, observed twice"
|
||||
);
|
||||
assert_eq!(
|
||||
classify("GET", "/ut/game/fifa17/USERMASSINFO"),
|
||||
Route::UserMassInfo
|
||||
);
|
||||
// Adjacent tails must NOT be swept up by the case-insensitive arm.
|
||||
assert_eq!(
|
||||
classify("GET", "/ut/game/fifa17/usermassinfox"),
|
||||
Route::Passthrough,
|
||||
"the alias must match the whole tail, not a prefix"
|
||||
);
|
||||
assert_eq!(
|
||||
classify("GET", "/ut/game/fifa17/usermass"),
|
||||
Route::Passthrough
|
||||
);
|
||||
// Method semantics are unchanged: only GET is this route.
|
||||
assert_eq!(
|
||||
classify("PUT", "/ut/game/fifa17/usermassinfo"),
|
||||
Route::Passthrough
|
||||
);
|
||||
assert_eq!(
|
||||
classify("POST", "/ut/game/fifa17/userMassInfo"),
|
||||
Route::Passthrough
|
||||
);
|
||||
|
||||
// GET /squad/active and every numeric GET resolve to the one Core-backed
|
||||
// squad. SAFE, not authentic — see is_numeric_squad_tail and the invariant
|
||||
// test below.
|
||||
assert_eq!(
|
||||
classify("GET", "/ut/game/fifa17/squad/active"),
|
||||
Route::SquadActive
|
||||
@@ -1150,6 +1230,7 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = put_body(
|
||||
"f442",
|
||||
@@ -1179,11 +1260,16 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
|
||||
assert_eq!(r.chemistry, Some(52), "client-reported shadow carried");
|
||||
}
|
||||
|
||||
/// The squad's manager is an ownership-backed assignment, not an opaque wire
|
||||
/// echo: a save assigns the owned instance behind the ref, and a later save
|
||||
/// without a manager CLEARS it (a full replacement replaces the manager too).
|
||||
/// The REAL captured partial body must succeed and take the PATCH path, not the
|
||||
/// replacement path.
|
||||
///
|
||||
/// Captured 2026-08-25 00:42:42 from the retail client's captain/kick-taker
|
||||
/// screen (`offline-seasons-squadexp-20260825T0018Z.pcap`). It carries no
|
||||
/// `players`, so the old code handed Core a replacement with zero slots; the
|
||||
/// empty-replacement guard refused it (400) and the host reported 502, losing
|
||||
/// the user's change. The body shape is the operation discriminator.
|
||||
#[test]
|
||||
fn put_assigns_the_owned_manager_and_a_later_save_clears_it() {
|
||||
fn put_partial_captain_and_kicktakers_patches_roles_without_replacing() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
@@ -1192,6 +1278,144 @@ fn put_assigns_the_owned_manager_and_a_later_save_clears_it() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
// Establish a squad first, so there is something to patch.
|
||||
let full = put_body(
|
||||
"f442",
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
|
||||
"[1,2,3]",
|
||||
Some(w["oc-b"]),
|
||||
);
|
||||
let (resp, log) = handle_put_squad(&full, &deps);
|
||||
assert_eq!(resp.status, 200, "{log:?}");
|
||||
assert_eq!(core.replaced().len(), 1);
|
||||
|
||||
// The captured partial shape: no players, no manager, no formation.
|
||||
let partial = format!(
|
||||
r#"{{"id":0,"custom":"[0,8,16,16,8,134220032]","captain":{},"kicktakers":[
|
||||
{{"index":0,"id":{},"dream":false}},{{"index":1,"id":{},"dream":false}},
|
||||
{{"index":2,"id":{},"dream":false}},{{"index":3,"id":{},"dream":false}},
|
||||
{{"index":4,"id":{},"dream":false}}]}}"#,
|
||||
w["oc-b"], w["oc-a"], w["oc-a"], w["oc-b"], w["oc-b"], w["oc-a"]
|
||||
);
|
||||
let (resp, log) = handle_put_squad(partial.as_bytes(), &deps);
|
||||
assert_eq!(resp.status, 200, "the partial shape must succeed: {log:?}");
|
||||
assert_eq!(resp.body, br#"{"id":0}"#, "same ack as a full save");
|
||||
|
||||
// It went through the PATCH path, never the replacement path.
|
||||
assert_eq!(
|
||||
core.replaced().len(),
|
||||
1,
|
||||
"a role patch must NOT reach replace_squad — that is what tripped the guard"
|
||||
);
|
||||
let patches = core.role_patches();
|
||||
assert_eq!(patches.len(), 1);
|
||||
assert_eq!(
|
||||
patches[0].0.as_deref(),
|
||||
Some("oc-b"),
|
||||
"captain resolved to the Core owned id, never the wire id"
|
||||
);
|
||||
// Omitted state is UNCHANGED, not cleared.
|
||||
assert_eq!(
|
||||
core.manager().as_deref(),
|
||||
Some("oc-b"),
|
||||
"a role patch must not touch the manager"
|
||||
);
|
||||
// The patch's opaque custom is carried, and kit numbers from the earlier
|
||||
// full save survive the merge rather than being overwritten away.
|
||||
assert!(patches[0].1.contains("134220032"), "patch custom carried");
|
||||
assert!(
|
||||
patches[0].1.contains("kit_numbers"),
|
||||
"kit numbers preserved from the stored extension: {}",
|
||||
patches[0].1
|
||||
);
|
||||
}
|
||||
|
||||
/// An explicit `"players": []` is still a REPLACEMENT and must still be refused
|
||||
/// by Core's guard — the patch path must not become a way to smuggle a
|
||||
/// destructive write past it.
|
||||
#[test]
|
||||
fn put_explicit_empty_players_is_still_a_replacement_not_a_patch() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, _w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = br#"{"id":0,"formation":"f442","custom":"[]","players":[],"manager":[]}"#;
|
||||
let (resp, log) = handle_put_squad(body, &deps);
|
||||
// Reaches the replacement path (Core decides), and NEVER the patch path.
|
||||
assert_eq!(
|
||||
core.role_patches().len(),
|
||||
0,
|
||||
"an explicit empty players array must not be treated as a role patch: {log:?}"
|
||||
);
|
||||
assert!(
|
||||
resp.status == 200 || resp.status >= 400,
|
||||
"handled by the replacement path"
|
||||
);
|
||||
assert_eq!(
|
||||
core.replaced().len(),
|
||||
1,
|
||||
"it went to replace_squad, where the empty-replacement guard lives"
|
||||
);
|
||||
}
|
||||
|
||||
/// A captain the resolver cannot map must refuse the whole patch, not silently
|
||||
/// apply the kick-takers and report success.
|
||||
#[test]
|
||||
fn put_partial_with_unresolvable_captain_refuses_the_whole_patch() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let full = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[1]", None);
|
||||
assert_eq!(handle_put_squad(&full, &deps).0.status, 200);
|
||||
|
||||
let partial = br#"{"id":0,"custom":"[9]","captain":999999999,"kicktakers":[]}"#;
|
||||
let (resp, log) = handle_put_squad(partial, &deps);
|
||||
assert_eq!(resp.status, 400, "unresolvable captain is a client error");
|
||||
assert_eq!(log.outcome, "unresolved_captain");
|
||||
assert_eq!(
|
||||
core.role_patches().len(),
|
||||
0,
|
||||
"nothing may be written when the captain cannot be resolved"
|
||||
);
|
||||
}
|
||||
|
||||
/// The squad's manager is an ownership-backed assignment, not an opaque wire
|
||||
/// echo: a save assigns the owned instance behind the ref. A later save that
|
||||
/// carries NO manager ref does NOT clear it.
|
||||
///
|
||||
/// This test previously asserted the opposite ("a full replacement replaces the
|
||||
/// manager too"). That was the bug: FIFA 17 has no wire shape that removes a
|
||||
/// manager — the client always sends a ref — so an absent ref means the save
|
||||
/// says nothing about the manager, not that the user cleared the slot. A client
|
||||
/// whose squad model had been destroyed sent exactly that shape and deleted a
|
||||
/// real manager row (WAL commit 468, squad_managers 1 -> 0).
|
||||
#[test]
|
||||
fn put_assigns_the_owned_manager_and_a_later_save_without_one_leaves_it() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
|
||||
let with_manager = put_body(
|
||||
@@ -1209,14 +1433,18 @@ fn put_assigns_the_owned_manager_and_a_later_save_clears_it() {
|
||||
"manager persisted as the Core owned id, never the wire id"
|
||||
);
|
||||
|
||||
// The exact destructive shape: `"manager": []`.
|
||||
let without_manager = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None);
|
||||
let (resp, log) = handle_put_squad(&without_manager, &deps);
|
||||
assert_eq!(resp.status, 200, "{log:?}");
|
||||
assert_eq!(
|
||||
core.manager(),
|
||||
None,
|
||||
"a full replacement without a manager clears the assignment"
|
||||
core.manager().as_deref(),
|
||||
Some("oc-b"),
|
||||
"a save carrying no manager ref must LEAVE the existing assignment alone"
|
||||
);
|
||||
|
||||
// And the squad itself still committed — the manager decision is separate.
|
||||
assert_eq!(core.replace_calls(), 2, "both saves committed their slots");
|
||||
}
|
||||
|
||||
/// The REAL client always sends a manager ref, and on a real profile it does not
|
||||
@@ -1234,6 +1462,7 @@ fn put_saves_the_squad_when_the_manager_ref_does_not_resolve() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
|
||||
let body = put_body(
|
||||
@@ -1279,6 +1508,7 @@ fn put_rejects_wire_id_owned_by_another_profile_core_unchanged() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = put_body(
|
||||
"f442",
|
||||
@@ -1305,6 +1535,7 @@ fn put_rejects_unknown_wire_id() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
// 999_999_999 was never allocated → unresolvable.
|
||||
let body = put_body(
|
||||
@@ -1330,6 +1561,7 @@ fn put_rejects_duplicate_owned_item() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = put_body(
|
||||
"f442",
|
||||
@@ -1355,6 +1587,7 @@ fn put_core_failure_returns_error_never_python() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None);
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
@@ -1372,6 +1605,7 @@ fn repeated_identical_put_is_idempotent() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = put_body(
|
||||
"f442",
|
||||
@@ -1408,6 +1642,7 @@ fn coupled_read_after_write_list_and_usermassinfo_agree() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = put_body(
|
||||
"f442",
|
||||
@@ -1513,6 +1748,7 @@ fn squad_active_serves_core_backed_object_with_configured_persona() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
// Commit a squad so Core has a fresh canonical squad + extension.
|
||||
let body = put_body(
|
||||
@@ -1560,6 +1796,7 @@ fn read_path_is_bounded_no_per_slot_lookup() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = put_body(
|
||||
"f442",
|
||||
@@ -1614,6 +1851,7 @@ fn stale_extension_is_not_applied_on_reads() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let (resp, log) = handle_squad_list(&deps);
|
||||
assert_eq!(log.outcome, "stale_integrity");
|
||||
@@ -1635,6 +1873,7 @@ fn missing_extension_is_explicit_on_reads() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let (resp, log) = handle_squad_list(&deps);
|
||||
assert_eq!(log.outcome, "missing_integrity");
|
||||
@@ -1656,6 +1895,7 @@ fn usermassinfo_never_serves_python_squad_on_integrity_failure() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let py_body = json!({
|
||||
"userInfo": {"personaId": 7},
|
||||
@@ -1730,6 +1970,7 @@ fn duplicate_definition_instances_stay_distinct_through_host() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = put_body(
|
||||
"f442",
|
||||
@@ -2767,3 +3008,45 @@ fn no_shipped_training_card_exceeds_the_declared_ceiling() {
|
||||
}
|
||||
assert_eq!(rare, 6, "all 6 rare all-six cards must resolve");
|
||||
}
|
||||
|
||||
/// Collapsing every numeric `squad/<id>` onto one squad is safe ONLY while
|
||||
/// exactly one squad is advertised. This is the tripwire for that assumption.
|
||||
///
|
||||
/// FIFA 17 has real multi-squad semantics — the client ships `SelectSquadById`,
|
||||
/// `RetrieveSquad` (distinct from `LoadActiveSquad`), `CreateSquadWithName`,
|
||||
/// `RenameSquad`, `DeleteSquad` and indexed `SQUAD_ID-%d` entries. We get away
|
||||
/// with ignoring the id purely because the client can never learn another one:
|
||||
/// the wire id is a constant 0 and `/squad/list` advertises a single squad.
|
||||
///
|
||||
/// If either fact stops holding, the numeric route needs a real lookup and this
|
||||
/// test must be the thing that says so.
|
||||
#[test]
|
||||
fn numeric_squad_routing_is_safe_only_while_one_squad_is_advertised() {
|
||||
assert_eq!(
|
||||
openfut_utas_host::ACTIVE_SQUAD_WIRE_ID,
|
||||
0,
|
||||
"the single advertised squad id is what makes id-collapsing safe"
|
||||
);
|
||||
|
||||
let projected = serde_json::json!({
|
||||
"id": openfut_utas_host::ACTIVE_SQUAD_WIRE_ID,
|
||||
"squadName": "OpenFUT",
|
||||
"formation": "f442",
|
||||
"squadType": "REGULAR_SQUAD",
|
||||
"rating": 90,
|
||||
"chemistry": 52,
|
||||
});
|
||||
let list = openfut_adapter_fifa17::fut::squad_projection::squad_list(&projected);
|
||||
let squads = list["squad"].as_array().expect("squad list is an array");
|
||||
assert_eq!(
|
||||
squads.len(),
|
||||
1,
|
||||
"more than one advertised squad means the client can address a second \
|
||||
id, and every numeric GET/PUT collapsing onto one squad becomes wrong"
|
||||
);
|
||||
assert_eq!(
|
||||
squads[0]["id"],
|
||||
openfut_utas_host::ACTIVE_SQUAD_WIRE_ID,
|
||||
"the advertised id must be the one the client echoes back"
|
||||
);
|
||||
}
|
||||
|
||||
+28
-16
@@ -182,9 +182,15 @@ DISPOSABLE_CARD = "fifa17_232273" # Nelson Atiagli LB 51, rareflag 1
|
||||
# Two authoritative modern kit-card definitions for one real source team. These
|
||||
# are staging fixtures derived from fcc_kitcards, not synthetic FIFA identities.
|
||||
KIT_TEAM_ID = 21
|
||||
# The trailing value is the client's own `fcc_kitcards.assetid`, the wire
|
||||
# `assetId` (record +0x20). It is the ART CLASS, not the carddbid: every
|
||||
# 63xxxxx (home/third) kit carries 14 and every 64xxxxx (away) kit carries 15,
|
||||
# per club_items.json and fifa17-kit-map.json's band_x_assetid evidence
|
||||
# (6300000/14 x828, 6400000/15 x654). Shipping the carddbid here left both
|
||||
# pre-match kit tiles rendering identically.
|
||||
STAGING_KITS = [
|
||||
("home", "owned-a-kit-home", "fifa17_6300006", 6_300_006),
|
||||
("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003),
|
||||
("home", "owned-a-kit-home", "fifa17_6300006", 6_300_006, 14),
|
||||
("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003, 15),
|
||||
]
|
||||
|
||||
# The remaining club-item families, so the rig exercises EVERY ownable class
|
||||
@@ -198,11 +204,15 @@ STAGING_KITS = [
|
||||
# badge 39, logo 40), which is what the importer gates on. A league logo has no
|
||||
# equipped slot, so it is owned as generic `misc` content.
|
||||
STAGING_CLUB_ITEMS = [
|
||||
# (slot, owned_id, card_id, resource_id, kind, subtype, card_asset_id, team_id)
|
||||
("badge", "owned-a-badge", "fifa17_6000005", 6_000_005, "badge", 11, 39, 21),
|
||||
("ball", "owned-a-ball", "fifa17_8120194", 8_120_194, "ball", 30, 37, None),
|
||||
("stadium", "owned-a-stadium", "fifa17_6200000", 6_200_000, "stadium", 10, 36, None),
|
||||
(None, "owned-a-leaguelogo", "fifa17_8010015", 8_010_015, "misc", 31, 40, None),
|
||||
# (slot, owned_id, card_id, resource_id, kind, subtype, card_asset_id, team_id,
|
||||
# club_asset_id)
|
||||
# club_asset_id is the wire `assetId` from club_items.json, family specific
|
||||
# and never the carddbid: badge 6000005 -> its teamid 21, ball 8120194 -> 100,
|
||||
# stadium 6200000 -> 1. A league logo has no equipped slot and keeps its own.
|
||||
("badge", "owned-a-badge", "fifa17_6000005", 6_000_005, "badge", 11, 39, 21, 21),
|
||||
("ball", "owned-a-ball", "fifa17_8120194", 8_120_194, "ball", 30, 37, None, 100),
|
||||
("stadium", "owned-a-stadium", "fifa17_6200000", 6_200_000, "stadium", 10, 36, None, 1),
|
||||
(None, "owned-a-leaguelogo", "fifa17_8010015", 8_010_015, "misc", 31, 40, None, None),
|
||||
]
|
||||
|
||||
# The club manager. FIFA refuses to start a match without one ("your player or
|
||||
@@ -524,7 +534,7 @@ def materialise(lay: Layout) -> None:
|
||||
with open(safe_path(lay.catalog)) as fh:
|
||||
catalog = json.load(fh)
|
||||
existing = {definition["id"] for definition in definitions}
|
||||
for _slot, _owned_id, card_id, resource_id in STAGING_KITS:
|
||||
for _slot, _owned_id, card_id, resource_id, club_asset_id in STAGING_KITS:
|
||||
if card_id not in existing:
|
||||
definitions.append({
|
||||
"id": card_id,
|
||||
@@ -550,10 +560,11 @@ def materialise(lay: Layout) -> None:
|
||||
"kind": "kit",
|
||||
"subtype": 9,
|
||||
"card_asset_id": 35,
|
||||
"club_asset_id": club_asset_id,
|
||||
"team_id": KIT_TEAM_ID,
|
||||
}
|
||||
|
||||
for _slot, _owned, card_id, resource_id, kind, subtype, art, team in STAGING_CLUB_ITEMS:
|
||||
for _slot, _owned, card_id, resource_id, kind, subtype, art, team, club_asset in STAGING_CLUB_ITEMS:
|
||||
if card_id not in existing:
|
||||
definitions.append({
|
||||
"id": card_id,
|
||||
@@ -580,6 +591,8 @@ def materialise(lay: Layout) -> None:
|
||||
"subtype": subtype,
|
||||
"card_asset_id": art,
|
||||
}
|
||||
if club_asset is not None:
|
||||
entry["club_asset_id"] = club_asset
|
||||
if team is not None:
|
||||
entry["team_id"] = team
|
||||
catalog["cards"][card_id] = entry
|
||||
@@ -701,7 +714,7 @@ def assert_seed_cards_resolvable(lay: Layout, real_club: dict | None) -> None:
|
||||
wanted = set(
|
||||
[card for _, card in SELLER_SQUAD_CARDS]
|
||||
+ [DISPOSABLE_CARD]
|
||||
+ [card for _, _, card, _ in STAGING_KITS]
|
||||
+ [card for _, _, card, _, _ in STAGING_KITS]
|
||||
+ [STAGING_MANAGER["card_id"]]
|
||||
)
|
||||
what = f"all {len(wanted)} fixture seed card ids"
|
||||
@@ -712,7 +725,7 @@ def assert_seed_cards_resolvable(lay: Layout, real_club: dict | None) -> None:
|
||||
conn.execute("SELECT DISTINCT card_id FROM owned_cards")}
|
||||
finally:
|
||||
conn.close()
|
||||
wanted |= {card for _, _, card, _ in STAGING_KITS}
|
||||
wanted |= {card for _, _, card, _, _ in STAGING_KITS}
|
||||
wanted.add(STAGING_MANAGER["card_id"])
|
||||
what = (f"all {len(wanted)} distinct card ids owned by the real club "
|
||||
"(plus the kit and manager fixtures)")
|
||||
@@ -983,7 +996,7 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
|
||||
# calling a kit a player, and Core is the ownership authority.
|
||||
owned = [
|
||||
(owned_id, seller_club, card_id, "kit", TS)
|
||||
for _slot, owned_id, card_id, _resource_id in STAGING_KITS
|
||||
for _slot, owned_id, card_id, _resource_id, _ca in STAGING_KITS
|
||||
]
|
||||
owned.append(
|
||||
(
|
||||
@@ -996,7 +1009,7 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
|
||||
)
|
||||
owned.extend(
|
||||
(owned_id, seller_club, card_id, kind, TS)
|
||||
for _slot, owned_id, card_id, _rid, kind, _st, _art, _team
|
||||
for _slot, owned_id, card_id, _rid, kind, _st, _art, _team, _ca
|
||||
in STAGING_CLUB_ITEMS
|
||||
)
|
||||
if real_club is None:
|
||||
@@ -1010,7 +1023,6 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
|
||||
"content_kind, acquired_at) VALUES (?, ?, ?, 0, ?, ?)",
|
||||
owned,
|
||||
)
|
||||
|
||||
if real_club is None:
|
||||
conn.execute(
|
||||
"INSERT INTO squads (id, club_id, name, formation, created_at, "
|
||||
@@ -1034,14 +1046,14 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
[
|
||||
(seller_club, f"{slot}_kit", owned_id, TS)
|
||||
for slot, owned_id, _card_id, _resource_id in STAGING_KITS
|
||||
for slot, owned_id, _card_id, _resource_id, _ca in STAGING_KITS
|
||||
]
|
||||
# badge / ball / stadium are slot-keyed exactly like the kits.
|
||||
# A league logo has no slot, so it stays owned-but-unequipped —
|
||||
# which is itself worth exercising.
|
||||
+ [
|
||||
(seller_club, slot, owned_id, TS)
|
||||
for slot, owned_id, _c, _r, _k, _s, _a, _t in STAGING_CLUB_ITEMS
|
||||
for slot, owned_id, _c, _r, _k, _s, _a, _t, _ca in STAGING_CLUB_ITEMS
|
||||
if slot is not None
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user