From 5c54c12df2888c2b76501a8094e368b4fc646cd1 Mon Sep 17 00:00:00 2001 From: macabeus Date: Sun, 2 Aug 2026 00:17:08 +0100 Subject: [PATCH] webapp: load a replay script from the file system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recorder panel could record and replay a script but never open one, so a script saved to disk was write-only. This adds a Load button backed by a new script-parser: the exact inverse of the serializer, sharing its button vocabulary (BUTTON_NAMES / buttonsFromString derive from the same BIT_TO_BUTTON_NAME table, so a name can't exist in only one direction). Parsing is strict on purpose: an unsupported statement is a per-line error shown in the panel, not a silent skip — a replay that dropped a line would "work" and desync. Comments are blanked in place so error line numbers survive, and a round-trip property test pins serialize∘parse = identity. Save and the "From recording start" origin are disabled while no snapshot exists, since both are meaningless before recording begins. docs/examples/klonoa-hidden-minigame.mjs is a real script to load: it boots Klonoa: Empire of Dreams holding A+B+RIGHT+L+R(+UP) and plays a few dodges of the hidden boot-menu minigame. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/__tests__/script-parser.spec.ts | 199 +++++++++++++ .../src/pages/play/ScriptRecorderPanel.tsx | 118 +++++++- apps/webapp/src/scripting/index.ts | 5 + apps/webapp/src/scripting/script-parser.ts | 267 ++++++++++++++++++ .../webapp/src/scripting/script-serializer.ts | 26 ++ docs/examples/klonoa-hidden-minigame.mjs | 39 +++ 6 files changed, 650 insertions(+), 4 deletions(-) create mode 100644 apps/webapp/src/__tests__/script-parser.spec.ts create mode 100644 apps/webapp/src/scripting/script-parser.ts create mode 100644 docs/examples/klonoa-hidden-minigame.mjs diff --git a/apps/webapp/src/__tests__/script-parser.spec.ts b/apps/webapp/src/__tests__/script-parser.spec.ts new file mode 100644 index 0000000..866c833 --- /dev/null +++ b/apps/webapp/src/__tests__/script-parser.spec.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; + +import { type InputSegment, buttonsFromString, parseScript, serializeToScript } from '../scripting'; + +/** Unwrap a successful parse, failing the test with the errors otherwise. */ +function segmentsOf(text: string): InputSegment[] { + const result = parseScript(text); + if (!result.ok) { + throw new Error(`expected parse to succeed, got: ${JSON.stringify(result.errors)}`); + } + return result.segments; +} + +describe('buttonsFromString', () => { + it('inverts buttonsToString for single and combined buttons', () => { + expect(buttonsFromString('a')).toEqual([0]); + expect(buttonsFromString('right')).toEqual([4]); + expect(buttonsFromString('a+right')).toEqual([0, 4]); + }); + + it('sorts by bit position regardless of written order', () => { + expect(buttonsFromString('right+a')).toEqual([0, 4]); + expect(buttonsFromString('down+start+a')).toEqual([0, 3, 7]); + }); + + it('is case-insensitive and tolerates spaces around +', () => { + expect(buttonsFromString('A + Right')).toEqual([0, 4]); + }); + + it('returns null for an unknown name', () => { + expect(buttonsFromString('triangle')).toBeNull(); + expect(buttonsFromString('a+triangle')).toBeNull(); + }); +}); + +describe('parseScript — press/wait form', () => { + it('parses a bare press as a single frame', () => { + expect(segmentsOf("await press('a');")).toEqual([{ buttons: [0], frames: 1 }]); + }); + + it('parses press with a hold', () => { + expect(segmentsOf("await press('a', { hold: 5 });")).toEqual([{ buttons: [0], frames: 5 }]); + }); + + it('parses wait as an empty-button segment', () => { + expect(segmentsOf('await wait({ frames: 10 });')).toEqual([{ buttons: [], frames: 10 }]); + }); + + it('parses a combined-button press', () => { + expect(segmentsOf("press('a+b+right+l+r', { hold: 3 });")).toEqual([{ buttons: [0, 1, 4, 8, 9], frames: 3 }]); + }); + + it('keeps statement order across many lines', () => { + const text = ['await wait({ frames: 10 });', "await press('a');", "await press('start', { hold: 2 });"].join('\n'); + expect(segmentsOf(text)).toEqual([ + { buttons: [], frames: 10 }, + { buttons: [0], frames: 1 }, + { buttons: [3], frames: 2 }, + ]); + }); +}); + +describe('parseScript — pressSequence form', () => { + it('parses a multi-line pressSequence', () => { + const text = ['await pressSequence([', " ['a+right', 5],", ' [null, 10],', ']);'].join('\n'); + expect(segmentsOf(text)).toEqual([ + { buttons: [0, 4], frames: 5 }, + { buttons: [], frames: 10 }, + ]); + }); + + it('parses a single-line pressSequence', () => { + expect(segmentsOf("await pressSequence([['a', 1], [null, 2]]);")).toEqual([ + { buttons: [0], frames: 1 }, + { buttons: [], frames: 2 }, + ]); + }); + + it('parses several pressSequence calls in order', () => { + const text = ["pressSequence([['a', 1]]);", "pressSequence([['b', 2]]);"].join('\n'); + expect(segmentsOf(text)).toEqual([ + { buttons: [0], frames: 1 }, + { buttons: [1], frames: 2 }, + ]); + }); +}); + +describe('parseScript — hand-editing tolerance', () => { + it('accepts missing await and missing semicolons', () => { + expect(segmentsOf("press('a')")).toEqual([{ buttons: [0], frames: 1 }]); + }); + + it('accepts double quotes', () => { + expect(segmentsOf('await press("a");')).toEqual([{ buttons: [0], frames: 1 }]); + }); + + it('ignores line and block comments and blank lines', () => { + const text = ['// boot combo', '/* several', ' lines */', '', "await press('a'); // trailing", ''].join('\n'); + expect(segmentsOf(text)).toEqual([{ buttons: [0], frames: 1 }]); + }); + + it('does not treat a // inside a string as a comment', () => { + const result = parseScript("await press('a//b');"); + expect(result.ok).toBe(false); + }); + + it('parses the empty-recording placeholder as zero segments', () => { + expect(segmentsOf('// No inputs recorded')).toEqual([]); + }); +}); + +describe('parseScript — errors', () => { + it('reports unknown buttons with a line number', () => { + const result = parseScript(["await press('a');", "await press('triangle');"].join('\n')); + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.errors).toHaveLength(1); + expect(result.errors[0]!.line).toBe(2); + expect(result.errors[0]!.message).toContain('triangle'); + }); + + it('rejects unsupported commands instead of skipping them', () => { + const result = parseScript("await screenshot({ name: 'x' });"); + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.errors[0]!.message).toContain('unsupported statement'); + }); + + it('rejects a malformed pressSequence entry rather than dropping it', () => { + const result = parseScript("await pressSequence([['a', 1], ['b']]);"); + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.errors[0]!.message).toContain('malformed'); + }); + + it('rejects zero-frame durations', () => { + expect(parseScript('await wait({ frames: 0 });').ok).toBe(false); + }); + + it('reports every bad line, not just the first', () => { + const result = parseScript(["await press('triangle');", 'await frobnicate();'].join('\n')); + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.errors.map((e) => e.line)).toEqual([1, 2]); + }); +}); + +describe('parseScript round-trips serializeToScript', () => { + const cases: Record = { + 'simple form (single buttons only)': [ + { buttons: [], frames: 10 }, + { buttons: [0], frames: 1 }, + { buttons: [], frames: 5 }, + { buttons: [3], frames: 3 }, + ], + 'pressSequence form (has a combined press)': [ + { buttons: [0, 4], frames: 5 }, + { buttons: [], frames: 10 }, + { buttons: [0, 1, 4, 8, 9], frames: 2 }, + ], + 'single segment': [{ buttons: [9], frames: 42 }], + }; + + for (const [name, segments] of Object.entries(cases)) { + it(`recovers the original segments — ${name}`, () => { + expect(segmentsOf(serializeToScript(segments))).toEqual(segments); + }); + } +}); + +describe('parseScript — segment line mapping', () => { + it('maps each pressSequence entry to its own line', () => { + const text = ['// header', 'await pressSequence([', " ['a', 5],", ' [null, 10],', ']);'].join('\n'); + const result = parseScript(text); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.segmentLines).toEqual([2, 3]); + }); + + it('maps press/wait statements to their own lines, skipping comments', () => { + const text = ['// header', '', "await press('a');", '// note', 'await wait({ frames: 4 });'].join('\n'); + const result = parseScript(text); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.segmentLines).toEqual([2, 4]); + }); +}); diff --git a/apps/webapp/src/pages/play/ScriptRecorderPanel.tsx b/apps/webapp/src/pages/play/ScriptRecorderPanel.tsx index 24267d5..db6e7a8 100644 --- a/apps/webapp/src/pages/play/ScriptRecorderPanel.tsx +++ b/apps/webapp/src/pages/play/ScriptRecorderPanel.tsx @@ -9,10 +9,12 @@ import { type ReplayDebugState, type ReplayMode, type ScriptMeta, + type ScriptParseError, type ScriptWithMapping, deleteScript, listScriptsByRom, loadScriptRecord, + parseScript, replayVisual, saveScript, serializeToScript, @@ -54,7 +56,10 @@ export function ScriptRecorderPanel({ const [replayMode, setReplayMode] = useState('from-start'); const [replayDebug, setReplayDebug] = useState(null); const [copied, setCopied] = useState(false); + const [loadedFileName, setLoadedFileName] = useState(null); + const [parseErrors, setParseErrors] = useState(null); const replayCancelRef = useRef<(() => void) | null>(null); + const fileInputRef = useRef(null); const saveCountRef = useRef(0); // Compute ROM hash @@ -93,9 +98,55 @@ export function ScriptRecorderPanel({ setSnapshot(snap); setScriptMapping(null); setSegments(null); + setLoadedFileName(null); + setParseErrors(null); onStartRecording(); }, [emulator, onStartRecording]); + /** + * Load a script from disk and make it replayable. The file's own text is + * what gets shown — parseScript reports the line each segment came from, so + * replay can still highlight it without normalising the user's formatting. + * + * A loaded script carries no snapshot, so it replays from the current state. + */ + const handleLoadFile = useCallback(async (file: File) => { + const text = await file.text(); + const result = parseScript(text); + + if (!result.ok) { + setParseErrors(result.errors); + setScriptMapping(null); + setSegments(null); + setLoadedFileName(file.name); + return; + } + + setParseErrors(null); + setLoadedFileName(file.name); + setSnapshot(null); + setReplayMode('from-current'); + setReplayDebug(null); + setSegments(result.segments); + setScriptMapping({ + text, + lines: text.split('\n'), + segmentToLine: result.segmentLines, + }); + }, []); + + const handleFileChange = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + handleLoadFile(file); + } + // Reset so picking the same file again still fires a change event. + event.target.value = ''; + }, + [handleLoadFile], + ); + const handleStopRecording = useCallback(() => { onStopRecording(); const segs = recorder.segments; @@ -108,6 +159,8 @@ export function ScriptRecorderPanel({ setSegments(null); setSnapshot(null); setReplayDebug(null); + setLoadedFileName(null); + setParseErrors(null); }, []); const handleCopy = useCallback(() => { @@ -224,6 +277,35 @@ export function ScriptRecorderPanel({ )} + {!isRecording && ( + <> + + + + )} + {isRecording && (
{formatTime(recorder.totalFrames)} @@ -257,6 +339,24 @@ export function ScriptRecorderPanel({
+ {/* Parse errors from a loaded file */} + {parseErrors && ( +
+
+
+ Could not load {loadedFileName ?? 'script'} +
+
+
+ {parseErrors.map((err) => ( +
+ line {err.line}: {err.message} +
+ ))} +
+
+ )} + {/* Live Preview (during recording) */} {isRecording && livePreview && (
@@ -271,8 +371,10 @@ export function ScriptRecorderPanel({ {isStopped && !isRecording && scriptMapping && (
-
Recorded Script
-
{segments?.length ?? 0} segments
+
+ {loadedFileName ? `Loaded — ${loadedFileName}` : 'Recorded Script'} +
+
{segments?.length ?? 0} segments
@@ -293,7 +395,9 @@ export function ScriptRecorderPanel({ @@ -309,12 +413,18 @@ export function ScriptRecorderPanel({
-