Skip to content
Snippets Groups Projects
gb.ts 33.19 KiB
import { loadAsync } from "jszip";
import {
    AudioSpecs,
    base64ToBuffer,
    BenchmarkResult,
    bufferToBase64,
    Compilation,
    Compiler,
    DebugPanel,
    Emulator,
    EmulatorBase,
    Entry,
    Feature,
    Frequency,
    FrequencySpecs,
    HelpPanel,
    PixelFormat,
    RomInfo,
    SaveState,
    SectionInfo,
    Size
} from "emukit";
import { PALETTES, PALETTES_MAP } from "./palettes";
import {
    DebugAudio,
    DebugGeneral,
    DebugSettings,
    HelpFaqs,
    HelpKeyboard,
    SerialSection,
    TestSection
} from "../react";

import {
    Cartridge,
    default as _wasm,
    GameBoy,
    GameBoyMode,
    GameBoySpeed,
    Info,
    PadKey,
    SaveStateFormat,
    StateManager
} from "../lib/boytacean";
import info from "../package.json";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare const require: any;

/**
 * The frequency at which the Game Boy emulator should
 * run "normally".
 */
const LOGIC_HZ = 4194304;

/**
 * The frequency at witch the the visual loop is going to
 * run, increasing this value will have a consequence in
 * the visual frames per second (FPS) of emulation.
 */
const VISUAL_HZ = 59.7275;

/**
 * The frequency of the pause polling update operation,
 * increasing this value will make resume from emulation
 * paused state fasted.
 */
const IDLE_HZ = 10;

const DISPLAY_WIDTH = 160;
const DISPLAY_HEIGHT = 144;
const DISPLAY_SCALE = 2;

/**
 * The rate at which the local storage RAM state flush
 * operation is going to be performed, this value is the
 * number of seconds in between flush operations (eg: 5 seconds).
 */
const STORE_RATE = 5;

/**
 * The sample rate that is going to be used for FPS calculus,
 * meaning that every N seconds we will calculate the number
 * of frames rendered divided by the N seconds.
 */
const FPS_SAMPLE_RATE = 3;

const KEYS_NAME: Record<string, number> = {
    ArrowUp: PadKey.Up,
    ArrowDown: PadKey.Down,
    ArrowLeft: PadKey.Left,
    ArrowRight: PadKey.Right,
    Start: PadKey.Start,
    Select: PadKey.Select,
    A: PadKey.A,
    B: PadKey.B
};

const ROM_PATH = require("../../../res/roms/demo/pocket.gb");

/**
 * Enumeration with the values for the complete set of available
 * serial devices that can be used in the emulator.
 */
export enum SerialDevice {
    Null = "null",
    Logger = "logger",
    Printer = "printer"
}

/**
 * Top level class that controls the emulator behaviour
 * and "joins" all the elements together to bring input/output
 * of the associated machine.
 */
export class GameboyEmulator extends EmulatorBase implements 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;

    /**
     * If the GB running mode should be automatically inferred
     * from the GBC flag in the cartridge. Meaning that if the
     * cartridge is a GBC compatible or GBC only the GBC emulation
     * mode is going to be used, otherwise the DMG mode is used
     * instead. This should provide an optimal usage experience.
     */
    private autoMode = false;

    private logicFrequency = LOGIC_HZ;
    private visualFrequency = VISUAL_HZ;
    private idleFrequency = IDLE_HZ;

    private paused = false;
    private nextTickTime = 0;
    private fps = 0;
    private frameStart: number = EmulatorBase.now();
    private frameCount = 0;
    private paletteIndex = 0;

    /**
     * The frequency at which the battery backed RAM is going
     * to be flushed to the `localStorage`.
     */
    private flushCycles: number = LOGIC_HZ * STORE_RATE;

    private romName: string | null = null;
    private romData: Uint8Array | null = null;
    private romSize = 0;
    private cartridge: Cartridge | null = null;

    private _serialDevice: SerialDevice = SerialDevice.Null;

    /**
     * Associative map for extra settings to be used in
     * opaque local storage operations, associated setting
     * name with its value as a string.
     */
    private extraSettings: Record<string, string> = {};

    constructor(extraSettings = {}) {
        super();
        this.extraSettings = extraSettings;
    }

    /**
     * Initializes the global module structures.
     */
    async init() {
        // initializes the WASM module, this is required
        // so that the global symbols become available
        await wasm();
    }

    /**
     * Runs the initialization and main loop execution for
     * the Game Boy emulator.
     * The main execution of this function should be an
     * infinite loop running machine `tick` operations.
     *
     * @param options The set of options that are going to be
     * used in he Game Boy emulator initialization.
     */
    async main({ romUrl }: { romUrl?: string }) {
        // boots the emulator subsystem with the initial
        // ROM retrieved from a remote data source
        await this.boot({ loadRom: true, romPath: romUrl ?? undefined });

        // 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 = EmulatorBase.now();

            try {
                pending = this.tick(
                    currentTime,
                    pending,
                    Math.round(
                        (this.logicFrequency *
                            (this.gameBoy?.multiplier() ?? 1)) /
                            this.visualFrequency
                    )
                );
            } 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.trigger("message", {
                    text: message,
                    error: true,
                    timeout: 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 means restarting both the WASM sub
                // system and the machine state (to be able to recover)
                if (isPanic) {
                    await wasm(true);
                    await this.boot({ restore: false });

                    this.trigger("error");
                }
            }

            // calculates the amount of time until the next draw operation
            // this is the amount of time that is going to be pending
            currentTime = EmulatorBase.now();
            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 = 70224) {
        // in case the reference to the system is not set then
        // returns the control flow immediately (not possible to tick)
        if (!this.gameBoy) return pending;

        // 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;

        // initializes the counter of cycles with the pending number
        // of cycles coming from the previous tick
        let counterCycles = pending;

        let lastFrame = this.gameBoy.ppu_frame();

        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
            const tickCycles = this.gameBoy.clock();
            counterCycles += tickCycles;

            // in case the frame is different from the previously
            // rendered one then it's time to update the canvas
            if (this.gameBoy.ppu_frame() !== lastFrame) {
                // updates the reference to the last frame index
                // to be used for comparison in the next tick
                lastFrame = this.gameBoy.ppu_frame();

                // triggers the frame event indicating that
                // a new frame is now available for drawing
                this.trigger("frame");
            }

            // in case the current cartridge is battery backed
            // then we need to check if a RAM flush to local
            // storage operation is required
            if (this.cartridge && this.cartridge.has_battery()) {
                this.flushCycles -= tickCycles;
                if (this.flushCycles <= 0) {
                    this.saveRam();
                    this.flushCycles = this.logicFrequency * STORE_RATE;
                }
            }
        }

        // triggers the audio event, meaning that the audio should be
        // processed for the current emulator, effectively emptying
        // the audio buffer that is pending processing
        this.trigger("audio");

        // 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 * FPS_SAMPLE_RATE) {
            const currentTime = EmulatorBase.now();
            const deltaTime = (currentTime - this.frameStart) / 1000;
            const fps = Math.round(this.frameCount / deltaTime);
            this.fps = fps;
            this.frameCount = 0;
            this.frameStart = currentTime;
        }

        // 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, meaning
        // a situation where the system resources are no able to emulate
        // the system on time and frames must be skipped (ticks > 1)
        if (this.nextTickTime === 0) this.nextTickTime = currentTime;
        let ticks = Math.ceil(
            (currentTime - this.nextTickTime) /
                ((1 / this.visualFrequency) * 1000)
        );
        ticks = Math.max(ticks, 1);

        // updates the next update time according to the number of ticks
        // that have elapsed since the last operation, this way this value
        // can better 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.
     *
     * This method can also be used to load a new ROM into the machine.
     *
     * @param options The options that are going to be used in the
     * starting of the machine, includes information on the ROM and
     * the emulator engine to use.
     */
    async boot({
        engine = "auto",
        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) {
            ({ name: romName, data: romData } =
                await GameboyEmulator.fetchRom(romPath));
        } else if (romName === null || romData === null) {
            [romName, romData] = [this.romName, this.romData];
        }

        // in case either the ROM's name or data is not available
        // throws an error as the boot process is not possible
        if (!romName || !romData) {
            throw new Error("Unable to load initial ROM");
        }

        // selects the proper engine for execution
        // and builds a new instance of it
        switch (engine) {
            case "auto":
                this.gameBoy = new GameBoy(GameBoyMode.Dmg);
                this.autoMode = true;
                break;
            case "cgb":
                this.gameBoy = new GameBoy(GameBoyMode.Cgb);
                this.autoMode = false;
                break;
            case "dmg":
                this.gameBoy = new GameBoy(GameBoyMode.Dmg);
                this.autoMode = false;
                break;
            default:
                if (!this.gameBoy) {
                    throw new Error("No engine requested");
                }
                break;
        }

        // runs the initial palette update operation, restoring
        // the palette of the emulator according to the currently
        // selected one
        this.updatePalette();

        // in case the auto emulation mode is enabled runs the
        // inference logic to try to infer the best mode from the
        // GBC header in the cartridge data
        if (this.autoMode) {
            this.gameBoy.infer_mode_wa(romData);
        }

        // resets the Game Boy engine to restore it into
        // a valid state ready to be used
        this.gameBoy.reset();
        this.gameBoy.load(true);

        // loads the ROM file into the system and retrieves
        // the cartridge instance associated with it
        const cartridge = this.gameBoy.load_rom_wa(romData);

        // loads the callbacks so that the Typescript code
        // gets notified about the various events triggered
        // in the WASM side
        this.gameBoy.load_callbacks_wa();

        // in case there's a serial device involved tries to load
        // it and initialize for the current Game Boy machine
        this.loadSerialDevice();

        // updates the name of the currently selected engine
        // to the one that has been provided (logic change)
        if (engine) this._engine = engine;

        // updates the ROM name in case there's extra information
        // coming from the cartridge
        romName = cartridge.title() ? cartridge.title() : romName;

        // updates the complete set of global information that
        // is going to be displayed
        this.setRom(romName, romData, cartridge);

        // in case there's a battery involved tries to load the
        // current RAM from the local storage
        if (cartridge.has_battery()) this.loadRam();

        // in case the restore (state) flag is set
        // then resumes the machine execution
        if (restore) this.resume();

        // triggers the booted event indicating that the
        // emulator has finished the loading process
        this.trigger("booted");
    }
    setRom(name: string, data: Uint8Array, cartridge: Cartridge) {
        this.romName = name;
        this.romData = data;
        this.romSize = data.length;
        this.cartridge = cartridge;
    }

    get instance(): GameBoy | null {
        return this.gameBoy;
    }

    get name(): string {
        return Info.name() ?? info.name;
    }

    get device(): Entry {
        return {
            text: Info.system(),
            url: "https://en.wikipedia.org/wiki/Game_Boy"
        };
    }

    get icon(): string | undefined {
        return require("../res/star.png");
    }

    get version(): Entry | undefined {
        return {
            text: Info.version() ?? info.version,
            url: "https://github.com/joamag/boytacean/blob/master/CHANGELOG.md"
        };
    }

    get repository(): Entry {
        return {
            text: "GitHub",
            url: "https://github.com/joamag/boytacean"
        };
    }

    get features(): Feature[] {
        return [
            Feature.Help,
            Feature.Debug,
            Feature.Themes,
            Feature.Palettes,
            Feature.Benchmark,
            Feature.Keyboard,
            Feature.KeyboardGB,
            Feature.RomTypeInfo,
            Feature.SaveState
        ];
    }

    get sections(): SectionInfo[] {
        const _sections: SectionInfo[] = [
            {
                name: "Serial",
                icon: require("../res/serial.svg"),
                node: SerialSection({ emulator: this })
            }
        ];
        if (process.env.NODE_ENV === "development") {
            _sections.push({
                name: "Test",
                node: TestSection({})
            });
        }
        return _sections;
    }

    get help(): HelpPanel[] {
        return [
            {
                name: "Keyboard",
                node: HelpKeyboard({})
            },
            {
                name: "FAQs",
                node: HelpFaqs({})
            }
        ];
    }

    get debug(): DebugPanel[] {
        return [
            {
                name: "General",
                node: DebugGeneral({ emulator: this })
            },
            {
                name: "Audio",
                node: DebugAudio({ emulator: this })
            },
            {
                name: "Settings",
                node: DebugSettings({ emulator: this })
            }
        ];
    }

    get engines(): string[] {
        return ["auto", "cgb", "dmg"];
    }

    get engine(): string {
        return this._engine || "auto";
    }

    get romExts(): string[] {
        return ["gb", "gbc", "zip"];
    }

    get pixelFormat(): PixelFormat {
        return PixelFormat.RGB;
    }

    get dimensions(): Size {
        return {
            width: DISPLAY_WIDTH,
            height: DISPLAY_HEIGHT,
            scale: DISPLAY_SCALE
        };
    }

    /**
     * Returns the array buffer that contains the complete set of
     * pixel data that is going to be drawn.
     *
     * @returns The current pixel data for the emulator display.
     */
    get imageBuffer(): Uint8Array {
        return this.gameBoy?.frame_buffer_eager() ?? new Uint8Array();
    }

    get audioSpecs(): AudioSpecs {
        return {
            samplingRate: this.gameBoy?.audio_sampling_rate() ?? 44100,
            channels: this.gameBoy?.audio_channels() ?? 2
        };
    }

    get audioBuffer(): Float32Array[] {
        const internalBuffer = this.gameBoy?.audio_buffer_eager(true) ?? [];
        const leftStream = new Float32Array(internalBuffer.length / 2);
        const rightStream = new Float32Array(internalBuffer.length / 2);
        for (let index = 0; index < internalBuffer.length; index += 2) {
            leftStream[index / 2] = internalBuffer[index] / 100.0;
            rightStream[index / 2] = internalBuffer[index + 1] / 100.0;
        }
        return [leftStream, rightStream];
    }

    get romInfo(): RomInfo {
        return {
            name: this.romName ?? undefined,
            data: this.romData ?? undefined,
            size: this.romSize,
            extra: {
                romType: this.cartridge?.rom_type_s(),
                romSize: this.cartridge?.rom_size_s(),
                ramSize: this.cartridge?.ram_size_s()
            }
        };
    }

    get frequency(): number {
        return this.logicFrequency;
    }

    set frequency(value: number) {
        value = Math.max(value, 0);
        this.logicFrequency = value;
        this.gameBoy?.set_clock_freq(value);
        this.trigger("frequency", value);
    }

    get frequencySpecs(): FrequencySpecs {
        return {
            unit: Frequency.MHz,
            delta: 400000,
            places: 2
        };
    }

    get compiler(): Compiler | null {
        if (!this.gameBoy) return null;
        return {
            name: Info.compiler(),
            version: Info.compiler_version()
        };
    }

    get compilation(): Compilation | null {
        if (!this.gameBoy) return null;
        return {
            date: Info.compilation_date(),
            time: Info.compilation_time()
        };
    }

    get wasmEngine(): string | null {
        if (!this.gameBoy) return null;
        return this.gameBoy.wasm_engine_wa() ?? null;
    }

    get framerate(): number {
        return this.fps;
    }
    get registers(): Record<string, string | number> {
        const registers = this.gameBoy?.registers();
        if (!registers) return {};
        return {
            pc: registers.pc,
            sp: registers.sp,
            a: registers.a,
            b: registers.b,
            c: registers.c,
            d: registers.d,
            e: registers.e,
            h: registers.h,
            l: registers.l,
            scy: registers.scy,
            scx: registers.scx,
            wy: registers.wy,
            wx: registers.wx,
            ly: registers.ly,
            lyc: registers.lyc
        };
    }

    get speed(): GameBoySpeed {
        return this.gameBoy?.speed() ?? GameBoySpeed.Normal;
    }

    get audioOutput(): Record<string, number> {
        const output = this.gameBoy?.audio_all_output();
        if (!output) return {};
        return {
            master: output[0],
            ch1: output[1],
            ch2: output[2],
            ch3: output[3],
            ch4: output[4]
        };
    }

    get palette(): string | undefined {
        const paletteObj = PALETTES[this.paletteIndex];
        return paletteObj.name;
    }

    set palette(value: string | undefined) {
        if (value === undefined) return;
        const paletteObj = PALETTES_MAP[value];
        this.paletteIndex = Math.max(PALETTES.indexOf(paletteObj), 0);
        this.updatePalette();
    }

    get serialDevice(): SerialDevice {
        return this._serialDevice;
    }

    set serialDevice(value: SerialDevice) {
        this._serialDevice = value;
    }

    toggleRunning() {
        if (this.paused) {
            this.resume();
        } else {
            this.pause();
        }
    }

    pause() {
        this.paused = true;
    }
    resume() {
        this.paused = false;
        this.nextTickTime = EmulatorBase.now();
    }

    reset() {
        this.boot({ engine: null });
    }

    keyPress(key: string) {
        const keyCode = KEYS_NAME[key];
        if (keyCode === undefined) return;
        this.gameBoy?.key_press(keyCode);
    }

    keyLift(key: string) {
        const keyCode = KEYS_NAME[key];
        if (keyCode === undefined) return;
        this.gameBoy?.key_lift(keyCode);
    }

    async buildRomData(file: File): Promise<Uint8Array> {
        const arrayBuffer = await file.arrayBuffer();
        let romData = new Uint8Array(arrayBuffer);

        if (file.name.endsWith(".zip")) {
            const zip = await loadAsync(romData);
            const firstFile = Object.values(zip.files)[0];
            romData = await firstFile.async("uint8array");
        }

        return romData;
    }

    serializeState(): Uint8Array {
        if (!this.gameBoy) throw new Error("Unable to serialize state");
        return StateManager.save_wa(this.gameBoy, SaveStateFormat.Bos);
    }

    unserializeState(data: Uint8Array) {
        if (!this.gameBoy) throw new Error("Unable to unserialize state");
        StateManager.load_wa(data, this.gameBoy, SaveStateFormat.Bos);
    }

    buildState(index: number, data: Uint8Array): SaveState {
        const state = StateManager.read_bos_wa(data);
        return {
            index: index,
            timestamp: Number(state.timestamp_wa()),
            agent: state.agent_wa(),
            model: state.model_wa(),
            thumbnail: state.image_eager_wa()
        };
    }

    pauseVideo() {
        this.gameBoy?.set_ppu_enabled(false);
    }

    resumeVideo() {
        this.gameBoy?.set_ppu_enabled(true);
    }

    getVideoState(): boolean {
        return this.gameBoy?.ppu_enabled() ?? false;
    }

    pauseAudio() {
        this.gameBoy?.set_apu_enabled(false);
        this.trigger("audio-state", { state: "paused", stateBool: false });
    }

    resumeAudio() {
        this.gameBoy?.set_apu_enabled(true);
        this.trigger("audio-state", { state: "resumed", stateBool: true });
    }

    getAudioState(): boolean {
        return this.gameBoy?.apu_enabled() ?? false;
    }

    getTile(index: number): Uint8Array {
        return this.gameBoy?.get_tile_buffer(index) ?? new Uint8Array();
    }

    changePalette(): string {
        this.paletteIndex += 1;
        this.paletteIndex %= PALETTES.length;
        this.updatePalette();
        return PALETTES[this.paletteIndex].name;
    }

    benchmark(count = 50000000): BenchmarkResult {
        let cycles = 0;
        this.pause();
        try {
            const initial = EmulatorBase.now();
            for (let i = 0; i < count; i++) {
                cycles += this.gameBoy?.clock() ?? 0;
            }
            const delta = (EmulatorBase.now() - initial) / 1000;
            const frequency_mhz = cycles / delta / 1000 / 1000;
            return {
                delta: delta,
                count: count,
                cycles: cycles,
                frequency_mhz: frequency_mhz
            };
        } finally {
            this.resume();
        }
    }

    onBackground(background: string) {
        this.extraSettings.background = background;
        this.storeSettings();
    }

    loadSerialDevice(device?: SerialDevice) {
        device = device ?? this.serialDevice;
        switch (device) {
            case SerialDevice.Null:
                this.loadNullDevice();
                break;

            case SerialDevice.Logger:
                this.loadLoggerDevice();
                break;

            case SerialDevice.Printer:
                this.loadPrinterDevice();
                break;
        }
    }

    loadNullDevice(set = true) {
        this.gameBoy?.load_null_wa();
        if (set) this.serialDevice = SerialDevice.Null;
    }
    loadLoggerDevice(set = true) {
        this.gameBoy?.load_logger_wa();
        if (set) this.serialDevice = SerialDevice.Logger;
    }

    loadPrinterDevice(set = true) {
        this.gameBoy?.load_printer_wa();
        if (set) this.serialDevice = SerialDevice.Printer;
    }

    onSpeedSwitch(speed: GameBoySpeed) {
        this.trigger("speed", { data: speed });
    }

    onLoggerDevice(data: Uint8Array) {
        this.trigger("logger", { data: data });
    }

    onPrinterDevice(imageBuffer: Uint8Array) {
        this.trigger("printer", { imageBuffer: imageBuffer });
    }

    /**
     * Tries for save/flush the current machine RAM into the
     * `localStorage`, so that it can be latter restored.
     */
    private saveRam() {
        if (!this.gameBoy || !this.cartridge || !window.localStorage) return;
        if (!this.cartridge.has_battery()) return;
        const title = this.cartridge.title();
        const ramData = this.gameBoy.ram_data_eager();
        const ramDataB64 = bufferToBase64(ramData);
        localStorage.setItem(title, ramDataB64);
    }

    /**
     * Tries to load game RAM from the `localStorage` using the
     * current cartridge title as the name of the item and
     * decoding it using Base64.
     */
    private loadRam() {
        if (!this.gameBoy || !this.cartridge || !window.localStorage) return;
        const ramDataB64 = localStorage.getItem(this.cartridge.title());
        if (!ramDataB64) return;
        const ramData = base64ToBuffer(ramDataB64);
        this.gameBoy.set_ram_data(ramData);
    }

    private storeSettings() {
        if (!window.localStorage) return;
        const settings = {
            palette: PALETTES[this.paletteIndex].name,
            ...this.extraSettings
        };
        localStorage.setItem("settings", JSON.stringify(settings));
    }

    private updatePalette() {
        const palette = PALETTES[this.paletteIndex];
        this.gameBoy?.set_palette_colors_wa(palette.colors);
        this.storeSettings();
    }

    private static async fetchRom(
        romPath: string
    ): Promise<{ name: string; data: Uint8Array }> {
        // extracts the name of the ROM from the provided
        // path by splitting its structure
        const romPathS = romPath.split(/\//g);
        let romName = romPathS[romPathS.length - 1].split("?")[0];
        const romNameS = romName.split(/\./g);
        romName = `${romNameS[0]}.${romNameS[romNameS.length - 1]}`;

        // loads the ROM data and converts it into the
        // target byte array buffer (to be used by WASM)
        const response = await fetch(romPath);
        const blob = await response.blob();
        const arrayBuffer = await blob.arrayBuffer();
        const romData = new Uint8Array(arrayBuffer);

        // returns both the name of the ROM and the data
        // contents as a byte array
        return {
            name: romName,
            data: romData
        };
    }
}

declare global {
    interface Window {
        emulator: GameboyEmulator;
        panic: (message: string) => void;
        speedCallback: (speed: GameBoySpeed) => void;
        loggerCallback: (data: Uint8Array) => void;
        printerCallback: (imageBuffer: Uint8Array) => void;
        rumbleCallback: (active: boolean) => void;
    }

    interface Console {
        image(url: string, size?: number): void;
    }
}

window.panic = (message: string) => {
    console.error(message);
};

window.speedCallback = (speed: GameBoySpeed) => {
    window.emulator.onSpeedSwitch(speed);
};

window.loggerCallback = (data: Uint8Array) => {
    window.emulator.onLoggerDevice(data);
};

window.printerCallback = (imageBuffer: Uint8Array) => {
    window.emulator.onPrinterDevice(imageBuffer);
};

window.rumbleCallback = (active: boolean) => {
    if (!active) return;

    // runs the vibration actuator on the current window
    // this will probably affect only mobile devices
    window?.navigator?.vibrate?.(250);

    // iterates over all the available gamepads to run
    // the vibration actuator on each of them
    let gamepadIndex = 0;
    while (true) {
        const gamepad = navigator.getGamepads()[gamepadIndex];
        if (!gamepad) break;
        gamepad?.vibrationActuator?.playEffect?.("dual-rumble", {
            startDelay: 0,
            duration: 150,
            weakMagnitude: 0.8,
            strongMagnitude: 0.0
        });
        gamepadIndex++;
    }
};

console.image = (url: string, size = 80) => {
    const style = `font-size: ${size}px; background-image: url("${url}"); background-size: contain; background-repeat: no-repeat;`;
    console.log("%c     ", style);
};

const wasm = async (setHook = true) => {
    await _wasm();

    // in case the set hook flag is set, then tries to
    // set the panic hook for the WASM module, this call
    // may fail in some versions of wasm-bindgen as the
    // thread is still marked as "panicking", so we need to
    // wrap the call around try/catch
    if (setHook) {
        try {
            GameBoy.set_panic_hook_wa();
        } catch (err) {
            console.error(err);
        }
    }
};