pvbemu/web/App.js

692 lines
22 KiB
JavaScript

import { Core } from /**/"./core/Core.js";
import { Debugger } from /**/"./debugger/Debugger.js";
import { Disassembler } from /**/"./core/Disassembler.js";
import { Toolkit } from /**/"./toolkit/Toolkit.js";
// Front-end emulator application
class App extends Toolkit.App {
///////////////////////// Initialization Methods //////////////////////////
constructor(bundle) {
super({
style: {
display : "grid",
gridTemplateRows: "max-content auto"
},
visibility: true,
visible : false
});
// Configure instance fields
this.bundle = bundle;
this.debugMode = true;
this.dualMode = false;
this.text = null;
}
async init() {
// Theme
Object.assign(document.body.style, { margin:"0", overflow:"hidden" });
this.stylesheet(/**/"web/theme/kiosk.css", false);
this.stylesheet(/**/"web/theme/vbemu.css", false);
this._theme = "auto";
this.themes = {
dark : this.stylesheet(/**/"web/theme/dark.css" ),
light : this.stylesheet(/**/"web/theme/light.css" ),
virtual: this.stylesheet(/**/"web/theme/virtual.css")
};
// Watch for dark mode preference changes
this.isDark = window.matchMedia("(prefers-color-scheme: dark)");
this.isDark.addEventListener("change", e=>this.onDark());
this.onDark();
// Locales
await this.addLocale(/**/"web/locale/en-US.json");
for (let id of [].concat(navigator.languages, ["en-US"])) {
if (this.setLocale(id))
break;
}
this.setTitle("{app.title}", true);
// Element
document.body.append(this.element);
window.addEventListener("resize", e=>{
this.element.style.height = window.innerHeight + "px";
this.element.style.width = window.innerWidth + "px";
});
window.dispatchEvent(new Event("resize"));
this.addEventListener("keydown", e=>this.onKeyDown(e));
// Menus
this.menuBar = new Toolkit.MenuBar(this);
this.menuBar.setLabel("{menu._}", true);
this.add(this.menuBar);
this.initFileMenu ();
this.initEmulationMenu();
this.initDebugMenu (0, this.debugMode);
this.initDebugMenu (1, this.debugMode && this.dualMode);
this.initThemeMenu ();
// Fallback for bubbled key events
document.body.addEventListener("focusout", e=>this.onBlur(e));
window .addEventListener("keydown" , e=>this.onKey (e));
window .addEventListener("keyup" , e=>this.onKey (e));
// Temporary: Faux game mode display
this.display = new Toolkit.Component(this, {
class : "tk display",
style : { position: "relative" },
visible: !this.debugMode
});
this.image1 = new Toolkit.Component(this, { style: {
background: "#000000",
position : "absolute"
}});
this.display.add(this.image1);
this.image2 = new Toolkit.Component(this, { style: {
background: "#000000",
position : "absolute"
}});
this.display.add(this.image2);
this.display.addEventListener("resize", e=>this.onDisplay());
this.add(this.display);
// Temporary: Faux debug mode display
this.desktop = new Toolkit.Desktop(this, {
visible: this.debugMode
});
this.add(this.desktop);
// Emulation core
this.core = await new Core().init();
let sims = (await this.core.create(2)).sims;
this.core.onsubscription = (k,m)=>this.onSubscription(k, m);
// Debugging managers
this.dasm = new Disassembler();
this.debug = new Array(sims.length);
for (let x = 0; x < sims.length; x++) {
let dbg = this.debug[x] = new Debugger(this, sims[x], x);
if (x == 0 && !this.dualMode) {
dbg.cpu .substitute("#", "");
dbg.memory.substitute("#", "");
}
this.desktop.add(dbg.cpu);
this.desktop.add(dbg.memory);
}
// Reveal the application
this.visible = true;
this.restoreFocus();
console.log(
"CPU window shortcuts:\n" +
" F11 Single step\n" +
" F10 Run to next\n" +
" Ctrl+B Toggle bytes column\n" +
" Ctrl+F Fit columns\n" +
" Ctrl+G Goto"
);
}
// Initialize File menu
initFileMenu() {
let bar = this.menuBar;
let item = bar.file = new Toolkit.MenuItem(this);
item.setText("{menu.file._}");
bar.add(item);
let menu = item.menu = new Toolkit.Menu(this);
item = bar.file.loadROM0 = new Toolkit.MenuItem(this);
item.setText("{menu.file.loadROM}");
item.substitute("#", this.dualMode ? " 1" : "", false);
item.addEventListener("action", e=>this.onLoadROM(0));
menu.add(item);
item = bar.file.loadROM1 = new Toolkit.MenuItem(this,
{ visible: this.dualMode });
item.setText("{menu.file.loadROM}");
item.substitute("#", " 2", false);
item.addEventListener("action", e=>this.onLoadROM(1));
menu.add(item);
item = bar.file.dualMode = new Toolkit.MenuItem(this,
{ checked: this.dualMode, type: "checkbox" });
item.setText("{menu.file.dualMode}");
item.addEventListener("action", e=>this.onDualMode());
menu.add(item);
item = bar.file.debugMode = new Toolkit.MenuItem(this,
{ checked: this.debugMode, disabled: true, type: "checkbox" });
item.setText("{menu.file.debugMode}");
item.addEventListener("action", e=>this.onDebugMode());
menu.add(item);
menu.addSeparator();
item = new Toolkit.MenuItem(this);
item.setText("Export source...", false);
item.addEventListener("action", ()=>this.bundle.save());
menu.add(item);
}
// Initialize Emulation menu
initEmulationMenu() {
let bar = this.menuBar;
let item = bar.emulation = new Toolkit.MenuItem(this);
item.setText("{menu.emulation._}");
bar.add(item);
let menu = item.menu = new Toolkit.Menu(this);
item = bar.emulation.run =
new Toolkit.MenuItem(this, { disabled: true });
item.setText("{menu.emulation.run}");
menu.add(item);
item = bar.emulation.reset0 = new Toolkit.MenuItem(this);
item.setText("{menu.emulation.reset}");
item.substitute("#", this.dualMode ? " 1" : "", false);
item.addEventListener("action", e=>this.onReset(0));
menu.add(item);
item = bar.emulation.reset1 = new Toolkit.MenuItem(this,
{ visible: this.dualMode });
item.setText("{menu.emulation.reset}");
item.substitute("#", " 2", false);
item.addEventListener("action", e=>this.onReset(1));
menu.add(item);
item = bar.emulation.linkSims = new Toolkit.MenuItem(this,
{ disabled: true, type: "checkbox", visible: this.dualMode });
item.setText("{menu.emulation.linkSims}");
menu.add(item);
}
// Initialize Debug menu
initDebugMenu(index, visible) {
let bar = this.menuBar;
let item = bar["debug" + index] = new Toolkit.MenuItem(this,
{ visible: visible }), top = item;
item.setText("{menu.debug._}");
item.substitute("#",
index == 0 ? this.dualMode ? "1" : "" : " 2", false);
bar.add(item);
let menu = item.menu = new Toolkit.Menu(this);
item = top.console = new Toolkit.MenuItem(this, { disabled: true });
item.setText("{menu.debug.console}");
menu.add(item);
item = top.memory = new Toolkit.MenuItem(this);
item.setText("{menu.debug.memory}");
item.addEventListener("action",
e=>this.showWindow(this.debug[index].memory));
menu.add(item);
item = top.cpu = new Toolkit.MenuItem(this);
item.setText("{menu.debug.cpu}");
item.addEventListener("action",
e=>this.showWindow(this.debug[index].cpu));
menu.add(item);
item=top.breakpoints = new Toolkit.MenuItem(this, { disabled: true });
item.setText("{menu.debug.breakpoints}");
menu.add(item);
menu.addSeparator();
item = top.palettes = new Toolkit.MenuItem(this, { disabled: true });
item.setText("{menu.debug.palettes}");
menu.add(item);
item = top.characters = new Toolkit.MenuItem(this, { disabled: true });
item.setText("{menu.debug.characters}");
menu.add(item);
item = top.bgMaps = new Toolkit.MenuItem(this, { disabled: true });
item.setText("{menu.debug.bgMaps}");
menu.add(item);
item = top.backgrounds = new Toolkit.MenuItem(this, { disabled:true });
item.setText("{menu.debug.backgrounds}");
menu.add(item);
item = top.objects = new Toolkit.MenuItem(this, { disabled: true });
item.setText("{menu.debug.objects}");
menu.add(item);
item = top.frameBuffers = new Toolkit.MenuItem(this, {disabled: true});
item.setText("{menu.debug.frameBuffers}");
menu.add(item);
}
// Initialize Theme menu
initThemeMenu() {
let bar = this.menuBar;
let item = bar.theme = new Toolkit.MenuItem(this);
item.setText("{menu.theme._}");
bar.add(item);
let menu = item.menu = new Toolkit.Menu(this);
item = bar.theme.auto = new Toolkit.MenuItem(this,
{ checked: true, type: "checkbox" });
item.setText("{menu.theme.auto}");
item.theme = "auto";
item.addEventListener("action", e=>this.theme = "auto");
menu.add(item);
item = bar.theme.light = new Toolkit.MenuItem(this,
{ checked: false, type: "checkbox" });
item.setText("{menu.theme.light}");
item.theme = "light";
item.addEventListener("action", e=>this.theme = "light");
menu.add(item);
item = bar.theme.dark = new Toolkit.MenuItem(this,
{ checked: false, type: "checkbox" });
item.setText("{menu.theme.dark}");
item.theme = "dark";
item.addEventListener("action", e=>this.theme = "dark");
menu.add(item);
item = bar.theme.light = new Toolkit.MenuItem(this,
{ checked: false, type: "checkbox" });
item.setText("{menu.theme.virtual}");
item.theme = "virtual";
item.addEventListener("action", e=>this.theme = "virtual");
menu.add(item);
}
///////////////////////////// Event Handlers //////////////////////////////
// All elements have lost focus
onBlur(e) {
if (
e.relatedTarget == null ||
e.relatedTarget == document.body
) this.restoreFocus();
}
// Dark mode preference changed
onDark() {
if (this._theme != "auto")
return;
let isDark = this.isDark.matches;
this.themes.light.disabled = isDark;
this.themes.dark .disabled = !isDark;
}
// Game mode display resized
onDisplay() {
let bounds = this.display.element.getBoundingClientRect();
let width = Math.max(1, bounds.width);
let height = Math.max(1, bounds.height);
let scale, x1, y1, x2, y2;
// Single mode
if (!this.dualMode) {
this.image2.visible = false;
scale = Math.max(1, Math.min(
Math.floor(width / 384),
Math.floor(height / 224)
));
x1 = Math.max(0, Math.floor((width - 384 * scale) / 2));
y1 = Math.max(0, Math.floor((height - 224 * scale) / 2));
x2 = y2 = 0;
}
// Dual mode
else {
this.image2.visible = true;
// Horizontal orientation
if (true) {
scale = Math.max(1, Math.min(
Math.floor(width / 768),
Math.floor(height / 224)
));
let gap = Math.max(0, width - 768 * scale);
gap = gap < 0 ? 0 : Math.floor(gap / 3) + (gap%3==2 ? 1 : 0);
x1 = gap;
x2 = Math.max(384 * scale, width - 384 * scale - gap);
y1 = y2 = Math.max(0, Math.floor((height - 224 * scale) / 2));
}
// Vertical orientation
else {
scale = Math.max(1, Math.min(
Math.floor(width / 384),
Math.floor(height / 448)
));
let gap = Math.max(0, height - 448 * scale);
gap = gap < 0 ? 0 : Math.floor(gap / 3) + (gap%3==2 ? 1 : 0);
x1 = x2 = Math.max(0, Math.floor((width - 384 * scale) / 2));
y1 = gap;
y2 = Math.max(224 * scale, height - 224 * scale - gap);
}
}
width = 384 * scale + "px";
height = 224 * scale + "px";
Object.assign(this.image1.element.style,
{ left: x1+"px", top: y1+"px", width: width, height: height });
Object.assign(this.image2.element.style,
{ left: x2+"px", top: y2+"px", width: width, height: height });
}
// File -> Debug mode
onDebugMode() {
this.debugMode =!this.debugMode;
this.display.visible =!this.debugMode;
this.desktop.visible = this.debugMode;
this.configMenus();
this.onDisplay();
}
// Emulation -> Dual mode
onDualMode() {
this.setDualMode(!this.dualMode);
this.configMenus();
this.onDisplay();
}
// Key press
onKeyDown(e) {
// Take no action
if (!e.altKey || e.key != "F10" ||
this.menuBar.contains(document.activeElement))
return;
// Move focus into the menu bar
this.menuBar.focus();
Toolkit.handle(e);
}
// File -> Load ROM
async onLoadROM(index) {
// Add a file picker to the document
let file = document.createElement("input");
file.type = "file";
file.style.position = "absolute";
file.style.visibility = "hidden";
document.body.appendChild(file);
// Prompt the user to select a file
await new Promise(resolve=>{
file.addEventListener("input", resolve);
file.click();
});
file.remove();
// No file was selected
file = file.files[0];
if (!file)
return;
// Load the file
let data = null;
try { data = new Uint8Array(await file.arrayBuffer()); }
catch {
alert(this.localize("{menu.file.loadROMError}"));
return;
}
// Attempt to process the file as an ISX binary
try { data = Debugger.isx(data).toROM(); } catch { }
// Error checking
if (
data.length < 1024 ||
data.length > 0x1000000 ||
(data.length & data.length - 1)
) {
alert(this.localize("{menu.file.loadROMInvalid}"));
return;
}
// Load the ROM into simulation memory
let rep = await this.core.setROM(this.debug[index].sim, data,
{ refresh: true });
if (!rep.success) {
alert(this.localize("{menu.file.loadROMError}"));
return;
}
this.debug[index].followPC(0xFFFFFFF0);
}
// Emulation -> Reset
async onReset(index) {
await this.core.reset(this.debug[index].sim, { refresh: true });
this.debug[index].followPC(0xFFFFFFF0);
}
// Core subscription
onSubscription(key, msg) {
let target = this.debug; // Handler object
for (let x = 1; x < key.length - 1; x++)
target = target[key[x]];
target[key[key.length - 1]].call(target, msg);
}
///////////////////////////// Public Methods //////////////////////////////
// Specify document title
setTitle(title, localize) {
this.setString("text", title, localize);
}
// Specify the color theme
get theme() { return this._theme; }
set theme(theme) {
switch (theme) {
case "light": case "dark": case "virtual":
this._theme = theme;
for (let entry of Object.entries(this.themes))
entry[1].disabled = entry[0] != theme;
break;
default:
this._theme = "auto";
this.themes["virtual"].disabled = true;
this.onDark();
}
for (let item of this.menuBar.theme.menu.children)
item.checked = item.theme == theme;
}
///////////////////////////// Package Methods /////////////////////////////
// Configure components for automatic localization, or localize a message
localize(a, b) {
// Default behavior
if (a && a != this)
return super.localize(a, b);
// Update localization strings
if (this.text != null) {
let text = this.text;
document.title = !text[1] ? text[0] :
this.localize(text[0], this.substitutions);
}
}
// Return focus to the most recent focused element
restoreFocus() {
// Focus was successfully restored
if (super.restoreFocus())
return true;
// Select the foremost visible window
let wnd = this.desktop.getActiveWindow();
if (wnd) {
wnd.focus();
return true;
}
// Select the menu bar
this.menuBar.focus();
return true;
}
// Perform a Run Next command on one of the simulations
runToNext(index, options) {
let debugs = [ this.debug[index] ];
if (this.dualMode)
debugs.push(this.debug[index ^ 1]);
let ret = this.core.runToNext(debugs.map(d=>d.sim), options);
if (ret instanceof Promise) ret.then(msg=>{
for (let x = 0; x < debugs.length; x++)
debugs[x].followPC(msg.pcs[x]);
});
}
// Perform a Single Step command on one of the simulations
singleStep(index, options) {
let debugs = [ this.debug[index] ];
if (this.dualMode)
debugs.push(this.debug[index ^ 1]);
let ret = this.core.singleStep(debugs.map(d=>d.sim), options);
if (ret instanceof Promise) ret.then(msg=>{
for (let x = 0; x < debugs.length; x++)
debugs[x].followPC(msg.pcs[x]);
});
}
///////////////////////////// Private Methods /////////////////////////////
// Configure menu item visibility
configMenus() {
let bar = this.menuBar;
bar.file.debugMode .checked = this.debugMode;
bar.file.loadROM1 .visible = this.dualMode;
bar.emulation.reset1 .visible = this.dualMode;
bar.emulation.linkSims.visible = this.dualMode;
bar.debug0 .visible = this.debugMode;
bar.debug1 .visible = this.debugMode && this.dualMode;
}
// Specify whether dual mode is active
setDualMode(dualMode) {
// Update state
if (dualMode == this.dualMode)
return;
this.dualMode = dualMode;
// Working variables
let index = dualMode ? " 1" : "";
// Update menus
let bar = this.menuBar;
bar.file.loadROM0 .substitute("#", index, false);
bar.debug0 .substitute("#", index, false);
bar.emulation.reset0.substitute("#", index, false);
bar.file.dualMode .checked = this.dualMode;
// Update sim 1 debug windows
let dbg = this.debug[0];
dbg.cpu .substitute("#", index);
dbg.memory.substitute("#", index);
// Re-show any sim 2 debug windows that were previously visible
if (dualMode) {
for (let wnd of this.desktop.children) {
if (wnd.index == 1 && wnd.wasVisible)
wnd.visible = true;
}
}
// Hide any visible sim 2 debug windows
else for (let wnd of this.desktop.children) {
if (wnd.index == 0)
continue;
wnd.wasVisible = wnd.visible;
wnd.visible = false;
}
}
// Ensure a debugger window is visible
showWindow(wnd) {
let adjust = false;
// The window is already visible
if (wnd.visible) {
// The window is already in the foreground
if (wnd == this.desktop.getActiveWindow())
;//adjust = true;
// Bring the window to the foreground
else this.desktop.bringToFront(wnd);
}
// The window is not visible
else {
adjust = !wnd.shown;
if (adjust && wnd.firstShow)
wnd.firstShow();
wnd.visible = true;
this.desktop.bringToFront(wnd);
}
// Adjust the window position
if (adjust) {
let bounds = this.desktop.element.getBoundingClientRect();
wnd.left = Math.max(0, (bounds.width - wnd.outerWidth ) / 2);
wnd.top = Math.max(0, (bounds.height - wnd.outerHeight) / 2);
}
// Transfer focus into the window
wnd.focus();
}
// Install a stylesheet. Returns the resulting <link> element
stylesheet(filename, disabled = true) {
let ret = document.createElement("link");
ret.href = filename;
ret.rel = "stylesheet";
ret.type = "text/css";
ret.disabled = disabled;
document.head.append(ret);
return ret;
}
///////////////////////////// Program Methods /////////////////////////////
// Program entry point
static main(bundle) {
new App(bundle).init();
}
}
export { App };