diff --git a/src/common/history.ts b/src/common/history.ts new file mode 100644 index 00000000..c3be3aff --- /dev/null +++ b/src/common/history.ts @@ -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 { + 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 { + + readonly core: DeterministicCore; + checkpointInterval: number; + maxCheckpoints: number; + private input?: FrameInputSource; + private checkpoints: Checkpoint[] = []; + /** 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, 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> { 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 | 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(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; + } +} diff --git a/src/common/platformcore.ts b/src/common/platformcore.ts new file mode 100644 index 00000000..2723e307 --- /dev/null +++ b/src/common/platformcore.ts @@ -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(); + private keys = new Map(); + 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); +} diff --git a/src/common/statehash.ts b/src/common/statehash.ts new file mode 100644 index 00000000..984d5518 --- /dev/null +++ b/src/common/statehash.ts @@ -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): 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()); +} diff --git a/src/common/timeline.ts b/src/common/timeline.ts new file mode 100644 index 00000000..c3580779 --- /dev/null +++ b/src/common/timeline.ts @@ -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 { + 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 { + 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 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(); + } +} diff --git a/src/tools/8bws.ts b/src/tools/8bws.ts index 6f0f0d65..86755eea 100644 --- a/src/tools/8bws.ts +++ b/src/tools/8bws.ts @@ -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 { 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(); + /** 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 { + 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 [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(); + 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 ': 'hexdump a hex address range', }, + 'verify-replay options': { + '-p, --platform ': 'platform emulator', + '-f, --frames ': 'frames to record (default 60)', + '--input ': 'press random keys while recording (default keys)', + '--seed ': '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; diff --git a/test/cli/testdeterminism.js b/test/cli/testdeterminism.js new file mode 100644 index 00000000..d0a9781f --- /dev/null +++ b/test/cli/testdeterminism.js @@ -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); + }); +}); diff --git a/test/unit/testhistory.ts b/test/unit/testhistory.ts new file mode 100644 index 00000000..3ce5b608 --- /dev/null +++ b/test/unit/testhistory.ts @@ -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/); + }); +}); diff --git a/test/unit/testtimeline.ts b/test/unit/testtimeline.ts new file mode 100644 index 00000000..c8ce30fa --- /dev/null +++ b/test/unit/testtimeline.ts @@ -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))); + }); +}); diff --git a/test/unit/testtimelinemachine.ts b/test/unit/testtimelinemachine.ts new file mode 100644 index 00000000..936e6a89 --- /dev/null +++ b/test/unit/testtimelinemachine.ts @@ -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(); + 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); + }); +});