Compare commits
2 Commits
f40e8587ac
...
e48d659bce
| Author | SHA1 | Date | |
|---|---|---|---|
| e48d659bce | |||
| a86d21ec79 |
Executable
+697
@@ -0,0 +1,697 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Decoder for EA APT (compiled ActionScript) as shipped in FIFA 17.
|
||||||
|
|
||||||
|
Clean-room implementation. The byte-level format facts (opcode numbers, operand
|
||||||
|
widths, alignment rule, branch base, DefineFunction2 field order) were taken from
|
||||||
|
a written specification derived from OpenSAGE, which is GPL-3.0 with EA
|
||||||
|
additional terms. No OpenSAGE code was copied or transliterated; only the format
|
||||||
|
description -- an interface specification -- was used. Reference read at
|
||||||
|
OpenSAGE/OpenSAGE commit 588ac477367a0022adf29f20a084e8873014e6ce and
|
||||||
|
OpenSAGE/AptEditor commit 09f73c655c45a781f883b623a93d2e8f5b065a6c.
|
||||||
|
|
||||||
|
FIFA 17 ships a 64-BIT variant of the format. Differences from the 32-bit SAGE
|
||||||
|
layout described by the reference, all established by measurement against
|
||||||
|
futSelectTeam and asserted by --selftest:
|
||||||
|
|
||||||
|
* Container pointers and counts are u64, not u32.
|
||||||
|
* Parameterised instructions align their operand block to 8 bytes, not 4.
|
||||||
|
Proven by the ConstantPool at 0xd38: aligning to 4 yields garbage, aligning
|
||||||
|
to 8 yields count=401 with an index array that ends exactly on the
|
||||||
|
parameter-list region.
|
||||||
|
* The constant pool lives in a separate "Apt1" container member rather than a
|
||||||
|
".const" sibling file. Entries are 16 bytes: {u64 type, u64 value}; type 1
|
||||||
|
is a string whose value is an absolute offset inside that same member.
|
||||||
|
* DefineFunction2's operand block is 48 bytes rather than 28, and the
|
||||||
|
0x1234567898765432 trailer is stored as two u64 halves.
|
||||||
|
* Branch displacements remain i32 and remain relative to the end of the
|
||||||
|
branch record, exactly as in the 32-bit format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
APT1_MAGIC = b"Apt1"
|
||||||
|
APTDATA_MAGIC = b"Apt Data:1:7:8\x1a\x00"
|
||||||
|
|
||||||
|
# Trailer sentinel on DefineFunction/DefineFunction2, stored as two u64 halves.
|
||||||
|
FUNC_SENTINEL_LO = 0x98765432
|
||||||
|
FUNC_SENTINEL_HI = 0x12345678
|
||||||
|
|
||||||
|
ALIGN = 8
|
||||||
|
|
||||||
|
# Operand kinds.
|
||||||
|
NONE = "none" # no operand block
|
||||||
|
U8REG = "u8reg" # 1 raw byte, register index
|
||||||
|
U8CONST = "u8const" # 1 raw byte, constant-pool index
|
||||||
|
U16CONST = "u16const" # 2 raw bytes, constant-pool index
|
||||||
|
U8LIT = "u8lit" # 1 raw byte, literal integer
|
||||||
|
U16LIT = "u16lit" # 2 raw bytes, literal integer
|
||||||
|
BRANCH = "branch" # aligned i32, relative to end of record
|
||||||
|
U32 = "u32" # aligned u32
|
||||||
|
F32 = "f32" # aligned f32
|
||||||
|
STR64 = "str64" # aligned u64 absolute offset to NUL-terminated string
|
||||||
|
POOL = "pool" # aligned u64 count + u64 array offset (array of u64 ids)
|
||||||
|
FUNC2 = "func2" # aligned DefineFunction2 record
|
||||||
|
FUNC1 = "func1" # aligned DefineFunction record
|
||||||
|
|
||||||
|
# opcode -> (mnemonic, operand kind)
|
||||||
|
OPCODES: dict[int, tuple[str, str]] = {
|
||||||
|
0x00: ("End", NONE),
|
||||||
|
0x04: ("NextFrame", NONE),
|
||||||
|
0x06: ("Play", NONE),
|
||||||
|
0x07: ("Stop", NONE),
|
||||||
|
0x0A: ("Add", NONE),
|
||||||
|
0x0B: ("Subtract", NONE),
|
||||||
|
0x0C: ("Multiply", NONE),
|
||||||
|
0x0D: ("Divide", NONE),
|
||||||
|
0x12: ("Not", NONE),
|
||||||
|
0x13: ("StringEquals", NONE),
|
||||||
|
0x17: ("Pop", NONE),
|
||||||
|
0x18: ("ToInteger", NONE),
|
||||||
|
0x1C: ("GetVariable", NONE),
|
||||||
|
0x1D: ("SetVariable", NONE),
|
||||||
|
0x21: ("StringConcat", NONE),
|
||||||
|
0x22: ("GetProperty", NONE),
|
||||||
|
0x23: ("SetProperty", NONE),
|
||||||
|
0x26: ("Trace", NONE),
|
||||||
|
0x30: ("Random", NONE),
|
||||||
|
0x3A: ("Delete", NONE),
|
||||||
|
0x3B: ("Delete2", NONE),
|
||||||
|
0x3C: ("DefineLocal", NONE),
|
||||||
|
0x3D: ("CallFunction", NONE),
|
||||||
|
0x3E: ("Return", NONE),
|
||||||
|
0x3F: ("Modulo", NONE),
|
||||||
|
0x40: ("NewObject", NONE),
|
||||||
|
0x41: ("Var", NONE),
|
||||||
|
0x42: ("InitArray", NONE),
|
||||||
|
0x43: ("InitObject", NONE),
|
||||||
|
0x44: ("TypeOf", NONE),
|
||||||
|
0x47: ("Add2", NONE),
|
||||||
|
0x48: ("LessThan2", NONE),
|
||||||
|
0x49: ("Equals2", NONE),
|
||||||
|
0x4A: ("ToNumber", NONE),
|
||||||
|
0x4B: ("ToString", NONE),
|
||||||
|
0x4C: ("PushDuplicate", NONE),
|
||||||
|
0x4E: ("GetMember", NONE),
|
||||||
|
0x4F: ("SetMember", NONE),
|
||||||
|
0x50: ("Increment", NONE),
|
||||||
|
0x51: ("Decrement", NONE),
|
||||||
|
0x52: ("CallMethod", NONE),
|
||||||
|
# 0x53 appears in the reference enum as NewMethod but the reference never
|
||||||
|
# parses it. Standard AVM1 ActionNewMethod carries no operand block;
|
||||||
|
# decoding it as zero-length keeps this artifact synchronised with every
|
||||||
|
# branch still landing on an instruction boundary, which is the check that
|
||||||
|
# would break first if the width were wrong.
|
||||||
|
0x53: ("NewMethod", NONE),
|
||||||
|
0x54: ("InstanceOf", NONE),
|
||||||
|
0x55: ("Enumerate2", NONE),
|
||||||
|
0x56: ("PushThis", NONE),
|
||||||
|
0x59: ("PushZero", NONE),
|
||||||
|
0x5A: ("PushOne", NONE),
|
||||||
|
0x5B: ("CallFuncPop", NONE),
|
||||||
|
0x5C: ("CallFunc", NONE),
|
||||||
|
0x5D: ("CallMethodPop", NONE),
|
||||||
|
0x62: ("BitwiseXOr", NONE),
|
||||||
|
0x66: ("StrictEqual", NONE),
|
||||||
|
0x67: ("Greater", NONE),
|
||||||
|
0x69: ("Extends", NONE),
|
||||||
|
0x70: ("PushThisVar", NONE),
|
||||||
|
0x71: ("PushGlobalVar", NONE),
|
||||||
|
0x72: ("ZeroVar", NONE),
|
||||||
|
0x73: ("PushTrue", NONE),
|
||||||
|
0x74: ("PushFalse", NONE),
|
||||||
|
0x75: ("PushNull", NONE),
|
||||||
|
0x76: ("PushUndefined", NONE),
|
||||||
|
0x87: ("SetRegister", U32),
|
||||||
|
0x88: ("ConstantPool", POOL),
|
||||||
|
0x8C: ("GotoLabel", STR64),
|
||||||
|
0x8E: ("DefineFunction2", FUNC2),
|
||||||
|
0x96: ("PushData", POOL),
|
||||||
|
0x99: ("BranchAlways", BRANCH),
|
||||||
|
0x9B: ("DefineFunction", FUNC1),
|
||||||
|
0x9D: ("BranchIfTrue", BRANCH),
|
||||||
|
0x9F: ("GotoFrame2", U32),
|
||||||
|
0xA1: ("PushString", STR64),
|
||||||
|
0xA2: ("PushConstantByte", U8CONST),
|
||||||
|
0xA3: ("PushConstantWord", U16CONST),
|
||||||
|
0xA4: ("GetStringVar", STR64),
|
||||||
|
0xA5: ("GetStringMember", STR64),
|
||||||
|
0xA6: ("SetStringVar", STR64),
|
||||||
|
0xA7: ("SetStringMember", STR64),
|
||||||
|
0xAE: ("PushValueOfVar", U8CONST),
|
||||||
|
0xAF: ("GetNamedMember", U8CONST),
|
||||||
|
0xB0: ("CallNamedFuncPop", U8CONST),
|
||||||
|
0xB1: ("CallNamedFunc", U8CONST),
|
||||||
|
0xB2: ("CallNamedMethodPop", U8CONST),
|
||||||
|
0xB3: ("CallNamedMethod", U8CONST),
|
||||||
|
0xB4: ("PushFloat", F32),
|
||||||
|
0xB5: ("PushByte", U8LIT),
|
||||||
|
0xB6: ("PushShort", U16LIT),
|
||||||
|
0xB8: ("BranchIfFalse", BRANCH),
|
||||||
|
0xB9: ("PushRegister", U8REG),
|
||||||
|
}
|
||||||
|
|
||||||
|
ALIGNED_KINDS = {BRANCH, U32, F32, STR64, POOL, FUNC2, FUNC1}
|
||||||
|
|
||||||
|
|
||||||
|
class DecodeError(Exception):
|
||||||
|
"""Raised when the stream cannot be decoded without guessing."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Instr:
|
||||||
|
offset: int
|
||||||
|
opcode: int
|
||||||
|
mnemonic: str
|
||||||
|
length: int # opcode byte through end of operand block, incl. padding
|
||||||
|
operands: dict
|
||||||
|
raw: bytes
|
||||||
|
target: int | None = None # resolved branch destination
|
||||||
|
comment: str = ""
|
||||||
|
|
||||||
|
def render(self, width: int = 22) -> str:
|
||||||
|
ops = self.comment or ""
|
||||||
|
return f" {self.offset:#07x} {self.mnemonic:<{width}} {ops}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Function:
|
||||||
|
name: str
|
||||||
|
record_offset: int # offset of the DefineFunction* opcode byte
|
||||||
|
body_start: int
|
||||||
|
body_end: int
|
||||||
|
n_params: int
|
||||||
|
n_registers: int
|
||||||
|
flags: int
|
||||||
|
params: list = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def anonymous(self) -> bool:
|
||||||
|
return not self.name
|
||||||
|
|
||||||
|
|
||||||
|
PRELOAD_FLAGS = [
|
||||||
|
(0x010000, "PreloadExtern"),
|
||||||
|
(0x008000, "PreloadParent"),
|
||||||
|
(0x004000, "PreloadRoot"),
|
||||||
|
(0x002000, "SupressSuper"),
|
||||||
|
(0x001000, "PreloadSuper"),
|
||||||
|
(0x000800, "SupressArguments"),
|
||||||
|
(0x000400, "PreloadArguments"),
|
||||||
|
(0x000200, "SupressThis"),
|
||||||
|
(0x000100, "PreloadThis"),
|
||||||
|
(0x000001, "PreloadGlobal"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Registers preloaded by the VM, in flag order, starting at index 1.
|
||||||
|
PRELOAD_ORDER = [
|
||||||
|
(0x000100, "this"),
|
||||||
|
(0x000400, "arguments"),
|
||||||
|
(0x001000, "super"),
|
||||||
|
(0x004000, "_root"),
|
||||||
|
(0x008000, "_parent"),
|
||||||
|
(0x000001, "_global"),
|
||||||
|
(0x010000, "extern"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def flag_names(flags: int) -> str:
|
||||||
|
got = [n for bit, n in PRELOAD_FLAGS if flags & bit]
|
||||||
|
return "|".join(got) if got else "0"
|
||||||
|
|
||||||
|
|
||||||
|
def register_map(fn: Function) -> dict[int, str]:
|
||||||
|
"""Reproduce the VM's register preload order, then bound parameters."""
|
||||||
|
regs: dict[int, str] = {}
|
||||||
|
idx = 1
|
||||||
|
for bit, name in PRELOAD_ORDER:
|
||||||
|
if fn.flags & bit:
|
||||||
|
regs[idx] = name
|
||||||
|
idx += 1
|
||||||
|
for reg, pname in fn.params:
|
||||||
|
if reg:
|
||||||
|
regs[reg] = pname
|
||||||
|
return regs
|
||||||
|
|
||||||
|
|
||||||
|
class ConstPool:
|
||||||
|
"""The 'Apt1' container member: header, 16-byte entries, string table."""
|
||||||
|
|
||||||
|
def __init__(self, data: bytes):
|
||||||
|
if data[:4] != APT1_MAGIC:
|
||||||
|
raise DecodeError(f"not an Apt1 member: {data[:4]!r}")
|
||||||
|
self.data = data
|
||||||
|
self.count = struct.unpack_from("<Q", data, 0x20)[0]
|
||||||
|
self.first = struct.unpack_from("<Q", data, 0x28)[0]
|
||||||
|
self.entries: list[tuple[int, int, str | None]] = []
|
||||||
|
for i in range(self.count):
|
||||||
|
off = self.first + i * 16
|
||||||
|
if off + 16 > len(data):
|
||||||
|
raise DecodeError(f"const entry {i} at {off:#x} runs past end")
|
||||||
|
etype, value = struct.unpack_from("<QQ", data, off)
|
||||||
|
text = None
|
||||||
|
if etype == 1:
|
||||||
|
if not (0 < value < len(data)):
|
||||||
|
raise DecodeError(
|
||||||
|
f"const entry {i}: string offset {value:#x} outside member"
|
||||||
|
)
|
||||||
|
end = data.find(b"\0", value)
|
||||||
|
if end < 0:
|
||||||
|
raise DecodeError(f"const entry {i}: unterminated string")
|
||||||
|
text = data[value:end].decode("latin1")
|
||||||
|
self.entries.append((etype, value, text))
|
||||||
|
|
||||||
|
def string(self, index: int) -> str:
|
||||||
|
if not (0 <= index < len(self.entries)):
|
||||||
|
raise DecodeError(f"const index {index} out of range (0..{len(self.entries)-1})")
|
||||||
|
etype, _, text = self.entries[index]
|
||||||
|
if etype != 1 or text is None:
|
||||||
|
raise DecodeError(f"const index {index} is type {etype}, not a string")
|
||||||
|
return text
|
||||||
|
|
||||||
|
def find(self, needle: str) -> list[int]:
|
||||||
|
return [i for i, (_, _, t) in enumerate(self.entries) if t == needle]
|
||||||
|
|
||||||
|
|
||||||
|
class AptData:
|
||||||
|
"""The 'Apt Data' container member: movie structures plus action streams."""
|
||||||
|
|
||||||
|
def __init__(self, data: bytes, pool: ConstPool):
|
||||||
|
if not data.startswith(APTDATA_MAGIC[:8]):
|
||||||
|
raise DecodeError(f"not an Apt Data member: {data[:16]!r}")
|
||||||
|
self.data = data
|
||||||
|
self.pool = pool
|
||||||
|
self.scope: list[str] = [] # installed by ConstantPool
|
||||||
|
self.functions: list[Function] = []
|
||||||
|
|
||||||
|
# -- helpers ---------------------------------------------------------
|
||||||
|
def cstr(self, off: int) -> str:
|
||||||
|
if not (0 <= off < len(self.data)):
|
||||||
|
raise DecodeError(f"string offset {off:#x} outside Apt Data")
|
||||||
|
end = self.data.find(b"\0", off)
|
||||||
|
if end < 0:
|
||||||
|
raise DecodeError(f"unterminated string at {off:#x}")
|
||||||
|
return self.data[off:end].decode("latin1")
|
||||||
|
|
||||||
|
def const(self, index: int) -> str:
|
||||||
|
"""Resolve through the scope pool installed by the most recent 0x88."""
|
||||||
|
if self.scope:
|
||||||
|
if not (0 <= index < len(self.scope)):
|
||||||
|
raise DecodeError(
|
||||||
|
f"scope-pool index {index} out of range (0..{len(self.scope)-1})"
|
||||||
|
)
|
||||||
|
return self.scope[index]
|
||||||
|
return self.pool.string(index)
|
||||||
|
|
||||||
|
def install_pool(self, ids: list[int]) -> None:
|
||||||
|
self.scope = [self.pool.string(i) for i in ids]
|
||||||
|
|
||||||
|
# -- instruction decoding --------------------------------------------
|
||||||
|
def decode_one(self, pos: int) -> Instr:
|
||||||
|
d = self.data
|
||||||
|
if pos >= len(d):
|
||||||
|
raise DecodeError(f"position {pos:#x} past end of stream")
|
||||||
|
op = d[pos]
|
||||||
|
entry = OPCODES.get(op)
|
||||||
|
if entry is None:
|
||||||
|
raise DecodeError(
|
||||||
|
f"unknown opcode {op:#04x} at {pos:#07x} "
|
||||||
|
f"(raw {d[pos:pos+8].hex(' ')}) - refusing to guess its length"
|
||||||
|
)
|
||||||
|
mnem, kind = entry
|
||||||
|
p = pos + 1
|
||||||
|
if kind in ALIGNED_KINDS:
|
||||||
|
p = (p + ALIGN - 1) & ~(ALIGN - 1)
|
||||||
|
|
||||||
|
ops: dict = {}
|
||||||
|
comment = ""
|
||||||
|
target = None
|
||||||
|
|
||||||
|
def need(n: int) -> None:
|
||||||
|
if p + n > len(d):
|
||||||
|
raise DecodeError(f"{mnem} at {pos:#07x} truncated: needs {n} bytes")
|
||||||
|
|
||||||
|
if kind == NONE:
|
||||||
|
pass
|
||||||
|
elif kind in (U8REG, U8LIT):
|
||||||
|
need(1)
|
||||||
|
ops["value"] = d[p]
|
||||||
|
p += 1
|
||||||
|
comment = f"r{ops['value']}" if kind == U8REG else str(ops["value"])
|
||||||
|
elif kind == U8CONST:
|
||||||
|
need(1)
|
||||||
|
ops["index"] = d[p]
|
||||||
|
p += 1
|
||||||
|
comment = f"{ops['index']:#04x} -> {self.const(ops['index'])!r}"
|
||||||
|
elif kind == U16CONST:
|
||||||
|
need(2)
|
||||||
|
ops["index"] = struct.unpack_from("<H", d, p)[0]
|
||||||
|
p += 2
|
||||||
|
comment = f"{ops['index']:#06x} -> {self.const(ops['index'])!r}"
|
||||||
|
elif kind == U16LIT:
|
||||||
|
need(2)
|
||||||
|
ops["value"] = struct.unpack_from("<H", d, p)[0]
|
||||||
|
p += 2
|
||||||
|
comment = str(ops["value"])
|
||||||
|
elif kind == U32:
|
||||||
|
need(4)
|
||||||
|
ops["value"] = struct.unpack_from("<I", d, p)[0]
|
||||||
|
p += 4
|
||||||
|
comment = str(ops["value"])
|
||||||
|
elif kind == F32:
|
||||||
|
need(4)
|
||||||
|
ops["value"] = struct.unpack_from("<f", d, p)[0]
|
||||||
|
p += 4
|
||||||
|
comment = repr(ops["value"])
|
||||||
|
elif kind == BRANCH:
|
||||||
|
need(4)
|
||||||
|
disp = struct.unpack_from("<i", d, p)[0]
|
||||||
|
p += 4
|
||||||
|
ops["displacement"] = disp
|
||||||
|
target = p + disp # base = end of record
|
||||||
|
comment = f"{disp:+d} -> {target:#07x}"
|
||||||
|
elif kind == STR64:
|
||||||
|
need(8)
|
||||||
|
off = struct.unpack_from("<Q", d, p)[0]
|
||||||
|
p += 8
|
||||||
|
ops["offset"] = off
|
||||||
|
ops["text"] = self.cstr(off)
|
||||||
|
comment = f"{ops['text']!r}"
|
||||||
|
elif kind == POOL:
|
||||||
|
need(16)
|
||||||
|
count, arr = struct.unpack_from("<QQ", d, p)
|
||||||
|
p += 16
|
||||||
|
if arr + count * 8 > len(d):
|
||||||
|
raise DecodeError(f"{mnem} at {pos:#07x}: array {arr:#x}[{count}] overruns")
|
||||||
|
ids = list(struct.unpack_from(f"<{count}Q", d, arr))
|
||||||
|
ops["count"], ops["array"], ops["ids"] = count, arr, ids
|
||||||
|
comment = f"count={count} array={arr:#x}"
|
||||||
|
elif kind in (FUNC2, FUNC1):
|
||||||
|
if kind == FUNC2:
|
||||||
|
need(48)
|
||||||
|
name_off, n_params = struct.unpack_from("<QI", d, p)
|
||||||
|
n_reg = d[p + 12]
|
||||||
|
flags = int.from_bytes(d[p + 13:p + 16], "little")
|
||||||
|
plist, body = struct.unpack_from("<QQ", d, p + 16)
|
||||||
|
lo, hi = struct.unpack_from("<QQ", d, p + 32)
|
||||||
|
p += 48
|
||||||
|
else:
|
||||||
|
need(40)
|
||||||
|
name_off, n_params, plist, body = struct.unpack_from("<QQQQ", d, p)
|
||||||
|
n_reg, flags = 4, 0
|
||||||
|
lo, hi = struct.unpack_from("<QQ", d, p + 32)
|
||||||
|
p += 40
|
||||||
|
if (lo, hi) != (FUNC_SENTINEL_LO, FUNC_SENTINEL_HI):
|
||||||
|
raise DecodeError(
|
||||||
|
f"{mnem} at {pos:#07x}: bad trailer {lo:#x}/{hi:#x}, "
|
||||||
|
"record layout is wrong"
|
||||||
|
)
|
||||||
|
name = self.cstr(name_off)
|
||||||
|
params = []
|
||||||
|
for i in range(n_params):
|
||||||
|
e = plist + i * 16
|
||||||
|
if e + 16 > len(d):
|
||||||
|
raise DecodeError(f"{mnem} at {pos:#07x}: param {i} overruns")
|
||||||
|
reg, pn = struct.unpack_from("<QQ", d, e)
|
||||||
|
params.append((reg, self.cstr(pn)))
|
||||||
|
ops.update(name=name, n_params=n_params, n_registers=n_reg,
|
||||||
|
flags=flags, params=params, body_size=body)
|
||||||
|
comment = (f"{name or '<anonymous>'}({', '.join(n for _, n in params)}) "
|
||||||
|
f"nRegs={n_reg} flags={flag_names(flags)} bodySize={body}")
|
||||||
|
ops["body_start"] = p
|
||||||
|
ops["body_end"] = p + body
|
||||||
|
else:
|
||||||
|
raise DecodeError(f"internal: unhandled kind {kind}")
|
||||||
|
|
||||||
|
return Instr(pos, op, mnem, p - pos, ops, d[pos:p], target, comment)
|
||||||
|
|
||||||
|
def decode_stream(self, start: int, limit: int | None = None) -> list[Instr]:
|
||||||
|
"""Linear decode using the reference termination rule.
|
||||||
|
|
||||||
|
Stops when the last instruction was End AND we are past every branch
|
||||||
|
destination seen so far. A stream may legitimately continue past an End.
|
||||||
|
"""
|
||||||
|
out: list[Instr] = []
|
||||||
|
pos = start
|
||||||
|
furthest = start
|
||||||
|
while True:
|
||||||
|
if limit is not None and pos >= limit:
|
||||||
|
break
|
||||||
|
ins = self.decode_one(pos)
|
||||||
|
out.append(ins)
|
||||||
|
if ins.target is not None:
|
||||||
|
furthest = max(furthest, ins.target)
|
||||||
|
if ins.mnemonic == "ConstantPool":
|
||||||
|
self.install_pool(ins.operands["ids"])
|
||||||
|
if ins.mnemonic in ("DefineFunction2", "DefineFunction"):
|
||||||
|
fn = Function(
|
||||||
|
name=ins.operands["name"],
|
||||||
|
record_offset=ins.offset,
|
||||||
|
body_start=ins.operands["body_start"],
|
||||||
|
body_end=ins.operands["body_end"],
|
||||||
|
n_params=ins.operands["n_params"],
|
||||||
|
n_registers=ins.operands["n_registers"],
|
||||||
|
flags=ins.operands["flags"],
|
||||||
|
params=ins.operands["params"],
|
||||||
|
)
|
||||||
|
self.functions.append(fn)
|
||||||
|
furthest = max(furthest, fn.body_end)
|
||||||
|
pos = ins.offset + ins.length
|
||||||
|
if ins.mnemonic == "End" and pos > furthest:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load(apt1_path: str, aptdata_path: str) -> tuple[ConstPool, AptData]:
|
||||||
|
pool = ConstPool(open(apt1_path, "rb").read())
|
||||||
|
movie = AptData(open(aptdata_path, "rb").read(), pool)
|
||||||
|
return pool, movie
|
||||||
|
|
||||||
|
|
||||||
|
def find_streams(movie: AptData) -> list[int]:
|
||||||
|
"""Seed stream starts: every ConstantPool record that validates."""
|
||||||
|
seeds = []
|
||||||
|
d = movie.data
|
||||||
|
for p in range(len(d)):
|
||||||
|
if d[p] != 0x88:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ins = movie.decode_one(p)
|
||||||
|
except DecodeError:
|
||||||
|
continue
|
||||||
|
if ins.operands.get("count", 0) and ins.operands["ids"] == list(
|
||||||
|
range(ins.operands["count"])
|
||||||
|
):
|
||||||
|
seeds.append(p)
|
||||||
|
return seeds
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--apt1", default="fifa17-recon/data/apt/futSelectTeam_Apt1.bin")
|
||||||
|
ap.add_argument("--aptdata", default="fifa17-recon/data/apt/futSelectTeam_AptData.bin")
|
||||||
|
ap.add_argument("--stream", type=lambda s: int(s, 0), help="decode one stream at offset")
|
||||||
|
ap.add_argument("--function", help="decode the named function's body")
|
||||||
|
ap.add_argument("--list-functions", action="store_true")
|
||||||
|
ap.add_argument("--report", action="store_true", help="structural validation report")
|
||||||
|
ap.add_argument("--strings", action="store_true", help="dump the constant pool")
|
||||||
|
ap.add_argument("--selftest", action="store_true")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
pool, movie = load(args.apt1, args.aptdata)
|
||||||
|
|
||||||
|
if args.selftest:
|
||||||
|
return selftest(pool, movie)
|
||||||
|
|
||||||
|
if args.strings:
|
||||||
|
for i, (t, v, s) in enumerate(pool.entries):
|
||||||
|
print(f" #{i:3d} type={t} @{v:#07x} {s!r}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
seeds = find_streams(movie)
|
||||||
|
if args.stream is not None:
|
||||||
|
seeds = [args.stream]
|
||||||
|
|
||||||
|
all_instrs: list[Instr] = []
|
||||||
|
for s in seeds:
|
||||||
|
all_instrs.extend(movie.decode_stream(s))
|
||||||
|
|
||||||
|
if args.list_functions:
|
||||||
|
for fn in movie.functions:
|
||||||
|
regs = register_map(fn)
|
||||||
|
rs = " ".join(f"r{k}={v}" for k, v in sorted(regs.items()))
|
||||||
|
print(f" {fn.body_start:#07x}-{fn.body_end:#07x} "
|
||||||
|
f"{fn.name or '<anonymous>':<34} {rs}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.function:
|
||||||
|
for fn in movie.functions:
|
||||||
|
if fn.name == args.function:
|
||||||
|
print(f"; {fn.name} body {fn.body_start:#x}..{fn.body_end:#x} "
|
||||||
|
f"flags={flag_names(fn.flags)} nRegs={fn.n_registers}")
|
||||||
|
regs = register_map(fn)
|
||||||
|
for k, v in sorted(regs.items()):
|
||||||
|
print(f"; r{k} = {v}")
|
||||||
|
for ins in movie.decode_stream(fn.body_start, fn.body_end):
|
||||||
|
print(ins.render())
|
||||||
|
return 0
|
||||||
|
print(f"function {args.function!r} not found", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.report:
|
||||||
|
return report(movie, seeds, all_instrs)
|
||||||
|
|
||||||
|
for ins in all_instrs:
|
||||||
|
print(ins.render())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def report(movie: AptData, seeds: list[int], instrs: list[Instr]) -> int:
|
||||||
|
import collections
|
||||||
|
hist = collections.Counter(i.mnemonic for i in instrs)
|
||||||
|
covered = set()
|
||||||
|
for i in instrs:
|
||||||
|
covered.update(range(i.offset, i.offset + i.length))
|
||||||
|
branches = [i for i in instrs if i.target is not None]
|
||||||
|
boundaries = {i.offset for i in instrs}
|
||||||
|
bad = [i for i in branches if i.target not in boundaries]
|
||||||
|
print(f" streams decoded : {len(seeds)} {[hex(s) for s in seeds]}")
|
||||||
|
print(f" instructions : {len(instrs)}")
|
||||||
|
print(f" bytes covered : {len(covered)} of {len(movie.data)}")
|
||||||
|
print(f" functions : {len(movie.functions)}")
|
||||||
|
print(f" branches : {len(branches)}")
|
||||||
|
print(f" invalid branch targets: {len(bad)}")
|
||||||
|
for i in bad[:10]:
|
||||||
|
print(f" {i.offset:#07x} {i.mnemonic} -> {i.target:#07x}")
|
||||||
|
print(f" distinct opcodes : {len(hist)}")
|
||||||
|
for m, n in hist.most_common():
|
||||||
|
print(f" {m:<22} {n}")
|
||||||
|
return 1 if bad else 0
|
||||||
|
|
||||||
|
|
||||||
|
def selftest(pool: ConstPool, movie: AptData) -> int:
|
||||||
|
"""Assertions that pin the measured format facts."""
|
||||||
|
ok = True
|
||||||
|
|
||||||
|
def check(label: str, cond: bool, detail: str = "") -> None:
|
||||||
|
nonlocal ok
|
||||||
|
print(f" [{'PASS' if cond else 'FAIL'}] {label}{(' - ' + detail) if detail else ''}")
|
||||||
|
ok = ok and cond
|
||||||
|
|
||||||
|
check("Apt1 entry count", pool.count == 414, f"{pool.count}")
|
||||||
|
check("Apt1 all entries are strings",
|
||||||
|
all(t == 1 for t, _, _ in pool.entries))
|
||||||
|
check("Apt1 entry array abuts string table",
|
||||||
|
pool.first + pool.count * 16 == min(v for t, v, _ in pool.entries if t == 1))
|
||||||
|
|
||||||
|
# Phase 3: exact pointer -> string resolution for known symbols.
|
||||||
|
for name in ("CheckIsKitLocked", "KITS_AVAILABLE", "FUT_GET_MATCH_KITS_DP",
|
||||||
|
"mcLockHome"):
|
||||||
|
idx = pool.find(name)
|
||||||
|
check(f"string resolves: {name}", len(idx) == 1 and pool.string(idx[0]) == name,
|
||||||
|
f"index {idx}")
|
||||||
|
|
||||||
|
# Bad pointers must raise, not fuzzy-match.
|
||||||
|
for bad in (-1, 10 ** 6):
|
||||||
|
try:
|
||||||
|
pool.string(bad)
|
||||||
|
check(f"bad const index {bad} rejected", False)
|
||||||
|
except DecodeError:
|
||||||
|
check(f"bad const index {bad} rejected", True)
|
||||||
|
|
||||||
|
# Phase 4 fixtures for the two EA opcodes.
|
||||||
|
movie.scope = ["alpha", "beta"] + [f"c{i}" for i in range(2, 300)]
|
||||||
|
fixtures = [
|
||||||
|
(bytes([0xB9, 0x00]), "PushRegister", 2, "r0"),
|
||||||
|
(bytes([0xB9, 0x05]), "PushRegister", 2, "r5"),
|
||||||
|
(bytes([0xB9, 0xFF]), "PushRegister", 2, "r255"),
|
||||||
|
(bytes([0xAF, 0x00]), "GetNamedMember", 2, "'alpha'"),
|
||||||
|
(bytes([0xAF, 0x01]), "GetNamedMember", 2, "'beta'"),
|
||||||
|
(bytes([0xA2, 0x01]), "PushConstantByte", 2, "'beta'"),
|
||||||
|
]
|
||||||
|
for raw, mnem, length, needle in fixtures:
|
||||||
|
probe = AptData(APTDATA_MAGIC + raw.ljust(16, b"\0"), pool)
|
||||||
|
probe.scope = movie.scope
|
||||||
|
ins = probe.decode_one(16)
|
||||||
|
check(f"fixture {raw.hex()} -> {mnem}",
|
||||||
|
ins.mnemonic == mnem and ins.length == length and needle in ins.comment,
|
||||||
|
f"{ins.mnemonic} len={ins.length} {ins.comment}")
|
||||||
|
|
||||||
|
# Truncated records must fail closed.
|
||||||
|
for raw in (bytes([0xB9]), bytes([0xAF]), bytes([0xA3, 0x01])):
|
||||||
|
probe = AptData(APTDATA_MAGIC + raw, pool)
|
||||||
|
probe.scope = movie.scope
|
||||||
|
try:
|
||||||
|
probe.decode_one(16)
|
||||||
|
check(f"truncated {raw.hex()} fails closed", False)
|
||||||
|
except DecodeError:
|
||||||
|
check(f"truncated {raw.hex()} fails closed", True)
|
||||||
|
|
||||||
|
# Out-of-range pool index must fail closed, not silently clamp.
|
||||||
|
probe = AptData(APTDATA_MAGIC + bytes([0xAF, 0x10]), pool)
|
||||||
|
probe.scope = ["only-one"]
|
||||||
|
try:
|
||||||
|
probe.decode_one(16)
|
||||||
|
check("out-of-range scope index rejected", False)
|
||||||
|
except DecodeError:
|
||||||
|
check("out-of-range scope index rejected", True)
|
||||||
|
|
||||||
|
# Unknown opcode must refuse rather than resynchronise.
|
||||||
|
probe = AptData(APTDATA_MAGIC + bytes([0xEE, 0x00]), pool)
|
||||||
|
try:
|
||||||
|
probe.decode_one(16)
|
||||||
|
check("unknown opcode refuses to guess length", False)
|
||||||
|
except DecodeError as e:
|
||||||
|
check("unknown opcode refuses to guess length", "refusing to guess" in str(e))
|
||||||
|
|
||||||
|
# Whole-artifact decode.
|
||||||
|
movie.scope = []
|
||||||
|
movie.functions = []
|
||||||
|
seeds = find_streams(movie)
|
||||||
|
instrs: list[Instr] = []
|
||||||
|
try:
|
||||||
|
for s in seeds:
|
||||||
|
instrs.extend(movie.decode_stream(s))
|
||||||
|
check("whole artifact decodes", True, f"{len(instrs)} instructions")
|
||||||
|
except DecodeError as e:
|
||||||
|
check("whole artifact decodes", False, str(e))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
boundaries = {i.offset for i in instrs}
|
||||||
|
bad = [i for i in instrs if i.target is not None and i.target not in boundaries]
|
||||||
|
check("every branch lands on an instruction boundary", not bad,
|
||||||
|
f"{len(bad)} bad")
|
||||||
|
|
||||||
|
# CheckIsKitLocked is CALLED here, never defined here: it is a method on the
|
||||||
|
# mcSelectTeam child clip, whose class lives in another asset. Assert the
|
||||||
|
# call site is bound exactly, and that this asset defines no such function.
|
||||||
|
called = [i for i in instrs if i.comment and "CheckIsKitLocked" in i.comment]
|
||||||
|
check("CheckIsKitLocked referenced exactly once", len(called) == 1,
|
||||||
|
f"{[hex(i.offset) for i in called]}")
|
||||||
|
check("CheckIsKitLocked reference is PushConstantWord (pool index > u8)",
|
||||||
|
bool(called) and called[0].mnemonic == "PushConstantWord")
|
||||||
|
check("CheckIsKitLocked is not defined in this asset",
|
||||||
|
"CheckIsKitLocked" not in {f.name for f in movie.functions})
|
||||||
|
|
||||||
|
# The gate contract the native DP builder must satisfy.
|
||||||
|
gate = [i for i in instrs if i.comment and "KITS_AVAILABLE" in i.comment]
|
||||||
|
check("KITS_AVAILABLE read exactly once", len(gate) == 1)
|
||||||
|
check("KITS_AVAILABLE read via GetNamedMember on the DP header",
|
||||||
|
bool(gate) and gate[0].mnemonic == "GetNamedMember")
|
||||||
|
|
||||||
|
# 8-byte alignment is load-bearing: prove 4 would break the pool record.
|
||||||
|
p4 = (0xD38 + 1 + 3) & ~3
|
||||||
|
c4 = struct.unpack_from("<Q", movie.data, p4)[0]
|
||||||
|
check("alignment is 8 not 4", c4 != 401, f"align4 count would be {c4:#x}")
|
||||||
|
|
||||||
|
print(f"\n {'ALL PASS' if ok else 'FAILURES PRESENT'}")
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Minimal AVM1 (ActionScript 2) disassembler for EA APT movies.
|
|
||||||
|
|
||||||
Enough of the opcode table to READ a predicate: pushes, member access, calls,
|
|
||||||
branches and comparisons. Not a decompiler and not complete — anything unknown is
|
|
||||||
printed as a raw opcode with its length so the reader can see there is a gap
|
|
||||||
rather than silently skipping it.
|
|
||||||
|
|
||||||
APT stores the same AVM1 action blocks a SWF DoAction does, so a constant pool
|
|
||||||
(0x88) followed by pushes that index it is the normal shape.
|
|
||||||
|
|
||||||
python3 avm1_disasm.py movie.bin --pool
|
|
||||||
python3 avm1_disasm.py movie.bin --around CheckIsKitLocked --span 900
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# opcode -> (name, has_payload)
|
|
||||||
OPS = {
|
|
||||||
0x04: "NextFrame", 0x06: "Play", 0x07: "Stop", 0x0A: "Add", 0x0B: "Subtract",
|
|
||||||
0x0C: "Multiply", 0x0D: "Divide", 0x0E: "Equals", 0x0F: "Less",
|
|
||||||
0x10: "And", 0x11: "Or", 0x12: "Not", 0x13: "StringEquals",
|
|
||||||
0x17: "Pop", 0x18: "ToInteger",
|
|
||||||
0x1C: "GetVariable", 0x1D: "SetVariable",
|
|
||||||
0x21: "StringAdd", 0x22: "GetProperty", 0x23: "SetProperty",
|
|
||||||
0x24: "CloneSprite", 0x26: "Trace", 0x28: "EndDrag",
|
|
||||||
0x2A: "Throw", 0x2B: "CastOp", 0x2C: "ImplementsOp",
|
|
||||||
0x30: "RandomNumber", 0x3A: "Delete", 0x3B: "Delete2",
|
|
||||||
0x3C: "DefineLocal", 0x3D: "CallFunction", 0x3E: "Return",
|
|
||||||
0x3F: "Modulo", 0x40: "NewObject", 0x41: "DefineLocal2",
|
|
||||||
0x42: "InitArray", 0x43: "InitObject", 0x44: "TypeOf",
|
|
||||||
0x46: "Enumerate", 0x47: "Add2", 0x48: "Less2", 0x49: "Equals2",
|
|
||||||
0x4A: "ToNumber", 0x4B: "ToString", 0x4C: "PushDuplicate", 0x4D: "StackSwap",
|
|
||||||
0x4E: "GetMember", 0x4F: "SetMember", 0x50: "Increment", 0x51: "Decrement",
|
|
||||||
0x52: "CallMethod", 0x53: "NewMethod", 0x54: "InstanceOf", 0x55: "Enumerate2",
|
|
||||||
0x60: "BitAnd", 0x61: "BitOr", 0x62: "BitXor",
|
|
||||||
0x66: "StrictEquals", 0x67: "Greater", 0x68: "StringGreater",
|
|
||||||
0x69: "Extends",
|
|
||||||
}
|
|
||||||
PAYLOAD = {
|
|
||||||
0x81: "GotoFrame", 0x83: "GetURL", 0x87: "StoreRegister", 0x88: "ConstantPool",
|
|
||||||
0x8A: "WaitForFrame", 0x8B: "SetTarget", 0x8C: "GotoLabel",
|
|
||||||
0x8D: "WaitForFrame2", 0x8E: "DefineFunction2", 0x8F: "Try",
|
|
||||||
0x94: "With", 0x96: "Push", 0x99: "Jump", 0x9A: "GetURL2",
|
|
||||||
0x9B: "DefineFunction", 0x9D: "If", 0x9E: "Call", 0x9F: "GotoFrame2",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def parse_push(data: bytes, pool: list[str]) -> list[str]:
|
|
||||||
out, i = [], 0
|
|
||||||
while i < len(data):
|
|
||||||
t = data[i]; i += 1
|
|
||||||
try:
|
|
||||||
if t == 0:
|
|
||||||
e = data.index(b"\0", i); out.append(repr(data[i:e].decode("latin1"))); i = e + 1
|
|
||||||
elif t == 1:
|
|
||||||
out.append(f"{struct.unpack_from('<f', data, i)[0]:g}"); i += 4
|
|
||||||
elif t == 2:
|
|
||||||
out.append("null")
|
|
||||||
elif t == 3:
|
|
||||||
out.append("undefined")
|
|
||||||
elif t == 4:
|
|
||||||
out.append(f"reg{data[i]}"); i += 1
|
|
||||||
elif t == 5:
|
|
||||||
out.append("true" if data[i] else "false"); i += 1
|
|
||||||
elif t == 6:
|
|
||||||
out.append(f"{struct.unpack_from('<d', data, i)[0]:g}"); i += 8
|
|
||||||
elif t == 7:
|
|
||||||
out.append(str(struct.unpack_from("<i", data, i)[0])); i += 4
|
|
||||||
elif t in (8, 9):
|
|
||||||
idx = data[i] if t == 8 else struct.unpack_from("<H", data, i)[0]
|
|
||||||
i += 1 if t == 8 else 2
|
|
||||||
out.append(f"c{idx}:{pool[idx]!r}" if idx < len(pool) else f"c{idx}")
|
|
||||||
else:
|
|
||||||
out.append(f"?type{t}"); break
|
|
||||||
except Exception:
|
|
||||||
out.append("<truncated>"); break
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def disasm(buf: bytes, start: int, end: int, pool: list[str]):
|
|
||||||
i, lines = start, []
|
|
||||||
while i < end and i < len(buf):
|
|
||||||
op = buf[i]
|
|
||||||
if op == 0:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
if op < 0x80:
|
|
||||||
lines.append((i, OPS.get(op, f"op{op:#04x}"), ""))
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
if i + 3 > len(buf):
|
|
||||||
break
|
|
||||||
ln = struct.unpack_from("<H", buf, i + 1)[0]
|
|
||||||
body = buf[i + 3:i + 3 + ln]
|
|
||||||
name = PAYLOAD.get(op, f"op{op:#04x}")
|
|
||||||
arg = ""
|
|
||||||
if op == 0x96:
|
|
||||||
arg = ", ".join(parse_push(body, pool))
|
|
||||||
elif op in (0x99, 0x9D) and ln >= 2:
|
|
||||||
arg = f"{struct.unpack_from('<h', body, 0)[0]:+d}"
|
|
||||||
elif op == 0x88 and ln >= 2:
|
|
||||||
arg = f"{struct.unpack_from('<H', body, 0)[0]} entries"
|
|
||||||
elif op in (0x8E, 0x9B):
|
|
||||||
e = body.index(b"\0") if b"\0" in body else 0
|
|
||||||
arg = body[:e].decode("latin1")
|
|
||||||
elif op == 0x87 and ln >= 1:
|
|
||||||
arg = f"reg{body[0]}"
|
|
||||||
else:
|
|
||||||
arg = body[:40].decode("latin1", "replace").replace("\0", ".")
|
|
||||||
lines.append((i, name, arg))
|
|
||||||
i += 3 + ln
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def constant_pools(buf: bytes):
|
|
||||||
"""Every ActionConstantPool in the blob, as (offset, [strings])."""
|
|
||||||
pools, i = [], 0
|
|
||||||
while i < len(buf) - 3:
|
|
||||||
if buf[i] == 0x88:
|
|
||||||
ln = struct.unpack_from("<H", buf, i + 1)[0]
|
|
||||||
body = buf[i + 3:i + 3 + ln]
|
|
||||||
if len(body) >= 2:
|
|
||||||
cnt = struct.unpack_from("<H", body, 0)[0]
|
|
||||||
parts = body[2:].split(b"\0")
|
|
||||||
if 0 < cnt <= 4000 and len(parts) >= cnt:
|
|
||||||
pools.append((i, [p.decode("latin1") for p in parts[:cnt]]))
|
|
||||||
i += 3 + ln
|
|
||||||
continue
|
|
||||||
i += 1
|
|
||||||
return pools
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("file")
|
|
||||||
ap.add_argument("--pool", action="store_true")
|
|
||||||
ap.add_argument("--around")
|
|
||||||
ap.add_argument("--span", type=int, default=700)
|
|
||||||
ap.add_argument("--at", type=lambda s: int(s, 0))
|
|
||||||
a = ap.parse_args()
|
|
||||||
|
|
||||||
buf = open(a.file, "rb").read()
|
|
||||||
pools = constant_pools(buf)
|
|
||||||
pool = max((p for _, p in pools), key=len, default=[])
|
|
||||||
|
|
||||||
if a.pool:
|
|
||||||
for off, p in pools:
|
|
||||||
print(f"== ConstantPool @ {off:#x}: {len(p)} entries ==")
|
|
||||||
for n, s in enumerate(p):
|
|
||||||
print(f" c{n:<4d} {s}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
anchor = a.at
|
|
||||||
if a.around:
|
|
||||||
anchor = buf.find(a.around.encode())
|
|
||||||
if anchor < 0:
|
|
||||||
print(f"{a.around!r} not found", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
print(f"# anchor {a.around!r} @ {anchor:#x}")
|
|
||||||
if anchor is None:
|
|
||||||
ap.error("--pool, --around or --at required")
|
|
||||||
|
|
||||||
lo = max(0, anchor - a.span)
|
|
||||||
for off, name, arg in disasm(buf, lo, anchor + a.span, pool):
|
|
||||||
print(f" {off:#08x} {name:<18s} {arg}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -2230,7 +2230,6 @@ fn position_tabs_serve_only_their_own_position_group() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn withheld_club_type_arms_are_empty_with_a_recorded_reason() {
|
fn withheld_club_type_arms_are_empty_with_a_recorded_reason() {
|
||||||
for (query, reason) in [
|
for (query, reason) in [
|
||||||
("type=equippables", "multi_family_crash_2026_08_05"),
|
|
||||||
("type=leaguelogos", "subtype_by_elimination_unprobed"),
|
("type=leaguelogos", "subtype_by_elimination_unprobed"),
|
||||||
("type=healing", "served_by_club_consumables_route"),
|
("type=healing", "served_by_club_consumables_route"),
|
||||||
("type=contract", "served_by_club_consumables_route"),
|
("type=contract", "served_by_club_consumables_route"),
|
||||||
@@ -2254,6 +2253,35 @@ fn withheld_club_type_arms_are_empty_with_a_recorded_reason() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `?type=equippables` is the family the MY CLUB kit tab actually asks for, and it
|
||||||
|
/// answers with the KIT family only. Withholding it WAS the "0 kits in my club"
|
||||||
|
/// symptom — the tab asked, got the withheld empty body, and drew nothing. Serving
|
||||||
|
/// it is operator-confirmed on the retail client.
|
||||||
|
///
|
||||||
|
/// Kits only, deliberately: the 2026-08-05 crash was 30 items across five families
|
||||||
|
/// at once, and nothing has retested that shape.
|
||||||
|
///
|
||||||
|
/// NOT exercised here: `OPENFUT_FIFA17_EQUIPPABLES=0`, which restores the withheld
|
||||||
|
/// body. It is read through a `OnceLock`, so flipping the process-wide env inside
|
||||||
|
/// one test would leak into every other test sharing this binary.
|
||||||
|
#[test]
|
||||||
|
fn equippables_serves_the_kit_family_because_that_is_the_my_club_kit_tab() {
|
||||||
|
let (items, _, outcome) = club_query("type=equippables");
|
||||||
|
assert_eq!(outcome, "ok");
|
||||||
|
assert_eq!(items.len(), 2, "the fixture club owns exactly two kits");
|
||||||
|
for it in &items {
|
||||||
|
assert_eq!(it["itemType"], "kit");
|
||||||
|
assert_eq!(
|
||||||
|
it["cardsubtypeid"], 9,
|
||||||
|
"kits only: mixing families is the shape that crashed the client"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two tabs are the same question, so they must never diverge.
|
||||||
|
let (kits, _, _) = club_query("type=kit");
|
||||||
|
assert_eq!(items, kits, "equippables and kit must serve one body");
|
||||||
|
}
|
||||||
|
|
||||||
/// The three club-customisation families Core can own are MAPPED (so the arm asks
|
/// The three club-customisation families Core can own are MAPPED (so the arm asks
|
||||||
/// Core for the right rows) but their item record is still withheld, so the answer
|
/// Core for the right rows) but their item record is still withheld, so the answer
|
||||||
/// is an empty list rather than a guessed record — and never another family's.
|
/// is an empty list rather than a guessed record — and never another family's.
|
||||||
|
|||||||
Reference in New Issue
Block a user