#!/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(" len(data): raise DecodeError(f"const entry {i} at {off:#x} runs past end") etype, value = struct.unpack_from(" 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(" {self.const(ops['index'])!r}" elif kind == U16LIT: need(2) ops["value"] = struct.unpack_from(" {target:#07x}" elif kind == STR64: need(8) off = struct.unpack_from(" 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(" len(d): raise DecodeError(f"{mnem} at {pos:#07x}: param {i} overruns") reg, pn = struct.unpack_from("'}({', '.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 '':<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("