diff --git a/packages/webamp-modern-2/.gitignore b/packages/webamp-modern-2/.gitignore new file mode 100644 index 00000000..d1638636 --- /dev/null +++ b/packages/webamp-modern-2/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/packages/webamp-modern-2/README.md b/packages/webamp-modern-2/README.md index 0a988d5b..0651190d 100644 --- a/packages/webamp-modern-2/README.md +++ b/packages/webamp-modern-2/README.md @@ -1,11 +1,13 @@ # TODO Next +- [ ] SystemObject.getruntimeversion +- [ ] SystemObject.getskinname +- [ ] Ensure only wrapped variables go on the stack + - [ ] I suspect that this will require type-aware coersion + Meanding we don't just always convert to number, but match the type coersion of Maki? - [ ] What is the root node? - [ ] Where do Layers actually go? - [ ] Where are scripts initialized? -- [ ] - ---- - [ ] When parsing skins, where is the root state accumulated? - [ ] How do includes work when parsing skins? Do they create new context? diff --git a/packages/webamp-modern-2/src/index.ts b/packages/webamp-modern-2/src/index.ts index 49e6db89..3021304f 100644 --- a/packages/webamp-modern-2/src/index.ts +++ b/packages/webamp-modern-2/src/index.ts @@ -10,6 +10,10 @@ async function main() { await parser.parse(); + for (const container of parser._containers) { + container.init(); + } + for (const container of parser._containers) { document.body.appendChild(container.getDebugDom()); } diff --git a/packages/webamp-modern-2/src/maki/MakiFile.ts b/packages/webamp-modern-2/src/maki/MakiFile.ts new file mode 100644 index 00000000..a608d32b --- /dev/null +++ b/packages/webamp-modern-2/src/maki/MakiFile.ts @@ -0,0 +1,71 @@ +// Holds a buffer and a pointer. Consumers can consume bytesoff the end of the +// file. When we want to run in the browser, we can refactor this class to use a +// typed array: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Typed_arrays +export default class MakiFile { + _arr: Uint8Array; + _i: number; + constructor(data: ArrayBuffer) { + this._arr = new Uint8Array(data); + this._i = 0; + } + + readInt32LE(): number { + const offset = this._i >>> 0; + this._i += 4; + + return ( + this._arr[offset] | + (this._arr[offset + 1] << 8) | + (this._arr[offset + 2] << 16) | + (this._arr[offset + 3] << 24) + ); + } + + readUInt32LE(): number { + const int = this.peekUInt32LE(); + this._i += 4; + return int; + } + + peekUInt32LE(): number { + const offset = this._i >>> 0; + + return ( + (this._arr[offset] | + (this._arr[offset + 1] << 8) | + (this._arr[offset + 2] << 16)) + + this._arr[offset + 3] * 0x1000000 + ); + } + + readUInt16LE(): number { + const offset = this._i >>> 0; + this._i += 2; + return this._arr[offset] | (this._arr[offset + 1] << 8); + } + + readUInt8(): number { + const int = this._arr[this._i]; + this._i++; + return int; + } + + readStringOfLength(length: number): string { + let ret = ""; + const end = Math.min(this._arr.length, this._i + length); + + for (let i = this._i; i < end; ++i) { + ret += String.fromCharCode(this._arr[i]); + } + this._i += length; + return ret; + } + + readString(): string { + return this.readStringOfLength(this.readUInt16LE()); + } + + getPosition(): number { + return this._i; + } +} diff --git a/packages/webamp-modern-2/src/maki/constants.js b/packages/webamp-modern-2/src/maki/constants.ts similarity index 100% rename from packages/webamp-modern-2/src/maki/constants.js rename to packages/webamp-modern-2/src/maki/constants.ts diff --git a/packages/webamp-modern-2/src/maki/interpreter.js b/packages/webamp-modern-2/src/maki/interpreter.js deleted file mode 100644 index f9384fee..00000000 --- a/packages/webamp-modern-2/src/maki/interpreter.js +++ /dev/null @@ -1,360 +0,0 @@ -import Variable from "./variable"; -import { isPromise, unimplementedWarning } from "../utils"; - -export function interpret(start, program) { - const interpreter = new Interpreter(program); - return interpreter.interpret(start); -} - -class Interpreter { - constructor(program) { - const { commands, methods, variables, classes } = program; - this.commands = commands; - this.methods = methods; - this.variables = variables; - this.classes = classes; - - this.stack = []; - this.callStack = []; - } - - interpret(start) { - // Instruction Pointer - let ip = start; - while (ip < this.commands.length) { - const command = this.commands[ip]; - - switch (command.opcode) { - // push - case 1: { - const offsetIntoVariables = command.arg; - this.stack.push(this.variables[offsetIntoVariables]); - break; - } - // pop - case 2: { - this.stack.pop(); - break; - } - // popTo - case 3: { - const aValue = this.popStackValue(); - const offsetIntoVariables = command.arg; - const toVar = this.variables[offsetIntoVariables]; - toVar.setValue(aValue); - break; - } - // == - case 8: { - const a = this.stack.pop(); - const b = this.stack.pop(); - switch (a.type) { - case "INT": - case "FLOAT": - case "DOUBLE": - case "BOOLEAN": { - break; - } - case "STRING": { - break; - } - default: - throw new Error(`Unexpected type: ${a}`); - } - let aValue = this.getValue(a); - const bValue = this.getValue(b); - - aValue = this.coerceTypes__DEPRECATED(a, b); - const result = Variable.newInt(bValue === aValue); - this.stack.push(result); - break; - } - // != - case 9: { - this.twoArgCoercingOperator((b, a) => b !== a); - break; - } - // > - case 10: { - this.twoArgCoercingOperator((b, a) => b > a); - break; - } - // >= - case 11: { - this.twoArgCoercingOperator((b, a) => b >= a); - break; - } - // < - case 12: { - this.twoArgCoercingOperator((b, a) => b < a); - break; - } - // <= - case 13: { - this.twoArgCoercingOperator((b, a) => b <= a); - break; - } - // jumpIf - case 16: { - const value = this.popStackValue(); - // This seems backwards. Seems like we're doing a "jump if not" - if (value) { - break; - } - ip = command.arg - 1; - break; - } - // jumpIfNot - case 17: { - const value = this.popStackValue(); - // This seems backwards. Same as above - if (!value) { - break; - } - ip = command.arg - 1; - break; - } - // jump - case 18: { - ip = command.arg - 1; - break; - } - // call - // strangeCall (seems to behave just like regular call) - case 24: - case 112: { - const methodOffset = command.arg; - const method = this.methods[methodOffset]; - let methodName = method.name; - const classesOffset = method.typeOffset; - methodName = methodName.toLowerCase(); - - const klass = this.classes[classesOffset]; - if (!klass) { - throw new Error("Need to add a missing class to runtime"); - } - // This is a bit awkward. Because the variables are stored on the stack - // before the object, we have to find the number of arguments without - // actually having access to the object instance. - if (!klass.prototype[methodName]) { - throw new Error( - `Need to add missing function (${methodName}) to ${klass.name}` - ); - } - let argCount = klass.prototype[methodName].length; - - const methodArgs = []; - while (argCount--) { - const aValue = this.popStackValue(); - methodArgs.push(aValue); - } - const obj = this.popStackValue(); - let value = obj[methodName](...methodArgs); - if (isPromise(value)) { - throw new Error("Did not expect maki method to return promise"); - } - if (value === null) { - // variables[1] holds global NULL value - value = this.variables[1]; - } - this.stack.push(value); - break; - } - // callGlobal - case 25: { - this.callStack.push(ip); - const offset = command.arg; - - ip = offset - 1; // -1 because we ++ after the switch - break; - } - // return - case 33: { - ip = this.callStack.pop(); - // TODO: Stack protection? - break; - } - // complete - case 40: { - // noop for now - unimplementedWarning("OPCODE: complete"); - break; - } - // mov - case 48: { - const a = this.stack.pop(); - const b = this.stack.pop(); - let aValue = a instanceof Variable ? a.getValue() : a; - if (b.type === "INT") { - aValue = Math.floor(aValue); - } - b.setValue(aValue); - this.stack.push(aValue); - break; - } - // postinc - case 56: { - const a = this.stack.pop(); - const aValue = a.getValue(); - a.setValue(aValue + 1); - this.stack.push(aValue); - break; - } - // postdec - case 57: { - const a = this.stack.pop(); - const aValue = a.getValue(); - a.setValue(aValue - 1); - this.stack.push(aValue); - break; - } - // preinc - case 58: { - const a = this.stack.pop(); - const aValue = a.getValue() + 1; - a.setValue(aValue); - this.stack.push(aValue); - break; - } - // predec - case 59: { - const a = this.stack.pop(); - const aValue = a.getValue() - 1; - a.setValue(aValue); - this.stack.push(aValue); - break; - } - // + (add) - case 64: { - this.twoArgOperator((b, a) => b + a); - break; - } - // - (subtract) - case 65: { - this.twoArgOperator((b, a) => b - a); - break; - } - // * (multiply) - case 66: { - this.twoArgOperator((b, a) => b * a); - break; - } - // / (divide) - case 67: { - this.twoArgOperator((b, a) => b / a); - break; - } - // % (mod) - case 68: { - const a = this.stack.pop(); - const b = this.stack.pop(); - const aValue = a instanceof Variable ? a.getValue() : a; - let bValue = b instanceof Variable ? b.getValue() : b; - // Need to coerce LHS if not int, RHS is always int (enforced by compiler) - if (b.type === "FLOAT" || b.type === "DOUBLE") { - bValue = Math.floor(bValue); - } - this.stack.push(bValue % aValue); - break; - } - // & (binary and) - case 72: { - this.twoArgOperator((b, a) => b & a); - break; - } - // | (binary or) - case 73: { - this.twoArgOperator((b, a) => b | a); - break; - } - // ! (not) - case 74: { - const aValue = this.popStackValue(); - this.stack.push(aValue ? 0 : 1); - break; - } - // - (negative) - case 76: { - const aValue = this.popStackValue(); - this.stack.push(-aValue); - break; - } - // logAnd (&&) - case 80: { - this.twoArgOperator((b, a) => b && a); - break; - } - // logOr || - case 81: { - this.twoArgOperator((b, a) => b || a); - break; - } - // << - case 88: { - this.twoArgOperator((b, a) => b << a); - break; - } - // >> - case 89: { - this.twoArgOperator((b, a) => b >> a); - break; - } - // new - case 96: { - const classesOffset = command.arg; - const Klass = this.classes[classesOffset]; - const system = this.variables[0].getValue(); - const klassInst = new Klass(null, system.getscriptgroup()); - this.stack.push(klassInst); - break; - } - // delete - case 97: { - const aValue = this.popStackValue(); - aValue.js_delete(); - break; - } - default: - throw new Error(`Unhandled opcode ${command.opcode}`); - } - - ip++; - } - } - - getValue(v) { - return v instanceof Variable ? v.getValue() : v; - } - - popStackValue() { - const v = this.stack.pop(); - return this.getValue(v); - } - - twoArgCoercingOperator(operator) { - const a = this.stack.pop(); - const b = this.stack.pop(); - let aValue = this.getValue(a); - const bValue = this.getValue(b); - - aValue = this.coerceTypes__DEPRECATED(a, b); - this.stack.push(operator(bValue, aValue)); - } - - twoArgOperator(operator) { - const aValue = this.popStackValue(); - const bValue = this.popStackValue(); - - this.stack.push(operator(bValue, aValue)); - } - - coerceTypes__DEPRECATED(var1, var2) { - if (var2.type === "INT") { - if (var1.type === "FLOAT" || var1.type === "DOUBLE") { - return Math.floor(this.getValue(var1)); - } - } - - return this.getValue(var1); - } -} diff --git a/packages/webamp-modern-2/src/maki/interpreter.ts b/packages/webamp-modern-2/src/maki/interpreter.ts new file mode 100644 index 00000000..3a74fef4 --- /dev/null +++ b/packages/webamp-modern-2/src/maki/interpreter.ts @@ -0,0 +1,513 @@ +import { V, Variable } from "./v"; +import { assert, assume } from "../utils"; +import { ParsedMaki, Command, Method } from "./parser"; + +export function interpret( + start: number, + program: ParsedMaki, + classResolver: (guid: string) => any +) { + const interpreter = new Interpreter(program, classResolver); + return interpreter.interpret(start); +} + +class Interpreter { + stack: Variable[]; + callStack: number[]; + classes: string[]; + variables: Variable[]; + methods: Method[]; + commands: Command[]; + classResolver: (guid: string) => any; + constructor(program: ParsedMaki, classResolver: (guid: string) => any) { + const { commands, methods, variables, classes } = program; + this.classResolver = classResolver; + this.commands = commands; + this.methods = methods; + this.variables = variables; + this.classes = classes; + + this.stack = []; + this.callStack = []; + } + + interpret(start: number) { + // Instruction Pointer + let ip = start; + while (ip < this.commands.length) { + const command = this.commands[ip]; + + switch (command.opcode) { + // push + case 1: { + const offsetIntoVariables = command.arg; + this.stack.push(this.variables[offsetIntoVariables]); + break; + } + // pop + case 2: { + this.stack.pop(); + break; + } + // popTo + case 3: { + const aValue = this.stack.pop(); + const offsetIntoVariables = command.arg; + const current = this.variables[offsetIntoVariables]; + assume( + aValue.type === current.type, + "Assigned from one type to a different type." + ); + + current.value = aValue.value; + break; + } + // == + case 8: { + const a = this.stack.pop(); + const b = this.stack.pop(); + assume( + a.type == b.type, + `Tried to compare a ${a.type} to a ${b.type}.` + ); + const result = V.newInt(a.value === b.value); + this.stack.push(result); + break; + } + // != + case 9: { + const a = this.stack.pop(); + const b = this.stack.pop(); + assume( + a.type == b.type, + `Tried to compare a ${a.type} to a ${b.type}.` + ); + const result = V.newInt(a.value !== b.value); + this.stack.push(result); + break; + } + // > + case 10: { + const a = this.stack.pop(); + const b = this.stack.pop(); + switch (a.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to add non-numbers."); + } + switch (b.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to add non-numbers."); + } + this.stack.push(V.newInt(a.value > b.value)); + break; + } + // >= + case 11: { + assume(false, "Unimplimented >= operator"); + break; + } + // < + case 12: { + const a = this.stack.pop(); + const b = this.stack.pop(); + switch (a.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to add non-numbers."); + } + switch (b.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to add non-numbers."); + } + this.stack.push(V.newInt(a.value < b.value)); + break; + } + // <= + case 13: { + assume(false, "Unimplimented <= operator"); + break; + } + // jumpIf + case 16: { + const value = this.stack.pop(); + // This seems backwards. Seems like we're doing a "jump if not" + if (value) { + break; + } + ip = command.arg - 1; + break; + } + // jumpIfNot + case 17: { + const value = this.stack.pop(); + // This seems backwards. Same as above + if (!value) { + break; + } + ip = command.arg - 1; + break; + } + // jump + case 18: { + ip = command.arg - 1; + break; + } + // call + // strangeCall (seems to behave just like regular call) + case 24: + case 112: { + const methodOffset = command.arg; + const method = this.methods[methodOffset]; + let methodName = method.name; + const returnType = method.returnType; + const classesOffset = method.typeOffset; + methodName = methodName.toLowerCase(); + + const guid = this.classes[classesOffset]; + const klass = this.classResolver(guid); + if (!klass) { + throw new Error("Need to add a missing class to runtime"); + } + // This is a bit awkward. Because the variables are stored on the stack + // before the object, we have to find the number of arguments without + // actually having access to the object instance. + if (!klass.prototype[methodName]) { + throw new Error( + `Need to add missing method "${methodName}" to ${klass.name}` + ); + } + let argCount = klass.prototype[methodName].length; + + const methodArgs = []; + while (argCount--) { + const a = this.stack.pop(); + methodArgs.push(a.value); + } + const obj = this.stack.pop(); + assert( + obj.type === "OBJECT", + "Tried to call a method on a primitive." + ); + let value = obj.value[methodName](...methodArgs); + if (typeof value.then === "function") { + throw new Error("Did not expect maki method to return promise"); + } + if (value === null) { + // variables[1] holds global NULL value + value = this.variables[1]; + } + this.stack.push({ type: returnType, value } as any); + break; + } + // callGlobal + case 25: { + this.callStack.push(ip); + const offset = command.arg; + + ip = offset - 1; // -1 because we ++ after the switch + break; + } + // return + case 33: { + ip = this.callStack.pop(); + // TODO: Stack protection? + break; + } + // complete + case 40: { + // noop for now + assume(false, "OPCODE: complete"); + break; + } + // mov + case 48: { + const a = this.stack.pop(); + const b = this.stack.pop(); + + assume( + a.type === b.type, + `Type mismatch: ${a.type} != ${b.type} at ip: ${ip}` + ); + b.value = a.value; + this.stack.push(a); + break; + } + // postinc + case 56: { + const a = this.stack.pop(); + switch (a.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to increment a non-number."); + } + const aValue = a.value; + a.value = aValue + 1; + this.stack.push({ type: a.type, value: aValue }); + break; + } + // postdec + case 57: { + const a = this.stack.pop(); + switch (a.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to decrement a non-number."); + } + const aValue = a.value; + a.value = aValue - 1; + this.stack.push({ type: a.type, value: aValue }); + break; + } + // preinc + case 58: { + const a = this.stack.pop(); + switch (a.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to increment a non-number."); + } + a.value++; + this.stack.push(a); + break; + } + // predec + case 59: { + const a = this.stack.pop(); + switch (a.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to increment a non-number."); + } + a.value--; + this.stack.push(a); + break; + } + // + (add) + case 64: { + const a = this.stack.pop(); + const b = this.stack.pop(); + switch (a.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to add non-numbers."); + } + switch (b.type) { + case "STRING": + case "OBJECT": + case "BOOL": + throw new Error("Tried to add non-numbers."); + } + // TODO: Do we need to round the value if INT? + this.stack.push({ type: a.type, value: a.value + b.value }); + break; + } + // - (subtract) + case 65: { + const a = this.stack.pop(); + const b = this.stack.pop(); + switch (a.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to add non-numbers."); + } + switch (b.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to add non-numbers."); + } + // TODO: Do we need to round the value if INT? + this.stack.push({ type: a.type, value: a.value - b.value }); + break; + } + // * (multiply) + case 66: { + const a = this.stack.pop(); + const b = this.stack.pop(); + switch (a.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to add non-numbers."); + } + switch (b.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to add non-numbers."); + } + // TODO: Do we need to round the value if INT? + this.stack.push({ type: a.type, value: a.value * b.value }); + break; + } + // / (divide) + case 67: { + const a = this.stack.pop(); + const b = this.stack.pop(); + switch (a.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to add non-numbers."); + } + switch (b.type) { + case "STRING": + case "OBJECT": + case "BOOL": + throw new Error("Tried to add non-numbers."); + } + // TODO: Do we need to round the value if INT? + this.stack.push({ type: a.type, value: a.value / b.value }); + break; + } + // % (mod) + case 68: { + const a = this.stack.pop(); + const b = this.stack.pop(); + switch (a.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to add non-numbers."); + } + switch (b.type) { + case "STRING": + case "OBJECT": + case "BOOL": + throw new Error("Tried to add non-numbers."); + // Need to coerce LHS if not int, RHS is always int (enforced by compiler) + case "FLOAT": + case "DOUBLE": + const value = Math.floor(b.value) % a.value; + this.stack.push({ type: a.type, value }); + break; + case "INT": + this.stack.push({ type: a.type, value: b.value % a.value }); + break; + } + break; + } + // & (binary and) + case 72: { + assume(false, "Unimplimented & operator"); + break; + } + // | (binary or) + case 73: { + assume(false, "Unimplimented | operator"); + break; + } + // ! (not) + case 74: { + const a = this.stack.pop(); + switch (a.type) { + case "STRING": + case "OBJECT": + throw new Error("Tried ! a string or object."); + } + this.stack.push(V.newInt(!a.value)); + break; + } + // - (negative) + case 76: { + const a = this.stack.pop(); + switch (a.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to add non-numbers."); + } + this.stack.push({ type: a.type, value: -a.value }); + break; + } + // logAnd (&&) + case 80: { + assume(false, "Unimplimented && operator"); + break; + } + // logOr || + case 81: { + const a = this.stack.pop(); + const b = this.stack.pop(); + // Some of these are probably valid, but we'll enable them once we see usage. + switch (a.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to add non-numbers."); + } + switch (b.type) { + case "STRING": + case "OBJECT": + case "BOOL": + case "NULL": + throw new Error("Tried to add non-numbers."); + } + if (a.value) { + return a; + } + return b; + } + // << + case 88: { + assume(false, "Unimplimented << operator"); + break; + } + // >> + case 89: { + assume(false, "Unimplimented >> operator"); + break; + } + // new + case 96: { + const classesOffset = command.arg; + const guid = this.classes[classesOffset]; + const Klass = this.classResolver(guid); + const klassInst = new Klass(); + this.stack.push(klassInst); + break; + } + // delete + case 97: { + const aValue = this.stack.pop(); + // TODO: Cleanup the object? + break; + } + default: + throw new Error(`Unhandled opcode ${command.opcode}`); + } + + ip++; + } + } +} diff --git a/packages/webamp-modern-2/src/maki/objectData/stdPatched.js b/packages/webamp-modern-2/src/maki/objectData/stdPatched.ts similarity index 96% rename from packages/webamp-modern-2/src/maki/objectData/stdPatched.js rename to packages/webamp-modern-2/src/maki/objectData/stdPatched.ts index f5239c49..3876fc70 100644 --- a/packages/webamp-modern-2/src/maki/objectData/stdPatched.js +++ b/packages/webamp-modern-2/src/maki/objectData/stdPatched.ts @@ -1,4 +1,4 @@ -const std = require("./std.json"); +import std from "./std.json"; const NAME_TO_DEF = {}; @@ -60,4 +60,4 @@ Found 1 error. */ -module.exports = std; +export default std; diff --git a/packages/webamp-modern-2/src/maki/objects.js b/packages/webamp-modern-2/src/maki/objects.ts similarity index 52% rename from packages/webamp-modern-2/src/maki/objects.js rename to packages/webamp-modern-2/src/maki/objects.ts index 21e7aa70..bb76604e 100644 --- a/packages/webamp-modern-2/src/maki/objects.js +++ b/packages/webamp-modern-2/src/maki/objects.ts @@ -1,13 +1,57 @@ -const stdPatched = require("./objectData/stdPatched"); -const pldir = require("./objectData/pldir.json"); -const config = require("./objectData/config.json"); +import stdPatched from "./objectData/stdPatched"; +import pldir from "./objectData/pldir.json"; +import config from "./objectData/config.json"; +import { DataType } from "./v"; -const objects = { ...stdPatched, ...pldir, ...config }; +type MethodDefinition = { + name: string; + result: string; + parameters: string[][]; +}; + +type ObjectDefinition = { + parent: string; + name: string; + functions: MethodDefinition[]; + parentClass?: ObjectDefinition; +}; + +const objects: { [key: string]: ObjectDefinition } = { + ...stdPatched, + ...pldir, + ...config, +}; + +export function getClass(id: String): ObjectDefinition { + return normalizedObjects[getFormattedId(id)]; +} + +export function getReturnType(classId: string, methodName: string): DataType { + const method = getMethod(classId, methodName); + const upper = method.result.toUpperCase(); + switch (upper) { + case "INT": + case "DOUBLE": + case "STRING": + case "FLOAT": + case "BOOL": + return upper as any; + case "": + return "NULL" as any; + default: + return "OBJECT" as any; + } +} + +function getMethod(classId: string, methodName: string): MethodDefinition { + const klass = getClass(classId); + return getObjectFunction(klass, methodName); +} // TODO: We could probably just fix the keys used in this file to already be normalized // We might even want to normalize the to match the formatting we get out the file. That could // avoid the awkward regex inside `getClass()`. -const normalizedObjects = {}; +const normalizedObjects: { [key: string]: ObjectDefinition } = {}; Object.keys(objects).forEach((key) => { normalizedObjects[key.toLowerCase()] = objects[key]; }); @@ -37,14 +81,14 @@ function getFormattedId(id) { return formattedId.toLowerCase(); } -function getClass(id) { - return normalizedObjects[getFormattedId(id)]; -} - -function getObjectFunction(klass, functionName) { +function getObjectFunction( + klass: ObjectDefinition, + functionName: string +): MethodDefinition { + const lowerName = functionName.toLowerCase(); const method = klass.functions.find((func) => { // TODO: This could probably be normalized at load time, or evern sooner. - return func.name.toLowerCase() === functionName.toLowerCase(); + return func.name.toLowerCase() === lowerName; }); if (method != null) { return method; @@ -54,25 +98,3 @@ function getObjectFunction(klass, functionName) { } return getObjectFunction(klass.parentClass, functionName); } - -function getFunctionObject(klass, functionName) { - const method = klass.functions.find((func) => { - // TODO: This could probably be normalized at load time, or evern sooner. - return func.name.toLowerCase() === functionName.toLowerCase(); - }); - if (method != null) { - return klass; - } - if (klass.parentClass == null) { - throw new Error(`Could not find method ${functionName} on ${klass.name}.`); - } - return getFunctionObject(klass.parentClass, functionName); -} - -module.exports = { - objects, - getFormattedId, - getClass, - getObjectFunction, - getFunctionObject, -}; diff --git a/packages/webamp-modern-2/src/maki/parser.js b/packages/webamp-modern-2/src/maki/parser.ts similarity index 73% rename from packages/webamp-modern-2/src/maki/parser.js rename to packages/webamp-modern-2/src/maki/parser.ts index 6d704cdf..685fb694 100644 --- a/packages/webamp-modern-2/src/maki/parser.js +++ b/packages/webamp-modern-2/src/maki/parser.ts @@ -1,5 +1,32 @@ +import { assert, assume } from "../utils"; import { COMMANDS } from "./constants"; -import Variable from "./variable"; +import { DataType, Variable } from "./v"; +import MakiFile from "./MakiFile"; +import { getMethod, getReturnType } from "./objects"; + +export type Command = { + opcode: number; + arg: number; +}; + +export type Method = { + name: string; + typeOffset: number; + returnType: DataType; +}; + +export type ParsedMaki = { + commands: Command[]; + methods: Method[]; + variables: Variable[]; + classes: string[]; + bindings: Binding[]; + version: number; +}; + +export type Binding = { + commandOffset: number; +}; const MAGIC = "FG"; @@ -11,7 +38,7 @@ const PRIMITIVE_TYPES = { 6: "STRING", }; -export function parse(data) { +export function parse(data: ArrayBuffer): ParsedMaki { const makiFile = new MakiFile(data); const magic = readMagic(makiFile); @@ -22,7 +49,7 @@ export function parse(data) { // Maybe it's additional version info? const extraVersion = makiFile.readUInt32LE(); const classes = readClasses(makiFile); - const methods = readMethods(makiFile); + const methods = readMethods(makiFile, classes); const variables = readVariables({ makiFile, classes }); readConstants({ makiFile, variables }); const bindings = readBindings(makiFile); @@ -42,33 +69,30 @@ export function parse(data) { } }); - const resolvedBindings = bindings.map((binding) => { + const resolvedBindings = bindings.map((binding): Binding => { return Object.assign({}, binding, { commandOffset: offsetToCommand[binding.binaryOffset], - binaryOffset: undefined, }); }); - const resolvedCommands = commands.map((command) => { + const resolvedCommands = commands.map((command): Command => { if (command.argType === "COMMAND_OFFSET") { return Object.assign({}, command, { arg: offsetToCommand[command.arg] }); } return command; }); return { - magic, classes, methods, variables, bindings: resolvedBindings, commands: resolvedCommands, version, - extraVersion, }; } // TODO: Don't depend upon COMMANDS -function opcodeToArgType(opcode) { +function opcodeToArgType(opcode: number) { const command = COMMANDS[opcode]; if (command == null) { throw new Error(`Unknown opcode ${opcode}`); @@ -87,77 +111,7 @@ function opcodeToArgType(opcode) { } } -// Holds a buffer and a pointer. Consumers can consume bytesoff the end of the -// file. When we want to run in the browser, we can refactor this class to use a -// typed array: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Typed_arrays -class MakiFile { - constructor(data) { - this._arr = new Uint8Array(data); - this._i = 0; - } - - readInt32LE() { - const offset = this._i >>> 0; - this._i += 4; - - return ( - this._arr[offset] | - (this._arr[offset + 1] << 8) | - (this._arr[offset + 2] << 16) | - (this._arr[offset + 3] << 24) - ); - } - - readUInt32LE() { - const int = this.peekUInt32LE(); - this._i += 4; - return int; - } - - peekUInt32LE() { - const offset = this._i >>> 0; - - return ( - (this._arr[offset] | - (this._arr[offset + 1] << 8) | - (this._arr[offset + 2] << 16)) + - this._arr[offset + 3] * 0x1000000 - ); - } - - readUInt16LE() { - const offset = this._i >>> 0; - this._i += 2; - return this._arr[offset] | (this._arr[offset + 1] << 8); - } - - readUInt8() { - const int = this._arr[this._i]; - this._i++; - return int; - } - - readStringOfLength(length) { - let ret = ""; - const end = Math.min(this._arr.length, this._i + length); - - for (let i = this._i; i < end; ++i) { - ret += String.fromCharCode(this._arr[i]); - } - this._i += length; - return ret; - } - - readString() { - return this.readStringOfLength(this.readUInt16LE()); - } - - getPosition() { - return this._i; - } -} - -function readMagic(makiFile) { +function readMagic(makiFile: MakiFile): string { const magic = makiFile.readStringOfLength(MAGIC.length); if (magic !== MAGIC) { throw new Error( @@ -167,12 +121,12 @@ function readMagic(makiFile) { return magic; } -function readVersion(makiFile) { +function readVersion(makiFile: MakiFile): number { // No idea what we're actually expecting here. return makiFile.readUInt16LE(); } -function readClasses(makiFile) { +function readClasses(makiFile: MakiFile): string[] { let count = makiFile.readUInt32LE(); const classes = []; while (count--) { @@ -186,9 +140,9 @@ function readClasses(makiFile) { return classes; } -function readMethods(makiFile) { +function readMethods(makiFile: MakiFile, classes: string[]): Method[] { let count = makiFile.readUInt32LE(); - const methods = []; + const methods: Method[] = []; while (count--) { const classCode = makiFile.readUInt16LE(); // Offset into our parsed types @@ -196,7 +150,12 @@ function readMethods(makiFile) { // This is probably the second half of a uint32 makiFile.readUInt16LE(); const name = makiFile.readString(); - methods.push({ name, typeOffset }); + + const className = classes[typeOffset]; + + const returnType = getReturnType(className, name); + + methods.push({ name, typeOffset, returnType }); } return methods; } @@ -220,17 +179,14 @@ function readVariables({ makiFile, classes }) { if (variable == null) { throw new Error("Invalid type"); } - variables.push( - new Variable({ type: variable, typeName: "SUBCLASS", global: !!global }) - ); + // assume(false, "Unimplemented subclass variable type"); + variables.push({ type: "OBJECT", value: object }); } else if (object) { const klass = classes[typeOffset]; if (klass == null) { throw new Error("Invalid type"); } - variables.push( - new Variable({ type: klass, typeName: "OBJECT", global: !!global }) - ); + variables.push({ type: "OBJECT", value: object }); } else { const typeName = PRIMITIVE_TYPES[typeOffset]; if (typeName == null) { @@ -259,12 +215,10 @@ function readVariables({ makiFile, classes }) { default: throw new Error("Invalid primitive type"); } - const variable = new Variable({ + const variable = { type: typeName, - typeName, - global: !!global, - }); - variable.setValue(value); + value, + }; variables.push(variable); } } @@ -279,7 +233,7 @@ function readConstants({ makiFile, variables }) { // TODO: Assert this is of type string. const value = makiFile.readString(); // TODO: Don't mutate - variable.setValue(value); + variable.value = value; } } @@ -346,8 +300,7 @@ function parseComand({ start, makiFile, length }) { makiFile.peekUInt32LE() >= 0xffff0000 && makiFile.peekUInt32LE() <= 0xffff000f ) { - command.foo = true; - command.stackProtection = makiFile.readUInt32LE(); + // command.stackProtection = makiFile.readUInt32LE(); } // TODO: What even is this? diff --git a/packages/webamp-modern-2/src/maki/prettyPrinter.js b/packages/webamp-modern-2/src/maki/prettyPrinter.js deleted file mode 100644 index 60201a27..00000000 --- a/packages/webamp-modern-2/src/maki/prettyPrinter.js +++ /dev/null @@ -1,55 +0,0 @@ -import Variable from "./variable"; -import runtime from "../runtime"; - -// Debug utility to pretty print a value/variable -function printValue(value) { - if (!(value instanceof Variable)) { - return value; - } - const variable = value; - let type = "UNKOWN"; - switch (variable.typeName) { - case "OBJECT": - const obj = runtime[variable.type]; - if (obj == null) { - type = "Unknown object"; - } else { - type = obj.getclassname(); - } - break; - case "STRING": - const str = variable.getValue(); - type = `STRING(${str})`; - break; - case "INT": - type = "INT"; - break; - case "FLOAT": - type = "FLOAT"; - break; - case "DOUBLE": - type = "DOUBLE"; - break; - case "BOOLEAN": - type = "BOOLEAN"; - break; - default: - throw new Error(`Unknown variable type ${variable.typeName}`); - } - return `Variable(${type})`; -} - -function printCommand({ i, command, stack, variables }) { - console.log( - `${i} (${command.start} + ${command.offset})`, - command.command.name.toUpperCase(), - command.opcode, - printValue(variables[command.arg]) - ); - stack.forEach((value, j) => { - const name = printValue(value, { runtime }); - console.log(" ", j + 1, name); - }); -} - -export default { printCommand }; diff --git a/packages/webamp-modern-2/src/maki/v.ts b/packages/webamp-modern-2/src/maki/v.ts new file mode 100644 index 00000000..742de3df --- /dev/null +++ b/packages/webamp-modern-2/src/maki/v.ts @@ -0,0 +1,32 @@ +import GuiObj from "../skin/GuiObj"; + +export type Variable = + | { + type: "BOOL"; + value: boolean; + } + | { + type: "INT" | "FLOAT" | "DOUBLE"; + value: number; + } + | { + type: "STRING"; + value: string; + } + | { + type: "OBJECT"; + value: GuiObj; + } + | { + type: "NULL"; + value: null; + }; + +export type DataType = Pick; + +export const V = { + // TODO: Split boolean out into its own method + newInt(value: number | boolean): Variable { + return { type: "INT", value: Number(value) }; + }, +}; diff --git a/packages/webamp-modern-2/src/maki/variable.js b/packages/webamp-modern-2/src/maki/variable.js deleted file mode 100644 index 7607db15..00000000 --- a/packages/webamp-modern-2/src/maki/variable.js +++ /dev/null @@ -1,17 +0,0 @@ -class Variable { - constructor({ type, typeName, global }) { - this.type = type; - this.typeName = typeName; - this.global = global; - } - - getValue() { - return this._value; - } - - setValue(value) { - this._value = value; - } -} - -export default Variable; diff --git a/packages/webamp-modern-2/src/skin/Container.ts b/packages/webamp-modern-2/src/skin/Container.ts index 53fa284c..b1e46e4b 100644 --- a/packages/webamp-modern-2/src/skin/Container.ts +++ b/packages/webamp-modern-2/src/skin/Container.ts @@ -31,6 +31,12 @@ export default class Container extends XmlObj { return true; } + init() { + for (const layout of this._layouts) { + layout.init(); + } + } + addLayout(layout: Layout) { layout.setParent(this); this._layouts.push(layout); diff --git a/packages/webamp-modern-2/src/skin/Group.ts b/packages/webamp-modern-2/src/skin/Group.ts index e1e3e130..e7f862c0 100644 --- a/packages/webamp-modern-2/src/skin/Group.ts +++ b/packages/webamp-modern-2/src/skin/Group.ts @@ -43,6 +43,15 @@ export default class Group extends GuiObj { return true; } + init() { + for (const systemObject of this._systemObjects) { + systemObject.init(); + } + for (const child of this._children) { + child.init(); + } + } + addSystemObject(systemObj: SystemObject) { systemObj.setParentGroup(this); this._systemObjects.push(systemObj); diff --git a/packages/webamp-modern-2/src/skin/GuiObj.ts b/packages/webamp-modern-2/src/skin/GuiObj.ts index 4104627e..8aa9261f 100644 --- a/packages/webamp-modern-2/src/skin/GuiObj.ts +++ b/packages/webamp-modern-2/src/skin/GuiObj.ts @@ -40,6 +40,10 @@ export default class GuiObj extends XmlObj { return true; } + init() { + // pass + } + getDebugDom(): HTMLDivElement { const div = window.document.createElement("div"); div.style.display = this._visible ? "inline-block" : "none"; diff --git a/packages/webamp-modern-2/src/skin/SystemObject.ts b/packages/webamp-modern-2/src/skin/SystemObject.ts index 68933bdf..c03e8d99 100644 --- a/packages/webamp-modern-2/src/skin/SystemObject.ts +++ b/packages/webamp-modern-2/src/skin/SystemObject.ts @@ -1,6 +1,8 @@ -import Group from "./Group"; +import { interpret } from "../maki/interpreter"; +import { getClass } from "../maki/objects"; +import { ParsedMaki } from "../maki/parser"; -type ParsedMaki = Object; // Not typed yet. +import Group from "./Group"; export default class SystemObject { _parentGroup: Group; @@ -10,7 +12,39 @@ export default class SystemObject { this._parsedScript = parsedScript; } + init() { + const initialVariable = this._parsedScript.variables[0]; + if (initialVariable.type !== "OBJECT") { + throw new Error("First variable was not SystemObject."); + } + initialVariable.value = this; + // TODO: How should we setup bindings? + // console.log(this._parsedScript.bindings); + interpret(0, this._parsedScript, classResover); + } + setParentGroup(group: Group) { this._parentGroup = group; } + + /* Required for Maki */ + getruntimeversion(): number { + return 5.666; + } + + getskinname() { + return "TODO: Get the Real skin name"; + } +} + +function classResover(guid: string): any { + switch (guid) { + case "d6f50f6449b793fa66baf193983eaeef": + return SystemObject; + default: + console.log(); + throw new Error( + `Unresolvable class "${getClass(guid).name}" (guid: ${guid})` + ); + } }