Newer
Older
import React, { FC, useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom/client";
ButtonIncrement,
ButtonSwitch,
export type Callback<T> = (owner: T, params?: any) => void;
/**
* Abstract class that implements the basic functionality
* part of the definition of the Observer pattern.
*
* @see {@link https://en.wikipedia.org/wiki/Observer_pattern}
*/
export class Observable {
private events: Record<string, [Callback<this>]> = {};
bind(event: string, callback: Callback<this>) {
const callbacks = this.events[event] ?? [];
if (callbacks.includes(callback)) return;
callbacks.push(callback);
this.events[event] = callbacks;
}
unbind(event: string, callback: Callback<this>) {
const callbacks = this.events[event] ?? [];
if (!callbacks.includes(callback)) return;
const index = callbacks.indexOf(callback);
callbacks.splice(index, 1);
this.events[event] = callbacks;
}
trigger(event: string, params?: any) {
const callbacks = this.events[event] ?? [];
export type RomInfo = {
name?: string;
data?: Uint8Array;
size?: number;
extra?: Record<string, string | undefined>;
};
export type BenchmarkResult = {
delta: number;
count: number;
export enum Feature {
Debug = 1,
Palettes,
Benchmark,
Keyboard,
KeyboardChip8,
KeyboardGB
export interface ObservableI {
bind(event: string, callback: Callback<this>): void;
unbind(event: string, callback: Callback<this>): void;
trigger(event: string): void;
}
/**
* Top level interface that declares the main abstract
* interface of an emulator structured entity.
* Should allow typical hardware operations to be performed.
*/
export interface Emulator extends ObservableI {
* The descriptive name of the emulator.
* The name of the the hardware that is being emulated
* by the emulator (eg: Super Nintendo).
/**
* A URL to a website that describes the device that is
* being emulated by the emulator (eg: Wikipedia link).
*/
get deviceUrl(): string | undefined;
* A semantic version string for the current version
* of the emulator.
* The URL to the page describing the current version
get versionUrl(): string;
/**
* The features available and compatible with the emulator,
* these values will influence the associated GUIs.
*/
get features(): Feature[];
* The complete set of engine names that can be used
/**
* The name of the current execution engine being used
* by the emulator.
*/
/**
* The complete set of file extensions that this emulator
* supports.
*/
get romExts(): string[];
* The pixel format of the emulator's display
get pixelFormat(): PixelFormat;
* Gets the complete image buffer as a sequence of
* bytes that respects the current pixel format from
* `getPixelFormat()`. This method returns an in memory
* pointer to the heap and not a copy.
*/
get imageBuffer(): Uint8Array;
* Gets information about the ROM that is currently
* loaded in the emulator, using a structure containing
* the information about the ROM that is currently
/**
* The current CPU frequency (logic) of the emulator,
* should impact other elements of the emulator.
*/
get frequency(): number;
/**
* The recommended frequency delta in hertz for scale up
* and scale down operations in the CPU frequency.
*/
get frequencyDelta(): number | null;
* The current logic framerate of the running emulator.
/**
* A dictionary that contains the register names associated
* with their value either as strings or numbers.
*/
get registers(): Record<string, string | number>;
/**
* Obtains the pixel buffer for the VRAM tile at the given
* index.
*
* @param index The index of the tile to obtain pixel buffer.
* @returns The pixel buffer of the tile at the given index.
*/
/**
* Boot (or reboots) the emulator according to the provided
* set of options.
*
* @param options The options that are going to be used for
* the booting operation of the emulator.
*/
boot(options: any): void;
/**
* Toggle the running state of the emulator between paused
* and running, prevents consumers from the need to access
* the current running state of the emulator to implement
* a logic toggle.
*/
toggleRunning(): void;
pause(): void;
resume(): void;
/**
* Resets the emulator machine to the start state and
* re-loads the ROM that is currently set in the emulator.
*/
keyPress(key: string): void;
keyLift(key: string): void;
/**
* Changes the palette of the emulator to the "next" one.
*/
changePalette?: { (): void };
* Runs a benchmark operation in the emulator, effectively
* measuring the performance of it.
* @param count The number of benchmark iterations to be
* run, increasing this value will make the benchmark take
* more time to be executed.
* @returns The result metrics from the benchmark run.
benchmark?: { (count?: number): BenchmarkResult };
export class EmulatorBase extends Observable {
get deviceUrl(): string | undefined {
return undefined;
}
get versionUrl(): string | undefined {
return undefined;
}
get features(): Feature[] {
return [];
}
get frequencyDelta(): number | null {
return FREQUENCY_DELTA;
}
/**
* Enumeration that describes the multiple pixel
* formats and the associated size in bytes.
*/
export enum PixelFormat {
RGB = 3,
RGBA = 4
fullscreen?: boolean;
debug?: boolean;
keyboard?: boolean;
backgrounds?: string[];
};
const isTouchDevice = () => {
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
};
export const App: FC<AppProps> = ({
emulator,
fullscreen = false,
debug = false,
keyboard = false,
backgrounds = ["264653"]
}) => {
const [paused, setPaused] = useState(false);
const [fullscreenState, setFullscreenState] = useState(fullscreen);
const [backgroundIndex, setBackgroundIndex] = useState(0);
const [romInfo, setRomInfo] = useState<RomInfo>({});
const [framerate, setFramerate] = useState(0);
const [keyaction, setKeyaction] = useState<string>();
const [modalTitle, setModalTitle] = useState<string>();
const [modalText, setModalText] = useState<string>();
const [modalVisible, setModalVisible] = useState(false);
const [toastText, setToastText] = useState<string>();
const [toastError, setToastError] = useState(false);
const [toastVisible, setToastVisible] = useState(false);
const [keyboardVisible, setKeyboardVisible] = useState(
isTouchDevice() || keyboard
);
const [infoVisible, setInfoVisible] = useState(true);
const [debugVisible, setDebugVisible] = useState(debug);
const frameRef = useRef<boolean>(false);
const modalCallbackRef =
useRef<(value: boolean | PromiseLike<boolean>) => void>();
useEffect(() => {
document.body.style.backgroundColor = `#${getBackground()}`;
}, [backgroundIndex]);
useEffect(() => {
switch (keyaction) {
emulator.frequency +=
emulator.frequencyDelta ?? FREQUENCY_DELTA;
setKeyaction(undefined);
break;
case "Minus":
emulator.frequency -=
emulator.frequencyDelta ?? FREQUENCY_DELTA;
setKeyaction(undefined);
break;
setKeyaction(undefined);
break;
case "Fullscreen":
setFullscreenState(!fullscreenState);
setKeyaction(undefined);
break;
}
}, [keyaction]);
useEffect(() => {
const onFullChange = (event: Event) => {
if (
!document.fullscreenElement &&
!(document as any).webkitFullscreenElement
) {
setFullscreenState(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case "+":
setKeyaction("Plus");
event.stopPropagation();
event.preventDefault();
break;
case "-":
setKeyaction("Minus");
event.stopPropagation();
event.preventDefault();
break;
case "Escape":
setKeyaction("Escape");
event.stopPropagation();
event.preventDefault();
break;
}
if (event.key === "f" && event.ctrlKey === true) {
setKeyaction("Fullscreen");
event.stopPropagation();
event.preventDefault();
}
setRomInfo(emulator.romInfo);
};
const onMessage = (
emulator: Emulator,
params: Record<string, any> = {}
) => {
showToast(params.text, params.error, params.timeout);
document.addEventListener("fullscreenchange", onFullChange);
document.addEventListener("webkitfullscreenchange", onFullChange);
document.addEventListener("keydown", onKeyDown);
emulator.bind("booted", onBooted);
emulator.bind("message", onMessage);
return () => {
document.removeEventListener("fullscreenchange", onFullChange);
document.removeEventListener(
"webkitfullscreenchange",
onFullChange
);
document.removeEventListener("keydown", onKeyDown);
emulator.unbind("booted", onBooted);
emulator.unbind("message", onMessage);
};
const getPauseText = () => (paused ? "Resume" : "Pause");
const getPauseIcon = () =>
paused ? require("../res/play.svg") : require("../res/pause.svg");
const getBackground = () => backgrounds[backgroundIndex];
const showModal = async (
text: string,
title = "Alert"
): Promise<boolean> => {
setModalText(text);
setModalTitle(title);
setModalVisible(true);
const result = (await new Promise((resolve) => {
modalCallbackRef.current = resolve;
})) as boolean;
return result;
};
const showToast = async (text: string, error = false, timeout = 3500) => {
setToastText(text);
setToastError(error);
setToastVisible(true);
toastCounterRef.current++;
const counter = toastCounterRef.current;
await new Promise((resolve) => {
setTimeout(() => {
if (counter !== toastCounterRef.current) return;
setToastVisible(false);
resolve(true);
}, timeout);
});
};
const hasFeature = (feature: Feature) => {
return emulator.features.includes(feature);
};
const onFile = async (file: File) => {
const fileExtension = file.name.split(".").pop() ?? "";
if (!emulator.romExts.includes(fileExtension)) {
`This is probably not a ${emulator.device} ROM file!`,
return;
}
const arrayBuffer = await file.arrayBuffer();
const romData = new Uint8Array(arrayBuffer);
emulator.boot({ engine: null, romName: file.name, romData: romData });
showToast(`Loaded ${file.name} ROM successfully!`);
};
const onModalConfirm = () => {
if (modalCallbackRef.current) {
modalCallbackRef.current(true);
modalCallbackRef.current = undefined;
}
setModalVisible(false);
};
const onModalCancel = () => {
if (modalCallbackRef.current) {
modalCallbackRef.current(false);
modalCallbackRef.current = undefined;
}
setModalVisible(false);
};
const onToastCancel = () => {
setToastVisible(false);
};
const onPauseClick = () => {
emulator.toggleRunning();
setPaused(!paused);
const onResetClick = () => {
emulator.reset();
};
if (!emulator.benchmark) return;
const result = await showModal(
"Are you sure you want to start a benchmark?\nThe benchmark is considered an expensive operation!",
"Confirm"
);
if (!result) return;
const { delta, count, frequency_mhz } = emulator.benchmark();
`Took ${delta.toFixed(
2
)} seconds to run ${count} ticks (${frequency_mhz.toFixed(
2
)} Mhz)!`,
undefined,
7500
setFullscreenState(!fullscreenState);
setInfoVisible(!infoVisible);
};
const onDebugClick = () => {
setDebugVisible(!debugVisible);
const onThemeClick = () => {
setBackgroundIndex((backgroundIndex + 1) % backgrounds.length);
};
if (!emulator.changePalette) return;
emulator.changePalette();
const onUploadFile = async (file: File) => {
const arrayBuffer = await file.arrayBuffer();
const romData = new Uint8Array(arrayBuffer);
emulator.boot({ engine: null, romName: file.name, romData: romData });
showToast(`Loaded ${file.name} ROM successfully!`);
};
const onEngineChange = (engine: string) => {
emulator.boot({ engine: engine.toLowerCase() });
`${emulator.device} running in engine "${engine}" from now on!`
const onFrequencyChange = (value: number) => {
emulator.frequency = value * 1000 * 1000;
};
const onFrequencyReady = (handler: (value: number) => void) => {
emulator.bind("frequency", (emulator: Emulator, frequency: number) => {
handler(frequency / 1000000);
});
};
const onMinimize = () => {
setFullscreenState(!fullscreenState);
const onKeyDown = (key: string) => {
emulator.keyPress(key);
};
const onKeyUp = (key: string) => {
emulator.keyLift(key);
};
if (frameRef.current) return;
frameRef.current = true;
handler(emulator.imageBuffer, PixelFormat.RGB);
setFramerate(emulator.framerate);
const onClearHandler = (handler: ClearHandler) => {
if (errorRef.current) return;
errorRef.current = true;
emulator.bind("error", async () => {
await handler(undefined, require("../res/storm.png"), 0.2);
});
};
<Overlay text={"Drag to load ROM"} onFile={onFile} />
<Modal
title={modalTitle}
text={modalText}
onConfirm={onModalConfirm}
onCancel={onModalCancel}
/>
<Toast
text={toastText}
error={toastError}
visible={toastVisible}
onCancel={onToastCancel}
/>
<Footer color={getBackground()}>
Built with ❤️ by{" "}
onDrawHandler={onDrawHandler}
onMinimize={onMinimize}
/>
<Section visible={keyboardVisible} separatorBottom={true}>
{hasFeature(Feature.KeyboardChip8) && (
<KeyboardChip8
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
/>
)}
{hasFeature(Feature.KeyboardGB) && (
<KeyboardGB
fullscreen={fullscreenState}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
/>
)}
text={emulator.name}
version={emulator.version}
emulator.versionUrl ? emulator.versionUrl : undefined
iconSrc={require("../res/thunder.png")}
></Title>
<Section>
<Paragraph>
This is a{" "}
{emulator.deviceUrl ? (
<Link href={emulator.deviceUrl} target="_blank">
{emulator.device}
emulator built using the{" "}
<Link href="https://www.rust-lang.org" target="_blank">
Rust Programming Language
</Link>{" "}
and is running inside this browser with the help of{" "}
<Link href="https://webassembly.org/" target="_blank">
WebAssembly
</Link>
.
</Paragraph>
<Paragraph>
You can check the source code of it at{" "}
<Link
href="https://gitlab.stage.hive.pt/joamag/boytacean"
target="_blank"
>
GitLab
</Link>
.
</Paragraph>
<Paragraph>
TIP: Drag and Drop ROM files to the Browser to load the
ROM.
</Paragraph>
</Section>
<div
style={{
display: "inline-block",
verticalAlign: "top",
marginRight: 32,
width: 256
}}
>
<h3>VRAM Tiles</h3>
<Tiles
getTile={(index) => emulator.getTile(index)}
tileCount={384}
width={"100%"}
contentBox={false}
/>
</div>
<div
style={{
display: "inline-block",
verticalAlign: "top"
}}
>
<h3>Registers</h3>
<RegistersGB
getRegisters={() => emulator.registers}
/>
</Section>
)}
{infoVisible && (
<Section>
<Info>
<Pair
key="button-engine"
name={"Engine"}
valueNode={
<ButtonSwitch
options={emulator.engines.map((e) =>
e.toUpperCase()
)}
size={"large"}
style={["simple"]}
onChange={onEngineChange}
/>
}
/>
<Pair
key="rom"
name={"ROM"}
value={romInfo.name ?? "-"}
/>
<Pair
key="rom-size"
name={"ROM Size"}
value={
romInfo.size
? `${new Intl.NumberFormat().format(
romInfo.size
)} bytes`
: "-"
}
/>
<Pair
key="button-frequency"
name={"CPU Frequency"}
valueNode={
<ButtonIncrement
value={emulator.frequency / 1000 / 1000}
delta={
(emulator.frequencyDelta ??
FREQUENCY_DELTA) /
1000 /
1000
}
onChange={onFrequencyChange}
onReady={onFrequencyReady}
/>
}
/>
<Pair
key="rom-type"
name={"ROM Type"}
value={
romInfo.extra?.romType
? `${romInfo.extra?.romType}`
: "-"
}
/>
<Pair
key="framerate"
name={"Framerate"}
value={`${framerate} fps`}
/>
</Info>
</Section>
)}
<ButtonContainer>
<Button
text={getPauseText()}
image={getPauseIcon()}
imageAlt="pause"
/>
<Button
text={"Reset"}
image={require("../res/reset.svg")}
imageAlt="reset"
onClick={onResetClick}
/>
{hasFeature(Feature.Benchmark) && (
<Button
text={"Benchmark"}
image={require("../res/bolt.svg")}
imageAlt="benchmark"
style={["simple", "border", "padded"]}
onClick={onBenchmarkClick}
/>
)}
<Button
text={"Fullscreen"}
image={require("../res/maximise.svg")}
imageAlt="maximise"
{hasFeature(Feature.Keyboard) && (
<Button
text={"Keyboard"}
image={require("../res/dialpad.svg")}
imageAlt="keyboard"
enabled={keyboardVisible}
style={["simple", "border", "padded"]}
onClick={onKeyboardClick}
/>
)}
<Button
text={"Information"}
image={require("../res/info.svg")}
enabled={infoVisible}
style={["simple", "border", "padded"]}
{hasFeature(Feature.Debug) && (
<Button
text={"Debug"}
image={require("../res/bug.svg")}
imageAlt="debug"
enabled={debugVisible}
style={["simple", "border", "padded"]}
onClick={onDebugClick}
/>
)}
<Button
text={"Theme"}
image={require("../res/marker.svg")}
onClick={onThemeClick}
/>
{hasFeature(Feature.Palettes) && (
<Button
text={"Palette"}
image={require("../res/brightness.svg")}
imageAlt="palette"
style={["simple", "border", "padded"]}
onClick={onPaletteClick}
/>
)}
image={require("../res/upload.svg")}
imageAlt="upload"
file={true}
accept={".gb"}
export const startApp = (
element: string,
{
emulator,
fullscreen = false,
debug = false,
keyboard = false,
backgrounds = []
}: {
emulator: Emulator;
fullscreen?: boolean;
debug?: boolean;
keyboard?: boolean;
backgrounds: string[];
}
const elementRef = document.getElementById(element);
const root = ReactDOM.createRoot(elementRef);
root.render(
<App
emulator={emulator}
fullscreen={fullscreen}
debug={debug}
keyboard={keyboard}
backgrounds={backgrounds}
/>
);