diff --git a/src/tools/runscript.ts b/src/tools/runscript.ts index 460e2cb8..361db85f 100644 --- a/src/tools/runscript.ts +++ b/src/tools/runscript.ts @@ -11,6 +11,7 @@ import { ProbeFlags, ProbeRecorder } from '../common/probe'; import { hex } from '../common/util'; import type { SymbolMap } from '../common/baseplatform'; import { lookupSymbol } from '../common/symbols/symbolfile'; +import { formatTimestamp, timestamp, Timestamp } from '../common/timeline'; import { hexdump, write } from './cliformat'; import { DEFAULT_MAX_FRAMES, EmuTarget } from './emutarget'; @@ -22,6 +23,13 @@ export const RUN_SCRIPT_HELP = [ ' trace [MAXLINES] ADDR - run until PC==ADDR, then log every', ' instruction until the routine returns', ' hist [MAXLINES] - last N instructions from the trace buffer', + 'Time travel (replays the recording; output is labeled [frame:step]):', + ' back [N] - step back N instructions (default 1)', + ' rewind [N] - back to the start of the Nth frame before (default 1)', + ' seek FRAME[:STEP] - go to a recorded moment', + ' rbreak ADDR - run back to the last time PC==ADDR', + ' now - print the current moment and the recorded range', + ' (run and step in the past replay the recording; a key starts a new future)', 'Inspection & input:', ' key KEY - press key (down, 3 frames, up)', ' keydown KEY / keyup KEY - raw key down/up events', @@ -148,7 +156,7 @@ export class RunScript { //// helpers used by the commands - private log(msg: string) { this.out(`[frame ${this.target.frameCount}] ${msg}\n`); } + private log(msg: string) { this.out(`[${formatTimestamp(this.target.now())}] ${msg}\n`); } private addr(tok: string): number { try { return parseNum(tok); } @@ -192,6 +200,17 @@ export class RunScript { } } + private requireRewind() { + if (!this.target.supportsRewind) { + throw new Error(`'${this.target.id}' cannot rewind (it can't save its state)`); + } + } + + private parseTimestamp(tok: string): Timestamp { + const [f, st] = tok.split(':'); + return timestamp(parseNum(f), st ? parseNum(st) : 0); + } + private requireStep() { if (!this.target.supportsStep) { throw new Error(`'${this.target.id}' does not support instruction stepping`); @@ -279,6 +298,55 @@ export class RunScript { this.out(`(${shown} instructions shown, ${p.idx} events recorded)\n`); } + cmdBack(tokens: string[]) { + this.requireRewind(); + this.requireStep(); + const n = tokens[1] ? parseNum(tokens[1]) : 1; + if (!this.target.stepBack(n)) throw new Error(`the recording doesn't reach back ${n} instruction${n == 1 ? '' : 's'}`); + this.log(`back ${n}: PC=$${hex(this.target.getPC(), 4)}`); + this.out(this.disasmLine(this.target.getPC(), true) + '\n'); + } + + cmdSeek(tokens: string[]) { + this.requireRewind(); + if (!tokens[1]) throw new Error('seek requires FRAME[:STEP]'); + this.target.seek(this.parseTimestamp(tokens[1])); + this.log(this.where()); + } + + cmdRewind(tokens: string[]) { + this.requireRewind(); + const n = tokens[1] ? parseNum(tokens[1]) : 1; + const t = this.target.now(); + // from partway into a frame, its own start counts as the first + const frame = t.step > 0 ? t.frame - n + 1 : t.frame - n; + const first = this.target.history.first(); + this.target.seek(timestamp(Math.max(frame, first.frame), 0)); + this.log(this.where()); + } + + cmdReverseBreak(tokens: string[]) { + this.requireRewind(); + if (!tokens[1]) throw new Error('rbreak requires an address'); + const addr = this.addr(tokens[1]); + const hit = this.target.reverseRunUntil(() => this.target.getPC() === addr); + const pc = this.target.getPC(); + this.log(`rbreak $${hex(addr, 4)}: ${hit ? 'HIT' : 'MISSED'} (pc=${pc != null ? '$' + hex(pc, 4) : '?'})`); + } + + cmdNow() { + this.log(this.where()); + } + + /** "at F:S (past; recorded A to B)" */ + private where(): string { + const t = this.target; + const h = t.history; + if (!h) return `at frame ${t.frameCount} (no recording)`; + const range = `recorded ${formatTimestamp(h.first())} to ${formatTimestamp(h.last())}`; + return `at ${formatTimestamp(t.now())} (${t.isInPast() ? 'past' : 'present'}; ${range})`; + } + cmdKey(tokens: string[]) { if (!tokens[1]) throw new Error('key requires a key name'); const { key, flags } = parseKeyValue(tokens[1]); @@ -365,5 +433,10 @@ const COMMANDS: { [name: string]: Command } = { 'pc': RunScript.prototype.cmdPC, 'info': RunScript.prototype.cmdInfo, 'reset': RunScript.prototype.cmdReset, + 'back': RunScript.prototype.cmdBack, + 'seek': RunScript.prototype.cmdSeek, + 'rewind': RunScript.prototype.cmdRewind, + 'rbreak': RunScript.prototype.cmdReverseBreak, + 'now': RunScript.prototype.cmdNow, 'echo': RunScript.prototype.cmdEcho, }; diff --git a/test/unit/testrunscript.ts b/test/unit/testrunscript.ts new file mode 100644 index 00000000..ec70a8f5 --- /dev/null +++ b/test/unit/testrunscript.ts @@ -0,0 +1,89 @@ +import assert from "assert"; +import * as fs from "fs"; +import { describe, it } from "mocha"; +import { hashState } from "../../src/common/statehash"; +import { formatTimestamp } from "../../src/common/timeline"; +import { hex } from "../../src/common/util"; +import { EmuTarget, loadPlatform } from "../../src/tools/emutarget"; +import { RunScript } from "../../src/tools/runscript"; + +// The run-script commands that move backwards in time: back, seek, rewind, +// rbreak, now. They drive EmuCore's timeline, so going back and forward again +// has to land on the same state. + +async function open() { + const t = await loadPlatform('mw8080bw'); + await t.start(); + await t.loadROM(new Uint8Array(fs.readFileSync('test/roms/mw8080bw/game2.c.rom'))); + let out = ''; + const script = new RunScript(t, (s: string) => { out += s; }); + return { t, script, output: () => { const s = out; out = ''; return s; } }; +} + +function stateOf(t: EmuTarget) { return hashState(t.machine.saveState()); } + +describe('RunScript time travel', function () { + this.timeout(60000); + + it('labels output with the frame and step', async function () { + const { script, output } = await open(); + script.run('run 3; step 2'); + const out = output(); + assert.match(out, /^\[3:0\] ran 3 frames$/m); + assert.match(out, /^\[3:2\] PC=\$[0-9A-F]{4}$/m); + }); + + it('steps back to where it stepped from', async function () { + const { t, script, output } = await open(); + script.run('run 5; step 1'); + const pc = t.getPC(); + const state = stateOf(t); + script.run('step 7; back 7'); + assert.strictEqual(t.getPC(), pc); + assert.strictEqual(stateOf(t), state); + assert.match(output(), new RegExp(`PC=\\$${hex(pc, 4)}`)); + }); + + it('seeks to a frame and step, and back to the present', async function () { + const { t, script, output } = await open(); + script.run('run 12'); + const present = stateOf(t); + script.run('seek 4:10; now'); + assert.strictEqual(formatTimestamp(t.now()), '4:10'); + assert.match(output(), /at 4:10 \(past; recorded 0:0 to 12:0\)/); + script.run('seek 12'); + assert.strictEqual(stateOf(t), present); + assert.ok(!t.isInPast()); + }); + + it('rewinds whole frames, to a frame start', async function () { + const { t, script } = await open(); + script.run('run 10; rewind 3'); + assert.strictEqual(formatTimestamp(t.now()), '7:0'); + script.run('step 5; rewind'); + assert.strictEqual(formatTimestamp(t.now()), '7:0'); + }); + + it('runs back to the last time the PC was somewhere', async function () { + const { t, script, output } = await open(); + script.run('run 10; step 1'); + const pc = t.getPC(); + const when = t.now(); + script.run('run 5'); + output(); + script.run(`rbreak $${hex(pc, 4)}`); + assert.match(output(), /HIT/); + assert.strictEqual(t.getPC(), pc); + assert.ok(t.isInPast()); + // the last visit, which is no earlier than the one we saw + assert.ok(t.now().frame >= when.frame); + }); + + it('reports a miss and stays put', async function () { + const { t, script, output } = await open(); + script.run('run 5'); + script.run('rbreak $FFFF'); + assert.match(output(), /MISSED/); + assert.strictEqual(formatTimestamp(t.now()), '5:0'); + }); +});