From 268e5457d7bf115f285df9ccf960d6461bad8abc Mon Sep 17 00:00:00 2001 From: Steven Hugg Date: Mon, 28 Sep 2026 17:04:15 +0200 Subject: [PATCH] timeline: EmuHalt as RunResult.halt, History.truncate(), byte budget for checkpoints --- extension/notes/PLAN.md | 19 +++++ src/common/history.ts | 54 ++++++++++++-- src/common/platformcore.ts | 20 ++++-- src/common/statehash.ts | 32 +++++++++ src/common/timeline.ts | 85 +++++++++++++++++----- test/unit/testhistory.ts | 120 +++++++++++++++++++++++++++++++ test/unit/testtimeline.ts | 81 +++++++++++++++++++++ test/unit/testtimelinemachine.ts | 60 ++++++++++++++++ 8 files changed, 441 insertions(+), 30 deletions(-) diff --git a/extension/notes/PLAN.md b/extension/notes/PLAN.md index 9b83bdf8..9bdf5f81 100644 --- a/extension/notes/PLAN.md +++ b/extension/notes/PLAN.md @@ -1657,6 +1657,25 @@ debugger shouldn't become a third. reports what each supports (`supportsTrap`, `supportsStep`, `supportsStepBack`), and hosts disable what's missing instead of faking it. Frame-granular breaking says so. +- **Halts are stops.** Done in `MachineCore`. An `EmuHalt` (6502 KIL, a + watchdog) comes back as `RunResult.halt`, not an exception. The core + parks just before the step that halted, so the PC shows the halting + instruction. An unmetered run replays the frame, counting steps, to find + that step. Running on halts again at the same place, and the past before + it can be rewound: "how did we get here?" works after a crash. Other + errors still propagate. `FramePlatformCore` reports the halt at the start + of its frame. Hosts turn `halt` into a `StopEvent` (`exception`), and + `EmuHalt.normal` into `halt`. +- **Steps are clocks on a 6502.** `BasicHeadlessMachine.advanceCPU()` runs + one clock on a clock-based CPU, so a step can land mid-instruction. Step + back means the last step where `cpu.isStable()` + (`findLast(isStable, first, previousStep())`), not `previousStep()`. + `createCore` reports these machines as `clock`. +- **Rewind budget and branching.** Done in `History`. `maxBytes` (default + 64 MB, sizes from `stateSize()`) caps checkpoints along with + `maxCheckpoints`. `truncate()` makes the current position the present + before running on from the past; the host calls it before delivering new + input. - **Pause is not a stop.** A user pause stops between frames; the controller still reports a `StopEvent` with a PC and source location so every host shows where it is. diff --git a/src/common/history.ts b/src/common/history.ts index c3be3aff..52a235af 100644 --- a/src/common/history.ts +++ b/src/common/history.ts @@ -9,6 +9,7 @@ // the same operation with different predicates. import { ProbeAll, TrapCondition } from "./devices"; +import { stateSize } from "./statehash"; import { compareTimestamps, DeterministicCore, @@ -31,11 +32,15 @@ export interface FrameInputSource { replay(frame: number): void; /** Drop everything recorded for frames before this one. */ trim?(firstFrame: number): void; + /** Drop everything recorded for this frame and later: the future is being rewritten. */ + truncate?(fromFrame: number): void; } export interface Checkpoint { at: Timestamp; state: S; + /** estimated size of `state`, for the byte budget */ + bytes: number; } /** @@ -70,8 +75,10 @@ function conditionReset(c: SearchCondition): void { export interface HistoryOptions { /** Frames between checkpoints. Larger = less memory, slower seeks. */ checkpointInterval?: number; - /** Oldest checkpoints are dropped past this. */ + /** Oldest checkpoints are dropped past this many... */ maxCheckpoints?: number; + /** ...or past this many bytes, whichever comes first. One is always kept. */ + maxBytes?: number; input?: FrameInputSource; } @@ -87,7 +94,9 @@ export class History { readonly core: DeterministicCore; checkpointInterval: number; maxCheckpoints: number; + maxBytes: number; private input?: FrameInputSource; + private checkpointBytes = 0; private checkpoints: Checkpoint[] = []; /** Latest point ever reached, i.e. the present. */ private head: Timestamp = timestamp(0, 0); @@ -99,13 +108,17 @@ export class History { this.core = core; this.checkpointInterval = opts.checkpointInterval ?? 10; this.maxCheckpoints = opts.maxCheckpoints ?? 300; + // checkpoint sizes run from 8 KB (mw8080bw) to 1 MB (nes) + this.maxBytes = opts.maxBytes ?? 64 * 1024 * 1024; this.input = opts.input; this.reset(); } /** Start recording again from wherever the core is now. */ reset(): void { - this.checkpoints = [{ at: this.core.now(), state: this.core.snapshot() }]; + this.checkpoints = []; + this.checkpointBytes = 0; + this.addCheckpoint(this.core.now()); this.head = this.core.now(); this.onChange?.(); } @@ -123,11 +136,14 @@ export class History { /** True if the core is showing a reconstructed past rather than the present. */ isInPast(): boolean { return compareTimestamps(this.core.now(), this.head) < 0; } getCheckpoints(): ReadonlyArray> { return this.checkpoints; } + /** Estimated bytes held by all checkpoints. */ + getCheckpointBytes(): number { return this.checkpointBytes; } /** * Run one frame at the head of the recording, capturing input and taking a * checkpoint when one is due. This is the recording loop; it must be called - * with the core positioned at the head. + * with the core positioned at the head. If the machine halts, the head stays + * at the halt, and every later call halts there again (see RunResult.halt). */ recordFrame(trap?: TrapCondition | null): RunResult { const t = this.core.now(); @@ -147,13 +163,37 @@ export class History { private checkpointIfDue(at: Timestamp): void { const last = this.checkpoints[this.checkpoints.length - 1]; if (last && at.frame - last.at.frame < this.checkpointInterval) return; - this.checkpoints.push({ at, state: this.core.snapshot() }); - while (this.checkpoints.length > this.maxCheckpoints) { - this.checkpoints.shift(); + this.addCheckpoint(at); + while (this.checkpoints.length > 1 && + (this.checkpoints.length > this.maxCheckpoints || this.checkpointBytes > this.maxBytes)) { + this.checkpointBytes -= this.checkpoints.shift().bytes; this.input?.trim?.(this.checkpoints[0].at.frame); } } + private addCheckpoint(at: Timestamp): void { + const state = this.core.snapshot(); + const bytes = stateSize(state); + this.checkpoints.push({ at, state, bytes }); + this.checkpointBytes += bytes; + } + + /** + * Make the current position the present, dropping the recorded future, so + * recording can carry on from here -- running on after rewinding. Call it + * before delivering any new input, or that input lands in the old future. + */ + truncate(): void { + const now = this.core.now(); + while (this.checkpoints.length > 1 && compareTimestamps(this.checkpoints[this.checkpoints.length - 1].at, now) > 0) { + this.checkpointBytes -= this.checkpoints.pop().bytes; + } + // a frame already under way has taken its input; later frames take new input + this.input?.truncate?.(now.step === 0 ? now.frame : now.frame + 1); + this.head = now; + this.onChange?.(); + } + /** The latest checkpoint at or before t, or null if t is before the window. */ checkpointAtOrBefore(t: Timestamp): Checkpoint | null { for (var i = this.checkpoints.length - 1; i >= 0; i--) { @@ -194,7 +234,7 @@ export class History { const frameEnd = timestamp(at.frame + 1, 0); const stop = compareTimestamps(frameEnd, t) < 0 ? frameEnd : t; const r = this.core.runUntil(stop, trap); - if (r.trapped) return r; + if (r.trapped || r.halt) return r; // a core that reports no progress can't reach t; stop rather than spin if (compareTimestamps(r.at, at) <= 0) break; } diff --git a/src/common/platformcore.ts b/src/common/platformcore.ts index 2723e307..52eb7498 100644 --- a/src/common/platformcore.ts +++ b/src/common/platformcore.ts @@ -6,7 +6,7 @@ // so through its granularity instead of pretending to sub-frame accuracy. import { Platform } from "./baseplatform"; -import { getNoiseSeed, setNoiseSeed } from "./emu"; +import { EmuHalt, getNoiseSeed, setNoiseSeed } from "./emu"; import { FrameInputSource } from "./history"; import { DeterministicCore, @@ -139,7 +139,15 @@ export class FramePlatformCore implements DeterministicCore { runUntil(target: Timestamp, trap?: TrapCondition | null): RunResult { while (this.frame < target.frame) { if (trap && trap()) return { at: this.now(), trapped: true }; - this.advanceFrame(); + try { + this.advanceFrame(); + } catch (e) { + // Frames are all this core can count, so the halt is reported at the + // start of the frame it happened in. The platform is left as the halt + // left it; seeking restores it from a checkpoint. + if (e instanceof EmuHalt) return { at: this.now(), trapped: false, halt: e }; + throw e; + } this.frame++; } return { at: this.now(), trapped: false }; @@ -163,13 +171,15 @@ export function isRewindable(platform: Platform): boolean { /** * Pick the most precise core the platform supports. WASM machines trap per - * clock tick, JS machines per instruction; both are exposed as "steps" but the - * granularity is reported so the UI can label the sub-frame axis honestly. + * clock tick, and so do JS machines with a clock-based CPU (6502); the rest + * trap per instruction. Both are exposed as "steps" but the granularity is + * reported so the UI can label the sub-frame axis honestly. */ export function createCore(platform: Platform): DeterministicCore { const machine = (platform as any).machine; if (machine && typeof machine.advanceFrame === 'function' && typeof machine.saveState === 'function') { - const granularity: Granularity = typeof machine.advanceFrameClock === 'function' ? 'clock' : 'insn'; + const clocked = typeof machine.advanceFrameClock === 'function' || typeof machine.cpu?.advanceClock === 'function'; + const granularity: Granularity = clocked ? 'clock' : 'insn'; return new MachineCore(machine, granularity); } return new FramePlatformCore(platform); diff --git a/src/common/statehash.ts b/src/common/statehash.ts index 984d5518..936aec98 100644 --- a/src/common/statehash.ts +++ b/src/common/statehash.ts @@ -67,3 +67,35 @@ function hashValue(h: number, v: any, path: Set): number { export function hashState(state: any): number { return hashValue(0x811c9dc5, state, new Set()); } + +/** + * Rough bytes held by a state, for budgeting checkpoints. Typed arrays count + * their byteLength; an object reachable twice (or through a cycle) counts + * once, since it is held once. Views on the same buffer each count, which + * overestimates, but states rarely share buffers that way. + */ +export function stateSize(state: any): number { + const seen = new Set(); + const walk = (v: any): number => { + if (v == null) return 0; + switch (typeof v) { + case 'number': return 8; + case 'boolean': return 4; + case 'string': return 2 * v.length; + case 'object': break; + default: return 0; + } + if (seen.has(v)) return 0; + seen.add(v); + if (ArrayBuffer.isView(v)) return v.byteLength; + if (v instanceof ArrayBuffer) return v.byteLength; + var n = 0; + if (Array.isArray(v)) { + for (var i = 0; i < v.length; i++) n += walk(v[i]); + } else { + for (const k of Object.keys(v)) n += walk(v[k]); + } + return n; + }; + return walk(state); +} diff --git a/src/common/timeline.ts b/src/common/timeline.ts index 77e5bb9b..6669a959 100644 --- a/src/common/timeline.ts +++ b/src/common/timeline.ts @@ -9,11 +9,14 @@ // // A *step* is one invocation of the TrapCondition that FrameBased.advanceFrame() // already defines. That means the unit is whatever the machine traps on -- one -// instruction for BasicScanlineMachine, one clock tick for the WASM machines -- -// and it is consistent within a machine, which is all the timeline needs. It is -// the same unit the replay bar's "Step" slider already uses. +// instruction for a Z80 or 6809 BasicScanlineMachine, one clock tick for a +// 6502 one and for the WASM machines -- and it is consistent within a machine, +// which is all the timeline needs. A clock step can land mid-instruction, so +// "the previous instruction" is the last step where cpu.isStable(), not +// previousStep(). It is the same unit the replay bar's "Step" slider uses. import { FrameBased, ProbeAll, SavesState, TrapCondition } from "./devices"; +import { EmuHalt } from "./emu"; /** * A point in emulated time. Frames count from the start of recording and are @@ -55,6 +58,12 @@ export interface RunResult { at: Timestamp; /** True if the caller's trap stopped the run before the target. */ trapped: boolean; + /** + * Set if the machine halted (a KIL opcode, a watchdog). `at` is then the + * position just before the step that halted, and the machine is parked + * there. Running on from it halts again at the same place. + */ + halt?: EmuHalt; } /** @@ -176,25 +185,32 @@ export class MachineCore implements DeterministicCore { while (compareTimestamps(this.now(), target) < 0) { // run to the end of this frame, or to target.step if the target is in it const untilStep = this.frame < target.frame ? Infinity : target.step; - if (this.runFrame(untilStep, trap)) { - return { at: this.now(), trapped: true }; - } + const stop = this.runFrame(untilStep, trap); + if (stop === 'trap') return { at: this.now(), trapped: true }; + if (stop instanceof EmuHalt) return { at: this.now(), trapped: false, halt: stop }; } return { at: this.now(), trapped: false }; } /** * Advance within the current frame to `untilStep` (Infinity = end of frame). - * Returns true if `trap` stopped the run. + * Returns 'trap' if `trap` stopped the run, the EmuHalt if the machine + * halted, or null if it reached `untilStep`. */ - private runFrame(untilStep: number, trap?: TrapCondition | null): boolean { - // Unmetered fast path: a whole frame with nothing to evaluate. Machines - // can take their bulk-execution route (e.g. machine_exec) instead of - // paying for a trap call per tick. - if (untilStep === Infinity && !trap) { - this.machine.advanceFrame(null); + private runFrame(untilStep: number, trap?: TrapCondition | null): 'trap' | EmuHalt | null { + // Unmetered fast path: a whole frame from its start with nothing to + // evaluate. Machines can take their bulk-execution route (e.g. + // machine_exec) instead of paying for a trap call per tick. + if (untilStep === Infinity && !trap && this.step === 0) { + try { + this.machine.advanceFrame(null); + } catch (e) { + if (!(e instanceof EmuHalt)) throw e; + // unmetered, so we don't know which step halted: find out + return this.parkAtHalt(this.findHaltStep(), e); + } this.endFrame(-1); - return false; + return null; } // Re-run the frame from its start if we're already partway into it, since // advanceFrame() restarts rather than resumes. Steps before our current @@ -230,16 +246,49 @@ export class MachineCore implements DeterministicCore { // hasn't really ended. An error from it (galaxian's watchdog) is not an // error at the stop. Resuming replays the whole frame, so a real one is // raised again then. - if (!stopState) throw e; + if (!stopState) { + if (!(e instanceof EmuHalt)) throw e; + // the last step the trap saw is the one that halted (or the frame's + // tail halted after it) + return this.parkAtHalt(n > 0 ? n - 1 : 0, e); + } } - if (stopState) this.machine.loadState(stopState); if (stopState) { + this.machine.loadState(stopState); this.step = n; - return trapped; + return trapped ? 'trap' : null; } // ran off the end of the frame without stopping this.endFrame(n); - return false; + return null; + } + + /** + * Replay the current frame from its start, counting steps, to find which one + * halts. Only needed after an unmetered run, which doesn't count. + */ + private findHaltStep(): number { + this.machine.loadState(this.frameStart); + let n = 0; + try { + this.machine.advanceFrame(() => { n++; return false; }); + } catch (e) { + if (e instanceof EmuHalt) return n > 0 ? n - 1 : 0; + throw e; + } + throw new Error(`halt at frame ${this.frame} did not happen again on replay; the machine is not deterministic`); + } + + /** + * Put the machine just before the step that halted, so the PC shows the + * halting instruction instead of the mess the halt left behind. + */ + private parkAtHalt(haltStep: number, halt: EmuHalt): EmuHalt { + this.machine.loadState(this.frameStart); + this.step = 0; + // stops before the halting step, so it can't halt again + if (haltStep > 0) this.runFrame(haltStep, null); + return halt; } private endFrame(steps: number): void { diff --git a/test/unit/testhistory.ts b/test/unit/testhistory.ts index 3ce5b608..402271de 100644 --- a/test/unit/testhistory.ts +++ b/test/unit/testhistory.ts @@ -1,6 +1,9 @@ import assert from "assert"; import { describe, it } from "mocha"; import { TrapCondition } from "../../src/common/devices"; +import { EmuHalt } from "../../src/common/emu"; +import { stateSize } from "../../src/common/statehash"; +import { FramePlatformCore } from "../../src/common/platformcore"; import { FrameInputSource, History } from "../../src/common/history"; import { formatTimestamp, @@ -175,6 +178,123 @@ describe('History', function () { assert.throws(() => hist.seek(timestamp(0, 0)), /older than the recording/); }); + it('truncates the future mid-frame and records a new one', function () { + const truncated: number[] = []; + const input: FrameInputSource = { + capture() { }, replay() { }, truncate(f) { truncated.push(f); }, + }; + const { m, hist } = newHistory({ checkpointInterval: 5, input }); + record(hist, 20); + hist.seek(timestamp(8, 5)); + hist.truncate(); + assert.ok(timestampsEqual(hist.last(), timestamp(8, 5)), formatTimestamp(hist.last())); + assert.strictEqual(hist.isInPast(), false); + assert.deepStrictEqual(hist.getCheckpoints().map(c => c.at.frame), [0, 5]); + // frame 8 already took its input, so only later frames are dropped + assert.deepStrictEqual(truncated, [9]); + // recording finishes frame 8, then carries on + hist.recordFrame(); + assert.ok(timestampsEqual(hist.last(), timestamp(9, 0)), formatTimestamp(hist.last())); + assert.strictEqual(m.acc, 90); + }); + + it('truncates at a frame boundary, and the new future replays', function () { + const m = new FakeMachine(); + const log: { [frame: number]: number } = {}; + var live = 0; + // input comes from `live`, as a player's would + const input: FrameInputSource = { + capture(frame) { log[frame] = m.inp = live; }, + replay(frame) { m.inp = log[frame]; }, + truncate(f) { for (const k of Object.keys(log)) if (+k >= f) delete log[+k]; }, + }; + const hist = new History(new MachineCore(m), { checkpointInterval: 5, input }); + record(hist, 20); + hist.seek(timestamp(12, 0)); + hist.truncate(); + assert.strictEqual(log[12], undefined, 'frame 12 must take new input'); + live = 1; + record(hist, 8); + assert.ok(timestampsEqual(hist.last(), timestamp(20, 0))); + const present = m.acc; + assert.strictEqual(present, 120 + 8 * 20); + // the new branch reproduces + hist.seek(timestamp(3, 0)); + hist.seek(hist.last()); + assert.strictEqual(m.acc, present); + }); + + it('keeps checkpoints within a byte budget', function () { + // same machine, but with 1000 bytes of RAM in its state + class BigMachine extends FakeMachine { + ram = new Uint8Array(1000); + saveState() { return { ...super.saveState(), ram: this.ram.slice() }; } + loadState(s) { super.loadState(s); this.ram.set(s.ram); } + } + const one = stateSize(new BigMachine().saveState()); + assert.ok(one >= 1000 && one < 1100, `size ${one}`); + const hist = new History(new MachineCore(new BigMachine()), { checkpointInterval: 2, maxBytes: one * 3 + 10 }); + record(hist, 20); + assert.strictEqual(hist.getCheckpoints().length, 3); + assert.ok(hist.getCheckpointBytes() <= one * 3 + 10); + // taken as each frame starts, so the last is frame 18 + assert.deepStrictEqual(hist.getCheckpoints().map(c => c.at.frame), [14, 16, 18]); + }); + + it('keeps one checkpoint even if it is over budget', function () { + const { hist } = newHistory({ checkpointInterval: 2, maxBytes: 1 }); + record(hist, 10); + assert.strictEqual(hist.getCheckpoints().length, 1); + assert.ok(timestampsEqual(hist.first(), timestamp(8, 0)), formatTimestamp(hist.first())); + }); + + it('records up to a halt, stops there, and rewinds from it', function () { + class HaltingMachine extends FakeMachine { + advanceFrame(trap: TrapCondition): number { + var n = 0; + for (var i = 0; i < this.stepsPerFrame; i++) { + if (trap && trap()) break; + if (this.acc === 123) throw new EmuHalt('KIL'); + this.acc = (this.acc + 1) | 0; + n++; + } + return n; + } + } + const m = new HaltingMachine(); + const hist = new History(new MachineCore(m), { checkpointInterval: 5 }); + var r; + for (var i = 0; i < 20 && !r?.halt; i++) r = hist.recordFrame(); + assert.ok(r.halt instanceof EmuHalt, 'expected a halt'); + assert.ok(timestampsEqual(hist.last(), timestamp(12, 3)), formatTimestamp(hist.last())); + assert.strictEqual(m.acc, 123); + // recording again halts at the same place + r = hist.recordFrame(); + assert.ok(r.halt instanceof EmuHalt); + assert.ok(timestampsEqual(hist.last(), timestamp(12, 3))); + // the past before the halt is all there + assert.ok(timestampsEqual(hist.previousStep(), timestamp(12, 2))); + assert.strictEqual(m.acc, 122); + const hit = hist.findLast(() => m.acc === 77); + assert.ok(timestampsEqual(hit, timestamp(7, 7)), formatTimestamp(hit)); + // and seeking back to the present doesn't halt + assert.strictEqual(hist.seek(hist.last()).halt, undefined); + assert.strictEqual(m.acc, 123); + }); + + it('reports a halt at the frame boundary on a frame-only core', function () { + var frames = 0; + const platform: any = { + advance() { if (++frames === 4) throw new EmuHalt('CPU STOPPED'); }, + saveState() { return { frames }; }, + loadState(s) { frames = s.frames; }, + }; + const core = new FramePlatformCore(platform); + const r = core.runUntil(timestamp(10, 0)); + assert.ok(r.halt instanceof EmuHalt, 'expected a halt'); + assert.ok(timestampsEqual(r.at, timestamp(3, 0)), formatTimestamp(r.at)); + }); + it('refuses to record from a replayed past', function () { const { hist } = newHistory(); record(hist, 10); diff --git a/test/unit/testtimeline.ts b/test/unit/testtimeline.ts index e1398d9c..4be68fea 100644 --- a/test/unit/testtimeline.ts +++ b/test/unit/testtimeline.ts @@ -1,6 +1,7 @@ import assert from "assert"; import { describe, it } from "mocha"; import { TrapCondition } from "../../src/common/devices"; +import { EmuHalt } from "../../src/common/emu"; import { compareTimestamps, CoreMachine, @@ -55,6 +56,15 @@ describe('Timestamp', function () { describe('MachineCore', function () { + it('finishes a frame it stopped in before running whole frames', function () { + const m = new FakeMachine(); + const c = new MachineCore(m); + c.runUntil(timestamp(2, 5)); + // no trap and a frame-boundary target: must not restart frame 2 midway + c.runUntil(timestamp(4, 0)); + assert.strictEqual(m.acc, 40); + }); + it('ignores an error from the frame tail after a stop, but not otherwise', function () { // like galaxian: code after the CPU loop mutates state and may throw class TailMachine extends FakeMachine { @@ -204,3 +214,74 @@ describe('MachineCore', function () { assert.ok(timestampsEqual(r.at, timestamp(2, 4))); }); }); + +// A machine whose CPU executes a halt instruction (6502 KIL) when acc reaches +// haltAt: the step throws EmuHalt partway through, as MOS6502 does. +class HaltingMachine extends FakeMachine { + haltAt = 23; + error: () => Error = () => new EmuHalt('CPU executed halt instruction'); + advanceFrame(trap: TrapCondition): number { + var n = 0; + for (var i = 0; i < this.stepsPerFrame; i++) { + if (trap && trap()) break; + if (this.acc === this.haltAt) throw this.error(); + this.acc = (this.acc + 1) | 0; + n++; + } + return n; + } +} + +describe('MachineCore halts', function () { + + it('reports a halt as a stop before the halting step, running unmetered', function () { + const m = new HaltingMachine(); + const c = new MachineCore(m); + const r = c.runUntil(timestamp(5, 0)); + assert.ok(r.halt instanceof EmuHalt, 'expected a halt'); + assert.strictEqual(r.trapped, false); + // frame 2 starts at acc 20; step 3 is the one that halts + assert.ok(timestampsEqual(r.at, timestamp(2, 3)), formatTimestamp(r.at)); + assert.ok(timestampsEqual(c.now(), timestamp(2, 3))); + assert.strictEqual(m.acc, 23); + }); + + it('reports the same halt when metered', function () { + const m = new HaltingMachine(); + const c = new MachineCore(m); + const r = c.runUntil(timestamp(5, 0), () => false); + assert.ok(r.halt instanceof EmuHalt); + assert.ok(timestampsEqual(r.at, timestamp(2, 3)), formatTimestamp(r.at)); + assert.strictEqual(m.acc, 23); + }); + + it('halts again, without progress, when run on from a halt', function () { + const m = new HaltingMachine(); + const c = new MachineCore(m); + c.runUntil(timestamp(5, 0)); + const r = c.runUntil(timestamp(5, 0)); + assert.ok(r.halt instanceof EmuHalt); + assert.ok(timestampsEqual(r.at, timestamp(2, 3)), formatTimestamp(r.at)); + assert.strictEqual(m.acc, 23); + }); + + it('can reach every step up to the halt without halting', function () { + const m = new HaltingMachine(); + const c = new MachineCore(m); + const snap = c.snapshot(); + const r = c.runUntil(timestamp(2, 3)); + assert.strictEqual(r.halt, undefined); + assert.strictEqual(m.acc, 23); + c.restore(snap, timestamp(0, 0)); + assert.strictEqual(c.runUntil(timestamp(2, 2)).halt, undefined); + assert.strictEqual(m.acc, 22); + }); + + it('lets errors that are not halts propagate', function () { + const m = new HaltingMachine(); + m.error = () => new TypeError('emulator bug'); + const c = new MachineCore(m); + assert.throws(() => c.runUntil(timestamp(5, 0)), TypeError); + }); +}); + diff --git a/test/unit/testtimelinemachine.ts b/test/unit/testtimelinemachine.ts index c0b15589..a81e2fa8 100644 --- a/test/unit/testtimelinemachine.ts +++ b/test/unit/testtimelinemachine.ts @@ -10,6 +10,8 @@ mockAudio(); // classes that live on globalThis import { Midway8080 } from "../../src/machine/mw8080bw"; import { GalaxianScrambleMachine } from "../../src/machine/galaxian"; +import { Devel6502 } from "../../src/machine/devel"; +import { EmuHalt } from "../../src/common/emu"; import { hashState as hashAnyState } from "../../src/common/statehash"; import { NullProbe } from "../../src/common/devices"; import { History } from "../../src/common/history"; @@ -242,3 +244,61 @@ describe('MachineCore break state', function () { assert.strictEqual(hashAnyState(m.saveState()), atStep); }); }); + +// A 6502 that counts $11 up to 100 (about 12 frames at 1 MHz), then executes +// KIL, which MOS6502 throws as EmuHalt from the middle of the instruction. +describe('MachineCore on a 6502 that halts', function () { + + const KIL_ADDR = 0x800c; + + function newDevel() { + const rom = new Uint8Array(0x8000); + rom.set([ + 0xe6, 0x10, // 8000 loop: inc $10 + 0xd0, 0xfc, // 8002 bne loop + 0xe6, 0x11, // 8004 inc $11 + 0xa5, 0x11, // 8006 lda $11 + 0xc9, 100, // 8008 cmp #100 + 0xd0, 0xf4, // 800a bne loop + 0x02, // 800c kil + ]); + rom[0x7ffc] = 0x00; rom[0x7ffd] = 0x80; + const m = new Devel6502(); + m.loadROM(rom); + m.reset(); + return { m, core: new MachineCore(m) }; + } + + it('parks on the KIL instruction, whether metered or not', function () { + const a = newDevel(); + const ra = a.core.runUntil(timestamp(100, 0)); + assert.ok(ra.halt instanceof EmuHalt, 'expected a halt'); + assert.ok(ra.at.frame > 5, formatTimestamp(ra.at)); + assert.strictEqual(a.m.cpu.getPC(), KIL_ADDR); + assert.strictEqual(a.m.ram[0x11], 100); + + const b = newDevel(); + const rb = b.core.runUntil(timestamp(100, 0), () => false); + assert.ok(rb.halt instanceof EmuHalt); + assert.ok(timestampsEqual(ra.at, rb.at), `${formatTimestamp(ra.at)} != ${formatTimestamp(rb.at)}`); + assert.strictEqual(hashAnyState(a.m.saveState()), hashAnyState(b.m.saveState())); + }); + + it('records to the halt and rewinds to the step before it', function () { + const { m, core } = newDevel(); + const hist = new History(core, { checkpointInterval: 4 }); + var r; + for (var i = 0; i < 100 && !r?.halt; i++) r = hist.recordFrame(); + assert.ok(r.halt instanceof EmuHalt, 'expected a halt'); + const haltAt = hist.last(); + assert.strictEqual(m.cpu.getPC(), KIL_ADDR); + // a 6502 steps by clocks, so the previous instruction is the last stable + // step before now: the bne that fell through to the KIL + const prev = hist.findLast(() => m.cpu.isStable(), hist.first(), hist.previousStep()); + assert.ok(prev, 'no previous instruction'); + assert.strictEqual(m.cpu.getPC(), 0x800a); + // and forward to the present again, without halting + assert.strictEqual(hist.seek(haltAt).halt, undefined); + assert.strictEqual(m.cpu.getPC(), KIL_ADDR); + }); +});