From 448c7d4f9dcdb4e38d7f15ebc30801bfcd67cc9e Mon Sep 17 00:00:00 2001 From: Steven Hugg Date: Sun, 27 Sep 2026 22:41:53 +0200 Subject: [PATCH] EmuHalt gets a 'normal' flag, fixed some problems in runpresets --- src/common/cpu/ARM.ts | 2 +- src/common/emu.ts | 9 ++++++++- src/common/emucore.ts | 6 ++++++ src/platform/pce.ts | 6 ++++++ src/tools/buildpresets.ts | 24 +++++++++++++++++------- src/tools/emutarget.ts | 5 ++++- src/tools/nodemock.ts | 31 +++++++++++++++++++++++++++++-- 7 files changed, 71 insertions(+), 12 deletions(-) diff --git a/src/common/cpu/ARM.ts b/src/common/cpu/ARM.ts index 7adc97e7..21f16547 100644 --- a/src/common/cpu/ARM.ts +++ b/src/common/cpu/ARM.ts @@ -3820,7 +3820,7 @@ ARMCore.prototype.compileArm = function(instruction) { // test for UDF instruction if ((instruction & 0xfff000f0) == (0xe7f000f0|0)) { var immediate = instruction & 0x0000000f; // TODO: full range - throw new EmuHalt("Program exited (" + immediate + ")"); + throw new EmuHalt("Program exited (" + immediate + ")", undefined, true); } var address : AddressFunction = function() { diff --git a/src/common/emu.ts b/src/common/emu.ts index d13bea93..f8dc4d27 100644 --- a/src/common/emu.ts +++ b/src/common/emu.ts @@ -280,9 +280,16 @@ export class RAM { export class EmuHalt extends Error { $loc: SourceLocation; squelchError = true; - constructor(msg: string, loc?: SourceLocation) { + /** + * true when the program stopped by design (e.g. a semihost exit), as opposed + * to a fault the CPU reported (illegal opcode, watchdog, bad ROM). Hosts can + * use this to tell "the program ended" from "the emulator gave up". + */ + normal: boolean; + constructor(msg: string, loc?: SourceLocation, normal = false) { super(msg); this.$loc = loc; + this.normal = normal; Object.setPrototypeOf(this, EmuHalt.prototype); } } diff --git a/src/common/emucore.ts b/src/common/emucore.ts index 5a8648dd..dee18504 100644 --- a/src/common/emucore.ts +++ b/src/common/emucore.ts @@ -119,6 +119,12 @@ function installHeadlessVideo() { this.setKeyboardEvents = setKeyboardEvents; this.getFrameData = function () { return datau32; }; this.getImageData = function () { return { data: datau8, width, height }; }; + // PCE (and other platforms) build their own ImageData from the canvas + // context during start(), so hand out a real one + this.createImageData = function (w: number, h: number) { + if (w === width && h === height) return { data: datau8, width, height }; + return { data: new Uint8ClampedArray(w * h * 4), width: w, height: h }; + }; this.updateFrame = function () { }; this.clearRect = function () { }; this.setupMouseEvents = function () { }; diff --git a/src/platform/pce.ts b/src/platform/pce.ts index 1b5e2708..5e0beeec 100644 --- a/src/platform/pce.ts +++ b/src/platform/pce.ts @@ -46,6 +46,12 @@ class PCEnginePlatform implements Platform { this.pce.Init(); this.pce.SetROM(rom); } + // headless hosts drive frames through advance(); the IDE uses the + // requestAnimationFrame loop that resume() starts + advance(novideo?: boolean): number { + this.pce.Run(); + return 1; + } getPlatformName(): string { return "PC Engine"; } diff --git a/src/tools/buildpresets.ts b/src/tools/buildpresets.ts index c7ef6c01..ae52e330 100644 --- a/src/tools/buildpresets.ts +++ b/src/tools/buildpresets.ts @@ -37,7 +37,7 @@ import * as path from 'path'; import * as util from 'util'; import { CompileResult, TOOLS, compileSourceFile, getToolForFilename, preload, romBytes } from './testlib'; import { EmuTarget, VideoOutput, captureRejectionListeners, dropAbortHandlers, installNodeMocks, loadPlatform } from './emutarget'; -import { PLATFORMS } from '../common/emu'; +import { EmuHalt, PLATFORMS } from '../common/emu'; import { getSkeletonName, getToolMeta } from '../common/toolmeta'; import { getBasePlatform } from '../common/util'; import { c } from './cliformat'; @@ -48,7 +48,7 @@ const PLATFORM_SRC_DIR = 'src/platform'; export interface RunResult { ok: boolean; // platform started and ran without throwing frames: number; // frames actually advanced - verdict: 'ok' | 'solid' | 'blank' | 'novideo' | 'error'; + verdict: 'ok' | 'solid' | 'blank' | 'novideo' | 'halted' | 'error'; width?: number; height?: number; colors?: number; // distinct pixel colors on the last frame @@ -479,9 +479,18 @@ export async function runPreset( if (opts.pngDir) run.png = await writePresetPNG(video, preset, opts.pngDir); } } catch (e) { - run.ok = false; - run.verdict = 'error'; - run.error = '' + (e && e.message ? e.message : e); + if (e instanceof EmuHalt && e.normal) { + // the program stopped by design (arm32 semihost exit); the emulator + // did its job, so that's a clean halt. Other EmuHalts (HLT opcode, + // watchdog, illegal instruction) are still failures. + run.ok = true; + run.verdict = 'halted'; + run.error = '' + (e.message || e); + } else { + run.ok = false; + run.verdict = 'error'; + run.error = '' + (e && e.message ? e.message : e); + } } // report what actually ran, which is less than requested if it threw if (target) run.frames = target.frameCount; @@ -596,6 +605,7 @@ function runStatus(run: RunResult): string { (run.color != null ? ` #${(run.color >>> 0).toString(16).padStart(8, '0')}` : ''); switch (run.verdict) { case 'ok': return green('run ok') + ' ' + dim(info); + case 'halted': return dim('run halted') + ' ' + dim(info); case 'novideo': return dim('run: no video'); case 'blank': return yellow(bold('run BLANK')) + ' ' + dim(info); case 'solid': return yellow(bold('run SOLID')) + ' ' + dim(info); @@ -657,7 +667,7 @@ async function main() { console.log(bold(`${run ? 'building and running' : 'building'} ${presets.length} presets...`)); const results = await buildAllPresets({ presets, timeout, verbose, run, frames, pngDir, onResult: (r) => { - const clean = !r.run || r.run.verdict === 'ok' || r.run.verdict === 'novideo'; + const clean = !r.run || r.run.verdict === 'ok' || r.run.verdict === 'novideo' || r.run.verdict === 'halted'; if (quiet && r.ok && clean) return; const status = r.ok ? green('ok ') : red(bold('FAIL')); const size = r.size != null ? `${r.size} bytes` : ''; @@ -690,7 +700,7 @@ async function main() { // what the builds did when loaded, separately from whether they built const ran = results.filter((r) => r.run); if (ran.length) { - const problems = ran.filter((r) => r.run.verdict !== 'ok' && r.run.verdict !== 'novideo'); + const problems = ran.filter((r) => r.run.verdict !== 'ok' && r.run.verdict !== 'novideo' && r.run.verdict !== 'halted'); const tally2 = `${ran.length - problems.length}/${ran.length} presets run clean`; console.log('\n' + bold(problems.length ? yellow(tally2) : green(tally2))); if (problems.length) { diff --git a/src/tools/emutarget.ts b/src/tools/emutarget.ts index fb3df118..197b400f 100644 --- a/src/tools/emutarget.ts +++ b/src/tools/emutarget.ts @@ -6,7 +6,7 @@ import { EmuCore } from "../common/emucore"; import { PLATFORMS } from "../common/emu"; import { getRootBasePlatform } from "../common/util"; import { importPlatform } from "../platform/_index"; -import { mockAudio, mockDOM, mockFetch, mockGlobals, mockScripts } from "./nodemock"; +import { mockAudio, mockDOM, mockFetch, mockGlobals, mockScripts, resetScripts } from "./nodemock"; export { EmuCore as EmuTarget, DISASSEMBLERS, DEFAULT_MAX_FRAMES } from "../common/emucore"; export type { VideoOutput, DebugSection } from "../common/emucore"; @@ -15,6 +15,9 @@ type EmuTarget = EmuCore; /** Load a platform module by ID (e.g. "nes", "c64.wasm", "atari8-5200"). */ export async function loadPlatform(platformId: string): Promise { installNodeMocks(); + // a headless host may load more than one platform (or the same one twice) + // in a process; scripts kept global state, so evaluate them fresh each time + resetScripts(); const baseId = getRootBasePlatform(platformId); // a platform module (verilog) may pull in an Emscripten runtime whose // rejection handler aborts the host; disarm it around the import diff --git a/src/tools/nodemock.ts b/src/tools/nodemock.ts index 8b316b7b..c9007991 100644 --- a/src/tools/nodemock.ts +++ b/src/tools/nodemock.ts @@ -37,18 +37,32 @@ export class NullAudio implements SampledAudioSink { } export function mockAudio() { + // The real TSS PsgDeviceChannel is a small register machine; platforms + // (astrocade, vectrex) read/write its registers and set its clock, so the + // stand-in has to keep those too, not just absorb the calls. class NullPsgDeviceChannel { + clock = 0; + register = new Int32Array(16); setMode() { } setDevice() { } + setClock(clock: number) { this.clock = clock; } generate() { } setBufferLength() { } setSampleRate() { } getBuffer() { return []; } writeRegister() { } writeRegisterSN() { } - writeRegisterAY() { } - readRegister() { return 0; } + writeRegisterAY(addr: number, val: number) { if (addr >= 0 && addr < 16) this.register[addr] = val & 0xff; } + readRegister(addr: number) { return this.register[addr & 0xf]; } } + // the constants the audio wrappers reference; values match tss/js/tss/PsgDeviceChannel.js + (NullPsgDeviceChannel as any).MODE_UNSIGNED = 0; + (NullPsgDeviceChannel as any).MODE_SIGNED = 1; + (NullPsgDeviceChannel as any).DEVICE_PSG = 0; + (NullPsgDeviceChannel as any).DEVICE_SSG = 1; + (NullPsgDeviceChannel as any).DEVICE_AY_3_8910 = 0; + (NullPsgDeviceChannel as any).DEVICE_YM_2149 = 1; + (NullPsgDeviceChannel as any).DEVICE_SN76489 = 2; class NullMasterChannel { addChannel() { } } @@ -69,8 +83,11 @@ export function mockFetch(rootDir: string = process.cwd()) { * Make loadScript() evaluate files from `rootDir` in the global scope, the * way a