Skip to content
Snippets Groups Projects
index.ts 39.1 KiB
Newer Older
  • Learn to ignore specific revisions
  • import { startApp } from "./react/app";
    
    
    import { default as _wasm, GameBoy, PadKey, PpuMode } from "./lib/boytacean.js";
    
    import info from "./package.json";
    
    
    declare const require: any;
    
    
    const PIXEL_UNSET_COLOR = 0x1b1a17ff;
    
    const LOGIC_HZ = 600;
    const VISUAL_HZ = 60;
    const TIMER_HZ = 60;
    const IDLE_HZ = 10;
    
    const FREQUENCY_DELTA = 60;
    
    const DISPLAY_WIDTH = 160;
    const DISPLAY_HEIGHT = 144;
    
    const DISPLAY_RATIO = DISPLAY_WIDTH / DISPLAY_HEIGHT;
    
    const SAMPLE_RATE = 2;
    
    const BACKGROUNDS = [
        "264653",
        "1b1a17",
        "023047",
        "bc6c25",
        "283618",
        "2a9d8f",
        "3a5a40"
    ];
    
    const KEYS: Record<string, number> = {
    
        ArrowUp: PadKey.Up,
        ArrowDown: PadKey.Down,
        ArrowLeft: PadKey.Left,
        ArrowRight: PadKey.Right,
        Enter: PadKey.Start,
        " ": PadKey.Select,
        a: PadKey.A,
        s: PadKey.B
    
    const ROM_PATH = require("../../res/roms/20y.gb");
    
    // Enumeration that describes the multiple pixel
    
    // formats and the associated size in bytes.
    
    enum PixelFormat {
        RGB = 3,
        RGBA = 4
    }
    
    
    /**
     * Top level class that controls the emulator behaviour
     * and "joins" all the elements together to bring input/output
     * of the associated machine.
     */
    
    class Emulator {
        /**
         * The Game Boy engine (probably coming from WASM) that
         * is going to be used for the emulation.
         */
        private gameBoy: GameBoy | null = null;
    
        /**
         * The descriptive name of the engine that is currently
         * in use to emulate the system.
         */
        private engine: string | null = null;
    
        private logicFrequency: number = LOGIC_HZ;
        private visualFrequency: number = VISUAL_HZ;
        private timerFrequency: number = TIMER_HZ;
        private idleFrequency: number = IDLE_HZ;
    
        private canvas: HTMLCanvasElement | null = null;
        private canvasScaled: HTMLCanvasElement | null = null;
        private canvasCtx: CanvasRenderingContext2D | null = null;
        private canvasScaledCtx: CanvasRenderingContext2D | null = null;
        private image: ImageData | null = null;
        private videoBuff: DataView | null = null;
        private toastTimeout: number | null = null;
        private paused: boolean = false;
        private nextTickTime: number = 0;
        private fps: number = 0;
        private frameStart: number = new Date().getTime();
        private frameCount: number = 0;
    
        private romName: string | null = null;
        private romData: Uint8Array | null = null;
        private romSize: number = 0;
    
        async main() {
            // initializes the WASM module, this is required
            // so that the global symbols become available
            await wasm();
    
            // initializes the complete set of sub-systems
            // and registers the event handlers
            await this.buildVisuals();
            await this.init();
            await this.register();
    
            // start the emulator subsystem with the initial
            // ROM retrieved from a remote data source
            await this.start({ loadRom: true });
    
            // the counter that controls the overflowing cycles
            // from tick to tick operation
            let pending = 0;
    
            // runs the sequence as an infinite loop, running
            // the associated CPU cycles accordingly
            while (true) {
                // in case the machine is paused we must delay the execution
                // a little bit until the paused state is recovered
                if (this.paused) {
                    await new Promise((resolve) => {
                        setTimeout(resolve, 1000 / this.idleFrequency);
                    });
                    continue;
                }
    
                // obtains the current time, this value is going
                // to be used to compute the need for tick computation
                let currentTime = new Date().getTime();
    
                try {
                    pending = this.tick(currentTime, pending);
                } catch (err) {
                    // sets the default error message to be displayed
                    // to the user, this value may be overridden in case
                    // a better and more explicit message can be determined
                    let message = String(err);
    
                    // verifies if the current issue is a panic one
                    // and updates the message value if that's the case
                    const messageNormalized = (err as Error).message.toLowerCase();
                    const isPanic =
                        messageNormalized.startsWith("unreachable") ||
                        messageNormalized.startsWith("recursive use of an object");
                    if (isPanic) {
                        message = "Unrecoverable error, restarting Game Boy";
                    }
    
                    // displays the error information to both the end-user
                    // and the developer (for diagnostics)
                    this.showToast(message, true, 5000);
                    console.error(err);
    
                    // pauses the machine, allowing the end-user to act
                    // on the error in a proper fashion
                    this.pause();
    
                    // if we're talking about a panic, proper action must be taken
                    // which in this case it means restarting both the WASM sub
                    // system and the machine state (to be able to recover)
                    // also sets the default color on screen to indicate the issue
                    if (isPanic) {
                        await this.clearCanvas(undefined, {
                            image: require("./res/storm.png"),
                            imageScale: 0.2
                        });
    
                        await wasm();
                        await this.start({ restore: false });
                    }
                }
    
                // calculates the amount of time until the next draw operation
                // this is the amount of time that is going to be pending
                currentTime = new Date().getTime();
                const pendingTime = Math.max(this.nextTickTime - currentTime, 0);
    
                // waits a little bit for the next frame to be draw,
                // this should control the flow of render
    
                await new Promise((resolve) => {
    
                    setTimeout(resolve, pendingTime);
    
        tick(currentTime: number, pending: number, cycles: number = 70224) {
            // in case the time to draw the next frame has not been
            // reached the flush of the "tick" logic is skipped
            if (currentTime < this.nextTickTime) return pending;
    
            // calculates the number of ticks that have elapsed since the
            // last draw operation, this is critical to be able to properly
            // operate the clock of the CPU in frame drop situations
            if (this.nextTickTime === 0) this.nextTickTime = currentTime;
            let ticks = Math.ceil(
                (currentTime - this.nextTickTime) /
                    ((1 / this.visualFrequency) * 1000)
            );
            ticks = Math.max(ticks, 1);
    
            // initializes the counter of cycles with the pending number
            // of cycles coming from the previous tick
            let counterCycles = pending;
    
            let lastFrame = -1;
    
            while (true) {
                // limits the number of cycles to the provided
                // cycle value passed as a parameter
                if (counterCycles >= cycles) {
                    break;
                }
    
                // runs the Game Boy clock, this operations should
                // include the advance of both the CPU and the PPU
                counterCycles += this.gameBoy!.clock();
    
                // in case the current PPU mode is VBlank and the
                // frame is different from the previously rendered
                // one then it's time to update the canvas
                if (
                    this.gameBoy!.ppu_mode() == PpuMode.VBlank &&
                    this.gameBoy!.ppu_frame() != lastFrame
                ) {
                    // updates the canvas object with the new
                    // visual information coming in
                    this.updateCanvas(
                        this.gameBoy!.frame_buffer_eager(),
                        PixelFormat.RGB
                    );
                    lastFrame = this.gameBoy!.ppu_frame();
    
            // increments the number of frames rendered in the current
            // section, this value is going to be used to calculate FPS
            this.frameCount += 1;
    
            // in case the target number of frames for FPS control
            // has been reached calculates the number of FPS and
            // flushes the value to the screen
            if (this.frameCount === this.visualFrequency * SAMPLE_RATE) {
                const currentTime = new Date().getTime();
                const deltaTime = (currentTime - this.frameStart) / 1000;
                const fps = Math.round(this.frameCount / deltaTime);
                this.setFps(fps);
                this.frameCount = 0;
                this.frameStart = currentTime;
            }
    
            // updates the next update time reference to the, so that it
            // can be used to control the game loop
            this.nextTickTime += (1000 / this.visualFrequency) * ticks;
    
            // calculates the new number of pending (overflow) cycles
            // that are going to be added to the next iteration
            return counterCycles - cycles;
    
        /**
         * Starts the current machine, setting the internal structure in
         * a proper state to start drawing and receiving input.
         *
         * @param options The options that are going to be used in the
         * starting of the machine.
         */
        async start({
            engine = "neo",
            restore = true,
            loadRom = false,
            romPath = ROM_PATH,
            romName = null,
            romData = null
        }: {
            engine?: string | null;
            restore?: boolean;
            loadRom?: boolean;
            romPath?: string;
            romName?: string | null;
            romData?: Uint8Array | null;
        } = {}) {
            // in case a remote ROM loading operation has been
            // requested then loads it from the remote origin
            if (loadRom) {
                [romName, romData] = await this.fetchRom(romPath);
            } else if (romName === null || romData === null) {
                [romName, romData] = [this.romName, this.romData];
    
            // selects the proper engine for execution
            // and builds a new instance of it
            switch (engine) {
                case "neo":
                    this.gameBoy = new GameBoy();
                    break;
    
                default:
                    if (!this.gameBoy) {
                        throw new Error("No engine requested");
                    }
                    break;
    
            // resets the Game Boy engine to restore it into
            // a valid state ready to be used
            this.gameBoy.reset();
            this.gameBoy.load_boot_default();
            const cartridge = this.gameBoy.load_rom_ws(romData!);
    
            // updates the ROM name in case there's extra information
            // coming from the cartridge
            romName = cartridge.title() ? cartridge.title() : romName;
    
            // updates the name of the currently selected engine
            // to the one that has been provided (logic change)
            if (engine) this.engine = engine;
    
            // updates the complete set of global information that
            // is going to be displayed
            this.setEngine(this.engine!);
            this.setRom(romName!, romData!);
            this.setLogicFrequency(this.logicFrequency);
            this.setFps(this.fps);
    
            // in case the restore (state) flag is set
            // then resumes the machine execution
            if (restore) this.resume();
    
        async buildVisuals() {
            /*     KeyValue.create("ROM", "-", { id: "diag:rom-name" }).mount(".diag");
            KeyValue.create("ROM Size", "-", { id: "diag:rom-size" }).mount(
                ".diag"
            );
            KeyValue.create("Framerate", "-", { id: "diag:framerate" }).mount(
                ".diag"
            );
            KeyValue.create("ROM Type", "-", { id: "diag:rom-type" }).mount(
                ".diag"
            );
            KeySwitch.create("Tobias", ["1", "2", "3"], {
                id: "diag:tobias"
            }).mount(".diag");
    
            Button.create("Tobias", require("./res/close.svg"))
                .bind("click", () => alert("Hello World"))
                .mount(".button-area");*/
        }
    
        // @todo remove this method, or at least most of it
        async register() {
            await Promise.all([
                this.registerDrop(),
                this.registerKeys(),
                this.registerButtons(),
                this.registerKeyboard(),
                this.registerCanvas(),
                this.registerToast(),
                this.registerModal()
            ]);
        }
    
        async init() {
            await Promise.all([this.initBase(), this.initCanvas()]);
    
        registerDrop() {
            document.addEventListener("drop", async (event) => {
                if (
                    !event.dataTransfer!.files ||
                    event.dataTransfer!.files.length === 0
                ) {
                    return;
                }
    
                event.preventDefault();
                event.stopPropagation();
    
                const overlay = document.getElementById("overlay")!;
                overlay.classList.remove("visible");
    
                const file = event.dataTransfer!.files[0];
    
                if (!file.name.endsWith(".gb")) {
                    this.showToast(
                        "This is probably not a Game Boy ROM file!",
                        true
                    );
                    return;
    
                const arrayBuffer = await file.arrayBuffer();
                const romData = new Uint8Array(arrayBuffer);
    
                this.start({ engine: null, romName: file.name, romData: romData });
    
                this.showToast(`Loaded ${file.name} ROM successfully!`);
            });
            document.addEventListener("dragover", async (event) => {
                if (!event.dataTransfer!.items || event.dataTransfer!.items[0].type)
                    return;
    
                event.preventDefault();
    
                const overlay = document.getElementById("overlay")!;
                overlay.classList.add("visible");
            });
            document.addEventListener("dragenter", async (event) => {
                if (!event.dataTransfer!.items || event.dataTransfer!.items[0].type)
                    return;
                const overlay = document.getElementById("overlay")!;
                overlay.classList.add("visible");
            });
            document.addEventListener("dragleave", async (event) => {
                if (!event.dataTransfer!.items || event.dataTransfer!.items[0].type)
                    return;
                const overlay = document.getElementById("overlay")!;
                overlay.classList.remove("visible");
            });
        }
    
        registerKeys() {
            document.addEventListener("keydown", (event) => {
                const keyCode = KEYS[event.key];
                if (keyCode !== undefined) {
                    this.gameBoy!.key_press(keyCode);
                    return;
                }
    
                switch (event.key) {
                    case "+":
                        this.setLogicFrequency(
                            this.logicFrequency + FREQUENCY_DELTA
                        );
                        break;
    
                    case "-":
                        this.setLogicFrequency(
                            this.logicFrequency - FREQUENCY_DELTA
                        );
                        break;
    
                    case "Escape":
                        this.minimize();
                        break;
                }
            });
    
            document.addEventListener("keyup", (event) => {
                const keyCode = KEYS[event.key];
                if (keyCode !== undefined) {
                    this.gameBoy!.key_lift(keyCode);
                    return;
                }
            });
        }
    
        registerButtons() {
            const engine = document.getElementById("engine")!;
            engine.addEventListener("click", () => {
                const name = this.engine == "neo" ? "classic" : "neo";
                this.start({ engine: name });
                this.showToast(
                    `Game Boy running in engine "${name.toUpperCase()}" from now on!`
                );
            });
    
            const logicFrequencyPlus = document.getElementById(
                "logic-frequency-plus"
            )!;
            logicFrequencyPlus.addEventListener("click", () => {
                this.setLogicFrequency(this.logicFrequency + FREQUENCY_DELTA);
            });
    
            const logicFrequencyMinus = document.getElementById(
                "logic-frequency-minus"
            )!;
            logicFrequencyMinus.addEventListener("click", () => {
                this.setLogicFrequency(this.logicFrequency - FREQUENCY_DELTA);
            });
    
            const buttonPause = document.getElementById("button-pause")!;
            buttonPause.addEventListener("click", () => {
                this.toggleRunning();
            });
    
            const buttonReset = document.getElementById("button-reset")!;
            buttonReset.addEventListener("click", () => {
                this.reset();
            });
    
            const buttonBenchmark = document.getElementById("button-benchmark")!;
            buttonBenchmark.addEventListener("click", async () => {
                const result = await this.showModal(
                    "Are you sure you want to start a benchmark?\nThe benchmark is considered an expensive operation!",
                    "Confirm"
                );
                if (!result) return;
                buttonBenchmark.classList.add("enabled");
                this.pause();
                try {
                    const initial = Date.now();
                    const count = 500000000;
                    for (let i = 0; i < count; i++) {
                        this.gameBoy!.clock();
                    }
                    const delta = (Date.now() - initial) / 1000;
                    const frequency_mhz = count / delta / 1000 / 1000;
                    this.showToast(
                        `Took ${delta.toFixed(
                            2
                        )} seconds to run ${count} ticks (${frequency_mhz.toFixed(
                            2
                        )} Mhz)!`,
                        undefined,
                        7500
                    );
                } finally {
                    this.resume();
                    buttonBenchmark.classList.remove("enabled");
                }
            });
    
            const buttonFullscreen = document.getElementById("button-fullscreen")!;
            buttonFullscreen.addEventListener("click", () => {
                this.maximize();
            });
    
            const buttonKeyboard = document.getElementById("button-keyboard")!;
            buttonKeyboard.addEventListener("click", () => {
                const sectionKeyboard =
                    document.getElementById("section-keyboard")!;
                const separatorKeyboard =
                    document.getElementById("separator-keyboard")!;
                const sectionNarrative =
                    document.getElementById("section-narrative")!;
                const separatorNarrative = document.getElementById(
                    "separator-narrative"
                )!;
                if (buttonKeyboard.classList.contains("enabled")) {
                    sectionKeyboard.style.display = "none";
                    separatorKeyboard.style.display = "none";
                    sectionNarrative.style.display = "block";
                    separatorNarrative.style.display = "block";
                    buttonKeyboard.classList.remove("enabled");
                } else {
                    sectionKeyboard.style.display = "block";
                    separatorKeyboard.style.display = "block";
                    sectionNarrative.style.display = "none";
                    separatorNarrative.style.display = "none";
                    buttonKeyboard.classList.add("enabled");
    
            });
    
            const buttonDebug = document.getElementById("button-debug")!;
            buttonDebug.addEventListener("click", () => {
                const sectionDebug = document.getElementById("section-debug")!;
                const separatorDebug = document.getElementById("separator-debug")!;
                const sectionNarrative =
                    document.getElementById("section-narrative")!;
                const separatorNarrative = document.getElementById(
                    "separator-narrative"
                )!;
                if (buttonDebug.classList.contains("enabled")) {
                    sectionDebug.style.display = "none";
                    separatorDebug.style.display = "none";
                    sectionNarrative.style.display = "block";
                    separatorNarrative.style.display = "block";
                    buttonDebug.classList.remove("enabled");
                } else {
                    sectionDebug.style.display = "block";
                    separatorDebug.style.display = "block";
                    sectionNarrative.style.display = "none";
                    separatorNarrative.style.display = "none";
                    buttonDebug.classList.add("enabled");
    
                    const canvasTiles = document.getElementById(
                        "canvas-tiles"
                    ) as HTMLCanvasElement;
                    const canvasTilesCtx = canvasTiles.getContext("2d")!;
                    canvasTilesCtx.imageSmoothingEnabled = false;
    
                    const canvasImage = canvasTilesCtx.createImageData(
                        canvasTiles.width,
                        canvasTiles.height
                    );
                    const videoBuff = new DataView(canvasImage.data.buffer);
    
                    /**
                     * Draws the tile at the given index to the proper
                     * vertical offset in the given context and buffer.
                     *
                     * @param index The index of the sprite to be drawn.
                     * @param format The pixel format of the sprite.
                     */
                    const drawTile = (
                        index: number,
                        context: CanvasRenderingContext2D,
                        buffer: DataView,
                        format: PixelFormat = PixelFormat.RGB
                    ) => {
                        const pixels = this.gameBoy!.get_tile_buffer(index);
                        const line = Math.floor(index / 16);
                        const column = index % 16;
                        let offset =
                            (line * canvasTiles.width * 8 + column * 8) *
                            PixelFormat.RGBA;
                        let counter = 0;
                        for (
                            let index = 0;
                            index < pixels.length;
                            index += format
                        ) {
                            const color =
                                (pixels[index] << 24) |
                                (pixels[index + 1] << 16) |
                                (pixels[index + 2] << 8) |
                                (format == PixelFormat.RGBA
                                    ? pixels[index + 3]
                                    : 0xff);
                            buffer.setUint32(offset, color);
    
                            counter++;
                            if (counter == 8) {
                                counter = 0;
                                offset +=
                                    (canvasTiles.width - 7) * PixelFormat.RGBA;
                            } else {
                                offset += PixelFormat.RGBA;
                            }
    
                        context.putImageData(canvasImage, 0, 0);
                    };
    
                    for (let index = 0; index < 384; index++) {
                        drawTile(index, canvasTilesCtx, videoBuff);
    
                    const vram = this.gameBoy!.vram_eager();
                    const step = 16;
                    for (let index = 0; index < vram.length; index += step) {
                        let line = `${(index + 0x8000)
                            .toString(16)
                            .padStart(4, "0")}`;
                        for (let j = 0; j < step; j++) {
                            line += ` ${vram[index + j]
                                .toString(16)
                                .padStart(2, "0")}`;
                        }
                        console.info(line);
                    }
    
            const buttonInformation =
                document.getElementById("button-information")!;
            buttonInformation.addEventListener("click", () => {
                const sectionDiag = document.getElementById("section-diag")!;
                const separatorDiag = document.getElementById("separator-diag")!;
                if (buttonInformation.classList.contains("enabled")) {
                    sectionDiag.style.display = "none";
                    separatorDiag.style.display = "none";
                    buttonInformation.classList.remove("enabled");
                } else {
                    sectionDiag.style.display = "block";
                    separatorDiag.style.display = "block";
                    buttonInformation.classList.add("enabled");
    
            const buttonUploadFile = document.getElementById(
                "button-upload-file"
            ) as HTMLInputElement;
            buttonUploadFile.addEventListener("change", async () => {
                if (
                    !buttonUploadFile.files ||
                    buttonUploadFile.files.length === 0
                ) {
                    return;
                }
    
                const file = buttonUploadFile.files[0];
    
                const arrayBuffer = await file.arrayBuffer();
                const romData = new Uint8Array(arrayBuffer);
    
                buttonUploadFile.value = "";
    
                this.start({ engine: null, romName: file.name, romData: romData });
    
                this.showToast(`Loaded ${file.name} ROM successfully!`);
            });
        }
    
        // @todo this should be converted into a component
        registerKeyboard() {
            const keyboard = document.getElementById("keyboard")!;
            const keys = keyboard.getElementsByClassName("key");
    
            keyboard.addEventListener("touchstart", function (event) {
    
                event.preventDefault();
                event.stopPropagation();
            });
    
    
            keyboard.addEventListener("touchend", function (event) {
    
                event.preventDefault();
                event.stopPropagation();
            });
    
    
            Array.prototype.forEach.call(keys, (k: Element) => {
                k.addEventListener(
                    "mousedown",
                    function (this: HTMLElement, event) {
                        const keyCode = KEYS[this.textContent!.toLowerCase()];
                        //this.gameBoy.key_press_ws(keyCode); @todo
                        event.preventDefault();
                        event.stopPropagation();
                    }
                );
    
                k.addEventListener(
                    "touchstart",
                    function (this: HTMLElement, event) {
                        const keyCode = KEYS[this.textContent!.toLowerCase()];
                        //this.gameBoy.key_press_ws(keyCode); @todo
                        event.preventDefault();
                        event.stopPropagation();
                    }
                );
    
                k.addEventListener("mouseup", function (this: HTMLElement, event) {
                    const keyCode = KEYS[this.textContent!.toLowerCase()];
                    //this.gameBoy.key_lift_ws(keyCode); @todo
                    event.preventDefault();
                    event.stopPropagation();
                });
    
                k.addEventListener("touchend", function (this: HTMLElement, event) {
                    const keyCode = KEYS[this.textContent!.toLowerCase()];
                    //this.gameBoy.key_lift_ws(keyCode); @todo
                    event.preventDefault();
                    event.stopPropagation();
                });
    
        registerCanvas() {
            const canvasClose = document.getElementById("canvas-close")!;
            canvasClose.addEventListener("click", () => {
                this.minimize();
    
        registerToast() {
            const toast = document.getElementById("toast")!;
            toast.addEventListener("click", () => {
                toast.classList.remove("visible");
            });
        }
    
        registerModal() {
            const modalClose = document.getElementById("modal-close")!;
            modalClose.addEventListener("click", () => {
                this.hideModal(false);
            });
    
            const modalCancel = document.getElementById("modal-cancel")!;
            modalCancel.addEventListener("click", () => {
                this.hideModal(false);
            });
    
            const modalConfirm = document.getElementById("modal-confirm")!;
            modalConfirm.addEventListener("click", () => {
                this.hideModal(true);
            });
    
            document.addEventListener("keydown", (event) => {
                if (event.key === "Escape") {
                    this.hideModal(false);
                }
            });
        }
    
        async initBase() {
            this.setVersion(info.version);
    
        async initCanvas() {
            // initializes the off-screen canvas that is going to be
            // used in the drawing process
            this.canvas = document.createElement("canvas");
            this.canvas.width = DISPLAY_WIDTH;
            this.canvas.height = DISPLAY_HEIGHT;
            this.canvasCtx = this.canvas.getContext("2d")!;
    
            this.canvasScaled = document.getElementById(
                "engine-canvas"
            ) as HTMLCanvasElement;
            this.canvasScaled.width =
                this.canvasScaled.width * window.devicePixelRatio;
            this.canvasScaled.height =
                this.canvasScaled.height * window.devicePixelRatio;
            this.canvasScaledCtx = this.canvasScaled.getContext("2d")!;
    
            this.canvasScaledCtx.scale(
                this.canvasScaled.width / this.canvas.width,
                this.canvasScaled.height / this.canvas.height
            );
            this.canvasScaledCtx.imageSmoothingEnabled = false;
    
            this.image = this.canvasCtx.createImageData(
                this.canvas.width,
                this.canvas.height
            );
            this.videoBuff = new DataView(this.image.data.buffer);
        }
    
        updateCanvas(pixels: Uint8Array, format: PixelFormat = PixelFormat.RGB) {
            let offset = 0;
            for (let index = 0; index < pixels.length; index += format) {
                const color =
                    (pixels[index] << 24) |
                    (pixels[index + 1] << 16) |
                    (pixels[index + 2] << 8) |
                    (format == PixelFormat.RGBA ? pixels[index + 3] : 0xff);
                this.videoBuff!.setUint32(offset, color);
                offset += PixelFormat.RGBA;
    
            this.canvasCtx!.putImageData(this.image!, 0, 0);
            this.canvasScaledCtx!.drawImage(this.canvas!, 0, 0);
    
        async clearCanvas(
            color = PIXEL_UNSET_COLOR,
            {
                image = null,
                imageScale = 1
            }: { image?: string | null; imageScale?: number } = {}
        ) {
            this.canvasScaledCtx!.fillStyle = `#${color
                .toString(16)
                .toUpperCase()}`;
            this.canvasScaledCtx!.fillRect(
                0,
                0,
                this.canvasScaled!.width,
                this.canvasScaled!.height
            );
    
            // in case an image was requested then uses that to load
            // an image at the center of the screen properly scaled
            if (image) {
                const img = await new Promise<HTMLImageElement>((resolve) => {
                    const img = new Image();
                    img.onload = () => {
                        resolve(img);
                    };
                    img.src = image;
                });
                const [imgWidth, imgHeight] = [
                    img.width * imageScale * window.devicePixelRatio,
                    img.height * imageScale * window.devicePixelRatio
                ];
                const [x0, y0] = [
                    this.canvasScaled!.width / 2 - imgWidth / 2,
                    this.canvasScaled!.height / 2 - imgHeight / 2
                ];
                this.canvasScaledCtx!.setTransform(1, 0, 0, 1, 0, 0);
                try {
                    this.canvasScaledCtx!.drawImage(
                        img,
                        x0,
                        y0,
                        imgWidth,
                        imgHeight
                    );
                } finally {
                    this.canvasScaledCtx!.scale(
                        this.canvasScaled!.width / this.canvas!.width,
                        this.canvasScaled!.height / this.canvas!.height
                    );
                }
            }
        }
    
        async showToast(message: string, error = false, timeout = 3500) {
            const toast = document.getElementById("toast")!;
            toast.classList.remove("error");
            if (error) toast.classList.add("error");
            toast.classList.add("visible");
            toast.textContent = message;
            if (this.toastTimeout) clearTimeout(this.toastTimeout);
            this.toastTimeout = setTimeout(() => {
                toast.classList.remove("visible");
                this.toastTimeout = null;
            }, timeout);
        }
    
        async showModal(message: string, title = "Alert"): Promise<boolean> {
            const modalContainer = document.getElementById("modal-container")!;
            const modalTitle = document.getElementById("modal-title")!;
            const modalText = document.getElementById("modal-text")!;
            modalContainer.classList.add("visible");
            modalTitle.textContent = title;
            modalText.innerHTML = message.replace(/\n/g, "<br/>");
            const result = (await new Promise((resolve) => {
                global.modalCallback = resolve;
            })) as boolean;
            return result;
        }
    
        async hideModal(result = true) {
            const modalContainer = document.getElementById("modal-container")!;
            modalContainer.classList.remove("visible");
            if (global.modalCallback) global.modalCallback(result);
            global.modalCallback = null;
        }
    
        setVersion(value: string) {
            document.getElementById("version")!.textContent = value;
        }
    
        setEngine(name: string, upper = true) {
            name = upper ? name.toUpperCase() : name;
            document.getElementById("engine")!.textContent = name;
        }
    
        setRom(name: string, data: Uint8Array) {
            this.romName = name;
            this.romData = data;
            this.romSize = data.length;
            //@todo update this one
            //Component.get<KeyValue>("diag:rom-name").value = name;
            //Component.get<KeyValue>("diag:rom-size").value = `${data.length} bytes`;
        }
    
        setLogicFrequency(value: number) {
            if (value < 0) this.showToast("Invalid frequency value!", true);
            value = Math.max(value, 0);
            this.logicFrequency = value;
            document.getElementById("logic-frequency")!.textContent = String(value);
        }
    
        setFps(value: number) {
            if (value < 0) this.showToast("Invalid FPS value!", true);
            value = Math.max(value, 0);
            this.fps = value;
            //@todo
            //Component.get<KeyValue>("diag:framerate").value = `${value} FPS`;
    
        toggleRunning() {
            if (this.paused) {
                this.resume();
            } else {
                this.pause();
            }
        }
    
        pause() {
            this.paused = true;
            const buttonPause = document.getElementById("button-pause")!;
            const img = buttonPause.getElementsByTagName("img")[0];
            const span = buttonPause.getElementsByTagName("span")[0];
            buttonPause.classList.add("enabled");
            img.src = require("./res/play.svg");
            span.textContent = "Resume";
        }
    
        resume() {
            this.paused = false;
            this.nextTickTime = new Date().getTime();
            const buttonPause = document.getElementById("button-pause")!;
            const img = buttonPause.getElementsByTagName("img")[0];
            const span = buttonPause.getElementsByTagName("span")[0];
            buttonPause.classList.remove("enabled");
            img.src = require("./res/pause.svg");
            span.textContent = "Pause";
        }
    
        /**
         * Resets the emulator machine to the start state and loads
         * the ROM that is currently set in the emulator.
         */
        reset() {
            this.start({ engine: null });
        }
    
        toggleWindow() {
            this.maximize();
        }
    
        /**
         * Maximizes the emulator's viewport taking up all the available
         * window space. This method is responsible for keeping the aspect
         * ratio of the emulator canvas according to the width/height ratio.
         */
        maximize() {
            const canvasContainer = document.getElementById("canvas-container")!;
            canvasContainer.classList.add("fullscreen");
    
            window.addEventListener("resize", this.crop);