mirror of
https://github.com/sehugg/8bitworkshop.git
synced 2026-10-24 08:02:27 +00:00
timeline: EmuHalt as RunResult.halt, History.truncate(), byte budget for checkpoints
This commit is contained in:
1 parent
b2d2ff2159
commit
268e5457d7
8 files changed
+441
-30
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user