timeline: EmuHalt as RunResult.halt, History.truncate(), byte budget for checkpoints

This commit is contained in:
Steven Hugg committed 2026-09-28 18:57:04 +02:00
1 parent b2d2ff2159
commit 268e5457d7
8 files changed
+441 -30

No files matched your search

+19
View File
@@ -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.
+47 -7
View File
@@ -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<S = any> {
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<S = any> {
readonly core: DeterministicCore<S>;
checkpointInterval: number;
maxCheckpoints: number;
maxBytes: number;
private input?: FrameInputSource;
private checkpointBytes = 0;
private checkpoints: Checkpoint<S>[] = [];
/** Latest point ever reached, i.e. the present. */
private head: Timestamp = timestamp(0, 0);
@@ -99,13 +108,17 @@ export class History<S = any> {
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<S = any> {
/** 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<Checkpoint<S>> { 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<S = any> {
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<S> | null {
for (var i = this.checkpoints.length - 1; i >= 0; i--) {
@@ -194,7 +234,7 @@ export class History<S = any> {
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;
}
+15 -5
View File
@@ -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);
+32
View File
@@ -67,3 +67,35 @@ function hashValue(h: number, v: any, path: Set<object>): 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<object>();
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);
}
+67 -18
View File
@@ -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<T extends CoreMachine> 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<T extends CoreMachine> 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 {
+120
View File
@@ -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);
+81
View File
@@ -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);
});
});
+60
View File
@@ -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);
});
});