Evaluator & coercion
How Avm1Machine linearly evaluates a DoAction byte stream into a global object table, the operators and coercion rules it implements, and how the Avm1Emitter writes a modified global table back to AVM1 bytecode.
Overview
Avm1Machine (in src/ShockwaveFlash.Avm1/Avm1Machine.cs) is a deliberately small, linear AVM1 interpreter. It is built to read data scripts — the kind of DoAction tag a tool authoring SWFs emits to seed a movie's global variables — not to run arbitrary control flow. It walks a decoded IReadOnlyList<Action> from top to bottom, maintains an operand stack, a register file, a constant pool, and a single global object table, and stops at the first ActionEnd. The product of a run is the Avm1Object held in Globals.
The machine does not implement jumps, branches, function calls, with scopes, timeline control, or object instantiation in any meaningful way. Those opcodes either decode to no-ops with placeholder stack results, or fall through to the unsupported path (see Unsupported opcodes). This is intentional: the use case is extracting and rewriting the flat name/value pairs a script pushes into globals (for example, the Dofus lang files), not faithfully emulating a player.
Running a script
The static entry point decodes the action bytes and executes them:
public static Avm1Object Run(ReadOnlyMemory<byte> actions, byte swfVersion, bool strict = false)
{
var machine = new Avm1Machine(swfVersion);
machine.Execute(Action.DecodeCollection(actions, swfVersion), strict);
return machine.Globals;
}Action.DecodeCollection (in Action.cs) turns the raw DoAction payload into typed Action records, and Execute interprets them. The SWF version is threaded through because several coercion rules are version-gated (see Version-gated behavior). The constructor defaults to version 6: Avm1Machine(byte swfVersion = 6).
tag.Evaluate
Most callers do not touch Avm1Machine directly; they use the extension methods in Avm1Extensions.cs on a DoActionTag:
public static Avm1Object Evaluate(this DoActionTag tag, byte swfVersion, bool strict = false)
{
return Avm1Machine.Run(tag.Data, swfVersion, strict);
}Evaluate is just Run over the tag's Data. There is also DecodeActions (decode only, no execution) on the same tag.
The execution loop
Execute is one big switch over the action records. Each case pops its operands, computes a result, and pushes it back. A few structural cases:
ActionConstantPoolreplaces the machine's_constantPoolwith the pool's strings.Pushvalues of typeConstant8/Constant16are later resolved against this list.ActionPushresolves everyPushValuein itsPushValueslist to anAvm1Valueand pushes each (see Push resolution).ActionEndreturns immediately, ending the run.- Anything not handled hits the
defaultarm.
Globals are populated through the variable and member opcodes:
case ActionSetVariable:
{
var value = Pop();
_globals.Members[ToStr(Pop())] = value;
break;
}ActionGetVariable reads back from _globals.Members (returning Avm1Value.Undefined when absent). ActionGetMember/ActionSetMember operate on whatever Avm1Object is on the stack, and ActionInitObject/ActionInitArray build Avm1Object/Avm1Array aggregates by popping count entries. This is how a flat script like push "key"; push "value"; setVariable lands a pair in Globals.
The value model
Values on the stack are Avm1Value records (in Types/Avm1Value.cs):
Avm1String(string Value)Avm1Number(double Value)Avm1Boolean(bool Value)Avm1NullandAvm1Undefined(singletons exposed asAvm1Value.Null/Avm1Value.Undefined)Avm1Object— aDictionary<string, Avm1Value>namedMembers(ordinal comparer)Avm1Array— aList<Avm1Value>namedItems
Implicit conversions let the machine push CLR primitives directly (string, double, int, bool all convert to Avm1Value), which is why expressions like _stack.Push(left + right) compile.
Push resolution
Resolve maps a decoded PushValue (the on-wire push operand, in Types/PushValue.cs) to a runtime Avm1Value:
private Avm1Value Resolve(PushValue value)
{
return value switch
{
PushValue.PushValueString item => item.Value,
PushValue.PushValueInteger item => item.Value,
PushValue.PushValueFloat item => (double)item.Value,
PushValue.PushValueDouble item => item.Value,
PushValue.PushValueBoolean item => item.Value,
PushValue.PushValueNull => Avm1Value.Null,
PushValue.PushValueRegister item => Register(item.RegisterIndex),
PushValue.PushValueConstant8 item => Constant(item.ConstantIndex),
PushValue.PushValueConstant16 item => Constant(item.ConstantIndex),
_ => Avm1Value.Undefined
};
}PushValueUndefined (and any unmapped case) becomes Avm1Value.Undefined. Register and Constant both bounds-check their index and return Undefined when out of range.
The register file
The machine holds a fixed bank of 256 registers, all initialized to Undefined:
private static Avm1Value[] CreateRegisters()
{
var registers = new Avm1Value[256];
Array.Fill(registers, Avm1Value.Undefined);
return registers;
}ActionStoreRegister peeks the top of stack (it does not pop) and writes it into _registers[RegisterNumber], ignoring register numbers >= _registers.Length. If the stack is empty it stores Undefined. Pushing register n (via PushValueRegister) reads it back through Register(int index), which returns Undefined for any index outside [0, 256).
The operand stack
The stack is a Stack<Avm1Value>. All reads go through Pop, which is never-throwing: an empty stack yields Undefined rather than an exception.
private Avm1Value Pop()
{
return _stack.Count > 0 ? _stack.Pop() : Avm1Value.Undefined;
}Stack-manipulation opcodes:
ActionPop— discard top.ActionPushDuplicate— pop, push twice.ActionStackSwap— swap the top two entries.DropArguments(a helper, not an opcode) pops a count then pops that many values; it backs the placeholder implementations ofActionNewObject,ActionCallMethod, andActionCallFunction, all of which discard their arguments and pushUndefined(or a fresh emptyAvm1ObjectforNewObject).
Operators
All arithmetic/comparison/bitwise opcodes pop right then left (right is on top), apply coercion, and push the result.
Arithmetic. ActionAdd, ActionSubtract, ActionMultiply, ActionDivide, ActionModulo coerce both operands with ToNum and produce a double. ActionDivide has a SWF4 quirk: dividing by zero on version < 5 pushes the string "#ERROR#" instead of infinity.
case ActionDivide:
{
var right = ToNum(Pop());
var left = ToNum(Pop());
_stack.Push(right == 0.0 && _version < 5 ? "#ERROR#" : left / right);
break;
}ActionAdd2 is the typed add: if either operand is an Avm1String it concatenates string coercions, otherwise it adds numbers. ActionStringAdd always concatenates ToStr of both.
Bitwise. ActionBitAnd, ActionBitOr, ActionBitXor coerce with ToInt32 and push the result back as a double. The shifts (ActionBitLShift, ActionBitRShift, ActionBitURShift) mask the shift count with & 31; the unsigned right shift coerces the value with ToUint32, the signed shifts with ToInt32.
Comparison/logic. ActionEquals and ActionLess are the SWF4 numeric forms (ToNum both sides, push a bool). The SWF5+ typed forms use abstract comparison helpers:
ActionEquals2→AbstractEqualsActionLess2→AbstractLessActionGreater→AbstractLess(right, left)ActionStrictEquals→StrictEquals
ActionNot pushes !ToBool(...). ActionAnd/ActionOr coerce both sides with ToBool (no short-circuit — both operands are already popped) and push the boolean result.
String ops. ActionStringEquals compares ordinally. ActionStringLess/ActionStringGreater use string.CompareOrdinal. ActionStringLength and ActionMBStringLength push .Length as a double. ActionStringExtract/ActionMBStringExtract delegate to StringExtract(text, index, count), which uses a 1-based start index (Flash substring semantics) and clamps the range. ActionCharToAscii/ActionMBCharToAscii push the first UTF-16 code unit (or 0); ActionAsciiToChar/ActionMBAsciiToChar map a code back to a one-char string (and produce an empty string for \0).
Type ops. ActionToInteger pushes ToInt32 as a double; ActionToNumber pushes ToNum; ActionToString pushes ToStr; ActionTypeOf pushes the TypeOf tag string.
Abstract comparison helpers
AbstractEquals mirrors ECMAScript loose equality, restricted to the machine's type set: objects/arrays compare by reference; null/undefined are equal to each other; two strings compare ordinally; two booleans compare as booleans; otherwise both sides go through ToNum. AbstractLess returns Avm1Value.Undefined when either side is NaN (the AVM1 "undefined comparison" result), compares two strings ordinally, compares objects/arrays as false, and otherwise compares ToNum. StrictEquals compares objects/arrays by reference and everything else by record value equality (left == right).
Coercion rules
The coercion methods are the heart of the machine and are version-aware.
ToNum (number coercion)
internal double ToNum(Avm1Value value)
{
return value switch
{
Avm1Number number => number.Value,
Avm1Boolean flag => flag.Value ? 1.0 : 0.0,
Avm1Null => _version < 7 ? 0.0 : double.NaN,
Avm1Undefined => _version < 7 ? 0.0 : double.NaN,
Avm1String text => StringToNumber(text.Value),
_ => double.NaN
};
}Objects and arrays coerce to NaN. null/undefined coerce to 0 before version 7 and to NaN from version 7. String-to-number goes through StringToNumber, which:
- Trims whitespace.
- On version
>= 6, sniffs a radix withGuessRadix: a leading0x/0Xis hex, a leading0followed by only octal digits is octal; those are parsed byParseRadix. - Otherwise parses a decimal float with
ParseFloatImpl. Parsing is strict (trailing garbage →NaN) on version>= 5; on SWF4 a non-numeric string coerces to0.
ToInt32/ToUint32 build on ToNum: ToUint32 returns 0 for non-finite numbers, truncates, then wraps modulo 2^32; ToInt32 is the unchecked reinterpretation of that.
ToStr (string coercion) and number formatting
internal string ToStr(Avm1Value value)
{
return value switch
{
Avm1String text => text.Value,
Avm1Number number => FormatNumber(number.Value),
Avm1Null => "null",
Avm1Undefined => _version < 7 ? string.Empty : "undefined",
Avm1Boolean flag => _version < 5 ? (flag.Value ? "1" : "0") : (flag.Value ? "true" : "false"),
Avm1Array array => string.Join(",", array.Items.Select(ToStr)),
Avm1Object => "[object Object]",
_ => "null"
};
}Numbers are stringified by FormatNumber, a static method that is a faithful port of Flash's own double-to-string routine rather than a call to .NET's double.ToString. It special-cases NaN, Infinity/-Infinity, and zero; emits an exact integer string for whole numbers in the 32-bit range; and for everything else reconstructs the decimal digits manually. It derives a base-10 exponent from the IEEE-754 binary exponent (log10Of2), extracts up to 15 significant digits via a power-of-ten DecimalShift, applies round-half-away rounding on the 16th digit, trims trailing zeros, and switches to e+/e- scientific notation for large or very small magnitudes. The point of porting it verbatim is byte-exact round-tripping: a number read out of a SWF and written back must serialize to the same characters the Flash authoring tool produced.
ToBool (boolean coercion)
internal bool ToBool(Avm1Value value)
{
return value switch
{
Avm1Boolean flag => flag.Value,
Avm1Number number => !double.IsNaN(number.Value) && number.Value != 0.0,
Avm1String text => ToBoolString(text.Value),
Avm1Object => true,
Avm1Array => true,
_ => false
};
}null/undefined are falsy. A number is truthy when it is non-NaN and non-zero. String truthiness is version-gated via ToBoolString: on version >= 7 a non-empty string is true; before that the string is coerced to a number and judged non-NaN/non-zero (SWF4/5/6 semantics).
TypeOf
TypeOf returns "undefined", "null", "number", "boolean", "string", or "object" (the fallback for both Avm1Object and Avm1Array).
Version-gated behavior
Summary of the rules that change with swfVersion:
- Divide by zero pushes
"#ERROR#"on version< 5. - Boolean → string is
"1"/"0"on version< 5, else"true"/"false". - null/undefined → number is
0before version 7,NaNfrom version 7. - undefined → string is empty before version 7,
"undefined"from version 7. - String → number uses radix sniffing (hex/octal) only on version
>= 6, and is strict (no trailing garbage) only on version>= 5. - String → boolean is "non-empty" on version
>= 7, numeric-truthiness before that.
Unsupported opcodes
The default arm handles every opcode the machine does not implement (jumps, ifs, with, function definitions, timeline actions, and so on):
default:
if (strict)
throw new Avm1UnsupportedActionException(action.Opcode);
_unsupported.Add(action.Opcode);
break;In the default (non-strict) mode, the opcode is simply recorded and execution continues. The set is exposed as UnsupportedOpcodes (IReadOnlySet<ActionOpcode>), so a caller can inspect which actions were skipped after a Run. In strict mode the machine throws Avm1UnsupportedActionException (carrying the Opcode) the moment it meets one — useful when you need a guarantee the script was a pure data script and nothing was silently ignored. Note that strict mode also tightens decoding: Action.DecodeCollection/ActionPush.Decode throw SwfFormatException on unknown opcodes, leftover payload bytes, invalid UTF-8, or unknown push types.
Writing globals back
After you mutate the Avm1Object returned by Run/Evaluate, Avm1Emitter (in Avm1Emitter.cs) serializes it back to a DoAction byte stream. The public surface:
public static ReadOnlyMemory<byte> EmitBytes(Avm1Object globals, byte swfVersion, IReadOnlyList<Action>? preamble = null)
{
return Action.EncodeCollection(Emit(globals, preamble), swfVersion);
}Emit builds the action list in two passes:
- Collect walks the value tree and interns every member name and string value into a constant pool via
Intern. Interning is bounded: it stops adding entries once the pool reachesushort.MaxValuestrings or its serialized size would exceedPoolByteBudget(60000 bytes). The walk also enforcesMaxNestingDepth(256) and throwsSwfFormatExceptionif the tree is nested deeper. If anything was interned, a singleActionConstantPoolis emitted first. - For each top-level member, it emits
PushString(name), thenEmitValue(value), thenActionSetVariable— the inverse of how the machine reads globals.
EmitValue reconstructs aggregates: an Avm1Object pushes each member (name then value, in reverse member order) followed by the member count and ActionInitObject; an Avm1Array pushes items in reverse, then the count, then ActionInitArray; strings go through PushString; scalars go through ToScalar wrapped in an ActionPush. PushString prefers a Constant8/Constant16 reference when the string was interned (choosing the 8-bit form for indices < 256), and falls back to an inline PushValueString. ToScalar maps booleans, null, and numbers (choosing a compact Integer push for whole numbers in the int range, otherwise Double), defaulting to an Undefined push. The list is terminated with ActionEnd.
An optional preamble of pre-existing actions can be prepended; any ActionEnd in the preamble is filtered out so it does not truncate the stream early.
WithGlobals
The convenient round-trip on a tag is the WithGlobals extension in Avm1Extensions.cs:
public static DoActionTag WithGlobals(this DoActionTag tag, Avm1Object globals, byte swfVersion)
{
return new DoActionTag(tag.Metadata, Avm1Emitter.EmitBytes(globals, swfVersion));
}It produces a new DoActionTag that reuses the original Metadata but carries freshly emitted bytes for the supplied globals. The typical flow is: var g = tag.Evaluate(version); → mutate g.Members → var newTag = tag.WithGlobals(g, version);. The sibling WithActions does the same for a raw action list when you want to control the bytecode directly rather than regenerate it from a value tree.
Practical notes
- The machine is single-shot and not thread-safe; create one (or call the static
Run) per script. - Because the evaluator ignores control flow, a script that guards its
setVariablecalls behind branches will not populate globals as the real player would — this evaluator targets straight-line data scripts only. EmitBytesdoes not necessarily reproduce the original byte stream verbatim (member ordering, pool packing, and scalar encoding are the emitter's own choices); it produces an equivalent script that re-evaluates to the same globals. For byte-exact identity of unchanged numbers, the portedFormatNumberis what keeps string values stable across a decode/encode round-trip.