mirror of
https://github.com/sehugg/8bitworkshop.git
synced 2026-10-25 00:02:16 +00:00
Merge branch 'debugger': deterministic timeline, history, verify-replay cmd
This commit is contained in:
commit
e252c780f0
9 files changed
+1610
No files matched your search
@@ -0,0 +1,302 @@
|
||||
// Recorded history over a DeterministicCore: periodic checkpoints plus the
|
||||
// per-frame input needed to reproduce everything between them.
|
||||
//
|
||||
// The point of recording is not to store the past but to be able to *re-run*
|
||||
// it. Nothing is kept between checkpoints, so a breakpoint condition can be
|
||||
// asked about a moment that has already gone by: restore the checkpoint before
|
||||
// it and replay with the condition installed as a trap. That is what findLast()
|
||||
// does, and it is why reverse-step and "when did this memory last change?" are
|
||||
// the same operation with different predicates.
|
||||
|
||||
import { ProbeAll, TrapCondition } from "./devices";
|
||||
import {
|
||||
compareTimestamps,
|
||||
DeterministicCore,
|
||||
formatTimestamp,
|
||||
RunResult,
|
||||
Timestamp,
|
||||
timestamp,
|
||||
} from "./timeline";
|
||||
|
||||
/**
|
||||
* Everything that has to be re-applied at a frame boundary for a replay to
|
||||
* reproduce the original run: controls, and the seed of the one PRNG a machine
|
||||
* is allowed to touch. Implemented by the platform, which knows what its input
|
||||
* looks like; the history layer only knows *when* to call it.
|
||||
*/
|
||||
export interface FrameInputSource {
|
||||
/** Record whatever input applies to this frame, as it is about to run. */
|
||||
capture(frame: number): void;
|
||||
/** Re-apply the input recorded for this frame before re-running it. */
|
||||
replay(frame: number): void;
|
||||
/** Drop everything recorded for frames before this one. */
|
||||
trim?(firstFrame: number): void;
|
||||
}
|
||||
|
||||
export interface Checkpoint<S = any> {
|
||||
at: Timestamp;
|
||||
state: S;
|
||||
}
|
||||
|
||||
/**
|
||||
* A condition to search for. A plain predicate is enough for anything that only
|
||||
* looks at current state (a PC, a register). A condition built on the probe
|
||||
* needs two more things:
|
||||
*
|
||||
* - `probe` is attached only for the duration of the search and detached after,
|
||||
* because probing costs real time (the probe wrapper roughly doubles the cost
|
||||
* of a frame) and recording must not pay for a question nobody has asked yet.
|
||||
* Determinism is what makes this safe: the probe sees the replay, and the
|
||||
* replay is the same execution as the original.
|
||||
* - `reset` drops what the probe latched, and is called after every reposition,
|
||||
* since a search seeks around and a latch set during a seek is not a hit.
|
||||
*/
|
||||
export interface ProbeCondition {
|
||||
test: TrapCondition;
|
||||
reset?: () => void;
|
||||
probe?: ProbeAll;
|
||||
}
|
||||
|
||||
export type SearchCondition = TrapCondition | ProbeCondition;
|
||||
|
||||
function conditionTest(c: SearchCondition): TrapCondition {
|
||||
return typeof c === 'function' ? c : c.test;
|
||||
}
|
||||
|
||||
function conditionReset(c: SearchCondition): void {
|
||||
if (typeof c !== 'function') c.reset?.();
|
||||
}
|
||||
|
||||
export interface HistoryOptions {
|
||||
/** Frames between checkpoints. Larger = less memory, slower seeks. */
|
||||
checkpointInterval?: number;
|
||||
/** Oldest checkpoints are dropped past this. */
|
||||
maxCheckpoints?: number;
|
||||
input?: FrameInputSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* A window of recorded emulated time, and the searches you can run over it.
|
||||
*
|
||||
* Checkpoints are only ever taken at frame boundaries, which is what lets any
|
||||
* sub-frame position be reached exactly: restore the frame's start and replay
|
||||
* into it (see MachineCore).
|
||||
*/
|
||||
export class History<S = any> {
|
||||
|
||||
readonly core: DeterministicCore<S>;
|
||||
checkpointInterval: number;
|
||||
maxCheckpoints: number;
|
||||
private input?: FrameInputSource;
|
||||
private checkpoints: Checkpoint<S>[] = [];
|
||||
/** Latest point ever reached, i.e. the present. */
|
||||
private head: Timestamp = timestamp(0, 0);
|
||||
|
||||
/** Called when the recorded range changes, for the UI's timeline. */
|
||||
onChange: () => void = null;
|
||||
|
||||
constructor(core: DeterministicCore<S>, opts: HistoryOptions = {}) {
|
||||
this.core = core;
|
||||
this.checkpointInterval = opts.checkpointInterval ?? 10;
|
||||
this.maxCheckpoints = opts.maxCheckpoints ?? 300;
|
||||
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.head = this.core.now();
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
/** Round a target to what the core can actually stop at. */
|
||||
clamp(t: Timestamp): Timestamp {
|
||||
return this.core.clamp ? this.core.clamp(t) : t;
|
||||
}
|
||||
|
||||
/** Oldest point still reachable. Earlier checkpoints have been dropped. */
|
||||
first(): Timestamp { return this.checkpoints[0].at; }
|
||||
/** The present: the furthest point recording has reached. */
|
||||
last(): Timestamp { return this.head; }
|
||||
now(): Timestamp { return this.core.now(); }
|
||||
/** 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; }
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
recordFrame(trap?: TrapCondition | null): RunResult {
|
||||
const t = this.core.now();
|
||||
if (compareTimestamps(t, this.head) !== 0) {
|
||||
throw new Error(`not at the head of the recording: ${formatTimestamp(t)} != ${formatTimestamp(this.head)}`);
|
||||
}
|
||||
if (t.step === 0) {
|
||||
this.input?.capture(t.frame);
|
||||
this.checkpointIfDue(t);
|
||||
}
|
||||
const r = this.core.runUntil(timestamp(t.frame + 1, 0), trap);
|
||||
this.head = r.at;
|
||||
this.onChange?.();
|
||||
return r;
|
||||
}
|
||||
|
||||
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.input?.trim?.(this.checkpoints[0].at.frame);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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--) {
|
||||
if (compareTimestamps(this.checkpoints[i].at, t) <= 0) return this.checkpoints[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the core at t, replaying from the nearest checkpoint. Seeking backwards
|
||||
* costs the same as seeking forwards -- that's the whole trick.
|
||||
*/
|
||||
seek(t: Timestamp, trap?: TrapCondition | null): RunResult {
|
||||
t = this.clamp(t);
|
||||
if (compareTimestamps(t, this.head) > 0) t = this.head;
|
||||
const cp = this.checkpointAtOrBefore(t);
|
||||
if (!cp) {
|
||||
throw new Error(`${formatTimestamp(t)} is older than the recording`);
|
||||
}
|
||||
// only replay from the checkpoint if we can't just run forward to it
|
||||
const cur = this.core.now();
|
||||
if (compareTimestamps(cur, t) > 0 || compareTimestamps(cur, cp.at) < 0) {
|
||||
this.core.restore(cp.state, cp.at);
|
||||
}
|
||||
return this.runTo(t, trap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run forward to t from the core's current position, re-applying recorded
|
||||
* input at each frame boundary. Frames are driven one at a time so the input
|
||||
* hook lands in the right place; the core handles everything within a frame.
|
||||
*/
|
||||
private runTo(t: Timestamp, trap?: TrapCondition | null): RunResult {
|
||||
t = this.clamp(t);
|
||||
while (compareTimestamps(this.core.now(), t) < 0) {
|
||||
const at = this.core.now();
|
||||
if (at.step === 0) this.input?.replay(at.frame);
|
||||
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;
|
||||
// a core that reports no progress can't reach t; stop rather than spin
|
||||
if (compareTimestamps(r.at, at) <= 0) break;
|
||||
}
|
||||
return { at: this.core.now(), trapped: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* First moment in [from, to) where pred holds, or null. Leaves the core at
|
||||
* the hit, or at `to` if there wasn't one.
|
||||
*/
|
||||
findNext(cond: SearchCondition, from?: Timestamp, to?: Timestamp): Timestamp | null {
|
||||
from = this.clamp(from ?? this.core.now());
|
||||
to = this.clamp(to ?? this.head);
|
||||
if (compareTimestamps(from, to) >= 0) return null;
|
||||
return this.withProbe(cond, () => {
|
||||
this.seek(from);
|
||||
conditionReset(cond);
|
||||
const r = this.runTo(to, conditionTest(cond));
|
||||
return r.trapped ? r.at : null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the condition's probe for the length of the search only. The core
|
||||
* runs unprobed the rest of the time, which is most of the time.
|
||||
*/
|
||||
private withProbe<R>(cond: SearchCondition, body: () => R): R {
|
||||
const probe = typeof cond === 'function' ? null : cond.probe;
|
||||
if (!probe || !this.core.connectProbe) return body();
|
||||
this.core.connectProbe(probe);
|
||||
try {
|
||||
return body();
|
||||
} finally {
|
||||
this.core.connectProbe(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Last moment in [from, to) where pred holds, or null -- the reverse search
|
||||
* that "break on a condition that already happened" is built from.
|
||||
*
|
||||
* Walks back a checkpoint at a time and replays each span forward collecting
|
||||
* hits, so the cost is bounded by the checkpoint interval rather than by how
|
||||
* far back the answer is. Leaves the core parked on the hit.
|
||||
*/
|
||||
findLast(cond: SearchCondition, from?: Timestamp, to?: Timestamp): Timestamp | null {
|
||||
from = this.clamp(from ?? this.first());
|
||||
to = this.clamp(to ?? this.core.now());
|
||||
if (compareTimestamps(from, to) >= 0) return null;
|
||||
return this.withProbe(cond, () => this.findLastUnprobed(cond, from, to));
|
||||
}
|
||||
|
||||
private findLastUnprobed(cond: SearchCondition, from: Timestamp, to: Timestamp): Timestamp | null {
|
||||
for (var i = this.checkpoints.length - 1; i >= 0; i--) {
|
||||
const cp = this.checkpoints[i];
|
||||
if (compareTimestamps(cp.at, to) >= 0) continue; // span is entirely ahead of us
|
||||
// search [max(cp, from), min(next checkpoint, to))
|
||||
const spanStart = compareTimestamps(cp.at, from) < 0 ? from : cp.at;
|
||||
const hit = this.lastHitIn(spanStart, to, cond);
|
||||
if (hit) {
|
||||
this.seek(hit);
|
||||
return hit;
|
||||
}
|
||||
to = cp.at; // nothing here; look further back
|
||||
if (compareTimestamps(cp.at, from) <= 0) break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Replay [from, to) collecting every hit, and report the last one. */
|
||||
private lastHitIn(from: Timestamp, to: Timestamp, cond: SearchCondition): Timestamp | null {
|
||||
this.seek(from);
|
||||
conditionReset(cond);
|
||||
const test = conditionTest(cond);
|
||||
var last: Timestamp = null;
|
||||
this.runTo(to, () => {
|
||||
if (test()) last = this.core.now();
|
||||
return false; // keep going; we want the last hit, not the first
|
||||
});
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
* The moment one step before t: reverse single-step. Exact, because it is
|
||||
* reached by replaying rather than by guessing a cycle count backwards.
|
||||
*/
|
||||
previousStep(t?: Timestamp): Timestamp | null {
|
||||
t = t ?? this.core.now();
|
||||
if (compareTimestamps(t, this.first()) <= 0) return null;
|
||||
if (t.step > 0) {
|
||||
const prev = timestamp(t.frame, t.step - 1);
|
||||
this.seek(prev);
|
||||
return prev;
|
||||
}
|
||||
// step 0 of a frame: the previous step is the last step of the frame before
|
||||
if (t.frame - 1 < this.first().frame) return null;
|
||||
var steps = 0;
|
||||
this.seek(timestamp(t.frame - 1, 0));
|
||||
this.runTo(timestamp(t.frame, 0), () => { steps++; return false; });
|
||||
if (steps === 0) return null;
|
||||
const prev = timestamp(t.frame - 1, steps - 1);
|
||||
this.seek(prev);
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Bridges the existing Platform objects to the deterministic timeline.
|
||||
//
|
||||
// Most platforms are built on a Machine and get MachineCore, which can stop
|
||||
// between instructions. The ones that aren't -- vcs (Javatari), verilog, x86 --
|
||||
// only know how to run a whole frame, so they get FramePlatformCore, which says
|
||||
// so through its granularity instead of pretending to sub-frame accuracy.
|
||||
|
||||
import { Platform } from "./baseplatform";
|
||||
import { getNoiseSeed, setNoiseSeed } from "./emu";
|
||||
import { FrameInputSource } from "./history";
|
||||
import {
|
||||
DeterministicCore,
|
||||
formatTimestamp,
|
||||
Granularity,
|
||||
MachineCore,
|
||||
RunResult,
|
||||
Timestamp,
|
||||
timestamp,
|
||||
} from "./timeline";
|
||||
import { TrapCondition } from "./devices";
|
||||
|
||||
export interface RecordedKey {
|
||||
key: number;
|
||||
code: number;
|
||||
flags: number;
|
||||
}
|
||||
|
||||
export interface PlatformInputOptions {
|
||||
/** Which frame an incoming key event belongs to. */
|
||||
currentFrame?: () => number;
|
||||
/** How to re-deliver a recorded key event during a replay. */
|
||||
dispatchKey?: (key: number, code: number, flags: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures and replays what varies between otherwise identical frames: the
|
||||
* controls, the seed of the shared PRNG, and the key events themselves.
|
||||
*
|
||||
* Snapshotting the controls is not enough on its own. Some platforms *act* on a
|
||||
* key event rather than only latching it -- atari8 raises a POKEY interrupt on
|
||||
* key-down (atari8.ts, getKeyboardFunction) -- and that effect lands in POKEY's
|
||||
* registers, which saveControlsState() does not cover. A replay that only
|
||||
* restored controls would silently lose the interrupt.
|
||||
*
|
||||
* So when a dispatcher is supplied, the recorded events are the source of truth
|
||||
* and the controls snapshot is not restored at all. The two must not be
|
||||
* combined: a checkpoint already contains the effect of every earlier event, so
|
||||
* redelivering on top of a restore applies them twice -- on c64 the key goes
|
||||
* into a queue inside the WASM state, and the doubled press diverges
|
||||
* immediately. Replaying just the events that came after the checkpoint is both
|
||||
* necessary and sufficient. Without a dispatcher there is nothing to replay, so
|
||||
* the controls snapshot is used instead.
|
||||
*
|
||||
* Frames are a fine granularity for this because the browser cannot deliver a
|
||||
* key event in the middle of advanceFrame() -- JS is single-threaded, so events
|
||||
* queue until the frame returns.
|
||||
*/
|
||||
export class PlatformFrameInput implements FrameInputSource {
|
||||
private frames = new Map<number, { controls: any, seed: number }>();
|
||||
private keys = new Map<number, RecordedKey[]>();
|
||||
private oldest = 0;
|
||||
|
||||
constructor(readonly platform: Platform, readonly opts: PlatformInputOptions = {}) { }
|
||||
|
||||
/** Note a key event as it is delivered, so the replay can redeliver it. */
|
||||
recordKey(key: number, code: number, flags: number): void {
|
||||
const frame = this.opts.currentFrame ? this.opts.currentFrame() : 0;
|
||||
var evs = this.keys.get(frame);
|
||||
if (!evs) this.keys.set(frame, evs = []);
|
||||
evs.push({ key, code, flags });
|
||||
}
|
||||
|
||||
capture(frame: number): void {
|
||||
this.frames.set(frame, {
|
||||
controls: this.platform.saveControlsState ? this.platform.saveControlsState() : undefined,
|
||||
seed: getNoiseSeed(),
|
||||
});
|
||||
}
|
||||
|
||||
replay(frame: number): void {
|
||||
const rec = this.frames.get(frame);
|
||||
if (rec) {
|
||||
if (!this.opts.dispatchKey && rec.controls !== undefined && this.platform.loadControlsState) {
|
||||
this.platform.loadControlsState(rec.controls);
|
||||
}
|
||||
setNoiseSeed(rec.seed);
|
||||
}
|
||||
const evs = this.opts.dispatchKey && this.keys.get(frame);
|
||||
if (evs) {
|
||||
for (const e of evs) this.opts.dispatchKey(e.key, e.code, e.flags);
|
||||
}
|
||||
}
|
||||
|
||||
trim(firstFrame: number): void {
|
||||
while (this.oldest < firstFrame) {
|
||||
this.frames.delete(this.oldest);
|
||||
this.keys.delete(this.oldest);
|
||||
this.oldest++;
|
||||
}
|
||||
}
|
||||
|
||||
get size(): number { return this.frames.size; }
|
||||
get keyEventCount(): number {
|
||||
var n = 0;
|
||||
this.keys.forEach((v) => n += v.length);
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A core for platforms with no Machine behind them. Frames are the only thing
|
||||
* it can count, so every target is rounded down to a frame boundary and
|
||||
* clamp() tells the history layer as much -- without that, a caller asking for
|
||||
* a sub-frame position would spin waiting for progress that can never happen.
|
||||
*/
|
||||
export class FramePlatformCore implements DeterministicCore {
|
||||
|
||||
readonly granularity: Granularity = 'frame';
|
||||
private frame: number = 0;
|
||||
|
||||
constructor(readonly platform: Platform) { }
|
||||
|
||||
now(): Timestamp { return timestamp(this.frame, 0); }
|
||||
|
||||
clamp(t: Timestamp): Timestamp {
|
||||
return t.step === 0 ? t : timestamp(t.frame, 0);
|
||||
}
|
||||
|
||||
snapshot() { return this.platform.saveState(); }
|
||||
|
||||
restore(state: any, at: Timestamp): void {
|
||||
if (at.step !== 0) {
|
||||
throw new Error(`can only restore at a frame boundary, not ${formatTimestamp(at)}`);
|
||||
}
|
||||
this.platform.loadState(state);
|
||||
this.frame = at.frame;
|
||||
}
|
||||
|
||||
runUntil(target: Timestamp, trap?: TrapCondition | null): RunResult {
|
||||
while (this.frame < target.frame) {
|
||||
if (trap && trap()) return { at: this.now(), trapped: true };
|
||||
this.advanceFrame();
|
||||
this.frame++;
|
||||
}
|
||||
return { at: this.now(), trapped: false };
|
||||
}
|
||||
|
||||
private advanceFrame(): void {
|
||||
const p = this.platform as any;
|
||||
// advance() is the bare frame; nextFrame() would also poll controls and
|
||||
// drive the old recorder, which would fight with this one
|
||||
if (p.advance) p.advance(true);
|
||||
else if (p.nextFrame) p.nextFrame(true);
|
||||
else throw new Error('platform cannot advance a frame');
|
||||
}
|
||||
}
|
||||
|
||||
/** True if this platform can be rewound at all. */
|
||||
export function isRewindable(platform: Platform): boolean {
|
||||
return !!(platform && platform.saveState && platform.loadState &&
|
||||
((platform as any).machine || (platform as any).advance));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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';
|
||||
return new MachineCore(machine, granularity);
|
||||
}
|
||||
return new FramePlatformCore(platform);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// A stable hash over an emulator state, for detecting divergence between a
|
||||
// live run and a replay of it. Save states are a grab bag of plain objects,
|
||||
// typed arrays, numbers and (on vcs) base64 strings, so the walk has to cope
|
||||
// with all of them and, above all, be order-independent for object keys --
|
||||
// two states that differ only in key insertion order are the same state.
|
||||
|
||||
function fnv(h: number, byte: number): number {
|
||||
return Math.imul(h ^ (byte & 0xff), 0x01000193) >>> 0;
|
||||
}
|
||||
|
||||
const scratch = new DataView(new ArrayBuffer(8));
|
||||
|
||||
function hashNumber(h: number, v: number): number {
|
||||
scratch.setFloat64(0, v);
|
||||
for (var i = 0; i < 8; i++) h = fnv(h, scratch.getUint8(i));
|
||||
return h;
|
||||
}
|
||||
|
||||
function hashString(h: number, s: string): number {
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
h = fnv(h, s.charCodeAt(i));
|
||||
h = fnv(h, s.charCodeAt(i) >> 8);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function hashValue(h: number, v: any, path: Set<object>): number {
|
||||
if (v == null) return fnv(h, v === null ? 1 : 2);
|
||||
switch (typeof v) {
|
||||
case 'number': return hashNumber(fnv(h, 3), v);
|
||||
case 'boolean': return fnv(fnv(h, 4), v ? 1 : 0);
|
||||
case 'string': return hashString(fnv(h, 5), v);
|
||||
case 'function': return fnv(h, 6); // not state
|
||||
}
|
||||
if (ArrayBuffer.isView(v)) {
|
||||
const bytes = new Uint8Array((v as any).buffer, (v as any).byteOffset, (v as any).byteLength);
|
||||
h = fnv(h, 7);
|
||||
h = hashNumber(h, bytes.length);
|
||||
for (var i = 0; i < bytes.length; i++) h = fnv(h, bytes[i]);
|
||||
return h;
|
||||
}
|
||||
// Emulator states are graphs, not trees -- jsnes's mapper points back at the
|
||||
// machine. Only cycles are collapsed, by tracking the current path; a shared
|
||||
// subtree reachable twice is hashed twice. Recording object *identity*
|
||||
// instead would make two states with identical contents hash differently
|
||||
// just because loadState() rebuilt an array that used to be shared, which is
|
||||
// divergence that isn't there. A depth cutoff is worse still: where it bites
|
||||
// depends on where the walk started.
|
||||
if (path.has(v)) return fnv(h, 10);
|
||||
path.add(v);
|
||||
if (Array.isArray(v)) {
|
||||
h = fnv(h, 8);
|
||||
h = hashNumber(h, v.length);
|
||||
for (var i = 0; i < v.length; i++) h = hashValue(h, v[i], path);
|
||||
} else {
|
||||
h = fnv(h, 9);
|
||||
for (const k of Object.keys(v).sort()) {
|
||||
h = hashString(h, k);
|
||||
h = hashValue(h, v[k], path);
|
||||
}
|
||||
}
|
||||
path.delete(v);
|
||||
return h;
|
||||
}
|
||||
|
||||
/** FNV-1a over the whole state graph. Equal hashes mean equal states. */
|
||||
export function hashState(state: any): number {
|
||||
return hashValue(0x811c9dc5, state, new Set());
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// Deterministic timeline: a monotonic time coordinate for the debugger, and
|
||||
// the narrow core interface that rewinding needs.
|
||||
//
|
||||
// The old debugger keyed everything on frames plus a per-frame clock that was
|
||||
// rewound on every frame boundary (BaseDebugPlatform.preFrame), which is why a
|
||||
// breakpoint condition could not span frames. Here time is a (frame, step) pair
|
||||
// that only ever moves forward as the machine runs, so "run until T" and "when
|
||||
// was P last true?" are expressible without caring where frames begin and end.
|
||||
//
|
||||
// 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.
|
||||
|
||||
import { FrameBased, ProbeAll, SavesState, TrapCondition } from "./devices";
|
||||
|
||||
/**
|
||||
* A point in emulated time. Frames count from the start of recording and are
|
||||
* never reset; step counts trap invocations since the start of the frame.
|
||||
*/
|
||||
export interface Timestamp {
|
||||
readonly frame: number;
|
||||
readonly step: number;
|
||||
}
|
||||
|
||||
export const TIMESTAMP_ZERO: Timestamp = { frame: 0, step: 0 };
|
||||
|
||||
export function timestamp(frame: number, step: number = 0): Timestamp {
|
||||
return { frame, step };
|
||||
}
|
||||
|
||||
/** Lexicographic order: <0 if a is before b, 0 if equal, >0 if after. */
|
||||
export function compareTimestamps(a: Timestamp, b: Timestamp): number {
|
||||
return a.frame - b.frame || a.step - b.step;
|
||||
}
|
||||
|
||||
export function timestampsEqual(a: Timestamp, b: Timestamp): boolean {
|
||||
return a.frame === b.frame && a.step === b.step;
|
||||
}
|
||||
|
||||
export function formatTimestamp(t: Timestamp): string {
|
||||
return `${t.frame}:${t.step}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* How finely a core can be positioned in time. Cores that can only stop at
|
||||
* frame boundaries say so instead of silently ignoring sub-frame targets, so
|
||||
* the UI can disable sub-frame seeking rather than lie about it.
|
||||
*/
|
||||
export type Granularity = 'clock' | 'insn' | 'frame';
|
||||
|
||||
export interface RunResult {
|
||||
/** Where the core ended up. */
|
||||
at: Timestamp;
|
||||
/** True if the caller's trap stopped the run before the target. */
|
||||
trapped: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The minimum an emulator must provide to be rewound and searched. Deliberately
|
||||
* smaller than Platform or Machine: no video, audio, input or disassembly, so
|
||||
* that emulators which don't descend from Machine (Javatari, jsnes) can be
|
||||
* adapted without being rewritten.
|
||||
*/
|
||||
export interface DeterministicCore<S = any> {
|
||||
readonly granularity: Granularity;
|
||||
|
||||
/** Current position in emulated time. */
|
||||
now(): Timestamp;
|
||||
|
||||
/**
|
||||
* Capture the machine state. Only states captured at a frame boundary
|
||||
* (step 0) may be passed back to restore(); see the class comment on
|
||||
* MachineCore for why.
|
||||
*/
|
||||
snapshot(): S;
|
||||
|
||||
/** Restore a frame-boundary snapshot and declare the time it was taken. */
|
||||
restore(state: S, at: Timestamp): void;
|
||||
|
||||
/**
|
||||
* Run forward until now() reaches `target`, or until `trap` returns true.
|
||||
* `trap` is evaluated at every step from the current position onward, so a
|
||||
* run that crosses frame boundaries evaluates it continuously -- there is no
|
||||
* per-frame reset.
|
||||
*/
|
||||
runUntil(target: Timestamp, trap?: TrapCondition | null): RunResult;
|
||||
|
||||
/**
|
||||
* Round a target to something this core can actually stop at. A frame-only
|
||||
* core rounds sub-frame targets down; without this a caller could ask for a
|
||||
* position the core can never reach and wait forever for progress.
|
||||
*/
|
||||
clamp?(t: Timestamp): Timestamp;
|
||||
|
||||
/** Connect a probe, if this core supports probing. */
|
||||
connectProbe?(probe: ProbeAll): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* What MachineCore needs from a machine. Any src/machine/* Machine satisfies
|
||||
* this structurally; the interface is stated in terms of devices.ts alone so
|
||||
* this module doesn't drag in baseplatform.ts and the DOM with it.
|
||||
*/
|
||||
export interface CoreMachine extends FrameBased, SavesState<any> {
|
||||
connectProbe?(probe: ProbeAll): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts any FrameBased + SavesState machine to DeterministicCore.
|
||||
*
|
||||
* The one subtlety is that advanceFrame() is not resumable on most machines:
|
||||
* BasicScanlineMachine restarts its scanline loop from the top on every call,
|
||||
* so breaking out mid-frame and calling it again re-runs the frame rather than
|
||||
* continuing it. So a frame is the atomic replay unit here: the core keeps the
|
||||
* snapshot taken at the start of the current frame, and any sub-frame position
|
||||
* is reached by restoring that snapshot and replaying to the wanted step. That
|
||||
* is what makes stepping backwards inside a frame exact instead of approximate,
|
||||
* and it costs one snapshot per frame -- which the recorder wants anyway.
|
||||
*/
|
||||
export class MachineCore<T extends CoreMachine> implements DeterministicCore {
|
||||
|
||||
readonly machine: T;
|
||||
readonly granularity: Granularity;
|
||||
|
||||
private frame: number = 0;
|
||||
private step: number = 0;
|
||||
// state at the start of the current frame; the anchor for sub-frame seeks
|
||||
private frameStart: any = null;
|
||||
// steps in the last frame we counted, or -1 if that frame ran unmetered
|
||||
private lastFrameSteps: number = -1;
|
||||
|
||||
constructor(machine: T, granularity: Granularity = 'insn') {
|
||||
this.machine = machine;
|
||||
this.granularity = granularity;
|
||||
this.frameStart = machine.saveState();
|
||||
}
|
||||
|
||||
now(): Timestamp {
|
||||
return { frame: this.frame, step: this.step };
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return this.machine.saveState();
|
||||
}
|
||||
|
||||
restore(state: any, at: Timestamp): void {
|
||||
if (at.step !== 0) {
|
||||
// Restoring mid-frame would leave us without a frame-start anchor, so
|
||||
// any later sub-frame seek in that frame would be unreachable. Callers
|
||||
// seek to a mid-frame position by restoring the frame and running to it.
|
||||
throw new Error(`can only restore at a frame boundary, not ${formatTimestamp(at)}`);
|
||||
}
|
||||
this.machine.loadState(state);
|
||||
this.frame = at.frame;
|
||||
this.step = 0;
|
||||
this.frameStart = state;
|
||||
this.lastFrameSteps = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps counted in the most recently completed frame, or -1 if that frame
|
||||
* ran without a trap and so was never counted. The UI uses this for the
|
||||
* range of the sub-frame slider.
|
||||
*/
|
||||
getLastFrameSteps(): number {
|
||||
return this.lastFrameSteps;
|
||||
}
|
||||
|
||||
connectProbe(probe: ProbeAll): void {
|
||||
this.machine.connectProbe?.(probe);
|
||||
}
|
||||
|
||||
runUntil(target: Timestamp, trap?: TrapCondition | null): RunResult {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
return { at: this.now(), trapped: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance within the current frame to `untilStep` (Infinity = end of frame).
|
||||
* Returns true if `trap` stopped the run.
|
||||
*/
|
||||
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);
|
||||
this.endFrame(-1);
|
||||
return false;
|
||||
}
|
||||
// Re-run the frame from its start if we're already partway into it, since
|
||||
// advanceFrame() restarts rather than resumes. Steps before our current
|
||||
// position already happened, so the caller's trap must not see them again.
|
||||
const from = this.step;
|
||||
if (from > 0) {
|
||||
this.machine.loadState(this.frameStart);
|
||||
}
|
||||
let n = 0;
|
||||
let trapped = false;
|
||||
// The trap runs before each step, so when it returns true exactly n steps
|
||||
// have executed -- n is the position, not the step about to run. step is
|
||||
// published before calling the caller's trap so that now() is the current
|
||||
// position while the condition is being evaluated; a search relies on that
|
||||
// to record where a hit happened.
|
||||
this.machine.advanceFrame(() => {
|
||||
this.step = n;
|
||||
if (n >= untilStep) return true;
|
||||
if (n >= from && trap && trap()) {
|
||||
trapped = true;
|
||||
return true;
|
||||
}
|
||||
n++;
|
||||
return false;
|
||||
});
|
||||
if (trapped || n >= untilStep) {
|
||||
this.step = n;
|
||||
return trapped;
|
||||
}
|
||||
// ran off the end of the frame without stopping
|
||||
this.endFrame(n);
|
||||
return false;
|
||||
}
|
||||
|
||||
private endFrame(steps: number): void {
|
||||
this.frame++;
|
||||
this.step = 0;
|
||||
this.lastFrameSteps = steps;
|
||||
this.frameStart = this.machine.saveState();
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { KeyFlags } from '../common/emu';
|
||||
import { History } from '../common/history';
|
||||
import { createCore, isRewindable, PlatformFrameInput } from '../common/platformcore';
|
||||
import { hashState } from '../common/statehash';
|
||||
import { formatTimestamp, timestamp } from '../common/timeline';
|
||||
import { isProbablyBinary } from '../common/util';
|
||||
import { fail, hasOutput, note, output, setJsonMode } from './cliformat';
|
||||
import { EmuTarget, loadPlatform } from './emutarget';
|
||||
@@ -43,12 +48,14 @@ const SHORT_FLAGS: { [short: string]: string } = {
|
||||
const BOOLEAN_FLAGS: { [command: string]: string[] } = {
|
||||
build: ['check', 'symbols', 'save'],
|
||||
run: ['info'],
|
||||
'verify-replay': ['verbose'],
|
||||
};
|
||||
|
||||
const ALIASES: { [alias: string]: string } = {
|
||||
compile: 'build',
|
||||
check: 'build',
|
||||
compilerun: 'run',
|
||||
verify: 'verify-replay',
|
||||
};
|
||||
|
||||
function parseArgs(argv: string[]): { command: string; args: Args; positional: string[] } {
|
||||
@@ -425,6 +432,150 @@ async function doDetect(positional: string[]): Promise<void> {
|
||||
output({ success: true, command: 'detect', data: { input, clear: isClearWinner(detections), detections: detections.slice(0, 8) } });
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// determinism
|
||||
|
||||
/**
|
||||
* Keys worth mashing: the directions and the fire/start buttons, which is what
|
||||
* a joystick-driven platform reads. Pressing arbitrary keyboard keys would be
|
||||
* noisier without testing anything more.
|
||||
*/
|
||||
const EXERCISE_KEYS = [37, 38, 39, 40, 32, 13]; // left up right down space enter
|
||||
|
||||
/**
|
||||
* A seeded button masher. Verification is only meaningful if the recording has
|
||||
* input in it -- without input the controls path is never exercised, and a
|
||||
* platform whose loadControlsState() is not the inverse of saveControlsState()
|
||||
* passes for the wrong reason. Seeded so a failure is reproducible.
|
||||
*/
|
||||
class InputExerciser {
|
||||
private state = 1;
|
||||
private held = new Map<number, boolean>();
|
||||
/** Where delivered events are logged so the replay can redeliver them. */
|
||||
log: PlatformFrameInput | null = null;
|
||||
|
||||
constructor(readonly target: EmuTarget, seed: number) {
|
||||
this.state = seed | 0 || 1;
|
||||
}
|
||||
|
||||
private next(): number {
|
||||
// xorshift32, same shape as the emulator's own noise()
|
||||
let x = this.state;
|
||||
x ^= x << 13; x ^= x >>> 17; x ^= x << 5;
|
||||
return (this.state = x) >>> 0;
|
||||
}
|
||||
|
||||
/** Press or release a key or two, as if a player were at the controls. */
|
||||
frame(): void {
|
||||
for (const key of EXERCISE_KEYS) {
|
||||
if ((this.next() & 7) !== 0) continue; // ~1 change per key per 8 frames
|
||||
const down = !this.held.get(key);
|
||||
this.held.set(key, down);
|
||||
const flags = down ? KeyFlags.KeyDown : KeyFlags.KeyUp;
|
||||
this.target.setKeyInput(key, key, flags);
|
||||
this.log?.recordKey(key, key, flags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a run, then replay it from the first checkpoint and check that every
|
||||
* frame comes back bit for bit. This is what keeps the deterministic debugger
|
||||
* honest: rewinding is only meaningful on platforms that reproduce, and a
|
||||
* platform that quietly picks up entropy (an unseeded Math.random, a clock)
|
||||
* shows up here as a diverging frame rather than as a mystery months later.
|
||||
*/
|
||||
async function doVerifyReplay(args: Args, positional: string[]): Promise<void> {
|
||||
const input = positional[0];
|
||||
const platformId = str(args, 'platform') || (input ? ROM_PLATFORMS[path.extname(input).toLowerCase()] : null);
|
||||
if (!platformId) fail('verify-replay', 'Required: verify-replay --platform <id> [rom]');
|
||||
|
||||
const target = await openTarget(args, platformId);
|
||||
if (input) {
|
||||
if (!fs.existsSync(input)) fail('verify-replay', `No such file: ${input}`);
|
||||
target.loadROM(new Uint8Array(fs.readFileSync(input)), path.basename(input));
|
||||
}
|
||||
const platform = target.platform;
|
||||
if (!isRewindable(platform)) {
|
||||
fail('verify-replay', `'${platformId}' cannot save and restore state`);
|
||||
}
|
||||
|
||||
const frames = parseInt(str(args, 'frames') ?? '') || 60;
|
||||
const wantInput = (str(args, 'input') ?? 'keys') !== 'none';
|
||||
const seed = parseInt(str(args, 'seed') ?? '') || 12345;
|
||||
let exerciser: InputExerciser | null = null;
|
||||
let inputMode = 'none';
|
||||
if (wantInput) {
|
||||
exerciser = new InputExerciser(target, seed);
|
||||
try {
|
||||
exerciser.frame(); // fails fast if the target takes no key input
|
||||
inputMode = 'keys';
|
||||
} catch (e) {
|
||||
exerciser = null;
|
||||
inputMode = 'unsupported';
|
||||
}
|
||||
}
|
||||
const core = createCore(platform);
|
||||
const input_ = new PlatformFrameInput(platform, {
|
||||
currentFrame: () => core.now().frame,
|
||||
dispatchKey: (key, code, flags) => target.setKeyInput(key, code, flags),
|
||||
});
|
||||
if (exerciser) exerciser.log = input_;
|
||||
// one checkpoint only, so the replay re-runs the whole span rather than
|
||||
// being handed a fresh state part way through
|
||||
const hist = new History(core, { checkpointInterval: frames + 1, input: input_ });
|
||||
|
||||
// record, hashing the state at every frame boundary
|
||||
const recorded: number[] = [];
|
||||
// distinct control states seen; 1 means the input never reached the machine,
|
||||
// which would make a passing verification meaningless
|
||||
const controlStates = new Set<number>();
|
||||
for (let i = 0; i < frames; i++) {
|
||||
// input goes in before recordFrame(), so the frame's captured controls
|
||||
// include it -- that is what the replay has to reproduce
|
||||
exerciser?.frame();
|
||||
if (platform.saveControlsState) controlStates.add(hashState(platform.saveControlsState()));
|
||||
hist.recordFrame();
|
||||
recorded.push(hashState(core.snapshot()));
|
||||
}
|
||||
|
||||
// replay the same span and compare
|
||||
const diverged: { frame: number }[] = [];
|
||||
hist.seek(hist.first());
|
||||
for (let i = 0; i < frames; i++) {
|
||||
hist.seek(timestamp(i + 1, 0));
|
||||
const h = hashState(core.snapshot());
|
||||
if (h !== recorded[i]) {
|
||||
diverged.push({ frame: i + 1 });
|
||||
if (diverged.length >= 5) break;
|
||||
}
|
||||
}
|
||||
|
||||
const ok = diverged.length === 0;
|
||||
if (!ok) {
|
||||
note(`first divergence at frame ${diverged[0].frame} of ${frames}`);
|
||||
}
|
||||
output({
|
||||
success: ok,
|
||||
command: 'verify-replay',
|
||||
error: ok ? undefined : `replay diverged at frame ${diverged[0].frame}`,
|
||||
data: {
|
||||
platform: platformId,
|
||||
rom: input ?? null,
|
||||
frames,
|
||||
input: inputMode === 'keys' && controlStates.size < 2 ? 'keys (no effect)' : inputMode,
|
||||
controlStates: controlStates.size,
|
||||
keyEvents: input_.keyEventCount,
|
||||
seed: inputMode === 'keys' ? seed : null,
|
||||
granularity: core.granularity,
|
||||
deterministic: ok,
|
||||
firstDivergentFrame: ok ? null : diverged[0].frame,
|
||||
divergentFrames: diverged.map((d) => d.frame),
|
||||
recordedTo: formatTimestamp(hist.last()),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// listings & help
|
||||
|
||||
@@ -450,6 +601,7 @@ function usage(error?: string): never {
|
||||
'build': 'compile a source file or folder to a ROM',
|
||||
'run': 'run a ROM -- or a source file or folder, built first',
|
||||
'detect': 'guess the platform and main file of a source file or directory',
|
||||
'verify-replay': 'record a run with random key input, replay it, and check every frame matches',
|
||||
'list-platforms': 'platforms available to --platform',
|
||||
'list-tools': 'compilers and assemblers available to --tool',
|
||||
},
|
||||
@@ -479,6 +631,12 @@ function usage(error?: string): never {
|
||||
'--info': 'dump debug info and disassembly when done',
|
||||
'--memdump <a,b>': 'hexdump a hex address range',
|
||||
},
|
||||
'verify-replay options': {
|
||||
'-p, --platform <id>': 'platform emulator',
|
||||
'-f, --frames <n>': 'frames to record (default 60)',
|
||||
'--input <keys|none>': 'press random keys while recording (default keys)',
|
||||
'--seed <n>': 'seed for the random keys',
|
||||
},
|
||||
'global options': {
|
||||
'--json': 'machine-readable output on stdout',
|
||||
},
|
||||
@@ -506,6 +664,7 @@ async function main() {
|
||||
case 'build': await doBuild(args, positional); break;
|
||||
case 'run': await doRun(args, positional); break;
|
||||
case 'detect': await doDetect(positional); break;
|
||||
case 'verify-replay': await doVerifyReplay(args, positional); break;
|
||||
case 'list-tools':
|
||||
case 'list-platforms': await doList(command); break;
|
||||
case 'help': usage(); break;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"use strict";
|
||||
|
||||
// Determinism regression tests: record a run with input, replay it from the
|
||||
// first checkpoint, and require every frame to come back bit for bit.
|
||||
//
|
||||
// This is the guard that keeps rewinding meaningful. Without it, a field that
|
||||
// is written but never saved (ANTIC's left/right, the WASM machines' joymask)
|
||||
// leaks across a rewind and nothing notices until a user is staring at a
|
||||
// debugger that shows the wrong past.
|
||||
|
||||
var assert = require('assert');
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
|
||||
var emutarget = require('../../gen/tools/emutarget.js');
|
||||
var platformcore = require('../../gen/common/platformcore.js');
|
||||
var history = require('../../gen/common/history.js');
|
||||
var statehash = require('../../gen/common/statehash.js');
|
||||
var emu = require('../../gen/common/emu.js');
|
||||
|
||||
var KEYS = [37, 38, 39, 40, 32, 13]; // left up right down space enter
|
||||
|
||||
/** Seeded button masher, so a failure is reproducible. */
|
||||
function makeExerciser(target, log, seed) {
|
||||
var state = seed | 0 || 1;
|
||||
var held = {};
|
||||
function next() {
|
||||
var x = state;
|
||||
x ^= x << 13; x ^= x >>> 17; x ^= x << 5;
|
||||
return (state = x) >>> 0;
|
||||
}
|
||||
return function () {
|
||||
for (var i = 0; i < KEYS.length; i++) {
|
||||
var key = KEYS[i];
|
||||
if ((next() & 7) !== 0) continue;
|
||||
var down = !held[key];
|
||||
held[key] = down;
|
||||
var flags = down ? emu.KeyFlags.KeyDown : emu.KeyFlags.KeyUp;
|
||||
target.setKeyInput(key, key, flags);
|
||||
log.recordKey(key, key, flags);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function firstROM(platid) {
|
||||
var dir = path.join('test', 'roms', platid);
|
||||
return path.join(dir, fs.readdirSync(dir)[0]);
|
||||
}
|
||||
|
||||
/** Record `frames` frames with input, then replay and compare every frame. */
|
||||
async function checkDeterministic(platid, frames) {
|
||||
var target = await emutarget.loadPlatform(platid);
|
||||
await target.start();
|
||||
target.loadROM(new Uint8Array(fs.readFileSync(firstROM(platid))));
|
||||
|
||||
var core = platformcore.createCore(target.platform);
|
||||
var input = new platformcore.PlatformFrameInput(target.platform, {
|
||||
currentFrame: function () { return core.now().frame; },
|
||||
dispatchKey: function (key, code, flags) { target.setKeyInput(key, code, flags); },
|
||||
});
|
||||
// one checkpoint, so the replay re-runs the whole span
|
||||
var hist = new history.History(core, { checkpointInterval: frames + 1, input: input });
|
||||
var exercise = makeExerciser(target, input, 12345);
|
||||
|
||||
var recorded = [];
|
||||
var controlStates = {};
|
||||
for (var i = 0; i < frames; i++) {
|
||||
exercise();
|
||||
if (target.platform.saveControlsState) {
|
||||
controlStates[statehash.hashState(target.platform.saveControlsState())] = 1;
|
||||
}
|
||||
hist.recordFrame();
|
||||
recorded.push(statehash.hashState(core.snapshot()));
|
||||
}
|
||||
// the input has to actually reach the machine, or a pass means nothing
|
||||
assert.ok(Object.keys(controlStates).length > 1,
|
||||
platid + ': the exerciser never changed the controls, so nothing was tested');
|
||||
|
||||
hist.seek(hist.first());
|
||||
for (var i = 0; i < frames; i++) {
|
||||
hist.seek({ frame: i + 1, step: 0 });
|
||||
assert.strictEqual(statehash.hashState(core.snapshot()), recorded[i],
|
||||
platid + ': replay diverged at frame ' + (i + 1) + ' of ' + frames);
|
||||
}
|
||||
return core.granularity;
|
||||
}
|
||||
|
||||
describe('Deterministic replay', function () {
|
||||
this.timeout(120000);
|
||||
|
||||
// a JS scanline machine, the two that had state-restore bugs, and a WASM
|
||||
// machine of each kind
|
||||
it('should replay mw8080bw exactly', async function () {
|
||||
assert.strictEqual(await checkDeterministic('mw8080bw', 20), 'insn');
|
||||
});
|
||||
it('should replay apple2 exactly', async function () {
|
||||
await checkDeterministic('apple2', 20);
|
||||
});
|
||||
it('should replay atari8-800 exactly (ANTIC left/right must round-trip)', async function () {
|
||||
await checkDeterministic('atari8-800', 20);
|
||||
});
|
||||
it('should replay atari8-5200 exactly (key-down IRQ must be redelivered)', async function () {
|
||||
await checkDeterministic('atari8-5200', 20);
|
||||
});
|
||||
it('should replay c64 exactly (joymask must round-trip)', async function () {
|
||||
assert.strictEqual(await checkDeterministic('c64', 20), 'clock');
|
||||
});
|
||||
it('should replay zx exactly (joymask must round-trip)', async function () {
|
||||
await checkDeterministic('zx', 20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import assert from "assert";
|
||||
import { describe, it } from "mocha";
|
||||
import { TrapCondition } from "../../src/common/devices";
|
||||
import { FrameInputSource, History } from "../../src/common/history";
|
||||
import {
|
||||
formatTimestamp,
|
||||
MachineCore,
|
||||
timestamp,
|
||||
timestampsEqual,
|
||||
} from "../../src/common/timeline";
|
||||
|
||||
// Same shape as the real machines: advanceFrame() restarts the frame rather
|
||||
// than resuming it. `inp` stands in for a controller: it changes only between
|
||||
// frames, and the frame's result depends on it, so a replay that forgets to
|
||||
// re-apply it diverges visibly.
|
||||
class FakeMachine {
|
||||
stepsPerFrame = 10;
|
||||
acc = 0;
|
||||
inp = 0;
|
||||
|
||||
advanceFrame(trap: TrapCondition): number {
|
||||
var n = 0;
|
||||
for (var i = 0; i < this.stepsPerFrame; i++) {
|
||||
if (trap && trap()) break;
|
||||
this.acc = (this.acc + 1 + this.inp) | 0;
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
saveState() { return { acc: this.acc, inp: this.inp }; }
|
||||
loadState(s) { this.acc = s.acc; this.inp = s.inp; }
|
||||
}
|
||||
|
||||
function newHistory(opts = {}) {
|
||||
const m = new FakeMachine();
|
||||
const core = new MachineCore(m);
|
||||
return { m, core, hist: new History(core, opts) };
|
||||
}
|
||||
|
||||
function record(hist: History, frames: number) {
|
||||
for (var i = 0; i < frames; i++) hist.recordFrame();
|
||||
}
|
||||
|
||||
describe('History', function () {
|
||||
|
||||
it('records frames and tracks the present', function () {
|
||||
const { m, hist } = newHistory();
|
||||
record(hist, 12);
|
||||
assert.ok(timestampsEqual(hist.last(), timestamp(12, 0)), formatTimestamp(hist.last()));
|
||||
assert.strictEqual(m.acc, 120);
|
||||
assert.strictEqual(hist.isInPast(), false);
|
||||
});
|
||||
|
||||
it('checkpoints at the configured interval', function () {
|
||||
const { hist } = newHistory({ checkpointInterval: 5 });
|
||||
record(hist, 21);
|
||||
const at = hist.getCheckpoints().map(c => c.at.frame);
|
||||
assert.deepStrictEqual(at, [0, 5, 10, 15, 20]);
|
||||
});
|
||||
|
||||
it('seeks backwards to an exact past state', function () {
|
||||
const { m, hist } = newHistory();
|
||||
record(hist, 30);
|
||||
hist.seek(timestamp(7, 3));
|
||||
assert.strictEqual(m.acc, 73);
|
||||
assert.strictEqual(hist.isInPast(), true);
|
||||
// and forward again to the present
|
||||
hist.seek(hist.last());
|
||||
assert.strictEqual(m.acc, 300);
|
||||
assert.strictEqual(hist.isInPast(), false);
|
||||
});
|
||||
|
||||
it('replays recorded input so the past reproduces', function () {
|
||||
const m = new FakeMachine();
|
||||
const captured: { [frame: number]: number } = {};
|
||||
// frame N runs with input N%3, which changes what the frame computes
|
||||
const input: FrameInputSource = {
|
||||
capture(frame) { m.inp = frame % 3; captured[frame] = m.inp; },
|
||||
replay(frame) { m.inp = captured[frame]; },
|
||||
};
|
||||
const hist = new History(new MachineCore(m), { input });
|
||||
record(hist, 20);
|
||||
const present = m.acc;
|
||||
|
||||
hist.seek(timestamp(9, 4));
|
||||
const at94 = m.acc;
|
||||
// an unrecorded replay would leave inp stuck at its last value and drift
|
||||
hist.seek(hist.last());
|
||||
assert.strictEqual(m.acc, present);
|
||||
hist.seek(timestamp(9, 4));
|
||||
assert.strictEqual(m.acc, at94);
|
||||
});
|
||||
|
||||
it('finds the last time a condition held, in the past', function () {
|
||||
const { m, hist } = newHistory();
|
||||
record(hist, 30);
|
||||
// acc hits 250 exactly once, at 25:0 -- 250 steps in
|
||||
const hit = hist.findLast(() => m.acc === 250);
|
||||
assert.ok(hit, 'no hit');
|
||||
assert.ok(timestampsEqual(hit, timestamp(25, 0)), formatTimestamp(hit));
|
||||
// and the core is parked there
|
||||
assert.strictEqual(m.acc, 250);
|
||||
});
|
||||
|
||||
it('finds the LAST of many hits, not the first', function () {
|
||||
const { m, hist } = newHistory();
|
||||
record(hist, 30);
|
||||
// true every 7 steps; the last one at or before 300 is 294
|
||||
const hit = hist.findLast(() => m.acc % 7 === 0 && m.acc > 0);
|
||||
assert.ok(timestampsEqual(hit, timestamp(29, 4)), formatTimestamp(hit));
|
||||
assert.strictEqual(m.acc, 294);
|
||||
});
|
||||
|
||||
it('searches back across many checkpoint spans', function () {
|
||||
const { m, hist } = newHistory({ checkpointInterval: 4 });
|
||||
record(hist, 40);
|
||||
// only true early on, so the search has to walk back ~8 spans
|
||||
const hit = hist.findLast(() => m.acc === 37);
|
||||
assert.ok(timestampsEqual(hit, timestamp(3, 7)), formatTimestamp(hit));
|
||||
assert.strictEqual(m.acc, 37);
|
||||
});
|
||||
|
||||
it('returns null when the condition never held', function () {
|
||||
const { m, hist } = newHistory();
|
||||
record(hist, 20);
|
||||
assert.strictEqual(hist.findLast(() => m.acc === 99999), null);
|
||||
});
|
||||
|
||||
it('finds the next time a condition holds', function () {
|
||||
const { m, hist } = newHistory();
|
||||
record(hist, 30);
|
||||
hist.seek(timestamp(5, 0));
|
||||
const hit = hist.findNext(() => m.acc === 123, timestamp(5, 0), hist.last());
|
||||
assert.ok(timestampsEqual(hit, timestamp(12, 3)), formatTimestamp(hit));
|
||||
assert.strictEqual(m.acc, 123);
|
||||
});
|
||||
|
||||
it('steps backwards exactly, within a frame', function () {
|
||||
const { m, hist } = newHistory();
|
||||
record(hist, 20);
|
||||
hist.seek(timestamp(8, 5));
|
||||
const prev = hist.previousStep();
|
||||
assert.ok(timestampsEqual(prev, timestamp(8, 4)), formatTimestamp(prev));
|
||||
assert.strictEqual(m.acc, 84);
|
||||
});
|
||||
|
||||
it('steps backwards across a frame boundary', function () {
|
||||
const { m, hist } = newHistory();
|
||||
record(hist, 20);
|
||||
hist.seek(timestamp(8, 0));
|
||||
const prev = hist.previousStep();
|
||||
// last step of frame 7 is position 9, i.e. 79 steps in
|
||||
assert.ok(timestampsEqual(prev, timestamp(7, 9)), formatTimestamp(prev));
|
||||
assert.strictEqual(m.acc, 79);
|
||||
});
|
||||
|
||||
it('drops the oldest checkpoints and reports the window it still has', function () {
|
||||
const trimmed: number[] = [];
|
||||
const input: FrameInputSource = {
|
||||
capture() { }, replay() { }, trim(f) { trimmed.push(f); },
|
||||
};
|
||||
const { hist } = newHistory({ checkpointInterval: 2, maxCheckpoints: 4, input });
|
||||
record(hist, 20);
|
||||
const at = hist.getCheckpoints().map(c => c.at.frame);
|
||||
assert.strictEqual(at.length, 4);
|
||||
assert.ok(at[0] > 0, 'old checkpoints should have been dropped');
|
||||
assert.ok(timestampsEqual(hist.first(), timestamp(at[0], 0)));
|
||||
// the input log is told to drop everything before the oldest checkpoint
|
||||
assert.strictEqual(trimmed[trimmed.length - 1], at[0]);
|
||||
});
|
||||
|
||||
it('refuses to seek before the recorded window', function () {
|
||||
const { hist } = newHistory({ checkpointInterval: 2, maxCheckpoints: 3 });
|
||||
record(hist, 20);
|
||||
assert.throws(() => hist.seek(timestamp(0, 0)), /older than the recording/);
|
||||
});
|
||||
|
||||
it('refuses to record from a replayed past', function () {
|
||||
const { hist } = newHistory();
|
||||
record(hist, 10);
|
||||
hist.seek(timestamp(4, 0));
|
||||
assert.throws(() => hist.recordFrame(), /not at the head/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from "assert";
|
||||
import { describe, it } from "mocha";
|
||||
import { TrapCondition } from "../../src/common/devices";
|
||||
import {
|
||||
compareTimestamps,
|
||||
CoreMachine,
|
||||
formatTimestamp,
|
||||
MachineCore,
|
||||
timestamp,
|
||||
timestampsEqual,
|
||||
} from "../../src/common/timeline";
|
||||
|
||||
// A machine with the same awkward property the real ones have: advanceFrame()
|
||||
// restarts the frame from the top instead of resuming it, so breaking out
|
||||
// mid-frame and calling it again re-runs the frame. MachineCore has to replay
|
||||
// from a frame-start snapshot to get back to a sub-frame position.
|
||||
class FakeMachine implements CoreMachine {
|
||||
stepsPerFrame = 10;
|
||||
acc = 0; // one increment per step
|
||||
ram = new Uint8Array(16);
|
||||
|
||||
advanceFrame(trap: TrapCondition): number {
|
||||
var n = 0;
|
||||
for (var i = 0; i < this.stepsPerFrame; i++) {
|
||||
if (trap && trap()) break;
|
||||
this.acc = (this.acc + 1) | 0;
|
||||
this.ram[this.acc & 15] = this.acc & 0xff;
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
saveState() {
|
||||
return { acc: this.acc, ram: this.ram.slice(0) };
|
||||
}
|
||||
loadState(s) {
|
||||
this.acc = s.acc;
|
||||
this.ram.set(s.ram);
|
||||
}
|
||||
}
|
||||
|
||||
function stateOf(m: FakeMachine) {
|
||||
return JSON.stringify({ acc: m.acc, ram: Array.from(m.ram) });
|
||||
}
|
||||
|
||||
describe('Timestamp', function () {
|
||||
it('orders lexicographically by frame then step', function () {
|
||||
assert.ok(compareTimestamps(timestamp(1, 5), timestamp(2, 0)) < 0);
|
||||
assert.ok(compareTimestamps(timestamp(2, 0), timestamp(1, 5)) > 0);
|
||||
assert.ok(compareTimestamps(timestamp(2, 3), timestamp(2, 4)) < 0);
|
||||
assert.strictEqual(compareTimestamps(timestamp(2, 3), timestamp(2, 3)), 0);
|
||||
assert.ok(timestampsEqual(timestamp(2, 3), timestamp(2, 3)));
|
||||
assert.strictEqual(formatTimestamp(timestamp(2, 3)), '2:3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MachineCore', function () {
|
||||
|
||||
it('counts frames monotonically and never rewinds the clock', function () {
|
||||
const m = new FakeMachine();
|
||||
const c = new MachineCore(m);
|
||||
assert.ok(timestampsEqual(c.now(), timestamp(0, 0)));
|
||||
c.runUntil(timestamp(3, 0));
|
||||
// the old debugger reset its clock every frame; this must not
|
||||
assert.ok(timestampsEqual(c.now(), timestamp(3, 0)));
|
||||
assert.strictEqual(m.acc, 30);
|
||||
});
|
||||
|
||||
it('lands exactly on a sub-frame target', function () {
|
||||
const m = new FakeMachine();
|
||||
const c = new MachineCore(m);
|
||||
const r = c.runUntil(timestamp(2, 5));
|
||||
assert.ok(timestampsEqual(r.at, timestamp(2, 5)), formatTimestamp(r.at));
|
||||
assert.strictEqual(r.trapped, false);
|
||||
assert.strictEqual(m.acc, 25);
|
||||
});
|
||||
|
||||
it('evaluates a trap across frame boundaries', function () {
|
||||
const m = new FakeMachine();
|
||||
const c = new MachineCore(m);
|
||||
// 23 steps in => frame 2, step 3; the trap has to survive two frame ends
|
||||
const r = c.runUntil(timestamp(9, 0), () => m.acc === 23);
|
||||
assert.strictEqual(r.trapped, true);
|
||||
assert.ok(timestampsEqual(r.at, timestamp(2, 3)), formatTimestamp(r.at));
|
||||
assert.strictEqual(m.acc, 23);
|
||||
});
|
||||
|
||||
it('reports no trap when the condition never holds', function () {
|
||||
const m = new FakeMachine();
|
||||
const c = new MachineCore(m);
|
||||
const r = c.runUntil(timestamp(2, 0), () => m.acc === 9999);
|
||||
assert.strictEqual(r.trapped, false);
|
||||
assert.ok(timestampsEqual(r.at, timestamp(2, 0)));
|
||||
});
|
||||
|
||||
it('resumes within a frame without re-running the trap on replayed steps', function () {
|
||||
const m = new FakeMachine();
|
||||
const c = new MachineCore(m);
|
||||
c.runUntil(timestamp(1, 4));
|
||||
assert.strictEqual(m.acc, 14);
|
||||
// getting from 1:4 to 1:9 replays the frame from its start internally,
|
||||
// but the trap must only see positions 4..8
|
||||
const seen: number[] = [];
|
||||
const r = c.runUntil(timestamp(1, 9), () => { seen.push(m.acc); return false; });
|
||||
assert.deepStrictEqual(seen, [14, 15, 16, 17, 18]);
|
||||
assert.ok(timestampsEqual(r.at, timestamp(1, 9)));
|
||||
assert.strictEqual(m.acc, 19);
|
||||
});
|
||||
|
||||
it('reproduces the same state when replayed from a snapshot', function () {
|
||||
const m = new FakeMachine();
|
||||
const c = new MachineCore(m);
|
||||
const snap0 = c.snapshot();
|
||||
c.runUntil(timestamp(3, 7));
|
||||
const expected = stateOf(m);
|
||||
|
||||
c.restore(snap0, timestamp(0, 0));
|
||||
assert.ok(timestampsEqual(c.now(), timestamp(0, 0)));
|
||||
c.runUntil(timestamp(3, 7));
|
||||
assert.strictEqual(stateOf(m), expected);
|
||||
});
|
||||
|
||||
it('seeks backwards to a sub-frame position exactly', function () {
|
||||
// what the state should be at 2:3, established independently
|
||||
const fresh = new FakeMachine();
|
||||
new MachineCore(fresh).runUntil(timestamp(2, 3));
|
||||
const expected = stateOf(fresh);
|
||||
|
||||
const m = new FakeMachine();
|
||||
const c = new MachineCore(m);
|
||||
const snap0 = c.snapshot();
|
||||
c.runUntil(timestamp(2, 9));
|
||||
assert.notStrictEqual(stateOf(m), expected);
|
||||
|
||||
c.restore(snap0, timestamp(0, 0));
|
||||
c.runUntil(timestamp(2, 3));
|
||||
assert.ok(timestampsEqual(c.now(), timestamp(2, 3)));
|
||||
assert.strictEqual(stateOf(m), expected);
|
||||
});
|
||||
|
||||
it('restores a checkpoint taken mid-recording', function () {
|
||||
const m = new FakeMachine();
|
||||
const c = new MachineCore(m);
|
||||
c.runUntil(timestamp(5, 0));
|
||||
const snap5 = c.snapshot();
|
||||
c.runUntil(timestamp(8, 2));
|
||||
const at82 = stateOf(m);
|
||||
|
||||
c.restore(snap5, timestamp(5, 0));
|
||||
assert.strictEqual(m.acc, 50);
|
||||
c.runUntil(timestamp(8, 2));
|
||||
assert.strictEqual(stateOf(m), at82);
|
||||
});
|
||||
|
||||
it('refuses to restore at a non-frame boundary', function () {
|
||||
const m = new FakeMachine();
|
||||
const c = new MachineCore(m);
|
||||
assert.throws(() => c.restore(c.snapshot(), timestamp(1, 3)), /frame boundary/);
|
||||
});
|
||||
|
||||
it('counts steps only for metered frames', function () {
|
||||
const m = new FakeMachine();
|
||||
const c = new MachineCore(m);
|
||||
c.runUntil(timestamp(1, 0)); // no trap: unmetered fast path
|
||||
assert.strictEqual(c.getLastFrameSteps(), -1);
|
||||
c.runUntil(timestamp(2, 0), () => false); // trap: counted
|
||||
assert.strictEqual(c.getLastFrameSteps(), 10);
|
||||
});
|
||||
|
||||
it('does nothing when the target is already reached', function () {
|
||||
const m = new FakeMachine();
|
||||
const c = new MachineCore(m);
|
||||
c.runUntil(timestamp(2, 4));
|
||||
const before = stateOf(m);
|
||||
const r = c.runUntil(timestamp(1, 0));
|
||||
assert.strictEqual(stateOf(m), before);
|
||||
assert.ok(timestampsEqual(r.at, timestamp(2, 4)));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import assert from "assert";
|
||||
import fs from "fs";
|
||||
import { describe, it } from "mocha";
|
||||
import { mockAudio, mockGlobals } from "../../src/tools/nodemock";
|
||||
|
||||
mockGlobals();
|
||||
mockAudio();
|
||||
|
||||
// imported after the globals are mocked -- the machine pulls in the audio
|
||||
// classes that live on globalThis
|
||||
import { Midway8080 } from "../../src/machine/mw8080bw";
|
||||
import { NullProbe } from "../../src/common/devices";
|
||||
import { History } from "../../src/common/history";
|
||||
import {
|
||||
compareTimestamps,
|
||||
formatTimestamp,
|
||||
MachineCore,
|
||||
timestamp,
|
||||
timestampsEqual,
|
||||
} from "../../src/common/timeline";
|
||||
|
||||
// MachineCore against a real machine: a Z80 BasicScanlineMachine running a
|
||||
// real ROM. The fake machine in testtimeline.ts pins the semantics; this pins
|
||||
// that they survive contact with an emulator whose advanceFrame() restarts the
|
||||
// scanline loop and whose state is typed arrays.
|
||||
|
||||
function newCore() {
|
||||
const m = new Midway8080();
|
||||
m.connectVideo(new Uint32Array(m.canvasWidth * m.numVisibleScanlines));
|
||||
m.loadROM(new Uint8Array(fs.readFileSync('./test/roms/mw8080bw/game2.c.rom')));
|
||||
m.reset();
|
||||
return { m, core: new MachineCore(m) };
|
||||
}
|
||||
|
||||
// cheap order-sensitive checksum over the whole machine state
|
||||
function hashState(m: Midway8080): number {
|
||||
const s: any = m.saveState();
|
||||
let h = 0x811c9dc5;
|
||||
const mix = (v: number) => { h = Math.imul(h ^ (v & 0xff), 0x01000193) >>> 0; };
|
||||
for (const k of Object.keys(s).sort()) {
|
||||
const v = s[k];
|
||||
if (v instanceof Uint8Array) for (let i = 0; i < v.length; i++) mix(v[i]);
|
||||
else for (const kk of Object.keys(v).sort()) { mix(kk.charCodeAt(0)); mix(v[kk]); mix(v[kk] >> 8); }
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
describe('MachineCore on a real machine', function () {
|
||||
|
||||
it('replays to the identical state from a snapshot 20 frames back', function () {
|
||||
const { m, core } = newCore();
|
||||
const snap0 = core.snapshot();
|
||||
core.runUntil(timestamp(20, 500), () => false);
|
||||
const expected = hashState(m);
|
||||
const pc = m.cpu.getPC();
|
||||
|
||||
core.restore(snap0, timestamp(0, 0));
|
||||
const r = core.runUntil(timestamp(20, 500), () => false);
|
||||
assert.ok(timestampsEqual(r.at, timestamp(20, 500)), formatTimestamp(r.at));
|
||||
assert.strictEqual(m.cpu.getPC(), pc);
|
||||
assert.strictEqual(hashState(m), expected);
|
||||
});
|
||||
|
||||
it('reaches a sub-frame position identically forwards and backwards', function () {
|
||||
// where the machine should be at 12:250, reached only by running forward
|
||||
const fwd = newCore();
|
||||
fwd.core.runUntil(timestamp(12, 250), () => false);
|
||||
const expected = hashState(fwd.m);
|
||||
|
||||
// now overshoot and come back to it
|
||||
const { m, core } = newCore();
|
||||
const snap0 = core.snapshot();
|
||||
core.runUntil(timestamp(12, 900), () => false);
|
||||
assert.notStrictEqual(hashState(m), expected);
|
||||
core.restore(snap0, timestamp(0, 0));
|
||||
core.runUntil(timestamp(12, 250), () => false);
|
||||
assert.strictEqual(hashState(m), expected);
|
||||
});
|
||||
|
||||
it('traps on a condition several frames ahead and reports where', function () {
|
||||
const { m, core } = newCore();
|
||||
core.runUntil(timestamp(5, 0));
|
||||
const snap5 = core.snapshot();
|
||||
// find some PC the ROM reaches later, whatever it is
|
||||
const seen = new Set<number>();
|
||||
core.runUntil(timestamp(6, 0), () => { seen.add(m.cpu.getPC()); return false; });
|
||||
core.restore(snap5, timestamp(5, 0));
|
||||
const targetPC = [...seen][Math.floor(seen.size / 2)];
|
||||
|
||||
const r = core.runUntil(timestamp(30, 0), () => m.cpu.getPC() === targetPC);
|
||||
assert.strictEqual(r.trapped, true);
|
||||
assert.strictEqual(m.cpu.getPC(), targetPC);
|
||||
|
||||
// the same search from the same snapshot must land on the same instant
|
||||
const hit = r.at;
|
||||
const expected = hashState(m);
|
||||
core.restore(snap5, timestamp(5, 0));
|
||||
const r2 = core.runUntil(timestamp(30, 0), () => m.cpu.getPC() === targetPC);
|
||||
assert.ok(timestampsEqual(r2.at, hit), `${formatTimestamp(r2.at)} != ${formatTimestamp(hit)}`);
|
||||
assert.strictEqual(hashState(m), expected);
|
||||
});
|
||||
|
||||
it('keeps counting frames past a trap instead of resetting', function () {
|
||||
const { m, core } = newCore();
|
||||
core.runUntil(timestamp(3, 100), () => false);
|
||||
assert.ok(timestampsEqual(core.now(), timestamp(3, 100)));
|
||||
core.runUntil(timestamp(7, 20), () => false);
|
||||
assert.ok(timestampsEqual(core.now(), timestamp(7, 20)));
|
||||
});
|
||||
|
||||
it('counts a whole metered frame', function () {
|
||||
const { core } = newCore();
|
||||
core.runUntil(timestamp(2, 0), () => false);
|
||||
// one Z80 instruction per step, so a frame is a few thousand of them
|
||||
const steps = core.getLastFrameSteps();
|
||||
assert.ok(steps > 100 && steps < 100000, `implausible step count ${steps}`);
|
||||
});
|
||||
});
|
||||
|
||||
// A watchpoint: a probe that latches a write to one address. Params are
|
||||
// optional because NullProbe declares these with none.
|
||||
class WatchProbe extends NullProbe {
|
||||
hit = false;
|
||||
constructor(readonly addr: number) { super(); }
|
||||
logWrite(a?: number, v?: number) { if (a === this.addr) this.hit = true; }
|
||||
// clearing on test means each write is reported once; handing the probe to
|
||||
// the search lets History attach it only while the search runs
|
||||
asCondition() {
|
||||
return {
|
||||
probe: this,
|
||||
test: () => { const h = this.hit; this.hit = false; return h; },
|
||||
reset: () => { this.hit = false; },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('History on a real machine', function () {
|
||||
|
||||
it('replays a recorded run back to the same present', function () {
|
||||
const { m, core } = newCore();
|
||||
const hist = new History(core, { checkpointInterval: 5 });
|
||||
for (var i = 0; i < 40; i++) hist.recordFrame();
|
||||
const present = hashState(m);
|
||||
|
||||
hist.seek(timestamp(3, 200));
|
||||
assert.notStrictEqual(hashState(m), present);
|
||||
hist.seek(hist.last());
|
||||
assert.strictEqual(hashState(m), present);
|
||||
});
|
||||
|
||||
it('finds when a memory location was last written, after the fact', function () {
|
||||
const WATCH = 0x200b; // written a couple of times early in the ROM
|
||||
|
||||
const { m, core } = newCore();
|
||||
const hist = new History(core, { checkpointInterval: 5 });
|
||||
const probe = new WatchProbe(WATCH);
|
||||
// recorded with no probe attached at all -- the point is to ask afterwards
|
||||
for (var i = 0; i < 40; i++) hist.recordFrame();
|
||||
assert.strictEqual(m.probing, false, 'recording should not be probing');
|
||||
|
||||
const hit = hist.findLast(probe.asCondition());
|
||||
assert.strictEqual(m.probing, false, 'probe should be detached after the search');
|
||||
assert.ok(hit, `no write to $${WATCH.toString(16)} found`);
|
||||
assert.ok(compareTimestamps(hit, hist.first()) >= 0 && compareTimestamps(hit, hist.last()) < 0,
|
||||
`hit ${formatTimestamp(hit)} outside the recorded window`);
|
||||
|
||||
// the multi-span walk backwards must agree with one linear scan
|
||||
const solo = newCore();
|
||||
const soloHist = new History(solo.core, { checkpointInterval: 100000 });
|
||||
const soloProbe = new WatchProbe(WATCH);
|
||||
for (var i = 0; i < 40; i++) soloHist.recordFrame();
|
||||
const soloHit = soloHist.findLast(soloProbe.asCondition());
|
||||
assert.ok(timestampsEqual(hit, soloHit),
|
||||
`checkpointed search said ${formatTimestamp(hit)}, linear scan said ${formatTimestamp(soloHit)}`);
|
||||
});
|
||||
|
||||
it('steps backwards one instruction exactly', function () {
|
||||
const { m, core } = newCore();
|
||||
const hist = new History(core, { checkpointInterval: 5 });
|
||||
for (var i = 0; i < 20; i++) hist.recordFrame();
|
||||
|
||||
hist.seek(timestamp(10, 40));
|
||||
const pcAt40 = m.cpu.getPC();
|
||||
const prev = hist.previousStep();
|
||||
assert.ok(timestampsEqual(prev, timestamp(10, 39)), formatTimestamp(prev));
|
||||
const pcAt39 = m.cpu.getPC();
|
||||
// seeking to each again must reproduce both
|
||||
hist.seek(timestamp(10, 40));
|
||||
assert.strictEqual(m.cpu.getPC(), pcAt40);
|
||||
hist.seek(timestamp(10, 39));
|
||||
assert.strictEqual(m.cpu.getPC(), pcAt39);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user