swf.js

JavaScript source for the SWF disassembler, 342 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).

var zlib = require('zlib');
var Reader = require('./reader').Reader;

var 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)
var 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
var 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 calls back with the whole file uncompressed
function inflate(buf, callback) {
  var sig = buf.toString('binary', 0, 3);
  if (sig === 'FWS')
    return callback(null, buf);
  if (sig === 'CWS') {
    return zlib.inflate(buf.slice(8), function (err, body) {
      if (err)
        return callback(err);
      var out = Buffer.concat([buf.slice(0, 8), body]);
      out[0] = 0x46;          //  F: it isn't compressed any more
      callback(null, out);
    });
  }
  if (sig === 'ZWS')
    return callback(new Error('LZMA-compressed SWF (ZWS) is not supported: these are ActionScript 3 files'));
  callback(new Error('not a SWF file (signature ' + JSON.stringify(sig) + ')'));
}

//  Parses a SWF into its header, its tags and the scripts inside them, and calls back with it
function parse(input, callback) {
  inflate(input, function (err, buf) {
    if (err)
      return callback(err);
    var swf;
    try {
      swf = read(buf, input.toString('binary', 0, 1) === 'C');
    } catch (e) {
      return callback(e);
    }
    callback(null, swf);
  });
}

function read(buf, compressed) {
  var r = new Reader(buf);
  r.skip(3);

  var swf = {
    version: r.u8(),
    fileLength: r.u32(),
    compressed: compressed,
    tags: [],
    scripts: [],
    exports: {},              //  character id -> linkage name, from ExportAssets
    exportIds: [],            //  the ids in the order they were exported
    labels: [],
    sprites: {},              //  character id -> { frameCount, tags }
    as3: false
  };
  r.utf8 = swf.version >= 6;

  var frame = r.rect();
  swf.width = (frame.xmax - frame.xmin) / 20;
  swf.height = (frame.ymax - frame.ymin) / 20;
  var 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) {
  var tags = [];
  var frame = 1;
  var id, count, i;

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

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

    var where = sprite === null ? 'frame ' + frame : 'sprite ' + sprite + ' frame ' + frame;
    var 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: sprite, frame: frame });
        break;

      case 59:  //  DoInitAction
        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
        id = body.u16();
        var s = { id: id, frameCount: body.u16(), tags: [] };
        swf.sprites[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
        count = body.u16();
        for (i = 0; i < count; i++) {
          id = body.u16();
          if (!swf.exports.hasOwnProperty(id))
            swf.exportIds.push(id);
          swf.exports[id] = body.string();
        }
        break;

      case 43:  //  FrameLabel
        swf.labels.push({ sprite: sprite, frame: 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) {
  var script = { kind: kind, where: where, buf: buf, start: start, end: end };
  for (var key in extra)
    script[key] = extra[key];
  swf.scripts.push(script);
}

//  DefineButton: button records up to a zero flags byte, then one set of actions for release
function readButton1(r, swf, end) {
  var 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) {
  var id = r.u16();
  r.u8();                       //  track as menu
  var start = r.pos;
  var actionOffset = r.u16();
  if (actionOffset === 0)
    return;

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

    var names = [];
    BUTTON_CONDITIONS.forEach(function (c) {
      if (conditions & c[0] && names.indexOf(c[1]) < 0)
        names.push(c[1]);
    });
    var key = conditions >> 9;
    if (key)
      names.push('keyPress ' + keyName(key));
    var handler = 'on (' + names.join(', ') + ')';
    addScript(swf, 'button', 'button ' + id + ' ' + handler, r.buf, r.pos, recordEnd, { button: id, handler: 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) {
  var flags = r.u8();
  var flags2 = code === 70 ? r.u8() : 0;
  if (!(flags & 0x80))
    return;

  var depth = r.u16();
  if (code === 70 && (flags2 & 0x08 || (flags2 & 0x10 && flags & 0x02)))
    r.string();                 //  class name
  var character = flags & 0x02 ? r.u16() : null;
  if (flags & 0x04)
    r.matrix();
  if (flags & 0x08)
    r.cxform(true);
  if (flags & 0x10)
    r.u16();                    //  ratio
  var 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
  }

  var wide = swf.version >= 6;
  r.u16();                      //  reserved
  if (wide)                     //  all the events used
    r.u32();
  else
    r.u16();

  var clip = [];
  if (character !== null)
    clip.push('character ' + character);
  if (name)
    clip.push('"' + name + '"');
  clip.push('depth ' + depth);
  clip = clip.join(' ');

  while (r.pos < end) {
    var events = wide ? r.u32() : r.u16();
    if (events === 0)
      break;
    var size = r.u32();
    var recordEnd = r.pos + size;
    var key = '';
    if (events & 0x00020000)
      key = ' ' + keyName(r.u8());
    var names = [];
    CLIP_EVENTS.forEach(function (c) {
      if (events & c[0])
        names.push(c[1] === 'keyPress' ? c[1] + key : c[1]);
    });
    var handler = 'onClipEvent (' + names.join(', ') + ')';
    addScript(swf, 'clip', where + ', clip ' + clip + ' ' + handler, r.buf, r.pos, recordEnd, { depth: depth, name: name, handler: handler });
    r.pos = recordEnd;
  }
}

//  PlaceObject3 filter lists: each filter has its own fixed or counted size
function skipFilters(r) {
  var count = r.u8();
  for (var i = 0; i < count; i++) {
    var 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: r.skip(r.u8() * 5 + 19); break;         //  gradient bevel
      case 5:                                         //  convolution
        var x = r.u8();
        var y = r.u8();
        r.skip(8 + x * y * 4 + 5);
        break;
      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) {
  var 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));
}

exports.TAG_NAMES = TAG_NAMES;
exports.CLIP_EVENTS = CLIP_EVENTS;
exports.inflate = inflate;
exports.parse = parse;