diff --git a/src/common/emu.ts b/src/common/emu.ts index 75383c03..e52c7221 100644 --- a/src/common/emu.ts +++ b/src/common/emu.ts @@ -69,7 +69,7 @@ export function _setKeyboardEvents(canvas: HTMLElement, callback: KeyboardCallba type VideoCanvasOptions = { rotate?: number, overscan?: boolean, aspect?: number }; -export class RasterVideo { +class RasterVideoImpl { mainElement: HTMLElement; width: number; @@ -167,7 +167,7 @@ export class RasterVideo { }; } -export class VectorVideo extends RasterVideo { +class VectorVideoImpl extends RasterVideoImpl { persistenceAlpha = 0.5; jitter = 1.0; @@ -247,6 +247,28 @@ export function drawCrosshair(ctx: CanvasRenderingContext2D, x: number, y: numbe ctx.stroke(); } +// Platforms construct video and timers through these bindings, so a headless +// host can swap in stand-ins with setVideoClasses (see src/tools/emutarget.ts). +export type RasterVideo = RasterVideoImpl; +export let RasterVideo = RasterVideoImpl; +export type VectorVideo = VectorVideoImpl; +export let VectorVideo = VectorVideoImpl; + +export interface VideoClasses { + RasterVideo: typeof RasterVideoImpl; + VectorVideo: typeof VectorVideoImpl; + AnimationTimer: typeof AnimationTimerImpl; +} + +/** Replace the classes platforms construct; returns the previous ones. */ +export function setVideoClasses(classes: Partial): VideoClasses { + const previous = { RasterVideo, VectorVideo, AnimationTimer }; + if (classes.RasterVideo) RasterVideo = classes.RasterVideo; + if (classes.VectorVideo) VectorVideo = classes.VectorVideo; + if (classes.AnimationTimer) AnimationTimer = classes.AnimationTimer; + return previous; +} + export class RAM { mem: Uint8Array; constructor(size: number) { @@ -266,7 +288,7 @@ export class EmuHalt extends Error { export var useRequestAnimationFrame: boolean = false; -export class AnimationTimer { +class AnimationTimerImpl { callback; running: boolean = false; @@ -347,6 +369,9 @@ export class AnimationTimer { } } +export type AnimationTimer = AnimationTimerImpl; +export let AnimationTimer = AnimationTimerImpl; + // TODO: move to util? export function dumpRAM(ram: ArrayLike, ramofs: number, ramlen: number): string { diff --git a/src/tools/emutarget.ts b/src/tools/emutarget.ts index fb270d9a..b9d9e096 100644 --- a/src/tools/emutarget.ts +++ b/src/tools/emutarget.ts @@ -24,12 +24,17 @@ import { CPU6809 } from "../common/cpu/6809"; import { PLATFORMS } from "../common/emu"; import * as emu from "../common/emu"; import { getRootBasePlatform } from "../common/util"; +import { importPlatform } from "../platform/_index"; import { mockAudio, mockDOM, mockFetch, mockGlobals } from "./nodemock"; export interface VideoOutput { pixels: Uint32Array; width: number; height: number; + /** display rotation in degrees (RasterVideo's `rotate` option) */ + rotate?: number; + /** display aspect ratio, if not width/height */ + aspect?: number; } export interface DebugSection { @@ -81,19 +86,18 @@ function installHeadlessVideo() { // and put the real ones back afterwards. Leaving the stubs installed would // leak into every other module sharing the (cached) emu import -- which is // exactly what happens when mocha reuses a worker across test files. - const original = { - RasterVideo: (emu as any).RasterVideo, - VectorVideo: (emu as any).VectorVideo, - AnimationTimer: (emu as any).AnimationTimer, - }; let pixels: Uint32Array | null = null; - let params: { width: number, height: number } | null = null; - (emu as any).RasterVideo = function (_el: any, width: number, height: number) { + let params: { width: number, height: number, rotate?: number, aspect?: number } | null = null; + let frameRate = 60; + const RasterVideo: any = function (_el: any, width: number, height: number, options?: { rotate?: number, aspect?: number }) { const buffer = new ArrayBuffer(width * height * 4); const datau8 = new Uint8Array(buffer); const datau32 = new Uint32Array(buffer); - params = { width, height }; - pixels = datau32; + // the first one is the screen; later ones are debug views (nes nametables) + if (!pixels) { + params = { width, height, rotate: options?.rotate, aspect: options?.aspect }; + pixels = datau32; + } this.create = function () { this.width = width; this.height = height; }; this.setKeyboardEvents = function () { }; this.getFrameData = function () { return datau32; }; @@ -108,26 +112,27 @@ function installHeadlessVideo() { this.putImageData = function () { }; this.style = {}; }; - (emu as any).VectorVideo = function () { + const VectorVideo: any = function () { this.create = function () { this.drawops = 0; }; this.setKeyboardEvents = function () { }; this.clear = function () { }; this.drawLine = function () { this.drawops++; }; }; - (emu as any).AnimationTimer = function () { + const AnimationTimer: any = function (fps: number) { + if (fps > 0) frameRate = fps; this.running = false; this.start = function () { }; this.stop = function () { }; this.isRunning = function () { return this.running; }; }; + const original = emu.setVideoClasses({ RasterVideo, VectorVideo, AnimationTimer }); return { get(): VideoOutput | null { - return pixels && params ? { pixels, width: params.width, height: params.height } : null; + return pixels && params ? { pixels, ...params } : null; }, + get frameRate() { return frameRate; }, restore() { - (emu as any).RasterVideo = original.RasterVideo; - (emu as any).VectorVideo = original.VectorVideo; - (emu as any).AnimationTimer = original.AnimationTimer; + emu.setVideoClasses(original); } }; } @@ -214,6 +219,8 @@ export class EmuTarget { } getVideo(): VideoOutput | null { return this.video ? this.video.get() : null; } + /** Frames per second the platform's timer asked for (60 if unknown). */ + get frameRate(): number { return this.video ? this.video.frameRate : 60; } saveState(): EmuState | null { return this.platform.saveState ? this.platform.saveState() : null; } getDebugInfo(): DebugSection[] { @@ -329,7 +336,8 @@ export class EmuTarget { export async function loadPlatform(platformId: string): Promise { installNodeMocks(); const baseId = getRootBasePlatform(platformId); - await import('../platform/' + baseId); + // the explicit import switch, so bundlers can find every platform module + await importPlatform(baseId); const PlatformClass = PLATFORMS[platformId] || PLATFORMS[baseId]; if (!PlatformClass) { throw new Error(`Platform '${platformId}' not found. Available: ${Object.keys(PLATFORMS).sort().join(', ')}`); @@ -339,12 +347,16 @@ export async function loadPlatform(platformId: string): Promise { let mocksInstalled = false; -/** Stub out the browser APIs that the platform modules expect. */ -export function installNodeMocks() { +/** + * Stub out the browser APIs that the platform modules expect. fetch() reads + * files (BIOS images, wasm cores) relative to `rootDir`. Only the first call + * takes effect. + */ +export function installNodeMocks(rootDir: string = process.cwd()) { if (mocksInstalled) return; mocksInstalled = true; mockGlobals(); mockAudio(); - mockFetch(); + mockFetch(rootDir); mockDOM(); } diff --git a/src/tools/nodemock.ts b/src/tools/nodemock.ts index 54c62ca2..ffe89b95 100644 --- a/src/tools/nodemock.ts +++ b/src/tools/nodemock.ts @@ -2,6 +2,7 @@ import { SampledAudioSink } from "../common/devices"; import fs from 'fs'; +import path from 'path'; export function mockGlobals() { global.atob = require('atob'); @@ -38,9 +39,10 @@ export function mockAudio() { global.PsgDeviceChannel = NullPsgDeviceChannel; } -export function mockFetch() { - global.fetch = async (path, init) => { - let bin = fs.readFileSync(path); +/** Serve fetch() from the filesystem, relative to `rootDir`. */ +export function mockFetch(rootDir: string = process.cwd()) { + global.fetch = async (url, init) => { + let bin = fs.readFileSync(path.resolve(rootDir, String(url))); let blob = new Blob([bin]); return new Response(blob); }