import { Rsemu } from "./rsemu.js";
export const BUTTONS = Rsemu.BUTTONS;
export const PAD = {
KeyZ: BUTTONS.a,
KeyX: BUTTONS.b,
ShiftLeft: BUTTONS.select,
ShiftRight: BUTTONS.select,
Enter: BUTTONS.start,
ArrowUp: BUTTONS.up,
ArrowDown: BUTTONS.down,
ArrowLeft: BUTTONS.left,
ArrowRight: BUTTONS.right,
};
const FPS_WINDOW_MS = 500;
const CONSOLE_FLUSH_MS = 40;
const MAX_CATCHUP_FRAMES = 4;
const CONSOLE_LIMIT = 24000;
const CONSOLE_TRIM = 18000;
function isFormControl(target) {
if (!target || !target.tagName) return false;
return ["INPUT", "SELECT", "TEXTAREA", "BUTTON", "A"].includes(target.tagName);
}
export class Session {
constructor() {
this.emu = null;
this.canvas = null;
this.ctx = null;
this.machines = [];
this.version = "";
this.machine = null;
this.paused = true;
this.held = 0;
this.consoleFocused = false;
this.consoleText = "";
this.pending = 0;
this.last = 0;
this.drawn = 0;
this.fpsAt = 0;
this.consoleAt = 0;
this.consoleDirty = false;
this.fps = 0;
this.raf = 0;
this.hooks = {
stats: () => {},
console: () => {},
status: () => {},
buttons: () => {},
};
}
on(hooks) {
Object.assign(this.hooks, hooks);
}
async load(url = "./rsemu.wasm") {
this.emu = await Rsemu.load(url);
this.version = this.emu.version();
if (this.emu.echo(0xdeadbeef) !== 0xdeadbeef) {
throw new Error("the module loaded but does not run correctly");
}
this.machines = this.emu.machines();
return this.machines;
}
attach(canvas) {
this.canvas = canvas;
this.ctx = canvas ? canvas.getContext("2d", { alpha: false }) : null;
}
boot(entry, image) {
this.emu.boot(entry.index, entry.media ? image : null);
this.machine = entry;
this.consoleText = "";
this.hooks.console("");
this.held = 0;
this.hooks.buttons(0);
if (this.canvas) {
this.canvas.width = this.emu.width || 256;
this.canvas.height = this.emu.height || 240;
this.ctx.fillStyle = "#000";
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
this.pending = 0;
this.drawn = 0;
this.last = performance.now();
this.fpsAt = this.last;
this.paused = false;
this.draw();
this.pushStats();
this.start();
}
shutdown() {
this.stop();
if (this.emu) this.emu.shutdown();
this.machine = null;
this.paused = true;
this.consoleText = "";
this.hooks.console("");
this.pushStats();
}
get running() {
return Boolean(this.emu && this.emu.running);
}
get hasVideo() {
return Boolean(this.emu && this.emu.hasVideo);
}
get hasConsole() {
return Boolean(this.emu && this.emu.hasConsole);
}
start() {
if (this.raf) return;
this.last = performance.now();
this.fpsAt = this.last;
this.raf = requestAnimationFrame(this.tick);
}
stop() {
if (this.raf) cancelAnimationFrame(this.raf);
this.raf = 0;
}
pause() {
this.paused = true;
this.pushStats();
}
resume() {
if (!this.running) return;
this.paused = false;
this.pending = 0;
this.last = performance.now();
this.fpsAt = this.last;
this.drawn = 0;
this.pushStats();
}
step() {
if (!this.running) return;
this.paused = true;
try {
if (this.emu.runFrame()) this.draw();
} catch (e) {
this.fail(e);
return;
}
this.pumpConsole(true);
this.pushStats(true);
}
reset() {
this.emu.reset();
this.pushStats(true);
}
tick = (now) => {
this.raf = requestAnimationFrame(this.tick);
if (!this.running || this.paused) return;
const period = this.emu.frameMs();
this.pending += (now - this.last) / period;
this.last = now;
const frames = Math.min(Math.floor(this.pending), MAX_CATCHUP_FRAMES);
this.pending -= frames;
if (frames <= 0) return;
let drew = false;
try {
drew = this.emu.runFrames(frames) > 0;
} catch (e) {
this.fail(e);
return;
}
if (drew) {
this.draw();
this.drawn += 1;
}
this.pumpConsole(false, now);
if (now - this.fpsAt > FPS_WINDOW_MS) {
this.fps = Math.round((this.drawn * 1000) / (now - this.fpsAt));
this.drawn = 0;
this.fpsAt = now;
this.pushStats();
}
};
draw() {
if (!this.ctx) return;
const image = this.emu.imageData();
if (image) this.ctx.putImageData(image, 0, 0);
}
pumpConsole(force, now = performance.now()) {
if (!this.hasConsole) return;
const bytes = this.emu.consoleRead();
if (bytes.length > 0) {
this.consoleText += decodeGuest(bytes);
if (this.consoleText.length > CONSOLE_LIMIT) {
this.consoleText = this.consoleText.slice(-CONSOLE_TRIM);
}
this.consoleDirty = true;
}
if (this.consoleDirty && (force || now - this.consoleAt > CONSOLE_FLUSH_MS)) {
this.consoleDirty = false;
this.consoleAt = now;
this.hooks.console(this.consoleText);
}
}
pushStats(force = false) {
if (!this.emu) return;
this.hooks.stats({
running: this.running,
paused: this.paused,
fps: this.paused ? 0 : (this.fps ?? 0),
frame: this.running ? this.emu.frameSerial() : 0,
seconds: this.running ? this.emu.nowNs() / 1e9 : 0,
hash: this.running ? this.emu.stateHash() : "—",
width: this.running ? this.emu.width : 0,
height: this.running ? this.emu.height : 0,
framePeriodMs: this.running ? this.emu.frameMs() : 0,
force,
});
}
fail(e) {
this.paused = true;
this.hooks.status({ text: String(e?.message ?? e), error: true });
this.pushStats(true);
}
save() {
return this.emu.save();
}
restore(bytes) {
this.emu.load(bytes);
this.draw();
this.pushStats(true);
}
listen() {
this.onKeyDown = (event) => this.key(event, true);
this.onKeyUp = (event) => this.key(event, false);
this.onBlur = () => this.releaseAll();
addEventListener("keydown", this.onKeyDown);
addEventListener("keyup", this.onKeyUp);
addEventListener("blur", this.onBlur);
}
unlisten() {
removeEventListener("keydown", this.onKeyDown);
removeEventListener("keyup", this.onKeyUp);
removeEventListener("blur", this.onBlur);
}
key(event, down) {
if (!this.running) return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (this.hasVideo && !isFormControl(event.target)) {
const bit = PAD[event.code];
if (bit !== undefined) {
this.held = down ? this.held | bit : this.held & ~bit;
this.emu.setButtons(0, this.held);
this.hooks.buttons(this.held);
event.preventDefault();
return;
}
}
if (!down || !this.hasConsole || !this.consoleFocused) return;
let text = null;
if (event.key.length === 1) text = event.key;
else if (event.key === "Enter") text = "\r";
else if (event.key === "Backspace") text = "\x7f";
else if (event.key === "Escape") text = "\x1b";
if (text !== null) {
this.emu.consoleWrite(text);
event.preventDefault();
}
}
setButton(bit, down) {
if (!this.running || !this.hasVideo) return;
this.held = down ? this.held | bit : this.held & ~bit;
this.emu.setButtons(0, this.held);
this.hooks.buttons(this.held);
}
releaseAll() {
this.held = 0;
if (this.emu) this.emu.setButtons(0, 0);
this.hooks.buttons(0);
}
}
export function decodeGuest(bytes) {
let out = "";
for (const byte of bytes) {
const c = byte & 0x7f;
if (c === 0x0d) out += "\n";
else if (c >= 0x20 && c < 0x7f) out += String.fromCharCode(c);
}
return out;
}