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));
}