diff --git a/packages/webamp-modern-2/src/index.ts b/packages/webamp-modern-2/src/index.ts index 60b186d5..f999094b 100644 --- a/packages/webamp-modern-2/src/index.ts +++ b/packages/webamp-modern-2/src/index.ts @@ -1,8 +1,17 @@ import JSZip from "jszip"; +// This module is imported early here in order to avoid a circular dependency. +import { classResolver } from "./skin/resolver"; import SkinParser from "./skin/parse"; +function hack() { + // Without this Snowpack will try to treeshake out resolver causing a circular + // dependency. + classResolver("A funny joke about why this is needed."); +} + async function main() { const response = await fetch("assets/CornerAmp_Redux.wal"); + // const response = await fetch("assets/Default_winamp3_build499.wal"); const data = await response.blob(); const zip = await JSZip.loadAsync(data); @@ -22,6 +31,9 @@ async function main() { document.body.appendChild(node); setInterval(() => { + if (window.pause) { + return; + } document.body.removeChild(node); node = document.createElement("div"); for (const container of parser._containers) { diff --git a/packages/webamp-modern-2/src/maki/interpreter.ts b/packages/webamp-modern-2/src/maki/interpreter.ts index b932ebab..2a47a0b9 100644 --- a/packages/webamp-modern-2/src/maki/interpreter.ts +++ b/packages/webamp-modern-2/src/maki/interpreter.ts @@ -5,9 +5,11 @@ import { ParsedMaki, Command, Method } from "./parser"; export function interpret( start: number, program: ParsedMaki, + stack: Variable[], classResolver: (guid: string) => any ) { const interpreter = new Interpreter(program, classResolver); + interpreter.stack = stack; return interpreter.interpret(start); } @@ -18,7 +20,7 @@ class Interpreter { variables: Variable[]; methods: Method[]; commands: Command[]; - debug: boolean; + debug: boolean = false; classResolver: (guid: string) => any; constructor(program: ParsedMaki, classResolver: (guid: string) => any) { const { commands, methods, variables, classes } = program; @@ -30,7 +32,6 @@ class Interpreter { this.stack = []; this.callStack = []; - this.debug = false; } interpret(start: number) { diff --git a/packages/webamp-modern-2/src/maki/parser.ts b/packages/webamp-modern-2/src/maki/parser.ts index fed68991..9ab84c12 100644 --- a/packages/webamp-modern-2/src/maki/parser.ts +++ b/packages/webamp-modern-2/src/maki/parser.ts @@ -2,7 +2,7 @@ import { assert, assume } from "../utils"; import { COMMANDS } from "./constants"; import { DataType, Variable } from "./v"; import MakiFile from "./MakiFile"; -import { getMethod, getReturnType } from "./objects"; +import { getReturnType } from "./objects"; export type Command = { opcode: number; @@ -27,6 +27,8 @@ export type ParsedMaki = { export type Binding = { commandOffset: number; methodOffset: number; + variableOffset: number; + binaryOffset: number; }; const MAGIC = "FG"; @@ -70,18 +72,24 @@ export function parse(data: ArrayBuffer): ParsedMaki { } }); - const resolvedBindings = bindings.map((binding): Binding => { - return Object.assign({}, binding, { - commandOffset: offsetToCommand[binding.binaryOffset], - }); - }); - - const resolvedCommands = commands.map((command): Command => { - if (command.argType === "COMMAND_OFFSET") { - return Object.assign({}, command, { arg: offsetToCommand[command.arg] }); + const resolvedBindings = bindings.map( + (binding): Binding => { + return Object.assign({}, binding, { + commandOffset: offsetToCommand[binding.binaryOffset], + }); } - return command; - }); + ); + + const resolvedCommands = commands.map( + (command): Command => { + if (command.argType === "COMMAND_OFFSET") { + return Object.assign({}, command, { + arg: offsetToCommand[command.arg], + }); + } + return command; + } + ); return { classes, methods, @@ -150,7 +158,7 @@ function readMethods(makiFile: MakiFile, classes: string[]): Method[] { const typeOffset = classCode & 0xff; // This is probably the second half of a uint32 makiFile.readUInt16LE(); - const name = makiFile.readString(); + const name = makiFile.readString().toLowerCase(); const className = classes[typeOffset]; @@ -238,7 +246,7 @@ function readConstants({ makiFile, variables }) { } } -function readBindings(makiFile) { +function readBindings(makiFile: MakiFile): Binding[] { let count = makiFile.readUInt32LE(); const bindings = []; while (count--) { diff --git a/packages/webamp-modern-2/src/maki/v.ts b/packages/webamp-modern-2/src/maki/v.ts index 742de3df..cff2398b 100644 --- a/packages/webamp-modern-2/src/maki/v.ts +++ b/packages/webamp-modern-2/src/maki/v.ts @@ -1,4 +1,4 @@ -import GuiObj from "../skin/GuiObj"; +import BaseObject from "../skin/BaseObject"; export type Variable = | { @@ -15,7 +15,7 @@ export type Variable = } | { type: "OBJECT"; - value: GuiObj; + value: BaseObject; } | { type: "NULL"; diff --git a/packages/webamp-modern-2/src/skin/Container.ts b/packages/webamp-modern-2/src/skin/Container.ts index 967fbb5d..174d0b4d 100644 --- a/packages/webamp-modern-2/src/skin/Container.ts +++ b/packages/webamp-modern-2/src/skin/Container.ts @@ -1,7 +1,5 @@ import { SkinContext } from "../types"; import { toBool } from "../utils"; -import Group from "./Group"; -import Layer from "./Layer"; import Layout from "./Layout"; import XmlObj from "./XmlObj"; @@ -74,6 +72,9 @@ export default class Container extends XmlObj { getDebugDom(): HTMLDivElement { const div = window.document.createElement("div"); + div.setAttribute("data-xml-id", this.getId()); + div.setAttribute("data-obj-name", "Container"); + if (this._defaultVisible && this._activeLayout) { div.appendChild(this._activeLayout.getDebugDom()); } diff --git a/packages/webamp-modern-2/src/skin/Group.ts b/packages/webamp-modern-2/src/skin/Group.ts index a38957a9..b826bd44 100644 --- a/packages/webamp-modern-2/src/skin/Group.ts +++ b/packages/webamp-modern-2/src/skin/Group.ts @@ -76,6 +76,7 @@ export default class Group extends GuiObj { getDebugDom(): HTMLDivElement { const div = super.getDebugDom(); + div.setAttribute("data-obj-name", "Group"); div.style.height = Utils.px(this._maximumHeight); div.style.width = Utils.px(this._maximumWidth); if (this._background != null && this._drawBackground) { diff --git a/packages/webamp-modern-2/src/skin/GuiObj.ts b/packages/webamp-modern-2/src/skin/GuiObj.ts index 17a3df15..0844fcdc 100644 --- a/packages/webamp-modern-2/src/skin/GuiObj.ts +++ b/packages/webamp-modern-2/src/skin/GuiObj.ts @@ -1,5 +1,6 @@ import { SkinContext } from "../types"; import * as Utils from "../utils"; +import { VM } from "./VM"; import XmlObj from "./XmlObj"; // http://wiki.winamp.com/wiki/XML_GUI_Objects#GuiObject_.28Global_params.29 @@ -112,8 +113,65 @@ export default class GuiObj extends XmlObj { this._dirty = true; } + /** + * Hookable. Event happens when the left mouse + * button was previously down and is now up. + * + * @param x The X position in the screen where the cursor was when the event was triggered. + * @param y The Y position in the screen where the cursor was when the event was triggered. + */ + onLeftButtonUp(x: number, y: number) { + VM.dispatch(this, "onleftbuttonup", [ + { type: "INT", value: x }, + { type: "INT", value: y }, + ]); + } + + /** + * Hookable. Event happens when the left mouse button + * is pressed. + * + * @param x The X position in the screen where the cursor was when the event was triggered. + * @param y The Y position in the screen where the cursor was when the event was triggered. + */ + onLeftButtonDown(x: number, y: number) { + VM.dispatch(this, "onleftbuttondown", [ + { type: "INT", value: x }, + { type: "INT", value: y }, + ]); + } + + /** + * Hookable. Event happens when the right mouse button + * was previously down and is now up. + * + * @param x The X position in the screen where the cursor was when the event was triggered. + * @param y The Y position in the screen where the cursor was when the event was triggered. + */ + onRightButtonUp(x: number, y: number) { + VM.dispatch(this, "onrightbuttonup", [ + { type: "INT", value: x }, + { type: "INT", value: y }, + ]); + } + + /** + * Hookable. Event happens when the right mouse button + * is pressed. + * + * @param x The X position in the screen where the cursor was when the event was triggered. + * @param y The Y position in the screen where the cursor was when the event was triggered. + */ + onRightButtonDown(x: number, y: number) { + VM.dispatch(this, "onrightbuttondown", [ + { type: "INT", value: x }, + { type: "INT", value: y }, + ]); + } + getDebugDom(): HTMLDivElement { const div = window.document.createElement("div"); + div.setAttribute("data-id", this.getId()); div.style.display = this._visible ? "inline-block" : "none"; div.style.position = "absolute"; if (this._x) { @@ -128,6 +186,13 @@ export default class GuiObj extends XmlObj { if (this._height) { div.style.height = Utils.px(this._height); } + div.addEventListener("mouseup", (e) => { + this.onLeftButtonUp(e.clientX, e.clientX); + }); + + div.addEventListener("mousedown", (e) => { + this.onLeftButtonDown(e.clientX, e.clientX); + }); return div; } } diff --git a/packages/webamp-modern-2/src/skin/Layer.ts b/packages/webamp-modern-2/src/skin/Layer.ts index 11640516..d2c2266c 100644 --- a/packages/webamp-modern-2/src/skin/Layer.ts +++ b/packages/webamp-modern-2/src/skin/Layer.ts @@ -22,6 +22,7 @@ export default class Layer extends GuiObj { getDebugDom(): HTMLDivElement { const div = super.getDebugDom(); + div.setAttribute("data-obj-name", "Layer"); if (this._image != null) { const bitmap = UI_ROOT.getBitmap(this._image); div.style.background = bitmap.getBackgrondCSSAttribute(); diff --git a/packages/webamp-modern-2/src/skin/Layout.ts b/packages/webamp-modern-2/src/skin/Layout.ts index 1d974106..2866fb21 100644 --- a/packages/webamp-modern-2/src/skin/Layout.ts +++ b/packages/webamp-modern-2/src/skin/Layout.ts @@ -1,7 +1,6 @@ import Group from "./Group"; import * as Utils from "../utils"; import Container from "./Container"; -import Layer from "./Layer"; // > A layout is a special kind of group, which shown inside a container. Each // > layout represents an appearance for that window. Layouts give you the ability @@ -31,4 +30,10 @@ export default class Layout extends Group { setParent(container: Container) { this._parent = container; } + + getDebugDom(): HTMLDivElement { + const div = super.getDebugDom(); + div.setAttribute("data-obj-name", "Layout"); + return div; + } } diff --git a/packages/webamp-modern-2/src/skin/SystemObject.ts b/packages/webamp-modern-2/src/skin/SystemObject.ts index 5162b700..b55b9ff1 100644 --- a/packages/webamp-modern-2/src/skin/SystemObject.ts +++ b/packages/webamp-modern-2/src/skin/SystemObject.ts @@ -1,24 +1,19 @@ -import { interpret } from "../maki/interpreter"; import { getClass } from "../maki/objects"; import { ParsedMaki } from "../maki/parser"; import { SkinContext } from "../types"; -import Button from "./Button"; +import BaseObject from "./BaseObject"; import Container from "./Container"; import Group from "./Group"; -import Layer from "./Layer"; -import Layout from "./Layout"; -import PopupMenu from "./PopupMenu"; -import Status from "./Status"; -import Text from "./Text"; -import ToggleButton from "./ToggleButton"; +import { VM } from "./VM"; -export default class SystemObject { +export default class SystemObject extends BaseObject { _parentGroup: Group; _parsedScript: ParsedMaki; _context: SkinContext; constructor(parsedScript: ParsedMaki) { + super(); this._parsedScript = parsedScript; } @@ -30,15 +25,9 @@ export default class SystemObject { 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); - interpret( - this._parsedScript.bindings[0].commandOffset, - this._parsedScript, - classResover - ); + VM.addScript(this._parsedScript); + VM.dispatch(this, "onscriptloaded"); } setParentGroup(group: Group) { @@ -166,33 +155,6 @@ export default class SystemObject { } } -function classResover(guid: string): any { - switch (guid) { - case "d6f50f6449b793fa66baf193983eaeef": - return SystemObject; - case "e90dc47b4ae7840d0b042cb0fcf775d2": - return Container; - case "60906d4e482e537e94cc04b072568861": - return Layout; - case "5ab9fa1545579a7d5765c8aba97cc6a6": - return Layer; - case "f4787af44ef7b2bb4be7fb9c8da8bea9": - return PopupMenu; - case "698eddcd4fec8f1e44f9129b45ff09f9": - return Button; - case "b4dccfff4bcc81fe0f721b96ff0fbed5": - return ToggleButton; - case "efaa867241fa310ea985dcb74bcb5b52": - return Text; - case "0f08c9404b23af39c4b8f38059bb7e8f": - return Status; - default: - throw new Error( - `Unresolvable class "${getClass(guid).name}" (guid: ${guid})` - ); - } -} - function dumpScriptDebug(script: ParsedMaki) { for (const [i, binding] of script.bindings.entries()) { const method = script.methods[binding.methodOffset]; diff --git a/packages/webamp-modern-2/src/skin/VM.ts b/packages/webamp-modern-2/src/skin/VM.ts new file mode 100644 index 00000000..955998ab --- /dev/null +++ b/packages/webamp-modern-2/src/skin/VM.ts @@ -0,0 +1,37 @@ +import { interpret } from "../maki/interpreter"; +import { ParsedMaki } from "../maki/parser"; +import { Variable } from "../maki/v"; +import BaseObject from "./BaseObject"; + +import { classResolver } from "./resolver"; + +class Vm { + _scripts: ParsedMaki[] = []; + // This could easily become performance sensitive. We could make this more + // performant by normalizing some of these things when scripts are added. + dispatch(object: BaseObject, event: string, args: Variable[] = []) { + for (const [scriptId, script] of this._scripts.entries()) { + for (const binding of script.bindings) { + if ( + script.methods[binding.methodOffset].name === event && + script.variables[binding.variableOffset].value === object + ) { + this.interpret(scriptId, binding.commandOffset, args); + } + } + } + } + + addScript(maki: ParsedMaki): number { + const index = this._scripts.length; + this._scripts.push(maki); + return index; + } + + interpret(scriptId: number, commandOffset: number, args: Variable[]) { + const script = this._scripts[scriptId]; + interpret(commandOffset, script, args, classResolver); + } +} + +export const VM = new Vm(); diff --git a/packages/webamp-modern-2/src/skin/XmlObj.ts b/packages/webamp-modern-2/src/skin/XmlObj.ts index 40535724..24f68b45 100644 --- a/packages/webamp-modern-2/src/skin/XmlObj.ts +++ b/packages/webamp-modern-2/src/skin/XmlObj.ts @@ -1,4 +1,6 @@ -export default class XmlObj { +import BaseObject from "./BaseObject"; + +export default class XmlObj extends BaseObject { setXmlAttributes(attributes: { [attrName: string]: string }) { for (const [key, value] of Object.entries(attributes)) { this.setXmlAttr(key, value); diff --git a/packages/webamp-modern-2/src/skin/parse.ts b/packages/webamp-modern-2/src/skin/parse.ts index 2b111b98..e45c43e9 100644 --- a/packages/webamp-modern-2/src/skin/parse.ts +++ b/packages/webamp-modern-2/src/skin/parse.ts @@ -55,6 +55,8 @@ export default class SkinParser { switch (node.name.toLowerCase()) { case "wasabixml": return this.wasabiXml(node); + case "winampabstractionlayer": + return this.winampAbstractionLayer(node); case "include": return this.include(node); case "skininfo": @@ -63,6 +65,8 @@ export default class SkinParser { return this.elements(node); case "bitmap": return this.bitmap(node); + case "bitmapfont": + return this.bitmapFont(node); case "color": return this.color(node); case "groupdef": @@ -130,6 +134,10 @@ export default class SkinParser { await this.traverseChildren(node); } + async winampAbstractionLayer(node: XmlElement) { + await this.traverseChildren(node); + } + async elements(node: XmlElement) { await this.traverseChildren(node); } @@ -153,6 +161,13 @@ export default class SkinParser { UI_ROOT.addBitmap(bitmap); } + async bitmapFont(node: XmlElement) { + assume( + node.children.length === 0, + "Unexpected children in XML node." + ); + } + async text(node: XmlElement) { assume( node.children.length === 0, @@ -416,7 +431,10 @@ export default class SkinParser { const path = [...this._path, fileName].join("/"); const zipFile = this.getCaseInsensitiveFile(path); - assert(zipFile != null, `Zip file not found for ${file}`); + if (zipFile == null) { + console.warn(`Zip file not found: ${file}`); + return; + } const includedXml = await zipFile.async("string"); // Note: Included files don't have a single root node, so we add a synthetic one. diff --git a/packages/webamp-modern-2/src/skin/resolver.ts b/packages/webamp-modern-2/src/skin/resolver.ts new file mode 100644 index 00000000..c9cd032e --- /dev/null +++ b/packages/webamp-modern-2/src/skin/resolver.ts @@ -0,0 +1,39 @@ +import { getClass } from "../maki/objects"; +import Button from "./Button"; +import SystemObject from "./SystemObject"; +import Container from "./Container"; +import Layout from "./Layout"; +import Layer from "./Layer"; +import PopupMenu from "./PopupMenu"; +import ToggleButton from "./ToggleButton"; +import Status from "./Status"; +import Text from "./Text"; + +// TODO: We could write a test using the data in object.ts which confirms that +// this is complete. +export function classResolver(guid: string): any { + switch (guid) { + case "d6f50f6449b793fa66baf193983eaeef": + return SystemObject; + case "e90dc47b4ae7840d0b042cb0fcf775d2": + return Container; + case "60906d4e482e537e94cc04b072568861": + return Layout; + case "5ab9fa1545579a7d5765c8aba97cc6a6": + return Layer; + case "f4787af44ef7b2bb4be7fb9c8da8bea9": + return PopupMenu; + case "698eddcd4fec8f1e44f9129b45ff09f9": + return Button; + case "b4dccfff4bcc81fe0f721b96ff0fbed5": + return ToggleButton; + case "efaa867241fa310ea985dcb74bcb5b52": + return Text; + case "0f08c9404b23af39c4b8f38059bb7e8f": + return Status; + default: + throw new Error( + `Unresolvable class "${getClass(guid).name}" (guid: ${guid})` + ); + } +}