import { Emulator, Observable, PixelFormat, RomInfo, startApp } from "./react/app"; import { Cartridge, 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"); /** * Top level class that controls the emulator behaviour * and "joins" all the elements together to bring input/output * of the associated machine. */ class GameboyEmulator extends Observable 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; private logicFrequency: number = LOGIC_HZ; private visualFrequency: number = VISUAL_HZ; private timerFrequency: number = TIMER_HZ; private idleFrequency: number = IDLE_HZ; 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; private cartridge: Cartridge | null = null; 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.init(); await this.register(); // boots the emulator subsystem with the initial // ROM retrieved from a remote data source await this.boot({ 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 wasm(); 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 = 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 ) { lastFrame = this.gameBoy!.ppu_frame(); // triggers the frame event indicating that // a new frame is now available for drawing this.trigger("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. * * 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 = "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!, cartridge); 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(); // triggers the booted event indicating that the // emulator has finished the loading process this.trigger("booted"); } // @todo remove this method, or at least most of it async register() { await Promise.all([ this.registerDrop(), this.registerKeys(), this.registerButtons(), this.registerKeyboard(), this.registerToast(), this.registerModal() ]); } async init() { await Promise.all([this.initBase()]); } 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.boot({ 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; } }); 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.boot({ 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 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.boot({ 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(); }); }); } 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 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, cartridge: Cartridge) { this.romName = name; this.romData = data; this.romSize = data.length; this.cartridge = cartridge; } 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; } getName() { return "Boytacean"; } getVersion() { return info.version; } getVersionUrl() { return "https://gitlab.stage.hive.pt/joamag/boytacean/-/blob/master/CHANGELOG.md"; } getPixelFormat(): PixelFormat { return PixelFormat.RGB; } /** * 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. */ getImageBuffer(): Uint8Array { return this.gameBoy!.frame_buffer_eager(); } getRomInfo(): RomInfo { return { name: this.romName || undefined, data: this.romData || undefined, size: this.romData?.length, extra: { romType: this.cartridge?.rom_type_s(), romSize: this.cartridge?.rom_size_s(), ramSize: this.cartridge?.ram_size_s() } }; } getFramerate(): number { return this.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.boot({ engine: null }); } async fetchRom(romPath: string): Promise<[string, 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(ROM_PATH); 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 [romName, romData]; } } type Global = { modalCallback: Function | null; }; //@todo check if this is really required const global: Global = { modalCallback: null }; declare global { interface Window { panic: (message: string) => void; } } window.panic = (message: string) => { console.error(message); }; const wasm = async () => { await _wasm(); GameBoy.set_panic_hook_ws(); }; (async () => { const emulator = new GameboyEmulator(); startApp("app", emulator, BACKGROUNDS); await emulator.main(); })();