swfdis.js

JavaScript source for the SWF disassembler, 88 lines: the command line, which reads a SWF and prints its scripts as ActionScript, as disassembly, or both. It's bin/swfdis.js in swf-disassembler.zip.

#!/usr/bin/env node
//  swfdis: disassembles and decompiles the ActionScript 1 and 2 in a SWF file.
//
//  swfdis movie.swf              decompiled ActionScript for every script
//  swfdis movie.swf --asm        AVM1 disassembly instead
//  swfdis movie.swf --both       each script as ActionScript, then as disassembly
//  swfdis movie.swf --info       the header, the tags and where the scripts are
//  swfdis movie.swf --out dir    one .as file (and with --asm or --both, one .asm file) per script
//  swfdis movie.swf --only 3     just script 3 (numbered as --info lists them)

import fs from 'node:fs';
import path from 'node:path';
import { parse } from '../lib/swf.js';
import { decode, hex } from '../lib/actions.js';
import { disassemble } from '../lib/disassemble.js';
import { decompile, printStatements } from '../lib/decompile.js';

const args = process.argv.slice(2);
const flag = (name) => args.includes(name);
const option = (name) => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const file = args.find((a, i) => !a.startsWith('--') && !['--out', '--only'].includes(args[i - 1]));

if (!file) {
  console.error('usage: swfdis <movie.swf> [--asm | --both | --info] [--out dir] [--only n]');
  process.exit(1);
}

const swf = parse(fs.readFileSync(file));
const only = option('--only') !== undefined ? Number(option('--only')) : null;
const outDir = option('--out');

if (flag('--info')) {
  console.log(`${path.basename(file)}: SWF ${swf.version}${swf.compressed ? ', compressed' : ''}, ${swf.width}x${swf.height}, ${swf.frameRate} fps, ${swf.frameCount} frames`);
  if (swf.as3)
    console.log('contains ActionScript 3 (DoABC), which this tool does not decode');
  const counts = new Map();
  (function count(tags) {
    for (const t of tags) {
      counts.set(t.name, (counts.get(t.name) || 0) + 1);
      if (t.sprite)
        count(t.sprite.tags);
    }
  })(swf.tags);
  console.log('\ntags: ' + [...counts].sort((a, b) => b[1] - a[1]).map(([n, c]) => `${n} ${c}`).join(', '));
  if (swf.exports.size)
    console.log('\nexported symbols: ' + [...swf.exports].map(([id, n]) => `${id} ${n}`).join(', '));
  if (swf.labels.length)
    console.log('\nframe labels: ' + swf.labels.map((l) => `${l.sprite === null ? '' : `sprite ${l.sprite} `}frame ${l.frame} "${l.name}"`).join(', '));
  console.log('\nscripts:');
  swf.scripts.forEach((s, n) => console.log(`  ${String(n).padStart(3)}  ${s.where}  (${s.end - s.start} bytes at ${hex(s.start, 6)})`));
  process.exit(0);
}

if (outDir)
  fs.mkdirSync(outDir, { recursive: true });

swf.scripts.forEach((script, n) => {
  if (only !== null && n !== only)
    return;
  const list = decode(script.buf, script.start, script.end, swf.version);
  const title = `//  ${n}: ${script.where}${script.kind === 'init' && swf.exports.has(script.sprite) ? ` (${swf.exports.get(script.sprite)})` : ''}`;

  const parts = [];
  if (!flag('--asm') || flag('--both'))
    parts.push(['as', `${title}\n\n${actionScript(script, list)}\n`]);
  if (flag('--asm') || flag('--both'))
    parts.push(['asm', `;  ${n}: ${script.where}\n\n${disassemble(list, script.start)}\n`]);

  for (const [ext, text] of parts) {
    if (outDir) {
      const name = `${String(n).padStart(3, '0')} ${script.where.replace(/[^\w #,()-]+/g, '_').slice(0, 80)}.${ext}`;
      fs.writeFileSync(path.join(outDir, name), text);
    } else {
      console.log(text);
    }
  }
});

//  A script's statements, inside its on() or onClipEvent() handler if it has one
function actionScript(script, list) {
  const stmts = decompile(list);
  if (!script.handler)
    return printStatements(stmts);
  return `${script.handler} {\n${printStatements(stmts, new Map(), 1)}\n}`;
}