runscript: back, rewind, seek, rbreak, now; label output [frame:step]

This commit is contained in:
Steven Hugg committed 2026-09-28 18:57:04 +02:00
1 parent e5067d48fe
commit 9cfb4865ce
2 files changed
+163 -1

No files matched your search

+74 -1
View File
@@ -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,
};
+89
View File
@@ -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');
});
});