A Flash movie doesn't keep its ActionScript as text. The Flash compiler turns it into bytecode for a little stack machine called AVM1, and that's what goes in the SWF and what the player runs. This reads a SWF, finds all of its scripts, and turns them back into ActionScript. It's what the code in the CubeLogix 3D website article came from.
It's plain JavaScript with no dependencies, and it covers everything from Flash 3 to Flash 8. I wrote it with Claude Code, testing it against hundreds of my own old movies as we went. Let's see how it works.
Using it
node bin/swfdis.js movie.swf # decompiled ActionScript for every script
node bin/swfdis.js movie.swf --asm # AVM1 disassembly instead
node bin/swfdis.js movie.swf --both # each script as ActionScript, then as disassembly
node bin/swfdis.js movie.swf --info # the header, the tags and where the scripts are
node bin/swfdis.js movie.swf --out dir # one file per script
node bin/swfdis.js movie.swf --only 3 # just script 3
--info is the place to start. For the CubeLogix site it shows a Flash 8 movie with its 21 scripts spread across the sprites inside it:
cubelogix.swf: SWF 8, compressed, 800x400, 120 fps, 1 frames
tags: PlaceObject2 154, ShowFrame 73, DefineText 59, End 44, DefineSprite 43, DefineShape 26, DoAction 12, ...
exported symbols: 11 window_services, 67 window_case, 96 window_contact
scripts:
0 sprite 46 frame 1 (562 bytes at 0050B4)
1 sprite 46 frame 1, clip character 23 depth 17 onClipEvent (press) (40 bytes at 0053A3)
...
16 sprite 101 frame 1 (55737 bytes at 009C6B)
...
Inside a SWF
A SWF starts with three letters: FWS for a plain file, or CWS for one where everything after the first 8 bytes has been squashed with zlib. (ZWS is LZMA, but that only appeared with Flash 11, which is ActionScript 3 anyway.) Unsquashing it gives a plain file back:
if (sig === 'CWS')
return Buffer.concat([Buffer.from('FWS'), buf.subarray(3, 8), zlib.inflateSync(buf.subarray(8))]);
Then comes the version, the file's length, and the size of the stage, which is stored as a rectangle in bit fields: 5 bits saying how wide the fields are, then four signed fields that wide, in twips, which are twentieths of a pixel. After that, the frame rate as 8.8 fixed point, and the number of frames:
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();
Everything else is tags. Each starts with 16 bits: the top 10 are the tag's code and the bottom 6 its length, and a length of 63 means the real length follows in 32 bits. So the reader can always step over a tag it doesn't care about:
const header = r.u16();
const code = header >> 6;
let length = header & 0x3f;
if (length === 0x3f)
length = r.u32();
Scripts turn up in five kinds of tag:
| Tag | The script |
|---|---|
| DoAction (12) | Frame actions, on the main timeline or inside a sprite |
| DoInitAction (59) | #initclip blocks, which run before a symbol is first used. ActionScript 2 classes compile to these |
| DefineButton (7) | One on (release) handler |
| DefineButton2 (34) | on (...) handlers, each with the changes of state that run it |
| PlaceObject2 (26), PlaceObject3 (70) | onClipEvent (...) handlers on a movie clip placed on a timeline |
A sprite (DefineSprite, 39) holds a whole timeline of tags of its own, so the reader goes into it the same way. The placement tags are the fiddliest, because the clip events come after the placement's matrix, its colour transform, its name and, in Flash 8, its filters and blend mode, all of which have to be stepped over first.
The instructions
An AVM1 instruction (Flash calls them actions) is one byte if its code is below 0x80. From 0x80 up, the code is followed by a 16-bit length and that many bytes of operands, so again, anything unknown can be stepped over.
Most of them work on a stack. push puts values on it, add2 takes two off and puts their sum back, getVariable takes a name off and puts back its value, and so on. Branches, jump and if, have a signed 16-bit distance from the end of the instruction.
A script usually starts with a constantPool, a list of every string it uses. After that a push can refer to a string by its number, in one byte for the first 256 and two after that. A push can hold several values, each with a type byte in front:
| Type | Value |
|---|---|
| 0 | A string, ending in a zero |
| 1 | A 32-bit float |
| 2, 3 | null, undefined |
| 4 | A register, by number |
| 5 | A boolean, in one byte |
| 6 | A 64-bit double, with its two halves the wrong way round |
| 7 | A 32-bit integer |
| 8, 9 | A string from the constant pool, by a one or two byte number |
The doubles really are the wrong way round. Each half is little-endian, but the high half comes first:
f64swapped() {
this.check(8);
const b = Buffer.concat([this.buf.subarray(this.pos + 4, this.pos + 8), this.buf.subarray(this.pos, this.pos + 4)]);
this.pos += 8;
return b.readDoubleLE(0);
}
Functions and registers
A function's code follows its defineFunction instruction, so the decoder reads the body into a list of its own, and the result is a tree. Flash 7 added defineFunction2, which gives a function up to 255 registers of its own. Its parameters can go straight into registers, and so can this, arguments, super, _root, _parent and _global, if the function uses them. Those take registers from 1 up, in that order, and the rest are free for local variables:
export function registerNames(fn) {
const names = new Map();
if (fn.code !== 0x8E)
return names;
let next = 1;
for (const p of PRELOAD_ORDER)
if (fn.preload[p])
names.set(next++, PRELOAD_NAMES[p]);
for (const p of fn.params)
if (p.register)
names.set(p.register, p.name);
return names;
}
The Flash 8 compiler puts every local variable it can in a register. That makes the code faster, and it's also why the names of local variables are lost: a register only has a number. The disassembly of pointDistance, from the picture at the top, shows its parameters in registers 3 to 6 and its two locals in 1 and 2:
0EDA defineFunction2 pointDistance(r:6=x1, r:4=y1, r:5=x2, r:3=y2) ; 7 registers, suppressSuper suppressArguments suppressThis
{
0F02 push r:5 (x2), r:6 (x1)
0F09 subtract
0F0A storeRegister r:2
0F0E pop
0F0F push r:3 (y2), r:4 (y1)
0F16 subtract
0F17 storeRegister r:1
0F1B pop
0F1C push r:2, r:2
0F23 multiply
0F24 push r:1, r:1
0F2B multiply
0F2C add2
0F2D push 1, 'Math'
0F37 getVariable
0F38 push 'sqrt'
0F3D callMethod
0F3E return
}
Decompiling
To decompile, the code is run, but on expressions instead of values. A push puts an expression on the stack, an operator takes two off and puts back an expression made from them, and anything with a side effect becomes a statement. Here's the last line of pointDistance:
| Instruction | The stack afterwards |
|---|---|
push r:2, r:2 | r2, r2 |
multiply | r2 * r2 |
push r:1, r:1 | r2 * r2, r1, r1 |
multiply | r2 * r2, r1 * r1 |
add2 | r2 * r2 + r1 * r1 |
push 1, 'Math' | r2 * r2 + r1 * r1, 1, "Math" |
getVariable | r2 * r2 + r1 * r1, 1, Math |
push 'sqrt' | r2 * r2 + r1 * r1, 1, Math, "sqrt" |
callMethod | Math.sqrt(r2 * r2 + r1 * r1) |
return | empty, and a statement: return Math.sqrt(r2 * r2 + r1 * r1); |
callMethod takes the method's name, the object, the number of arguments and then the arguments off the stack, first argument first:
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;
}
The expressions are trees, so printing one puts brackets in only where the order of operations needs them. storeRegister followed by pop is an assignment to a local, and in a defineFunction2 the first one is written as a var, which is how var r2 = x2 - x1; comes back.
Control flow
The only control flow in AVM1 is jump and if, so the loops and ifs have to be rebuilt from where the branches go. The Flash compilers produce the same few shapes every time, and the decompiler looks for those. A while loop is the condition, a branch out past the end when it's false, the body, and a jump back to the top:
Every branch back to an earlier instruction marks a loop, and its target is the loop's first instruction. The rest are worked out from there:
| Source | Compiles to |
|---|---|
if (c) { a } | c, not, if L1, a, L1: |
if (c) { a } else { b } | c, not, if L1, a, jump L2, L1: b, L2: |
do { a } while (c) | L1: a, c, if L1 |
for (x in o) { a } | o, enumerate2, L1: storeRegister 0, push null, equals2, if L2, a, jump L1, L2: |
a && b | a, pushDuplicate, not, if L1, pop, b, L1: |
a || b | a, pushDuplicate, if L1, pop, b, L1: |
c ? a : b | c, if L1, b, jump L2, L1: a, L2: |
switch (v) | v, storeRegister r, push x, strictEquals, if L1, push r y, strictEquals, if L2, jump L3 ... |
A for loop is a while loop with an assignment just before it and a step at the end of its body. The if/else shape is spotted from the last instruction before the else: if it's a forward jump, that's where the end of the if is:
// 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;
}
}
Anything that doesn't fit one of the shapes is written out with labels and goto, which isn't ActionScript, but it means no code is ever left out.
The tricky parts
Most of the work was in the cases that didn't decompile cleanly the first time.
this.objects[this.numObjects++] = obj pushes the array and the old count, then adds one to the count, then stores into the array using the old count that's still on the stack. Making the increment its own statement, as the first version did, gives the wrong answer, because by the time the store is printed, this.numObjects means the new count. So when a variable is changed by one while it's still waiting on the stack, the waiting copy becomes 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;
};
a = b = c keeps c in register 0 while it sets b, then pushes register 0 back to set a, so that comes back as one chained assignment rather than a stray r0. A break or return inside for..in runs a little loop first that pops the names that are still on the stack, and that loop isn't in the source, so it's left out. And the Flash 6 compiler writes switch differently from Flash 8: it works the value out again for each case, instead of keeping it in a register.
The Flash 6 compiler also keeps local variables in registers, even in an ordinary defineFunction. It saves the registers it's about to use on the stack, 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. Read naively, that gives a goto at every return and the wrong return value, so the decompiler takes the wrapper out, turns the jumps back into returns, and names each register after the this or parameter it holds. Mesh.addPoint from the 3D engine comes back as it was written:
Mesh.prototype.addPoint = function(p) {
this.p[this.p.length] = p;
return this.p.length;
};
Some changes are just for reading. Flash compiles -x as 0 - x and a >= b as !(a < b), so they're written back the way they would have been typed.
Testing
There are two tests. One decodes every script in every SWF it's given, and checks that every branch lands on the start of an instruction. The other decompiles every script, counts the ones that needed a goto, and, where a SWF has its .as source next to it, checks that every function in the source comes back.
Across 642 movies, with 19,274 scripts and 1.5 million instructions, everything decodes except some third-party movies that were made hard to decompile on purpose, with branches into the middle of instructions and junk in between. Every script in my own movies decompiles without a goto. The 80 that still need one are all in third-party movies. Of the functions in the .as files next to those movies, 1,017 of 1,025 come back by name.
Here's a function from one of the test movies, as it was written:
Polygon.prototype.render = function ( b )
{
if( this.point.length <= 0 ) return
b.lineStyle( 1, 0x000000, 100 )
var i = this.point.length-1;
b.moveTo( 200 + 400 * this.point[ i ][0] / (this.point[i][2] + 20), 200 + 400 *this.point[ i ][1] / (this.point[i][2] + 20) )
b.beginFill( this.col, 95 )
for( i=0; i<this.point.length; i++)
{
ii = i-1; if(ii<0) ii = this.point.length-1
if(this.white[ii]) b.lineStyle()// 1, 0x000000, 50 ) // Draw feint line if it's not a main edge (the edge has been created by a split)
b.lineTo( 200 + 400 * this.point[i][0] / (this.point[i][2] + 20), 200 + 400 * this.point[i][1] / (this.point[i][2] + 20))
if(this.white[ii]) b.lineStyle( 1, 0x000000, 100 )
}
b.endFill()
...
}
And here it is decompiled from the movie:
Polygon.prototype.render = function(b) {
if (this.point.length <= 0) {
return;
}
b.lineStyle(1, 0, 100);
var r2 = this.point.length - 1;
b.moveTo(200 + 400 * this.point[r2][0] / (this.point[r2][2] + 20), 200 + 400 * this.point[r2][1] / (this.point[r2][2] + 20));
b.beginFill(this.col, 95);
for (r2 = 0; r2 < this.point.length; r2++) {
ii = r2 - 1;
if (ii < 0) {
ii = this.point.length - 1;
}
if (this.white[ii]) {
b.lineStyle();
}
b.lineTo(200 + 400 * this.point[r2][0] / (this.point[r2][2] + 20), 200 + 400 * this.point[r2][1] / (this.point[r2][2] + 20));
if (this.white[ii]) {
b.lineStyle(1, 0, 100);
}
}
b.endFill();
};
Comments and layout don't survive compiling, 0x000000 comes back as 0, and the local i comes back as r2. ii keeps its name because it was never declared with var, so it's an ordinary variable rather than a register. Everything else is as it was.
Source code
The tool
- swfdis.js JavaScript · 88 lines · the command line
- reader.js JavaScript · 165 lines · bytes, numbers, strings and bit fields
- swf.js JavaScript · 295 lines · the header, the tags, and where the scripts are
- actions.js JavaScript · 250 lines · the AVM1 instruction set and decoder
- disassemble.js JavaScript · 142 lines · instruction listings
- decompile.js JavaScript · 1260 lines · stack simulation and control flow, back to ActionScript
Download
- swf-disassembler.zip the whole project, with its tests and test movies