Newer
Older
import {
Emulator,
Observable,
PixelFormat,
RomInfo,
startApp
} from "./react/app";
import {
Cartridge,
default as _wasm,
GameBoy,
PadKey,
PpuMode
} from "./lib/boytacean.js";
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
// 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();
// 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
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.
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
// @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()
]);
}
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;
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.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();
});
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
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");
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
});
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 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!`);
});
}
// @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();
});
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
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;
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();
}
}
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
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() {
}
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();
const emulator = new GameboyEmulator();
startApp("app", emulator, BACKGROUNDS);