mirror of
https://github.com/sehugg/8bitworkshop.git
synced 2026-10-25 15:05:25 +00:00
280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
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 { GalaxianScrambleMachine } from "../../src/machine/galaxian";
|
|
import { Devel6502 } from "../../src/machine/devel";
|
|
import { EmuHalt } from "../../src/common/emu";
|
|
import { hashState as hashAnyState } from "../../src/common/statehash";
|
|
import { NullProbe } from "../../src/common/devices";
|
|
import { History } from "../../src/common/history";
|
|
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)));
|
|
});
|
|
});
|
|
|
|
// 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)}`);
|
|
});
|
|
});
|
|
|
|
// A trap stops the CPU loop, not the frame: galaxian's advanceFrame() still
|
|
// advances its graphics, decrements the watchdog and raises an NMI after the
|
|
// loop exits. A core stopped mid-frame must show the state at the stop, not
|
|
// the state after that tail ran.
|
|
describe('MachineCore break state', function () {
|
|
|
|
function newGalaxian() {
|
|
const m = new GalaxianScrambleMachine();
|
|
m.connectVideo(new Uint32Array(m.canvasWidth * m.numVisibleScanlines));
|
|
m.loadROM(new Uint8Array(fs.readFileSync('./test/roms/galaxian-scramble/shoot2.c.rom')));
|
|
m.reset();
|
|
return { m, core: new MachineCore(m) };
|
|
}
|
|
|
|
it('shows the state at the trap, not after the frame tail', function () {
|
|
const { m, core } = newGalaxian();
|
|
core.runUntil(timestamp(5, 0));
|
|
var n = 0;
|
|
var atTrap: number = null;
|
|
const r = core.runUntil(timestamp(6, 0), () => {
|
|
if (n++ < 500) return false;
|
|
atTrap = hashAnyState(m.saveState());
|
|
return true;
|
|
});
|
|
assert.ok(r.trapped);
|
|
assert.strictEqual(hashAnyState(m.saveState()), atTrap);
|
|
});
|
|
|
|
it('shows the state at the target step when stopping at one', function () {
|
|
// the trap is not consulted at the target step itself, so capture the
|
|
// expected state from a second run that goes one step further
|
|
const ref = newGalaxian();
|
|
ref.core.runUntil(timestamp(5, 0));
|
|
var atStep: number = null;
|
|
var n = 0;
|
|
ref.core.runUntil(timestamp(5, 501), () => {
|
|
if (n++ === 500) atStep = hashAnyState(ref.m.saveState());
|
|
return false;
|
|
});
|
|
assert.notStrictEqual(atStep, null);
|
|
|
|
const { m, core } = newGalaxian();
|
|
core.runUntil(timestamp(5, 0));
|
|
core.runUntil(timestamp(5, 500));
|
|
assert.ok(timestampsEqual(core.now(), timestamp(5, 500)), formatTimestamp(core.now()));
|
|
assert.strictEqual(hashAnyState(m.saveState()), atStep);
|
|
});
|
|
});
|
|
|
|
// A 6502 that counts $11 up to 100 (about 12 frames at 1 MHz), then executes
|
|
// KIL, which MOS6502 throws as EmuHalt from the middle of the instruction.
|
|
describe('MachineCore on a 6502 that halts', function () {
|
|
|
|
const KIL_ADDR = 0x800c;
|
|
|
|
function newDevel() {
|
|
const rom = new Uint8Array(0x8000);
|
|
rom.set([
|
|
0xe6, 0x10, // 8000 loop: inc $10
|
|
0xd0, 0xfc, // 8002 bne loop
|
|
0xe6, 0x11, // 8004 inc $11
|
|
0xa5, 0x11, // 8006 lda $11
|
|
0xc9, 100, // 8008 cmp #100
|
|
0xd0, 0xf4, // 800a bne loop
|
|
0x02, // 800c kil
|
|
]);
|
|
rom[0x7ffc] = 0x00; rom[0x7ffd] = 0x80;
|
|
const m = new Devel6502();
|
|
m.loadROM(rom);
|
|
m.reset();
|
|
return { m, core: new MachineCore(m) };
|
|
}
|
|
|
|
it('parks on the KIL instruction, whether metered or not', function () {
|
|
const a = newDevel();
|
|
const ra = a.core.runUntil(timestamp(100, 0));
|
|
assert.ok(ra.halt instanceof EmuHalt, 'expected a halt');
|
|
assert.ok(ra.at.frame > 5, formatTimestamp(ra.at));
|
|
assert.strictEqual(a.m.cpu.getPC(), KIL_ADDR);
|
|
assert.strictEqual(a.m.ram[0x11], 100);
|
|
|
|
const b = newDevel();
|
|
const rb = b.core.runUntil(timestamp(100, 0), () => false);
|
|
assert.ok(rb.halt instanceof EmuHalt);
|
|
assert.ok(timestampsEqual(ra.at, rb.at), `${formatTimestamp(ra.at)} != ${formatTimestamp(rb.at)}`);
|
|
assert.strictEqual(hashAnyState(a.m.saveState()), hashAnyState(b.m.saveState()));
|
|
});
|
|
|
|
it('records to the halt and rewinds to the step before it', function () {
|
|
const { m, core } = newDevel();
|
|
const hist = new History(core, { checkpointInterval: 4 });
|
|
var r;
|
|
for (var i = 0; i < 100 && !r?.halt; i++) r = hist.recordFrame();
|
|
assert.ok(r.halt instanceof EmuHalt, 'expected a halt');
|
|
const haltAt = hist.last();
|
|
assert.strictEqual(m.cpu.getPC(), KIL_ADDR);
|
|
// a 6502 steps by clocks, so the previous instruction is the last stable
|
|
// step before now: the bne that fell through to the KIL
|
|
const prev = hist.findLast(() => m.cpu.isStable(), hist.first(), hist.now());
|
|
assert.ok(prev, 'no previous instruction');
|
|
assert.strictEqual(m.cpu.getPC(), 0x800a);
|
|
// and forward to the present again, without halting
|
|
assert.strictEqual(hist.seek(haltAt).halt, undefined);
|
|
assert.strictEqual(m.cpu.getPC(), KIL_ADDR);
|
|
});
|
|
});
|