kits: decode the FIFA 17 APT bytecode; KITS_AVAILABLE is not a server field
Adds fifa17-recon/tools/apt_decode.py, a clean-room decoder for the EA APT
compiled-ActionScript format as FIFA 17 ships it, and deletes avm1_disasm.py,
which assumed SWF framing and could not decode this artifact.
FORMAT. FIFA 17 uses a 64-bit variant of the format: constant-pool entries are
16-byte {u64 type, u64 value} in a separate "Apt1" container member, container
pointers and counts are u64, DefineFunction2's operand block is 48 bytes rather
than 28, its 0x1234567898765432 trailer is stored as two u64 halves, and
parameterised instructions align their operand block to EIGHT bytes, not four.
The alignment is the fact that made the stream decodable: the ConstantPool at
0xd38 yields garbage at align-4 and count=401 at align-8, with an index array
that terminates exactly on the parameter-list region.
The three opcodes that blocked the previous attempt are all unaligned 2-byte
records: 0xAF EA_GetNamedMember (u8 constant-pool index, pop object push member),
0xB9 EA_PushRegister (u8 register index), 0xA2 EA_PushConstantByte (u8
constant-pool index). Byte-wide indices only reach pool entries 0-255, which is
why CheckIsKitLocked at index 293 is emitted as 0xA3 PushConstantWord.
Format facts came from a written specification derived from OpenSAGE
(588ac477367a0022adf29f20a084e8873014e6ce, GPL-3.0 with EA section 7 additional
terms). No code was copied or transliterated; only the interface specification
was used. Provenance is recorded in the module docstring.
VALIDATION. The whole artifact decodes: 5251 instructions, 24 functions, action
stream 0xd38..0x3e75 with 12605 of 12605 bytes covered and zero interior gaps,
zero unresolved opcodes, zero invalid branch targets, zero unresolved strings.
Every byte of the 20630-byte member is accounted for by region. --selftest
asserts all of it, plus operand fixtures and fail-closed behaviour on truncated
records, out-of-range pool indices and unknown opcodes. Unknown opcodes still
refuse to guess a length rather than resynchronising.
RESULT. KITS_AVAILABLE is a BOOLEAN in the DataProvider header, not a count, so
the logged Some(0) means false. futSelectTeam::Publish reads
publishObject.header.KITS_AVAILABLE == true and only then fills m_arrKitPanelData
from data[side].LENGTH and data[side]["KIT_"+i]; CardsDLL's builder writes
exactly that shape ("LENGTH" and "KIT_%d" confirmed in .rdata). The flag comes
from ctx+0x152, whose only setter is internal message 0x757a. That message is
never constructed in ActionScript (the asset contains zero integer literals
above 255) and never sent by CardsDLL (247 of 247 send sites pass an immediate,
none of them 0x757a; four sites in the same family are the positive control).
So no HTTP response can open this gate, and no OpenFUT change is made here.
CheckIsKitLocked is called at 0x2cc3 but defined on the mcSelectTeam child clip
in another asset, so its body is deliberately NOT reconstructed rather than
guessed. Full findings in the Vault under Kit Selector APT Decode.
This commit is contained in:
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())
|
||||
Reference in New Issue
Block a user