From ae9f623b4985482429d66ac1a8b9d901b0da6a67 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 26 Aug 2026 21:51:41 +0200 Subject: [PATCH] feat(replay): add script runner --- src/Chip8.Replay/ScriptException.cs | 33 ++++++++ src/Chip8.Replay/ScriptRunner.cs | 119 ++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/Chip8.Replay/ScriptException.cs create mode 100644 src/Chip8.Replay/ScriptRunner.cs diff --git a/src/Chip8.Replay/ScriptException.cs b/src/Chip8.Replay/ScriptException.cs new file mode 100644 index 0000000..810203a --- /dev/null +++ b/src/Chip8.Replay/ScriptException.cs @@ -0,0 +1,33 @@ +using System; + +namespace Chip8.Replay; + +public class ScriptException : Exception +{ + public int line { get; } + + public ScriptException(int line, string message) + : base("line " + line + ": " + message) + { + this.line = line; + } +} + +public sealed class ScriptParseException : ScriptException +{ + public ScriptParseException(int line, string message) + : base(line, message) + { + } +} + +public sealed class ScriptAssertionException : ScriptException +{ + public int frame { get; } + + public ScriptAssertionException(int line, int frame, string message) + : base(line, "frame " + frame + ": " + message) + { + this.frame = frame; + } +} diff --git a/src/Chip8.Replay/ScriptRunner.cs b/src/Chip8.Replay/ScriptRunner.cs new file mode 100644 index 0000000..5b4ed87 --- /dev/null +++ b/src/Chip8.Replay/ScriptRunner.cs @@ -0,0 +1,119 @@ +using Chip8.Core; + +namespace Chip8.Replay; + +public sealed class ScriptRunner +{ + public Machine machine { get; } + public int instructionsPerFrame { get; } + public int currentFrame { get; private set; } + public KeyMask heldKeys { get; private set; } + public int assertionsPassed { get; private set; } + + readonly InputMovie? record; + int activeLine; + + public ScriptRunner(Machine machine, int instructionsPerFrame, InputMovie? record = null) + { + this.machine = machine; + this.instructionsPerFrame = instructionsPerFrame; + this.record = record; + heldKeys = KeyMask.empty; + } + + public static ScriptRunner forRom(byte[] rom, Platform platform, Quirks? quirks, int instructionsPerFrame, int? randomSeed = 1) + { + var machine = new Machine(quirks, randomSeed, platform); + machine.loadRom(rom); + return new ScriptRunner(machine, instructionsPerFrame); + } + + public void run(InputScript script) + { + foreach (ScriptCommand command in script.commands) + { + activeLine = command.sourceLine; + command.execute(this); + } + } + + public void press(int key) + { + heldKeys = heldKeys.withPressed(key); + } + + public void release(int key) + { + heldKeys = heldKeys.withReleased(key); + } + + public void advanceFrames(int count) + { + for (int frame = 0; frame < count; frame++) + { + heldKeys.applyTo(machine.keypad); + record?.recordFrame(currentFrame, heldKeys); + machine.runFrame(instructionsPerFrame); + currentFrame++; + } + + record?.extendLength(currentFrame); + } + + public void advanceTo(int frame) + { + if (frame < currentFrame) + { + throw new ScriptException(activeLine, + "cannot schedule frame " + frame + ", timeline is already at frame " + currentFrame); + } + advanceFrames(frame - currentFrame); + } + + public void checkCompare(ScriptCommand command, string name, int actual, CompareOp op, int expected) + { + if (CompareOps.evaluate(op, actual, expected)) + { + assertionsPassed++; + return; + } + + throw new ScriptAssertionException(command.sourceLine, currentFrame, + "expected " + name + " " + CompareOps.symbol(op) + " " + expected + " but was " + actual); + } + + public void checkPixel(AssertPixelCommand command) + { + FramebufferSnapshot snapshot = FramebufferSnapshot.capture(machine); + if (command.x < 0 || command.y < 0 || command.x >= snapshot.width || command.y >= snapshot.height) + { + throw new ScriptAssertionException(command.sourceLine, currentFrame, + "pixel (" + command.x + "," + command.y + ") is outside the " + + snapshot.width + "x" + snapshot.height + " framebuffer"); + } + + bool lit = snapshot.isLit(command.x, command.y); + if (lit == command.expectedLit) + { + assertionsPassed++; + return; + } + + throw new ScriptAssertionException(command.sourceLine, currentFrame, + "expected pixel (" + command.x + "," + command.y + ") to be " + + (command.expectedLit ? "on" : "off") + " but it was " + (lit ? "on" : "off")); + } + + public void checkExited(AssertExitedCommand command) + { + if (machine.exited == command.expectedExited) + { + assertionsPassed++; + return; + } + + throw new ScriptAssertionException(command.sourceLine, currentFrame, + "expected machine to be " + (command.expectedExited ? "exited" : "running") + + " but exited=" + machine.exited); + } +}