diff --git a/fifa17-recon/tools/apt_decode.py b/fifa17-recon/tools/apt_decode.py new file mode 100755 index 0000000..6e0018a --- /dev/null +++ b/fifa17-recon/tools/apt_decode.py @@ -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(" 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(" (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('"); 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("= 2: - arg = f"{struct.unpack_from('= 2: - arg = f"{struct.unpack_from('= 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("= 2: - cnt = struct.unpack_from("= 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())