swf.js

JavaScript source for the SWF disassembler, 295 lines: decompressing a SWF, reading its header and tags, and finding every script in frames, sprites, buttons and clip events. It's lib/swf.js in swf-disassembler.zip.

//  Reads a SWF file: the header, the tags, the sprites nested inside them, and every place a script
//  can live (frame actions, #initclip blocks, button events and clip events).

import zlib from 'node:zlib';
import { Reader } from './reader.js';

export const TAG_NAMES = {
  0: 'End', 1: 'ShowFrame', 2: 'DefineShape', 4: 'PlaceObject', 5: 'RemoveObject', 6: 'DefineBits',
  7: 'DefineButton', 8: 'JPEGTables', 9: 'SetBackgroundColor', 10: 'DefineFont', 11: 'DefineText',
  12: 'DoAction', 13: 'DefineFontInfo', 14: 'DefineSound', 15: 'StartSound', 17: 'DefineButtonSound',
  18: 'SoundStreamHead', 19: 'SoundStreamBlock', 20: 'DefineBitsLossless', 21: 'DefineBitsJPEG2',
  22: 'DefineShape2', 23: 'DefineButtonCxform', 24: 'Protect', 26: 'PlaceObject2', 28: 'RemoveObject2',
  32: 'DefineShape3', 33: 'DefineText2', 34: 'DefineButton2', 35: 'DefineBitsJPEG3',
  36: 'DefineBitsLossless2', 37: 'DefineEditText', 39: 'DefineSprite', 41: 'ProductInfo',
  43: 'FrameLabel', 45: 'SoundStreamHead2', 46: 'DefineMorphShape', 48: 'DefineFont2',
  56: 'ExportAssets', 57: 'ImportAssets', 58: 'EnableDebugger', 59: 'DoInitAction',
  60: 'DefineVideoStream', 61: 'VideoFrame', 62: 'DefineFontInfo2', 63: 'DebugID', 64: 'EnableDebugger2',
  65: 'ScriptLimits', 66: 'SetTabIndex', 69: 'FileAttributes', 70: 'PlaceObject3', 71: 'ImportAssets2',
  73: 'DefineFontAlignZones', 74: 'CSMTextSettings', 75: 'DefineFont3', 76: 'SymbolClass',
  77: 'Metadata', 78: 'DefineScalingGrid', 82: 'DoABC', 83: 'DefineShape4', 84: 'DefineMorphShape2',
  86: 'DefineSceneAndFrameLabelData', 87: 'DefineBinaryData', 88: 'DefineFontName', 89: 'StartSound2',
  90: 'DefineBitsJPEG4', 91: 'DefineFont4',
};

//  The events a clip can have actions for, by bit in the 32-bit event flags (SWF 6 and later)
export const CLIP_EVENTS = [
  [0x00000001, 'load'], [0x00000002, 'enterFrame'], [0x00000004, 'unload'], [0x00000008, 'mouseMove'],
  [0x00000010, 'mouseDown'], [0x00000020, 'mouseUp'], [0x00000040, 'keyDown'], [0x00000080, 'keyUp'],
  [0x00000100, 'data'], [0x00000200, 'initialize'], [0x00000400, 'press'], [0x00000800, 'release'],
  [0x00001000, 'releaseOutside'], [0x00002000, 'rollOver'], [0x00004000, 'rollOut'],
  [0x00008000, 'dragOver'], [0x00010000, 'dragOut'], [0x00020000, 'keyPress'], [0x00040000, 'construct'],
];

//  The button state changes a DefineButton2 action can be attached to
const BUTTON_CONDITIONS = [
  [0x0001, 'rollOver'], [0x0002, 'rollOut'], [0x0004, 'press'], [0x0008, 'release'],
  [0x0010, 'dragOut'], [0x0020, 'dragOver'], [0x0040, 'releaseOutside'], [0x0080, 'dragOver'],
  [0x0100, 'dragOut'],
];

//  Decompresses a SWF if it needs it, and returns the whole file uncompressed
export function inflate(buf) {
  const sig = buf.toString('latin1', 0, 3);
  if (sig === 'FWS')
    return buf;
  if (sig === 'CWS')
    return Buffer.concat([Buffer.from('FWS'), buf.subarray(3, 8), zlib.inflateSync(buf.subarray(8))]);
  if (sig === 'ZWS')
    throw new Error('LZMA-compressed SWF (ZWS) is not supported: these are ActionScript 3 files');
  throw new Error(`not a SWF file (signature ${JSON.stringify(sig)})`);
}

//  Parses a SWF into its header, its tags and the scripts inside them
export function parse(input) {
  const buf = inflate(input);
  const r = new Reader(buf);
  r.skip(3);

  const swf = {
    version: r.u8(),
    fileLength: r.u32(),
    compressed: input.toString('latin1', 0, 1) === 'C',
    tags: [],
    scripts: [],
    exports: new Map(),       //  character id -> linkage name, from ExportAssets
    labels: [],
    sprites: new Map(),       //  character id -> { frameCount, tags }
    as3: false,
  };
  r.utf8 = swf.version >= 6;

  const frame = r.rect();
  swf.width = (frame.xmax - frame.xmin) / 20;
  swf.height = (frame.ymax - frame.ymin) / 20;
  const fraction = r.u8();
  swf.frameRate = r.u8() + fraction / 256;
  swf.frameCount = r.u16();

  swf.tags = readTags(r, buf.length, swf, null);
  return swf;
}

//  Reads tags until an End tag, collecting scripts as it goes. sprite is the DefineSprite they're in.
function readTags(r, end, swf, sprite) {
  const tags = [];
  let frame = 1;

  while (r.pos < end) {
    const header = r.u16();
    const code = header >> 6;
    let length = header & 0x3f;
    if (length === 0x3f)
      length = r.u32();

    const start = r.pos;
    const tag = { code, name: TAG_NAMES[code] || `Tag${code}`, offset: start, length, frame };
    tags.push(tag);

    const where = sprite === null ? `frame ${frame}` : `sprite ${sprite} frame ${frame}`;
    const body = new Reader(r.buf, start, start + length);
    body.utf8 = r.utf8;

    switch (code) {
      case 1:   //  ShowFrame
        frame++;
        break;

      case 12:  //  DoAction
        addScript(swf, 'frame', where, body.buf, start, start + length, { sprite, frame });
        break;

      case 59: {  //  DoInitAction
        const id = body.u16();
        addScript(swf, 'init', `#initclip sprite ${id}`, body.buf, body.pos, start + length, { sprite: id, initclip: true });
        break;
      }

      case 39: {  //  DefineSprite: its own timeline of tags
        const id = body.u16();
        const frameCount = body.u16();
        const s = { id, frameCount, tags: [] };
        swf.sprites.set(id, s);
        s.tags = readTags(body, start + length, swf, id);
        tag.sprite = s;
        break;
      }

      case 7:   //  DefineButton
        readButton1(body, swf, start + length);
        break;

      case 34:  //  DefineButton2
        readButton2(body, swf, start + length);
        break;

      case 26:  //  PlaceObject2
      case 70:  //  PlaceObject3
        readPlaceObject(body, swf, code, where, start + length);
        break;

      case 56: {  //  ExportAssets: linkage names, which are also the names of AS2 classes
        const count = body.u16();
        for (let i = 0; i < count; i++) {
          const id = body.u16();
          swf.exports.set(id, body.string());
        }
        break;
      }

      case 43:  //  FrameLabel
        swf.labels.push({ sprite, frame, name: body.string() });
        break;

      case 82:  //  DoABC
      case 72:
        swf.as3 = true;
        break;
    }

    r.pos = start + length;
    if (code === 0)
      break;
  }

  return tags;
}

function addScript(swf, kind, where, buf, start, end, extra = {}) {
  swf.scripts.push({ kind, where, buf, start, end, ...extra });
}

//  DefineButton: button records up to a zero flags byte, then one set of actions for release
function readButton1(r, swf, end) {
  const id = r.u16();
  while (r.u8() !== 0) {
    r.u16();      //  character id
    r.u16();      //  depth
    r.matrix();
  }
  addScript(swf, 'button', `button ${id} on (release)`, r.buf, r.pos, end, { button: id, handler: 'on (release)' });
}

//  DefineButton2: button records, then a list of actions, each with the conditions that run it
function readButton2(r, swf, end) {
  const id = r.u16();
  r.u8();                       //  track as menu
  const start = r.pos;
  const actionOffset = r.u16();
  if (actionOffset === 0)
    return;

  r.pos = start + actionOffset;
  while (true) {
    const recordStart = r.pos;
    const size = r.u16();
    const conditions = r.u16();
    const recordEnd = size === 0 ? end : recordStart + size;

    const names = BUTTON_CONDITIONS.filter(([bit]) => conditions & bit).map(([, n]) => n);
    const key = conditions >> 9;
    if (key)
      names.push(`keyPress ${keyName(key)}`);
    const handler = `on (${[...new Set(names)].join(', ')})`;
    addScript(swf, 'button', `button ${id} ${handler}`, r.buf, r.pos, recordEnd, { button: id, handler });

    if (size === 0)
      break;
    r.pos = recordEnd;
  }
}

//  PlaceObject2 and PlaceObject3: step over the placement fields to reach the clip actions
function readPlaceObject(r, swf, code, where, end) {
  const flags = r.u8();
  const flags2 = code === 70 ? r.u8() : 0;
  if (!(flags & 0x80))
    return;

  const depth = r.u16();
  if (code === 70 && (flags2 & 0x08 || (flags2 & 0x10 && flags & 0x02)))
    r.string();                 //  class name
  const character = flags & 0x02 ? r.u16() : null;
  if (flags & 0x04)
    r.matrix();
  if (flags & 0x08)
    r.cxform(true);
  if (flags & 0x10)
    r.u16();                    //  ratio
  const name = flags & 0x20 ? r.string() : null;
  if (flags & 0x40)
    r.u16();                    //  clip depth

  if (code === 70) {
    if (flags2 & 0x01)
      skipFilters(r);
    if (flags2 & 0x02)
      r.u8();                   //  blend mode
    if (flags2 & 0x04)
      r.u8();                   //  cache as bitmap
    if (flags2 & 0x20)
      r.u8();                   //  visible
    if (flags2 & 0x40)
      r.u32();                  //  background colour
  }

  const wide = swf.version >= 6;
  r.u16();                      //  reserved
  wide ? r.u32() : r.u16();     //  all the events used

  const clip = [character !== null ? `character ${character}` : null, name ? `"${name}"` : null, `depth ${depth}`]
    .filter(Boolean).join(' ');

  while (r.pos < end) {
    const events = wide ? r.u32() : r.u16();
    if (events === 0)
      break;
    const size = r.u32();
    const recordEnd = r.pos + size;
    let key = '';
    if (events & 0x00020000) {
      key = ` ${keyName(r.u8())}`;
    }
    const names = CLIP_EVENTS.filter(([bit]) => events & bit).map(([, n]) => n === 'keyPress' ? n + key : n);
    const handler = `onClipEvent (${names.join(', ')})`;
    addScript(swf, 'clip', `${where}, clip ${clip} ${handler}`, r.buf, r.pos, recordEnd, { depth, name, handler });
    r.pos = recordEnd;
  }
}

//  PlaceObject3 filter lists: each filter has its own fixed or counted size
function skipFilters(r) {
  const count = r.u8();
  for (let i = 0; i < count; i++) {
    const id = r.u8();
    switch (id) {
      case 0: r.skip(23); break;                      //  drop shadow
      case 1: r.skip(9); break;                       //  blur
      case 2: r.skip(15); break;                      //  glow
      case 3: r.skip(27); break;                      //  bevel
      case 4:                                         //  gradient glow
      case 7: { const n = r.u8(); r.skip(n * 5 + 19); break; }   //  gradient bevel
      case 5: { const x = r.u8(); const y = r.u8(); r.skip(8 + x * y * 4 + 5); break; }   //  convolution
      case 6: r.skip(80); break;                      //  colour matrix
      default: throw new Error(`unknown filter ${id}`);
    }
  }
}

//  Key codes used by on (keyPress "...") handlers
function keyName(code) {
  const special = { 1: '<Left>', 2: '<Right>', 3: '<Home>', 4: '<End>', 5: '<Insert>', 6: '<Delete>',
    8: '<Backspace>', 13: '<Enter>', 14: '<Up>', 15: '<Down>', 16: '<PageUp>', 17: '<PageDown>',
    18: '<Tab>', 19: '<Escape>', 32: '<Space>' };
  return JSON.stringify(special[code] || String.fromCharCode(code));
}