diff --git a/packages/webamp-modern-2/src/test.html b/packages/webamp-modern-2/src/test.html
new file mode 100644
index 00000000..29279390
--- /dev/null
+++ b/packages/webamp-modern-2/src/test.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ Webamp Modern Test Suite
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/webamp-modern-2/src/tests.ts b/packages/webamp-modern-2/src/tests.ts
new file mode 100644
index 00000000..94c1d291
--- /dev/null
+++ b/packages/webamp-modern-2/src/tests.ts
@@ -0,0 +1,362 @@
+import "./maki/parser";
+import { parse as parseMaki, ParsedMaki } from "./maki/parser";
+import { getMethod, getClass } from "./maki/objects";
+
+import JSZip from "jszip";
+// This module is imported early here in order to avoid a circular dependency.
+import { classResolver } from "./skin/resolver";
+
+function hack() {
+ // Without this Snowpack will try to treeshake out resolver causing a circular
+ // dependency.
+ classResolver("A funny joke about why this is needed.");
+}
+
+type Ast =
+ | {
+ kind: "VARIABLE_DEFINITION";
+ typeName: string;
+ name: string;
+ }
+ | {
+ kind: "HOOK";
+ body: Ast[];
+ variableName: string;
+ hookName: string;
+ // TODO: make this Ast[]
+ args: string;
+ }
+ | {
+ kind: "CALL";
+ objectName: string;
+ methodName: string;
+ args: Ast[];
+ body: Ast[];
+ }
+ | {
+ kind: "ASSIGNMENT";
+ variableName: string;
+ expression: Ast;
+ }
+ | { kind: "IDENTIFIER"; value: string }
+ | {
+ kind: "BINARY_EXPRESSION";
+ left: Ast;
+ right: Ast;
+ operator: "<" | ">" | "||";
+ }
+ | { kind: "CONDITIONAL"; test: Ast };
+
+function whiteSpace(indent: number): string {
+ let white = "";
+ for (let i = 0; i < indent; i++) {
+ white += " ";
+ }
+ return white;
+}
+function indented(code: string, depth: number): string {
+ return code
+ .split("\n")
+ .map((str) => whiteSpace(depth) + str)
+ .join("\n");
+}
+
+function prettyPrint(ast: Ast, indent: number = 0): string {
+ switch (ast.kind) {
+ case "VARIABLE_DEFINITION":
+ return `Global ${ast.typeName} ${ast.name}`;
+ case "HOOK":
+ const body = ast.body.map((statement) => {
+ // Lazy indenting
+ return indented(prettyPrint(statement, indent + 1), indent + 1);
+ });
+ const name = `${ast.variableName}.${ast.hookName}`;
+ return `${name}(${ast.args}){\n${body.join("\n")}\n}`;
+ case "CALL":
+ return `${ast.objectName}.${ast.methodName}(${ast.args.map(
+ prettyPrint
+ )})`;
+ case "ASSIGNMENT":
+ return `${ast.variableName} = ${prettyPrint(ast.expression)};`;
+ case "IDENTIFIER":
+ return `${ast.value}`;
+ case "BINARY_EXPRESSION":
+ return `(${prettyPrint(ast.left)} ${ast.operator} ${prettyPrint(
+ ast.right
+ )})`;
+ case "CONDITIONAL":
+ return `if(${prettyPrint(ast.test)}) {\n // SOME BODY\n}\n`;
+ default:
+ throw new Error(`Unhandled ast kind "${ast.kind}"`);
+ }
+}
+
+class Decompiler {
+ _program: ParsedMaki;
+ constructor(program: ParsedMaki) {
+ this._program = program;
+ }
+
+ decompile(): string {
+ const sections = [...this.variables(), ...this.bindings()];
+
+ return sections.map((node) => prettyPrint(node)).join("\n");
+ }
+
+ variableTypeName(offset: number): string {
+ const variable = this._program.variables[offset];
+ let type = variable.type;
+ switch (type) {
+ case "OBJECT": {
+ const guid = variable.guid;
+ const klass = getClass(guid);
+ return klass.name;
+ }
+ case "INT":
+ return "int";
+ case "DOUBLE":
+ return "double";
+ case "STRING":
+ return "string";
+ default:
+ throw new Error(`Unexpected type: ${type}`);
+ }
+ }
+
+ variables(): Ast[] {
+ const output = [];
+
+ // TODO: We could try to do the style of declaration where you do multiple
+ // names of the same type all at once.
+ for (const [i, variable] of this._program.variables.entries()) {
+ if (i == 0 || i == 1) {
+ // 0 = Global system object
+ // 1 = Global null
+ continue;
+ }
+ if (variable.global) {
+ output.push({
+ kind: "VARIABLE_DEFINITION",
+ typeName: this.variableTypeName(i),
+ name: this.variableName(i),
+ });
+ }
+ }
+ return output;
+ }
+
+ // TODO: Include type in variable name
+ variableName(offset: number) {
+ switch (offset) {
+ case 0:
+ return "System";
+ default:
+ return `${this.variableTypeName(offset).toLowerCase()}${offset}`;
+ }
+ }
+
+ bindings() {
+ const output = [];
+
+ for (const binding of this._program.bindings) {
+ const method = this._program.methods[binding.methodOffset];
+ const guid = this._program.classes[method.typeOffset];
+ const methodDefinition = getMethod(guid, method.name);
+ output.push({
+ kind: "HOOK",
+ variableName: this.variableName(binding.variableOffset),
+ hookName: method.name,
+ args: methodDefinition.parameters
+ // TODO: What if names collide with outer scope?
+ // Ensure type names are correct.
+ .map(([type, name]) => `${type} ${name}`)
+ .join(", "),
+ body: this.code(
+ binding.commandOffset,
+ methodDefinition.parameters
+ // TODO: What if names collide with outer scope?
+ // Ensure type names are correct.
+ .map(
+ ([type, name]) =>
+ ({
+ kind: "IDENTIFIER",
+ value: name,
+ } as const)
+ )
+ ),
+ });
+ }
+
+ return output;
+ }
+
+ code(commandOffset: number, args: Ast[]): Ast[] {
+ const nodes: Ast[] = [];
+ let ip = commandOffset;
+ const callStack = [];
+ const stack = args;
+ outer: while (ip < this._program.commands.length) {
+ const command = this._program.commands[ip];
+ switch (command.opcode) {
+ // push
+ case 1: {
+ stack.push({
+ kind: "IDENTIFIER",
+ value: this.variableName(command.arg),
+ });
+ break;
+ }
+ // pop
+ case 2: {
+ stack.pop();
+ break;
+ }
+ // popTo
+ case 3: {
+ const value = stack.pop();
+ if (value == null) {
+ throw new Error("Unexpected empty stack");
+ }
+ nodes.push({
+ kind: "ASSIGNMENT",
+ variableName: this.variableName(command.arg),
+ expression: value,
+ });
+ break;
+ }
+ // ==
+ case 8:
+ // !=
+ case 9:
+ // >
+ case 10:
+ // <
+ case 12:
+ // +
+ case 64:
+ /*
+ // -
+ case 65:
+ */
+ // *
+ case 66:
+ // divide
+ case 67:
+ // ||
+ case 81: {
+ const operatorMap = {
+ 8: "==",
+ 9: "!=",
+ 10: ">",
+ 12: "<",
+ 64: "+",
+ 65: "-",
+ 66: "*",
+ 67: "/",
+ 81: "||",
+ };
+ const a = stack.pop();
+ const b = stack.pop();
+ stack.push({
+ kind: "BINARY_EXPRESSION",
+ left: b,
+ right: a,
+ operator: operatorMap[command.opcode],
+ });
+ break;
+ }
+ // jumpIf
+ case 16: {
+ const test = stack.pop();
+ nodes.push({ kind: "CONDITIONAL", test });
+ ip = command.arg - 1;
+ break;
+ }
+
+ // jumpIfNot
+ case 17: {
+ const test = stack.pop();
+ // TODO: NOT
+ nodes.push({ kind: "CONDITIONAL", test });
+ ip = command.arg - 1;
+ break;
+ }
+ // call
+ case 24:
+ case 112:
+ const methodOffset = command.arg;
+ const method = this._program.methods[methodOffset];
+ let methodName = method.name;
+ methodName = methodName.toLowerCase();
+ const guid = this._program.classes[method.typeOffset];
+ const methodDefinition = getMethod(guid, method.name);
+ const obj = stack.pop();
+ if (obj.kind !== "IDENTIFIER") {
+ throw new Error("Expectd ident");
+ }
+ const args = methodDefinition.parameters.map((param) => {
+ return stack.pop();
+ });
+ stack.push({
+ objectName: obj.value,
+ methodName,
+ kind: "CALL",
+ args,
+ });
+ break;
+ case 33: {
+ ip = callStack.pop();
+ // TODO: Stack protection?
+ break;
+ }
+ case 40:
+ // What should we do here?
+ return nodes;
+ break;
+ // mov
+ case 48:
+ const a = stack.pop();
+ const b = stack.pop();
+ stack.push({
+ kind: "ASSIGNMENT",
+ variableName: a.name,
+ expression: b,
+ });
+ break;
+ case 25:
+ callStack.push(ip);
+ ip = command.arg - 1;
+ break;
+ default:
+ console.log(`Missing support for opcode ${command.opcode}`);
+ break outer;
+ }
+ ip++;
+ }
+ return nodes;
+ }
+}
+
+async function main() {
+ const response = await fetch("assets/CornerAmp_Redux.wal");
+ const data = await response.blob();
+ const zip = await JSZip.loadAsync(data);
+
+ const makiFile = zip.file("scripts/corner.maki");
+ const scriptContents = await makiFile.async("arraybuffer");
+ // TODO: Try catch?
+ const parsedScript = parseMaki(scriptContents);
+
+ const decompiler = new Decompiler(parsedScript);
+
+ const decompiled = decompiler.decompile();
+
+ const textarea = document.createElement("textarea");
+ textarea.style.display = "flex";
+ textarea.style.width = "100%";
+ textarea.style.height = "100vh";
+ textarea.innerHTML = decompiled;
+ document.body.appendChild(textarea);
+}
+
+main();