@henols/c64-re-tools 1.1.0 → 2.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +190 -37
- package/distribution/package.ts +36 -0
- package/distribution/plugin.ts +120 -0
- package/package.json +34 -40
- package/skills/c64-assembler/SKILL.md +73 -0
- package/skills/c64-assembler/scripts/assemble.ts +103 -0
- package/skills/c64-basic/SKILL.md +67 -0
- package/skills/c64-basic/scripts/basic.ts +46 -0
- package/skills/c64-disk/SKILL.md +79 -0
- package/skills/c64-disk/scripts/disk.ts +65 -0
- package/skills/c64-emulator/SKILL.md +124 -0
- package/skills/c64-knowledge/SKILL.md +102 -0
- package/skills/c64-knowledge/scripts/knowledge.ts +257 -0
- package/skills/c64-memory-map/SKILL.md +76 -0
- package/skills/c64-memory-map/scripts/memmap.ts +81 -0
- package/skills/c64-memory-map/scripts/platform.ts +424 -0
- package/skills/c64-provenance/SKILL.md +76 -0
- package/skills/c64-provenance/scripts/compare.ts +92 -0
- package/skills/c64-provenance/scripts/provenance.ts +83 -0
- package/skills/c64-reverse-engineering/SKILL.md +90 -0
- package/skills/c64-static-analysis/SKILL.md +96 -0
- package/skills/c64-static-analysis/scripts/analyze.ts +175 -0
- package/skills/c64-static-analysis/scripts/findings.ts +73 -0
- package/skills/c64-testing/SKILL.md +79 -0
- package/skills/c64-testing/references/scenario-format.md +76 -0
- package/skills/c64-testing/scripts/checklist.ts +51 -0
- package/skills/c64-testing/scripts/scenario.ts +473 -0
- package/skills/c64-testing/scripts/test.ts +55 -0
- package/skills/c64-unpacker/SKILL.md +82 -0
- package/skills/c64-unpacker/scripts/evidence.ts +135 -0
- package/skills/c64-unpacker/scripts/unpack.ts +216 -0
- package/src/c64.ts +281 -0
- package/src/cli/ap-sdk.ts +44 -0
- package/src/cli/cleanup.ts +194 -0
- package/src/cli/main.ts +122 -0
- package/src/cli/status.ts +34 -0
- package/src/host/main.ts +121 -0
- package/src/host/server.ts +409 -0
- package/src/host/tools/c1541.ts +255 -0
- package/src/host/tools/index.ts +31 -0
- package/src/host/tools/petcat.ts +70 -0
- package/src/host/tools/status.ts +30 -0
- package/src/host/tools/version.ts +41 -0
- package/src/host/vice/adapter.ts +761 -0
- package/src/host/vice/binary-monitor.ts +389 -0
- package/src/host/vice/chips.ts +108 -0
- package/src/host/vice/connect.ts +34 -0
- package/src/host/vice/process.ts +402 -0
- package/src/host/vice/screen.ts +183 -0
- package/src/host/vice/session.ts +1316 -0
- package/src/host/vice/text-monitor.ts +223 -0
- package/src/host-client/connect.ts +241 -0
- package/src/host-client/tools.ts +89 -0
- package/src/host-client/transfer.ts +100 -0
- package/src/host-client/vice-session.ts +254 -0
- package/src/knowledge/database.ts +132 -0
- package/src/knowledge/history.ts +128 -0
- package/src/knowledge/import.ts +344 -0
- package/src/knowledge/read.ts +191 -0
- package/src/knowledge/schema.ts +140 -0
- package/src/knowledge/write.ts +348 -0
- package/src/mcp/main.ts +109 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/tools/debug.ts +237 -0
- package/src/mcp/tools/execution.ts +116 -0
- package/src/mcp/tools/input.ts +69 -0
- package/src/mcp/tools/machine.ts +97 -0
- package/src/mcp/tools/media.ts +106 -0
- package/src/mcp/tools/memory.ts +203 -0
- package/src/mcp/tools/video.ts +260 -0
- package/src/native/acme.ts +116 -0
- package/src/native/discover.ts +141 -0
- package/src/native/dxa.ts +166 -0
- package/src/native/ghidra/analyze.ts +86 -0
- package/src/native/ghidra/index.ts +157 -0
- package/src/native/ghidra/language/c64rt_6510.cspec +34 -0
- package/src/native/ghidra/language/c64rt_6510.ldefs +15 -0
- package/src/native/ghidra/language/c64rt_6510.pspec +15 -0
- package/src/native/ghidra/language/c64rt_6510.slaspec +596 -0
- package/src/native/ghidra/scripts/C64Export.java +242 -0
- package/src/native/ghidra/scripts/C64Prepare.java +60 -0
- package/src/native/local.ts +34 -0
- package/src/native/processes.ts +350 -0
- package/src/native/run.ts +172 -0
- package/src/native/staging.ts +83 -0
- package/src/native/status.ts +101 -0
- package/src/native/types.ts +228 -0
- package/src/native/watchdog.ts +78 -0
- package/src/project.ts +39 -0
- package/src/protocol/framing.ts +132 -0
- package/src/protocol/messages.ts +252 -0
- package/src/protocol/tools.ts +247 -0
- package/src/protocol/vice.ts +1235 -0
- package/src/script.ts +59 -0
- package/src/time.ts +15 -0
- package/src/trace.ts +162 -0
- package/THIRD-PARTY-NOTICES.md +0 -26
- package/bin/cli.mjs +0 -283
- package/skills/acme-build/SKILL.md +0 -260
- package/skills/acme-build/scripts/acme.mjs +0 -341
- package/skills/acme-build/template.a +0 -53
- package/skills/c64-disk-access/SKILL.md +0 -159
- package/skills/c64-disk-access/scripts/c1541.mjs +0 -569
- package/skills/c64-memory-mapping/SKILL.md +0 -631
- package/skills/c64-memory-mapping/memmap.json +0 -8800
- package/skills/c64-memory-mapping/scripts/driver.mjs +0 -553
- package/skills/c64-petcat/SKILL.md +0 -87
- package/skills/c64-petcat/scripts/petcat.mjs +0 -221
- package/skills/c64-program-recon/SKILL.md +0 -706
- package/skills/c64-program-recon/references/control-flow.md +0 -180
- package/skills/c64-program-recon/references/graphics.md +0 -79
- package/skills/c64-program-recon/references/observation-hazards.md +0 -143
- package/skills/c64-program-recon/references/reconstruction.md +0 -133
- package/skills/c64-program-recon/references/sound-and-input.md +0 -74
- package/skills/c64-program-recon/references/tool-selection.md +0 -78
- package/skills/c64-program-recon/scripts/derive.mjs +0 -364
- package/skills/c64-program-recon/scripts/packer-finding.mjs +0 -709
- package/skills/c64-program-recon/templates/memory-map.template.md +0 -101
- package/skills/c64-provenance-diff/SKILL.md +0 -292
- package/skills/c64-provenance-diff/scripts/diff-images.mjs +0 -984
- package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +0 -395
- package/skills/c64-ram-capture/RELEASES.json.example +0 -17
- package/skills/c64-ram-capture/SKILL.md +0 -474
- package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +0 -525
- package/skills/c64-ram-capture/scripts/compare.mjs +0 -258
- package/skills/c64-ram-capture/scripts/derive-transients.mjs +0 -575
- package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +0 -317
- package/skills/c64-ram-capture/scripts/mcp-module.mjs +0 -174
- package/skills/c64-ram-capture/scripts/project-paths.mjs +0 -81
- package/skills/c64-ram-capture/scripts/releases.mjs +0 -109
- package/skills/c64-ram-capture/scripts/vsf-slice.mjs +0 -147
- package/skills/c64-ram-capture/scripts/watch-loads.mjs +0 -581
- package/skills/c64-ram-capture/templates/capture-record.template.md +0 -99
- package/skills/c64-ram-capture/transients/README.md +0 -137
- package/skills/routine-queue-walker/SKILL.md +0 -394
- package/skills/routine-queue-walker/scripts/completeness-report.mjs +0 -465
- package/skills/vice-wedge-triage/SKILL.md +0 -269
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Scenario format
|
|
2
|
+
|
|
3
|
+
A scenario is one JSON object in a project file.
|
|
4
|
+
|
|
5
|
+
```json
|
|
6
|
+
{
|
|
7
|
+
"name": "player moves left",
|
|
8
|
+
"original": { "program": "original/game.prg", "symbols": "knowledge" },
|
|
9
|
+
"rebuild": { "program": "build/game.prg", "symbols": "build/game.json" },
|
|
10
|
+
"steps": [
|
|
11
|
+
{ "frames": 150 },
|
|
12
|
+
{ "load": true },
|
|
13
|
+
{ "registers": { "pc": "start" } },
|
|
14
|
+
{ "runUntil": { "at": "main_loop" }, "timeoutFrames": 600 },
|
|
15
|
+
{ "joystick": { "port": 2, "direction": "left" } },
|
|
16
|
+
{ "frames": 10 },
|
|
17
|
+
{ "observe": "moved", "memory": ["player_x", { "at": "$0400", "size": 40 }], "screen": {} }
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Subjects
|
|
23
|
+
|
|
24
|
+
`original` and `rebuild` each have:
|
|
25
|
+
|
|
26
|
+
- `program`: a PRG file, relative to the project directory.
|
|
27
|
+
- `symbols`: where the names come from:
|
|
28
|
+
- `knowledge`: the project knowledge.
|
|
29
|
+
- A JSON file: the output of the c64-assembler skill, or an object of names and `$` addresses.
|
|
30
|
+
- `none`: no names.
|
|
31
|
+
|
|
32
|
+
Optional: `videoStandard`, `pal` (default) or `ntsc`.
|
|
33
|
+
|
|
34
|
+
## Steps
|
|
35
|
+
|
|
36
|
+
Before the first step of each side, the script does a hard reset. The CPU stops at the reset vector. Then the script does the steps in sequence. Each step has one of these keys:
|
|
37
|
+
|
|
38
|
+
| Step | Effect |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `{ "reset": "hard" }` | Reset the C64 (`hard` or `soft`). The CPU stops at the reset vector. |
|
|
41
|
+
| `{ "frames": 150 }` | Run for an exact number of frames, from 1 to 10000. Use it after a reset to start the C64. |
|
|
42
|
+
| `{ "load": true }` | Load the program of the side into memory. The CPU stays stopped. |
|
|
43
|
+
| `{ "autostart": true }` | Load and start the program of the side as VICE autostart does. |
|
|
44
|
+
| `{ "write": { "at": "seed", "bytes": [1, 2] } }` | Write bytes into memory: 1 to 4096 numbers from 0 to 255. |
|
|
45
|
+
| `{ "registers": { "pc": "start", "a": 0 } }` | Set registers: `pc`, `a`, `x`, `y` or `sp`. A value is a number or a name. |
|
|
46
|
+
| `{ "joystick": { "port": 2, "direction": "left", "fire": true } }` | Set a joystick. The joystick stays in this state until the next joystick step. |
|
|
47
|
+
| `{ "type": "RUN\n" }` | Type text on the keyboard, at most 1024 keys. |
|
|
48
|
+
| `{ "runUntil": { "at": "main_loop" }, "timeoutFrames": 600 }` | Run until the CPU gets to an address. |
|
|
49
|
+
| `{ "runUntil": { "memory": { "at": "game_state", "equals": 2 } }, "timeoutFrames": 600 }` | Run until a byte in memory has a value. |
|
|
50
|
+
| `{ "observe": "moved", ... }` | Record a checkpoint. |
|
|
51
|
+
|
|
52
|
+
The `port` of a joystick is 1 or 2. The `direction` is `center`, `up`, `down`, `left`, `right`, `up-left`, `up-right`, `down-left` or `down-right`.
|
|
53
|
+
|
|
54
|
+
`timeoutFrames` is from 1 to 30000. A `runUntil` that does not get to its target in `timeoutFrames` frames makes the result INCONCLUSIVE.
|
|
55
|
+
|
|
56
|
+
An address is `$` and four hex digits, or a symbol name. Each side finds a name in its own symbols.
|
|
57
|
+
|
|
58
|
+
## Observations
|
|
59
|
+
|
|
60
|
+
An `observe` step has a checkpoint name. A scenario can have each name only one time. The step can have these keys:
|
|
61
|
+
|
|
62
|
+
| Key | What the script compares |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `memory` | A list of names, `$` addresses, or objects with `at`, `size` (1 to 4096, default 1) and `tolerance` (0 to 255, default 0). |
|
|
65
|
+
| `registers` | A list of register names: `pc`, `a`, `x`, `y`, `sp`, `flags`. |
|
|
66
|
+
| `screen` | The screen of the rebuild against the screen of the original. `maxMismatchRatio` (default 0) is the largest permitted part of different pixels. `mask` is a list of rectangles (`x`, `y`, `width`, `height`) that the comparison does not include. |
|
|
67
|
+
| `vicii` | The VIC-II state: mode, screen address, scroll and colors. |
|
|
68
|
+
| `sprites` | A list of sprite numbers from 0 to 7. The script compares their position, color and flags. |
|
|
69
|
+
|
|
70
|
+
A `tolerance` is the largest permitted difference for each byte. A `mask` has at most 64 rectangles. A scenario can observe the screen at most at 64 checkpoints.
|
|
71
|
+
|
|
72
|
+
A `pc` value is an address. Compare `pc` only when both programs have the code at the same address.
|
|
73
|
+
|
|
74
|
+
## Checks
|
|
75
|
+
|
|
76
|
+
The script checks the scenario before it starts the emulator. A step with an unknown key or a wrong value stops the script with `invalid-input`. The message names the step.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The c64-testing playtest checklist: a Markdown list for a human tester,
|
|
2
|
+
// made from the scenarios' checkpoints and the behavior that automated
|
|
3
|
+
// scenarios cannot check. Nothing is stored.
|
|
4
|
+
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
|
|
7
|
+
import { runScript, UsageError } from "#src/script.ts";
|
|
8
|
+
import { readScenario, ScenarioError, type Scenario } from "./scenario.ts";
|
|
9
|
+
|
|
10
|
+
const USAGE = `checklist.ts <scenario.json> [<scenario.json> ...] [--item <text> ...]
|
|
11
|
+
|
|
12
|
+
Prints a Markdown checklist. --item adds an application-specific line.`;
|
|
13
|
+
|
|
14
|
+
/** Behavior that only a person can judge. */
|
|
15
|
+
const ALWAYS = [
|
|
16
|
+
"The controls respond as fast as in the original.",
|
|
17
|
+
"The animation is smooth and has no glitches.",
|
|
18
|
+
"The music and the sound effects sound the same as in the original.",
|
|
19
|
+
"The game flow and the level changes work from start to end.",
|
|
20
|
+
"Nothing unexpected happens outside the tested scenarios.",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function run(argv: string[]): string {
|
|
24
|
+
const { values, positionals } = parseArgs({ args: argv, strict: true, allowPositionals: true, options: { item: { type: "string", multiple: true }, help: { type: "boolean" } } });
|
|
25
|
+
if (values.help) throw new UsageError("");
|
|
26
|
+
if (positionals.length === 0) throw new UsageError("give at least one scenario file");
|
|
27
|
+
const lines = ["# Playtest checklist", ""];
|
|
28
|
+
for (const path of positionals) {
|
|
29
|
+
let scenario: Scenario;
|
|
30
|
+
try {
|
|
31
|
+
scenario = readScenario(path);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error instanceof ScenarioError) throw new UsageError(error.message);
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
lines.push(`## ${scenario.name}`, "");
|
|
37
|
+
for (const step of scenario.steps) if ("observe" in step) lines.push(`- [ ] At "${step.observe}", the rebuild looks and behaves as the original.`);
|
|
38
|
+
lines.push("");
|
|
39
|
+
}
|
|
40
|
+
lines.push("## Application", "");
|
|
41
|
+
for (const item of values.item ?? []) lines.push(`- [ ] ${item}`);
|
|
42
|
+
for (const item of ALWAYS) lines.push(`- [ ] ${item}`);
|
|
43
|
+
return `${lines.join("\n")}\n`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await runScript(
|
|
47
|
+
() => {
|
|
48
|
+
process.stdout.write(run(process.argv.slice(2)));
|
|
49
|
+
},
|
|
50
|
+
{ usage: USAGE },
|
|
51
|
+
);
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// Functional-equivalence scenarios: a small JSON scenario of ordered
|
|
2
|
+
// steps runs on the original and then on the rebuild in one VICE session.
|
|
3
|
+
// Observations name semantic symbols that resolve per side, comparisons are
|
|
4
|
+
// explicit, and the result is PASS, FAIL or INCONCLUSIVE.
|
|
5
|
+
|
|
6
|
+
import { formatC64Address, parseC64Address, textToPetscii } from "#src/c64.ts";
|
|
7
|
+
import {
|
|
8
|
+
JOYSTICK_DIRECTIONS,
|
|
9
|
+
MAX_BASELINES,
|
|
10
|
+
MAX_EXECUTION_COUNT,
|
|
11
|
+
MAX_KEYBOARD_BYTES,
|
|
12
|
+
MAX_MASK_RECTANGLES,
|
|
13
|
+
MAX_MEMORY_READ,
|
|
14
|
+
MAX_MEMORY_WRITE,
|
|
15
|
+
MAX_TIMEOUT_FRAMES,
|
|
16
|
+
WireFailure,
|
|
17
|
+
} from "#src/host-client/tools.ts";
|
|
18
|
+
import { readProjectFile } from "#src/host-client/transfer.ts";
|
|
19
|
+
import type { ViceSessionClient } from "#src/host-client/vice-session.ts";
|
|
20
|
+
import { openForRead } from "#src/knowledge/database.ts";
|
|
21
|
+
import { listSymbols } from "#src/knowledge/read.ts";
|
|
22
|
+
|
|
23
|
+
export class ScenarioError extends Error {}
|
|
24
|
+
|
|
25
|
+
export type Side = "original" | "rebuild";
|
|
26
|
+
export const SIDES: Side[] = ["original", "rebuild"];
|
|
27
|
+
|
|
28
|
+
/** A "$xxxx" address or a symbol name that each side resolves on its own. */
|
|
29
|
+
export type Location = string;
|
|
30
|
+
|
|
31
|
+
export interface MemoryObservation {
|
|
32
|
+
at: Location;
|
|
33
|
+
/** Bytes from `at`; 1 when not given. */
|
|
34
|
+
size?: number;
|
|
35
|
+
/** The largest allowed difference per byte; 0 when not given. */
|
|
36
|
+
tolerance?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ScreenPolicy {
|
|
40
|
+
/** The largest allowed fraction of different pixels; 0 (exact) when not given. */
|
|
41
|
+
maxMismatchRatio?: number;
|
|
42
|
+
/** Rectangles that the comparison ignores, in screen pixels. */
|
|
43
|
+
mask?: Array<{ x: number; y: number; width: number; height: number }>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type Step =
|
|
47
|
+
| { reset: "hard" | "soft" }
|
|
48
|
+
| { load: true }
|
|
49
|
+
| { autostart: true }
|
|
50
|
+
| { write: { at: Location; bytes: number[] } }
|
|
51
|
+
| { registers: Partial<Record<"pc" | "a" | "x" | "y" | "sp", number | Location>> }
|
|
52
|
+
| { joystick: { port: 1 | 2; direction: JoystickDirection; fire?: boolean } }
|
|
53
|
+
| { type: string }
|
|
54
|
+
| { frames: number }
|
|
55
|
+
| { runUntil: { at: Location } | { memory: { at: Location; equals: number } }; timeoutFrames: number }
|
|
56
|
+
| { observe: string; memory?: Array<Location | MemoryObservation>; registers?: ObservedRegister[]; screen?: ScreenPolicy; vicii?: boolean; sprites?: number[] };
|
|
57
|
+
|
|
58
|
+
export interface Subject {
|
|
59
|
+
program: string;
|
|
60
|
+
/** "knowledge" for knowledge.db, a project JSON file (c64-assembler output or a name → "$xxxx" map), or "none". */
|
|
61
|
+
symbols: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface Scenario {
|
|
65
|
+
name: string;
|
|
66
|
+
videoStandard?: "pal" | "ntsc";
|
|
67
|
+
original: Subject;
|
|
68
|
+
rebuild: Subject;
|
|
69
|
+
steps: Step[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type Outcome = "PASS" | "FAIL" | "INCONCLUSIVE";
|
|
73
|
+
|
|
74
|
+
export interface Difference {
|
|
75
|
+
checkpoint: string;
|
|
76
|
+
what: string;
|
|
77
|
+
original: unknown;
|
|
78
|
+
rebuild: unknown;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ScenarioResult {
|
|
82
|
+
result: Outcome;
|
|
83
|
+
scenario: string;
|
|
84
|
+
/** Why the result is INCONCLUSIVE. */
|
|
85
|
+
reason?: string;
|
|
86
|
+
checkpoints: string[];
|
|
87
|
+
differences: Difference[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const STEP_KINDS = ["reset", "load", "autostart", "write", "registers", "joystick", "type", "frames", "runUntil", "observe"];
|
|
91
|
+
|
|
92
|
+
/** The keys each kind of step can have. */
|
|
93
|
+
const STEP_KEYS: Record<string, readonly string[]> = {
|
|
94
|
+
runUntil: ["runUntil", "timeoutFrames"],
|
|
95
|
+
observe: ["observe", "memory", "registers", "screen", "vicii", "sprites"],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** The registers an observe step can compare. */
|
|
99
|
+
export const OBSERVED_REGISTERS = ["pc", "a", "x", "y", "sp", "flags"] as const;
|
|
100
|
+
type ObservedRegister = (typeof OBSERVED_REGISTERS)[number];
|
|
101
|
+
/** The registers a registers step can set. */
|
|
102
|
+
const SET_REGISTERS = ["pc", "a", "x", "y", "sp"];
|
|
103
|
+
|
|
104
|
+
type JoystickDirection = (typeof JOYSTICK_DIRECTIONS)[number];
|
|
105
|
+
|
|
106
|
+
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
107
|
+
const isInteger = (value: unknown, min: number, max: number): value is number => Number.isInteger(value) && (value as number) >= min && (value as number) <= max;
|
|
108
|
+
|
|
109
|
+
/** Reads and checks a scenario file. Throws ScenarioError naming the first problem. */
|
|
110
|
+
export function readScenario(path: string): Scenario {
|
|
111
|
+
let value: unknown;
|
|
112
|
+
try {
|
|
113
|
+
value = JSON.parse(readProjectFile(path).bytes.toString("utf8"));
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw new ScenarioError(`cannot read the scenario ${path}: ${(error as Error).message}`);
|
|
116
|
+
}
|
|
117
|
+
const fail = (message: string): never => {
|
|
118
|
+
throw new ScenarioError(`scenario ${path}: ${message}`);
|
|
119
|
+
};
|
|
120
|
+
if (!isObject(value)) return fail("is not a JSON object");
|
|
121
|
+
const scenario = value as unknown as Scenario;
|
|
122
|
+
if (typeof scenario.name !== "string" || scenario.name === "") fail("needs a name");
|
|
123
|
+
if (scenario.videoStandard !== undefined && scenario.videoStandard !== "pal" && scenario.videoStandard !== "ntsc") fail("videoStandard must be pal or ntsc");
|
|
124
|
+
for (const side of SIDES) {
|
|
125
|
+
const subject = scenario[side];
|
|
126
|
+
if (typeof subject?.program !== "string" || typeof subject.symbols !== "string") fail(`${side} needs a program and symbols`);
|
|
127
|
+
}
|
|
128
|
+
if (!Array.isArray(scenario.steps) || scenario.steps.length === 0) fail("needs steps");
|
|
129
|
+
const labels = new Set<string>();
|
|
130
|
+
let screens = 0;
|
|
131
|
+
scenario.steps.forEach((step: unknown, index) => {
|
|
132
|
+
const at = `step ${index + 1}`;
|
|
133
|
+
if (!isObject(step)) return fail(`${at} must be an object`);
|
|
134
|
+
// An observe step names what it observes with "memory" and "registers" too.
|
|
135
|
+
const kinds = "observe" in step ? ["observe"] : Object.keys(step).filter((key) => STEP_KINDS.includes(key));
|
|
136
|
+
if (kinds.length !== 1) return fail(`${at} must have exactly one of ${STEP_KINDS.join(", ")}`);
|
|
137
|
+
const kind = kinds[0]!;
|
|
138
|
+
const allowed = STEP_KEYS[kind] ?? [kind];
|
|
139
|
+
for (const key of Object.keys(step)) if (!allowed.includes(key)) fail(`${at} has the key ${key}, which a ${kind} step does not have`);
|
|
140
|
+
checkStep(kind, step, at, fail);
|
|
141
|
+
if (kind === "observe") {
|
|
142
|
+
if (labels.has(step.observe as string)) fail(`${at} needs a new checkpoint name`);
|
|
143
|
+
labels.add(step.observe as string);
|
|
144
|
+
if (step.screen !== undefined) screens++;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
if (labels.size === 0) fail("observes nothing; add an observe step");
|
|
148
|
+
if (screens > MAX_BASELINES) fail(`observes the screen at ${screens} checkpoints; the limit is ${MAX_BASELINES}`);
|
|
149
|
+
return scenario;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Checks a "$xxxx" address or a symbol name. */
|
|
153
|
+
function checkLocation(value: unknown, what: string, fail: (message: string) => never): void {
|
|
154
|
+
if (typeof value !== "string" || value === "") fail(`${what} must be a $ address or a symbol name`);
|
|
155
|
+
if ((value as string).startsWith("$")) {
|
|
156
|
+
try {
|
|
157
|
+
parseC64Address(value as string);
|
|
158
|
+
} catch {
|
|
159
|
+
fail(`${what} must be $ followed by four hex digits, not ${value as string}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Checks the value of one step against its kind. */
|
|
165
|
+
function checkStep(kind: string, step: Record<string, unknown>, at: string, fail: (message: string) => never): void {
|
|
166
|
+
switch (kind) {
|
|
167
|
+
case "reset":
|
|
168
|
+
if (step.reset !== "hard" && step.reset !== "soft") fail(`${at}: reset must be hard or soft`);
|
|
169
|
+
return;
|
|
170
|
+
case "load":
|
|
171
|
+
case "autostart":
|
|
172
|
+
if (step[kind] !== true) fail(`${at}: ${kind} must be true`);
|
|
173
|
+
return;
|
|
174
|
+
case "write": {
|
|
175
|
+
const write = step.write;
|
|
176
|
+
if (!isObject(write) || Object.keys(write).some((key) => key !== "at" && key !== "bytes")) return fail(`${at}: write needs at and bytes, and nothing else`);
|
|
177
|
+
checkLocation(write.at, `${at}: write.at`, fail);
|
|
178
|
+
if (!Array.isArray(write.bytes) || write.bytes.length === 0 || write.bytes.length > MAX_MEMORY_WRITE || !write.bytes.every((byte) => isInteger(byte, 0, 0xff))) {
|
|
179
|
+
fail(`${at}: write.bytes must be a list of 1 to ${MAX_MEMORY_WRITE} numbers from 0 to 255`);
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
case "registers": {
|
|
184
|
+
const registers = step.registers;
|
|
185
|
+
if (!isObject(registers) || Object.keys(registers).length === 0) return fail(`${at}: registers must name at least one of ${SET_REGISTERS.join(", ")}`);
|
|
186
|
+
for (const [name, value] of Object.entries(registers)) {
|
|
187
|
+
if (!SET_REGISTERS.includes(name)) fail(`${at}: registers can set only ${SET_REGISTERS.join(", ")}, not ${name}`);
|
|
188
|
+
if (typeof value === "string") checkLocation(value, `${at}: registers.${name}`, fail);
|
|
189
|
+
else if (!isInteger(value, 0, name === "pc" ? 0xffff : 0xff)) fail(`${at}: registers.${name} must be a symbol name, a $ address or a number from 0 to ${name === "pc" ? 65535 : 255}`);
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
case "joystick": {
|
|
194
|
+
const joystick = step.joystick;
|
|
195
|
+
if (!isObject(joystick) || Object.keys(joystick).some((key) => !["port", "direction", "fire"].includes(key))) return fail(`${at}: joystick needs port and direction, and fire is optional`);
|
|
196
|
+
if (joystick.port !== 1 && joystick.port !== 2) fail(`${at}: joystick.port must be 1 or 2`);
|
|
197
|
+
if (!(JOYSTICK_DIRECTIONS as readonly unknown[]).includes(joystick.direction)) fail(`${at}: joystick.direction must be one of ${JOYSTICK_DIRECTIONS.join(", ")}`);
|
|
198
|
+
if (joystick.fire !== undefined && typeof joystick.fire !== "boolean") fail(`${at}: joystick.fire must be true or false`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
case "type": {
|
|
202
|
+
if (typeof step.type !== "string" || step.type === "") return fail(`${at}: type needs text`);
|
|
203
|
+
let bytes: Uint8Array;
|
|
204
|
+
try {
|
|
205
|
+
bytes = textToPetscii(step.type);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return fail(`${at}: ${(error as Error).message}`);
|
|
208
|
+
}
|
|
209
|
+
if (bytes.length > MAX_KEYBOARD_BYTES) fail(`${at}: type can have at most ${MAX_KEYBOARD_BYTES} keys`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
case "frames":
|
|
213
|
+
if (!isInteger(step.frames, 1, MAX_EXECUTION_COUNT)) fail(`${at} needs a frame count from 1 to ${MAX_EXECUTION_COUNT}`);
|
|
214
|
+
return;
|
|
215
|
+
case "runUntil": {
|
|
216
|
+
const target = step.runUntil;
|
|
217
|
+
if (!isObject(target) || Object.keys(target).length !== 1 || !("at" in target || "memory" in target)) return fail(`${at}: runUntil needs at or memory`);
|
|
218
|
+
if ("at" in target) checkLocation(target.at, `${at}: runUntil.at`, fail);
|
|
219
|
+
else {
|
|
220
|
+
const memory = target.memory;
|
|
221
|
+
if (!isObject(memory) || Object.keys(memory).some((key) => key !== "at" && key !== "equals")) return fail(`${at}: runUntil.memory needs at and equals, and nothing else`);
|
|
222
|
+
checkLocation(memory.at, `${at}: runUntil.memory.at`, fail);
|
|
223
|
+
if (!isInteger(memory.equals, 0, 0xff)) fail(`${at}: runUntil.memory.equals must be a number from 0 to 255`);
|
|
224
|
+
}
|
|
225
|
+
if (!isInteger(step.timeoutFrames, 1, MAX_TIMEOUT_FRAMES)) fail(`${at} needs timeoutFrames from 1 to ${MAX_TIMEOUT_FRAMES}`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
default:
|
|
229
|
+
checkObserve(step, at, fail);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function checkObserve(step: Record<string, unknown>, at: string, fail: (message: string) => never): void {
|
|
234
|
+
if (typeof step.observe !== "string" || step.observe === "") fail(`${at} needs a new checkpoint name`);
|
|
235
|
+
if (step.memory !== undefined) {
|
|
236
|
+
if (!Array.isArray(step.memory) || step.memory.length === 0) fail(`${at}: memory must be a list of names, $ addresses or objects with at`);
|
|
237
|
+
for (const item of step.memory as unknown[]) {
|
|
238
|
+
if (typeof item === "string") {
|
|
239
|
+
checkLocation(item, `${at}: memory`, fail);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!isObject(item) || Object.keys(item).some((key) => !["at", "size", "tolerance"].includes(key))) return fail(`${at}: a memory object has at, size and tolerance, and nothing else`);
|
|
243
|
+
checkLocation(item.at, `${at}: memory.at`, fail);
|
|
244
|
+
if (item.size !== undefined && !isInteger(item.size, 1, MAX_MEMORY_READ)) fail(`${at}: memory.size must be a number from 1 to ${MAX_MEMORY_READ}`);
|
|
245
|
+
if (item.tolerance !== undefined && !isInteger(item.tolerance, 0, 0xff)) fail(`${at}: memory.tolerance must be a number from 0 to 255`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (step.registers !== undefined) {
|
|
249
|
+
const names = step.registers;
|
|
250
|
+
if (!Array.isArray(names) || names.length === 0) return fail(`${at}: registers must be a list of ${OBSERVED_REGISTERS.join(", ")}`);
|
|
251
|
+
for (const name of names) if (!(OBSERVED_REGISTERS as readonly unknown[]).includes(name)) fail(`${at}: registers can have only ${OBSERVED_REGISTERS.join(", ")}, not ${JSON.stringify(name)}`);
|
|
252
|
+
if (new Set(names).size !== names.length) fail(`${at}: registers names a register two times`);
|
|
253
|
+
}
|
|
254
|
+
if (step.screen !== undefined) {
|
|
255
|
+
const screen = step.screen;
|
|
256
|
+
if (!isObject(screen) || Object.keys(screen).some((key) => key !== "maxMismatchRatio" && key !== "mask")) return fail(`${at}: screen can have maxMismatchRatio and mask, and nothing else`);
|
|
257
|
+
if (screen.maxMismatchRatio !== undefined && !(typeof screen.maxMismatchRatio === "number" && screen.maxMismatchRatio >= 0 && screen.maxMismatchRatio <= 1)) {
|
|
258
|
+
fail(`${at}: screen.maxMismatchRatio must be a number from 0 to 1`);
|
|
259
|
+
}
|
|
260
|
+
if (screen.mask !== undefined) {
|
|
261
|
+
if (!Array.isArray(screen.mask) || screen.mask.length > MAX_MASK_RECTANGLES) return fail(`${at}: screen.mask must be a list of at most ${MAX_MASK_RECTANGLES} rectangles`);
|
|
262
|
+
for (const rectangle of screen.mask as unknown[]) {
|
|
263
|
+
const ok =
|
|
264
|
+
isObject(rectangle) &&
|
|
265
|
+
Object.keys(rectangle).every((key) => ["x", "y", "width", "height"].includes(key)) &&
|
|
266
|
+
isInteger(rectangle.x, 0, 4095) &&
|
|
267
|
+
isInteger(rectangle.y, 0, 4095) &&
|
|
268
|
+
isInteger(rectangle.width, 1, 4096) &&
|
|
269
|
+
isInteger(rectangle.height, 1, 4096);
|
|
270
|
+
if (!ok) fail(`${at}: a screen.mask rectangle needs whole numbers x and y from 0, and width and height from 1`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (step.vicii !== undefined && typeof step.vicii !== "boolean") fail(`${at}: vicii must be true or false`);
|
|
275
|
+
if (step.sprites !== undefined) {
|
|
276
|
+
const sprites = step.sprites;
|
|
277
|
+
if (!Array.isArray(sprites) || sprites.length === 0 || !sprites.every((sprite) => isInteger(sprite, 0, 7)) || new Set(sprites).size !== sprites.length) {
|
|
278
|
+
fail(`${at}: sprites must be a list of different sprite numbers from 0 to 7`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Name → address for one side. */
|
|
284
|
+
export function loadSymbols(subject: Subject): Map<string, number> {
|
|
285
|
+
const symbols = new Map<string, number>();
|
|
286
|
+
if (subject.symbols === "none") return symbols;
|
|
287
|
+
if (subject.symbols === "knowledge") {
|
|
288
|
+
const db = openForRead();
|
|
289
|
+
for (const symbol of listSymbols(db)) symbols.set(symbol.name, symbol.address);
|
|
290
|
+
db?.close();
|
|
291
|
+
return symbols;
|
|
292
|
+
}
|
|
293
|
+
let value: unknown;
|
|
294
|
+
try {
|
|
295
|
+
value = JSON.parse(readProjectFile(subject.symbols).bytes.toString("utf8"));
|
|
296
|
+
} catch (error) {
|
|
297
|
+
throw new ScenarioError(`cannot read the symbols ${subject.symbols}: ${(error as Error).message}`);
|
|
298
|
+
}
|
|
299
|
+
// c64-assembler output, or a plain name → "$xxxx" map. A constant in the
|
|
300
|
+
// assembler output counts too: RAM variables such as "counter = $c100" lie
|
|
301
|
+
// outside the program, so the assembler reports them as constants.
|
|
302
|
+
const assembled = (value as { symbols?: Array<{ name: string; address?: string; value?: number }> }).symbols;
|
|
303
|
+
const entries: Array<[string, unknown]> = Array.isArray(assembled)
|
|
304
|
+
? assembled.flatMap((symbol): Array<[string, unknown]> => {
|
|
305
|
+
if (symbol.address !== undefined) return [[symbol.name, symbol.address]];
|
|
306
|
+
if (Number.isInteger(symbol.value) && symbol.value! >= 0 && symbol.value! <= 0xffff) return [[symbol.name, formatC64Address(symbol.value!)]];
|
|
307
|
+
return [];
|
|
308
|
+
})
|
|
309
|
+
: Object.entries(value as Record<string, unknown>);
|
|
310
|
+
for (const [name, address] of entries) {
|
|
311
|
+
try {
|
|
312
|
+
symbols.set(name, parseC64Address(String(address)));
|
|
313
|
+
} catch {
|
|
314
|
+
throw new ScenarioError(`the symbol ${name} in ${subject.symbols} has no $xxxx address`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return symbols;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** A failure that makes the comparison impossible: the result is INCONCLUSIVE. */
|
|
321
|
+
class Inconclusive extends Error {}
|
|
322
|
+
|
|
323
|
+
export function resolve(location: Location, symbols: Map<string, number>, side: Side): number {
|
|
324
|
+
if (location.startsWith("$")) {
|
|
325
|
+
try {
|
|
326
|
+
return parseC64Address(location);
|
|
327
|
+
} catch {
|
|
328
|
+
throw new ScenarioError(`${location} is not $ followed by four hex digits`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const address = symbols.get(location);
|
|
332
|
+
if (address === undefined) throw new Inconclusive(`the ${side} side has no symbol ${location}`);
|
|
333
|
+
return address;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
interface Observation {
|
|
337
|
+
memory: Array<{ label: string; bytes: number[]; tolerance: number }>;
|
|
338
|
+
registers: Partial<Record<ObservedRegister, unknown>>;
|
|
339
|
+
screen?: { match: boolean; mismatchingPixels: number; mismatchRatio: number; bounds?: unknown };
|
|
340
|
+
vicii?: unknown;
|
|
341
|
+
sprites?: unknown;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* A session baseline name for a checkpoint: letters, digits, dots, underscores
|
|
346
|
+
* or hyphens. The checkpoint's number comes first, so two checkpoints never
|
|
347
|
+
* get the same name.
|
|
348
|
+
*/
|
|
349
|
+
export function baselineName(checkpoint: number, label: string): string {
|
|
350
|
+
return `test-${checkpoint}-${label}`.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function runSide(client: ViceSessionClient, scenario: Scenario, side: Side, symbols: Map<string, number>): Promise<Map<string, Observation>> {
|
|
354
|
+
const subject = scenario[side];
|
|
355
|
+
const observations = new Map<string, Observation>();
|
|
356
|
+
const at = (location: Location) => resolve(location, symbols, side);
|
|
357
|
+
const stop = async () => {
|
|
358
|
+
if ((await client.status()).state === "running") await client.execution({ action: "pause", space: "c64" });
|
|
359
|
+
};
|
|
360
|
+
// Each side starts from a hard reset, stopped at the reset vector, with the control ports released.
|
|
361
|
+
await client.reset({ mode: "hard", run: false });
|
|
362
|
+
await client.joystick({ port: 1, direction: "center", fire: false });
|
|
363
|
+
await client.joystick({ port: 2, direction: "center", fire: false });
|
|
364
|
+
let checkpoint = 0;
|
|
365
|
+
for (const step of scenario.steps) {
|
|
366
|
+
// Observe first: an observe step may also carry "registers" or "memory".
|
|
367
|
+
if ("observe" in step) {
|
|
368
|
+
checkpoint++;
|
|
369
|
+
const observation: Observation = { memory: [], registers: {} };
|
|
370
|
+
for (const item of step.memory ?? []) {
|
|
371
|
+
const spec = typeof item === "string" ? { at: item } : item;
|
|
372
|
+
const read = await client.memoryRead({ address: at(spec.at), size: spec.size ?? 1, space: "c64", view: "cpu" });
|
|
373
|
+
observation.memory.push({ label: spec.size !== undefined && spec.size > 1 ? `${spec.at} (${spec.size} bytes)` : spec.at, bytes: [...Buffer.from(read.data, "hex")], tolerance: spec.tolerance ?? 0 });
|
|
374
|
+
}
|
|
375
|
+
if (step.registers !== undefined) {
|
|
376
|
+
const registers = await client.registersGet("c64");
|
|
377
|
+
for (const name of step.registers) observation.registers[name] = registers[name];
|
|
378
|
+
}
|
|
379
|
+
if (step.vicii) {
|
|
380
|
+
// The raster line changes from one moment to the next; the comparison leaves it out.
|
|
381
|
+
const { rasterLine: _rasterLine, ...state } = await client.vicii();
|
|
382
|
+
observation.vicii = state;
|
|
383
|
+
}
|
|
384
|
+
if (step.sprites !== undefined) observation.sprites = (await client.sprites(step.sprites)).sprites;
|
|
385
|
+
if (step.screen !== undefined) {
|
|
386
|
+
const baseline = baselineName(checkpoint, step.observe);
|
|
387
|
+
if (side === "original") {
|
|
388
|
+
await client.screenCapture(baseline);
|
|
389
|
+
} else {
|
|
390
|
+
const compared = await client.screenCompare({ baseline, maxMismatchRatio: step.screen.maxMismatchRatio ?? 0, mask: step.screen.mask ?? [], includeDiff: false });
|
|
391
|
+
observation.screen = { match: compared.match, mismatchingPixels: compared.mismatchingPixels, mismatchRatio: compared.mismatchRatio, ...(compared.bounds === undefined ? {} : { bounds: compared.bounds }) };
|
|
392
|
+
await client.screenDiscard(baseline);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
observations.set(step.observe, observation);
|
|
396
|
+
} else if ("reset" in step) {
|
|
397
|
+
// Stopped at the reset vector, so frame counts from here are exact.
|
|
398
|
+
await client.reset({ mode: step.reset, run: false });
|
|
399
|
+
} else if ("load" in step) {
|
|
400
|
+
await client.programLoad({ path: subject.program });
|
|
401
|
+
} else if ("autostart" in step) {
|
|
402
|
+
await client.autostart({ path: subject.program, index: 0, run: true });
|
|
403
|
+
} else if ("write" in step) {
|
|
404
|
+
await stop();
|
|
405
|
+
await client.memoryWrite({ address: at(step.write.at), data: Buffer.from(step.write.bytes).toString("hex"), space: "c64", view: "cpu" });
|
|
406
|
+
} else if ("registers" in step) {
|
|
407
|
+
await stop();
|
|
408
|
+
const values: Record<string, number> = {};
|
|
409
|
+
for (const [name, value] of Object.entries(step.registers)) values[name] = typeof value === "number" ? value : at(value as Location);
|
|
410
|
+
await client.registersSet("c64", values);
|
|
411
|
+
} else if ("joystick" in step) {
|
|
412
|
+
await client.joystick({ port: step.joystick.port, direction: step.joystick.direction, fire: step.joystick.fire ?? false });
|
|
413
|
+
} else if ("type" in step) {
|
|
414
|
+
await client.keyboard(textToPetscii(step.type));
|
|
415
|
+
} else if ("frames" in step) {
|
|
416
|
+
await stop();
|
|
417
|
+
await client.execution({ action: "advance-frames", count: step.frames, space: "c64" });
|
|
418
|
+
} else if ("runUntil" in step) {
|
|
419
|
+
const target =
|
|
420
|
+
"at" in step.runUntil
|
|
421
|
+
? { kind: "address" as const, address: at(step.runUntil.at), space: "c64" as const }
|
|
422
|
+
: { kind: "memory" as const, address: at(step.runUntil.memory.at), operator: "eq" as const, value: step.runUntil.memory.equals, space: "c64" as const, view: "cpu" as const };
|
|
423
|
+
const reached = await client.runUntil({ target, timeoutFrames: step.timeoutFrames });
|
|
424
|
+
if (!reached.reached) {
|
|
425
|
+
throw new Inconclusive(`the ${side} side did not reach the checkpoint before ${step.timeoutFrames} frames (it stopped by ${reached.stopReason} at ${formatC64Address(reached.pc)})`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return observations;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** The differences between the two sides' observations at each checkpoint, under the scenario's rules. */
|
|
433
|
+
export function compare(original: Map<string, Observation>, rebuild: Map<string, Observation>): Difference[] {
|
|
434
|
+
const differences: Difference[] = [];
|
|
435
|
+
for (const [checkpoint, left] of original) {
|
|
436
|
+
const right = rebuild.get(checkpoint)!;
|
|
437
|
+
left.memory.forEach((item, index) => {
|
|
438
|
+
const other = right.memory[index]!;
|
|
439
|
+
if (item.bytes.some((byte, at) => Math.abs(byte - other.bytes[at]!) > item.tolerance)) {
|
|
440
|
+
const show = (bytes: number[]) => (bytes.length === 1 ? bytes[0] : Buffer.from(bytes).toString("hex"));
|
|
441
|
+
differences.push({ checkpoint, what: `memory ${item.label}`, original: show(item.bytes), rebuild: show(other.bytes) });
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
for (const [name, value] of Object.entries(left.registers)) {
|
|
445
|
+
const other = right.registers[name as ObservedRegister];
|
|
446
|
+
if (JSON.stringify(other) !== JSON.stringify(value)) differences.push({ checkpoint, what: `register ${name}`, original: value, rebuild: other });
|
|
447
|
+
}
|
|
448
|
+
const screenDiffers = right.screen !== undefined && !right.screen.match;
|
|
449
|
+
if (screenDiffers) differences.push({ checkpoint, what: "screen", original: "baseline", rebuild: right.screen });
|
|
450
|
+
if (left.vicii !== undefined && JSON.stringify(left.vicii) !== JSON.stringify(right.vicii)) differences.push({ checkpoint, what: "VIC-II state", original: left.vicii, rebuild: right.vicii });
|
|
451
|
+
if (left.sprites !== undefined && JSON.stringify(left.sprites) !== JSON.stringify(right.sprites)) differences.push({ checkpoint, what: "sprites", original: left.sprites, rebuild: right.sprites });
|
|
452
|
+
}
|
|
453
|
+
return differences;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Runs the scenario on the original, then on the rebuild, in one VICE session. */
|
|
457
|
+
export async function runScenario(client: ViceSessionClient, scenario: Scenario): Promise<ScenarioResult> {
|
|
458
|
+
const checkpoints = scenario.steps.flatMap((step) => ("observe" in step ? [step.observe] : []));
|
|
459
|
+
const observed = new Map<Side, Map<string, Observation>>();
|
|
460
|
+
for (const side of SIDES) {
|
|
461
|
+
try {
|
|
462
|
+
observed.set(side, await runSide(client, scenario, side, loadSymbols(scenario[side])));
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (error instanceof Inconclusive) return { result: "INCONCLUSIVE", scenario: scenario.name, reason: error.message, checkpoints, differences: [] };
|
|
465
|
+
if (error instanceof WireFailure) {
|
|
466
|
+
return { result: "INCONCLUSIVE", scenario: scenario.name, reason: `the ${side} side could not run: ${error.message}`, checkpoints, differences: [] };
|
|
467
|
+
}
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const differences = compare(observed.get("original")!, observed.get("rebuild")!);
|
|
472
|
+
return { result: differences.length === 0 ? "PASS" : "FAIL", scenario: scenario.name, checkpoints, differences };
|
|
473
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The c64-testing script: runs a scenario on the original and then on the
|
|
2
|
+
// rebuild in one emulator, and prints PASS, FAIL or INCONCLUSIVE with the
|
|
3
|
+
// differences. Exit status: 0 PASS, 1 FAIL, 3 INCONCLUSIVE, 2 a bad call.
|
|
4
|
+
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
|
|
7
|
+
import { ViceSessionClient } from "#src/host-client/vice-session.ts";
|
|
8
|
+
import { WireFailure } from "#src/host-client/tools.ts";
|
|
9
|
+
import { runScript, UsageError } from "#src/script.ts";
|
|
10
|
+
import { readScenario, runScenario, ScenarioError, type ScenarioResult } from "./scenario.ts";
|
|
11
|
+
|
|
12
|
+
const USAGE = `test.ts <scenario.json>
|
|
13
|
+
|
|
14
|
+
<scenario.json> is a scenario file relative to the project directory. See
|
|
15
|
+
references/scenario-format.md. The result is one JSON object.`;
|
|
16
|
+
|
|
17
|
+
const EXIT = { PASS: 0, FAIL: 1, INCONCLUSIVE: 3 } as const;
|
|
18
|
+
|
|
19
|
+
async function run(argv: string[]): Promise<ScenarioResult> {
|
|
20
|
+
const { values, positionals } = parseArgs({ args: argv, strict: true, allowPositionals: true, options: { help: { type: "boolean" } } });
|
|
21
|
+
if (values.help) throw new UsageError("");
|
|
22
|
+
if (positionals.length !== 1) throw new UsageError("give one scenario file");
|
|
23
|
+
try {
|
|
24
|
+
return await runScenarioFile(positionals[0]!);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
// A scenario or symbol file that is not usable is a bad call.
|
|
27
|
+
if (error instanceof ScenarioError) throw new UsageError(error.message);
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function runScenarioFile(path: string): Promise<ScenarioResult> {
|
|
33
|
+
const scenario = readScenario(path);
|
|
34
|
+
let client: ViceSessionClient;
|
|
35
|
+
try {
|
|
36
|
+
client = await ViceSessionClient.open({ videoStandard: scenario.videoStandard ?? "pal" });
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (!(error instanceof WireFailure)) throw error;
|
|
39
|
+
return { result: "INCONCLUSIVE", scenario: scenario.name, reason: `no emulator: ${error.message}`, checkpoints: [], differences: [] };
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
return await runScenario(client, scenario);
|
|
43
|
+
} finally {
|
|
44
|
+
await client.close();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await runScript(
|
|
49
|
+
async () => {
|
|
50
|
+
const result = await run(process.argv.slice(2));
|
|
51
|
+
process.exitCode = EXIT[result.result];
|
|
52
|
+
return result;
|
|
53
|
+
},
|
|
54
|
+
{ usage: USAGE },
|
|
55
|
+
);
|