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:
funman300
2026-08-23 22:44:14 +00:00
parent f40e8587ac
commit a86d21ec79
2 changed files with 697 additions and 174 deletions
+697
View File
@@ -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())
-174
View File
@@ -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())