Flash Tools Parsing JavaScript AI

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
The command line

--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)
   ...
What --info shows for the CubeLogix website (trimmed)

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))]);
Unsquashing a compressed SWF

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();
The rest of the header

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();
A tag's header

Scripts turn up in five kinds of tag:

TagThe 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
Where scripts are kept

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:

TypeValue
0A string, ending in a zero
1A 32-bit float
2, 3null, undefined
4A register, by number
5A boolean, in one byte
6A 64-bit double, with its two halves the wrong way round
7A 32-bit integer
8, 9A string from the constant pool, by a one or two byte number
The kinds of value a push can hold

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);
}
Reading a double from a push

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;
}
Which name each register holds when a function starts

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
      }
The disassembly of one function

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:

InstructionThe stack afterwards
push r:2, r:2r2, r2
multiplyr2 * r2
push r:1, r:1r2 * r2, r1, r1
multiplyr2 * r2, r1 * r1
add2r2 * r2 + r1 * r1
push 1, 'Math'r2 * r2 + r1 * r1, 1, "Math"
getVariabler2 * r2 + r1 * r1, 1, Math
push 'sqrt'r2 * r2 + r1 * r1, 1, Math, "sqrt"
callMethodMath.sqrt(r2 * r2 + r1 * r1)
returnempty, and a statement: return Math.sqrt(r2 * r2 + r1 * r1);
Running the last line of pointDistance on expressions

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;
}
A method call: name, object, how many arguments, then the arguments

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:

The shape a while loop compiles to
The shape a while loop compiles to

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:

SourceCompiles 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 && ba, pushDuplicate, not, if L1, pop, b, L1:
a || ba, pushDuplicate, if L1, pop, b, L1:
c ? a : bc, 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 ...
What the Flash compiler turns each kind of statement into

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;
  }
}
Spotting an if and its else

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;
};
x = x + 1 while an x is still on the stack is x++

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;
};
A Flash 6 function, with the register saving taken out

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()

  ...
}
Polygon.prototype.render, as written (trimmed)

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();
};
The same function, decompiled

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