@henols/c64-re-tools 1.1.0 → 2.0.0-rc.11
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 +154 -37
- package/distribution/package.ts +21 -0
- package/distribution/plugin.ts +122 -0
- package/package.json +34 -40
- package/skills/c64-assembler/SKILL.md +76 -0
- package/skills/c64-assembler/scripts/assemble.ts +110 -0
- package/skills/c64-assembler/scripts/compare.ts +84 -0
- package/skills/c64-basic/SKILL.md +66 -0
- package/skills/c64-basic/scripts/basic.ts +46 -0
- package/skills/c64-disk/SKILL.md +93 -0
- package/skills/c64-disk/scripts/containers.ts +124 -0
- package/skills/c64-disk/scripts/disk.ts +120 -0
- package/skills/c64-emulator/SKILL.md +134 -0
- package/skills/c64-knowledge/SKILL.md +192 -0
- package/skills/c64-knowledge/scripts/knowledge.ts +613 -0
- package/skills/c64-memory-map/SKILL.md +79 -0
- package/skills/c64-memory-map/scripts/memmap.ts +92 -0
- package/skills/c64-provenance/SKILL.md +110 -0
- package/skills/c64-provenance/scripts/compare.ts +152 -0
- package/skills/c64-provenance/scripts/provenance.ts +334 -0
- package/skills/c64-provenance/scripts/runtime.ts +191 -0
- package/skills/c64-provenance/scripts/separate.ts +140 -0
- package/skills/c64-reverse-engineering/SKILL.md +144 -0
- package/skills/c64-source/SKILL.md +133 -0
- package/skills/c64-source/scripts/generate.ts +503 -0
- package/skills/c64-source/scripts/loader.ts +589 -0
- package/skills/c64-source/scripts/opcodes.ts +61 -0
- package/skills/c64-source/scripts/source.ts +673 -0
- package/skills/c64-static-analysis/SKILL.md +120 -0
- package/skills/c64-static-analysis/scripts/analyze.ts +293 -0
- package/skills/c64-static-analysis/scripts/coverage.ts +78 -0
- package/skills/c64-static-analysis/scripts/findings.ts +75 -0
- package/skills/c64-static-analysis/scripts/idioms.ts +149 -0
- package/skills/c64-static-analysis/scripts/instructions.ts +105 -0
- package/skills/c64-static-analysis/scripts/pointers.ts +108 -0
- package/skills/c64-testing/SKILL.md +103 -0
- package/skills/c64-testing/references/scenario-format.md +116 -0
- package/skills/c64-testing/scripts/checklist.ts +51 -0
- package/skills/c64-testing/scripts/run.ts +189 -0
- package/skills/c64-testing/scripts/scenario.ts +493 -0
- package/skills/c64-testing/scripts/test.ts +65 -0
- package/skills/c64-unpacker/SKILL.md +135 -0
- package/skills/c64-unpacker/scripts/evidence.ts +207 -0
- package/skills/c64-unpacker/scripts/loads.ts +209 -0
- package/skills/c64-unpacker/scripts/machine.ts +80 -0
- package/skills/c64-unpacker/scripts/stages.ts +130 -0
- package/skills/c64-unpacker/scripts/unpack.ts +683 -0
- package/src/c64.ts +411 -0
- package/src/cli/ap-sdk.ts +44 -0
- package/src/cli/cleanup.ts +194 -0
- package/src/cli/main.ts +125 -0
- package/src/cli/status.ts +61 -0
- package/src/host/main.ts +134 -0
- package/src/host/server.ts +392 -0
- package/src/host/tools/c1541.ts +284 -0
- package/src/host/tools/index.ts +35 -0
- package/src/host/tools/petcat.ts +77 -0
- package/src/host/tools/status.ts +47 -0
- package/src/host/tools/version.ts +41 -0
- package/src/host/vice/adapter.ts +804 -0
- package/src/host/vice/binary-monitor.ts +389 -0
- package/src/host/vice/chips.ts +118 -0
- package/src/host/vice/connect.ts +34 -0
- package/src/host/vice/process.ts +413 -0
- package/src/host/vice/screen.ts +184 -0
- package/src/host/vice/session.ts +1426 -0
- package/src/host/vice/text-monitor.ts +223 -0
- package/src/host-client/autostart.ts +55 -0
- package/src/host-client/connect.ts +241 -0
- package/src/host-client/coverage.ts +63 -0
- package/src/host-client/image.ts +63 -0
- package/src/host-client/steps.ts +170 -0
- package/src/host-client/tools.ts +89 -0
- package/src/host-client/transfer.ts +126 -0
- package/src/host-client/vice-session.ts +316 -0
- package/src/knowledge/database.ts +132 -0
- package/src/knowledge/expression.ts +209 -0
- package/src/knowledge/history.ts +169 -0
- package/src/knowledge/import.ts +392 -0
- package/src/knowledge/read.ts +392 -0
- package/src/knowledge/schema.ts +250 -0
- package/src/knowledge/write.ts +837 -0
- package/src/mcp/main.ts +134 -0
- package/src/mcp/output.ts +32 -0
- package/src/mcp/server.ts +316 -0
- package/src/mcp/tools/debug.ts +327 -0
- package/src/mcp/tools/execution.ts +117 -0
- package/src/mcp/tools/input.ts +72 -0
- package/src/mcp/tools/machine.ts +100 -0
- package/src/mcp/tools/media.ts +129 -0
- package/src/mcp/tools/memory.ts +250 -0
- package/src/mcp/tools/video.ts +271 -0
- package/src/native/acme.ts +116 -0
- package/src/native/discover.ts +179 -0
- package/src/native/dxa.ts +166 -0
- package/src/native/ghidra/analyze.ts +86 -0
- package/src/native/ghidra/index.ts +173 -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 +274 -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 +113 -0
- package/src/native/types.ts +257 -0
- package/src/native/watchdog.ts +78 -0
- package/src/package.ts +21 -0
- package/src/platform.ts +441 -0
- package/src/project.ts +39 -0
- package/src/protocol/framing.ts +132 -0
- package/src/protocol/messages.ts +259 -0
- package/src/protocol/tools.ts +266 -0
- package/src/protocol/vice.ts +1348 -0
- package/src/script.ts +60 -0
- package/src/time.ts +15 -0
- package/src/trace.ts +194 -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,149 @@
|
|
|
1
|
+
// 6502 idioms that hide code from a disassembler (D44): RTS dispatch tables,
|
|
2
|
+
// JMP tables and BIT skips are found by their exact instruction patterns and
|
|
3
|
+
// become heuristic findings and entry points; DOP padding and self-modified
|
|
4
|
+
// opcodes are only reported. A self-modified opcode is one that one run wrote
|
|
5
|
+
// and executed, in either order, since a coverage file holds no order (R3-04):
|
|
6
|
+
// a copier's writes in one run and their execution in another do not count (F-16). A table's length is not known statically: entries
|
|
7
|
+
// count while each target is a plausible instruction in the program.
|
|
8
|
+
|
|
9
|
+
import { COVERAGE } from "#src/c64.ts";
|
|
10
|
+
import { joinCoverage } from "#src/host-client/coverage.ts";
|
|
11
|
+
import type { NormalizedFindings } from "#src/knowledge/import.ts";
|
|
12
|
+
import type { ImageByte } from "./coverage.ts";
|
|
13
|
+
|
|
14
|
+
const MAX_TABLE = 64;
|
|
15
|
+
const MIN_JUMPS = 3;
|
|
16
|
+
const LDA_INDEXED = new Set([0xbd, 0xb9]);
|
|
17
|
+
const JAMS = new Set([0x02, 0x12, 0x22, 0x32, 0x42, 0x52, 0x62, 0x72, 0x92, 0xb2, 0xd2, 0xf2]);
|
|
18
|
+
/** Undocumented NOPs with an operand byte (DOP): padding between instructions hides the real ones. */
|
|
19
|
+
const DOPS = new Set([0x04, 0x14, 0x34, 0x44, 0x54, 0x64, 0x74, 0x80, 0x82, 0x89, 0xc2, 0xd4, 0xe2, 0xf4]);
|
|
20
|
+
|
|
21
|
+
export interface Idioms {
|
|
22
|
+
findings: NormalizedFindings;
|
|
23
|
+
/** Entry points for the same analysis run: analyzer facts are never seeds. */
|
|
24
|
+
entryPoints: number[];
|
|
25
|
+
/** Table ranges for the same run: they are data. */
|
|
26
|
+
dataRanges: Array<{ start: number; end: number }>;
|
|
27
|
+
counts: { rtsTables: number; jumpTables: number; bitSkips: number };
|
|
28
|
+
/** Seen in the coverage, not written: the LLM decides. All of them: the result lists the first ones. */
|
|
29
|
+
candidates: { dop: number[]; selfModified: number[] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** True when an instruction can start at the address: inside the program, and no JAM. */
|
|
33
|
+
function plausible(byteAt: ImageByte, address: number, range: { start: number; end: number }): boolean {
|
|
34
|
+
const opcode = byteAt(address);
|
|
35
|
+
return address >= range.start && address <= range.end && opcode !== undefined && !JAMS.has(opcode);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const word = (byteAt: ImageByte, address: number) => {
|
|
39
|
+
const low = byteAt(address);
|
|
40
|
+
const high = byteAt(address + 1);
|
|
41
|
+
return low === undefined || high === undefined ? undefined : low | (high << 8);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** `runs` are the coverage maps, one for each file. */
|
|
45
|
+
export function findIdioms(byteAt: ImageByte, range: { start: number; end: number }, runs: readonly Uint8Array[] = []): Idioms {
|
|
46
|
+
const coverage = runs.length === 0 ? undefined : joinCoverage(runs);
|
|
47
|
+
const regions: NormalizedFindings["regions"] = [];
|
|
48
|
+
const references: NormalizedFindings["references"] = [];
|
|
49
|
+
const entryPoints = new Set<number>();
|
|
50
|
+
const counts = { rtsTables: 0, jumpTables: 0, bitSkips: 0 };
|
|
51
|
+
const taken = (start: number, end: number) => regions.some((region) => start <= region.end && end >= region.start);
|
|
52
|
+
const addRegion = (start: number, end: number, type: NormalizedFindings["regions"][number]["type"]) => {
|
|
53
|
+
if (start < range.start || end > range.end || taken(start, end)) return false;
|
|
54
|
+
regions.push({ start, end, type });
|
|
55
|
+
return true;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
for (let at = range.start; at + 8 <= range.end; at++) {
|
|
59
|
+
// RTS dispatch: LDA high,X / PHA / LDA low,X / PHA / RTS.
|
|
60
|
+
if (LDA_INDEXED.has(byteAt(at)!) && byteAt(at + 3) === 0x48 && LDA_INDEXED.has(byteAt(at + 4)!) && byteAt(at + 7) === 0x48 && byteAt(at + 8) === 0x60) {
|
|
61
|
+
const high = word(byteAt, at + 1)!;
|
|
62
|
+
const low = word(byteAt, at + 5)!;
|
|
63
|
+
const interleaved = high === low + 1;
|
|
64
|
+
const step = interleaved ? 2 : 1;
|
|
65
|
+
const targets: number[] = [];
|
|
66
|
+
for (let index = 0; index < MAX_TABLE; index++) {
|
|
67
|
+
const lowByte = byteAt(low + index * step);
|
|
68
|
+
const highByte = byteAt(high + index * step);
|
|
69
|
+
if (lowByte === undefined || highByte === undefined) break;
|
|
70
|
+
const target = ((highByte << 8) | lowByte) + 1;
|
|
71
|
+
if (!plausible(byteAt, target, range)) break;
|
|
72
|
+
targets.push(target);
|
|
73
|
+
}
|
|
74
|
+
if (targets.length === 0) continue;
|
|
75
|
+
const n = targets.length;
|
|
76
|
+
const written = interleaved
|
|
77
|
+
? addRegion(low, low + 2 * n - 1, "rts-addresses")
|
|
78
|
+
: low + n === high
|
|
79
|
+
? addRegion(low, high + n - 1, "rts-addresses-split")
|
|
80
|
+
: addRegion(low, low + n - 1, "bytes") && addRegion(high, high + n - 1, "bytes");
|
|
81
|
+
if (!written) continue;
|
|
82
|
+
counts.rtsTables++;
|
|
83
|
+
for (const target of targets) {
|
|
84
|
+
references.push({ from: at + 8, to: target, kind: "jump" });
|
|
85
|
+
entryPoints.add(target);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// JMP tables: at least three JMP abs in a row.
|
|
91
|
+
for (let at = range.start; at + 3 * MIN_JUMPS - 1 <= range.end; at++) {
|
|
92
|
+
let n = 0;
|
|
93
|
+
while (at + 3 * n + 2 <= range.end && byteAt(at + 3 * n) === 0x4c) n++;
|
|
94
|
+
if (n < MIN_JUMPS || !addRegion(at, at + 3 * n - 1, "jumps")) continue;
|
|
95
|
+
counts.jumpTables++;
|
|
96
|
+
for (let index = 0; index < n; index++) {
|
|
97
|
+
entryPoints.add(at + 3 * index);
|
|
98
|
+
const target = word(byteAt, at + 3 * index + 1)!;
|
|
99
|
+
if (plausible(byteAt, target, range)) entryPoints.add(target);
|
|
100
|
+
}
|
|
101
|
+
at += 3 * n - 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// BIT skips: BIT abs ($2c) whose operand bytes are an instruction that a JMP, a JSR or the coverage enters.
|
|
105
|
+
const entered = new Set<number>();
|
|
106
|
+
for (let at = range.start; at + 2 <= range.end; at++) {
|
|
107
|
+
if (byteAt(at) === 0x4c || byteAt(at) === 0x20) entered.add(word(byteAt, at + 1)!);
|
|
108
|
+
}
|
|
109
|
+
if (coverage !== undefined) for (let address = range.start; address <= range.end; address++) if ((coverage[address]! & COVERAGE.ramExecute) !== 0) entered.add(address);
|
|
110
|
+
for (let at = range.start; at + 2 <= range.end; at++) {
|
|
111
|
+
if (byteAt(at) !== 0x2c || !entered.has(at + 1) || !plausible(byteAt, at + 1, range)) continue;
|
|
112
|
+
if (!addRegion(at + 1, at + 2, "dual-use")) continue;
|
|
113
|
+
counts.bitSkips++;
|
|
114
|
+
entryPoints.add(at);
|
|
115
|
+
entryPoints.add(at + 1);
|
|
116
|
+
for (let from = range.start; from + 2 <= range.end; from++) {
|
|
117
|
+
if ((byteAt(from) === 0x4c || byteAt(from) === 0x20) && word(byteAt, from + 1) === at + 1) references.push({ from, to: at + 1, kind: byteAt(from) === 0x20 ? "call" : "jump" });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Candidates from the coverage: DOP padding that ran, and opcodes that one run wrote and executed.
|
|
122
|
+
const candidates = { dop: [] as number[], selfModified: [] as number[] };
|
|
123
|
+
if (coverage !== undefined) {
|
|
124
|
+
const both = COVERAGE.ramWrite | COVERAGE.ramExecute;
|
|
125
|
+
for (let address = range.start; address <= range.end; address++) {
|
|
126
|
+
if ((coverage[address]! & COVERAGE.ramExecute) === 0) continue;
|
|
127
|
+
if (DOPS.has(byteAt(address)!)) candidates.dop.push(address);
|
|
128
|
+
if (runs.some((run) => (run[address]! & both) === both)) candidates.selfModified.push(address);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
regions.sort((a, b) => a.start - b.start);
|
|
133
|
+
const dataRanges = regions.filter((region) => region.type !== "jumps" && region.type !== "dual-use").map(({ start, end }) => ({ start, end }));
|
|
134
|
+
return {
|
|
135
|
+
findings: {
|
|
136
|
+
analyzer: "heuristic",
|
|
137
|
+
coverage: [range],
|
|
138
|
+
authoritative: { symbols: true, regions: true, references: true },
|
|
139
|
+
symbols: [],
|
|
140
|
+
regions,
|
|
141
|
+
// Each reference once: the importer keys them by from, to and kind.
|
|
142
|
+
references: [...new Map(references.map((reference) => [`${reference.from}|${reference.to}|${reference.kind}`, reference])).values()],
|
|
143
|
+
},
|
|
144
|
+
entryPoints: [...entryPoints].sort((a, b) => a - b),
|
|
145
|
+
dataRanges,
|
|
146
|
+
counts,
|
|
147
|
+
candidates,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The structured instruction list (D44): Ghidra's decoded instructions with
|
|
2
|
+
// the addressing mode, the operand in 6502 syntax, the flow target and,
|
|
3
|
+
// with a coverage file, whether the instruction ran.
|
|
4
|
+
|
|
5
|
+
import { COVERAGE, formatC64Address } from "#src/c64.ts";
|
|
6
|
+
import type { GhidraInstruction } from "#src/native/types.ts";
|
|
7
|
+
|
|
8
|
+
export type Mode =
|
|
9
|
+
| "implied"
|
|
10
|
+
| "accumulator"
|
|
11
|
+
| "immediate"
|
|
12
|
+
| "zeropage"
|
|
13
|
+
| "zeropage,x"
|
|
14
|
+
| "zeropage,y"
|
|
15
|
+
| "absolute"
|
|
16
|
+
| "absolute,x"
|
|
17
|
+
| "absolute,y"
|
|
18
|
+
| "(indirect)"
|
|
19
|
+
| "(indirect,x)"
|
|
20
|
+
| "(indirect),y"
|
|
21
|
+
| "relative";
|
|
22
|
+
|
|
23
|
+
const BRANCHES = new Set([0x10, 0x30, 0x50, 0x70, 0x90, 0xb0, 0xd0, 0xf0]);
|
|
24
|
+
const JMP_INDIRECT = 0x6c;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The addressing mode from Ghidra's operand text and the instruction length.
|
|
28
|
+
* Ghidra prints zero page and absolute alike, and JMP (indirect) as an
|
|
29
|
+
* address, so the length and the opcode decide those.
|
|
30
|
+
*/
|
|
31
|
+
export function modeOf(opcode: number, length: number, operands: string): Mode {
|
|
32
|
+
const text = operands.toUpperCase();
|
|
33
|
+
if (text === "") return "implied";
|
|
34
|
+
if (text === "A") return "accumulator";
|
|
35
|
+
if (text.startsWith("#")) return "immediate";
|
|
36
|
+
if (text.startsWith("(") && text.endsWith(",X)")) return "(indirect,x)";
|
|
37
|
+
if (text.startsWith("(") && text.endsWith("),Y")) return "(indirect),y";
|
|
38
|
+
if (text.endsWith(",X")) return length === 2 ? "zeropage,x" : "absolute,x";
|
|
39
|
+
if (text.endsWith(",Y")) return length === 2 ? "zeropage,y" : "absolute,y";
|
|
40
|
+
if (BRANCHES.has(opcode)) return "relative";
|
|
41
|
+
if (opcode === JMP_INDIRECT) return "(indirect)";
|
|
42
|
+
return length === 2 ? "zeropage" : "absolute";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The operand in 6502 syntax, from the instruction bytes; empty for implied. */
|
|
46
|
+
export function operandText(mode: Mode, bytes: Uint8Array, target: number | undefined): string {
|
|
47
|
+
const byte = `$${(bytes[1] ?? 0).toString(16).padStart(2, "0")}`;
|
|
48
|
+
const word = formatC64Address((bytes[1] ?? 0) | ((bytes[2] ?? 0) << 8));
|
|
49
|
+
switch (mode) {
|
|
50
|
+
case "implied":
|
|
51
|
+
return "";
|
|
52
|
+
case "accumulator":
|
|
53
|
+
return "a";
|
|
54
|
+
case "immediate":
|
|
55
|
+
return `#${byte}`;
|
|
56
|
+
case "zeropage":
|
|
57
|
+
return byte;
|
|
58
|
+
case "zeropage,x":
|
|
59
|
+
return `${byte},x`;
|
|
60
|
+
case "zeropage,y":
|
|
61
|
+
return `${byte},y`;
|
|
62
|
+
case "absolute":
|
|
63
|
+
return word;
|
|
64
|
+
case "absolute,x":
|
|
65
|
+
return `${word},x`;
|
|
66
|
+
case "absolute,y":
|
|
67
|
+
return `${word},y`;
|
|
68
|
+
case "(indirect)":
|
|
69
|
+
return `(${word})`;
|
|
70
|
+
case "(indirect,x)":
|
|
71
|
+
return `(${byte},x)`;
|
|
72
|
+
case "(indirect),y":
|
|
73
|
+
return `(${byte}),y`;
|
|
74
|
+
case "relative":
|
|
75
|
+
return target === undefined ? byte : formatC64Address(target);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ListedInstruction {
|
|
80
|
+
address: string;
|
|
81
|
+
bytes: string;
|
|
82
|
+
mnemonic: string;
|
|
83
|
+
mode: Mode;
|
|
84
|
+
operand: string;
|
|
85
|
+
target?: string;
|
|
86
|
+
executed?: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Ghidra's instructions as the list that --instructions writes. */
|
|
90
|
+
export function instructionList(instructions: GhidraInstruction[], coverage?: Uint8Array): ListedInstruction[] {
|
|
91
|
+
return instructions.map((instruction) => {
|
|
92
|
+
const bytes = Buffer.from(instruction.bytes, "hex");
|
|
93
|
+
const mode = modeOf(bytes[0]!, bytes.length, instruction.operands);
|
|
94
|
+
const [target] = instruction.flows;
|
|
95
|
+
return {
|
|
96
|
+
address: formatC64Address(instruction.address),
|
|
97
|
+
bytes: (instruction.bytes.match(/../g) ?? []).join(" "),
|
|
98
|
+
mnemonic: instruction.mnemonic,
|
|
99
|
+
mode,
|
|
100
|
+
operand: operandText(mode, bytes, target),
|
|
101
|
+
...(target === undefined ? {} : { target: formatC64Address(target) }),
|
|
102
|
+
...(coverage === undefined ? {} : { executed: (coverage[instruction.address]! & COVERAGE.ramExecute) !== 0 }),
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Pointer candidates (D65): code that builds an address from immediates. The
|
|
2
|
+
// byte of each immediate is the low or high byte of the address, so a rebuild
|
|
3
|
+
// that moves the address needs an operand expression there. They are only
|
|
4
|
+
// reported, never written: the LLM records the expressions with c64-knowledge.
|
|
5
|
+
//
|
|
6
|
+
// The scan reads short straight runs of instructions: LDA/LDX/LDY #imm and
|
|
7
|
+
// STA/STX/STY to zero page or absolute, plus SEI, CLI, CLC, SEC and NOP. Any
|
|
8
|
+
// other instruction ends the run, so no register value is guessed across it.
|
|
9
|
+
|
|
10
|
+
import { instructionLength } from "#src/c64.ts";
|
|
11
|
+
import type { ImageByte } from "./coverage.ts";
|
|
12
|
+
|
|
13
|
+
const LOADS: Record<number, "a" | "x" | "y"> = { 0xa9: "a", 0xa2: "x", 0xa0: "y" };
|
|
14
|
+
const STORES: Record<number, "a" | "x" | "y"> = { 0x85: "a", 0x8d: "a", 0x86: "x", 0x8e: "x", 0x84: "y", 0x8c: "y" };
|
|
15
|
+
const NEUTRAL = new Set([0x78, 0x58, 0x18, 0x38, 0xea]);
|
|
16
|
+
/** The instructions of one run: enough for two loads, two stores and a few neutral ones. */
|
|
17
|
+
const MAX_RUN = 8;
|
|
18
|
+
/** The vectors that the KERNAL and the CPU read: IRQ, BRK, NMI, and the hardware vectors. */
|
|
19
|
+
const VECTORS = new Set([0x0314, 0x0316, 0x0318, 0xfffa, 0xfffc, 0xfffe]);
|
|
20
|
+
/** (zp),y: ORA AND EOR ADC STA LDA CMP SBC. */
|
|
21
|
+
const INDIRECT_Y = new Set([0x11, 0x31, 0x51, 0x71, 0x91, 0xb1, 0xd1, 0xf1]);
|
|
22
|
+
/** The sprite pointers of the default screen at $0400 in VIC bank 0. */
|
|
23
|
+
const SPRITE_POINTERS = { start: 0x07f8, end: 0x07ff };
|
|
24
|
+
|
|
25
|
+
export type PointerCandidate =
|
|
26
|
+
| {
|
|
27
|
+
kind: "vector" | "zero-page pointer";
|
|
28
|
+
/** The vector or zero-page pair that gets the address. */
|
|
29
|
+
to: number;
|
|
30
|
+
/** The address that the two immediates make. */
|
|
31
|
+
target: number;
|
|
32
|
+
/** The instructions with the low and the high byte as immediate. */
|
|
33
|
+
low: number;
|
|
34
|
+
high: number;
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
kind: "sprite pointer";
|
|
38
|
+
to: number;
|
|
39
|
+
/** value × 64: the sprite data, in VIC bank 0. */
|
|
40
|
+
target: number;
|
|
41
|
+
/** The instruction with the pointer as immediate. */
|
|
42
|
+
at: number;
|
|
43
|
+
value: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
interface Store {
|
|
47
|
+
/** The address of the load of the immediate. */
|
|
48
|
+
load: number;
|
|
49
|
+
value: number;
|
|
50
|
+
to: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The stores of immediates in the straight run that starts at `start`. */
|
|
54
|
+
function storesFrom(byteAt: ImageByte, start: number, range: { start: number; end: number }): Store[] {
|
|
55
|
+
const last: Partial<Record<"a" | "x" | "y", { load: number; value: number }>> = {};
|
|
56
|
+
const stores: Store[] = [];
|
|
57
|
+
let at = start;
|
|
58
|
+
for (let count = 0; count < MAX_RUN; count++) {
|
|
59
|
+
const opcode = byteAt(at);
|
|
60
|
+
if (opcode === undefined) break;
|
|
61
|
+
const length = instructionLength(opcode);
|
|
62
|
+
if (at + length - 1 > range.end) break;
|
|
63
|
+
const operand = length === 2 ? byteAt(at + 1)! : length === 3 ? byteAt(at + 1)! | (byteAt(at + 2)! << 8) : 0;
|
|
64
|
+
if (LOADS[opcode] !== undefined) last[LOADS[opcode]] = { load: at, value: operand };
|
|
65
|
+
else if (STORES[opcode] !== undefined) {
|
|
66
|
+
const loaded = last[STORES[opcode]];
|
|
67
|
+
if (loaded === undefined) break;
|
|
68
|
+
stores.push({ ...loaded, to: operand });
|
|
69
|
+
} else if (!NEUTRAL.has(opcode)) break;
|
|
70
|
+
at += length;
|
|
71
|
+
}
|
|
72
|
+
return stores;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Finds the pointer candidates in the image: an immediate pair stored to a
|
|
77
|
+
* vector or to a zero-page pair that (zp),y uses, where the pair makes an
|
|
78
|
+
* address in the image or a platform name; and an immediate stored to a sprite
|
|
79
|
+
* pointer of the default screen whose sprite data is in a sprite region.
|
|
80
|
+
*/
|
|
81
|
+
export function findPointers(
|
|
82
|
+
byteAt: ImageByte,
|
|
83
|
+
range: { start: number; end: number },
|
|
84
|
+
known: { platform: (address: number) => boolean; sprites: ReadonlyArray<{ start: number; end: number }> },
|
|
85
|
+
): PointerCandidate[] {
|
|
86
|
+
const indirect = new Set<number>();
|
|
87
|
+
for (let at = range.start; at < range.end; at++) if (INDIRECT_Y.has(byteAt(at)!)) indirect.add(byteAt(at + 1)!);
|
|
88
|
+
const inImage = (address: number) => address >= range.start && address <= range.end && byteAt(address) !== undefined;
|
|
89
|
+
const found = new Map<string, PointerCandidate>();
|
|
90
|
+
for (let at = range.start; at <= range.end; at++) {
|
|
91
|
+
if (LOADS[byteAt(at)!] === undefined) continue;
|
|
92
|
+
const stores = storesFrom(byteAt, at, range);
|
|
93
|
+
for (const low of stores) {
|
|
94
|
+
const high = stores.find((store) => store.to === low.to + 1);
|
|
95
|
+
const target = high === undefined ? undefined : low.value | (high.value << 8);
|
|
96
|
+
const kind = VECTORS.has(low.to) ? "vector" : low.to < 0xff && indirect.has(low.to) ? "zero-page pointer" : undefined;
|
|
97
|
+
if (high !== undefined && target !== undefined && kind !== undefined && (inImage(target) || known.platform(target))) {
|
|
98
|
+
found.set(`${low.load}|${high.load}`, { kind, to: low.to, target, low: low.load, high: high.load });
|
|
99
|
+
}
|
|
100
|
+
const sprite = low.value * 64;
|
|
101
|
+
if (low.to >= SPRITE_POINTERS.start && low.to <= SPRITE_POINTERS.end && known.sprites.some((region) => sprite >= region.start && sprite <= region.end)) {
|
|
102
|
+
found.set(`${low.load}`, { kind: "sprite pointer", to: low.to, target: sprite, at: low.load, value: low.value });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const first = (candidate: PointerCandidate) => (candidate.kind === "sprite pointer" ? candidate.at : Math.min(candidate.low, candidate.high));
|
|
107
|
+
return [...found.values()].sort((a, b) => first(a) - first(b));
|
|
108
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: c64-testing
|
|
3
|
+
description: Use this skill to test if a rebuilt or changed C64 program behaves as the original. It runs one scenario on the original and then on the rebuild in an emulator. It compares memory, registers, chip state and the screen with explicit rules, and gives PASS, FAIL or INCONCLUSIVE. It also makes a checklist for a human playtest. Use its run command to explore a program with many steps, for example to visit each room or level. A run keeps screens, coverage maps and snapshots as files. Do not use it to find the cause of a difference.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# c64-testing
|
|
7
|
+
|
|
8
|
+
## Purpose
|
|
9
|
+
|
|
10
|
+
Test if a rebuild or a changed program behaves as the original in a scenario. Give one result: PASS, FAIL or INCONCLUSIVE.
|
|
11
|
+
|
|
12
|
+
A PASS proves only the behavior that the scenario does and observes. It does not prove that the two programs are equal in all behavior.
|
|
13
|
+
|
|
14
|
+
## Inputs
|
|
15
|
+
|
|
16
|
+
- The original program and the rebuild: PRG files in the project.
|
|
17
|
+
- The symbols of each side. For the original, the project knowledge. For the rebuild, the JSON output of the c64-assembler skill in a file.
|
|
18
|
+
- A scenario file: the steps, the checkpoints and the observations. See [references/scenario-format.md](references/scenario-format.md).
|
|
19
|
+
|
|
20
|
+
## Tools / execution path
|
|
21
|
+
|
|
22
|
+
The script starts one emulator through the host runtime. It runs the scenario on the original and then on the rebuild. Before each side, it does a hard reset of the emulator. The CPU then stops at the reset vector.
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
node <skill>/scripts/test.ts tests/moves-left.json --out results/moves-left
|
|
26
|
+
node <skill>/scripts/checklist.ts tests/moves-left.json --item 'FIRE starts the game.'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The test script prints one JSON object. The exit status is 0 for PASS, 1 for FAIL, 3 for INCONCLUSIVE and 2 for a bad call. With `--out`, a FAIL writes files into that directory: both screens and the difference image for each screen difference, and `memory.json` with each byte that differs. `files` in the result names them.
|
|
30
|
+
|
|
31
|
+
The checklist script prints a Markdown checklist for a human tester.
|
|
32
|
+
|
|
33
|
+
### Runs
|
|
34
|
+
|
|
35
|
+
A run does steps on one program, with no comparison, and keeps the results as files. Use it for many steps. One run replaces many emulator calls, for example a visit to each room of a game.
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
node <skill>/scripts/run.ts runs/rooms.json --out runs/rooms
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The run file gives where to start (a program, or a snapshot file), the steps, and optional symbols. To go on from the live emulator, save its state with `c64_snapshot` action `save` and `out`, and start the run from that file. Besides the steps of a scenario, a run has these steps:
|
|
42
|
+
|
|
43
|
+
| Step | Effect |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `{ "snapshot": "snapshots/room0.vsf" }` | Restore a snapshot file. |
|
|
46
|
+
| `{ "save": "room3" }` | Write the state now to `room3.vsf` in the output directory. |
|
|
47
|
+
| `{ "screen": "room3" }` | Write the screen to `room3.png`. |
|
|
48
|
+
| `{ "coverage": "room3" }` | Write the coverage map since the start or the last coverage step to `room3.bin`, then clear the map. |
|
|
49
|
+
| `{ "observe": "room3", "memory": ["$c100"], "registers": true }` | Record memory, registers, `vicii` or `sprites` in `results.json`. |
|
|
50
|
+
|
|
51
|
+
See [references/scenario-format.md](references/scenario-format.md), section Runs. The script prints one JSON object. The exit status is 0 when all steps ran, 1 when a step did not reach its target, and 2 for a bad call. A long run can need more time than one shell call. Then run it in the background.
|
|
52
|
+
|
|
53
|
+
## Workflow
|
|
54
|
+
|
|
55
|
+
1. Write the scenario file. Start the steps with a frame count, for example `{ "frames": 150 }`. Each side starts at the reset vector, and the C64 needs these frames to start.
|
|
56
|
+
2. Use symbol names, for example `player_x`, not fixed addresses. Each side finds the name in its own symbols. Use a `$` address only when both programs use the same address.
|
|
57
|
+
3. Make each checkpoint a condition, for example `runUntil` at a routine. Do not wait for a time.
|
|
58
|
+
4. Save the c64-assembler output of the rebuild to the symbol file: `assemble.ts ... > build/game.json`.
|
|
59
|
+
5. Run the test script.
|
|
60
|
+
6. Read the result:
|
|
61
|
+
- `PASS`: all observations are equal, or the differences are in the allowed tolerance.
|
|
62
|
+
- `FAIL`: `differences` lists each difference with the checkpoint, the item, the original value and the rebuild value.
|
|
63
|
+
- `INCONCLUSIVE`: `reason` tells why the script could not compare. This is not a failure of the program.
|
|
64
|
+
7. For a FAIL, examine the first difference with the c64-emulator skill.
|
|
65
|
+
|
|
66
|
+
## Knowledge
|
|
67
|
+
|
|
68
|
+
Read:
|
|
69
|
+
- the symbols of the original from the project knowledge.
|
|
70
|
+
|
|
71
|
+
Automatic write:
|
|
72
|
+
- none.
|
|
73
|
+
|
|
74
|
+
Do not store:
|
|
75
|
+
- test results
|
|
76
|
+
- screenshots
|
|
77
|
+
- playtest results.
|
|
78
|
+
|
|
79
|
+
## Result
|
|
80
|
+
|
|
81
|
+
PASS, FAIL or INCONCLUSIVE, the checkpoints and the smallest differences that explain a FAIL.
|
|
82
|
+
|
|
83
|
+
## Failure and conflicts
|
|
84
|
+
|
|
85
|
+
- `invalid-input`: the scenario file has an error. The message names the step. Change the step and run the test again.
|
|
86
|
+
- INCONCLUSIVE, "did not reach the checkpoint": one side did not reach the checkpoint in the frame limit. Find the reason with the c64-emulator skill. Do not report a FAIL.
|
|
87
|
+
- INCONCLUSIVE, "has no symbol": one side has no symbol with that name. Add the name to the knowledge or to the rebuild source.
|
|
88
|
+
- INCONCLUSIVE, "no emulator" or "could not run": the host runtime or the emulator failed. Tell the user the message.
|
|
89
|
+
- `installation-incomplete`, or `machine-unavailable` with a message that the host runtime does not run: stop the task. Give the message to the user and wait for the user. The message gives the remedy. Do not make a replacement for the tool or the host runtime, for example your own disk parser, disassembler or emulator script.
|
|
90
|
+
- A FAIL with only a `screen` difference: add `vicii` or `sprites` to the observation to find which chip state is different.
|
|
91
|
+
- A program that uses random numbers: set the random seed with a `write` step on both sides. If that is not possible, observe only values that do not depend on the random numbers.
|
|
92
|
+
- The two sides start differently, for example the original after a cracked intro. Give the steps that only one side needs a `side`. Then make the memory the same with a `normalize` step from the entry state of the c64-unpacker skill.
|
|
93
|
+
- Frame counts drift by one frame between the sides: the sides got to the same address at different raster positions. Do not sync on an IRQ handler. Sync on a value that the IRQ changes, with `runUntil` `memory`, or on an address and a raster line together.
|
|
94
|
+
- A byte that cannot be the same on both sides, for example a read-only collision register: use `exclude` with the reason. Do not use `exclude` to hide a real difference.
|
|
95
|
+
|
|
96
|
+
Do not add a mask or a tolerance only to make a test pass. A tolerance must have a reason in the behavior of the program.
|
|
97
|
+
|
|
98
|
+
## Handoffs
|
|
99
|
+
|
|
100
|
+
- Find the cause of a difference → c64-emulator
|
|
101
|
+
- Find the code that causes it → c64-static-analysis
|
|
102
|
+
- Rebuild after a correction → c64-assembler
|
|
103
|
+
- Record a confirmed conclusion → c64-knowledge
|
|
@@ -0,0 +1,116 @@
|
|
|
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 the program of the side, type `RUN` and wait until the program runs. The program must have a BASIC start line. The keys of a later `type` step go to the program. |
|
|
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. The keys go into the keyboard queue of the KERNAL. A program that reads the keyboard hardware does not see them. A step cannot press one specific key of the keyboard matrix. |
|
|
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
|
+
| `{ "runUntil": { "raster": 250 }, "timeoutFrames": 5 }` | Run until the raster beam gets to a line (0 to 311 for PAL, 0 to 262 for NTSC). |
|
|
51
|
+
| `{ "runUntil": { "at": "irq_tick", "raster": 250 }, "timeoutFrames": 600 }` | Run until the CPU gets to an address while the beam is on that line. |
|
|
52
|
+
| `{ "snapshot": "snapshots/start.vsf" }` | Restore a snapshot file. Usually with `side`: the snapshot of one program is not a state of the other. |
|
|
53
|
+
| `{ "normalize": "unpacked/entry_state.json" }` | Write pages 0 to 3 and `$00`/`$01` from an entry state file of the c64-unpacker skill. Then both sides have the same zero page, stack and KERNAL work area. |
|
|
54
|
+
| `{ "observe": "moved", ... }` | Record a checkpoint. |
|
|
55
|
+
|
|
56
|
+
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`.
|
|
57
|
+
|
|
58
|
+
`timeoutFrames` is from 1 to 30000. A `runUntil` that does not get to its target in `timeoutFrames` frames makes the result INCONCLUSIVE.
|
|
59
|
+
|
|
60
|
+
Each step except `observe` can have `"side": "original"` or `"side": "rebuild"`. Then only that side does the step. Use it when only one side needs a step, for example a key for the intro of the original. An `observe` step has no `side`, because it compares both sides.
|
|
61
|
+
|
|
62
|
+
An address is `$` and four hex digits, or a symbol name. Each side finds a name in its own symbols.
|
|
63
|
+
|
|
64
|
+
## Observations
|
|
65
|
+
|
|
66
|
+
An `observe` step has a checkpoint name. A scenario can have each name only one time. The step can have these keys:
|
|
67
|
+
|
|
68
|
+
| Key | What the script compares |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `memory` | A list of names, `$` addresses, or objects with `at`, `size` (1 to 4096, default 1), `tolerance` (0 to 255, default 0) and `exclude`. |
|
|
71
|
+
| `registers` | A list of register names: `pc`, `a`, `x`, `y`, `sp`, `flags`. |
|
|
72
|
+
| `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. |
|
|
73
|
+
| `vicii` | The VIC-II state: mode, screen address, scroll and colors. |
|
|
74
|
+
| `sprites` | A list of sprite numbers from 0 to 7. The script compares their position, color and flags. |
|
|
75
|
+
|
|
76
|
+
A `tolerance` is the largest permitted difference for each byte. A `mask` has at most 64 rectangles. A `mask` must not cover all of the screen: PAL is 384×272 pixels and NTSC is 384×247 pixels. A scenario can observe the screen at most at 64 checkpoints.
|
|
77
|
+
|
|
78
|
+
`exclude` is a list of bytes in the observed range that the comparison does not include. Each has `at` and a `reason`, for example `{ "at": "$d01e", "reason": "latched sprite collision, not writable" }`. The result lists each excluded byte with its reason.
|
|
79
|
+
|
|
80
|
+
A `pc` value is an address. Compare `pc` only when both programs have the code at the same address.
|
|
81
|
+
|
|
82
|
+
## Runs
|
|
83
|
+
|
|
84
|
+
A run file (for `run.ts`) has:
|
|
85
|
+
|
|
86
|
+
- `name`: a short name.
|
|
87
|
+
- `start`: `{ "program": "game.prg" }` to autostart a program with a BASIC start line, `{ "program": "game.prg", "load": true }` to load it with the CPU stopped (set `pc` in a step), or `{ "snapshot": "snapshots/room0.vsf" }`.
|
|
88
|
+
- `symbols`: optional, `knowledge`, `none` (default), or a JSON file as for a subject.
|
|
89
|
+
- `videoStandard`: optional, `pal` (default) or `ntsc`.
|
|
90
|
+
- `steps`: the steps `write`, `registers`, `joystick`, `type`, `frames` and `runUntil` of a scenario, and the run steps `snapshot`, `save`, `screen`, `coverage` and `observe`.
|
|
91
|
+
|
|
92
|
+
A name in `save`, `screen`, `coverage` and `observe` has 1 to 64 letters, digits, dots, underscores or hyphens. The files go into the `--out` directory, with `results.json`. It has the observations, the files and the steps done. If a `runUntil` step did not reach its target, it also tells why the run stopped.
|
|
93
|
+
|
|
94
|
+
Example: visit two rooms from the same start.
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"name": "rooms",
|
|
99
|
+
"start": { "snapshot": "snapshots/room0.vsf" },
|
|
100
|
+
"steps": [
|
|
101
|
+
{ "write": { "at": "$0350", "bytes": [1] } },
|
|
102
|
+
{ "frames": 50 },
|
|
103
|
+
{ "screen": "room1" },
|
|
104
|
+
{ "coverage": "room1" },
|
|
105
|
+
{ "snapshot": "snapshots/room0.vsf" },
|
|
106
|
+
{ "write": { "at": "$0350", "bytes": [2] } },
|
|
107
|
+
{ "frames": 50 },
|
|
108
|
+
{ "screen": "room2" },
|
|
109
|
+
{ "coverage": "room2" }
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Checks
|
|
115
|
+
|
|
116
|
+
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
|
+
);
|