decompile.js

JavaScript source for the SWF disassembler, 1260 lines: running the instructions symbolically, rebuilding loops, branches and switches from the shapes the compiler leaves, and printing the result as ActionScript. It's lib/decompile.js in swf-disassembler.zip.

//  Turns decoded AVM1 actions back into ActionScript.
//
//  AVM1 is a stack machine, so the decompiler runs the code symbolically: pushes put expressions on a
//  stack, operators combine them, and actions with side effects become statements. Control flow is
//  rebuilt from the shapes the Flash compiler produces for if/else, while, do/while, for, for..in, &&,
//  || and ?:. Anything that doesn't match a known shape is left as labels and gotos, so nothing is lost.

import { registerNames } from './actions.js';

const NOT = 0x12, POP = 0x17, IF = 0x9D, JUMP = 0x99, PUSH = 0x96, STORE = 0x87, EQUALS2 = 0x49, PLAY = 0x06;

const PROPERTIES = ['_x', '_y', '_xscale', '_yscale', '_currentframe', '_totalframes', '_alpha', '_visible',
  '_width', '_height', '_rotation', '_target', '_framesloaded', '_name', '_droptarget', '_url', '_highquality',
  '_focusrect', '_soundbuftime', '_quality', '_xmouse', '_ymouse'];

//  Binary operators: opcode -> [operator, precedence]
const BINARY = {
  0x0A: ['+', 11], 0x0B: ['-', 11], 0x0C: ['*', 12], 0x0D: ['/', 12], 0x0E: ['==', 8], 0x0F: ['<', 9],
  0x10: ['and', 4], 0x11: ['or', 3], 0x13: ['eq', 8], 0x21: ['add', 11], 0x29: ['lt', 9],
  0x3F: ['%', 12], 0x47: ['+', 11], 0x48: ['<', 9], 0x49: ['==', 8], 0x54: ['instanceof', 9],
  0x60: ['&', 7], 0x61: ['|', 5], 0x62: ['^', 6], 0x63: ['<<', 10], 0x64: ['>>', 10], 0x65: ['>>>', 10],
  0x66: ['===', 8], 0x67: ['>', 9], 0x68: ['gt', 9],
};

const PRECEDENCE = {
  '||': 3, 'or': 3, '&&': 4, 'and': 4, '|': 5, '^': 6, '&': 7, '==': 8, '!=': 8, '===': 8, '!==': 8, 'eq': 8, 'ne': 8,
  '<': 9, '>': 9, '<=': 9, '>=': 9, 'lt': 9, 'gt': 9, 'le': 9, 'ge': 9, 'instanceof': 9,
  '<<': 10, '>>': 10, '>>>': 10, '+': 11, '-': 11, 'add': 11, '*': 12, '/': 12, '%': 12,
};

//  One-operand actions that are really built-in functions
const BUILTIN_1 = {
  0x14: 'length', 0x18: 'int', 0x30: 'random', 0x31: 'mblength', 0x32: 'ord', 0x33: 'chr', 0x36: 'mbord',
  0x37: 'mbchr', 0x45: 'targetPath', 0x4A: 'Number', 0x4B: 'String', 0x25: 'removeMovieClip', 0x26: 'trace',
};

//  Thrown when a range that should be a pure expression turns out to need statements
class NotAnExpression extends Error {}

//  Decompiles a list of actions into statements. names maps registers to the names they hold.
export function decompile(list, options = {}) {
  const ctx = makeContext(list, options.names || new Map(), new Set(options.declared || []));
  ctx.fn2 = !!options.fn2;
  let result = block(ctx, 0, list.length, [], null, false);
  //  If gotos were needed, run again with labels at their targets
  if (ctx.gotos.size) {
    ctx.labels = new Set(ctx.gotos);
    ctx.placed = new Set();
    ctx.declared = new Set(options.declared || []);
    result = block(ctx, 0, list.length, [], null, false);
  }
  return result.stmts;
}

function makeContext(list, names, declared) {
  const index = new Map();
  list.forEach((ins, i) => index.set(ins.offset, i));
  const last = list[list.length - 1];
  const endOffset = last ? last.offset + last.size : 0;
  index.set(endOffset, list.length);

  //  Every backward branch marks a loop: its target is the loop's first instruction
  const backEdges = new Map();
  list.forEach((ins, i) => {
    if ((ins.code === JUMP || ins.code === IF) && ins.target <= ins.offset && index.has(ins.target)) {
      const h = index.get(ins.target);
      if (!backEdges.has(h) || backEdges.get(h) < i)
        backEdges.set(h, i);
    }
  });

  return { list, index, endOffset, backEdges, names, declared, labels: new Set(), placed: new Set(), gotos: new Set(), active: new Set() };
}

//  ---- Expression nodes --------------------------------------------------------------------------------

const lit = (v) => ({ k: 'lit', v });
const variable = (name) => ({ k: 'var', name });
const member = (obj, prop) => ({ k: 'mem', obj, prop });
const call = (fn, args) => ({ k: 'call', fn, args });
const binary = (op, a, b) => ({ k: 'bin', op, a, b });
const unary = (op, a) => ({ k: 'un', op, a });

function not(e) {
  return unary('!', e);
}

//  The opposite of a condition, written the way the source most likely had it
function negate(e) {
  if (e.k === 'un' && e.op === '!')
    return simplifyCondition(e.a);
  return simplifyCondition(not(e));
}

function simplifyCondition(e) {
  if (e.k === 'logic')
    return { ...e, a: simplifyCondition(e.a), b: simplifyCondition(e.b) };
  if (e.k !== 'un' || e.op !== '!')
    return e;
  const a = e.a;
  if (a.k === 'lit' && typeof a.v === 'boolean')
    return lit(!a.v);
  if (a.k === 'un' && a.op === '!')
    return simplifyCondition(a.a);
  if (a.k === 'bin') {
    const flip = { '==': '!=', '!=': '==', '===': '!==', '!==': '===', '<': '>=', '>': '<=', '<=': '>', '>=': '<', 'eq': 'ne', 'lt': 'ge', 'gt': 'le' };
    if (flip[a.op])
      return binary(flip[a.op], a.a, a.b);
  }
  return e;
}

//  A string pushed as a name becomes an identifier
function nameOf(e) {
  return e.k === 'lit' && typeof e.v === 'string' ? variable(e.v) : { k: 'eval', e };
}

function numberOf(e) {
  return e.k === 'lit' && typeof e.v === 'number' ? e.v : null;
}

//  ---- The decompiler ----------------------------------------------------------------------------------

//  Decompiles list[lo..hi). stack holds values left by code before this range. loop is the innermost
//  loop around it (where break and continue go). In expression mode, any statement is a failure.
function block(ctx, lo, hi, stack, loop, exprMode) {
  const { list, index } = ctx;
  const out = [];
  const off = (i) => (i < list.length ? list[i].offset : ctx.endOffset);
  const idx = (offset) => index.get(offset);
  const emit = (s) => {
    if (exprMode)
      throw new NotAnExpression();
    out.push(s);
  };
  const pop = () => (stack.length ? stack.pop() : { k: 'missing' });
  const push = (e) => stack.push(e);
  const popArgs = (n) => {
    const args = [];
    for (let j = 0; j < n; j++)
      args.push(pop());
    return args;
  };
  const tryExpr = (a, b) => {
    try {
      const r = block(ctx, a, b, [], loop, true);
      return r.stack.length === 1 ? r.stack[0] : null;
    } catch (e) {
      if (e instanceof NotAnExpression)
        return null;
      throw e;
    }
  };
  const goTo = (target) => {
    ctx.gotos.add(target);
    return { k: 'goto', target };
  };
  const jumpStatement = (target) => {
    if (loop && target === loop.brk)
      return { k: 'break' };
    if (loop && target === loop.cont)
      return { k: 'continue' };
    if (target === ctx.endOffset)
      return { k: 'return' };
    return goTo(target);
  };

  //  a = b = c compiles as: c, storeRegister 0, (set b), push r:0, (set a). The first set becomes an
  //  assignment expression, which the push of r:0 then stands for.
  let chainPending = false;
  let chainValue = null;
  const chained = (target, value) => {
    if (!chainPending)
      return false;
    chainPending = false;
    chainValue = { k: 'assignx', target, value };
    return true;
  };

  //  a[i++] compiles as: a, i (the old value, left on the stack), then i = i + 1, then the set that
  //  uses the old value. If x = x ± 1 happens while an x is still waiting on the stack, that x is x++.
  const postfix = (target, value) => {
    if (value.k !== 'bin' || (value.op !== '+' && value.op !== '-') || !same(value.a, target) ||
        value.b.k !== 'lit' || value.b.v !== 1)
      return false;
    for (let n = stack.length - 1; n >= 0; n--)
      if (same(stack[n], target)) {
        stack[n] = { k: 'postfix', op: value.op + value.op, target };
        return true;
      }
    return false;
  };

  let i = lo;
  while (i < hi) {
    const ins = list[i];
    if (ctx.labels.has(ins.offset) && !ctx.placed.has(ins.offset) && !exprMode) {
      ctx.placed.add(ins.offset);
      out.push({ k: 'label', target: ins.offset });
    }

    //  ---- Loops: this instruction is the target of a later backward branch inside this range
    const j = ctx.backEdges.get(i);
    if (j !== undefined && j < hi && !ctx.active.has(i) && !exprMode) {
      ctx.active.add(i);
      const exit = off(j + 1);
      const inner = { brk: exit, cont: off(i) };
      const back = list[j];
      let next = j + 1;
      const forIn = stack.length && stack[stack.length - 1].k === 'enum' ? forInShape(list, i, j, off) : null;

      if (back.code === IF && j === i + 3 && list[i].code === PUSH && list[i].values.length === 1 &&
          list[i].values[0].type === 'null' && list[i + 1].code === EQUALS2 && list[i + 2].code === NOT) {
        //  push null, equals2, not, if (back): pops what a for..in left on the stack before a break
        //  or return leaves the loop. It isn't in the source.
      } else if (back.code === IF) {
        //  do { body } while (cond): the condition is left on the stack at the backward branch
        const r = block(ctx, i, j, [], inner, false);
        const cond = r.stack.length ? r.stack.pop() : lit(true);
        emit({ k: 'dowhile', body: r.stmts, cond });
      } else if (forIn) {
        //  for (x in obj): enumerate pushed a null and then each name, and the loop pops them in turn
        const obj = pop().obj;
        const r = block(ctx, i + 4, j, [], { brk: forIn.brk, cont: off(i) }, false);
        next = forIn.next;
        const body = r.stmts;
        let target = { k: 'var', name: '?' };
        let declare = false;
        if (body.length && body[0].k === 'assign' && body[0].value.k === 'reg' && body[0].value.n === 0) {
          target = body[0].target;
          declare = body[0].declare;
          body.shift();
        }
        emit({ k: 'forin', target, declare, obj, body });
      } else {
        //  while (cond) { body }: the condition is tested at the top, and exits past the backward jump
        let k = -1;
        for (let m = i; m < j; m++)
          if (list[m].code === IF && list[m].target === exit) { k = m; break; }
        const cond = k >= 0 ? tryExpr(i, k) : null;
        if (cond) {
          const r = block(ctx, k + 1, j, [], inner, false);
          emit(forLoop(out, negate(cond), r.stmts));
        } else {
          const r = block(ctx, i, j, [], inner, false);
          emit({ k: 'while', cond: lit(true), body: r.stmts });
        }
      }
      ctx.active.delete(i);
      i = next;
      continue;
    }

    switch (ins.code) {
      case 0x00:  //  end
      case 0x88:  //  constantPool
        break;

      case PUSH:
        ins.values.forEach((v, n) => {
          if (n === 0 && chainValue && v.type === 'register' && v.value === 0) {
            push(chainValue);
            chainValue = null;
          } else {
            push(v.type === 'register' ? reg(ctx, v.value) : lit(v.value));
          }
        });
        break;

      case POP: {
        const e = pop();
        if (e.k !== 'missing' && e.k !== 'enum' && hasEffect(e))
          emit({ k: 'expr', e });
        break;
      }

      case 0x4C: {  //  pushDuplicate: the start of a && or || if a branch and a pop follow
        let k = i + 1;
        let and = false;
        if (list[k] && list[k].code === NOT) {
          and = true;
          k++;
        }
        if (list[k] && list[k].code === IF && list[k].target > list[k].offset && list[k + 1] && list[k + 1].code === POP) {
          const end = idx(list[k].target);
          if (end !== undefined && end <= hi) {
            const right = tryExpr(k + 2, end);
            if (right) {
              push({ k: 'logic', op: and ? '&&' : '||', a: pop(), b: right });
              i = end;
              continue;
            }
          }
        }
        const top = pop();
        push(top);
        push(top);
        break;
      }

      case 0x4D: {  //  stackSwap
        const a = pop(), b = pop();
        push(a);
        push(b);
        break;
      }

      case 0x1C:  //  getVariable
        push(nameOf(pop()));
        break;

      case 0x1D: {  //  setVariable
        const value = pop();
        const target = nameOf(pop());
        if (!chained(target, value) && !postfix(target, value))
          emit({ k: 'assign', target, value });
        break;
      }

      case 0x3C: {  //  defineLocal
        const value = pop();
        emit({ k: 'assign', target: nameOf(pop()), value, declare: true });
        break;
      }

      case 0x41:  //  defineLocal2
        emit({ k: 'declare', target: nameOf(pop()) });
        break;

      case 0x4E: {  //  getMember
        const prop = pop();
        push(member(pop(), prop));
        break;
      }

      case 0x4F: {  //  setMember
        const value = pop();
        const prop = pop();
        const target = member(pop(), prop);
        if (!chained(target, value) && !postfix(target, value))
          emit({ k: 'assign', target, value });
        break;
      }

      case STORE: {  //  storeRegister: copies the top of the stack into a register, leaving it there
        if (!exprMode) {
          const sw = switchAt(ctx, i, hi, stack[stack.length - 1], loop);
          if (sw) {
            pop();
            emit(sw.stmt);
            i = sw.next;
            continue;
          }
        }
        //  The start of a chained assignment: leave the value where it is for the set that follows
        const nextSet = list[i + 1] && (list[i + 1].code === 0x4F || list[i + 1].code === 0x1D);
        const thenR0 = list[i + 2] && list[i + 2].code === PUSH && list[i + 2].values[0] &&
          list[i + 2].values[0].type === 'register' && list[i + 2].values[0].value === 0;
        if (ins.register === 0 && nextSet && thenR0 && i + 2 < hi) {
          chainPending = true;
          break;
        }
        const value = pop();
        const r = reg(ctx, ins.register);
        if (exprMode) {
          push({ k: 'assignx', target: r, value });
          break;
        }
        const popped = list[i + 1] && list[i + 1].code === POP && i + 1 < hi;
        if (popped && postfix(r, value)) {
          i += 2;
          continue;
        }
        const declare = ctx.fn2 && !ctx.declared.has(ins.register);
        if (declare)
          ctx.declared.add(ins.register);
        emit({ k: 'assign', target: r, value, declare });
        if (popped) {
          i += 2;
          continue;
        }
        push(r);
        break;
      }

      case 0x3D: {  //  callFunction
        const fn = nameOf(pop());
        const n = numberOf(pop()) ?? 0;
        push(call(fn, popArgs(n)));
        break;
      }

      case 0x52:  //  callMethod
      case 0x53: {  //  newMethod
        const name = pop();
        const obj = pop();
        const n = numberOf(pop()) ?? 0;
        const args = popArgs(n);
        const fn = name.k === 'lit' && (name.v === undefined || name.v === '') ? obj : member(obj, name);
        push(ins.code === 0x52 ? call(fn, args) : { k: 'new', fn, args });
        break;
      }

      case 0x40: {  //  newObject
        const fn = nameOf(pop());
        const n = numberOf(pop()) ?? 0;
        push({ k: 'new', fn, args: popArgs(n) });
        break;
      }

      case 0x42: {  //  initArray
        const n = numberOf(pop()) ?? 0;
        push({ k: 'array', items: popArgs(n) });
        break;
      }

      case 0x43: {  //  initObject
        const n = numberOf(pop()) ?? 0;
        const pairs = [];
        for (let m = 0; m < n; m++) {
          const value = pop();
          pairs.unshift([pop(), value]);
        }
        push({ k: 'object', pairs });
        break;
      }

      case NOT:
        push(not(pop()));
        break;

      case 0x50:  //  increment
        push(binary('+', pop(), lit(1)));
        break;

      case 0x51:  //  decrement
        push(binary('-', pop(), lit(1)));
        break;

      case 0x44:  //  typeOf
        push(unary('typeof ', pop()));
        break;

      case 0x3A: {  //  delete
        const prop = pop();
        push(unary('delete ', member(pop(), prop)));
        break;
      }

      case 0x3B:  //  delete2
        push(unary('delete ', nameOf(pop())));
        break;

      case 0x22: {  //  getProperty
        const prop = numberOf(pop());
        const target = pop();
        push({ k: 'prop', target, name: PROPERTIES[prop] ?? `property${prop}` });
        break;
      }

      case 0x23: {  //  setProperty
        const value = pop();
        const prop = numberOf(pop());
        const target = pop();
        emit({ k: 'assign', target: { k: 'prop', target, name: PROPERTIES[prop] ?? `property${prop}` }, value });
        break;
      }

      case 0x15: {  //  stringExtract
        const count = pop(), at = pop(), s = pop();
        push(call(variable('substring'), [s, at, count]));
        break;
      }

      case 0x35: {  //  mbStringExtract
        const count = pop(), at = pop(), s = pop();
        push(call(variable('mbsubstring'), [s, at, count]));
        break;
      }

      case 0x34:  //  getTime
        push(call(variable('getTimer'), []));
        break;

      case 0x2B: {  //  castOp
        const obj = pop();
        push(call(pop(), [obj]));
        break;
      }

      case 0x46:  //  enumerate
        push({ k: 'enum', obj: nameOf(pop()) });
        break;

      case 0x55:  //  enumerate2
        push({ k: 'enum', obj: pop() });
        break;

      case 0x3E:  //  return
        emit({ k: 'return', e: pop() });
        break;

      case 0x2A:  //  throw
        emit({ k: 'throw', e: pop() });
        break;

      case 0x26:  //  trace
        emit({ k: 'expr', e: call(variable('trace'), [pop()]) });
        break;

      case 0x69: {  //  extends
        const superclass = pop();
        emit({ k: 'extends', sub: pop(), superclass });
        break;
      }

      case 0x2C: {  //  implementsOp
        const ctor = pop();
        const n = numberOf(pop()) ?? 0;
        emit({ k: 'implements', ctor, interfaces: popArgs(n) });
        break;
      }

      case 0x9B:  //  defineFunction
      case 0x8E: {  //  defineFunction2
        const fn = functionNode(ins, ctx);
        if (ins.fname)
          emit({ k: 'function', fn });
        else
          push(fn);
        break;
      }

      case 0x94: {  //  with
        const obj = pop();
        emit({ k: 'with', obj, body: decompile(ins.body, { names: ctx.names, declared: ctx.declared }) });
        break;
      }

      case 0x8F: {  //  try
        const opts = { names: ctx.names, declared: ctx.declared };
        emit({
          k: 'try',
          body: decompile(ins.body, opts),
          catchName: ins.hasCatch ? (ins.catchInRegister ? regName(ctx, ins.catchRegister) : ins.catchName) : null,
          catchBody: ins.hasCatch ? decompile(ins.catchBody, opts) : null,
          finallyBody: ins.hasFinally ? decompile(ins.finallyBody, opts) : null,
        });
        break;
      }

      //  ---- Timeline and movie clip actions

      case 0x04: emit(callStatement('nextFrame')); break;
      case 0x05: emit(callStatement('prevFrame')); break;
      case PLAY: emit(callStatement('play')); break;
      case 0x07: emit(callStatement('stop')); break;
      case 0x08: emit(callStatement('toggleHighQuality')); break;
      case 0x09: emit(callStatement('stopAllSounds')); break;
      case 0x28: emit(callStatement('stopDrag')); break;

      case 0x81:  //  gotoFrame: frames count from 0 here and from 1 in ActionScript
        if (list[i + 1] && list[i + 1].code === PLAY && i + 1 < hi) {
          emit(callStatement('gotoAndPlay', [lit(ins.frame + 1)]));
          i += 2;
          continue;
        }
        emit(callStatement('gotoAndStop', [lit(ins.frame + 1)]));
        break;

      case 0x9F:  //  gotoFrame2
        emit(callStatement(ins.play ? 'gotoAndPlay' : 'gotoAndStop', [pop()]));
        break;

      case 0x8C:  //  goToLabel
        emit(callStatement('gotoAndStop', [lit(ins.value)]));
        break;

      case 0x9E:  //  call
        emit(callStatement('call', [pop()]));
        break;

      case 0x83:  //  getURL
        emit(getURLStatement(ins.url, ins.window));
        break;

      case 0x9A: {  //  getURL2
        const target = pop();
        const url = pop();
        const method = ins.method === 'none' ? [] : [lit(ins.method)];
        if (ins.loadVariables)
          emit(callStatement(ins.loadTarget ? 'loadVariables' : 'loadVariablesNum', [url, target, ...method]));
        else if (ins.loadTarget)
          emit(callStatement('loadMovie', [url, target, ...method]));
        else
          emit(callStatement('getURL', [url, target, ...method]));
        break;
      }

      case 0x8B:  //  setTarget
        emit(callStatement('setTarget', [lit(ins.value)]));
        break;

      case 0x20:  //  setTarget2
        emit(callStatement('setTarget', [pop()]));
        break;

      case 0x8A:  //  waitForFrame
        emit({ k: 'raw', text: `ifFrameLoaded (${ins.frame + 1})  //  skips ${ins.skip} actions if not` });
        break;

      case 0x8D:  //  waitForFrame2
        emit({ k: 'raw', text: `ifFrameLoaded (${print(pop())})  //  skips ${ins.skip} actions if not` });
        break;

      case 0x27: {  //  startDrag: Flash 4 pushes the flags as the strings '0' and '1'
        const target = pop(), lock = pop(), constrain = pop();
        const args = [target, lock];
        if (constrain.k !== 'lit' || Number(constrain.v)) {
          const y2 = pop(), x2 = pop(), y1 = pop(), x1 = pop();
          args.push(x1, y1, x2, y2);
        }
        emit(callStatement('startDrag', args));
        break;
      }

      case 0x24: {  //  cloneSprite
        const depth = pop(), target = pop(), source = pop();
        emit(callStatement('duplicateMovieClip', [source, target, depth]));
        break;
      }

      case 0x2D:  //  fsCommand2
        emit(callStatement('fscommand2', popArgs(numberOf(pop()) ?? 0)));
        break;

      //  ---- Branches

      case IF: {
        const value = pop();
        const T = ins.target;
        const t = idx(T);

        if (t === undefined || T <= ins.offset) {
          emit({ k: 'if', cond: value, then: [goTo(T)], else: null });
          break;
        }

        //  value === a, if L1, value === b, if L2, jump L3: a switch from the SWF 6 compiler
        if (!exprMode && value.k === 'bin' && value.op === '===') {
          const sw = switchChain(ctx, i, hi, value, loop);
          if (sw) {
            emit(sw.stmt);
            i = sw.next;
            continue;
          }
        }

        //  c ? a : b   compiles as   c, if L1, b, jump L2, L1: a, L2:
        if (t - 1 > i && list[t - 1].code === JUMP && list[t - 1].target > T) {
          const e = idx(list[t - 1].target);
          if (e !== undefined && e <= hi) {
            const ifFalse = tryExpr(i + 1, t - 1);
            const ifTrue = ifFalse && tryExpr(t, e);
            if (ifFalse && ifTrue) {
              push({ k: 'cond', c: value, a: ifTrue, b: ifFalse });
              i = e;
              continue;
            }
          }
        }

        //  A branch out of this range: break, continue or goto
        if (t > hi) {
          emit({ k: 'if', cond: value, then: [jumpStatement(T)], else: null });
          break;
        }

        //  if (c) { a } else { b }   compiles as   c, not, if L1, a, jump L2, L1: b, L2:
        if (t - 1 > i && list[t - 1].code === JUMP && list[t - 1].target > T) {
          const E = list[t - 1].target;
          const e = idx(E);
          if (e !== undefined && e <= hi) {
            const a = block(ctx, i + 1, t - 1, [], loop, false);
            const b = block(ctx, t, e, [], loop, false);
            emit({ k: 'if', cond: negate(value), then: a.stmts, else: b.stmts });
            i = e;
            continue;
          }
        }

        //  if (c) { a }   compiles as   c, not, if L1, a, L1:
        const a = block(ctx, i + 1, t, [], loop, false);
        emit({ k: 'if', cond: negate(value), then: a.stmts, else: null });
        i = t;
        continue;
      }

      case JUMP:
        //  A jump to the next instruction, or to the end of this block, is the same as carrying on,
        //  unless it's a break
        if (ins.target === off(i + 1) || (ins.target === off(hi) && !(loop && ins.target === loop.brk)))
          break;
        emit(jumpStatement(ins.target));
        break;

      default:
        if (BINARY[ins.code]) {
          const b = pop();
          push(binary(BINARY[ins.code][0], pop(), b));
        } else if (BUILTIN_1[ins.code]) {
          const e = call(variable(BUILTIN_1[ins.code]), [pop()]);
          if (ins.code === 0x25 || ins.code === 0x26)
            emit({ k: 'expr', e });
          else
            push(e);
        } else {
          emit({ k: 'raw', text: `//  ${ins.name}` });
        }
    }
    i++;
  }

  return { stmts: out, stack };
}

//  A switch compiles as: the value, storeRegister r, then for each case push (r,) value, strictEquals,
//  if (case label), and finally a jump to the default case or past the end. Each break jumps to the end.
function switchAt(ctx, i, hi, disc, loop) {
  const { list, index } = ctx;
  const STRICT = 0x66;
  const r = list[i].register;
  const isCase = (k, first) => list[k] && list[k].code === PUSH && list[k + 1] && list[k + 1].code === STRICT &&
    list[k + 2] && list[k + 2].code === IF && list[k + 2].target > list[k + 2].offset &&
    (first ? list[k].values.length === 1
           : list[k].values.length === 2 && list[k].values[0].type === 'register' && list[k].values[0].value === r);
  if (!disc || !isCase(i + 1, true))
    return null;

  const cases = [];
  let k = i + 1;
  let first = true;
  while (isCase(k, first)) {
    const v = list[k].values[first ? 0 : 1];
    cases.push({ value: v.type === 'register' ? reg(ctx, v.value) : lit(v.value), target: list[k + 2].target });
    k += 3;
    first = false;
  }
  return buildSwitch(ctx, cases, k, hi, disc, loop);
}

//  The older compiler (SWF 6) evaluates the value again for each case: value === case, if (label).
//  This starts at the first if, with its comparison already popped.
function switchChain(ctx, i, hi, first, loop) {
  const { list } = ctx;
  const disc = first.a;
  const cases = [{ value: first.b, target: list[i].target }];
  let k = i + 1;
  while (k < hi && list[k].code !== JUMP) {
    let m = k;
    while (m < hi && list[m].code !== IF && list[m].code !== JUMP)
      m++;
    if (m >= hi || list[m].code !== IF || list[m].target <= list[m].offset)
      return null;
    let e = null;
    try {
      const r = block(ctx, k, m, [], loop, true);
      e = r.stack.length === 1 ? r.stack[0] : null;
    } catch (err) {
      if (!(err instanceof NotAnExpression))
        throw err;
    }
    if (!e || e.k !== 'bin' || e.op !== '===' || !same(e.a, disc))
      return null;
    cases.push({ value: e.b, target: list[m].target });
    k = m + 1;
  }
  return buildSwitch(ctx, cases, k, hi, disc, loop);
}

//  The case bodies follow the jump at index k, which goes to the default case or past the end
function buildSwitch(ctx, cases, k, hi, disc, loop) {
  const { list, index } = ctx;
  if (!list[k] || list[k].code !== JUMP || list[k].target <= list[k].offset)
    return null;
  const D = list[k].target;
  const bodyStart = k + 1;
  const off = (n) => (n < list.length ? list[n].offset : ctx.endOffset);
  const limit = off(hi);

  //  The end is where the breaks go: the furthest forward jump that stays inside this range
  const starts = [...cases.map((c) => c.target), D];
  const lastStart = Math.max(...starts);
  let E = lastStart;
  for (let m = bodyStart; m < hi && list[m].offset < E; m++)
    if (list[m].code === JUMP && list[m].target > E && list[m].target <= limit)
      E = list[m].target;
  if ([...starts, E].some((t) => !index.has(t)))
    return null;
  if (off(bodyStart) !== Math.min(...starts))
    return null;

  //  If nothing breaks past the default label, there is no default: D is the end
  const hasDefault = E > D;
  const end = hasDefault ? E : D;
  const inner = { brk: end, cont: loop ? loop.cont : undefined };

  //  Each body runs from its label to the next label; cases sharing a label share a body
  const labels = [...new Set([...cases.map((c) => c.target), ...(hasDefault ? [D] : [])])].sort((a, b) => a - b);
  const bodies = new Map();
  labels.forEach((L, n) => {
    const to = n + 1 < labels.length ? labels[n + 1] : end;
    bodies.set(L, block(ctx, index.get(L), index.get(to), [], inner, false).stmts);
  });

  const groups = labels.map((L) => ({
    values: cases.filter((c) => c.target === L).map((c) => c.value),
    isDefault: hasDefault && L === D,
    body: bodies.get(L),
  }));
  return { stmt: { k: 'switch', disc, groups }, next: index.get(end) };
}

//  A for..in loop starts storeRegister 0, push null, equals2, if (exit), with its backward jump at j.
//  If the body has a break, the break jumps to a little loop after the backward jump that pops the
//  names left on the stack (push null, equals2, not, if back to itself), and the real exit is after it.
function forInShape(list, h, j, off) {
  const isNull = (k) => list[k] && list[k].code === PUSH && list[k].values.length === 1 && list[k].values[0].type === 'null';
  if (!(list[h] && list[h].code === STORE && list[h].register === 0 && isNull(h + 1) &&
        list[h + 2] && list[h + 2].code === EQUALS2 && list[h + 3] && list[h + 3].code === IF))
    return null;
  const X = list[h + 3].target;
  if (X === off(j + 1))
    return { brk: X, next: j + 1 };
  if (isNull(j + 1) && list[j + 2] && list[j + 2].code === EQUALS2 && list[j + 3] && list[j + 3].code === NOT &&
      list[j + 4] && list[j + 4].code === IF && list[j + 4].target === off(j + 1) && X === off(j + 5))
    return { brk: off(j + 1), next: j + 5 };
  return null;
}

//  A while loop that sets a variable just before it and changes it at the end of its body is a for loop
function forLoop(out, cond, body) {
  const init = out[out.length - 1];
  const update = body[body.length - 1];
  const hasContinue = JSON.stringify(body).includes('"k":"continue"');
  if (init && update && !hasContinue && init.k === 'assign' && update.k === 'assign' &&
      same(init.target, update.target) && mentions(cond, init.target) && isStep(update)) {
    out.pop();
    return { k: 'for', init, cond, update, body: body.slice(0, -1) };
  }
  return { k: 'while', cond, body };
}

function isStep(s) {
  return s.value.k === 'bin' && ['+', '-', '*', '/'].includes(s.value.op) && same(s.value.a, s.target);
}

function mentions(e, target) {
  if (same(e, target))
    return true;
  for (const v of Object.values(e))
    if (v && typeof v === 'object' && !Array.isArray(v) && mentions(v, target))
      return true;
  return false;
}

function same(a, b) {
  return JSON.stringify(a) === JSON.stringify(b);
}

function hasEffect(e) {
  return e.k === 'call' || e.k === 'new' || e.k === 'assignx' || e.k === 'postfix' || (e.k === 'un' && e.op === 'delete ');
}

//  A register, named after the parameter or preloaded value it holds where there is one
function reg(ctx, n) {
  return { k: 'reg', n, name: ctx.names.get(n) };
}

function callStatement(name, args = []) {
  return { k: 'expr', e: call(variable(name), args) };
}

function getURLStatement(url, win) {
  if (url.startsWith('FSCommand:'))
    return callStatement('fscommand', [lit(url.slice(10)), lit(win)]);
  const level = /^_level(\d+)$/.exec(win);
  if (level && url === '')
    return callStatement('unloadMovieNum', [lit(Number(level[1]))]);
  if (level)
    return callStatement('loadMovieNum', [lit(url), lit(Number(level[1]))]);
  return callStatement('getURL', win ? [lit(url), lit(win)] : [lit(url)]);
}

//  A function definition, with its body decompiled in its own register context
function functionNode(ins, ctx) {
  const names = registerNames(ins);
  const declared = new Set([...names.keys()]);
  let list = ins.body;
  let locals = ins.code === 0x8E;
  if (ins.code === 0x9B) {
    const unsaved = unsaveRegisters(list, ins.params.map((p) => p.name), names);
    if (unsaved) {
      list = unsaved;
      locals = true;
      for (const n of names.keys())
        declared.add(n);
    }
  }
  const body = decompile(list, { names, declared, fn2: locals });
  return { k: 'fn', name: ins.fname, params: ins.params.map((p) => p.name), body };
}

//  The Flash MX compiler keeps locals in registers even in a plain defineFunction. It saves the
//  registers it uses on the stack (push r:1, r:2), copies this and the parameters into some of them,
//  and puts the saved values back at the end. Every return jumps to that shared ending with its value
//  on the stack:
//
//    L: storeRegister r:0, pop, storeRegister r:2, pop, storeRegister r:1, pop, push r:0, return
//
//  Taking the saving and restoring out, and turning each jump to L back into a return, leaves the
//  function as it was written. Registers holding this or a parameter get its name. Returns null if
//  the function isn't in this shape.
function unsaveRegisters(list, params, names) {
  const first = list[0];
  if (!first || first.code !== PUSH || !first.values.length ||
      !first.values.every((v) => v.type === 'register' && v.value > 0))
    return null;
  const saved = first.values.map((v) => v.value);
  const n = saved.length;
  let end = list.length;
  if (list[end - 1].code === 0x00)
    end--;

  //  The restores come last saved first: storeRegister, pop for each
  const restores = (k) => saved.every((r, m) => {
    const s = list[k + 2 * (n - 1 - m)];
    return s && s.code === STORE && s.register === r && list[k + 2 * (n - 1 - m) + 1].code === POP;
  });
  const isReg0 = (s) => s && s.code === PUSH && s.values.length === 1 && s.values[0].type === 'register' && s.values[0].value === 0;
  const returns = end >= 2 && isReg0(list[end - 2]) && list[end - 1].code === 0x3E;
  const E = returns ? end - 2 - 2 * n - 2 : end - 2 * n;
  if (E < 1 || !restores(returns ? E + 2 : E))
    return null;
  if (returns && !(list[E].code === STORE && list[E].register === 0 && list[E + 1].code === POP))
    return null;
  if (list.some((s) => s.target === first.offset))
    return null;

  const exit = list[E].offset;
  let body = list.slice(1, E);
  //  With return values, falling off the end pushes undefined for the shared ending to return
  const last = body[body.length - 1];
  if (returns && last && last.code === PUSH && last.values.length === 1 && last.values[0].type === 'undefined')
    body = body.slice(0, -1);
  if (returns)
    body = body.map((s) => (s.code === JUMP && s.target === exit ? { ...s, code: 0x3E, name: 'return' } : s));

  //  push 'this' (or a parameter), getVariable, storeRegister r, pop: the register stands for that name
  const assigned = (r, from) => body.some((s, k) => k >= from && s.code === STORE && s.register === r);
  const aliases = new Map();
  let k = 0;
  while (body[k] && body[k].code === PUSH && body[k].values.length === 1 && body[k].values[0].type === 'string' &&
         body[k + 1] && body[k + 1].code === 0x1C && body[k + 2] && body[k + 2].code === STORE &&
         body[k + 3] && body[k + 3].code === POP) {
    const name = body[k].values[0].value;
    const r = body[k + 2].register;
    if (!saved.includes(r) || names.has(r) || aliases.has(r) ||
        !(params.includes(name) || (name === 'this' && !assigned(r, k + 3))))
      break;
    aliases.set(r, name);
    k += 4;
  }
  if (body.slice(0, k).some((s) => body.some((t) => t.target === s.offset)))
    return null;
  for (const [r, name] of aliases)
    names.set(r, name);
  return body.slice(k);
}

function regName(ctx, n) {
  return ctx.names.get(n) ?? `r${n}`;
}

//  ---- Printing ----------------------------------------------------------------------------------------

const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;

//  Prints statements as ActionScript, indented by depth
export function printStatements(stmts, names = new Map(), depth = 0) {
  const lines = [];
  for (const s of stmts)
    printStatement(s, names, depth, lines);
  return lines.join('\n');
}

function printStatement(s, names, depth, lines) {
  const pad = '  '.repeat(depth);
  const p = (e, prec = 0) => print(e, names, prec, depth);
  const body = (stmts) => {
    for (const t of stmts)
      printStatement(t, names, depth + 1, lines);
  };

  switch (s.k) {
    case 'expr':
      lines.push(`${pad}${p(s.e)};`);
      break;

    case 'assign': {
      //  var a, b; compiles to registers set to undefined
      if (s.declare && s.value.k === 'lit' && s.value.v === undefined)
        lines.push(`${pad}var ${p(s.target)};`);
      else
        lines.push(`${pad}${s.declare ? 'var ' : ''}${printAssignment(s.target, s.value, names, depth)};`);
      break;
    }

    case 'declare':
      lines.push(`${pad}var ${p(s.target)};`);
      break;

    case 'return':
      lines.push(s.e && s.e.k !== 'missing' && !(s.e.k === 'lit' && s.e.v === undefined) ? `${pad}return ${p(s.e)};` : `${pad}return;`);
      break;

    case 'throw':
      lines.push(`${pad}throw ${p(s.e)};`);
      break;

    case 'if': {
      lines.push(`${pad}if (${p(s.cond)}) {`);
      body(s.then);
      let els = s.else;
      //  else { if ... } is written as else if
      while (els && els.length === 1 && els[0].k === 'if') {
        lines.push(`${pad}} else if (${p(els[0].cond)}) {`);
        body(els[0].then);
        els = els[0].else;
      }
      if (els && els.length) {
        lines.push(`${pad}} else {`);
        body(els);
      }
      lines.push(`${pad}}`);
      break;
    }

    case 'while':
      lines.push(`${pad}while (${p(s.cond)}) {`);
      body(s.body);
      lines.push(`${pad}}`);
      break;

    case 'dowhile':
      lines.push(`${pad}do {`);
      body(s.body);
      lines.push(`${pad}} while (${p(s.cond)});`);
      break;

    case 'for': {
      const init = `${s.init.declare ? 'var ' : ''}${printAssignment(s.init.target, s.init.value, names, depth)}`;
      const update = printAssignment(s.update.target, s.update.value, names, depth);
      lines.push(`${pad}for (${init}; ${p(s.cond)}; ${update}) {`);
      body(s.body);
      lines.push(`${pad}}`);
      break;
    }

    case 'forin':
      lines.push(`${pad}for (${s.declare ? 'var ' : ''}${p(s.target)} in ${p(s.obj)}) {`);
      body(s.body);
      lines.push(`${pad}}`);
      break;

    case 'with':
      lines.push(`${pad}with (${p(s.obj)}) {`);
      body(s.body);
      lines.push(`${pad}}`);
      break;

    case 'try':
      lines.push(`${pad}try {`);
      body(s.body);
      if (s.catchBody) {
        lines.push(`${pad}} catch (${s.catchName}) {`);
        body(s.catchBody);
      }
      if (s.finallyBody) {
        lines.push(`${pad}} finally {`);
        body(s.finallyBody);
      }
      lines.push(`${pad}}`);
      break;

    case 'function': {
      const fn = s.fn;
      lines.push(`${pad}function ${fn.name}(${fn.params.join(', ')}) {`);
      const inner = fnNames(fn);
      for (const t of fn.body)
        printStatement(t, inner, depth + 1, lines);
      lines.push(`${pad}}`);
      break;
    }

    case 'switch':
      lines.push(`${pad}switch (${p(s.disc)}) {`);
      for (const g of s.groups) {
        for (const v of g.values)
          lines.push(`${pad}  case ${p(v)}:`);
        if (g.isDefault)
          lines.push(`${pad}  default:`);
        for (const t of g.body)
          printStatement(t, names, depth + 2, lines);
      }
      lines.push(`${pad}}`);
      break;

    case 'extends':
      lines.push(`${pad}${p(s.sub)} extends ${p(s.superclass)};`);
      break;

    case 'implements':
      lines.push(`${pad}${p(s.ctor)} implements ${s.interfaces.map((e) => p(e)).join(', ')};`);
      break;

    case 'break':
      lines.push(`${pad}break;`);
      break;
    case 'continue':
      lines.push(`${pad}continue;`);
      break;
    case 'goto':
      lines.push(`${pad}goto ${labelName(s.target)};`);
      break;
    case 'label':
      lines.push(`${'  '.repeat(Math.max(0, depth - 1))}${labelName(s.target)}:`);
      break;
    case 'raw':
      lines.push(`${pad}${s.text}`);
      break;
    default:
      lines.push(`${pad}//  ${s.k}`);
  }
}

//  Assignments are written as x++, x += y and so on where they can be
function printAssignment(target, value, names, depth) {
  const t = print(target, names, 0, depth);
  if (value.k === 'bin' && same(value.a, target)) {
    if (value.b.k === 'lit' && value.b.v === 1 && (value.op === '+' || value.op === '-'))
      return `${t}${value.op}${value.op}`;
    if (['+', '-', '*', '/', '%', '&', '|', '^', '<<', '>>', '>>>'].includes(value.op))
      return `${t} ${value.op}= ${print(value.b, names, 1, depth)}`;
  }
  return `${t} = ${print(value, names, 1, depth)}`;
}

function fnNames(fn) {
  return fn.names || new Map();
}

export function labelName(offset) {
  return `label_${offset.toString(16).toUpperCase()}`;
}

//  Prints an expression. prec is the precedence of the context, so brackets go in only where needed.
export function print(e, names = new Map(), prec = 0, depth = 0) {
  const p = (x, q) => print(x, names, q, depth);
  const wrap = (mine, text) => (mine < prec ? `(${text})` : text);

  switch (e.k) {
    case 'lit':
      return literal(e.v);
    case 'reg':
      return e.name ?? `r${e.n}`;
    case 'var':
      return e.name;
    case 'eval':
      return `eval(${p(e.e, 0)})`;
    case 'missing':
      return '/*?*/';
    case 'mem': {
      const obj = p(e.obj, 14);
      if (e.prop.k === 'lit' && typeof e.prop.v === 'string' && IDENTIFIER.test(e.prop.v))
        return `${obj}.${e.prop.v}`;
      return `${obj}[${p(e.prop, 0)}]`;
    }
    case 'prop':
      //  An empty target is the timeline the code is on
      if (e.target.k === 'lit' && e.target.v === '')
        return e.name;
      return `${p(e.target, 14)}.${e.name}`;
    case 'call': {
      const fn = e.fn.k === 'fn' ? `(${p(e.fn, 0)})` : p(e.fn, 14);
      return `${fn}(${e.args.map((a) => p(a, 1)).join(', ')})`;
    }
    case 'new':
      return wrap(14, `new ${p(e.fn, 15)}(${e.args.map((a) => p(a, 1)).join(', ')})`);
    case 'bin': {
      //  -x compiles as 0 - x
      if (e.op === '-' && e.a.k === 'lit' && e.a.v === 0)
        return wrap(13, `-${p(e.b, 13)}`);
      const q = PRECEDENCE[e.op] ?? 9;
      return wrap(q, `${p(e.a, q)} ${e.op} ${p(e.b, q + 1)}`);
    }
    case 'un':
      return wrap(13, `${e.op}${p(e.a, 13)}`);
    case 'logic': {
      const q = PRECEDENCE[e.op];
      return wrap(q, `${p(e.a, q)} ${e.op} ${p(e.b, q + 1)}`);
    }
    case 'cond':
      return wrap(2, `${p(e.c, 3)} ? ${p(e.a, 2)} : ${p(e.b, 2)}`);
    case 'assignx': {
      //  Used as a value, x = x + 1 is ++x
      const v = e.value;
      if (v.k === 'bin' && (v.op === '+' || v.op === '-') && v.b.k === 'lit' && v.b.v === 1 && same(v.a, e.target))
        return wrap(13, `${v.op}${v.op}${p(e.target, 13)}`);
      return wrap(1, printAssignment(e.target, e.value, names, depth));
    }
    case 'postfix':
      return wrap(14, `${p(e.target, 14)}${e.op}`);
    case 'array':
      return `[${e.items.map((a) => p(a, 1)).join(', ')}]`;
    case 'object':
      if (!e.pairs.length)
        return '{}';
      return `{${e.pairs.map(([k, v]) => `${k.k === 'lit' && IDENTIFIER.test(String(k.v)) ? k.v : p(k, 0)}: ${p(v, 1)}`).join(', ')}}`;
    case 'fn': {
      const lines = [];
      const inner = fnNames(e);
      for (const t of e.body)
        printStatement(t, inner, depth + 1, lines);
      const head = `function${e.name ? ' ' + e.name : ''}(${e.params.join(', ')}) {`;
      return lines.length ? `${head}\n${lines.join('\n')}\n${'  '.repeat(depth)}}` : `${head}}`;
    }
    case 'enum':
      return `/* enumerate ${p(e.obj, 0)} */`;
    default:
      return `/* ${e.k} */`;
  }
}

function literal(v) {
  if (v === undefined)
    return 'undefined';
  if (v === null)
    return 'null';
  if (typeof v === 'string')
    return JSON.stringify(v);
  if (typeof v === 'number') {
    if (Number.isInteger(v))
      return String(v);
    //  32-bit floats print with a long tail: keep the digits a person would have typed
    const short = Number(v.toPrecision(7));
    return String(Math.fround(short) === Math.fround(v) && Math.abs(short - v) < 1e-6 * Math.abs(v) ? short : v);
  }
  return String(v);
}