@henols/c64-re-tools 2.1.0-rc.2 → 2.1.1
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/package.json +1 -1
- package/skills/c64-assembler/SKILL.md +1 -0
- package/skills/c64-assembler/scripts/assembler.ts +10 -0
- package/skills/c64-disk/SKILL.md +2 -2
- package/skills/c64-disk/scripts/disk.ts +24 -10
- package/skills/c64-disk/scripts/lib/containers.ts +9 -9
- package/skills/c64-docs/scripts/lib/links.ts +3 -3
- package/skills/c64-docs/scripts/lib/reference.ts +2 -2
- package/skills/c64-emulator/SKILL.md +8 -2
- package/skills/c64-knowledge/SKILL.md +3 -1
- package/skills/c64-knowledge/scripts/knowledge.ts +59 -43
- package/skills/c64-memory-map/SKILL.md +1 -1
- package/skills/c64-memory-map/scripts/memory-map.ts +3 -2
- package/skills/c64-provenance/SKILL.md +5 -4
- package/skills/c64-provenance/scripts/lib/library.ts +18 -6
- package/skills/c64-provenance/scripts/lib/separate.ts +45 -6
- package/skills/c64-provenance/scripts/provenance.ts +5 -4
- package/skills/c64-source/SKILL.md +2 -0
- package/skills/c64-source/scripts/lib/source.ts +2 -2
- package/skills/c64-source/scripts/source.ts +10 -6
- package/skills/c64-static-analysis/scripts/lib/idioms.ts +6 -2
- package/skills/c64-static-analysis/scripts/static-analysis.ts +4 -3
- package/skills/c64-testing/SKILL.md +11 -1
- package/skills/c64-testing/references/recording-format.md +2 -0
- package/skills/c64-testing/references/scenario-format.md +6 -0
- package/skills/c64-testing/scripts/lib/diff-files.ts +63 -13
- package/skills/c64-testing/scripts/lib/diff.ts +3 -3
- package/skills/c64-testing/scripts/lib/recording.ts +47 -12
- package/skills/c64-testing/scripts/lib/replay.ts +11 -0
- package/skills/c64-testing/scripts/lib/runs.ts +64 -4
- package/skills/c64-testing/scripts/lib/scenario.ts +43 -6
- package/skills/c64-testing/scripts/testing.ts +15 -2
- package/skills/c64-unpacker/SKILL.md +9 -5
- package/skills/c64-unpacker/scripts/lib/evidence.ts +4 -5
- package/skills/c64-unpacker/scripts/lib/loads.ts +3 -2
- package/skills/c64-unpacker/scripts/unpacker.ts +75 -25
- package/src/host/vice/session.ts +99 -10
- package/src/host-client/image.ts +32 -0
- package/src/host-client/steps.ts +106 -6
- package/src/host-client/tools.ts +1 -1
- package/src/host-client/transfer.ts +35 -8
- package/src/knowledge/expression.ts +1 -1
- package/src/knowledge/history.ts +19 -2
- package/src/knowledge/names.ts +2 -1
- package/src/knowledge/program.ts +1 -16
- package/src/knowledge/read.ts +43 -0
- package/src/knowledge/schema.ts +3 -2
- package/src/knowledge/text.ts +1 -0
- package/src/knowledge/write.ts +40 -6
- package/src/mcp/output.ts +5 -0
- package/src/mcp/server.ts +19 -4
- package/src/mcp/tools/debug.ts +7 -6
- package/src/mcp/tools/execution.ts +18 -5
- package/src/mcp/tools/media.ts +14 -2
- package/src/mcp/tools/memory.ts +7 -3
- package/src/mcp/tools/video.ts +5 -3
- package/src/project.ts +1 -1
- package/src/protocol/vice.ts +4 -1
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
// its start, so match finds it in another program at another address and
|
|
5
5
|
// suggests its names there as an apply batch. Nothing here writes knowledge.
|
|
6
6
|
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
7
8
|
import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
8
|
-
import { join, resolve } from "node:path";
|
|
9
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
9
10
|
|
|
10
11
|
import { Refusal } from "#src/errors.ts";
|
|
12
|
+
import { writeRefusal, type Written } from "#src/host-client/transfer.ts";
|
|
11
13
|
import { formatC64Address, instructionLength } from "#src/c64.ts";
|
|
12
14
|
import { evaluate, identifiers, operandText } from "#src/knowledge/expression.ts";
|
|
13
15
|
import type { CommentRow, OperandRow, ReferenceRow, RegionRow, SymbolRow } from "#src/knowledge/read.ts";
|
|
@@ -317,16 +319,26 @@ export function readLibrary(folder: string): Routine[] {
|
|
|
317
319
|
.map((name) => parseRoutine(readFileSync(join(folder, name), "utf8"), name));
|
|
318
320
|
}
|
|
319
321
|
|
|
320
|
-
/**
|
|
321
|
-
|
|
322
|
+
/**
|
|
323
|
+
* Writes a routine file into the library: a temporary file, then a rename.
|
|
324
|
+
* Refuses a name that exists unless `replace`. Reports the file as a written
|
|
325
|
+
* file: its path relative to the project folder `root`, its size and its SHA-256.
|
|
326
|
+
*/
|
|
327
|
+
export function saveRoutine(folder: string, routine: Routine, replace: boolean, root = process.cwd()): Written {
|
|
322
328
|
const file = join(folder, `${routine.name}.json`);
|
|
329
|
+
const out = relative(root, file).split(sep).join("/");
|
|
323
330
|
if (!replace && statSync(file, { throwIfNoEntry: false }) !== undefined) {
|
|
324
331
|
throw new Refusal("conflict", `The library already has the routine ${routine.name}. Give another --name, or give --replace to save over it.`);
|
|
325
332
|
}
|
|
333
|
+
const bytes = Buffer.from(routineText(routine), "utf8");
|
|
326
334
|
const temporary = `${file}.${process.pid}.tmp`;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
335
|
+
try {
|
|
336
|
+
writeFileSync(temporary, bytes);
|
|
337
|
+
renameSync(temporary, file);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
throw writeRefusal(error, out, "--library") ?? error;
|
|
340
|
+
}
|
|
341
|
+
return { out, bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") };
|
|
330
342
|
}
|
|
331
343
|
|
|
332
344
|
/** An image to search: its segments and its bytes inside them. */
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Evidence from one release: for each range of the image, the unpack
|
|
2
2
|
// stage that wrote it and the access class from coverage; for each code
|
|
3
|
-
// region of knowledge, whether call and jump references
|
|
4
|
-
// entry; and what the program reads
|
|
5
|
-
// only: nothing here decides what is
|
|
3
|
+
// region of knowledge, whether call and jump references or the run on from
|
|
4
|
+
// its last instruction reach it from the entry; and what the program reads
|
|
5
|
+
// or executes outside the image. Evidence only: nothing here decides what is
|
|
6
|
+
// original.
|
|
6
7
|
|
|
7
|
-
import { type AddressRange, COVERAGE, executedRamCode } from "#src/c64.ts";
|
|
8
|
+
import { type AddressRange, COVERAGE, executedRamCode, instructionLength } from "#src/c64.ts";
|
|
8
9
|
|
|
9
10
|
export type Access = "executed" | "read" | "written" | "read-written" | "untouched";
|
|
10
11
|
|
|
@@ -49,6 +50,27 @@ export interface Separation {
|
|
|
49
50
|
|
|
50
51
|
const SYSTEM_PAGES_END = 0x03ff;
|
|
51
52
|
|
|
53
|
+
/** BRK, JMP, RTI, RTS, JMP (indirect) and the JAM opcodes: the CPU does not run on to the next instruction after them. */
|
|
54
|
+
const NO_FALL_THROUGH = new Set([0x00, 0x4c, 0x40, 0x60, 0x6c, 0x02, 0x12, 0x22, 0x32, 0x42, 0x52, 0x62, 0x72, 0x92, 0xb2, 0xd2, 0xf2]);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The address after a code region where the CPU runs on, or undefined when
|
|
58
|
+
* its last instruction does not run on (RTS, RTI, JMP, BRK, JAM). The
|
|
59
|
+
* instructions follow one another from the start of the region.
|
|
60
|
+
*/
|
|
61
|
+
export function fallThrough(region: AddressRange, memory: Uint8Array): number | undefined {
|
|
62
|
+
let at = region.start;
|
|
63
|
+
let last = at;
|
|
64
|
+
while (at <= region.end) {
|
|
65
|
+
last = at;
|
|
66
|
+
at += instructionLength(memory[at]!);
|
|
67
|
+
}
|
|
68
|
+
return NO_FALL_THROUGH.has(memory[last]!) || at > 0xffff ? undefined : at;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The most instructions outside the code regions that the run on from a region goes through, for example an instruction in a dual-use region. */
|
|
72
|
+
const MAX_RUN_ON = 16;
|
|
73
|
+
|
|
52
74
|
/** Joins addresses in order into ranges; `gap` is the most bytes between two addresses in one range. */
|
|
53
75
|
function merge(addresses: number[], gap = 0, joins: (from: number, to: number) => boolean = () => true): AddressRange[] {
|
|
54
76
|
const ranges: AddressRange[] = [];
|
|
@@ -92,12 +114,29 @@ export function separate(input: SeparationInput): Separation {
|
|
|
92
114
|
}
|
|
93
115
|
}
|
|
94
116
|
|
|
95
|
-
// Reachability: from the region that holds the entry, through call and jump references
|
|
117
|
+
// Reachability: from the region that holds the entry, through call and jump references and the run on
|
|
118
|
+
// from the last instruction of a region into the next address, to a fixed point.
|
|
96
119
|
const regions = input.codeRegions.filter((region) => input.segments.some((segment) => region.start <= segment.end && segment.start <= region.end));
|
|
97
120
|
const holding = (address: number) => regions.findIndex((region) => address >= region.start && address <= region.end);
|
|
98
121
|
const reached = new Set<number>();
|
|
99
122
|
if (input.entry !== undefined && holding(input.entry) >= 0) reached.add(holding(input.entry));
|
|
100
|
-
|
|
123
|
+
// The CPU runs the bytes after the region as instructions: through bytes of no code region (a dual-use
|
|
124
|
+
// instruction, for example) into the next code region, unless one of them does not run on.
|
|
125
|
+
const runOn = (from: number): number | undefined => {
|
|
126
|
+
let at = from;
|
|
127
|
+
for (let count = 0; count <= MAX_RUN_ON && at <= 0xffff && inImage(at); count++) {
|
|
128
|
+
if (holding(at) >= 0) return at;
|
|
129
|
+
if (NO_FALL_THROUGH.has(input.memory[at]!)) return undefined;
|
|
130
|
+
at += instructionLength(input.memory[at]!);
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
};
|
|
134
|
+
const runsOn = regions.flatMap((region) => {
|
|
135
|
+
const next = fallThrough(region, input.memory);
|
|
136
|
+
const to = next === undefined ? undefined : runOn(next);
|
|
137
|
+
return to === undefined ? [] : [{ from: region.end, to }];
|
|
138
|
+
});
|
|
139
|
+
const flows = [...input.references.filter((reference) => reference.kind === "call" || reference.kind === "jump"), ...runsOn];
|
|
101
140
|
for (let grown = true; grown; ) {
|
|
102
141
|
grown = false;
|
|
103
142
|
for (const flow of flows) {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// routines that several programs share in a library folder and find them in the
|
|
9
9
|
// program (D71).
|
|
10
10
|
|
|
11
|
-
import { basename
|
|
11
|
+
import { basename } from "node:path";
|
|
12
12
|
|
|
13
13
|
import { type AddressRange, c64AddressOf, formatC64Address } from "#src/c64.ts";
|
|
14
14
|
import { Refusal } from "#src/errors.ts";
|
|
@@ -108,6 +108,7 @@ async function compare(call: Call): Promise<unknown> {
|
|
|
108
108
|
for (const text of call.strings("shift")) {
|
|
109
109
|
const match = /^(\d+):([+-]?\d+)$/.exec(text);
|
|
110
110
|
if (match === null || Number(match[1]) < 1 || Number(match[1]) > positionals.length) throw new UsageError(`--shift must be <release number>:<bytes>, not ${text}`);
|
|
111
|
+
if (shifts.has(Number(match[1]))) throw new UsageError(`--shift gives release ${match[1]} more than one shift (${shifts.get(Number(match[1]))} and ${match[2]}). Give one --shift for each release`);
|
|
111
112
|
shifts.set(Number(match[1]), Number(match[2]));
|
|
112
113
|
}
|
|
113
114
|
const frames = call.count("frames");
|
|
@@ -364,10 +365,10 @@ function routineCommand(call: Call): unknown {
|
|
|
364
365
|
operands: listOperands(db, range, SEMANTIC_ORIGINS),
|
|
365
366
|
platform: platform(),
|
|
366
367
|
});
|
|
367
|
-
const
|
|
368
|
+
const written = saveRoutine(libraryFolder(library, { create: true, cwd: projectRoot() }), routine, call.flag("replace"), projectRoot());
|
|
368
369
|
return {
|
|
369
370
|
routine: routine.name,
|
|
370
|
-
|
|
371
|
+
...written,
|
|
371
372
|
start: formatC64Address(start),
|
|
372
373
|
end: formatC64Address(end),
|
|
373
374
|
length: routine.bytes.length,
|
|
@@ -460,7 +461,7 @@ const COMMANDS: CommandSpec[] = [
|
|
|
460
461
|
},
|
|
461
462
|
{
|
|
462
463
|
name: "separate",
|
|
463
|
-
summary: "Evidence from the program in the knowledge: the unpack stage that wrote each range, its access in the coverage, whether call and jump references reach each code region from the entry, and what the program reads or executes outside its segments.",
|
|
464
|
+
summary: "Evidence from the program in the knowledge: the unpack stage that wrote each range, its access in the coverage, whether call and jump references, or the run on from the code before it, reach each code region from the entry, and what the program reads or executes outside its segments.",
|
|
464
465
|
options: { out: { ...OPTIONS.out, value: "<file>", help: "write all ranges to this file" }, "max-listed": OPTIONS["max-listed"] },
|
|
465
466
|
run: separateCommand,
|
|
466
467
|
},
|
|
@@ -150,6 +150,7 @@ Do not store:
|
|
|
150
150
|
- `operandsAsNumbers`: the operand expressions that the source has as a number with a side comment, each with its `address`, `expression` and `reason`. An expression becomes a number when no instruction with an operand value is at the address, or when a name has no address. An expression that ACME refuses or does not assemble to the program also becomes a number. Then the script assembles the source again, at most six times.
|
|
151
151
|
- `unusedModules`: modules in the knowledge whose start is in no segment of the program. Often this tells that the program does not hold code that the knowledge describes.
|
|
152
152
|
- `entryOutside`: the entry is in no segment of the program in the knowledge, with a `reason`. The capture does not hold the program.
|
|
153
|
+
- `dataOnly`: the knowledge has no entry, no routine, no code region and no executed address in the space. Thus the source has only data, and Ghidra does not run. A table or a key page in a cartridge bank is an example.
|
|
153
154
|
- `notExecuted`: when the knowledge has coverage, the count of instructions that no run executed, the count of their ranges, and the first ranges. `rest` names the file `not_executed.json` in `--out` when the list is not complete. This file has all of the ranges. Each range has its source `file` and the `routine` before it. `files` counts the ranges in each file.
|
|
154
155
|
- `macroUses`: the count of macro uses that the source has.
|
|
155
156
|
- `macrosAsInstructions`: the macro uses that the source has as instructions, each with its `start`, `end`, `macro` and `reason`.
|
|
@@ -171,6 +172,7 @@ Do not store:
|
|
|
171
172
|
- `patchesNotWritten`: read each `reason`. Move the start or the end of the patch to an instruction, or give the switch another name.
|
|
172
173
|
- `macrosAsInstructions`: read each `reason`. Correct the macro or the args, or remove the use with `unuse`. The source stays identical, so this is not a failure.
|
|
173
174
|
- `entryOutside`: the run fails, but the script still writes and checks the source. With `--loader`, `loader.verified` is `false` with the same `reason`. Capture the program again with c64-unpacker `loads` or `capture`. Make sure that a segment holds the entry. Then put it into the knowledge again with `image`.
|
|
175
|
+
- `dataOnly`: this is not a failure. If the space has code, give `--entry` or classify the code with the c64-knowledge skill. Then generate again.
|
|
174
176
|
- `entriesInData`: an analyzer region of data hides the code at the entry. If the entry is code, record a code region at the entry with the c64-knowledge skill, for example `classify '$0500-$05ff' code`. A region of the user or the LLM replaces the analyzer region. Then generate again.
|
|
175
177
|
- `loader.verified: false` with a `reason`: the script did not make the loader. The script still writes and checks the source. When no copy order or no free pages exist, or the loader needs too many copies, the reason gives the ranges. Capture the program again with fewer segments, or write the loader by hand.
|
|
176
178
|
- `loader.verified: false` with `differences`: the loader ran, and `differences` gives the bytes and registers that are not equal.
|
|
@@ -384,7 +384,7 @@ export function layout(input: SourceInput): Layout {
|
|
|
384
384
|
}
|
|
385
385
|
const missing = [...new Set(use.args.flatMap((arg) => identifiers(arg)))].filter((name) => !byName.has(name));
|
|
386
386
|
if (missing.length > 0) {
|
|
387
|
-
fallBack(
|
|
387
|
+
fallBack(`${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not a symbol of the knowledge or a platform name. Name the address with knowledge.ts rename, or give other args with knowledge.ts use.`);
|
|
388
388
|
continue;
|
|
389
389
|
}
|
|
390
390
|
candidates.push({ use, items: run as InstructionItem[] });
|
|
@@ -403,7 +403,7 @@ export function layout(input: SourceInput): Layout {
|
|
|
403
403
|
}
|
|
404
404
|
const missing = identifiers(expression).filter((name) => !byName.has(name));
|
|
405
405
|
if (missing.length > 0) {
|
|
406
|
-
operandsAsNumbers.push({ address: operand.address, expression, reason:
|
|
406
|
+
operandsAsNumbers.push({ address: operand.address, expression, reason: `${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not a symbol of the knowledge or a platform name. Name the address with knowledge.ts rename, or write the operand again with knowledge.ts operand.` });
|
|
407
407
|
continue;
|
|
408
408
|
}
|
|
409
409
|
// An immediate operand builds the address of a name; any other operand uses the address as the instruction does.
|
|
@@ -40,6 +40,9 @@ const MAX_DIAGNOSTICS = 20;
|
|
|
40
40
|
const ROUNDS = 6;
|
|
41
41
|
const PROBE_BASE = 0x1000;
|
|
42
42
|
const PROBE_SLOT = 4;
|
|
43
|
+
/** Why a source has no instructions when the knowledge has no code. */
|
|
44
|
+
const DATA_ONLY = "The knowledge has no entry, routine, code region or executed address in this space. Thus the source has only data. For instructions, give --entry, or classify the code with knowledge.ts classify, then generate again.";
|
|
45
|
+
|
|
43
46
|
/** The copies of a full 64 KiB take about 140 frames. */
|
|
44
47
|
const LOADER_FRAMES = 1500;
|
|
45
48
|
|
|
@@ -99,9 +102,10 @@ function withAnalyzerNames(symbols: SymbolRow[], second: SymbolRow[]): SymbolRow
|
|
|
99
102
|
* Ghidra's instruction starts and lengths inside the image, seeded from the
|
|
100
103
|
* entry, from knowledge of any origin, and from the coverage: a run's flow
|
|
101
104
|
* starts, also after a JSR, so code that an analyzer does not reach by itself
|
|
102
|
-
* is in the source.
|
|
105
|
+
* is in the source. A program without a seed has no known code: it is data
|
|
106
|
+
* only, and Ghidra does not run.
|
|
103
107
|
*/
|
|
104
|
-
async function decode(image: ProgramImage, knowledge: ReturnType<typeof readKnowledge>, entries: number[], coverage: Uint8Array | undefined): Promise<Map<number, number
|
|
108
|
+
async function decode(image: ProgramImage, knowledge: ReturnType<typeof readKnowledge>, entries: number[], coverage: Uint8Array | undefined): Promise<{ instructions: Map<number, number>; dataOnly: boolean }> {
|
|
105
109
|
const inImage = (address: number) => image.segments.some((segment) => address >= segment.start && address <= segment.end);
|
|
106
110
|
const seeds = new Set<number>([...(image.entry === undefined ? [] : [image.entry]), ...entries]);
|
|
107
111
|
for (const address of knowledge.routines) seeds.add(address);
|
|
@@ -114,8 +118,7 @@ async function decode(image: ProgramImage, knowledge: ReturnType<typeof readKnow
|
|
|
114
118
|
const entryPoints = [...seeds].filter(inImage).sort((a, b) => a - b);
|
|
115
119
|
if (entryPoints.length === 0) {
|
|
116
120
|
// An entry in no segment is a capture fault: the image is data only, and the result says entryOutside.
|
|
117
|
-
|
|
118
|
-
throw new UsageError("Ghidra needs an entry point: give --entry, or record a routine or a code region in knowledge with the c64-knowledge skill");
|
|
121
|
+
return { instructions: new Map(), dataOnly: image.entry === undefined || inImage(image.entry) };
|
|
119
122
|
}
|
|
120
123
|
const dataRanges = knowledge.regions
|
|
121
124
|
.filter((region) => !CODE_REGION_TYPES.includes(region.type) && region.type !== "dual-use")
|
|
@@ -138,7 +141,7 @@ async function decode(image: ProgramImage, knowledge: ReturnType<typeof readKnow
|
|
|
138
141
|
const inside = Array.from({ length: bytes.length }, (_, offset) => instruction.address + offset).every((address, offset) => inImage(address) && image.byteAt(address) === bytes[offset]);
|
|
139
142
|
if (inside) instructions.set(instruction.address, bytes.length);
|
|
140
143
|
}
|
|
141
|
-
return instructions;
|
|
144
|
+
return { instructions, dataOnly: false };
|
|
142
145
|
}
|
|
143
146
|
|
|
144
147
|
/** Assembles files given as text with ACME; the program or the diagnostics. */
|
|
@@ -547,7 +550,7 @@ async function generate(call: Call): Promise<{ output: Record<string, unknown>;
|
|
|
547
550
|
// Read the entry state before the long steps, so a bad state stops the run at once.
|
|
548
551
|
const state = loaderWanted ? loaderState(image.state, "the entry state of the knowledge") : undefined;
|
|
549
552
|
const romUse = romsInUse(image, coverage);
|
|
550
|
-
const instructions = await decode(image, knowledge, entries, coverage);
|
|
553
|
+
const { instructions, dataOnly } = await decode(image, knowledge, entries, coverage);
|
|
551
554
|
const laid = layout({
|
|
552
555
|
segments: image.segments,
|
|
553
556
|
byteAt: image.byteAt,
|
|
@@ -634,6 +637,7 @@ async function generate(call: Call): Promise<{ output: Record<string, unknown>;
|
|
|
634
637
|
// The ROM names that the source leaves out, and the evidence.
|
|
635
638
|
...(space !== DEFAULT_SPACE || (romUse.basic && romUse.kernal) ? {} : { romNamesLeftOut: { roms: [...(romUse.basic ? [] : ["BASIC"]), ...(romUse.kernal ? [] : ["KERNAL"])], ...(romUse.basic || romUse.kernal ? {} : { systemArea: true }), from: romUse.from } }),
|
|
636
639
|
...(missingEntry === undefined ? {} : { entryOutside: { entry: formatC64Address(image.entry!), reason: missingEntry } }),
|
|
640
|
+
...(dataOnly ? { dataOnly: DATA_ONLY } : {}),
|
|
637
641
|
labels: { named: laid.namedLabels, generated: laid.generatedLabels },
|
|
638
642
|
// An --entry in a data region decodes nothing: the result says so, and a code region in the knowledge changes it.
|
|
639
643
|
...(inData.length === 0
|
|
@@ -31,7 +31,7 @@ export interface Idioms {
|
|
|
31
31
|
candidates: { dop: number[]; selfModified: number[] };
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/** True when an instruction can start at the address: inside the program, and no JAM. */
|
|
34
|
+
/** True when an instruction can start at the address: inside the range and the program (`byteAt` gives a byte), and no JAM. */
|
|
35
35
|
function plausible(byteAt: ImageByte, address: number, range: { start: number; end: number }): boolean {
|
|
36
36
|
const opcode = byteAt(address);
|
|
37
37
|
return address >= range.start && address <= range.end && opcode !== undefined && !JAMS.has(opcode);
|
|
@@ -43,7 +43,11 @@ const word = (byteAt: ImageByte, address: number) => {
|
|
|
43
43
|
return low === undefined || high === undefined ? undefined : low | (high << 8);
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* `byteAt` gives the bytes of the program and undefined outside it, so a
|
|
48
|
+
* table and its targets stay inside the program. `covered` is the coverage of
|
|
49
|
+
* the knowledge: the joined map, and the addresses that one run wrote and executed.
|
|
50
|
+
*/
|
|
47
51
|
export function findIdioms(byteAt: ImageByte, range: { start: number; end: number }, covered?: { map: Uint8Array; selfModified: Uint8Array }): Idioms {
|
|
48
52
|
const coverage = covered?.map;
|
|
49
53
|
const regions: NormalizedFindings["regions"] = [];
|
|
@@ -184,7 +184,8 @@ async function analyze(call: Call): Promise<unknown> {
|
|
|
184
184
|
// With coverage, a code finding over an executed instruction start replaces the data of the other analyzer.
|
|
185
185
|
const executed = coverage === undefined ? {} : { executed: (address: number) => (coverage[address]! & COVERAGE.ramExecute) !== 0 };
|
|
186
186
|
const loaded = imageKind === "flat64k" ? { start: 0x0000, end: 0xffff } : prgRange(bytes);
|
|
187
|
-
|
|
187
|
+
// The idioms read only the program: a table that runs past a segment gets no targets from the 0 bytes of the 64 KiB.
|
|
188
|
+
const idioms = findIdioms(inProgram, { start: loaded.start, end: loaded.end }, covered);
|
|
188
189
|
|
|
189
190
|
const revision = currentRevision(reader);
|
|
190
191
|
const seeds = seedsFromKnowledge(reader, entries, space);
|
|
@@ -281,7 +282,7 @@ async function analyze(call: Call): Promise<unknown> {
|
|
|
281
282
|
coverage: result.coverage.map((range) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end) })),
|
|
282
283
|
...imported,
|
|
283
284
|
regions: result.regions.map((region) => ({ start: formatC64Address(region.start), end: formatC64Address(region.end), classification: region.classification })),
|
|
284
|
-
labels: listed(result.labels, max, (label) => ({ address: formatC64Address(label.address), name: label.name }), "c64-knowledge: knowledge.ts symbols"),
|
|
285
|
+
labels: listed(result.labels, max, (label) => ({ address: formatC64Address(label.address), name: label.name }), "c64-knowledge: knowledge.ts symbols --analyzer --origin dxa"),
|
|
285
286
|
...(written === undefined ? {} : { listing: written }),
|
|
286
287
|
};
|
|
287
288
|
}
|
|
@@ -311,7 +312,7 @@ async function analyze(call: Call): Promise<unknown> {
|
|
|
311
312
|
...(coverage === undefined ? {} : { coverageGaps: listed(gaps, max, formatC64Address, "a larger --max-listed") }),
|
|
312
313
|
coverage: result.coverage.map((range) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end) })),
|
|
313
314
|
...imported,
|
|
314
|
-
functions: listed(result.functions, max, (fn) => ({ entry: formatC64Address(fn.entry), name: fn.name }), "c64-knowledge: knowledge.ts symbols --
|
|
315
|
+
functions: listed(result.functions, max, (fn) => ({ entry: formatC64Address(fn.entry), name: fn.name }), "c64-knowledge: knowledge.ts symbols --analyzer --origin ghidra"),
|
|
315
316
|
decompilations: result.decompilations.map((item) => ({ entry: formatC64Address(item.entry), text: item.text, ...(item.truncated ? { truncated: true } : {}) })),
|
|
316
317
|
...((result.notDecompiled ?? []).length === 0
|
|
317
318
|
? {}
|
|
@@ -30,6 +30,8 @@ node <skill>/scripts/testing.ts checklist tests/moves-left.json --item 'FIRE sta
|
|
|
30
30
|
|
|
31
31
|
`testing.ts checklist` gives a Markdown checklist for a human tester in `text`.
|
|
32
32
|
|
|
33
|
+
Before the emulator starts, `testing.ts test` examines each file that the scenario names: the program and the symbols of each side, and each snapshot file. A missing file gives `not-found`. A program must be a PRG. The script refuses a disk, tape or cartridge image.
|
|
34
|
+
|
|
33
35
|
### Runs
|
|
34
36
|
|
|
35
37
|
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.
|
|
@@ -50,6 +52,8 @@ The run file gives where to start (a program, or a snapshot file), the steps, an
|
|
|
50
52
|
| `{ "trace": "d011", "points": [{ "kind": "write", "start": "$d011" }], "frames": 100 }` | Record each hit of the points for 100 frames in `d011.json`: the instruction, its registers and the raster line and cycle. A point has the `kind` `exec`, `read` or `write`, a `start` and an optional `end`. `maxHits` limits the hits. |
|
|
51
53
|
| `{ "observe": "all3", "memory": "all" }` | Write all 64 KiB of RAM to `all3.bin`, also the RAM under the ROMs and the I/O area. |
|
|
52
54
|
|
|
55
|
+
Each step that writes a file must have its own file name. The script refuses a run where two steps write the same file, for example `{ "coverage": "x" }` and `{ "observe": "x", "memory": "all" }`, which both write `x.bin`. The script also examines the start program and each snapshot file before the emulator starts.
|
|
56
|
+
|
|
53
57
|
A memory item can have `"view": "ram"`. Then it reads the RAM under the ROMs and the I/O area, for example code at `$e000` under the KERNAL. The default view `cpu` reads as the CPU sees memory. An item larger than 4096 bytes goes into its own `.bin` file, and `results.json` gives the file name.
|
|
54
58
|
|
|
55
59
|
See [references/scenario-format.md](references/scenario-format.md), section Runs. `result` is PASS when all steps ran, and FAIL when a step did not run. `reason` tells why. A long run can need more time than one shell call. Then run it in the background.
|
|
@@ -118,7 +122,11 @@ node <skill>/scripts/testing.ts diff .c64-re-tools/local/runs/rooms/results.json
|
|
|
118
122
|
- Two memory files of a run: the changed ranges. For a file of less than 64 KiB, `--address` gives the address of its first byte.
|
|
119
123
|
- Two observations of runs, also of one run: the changed memory items and registers.
|
|
120
124
|
|
|
121
|
-
|
|
125
|
+
`registers` gives each changed register and chip value. `cpu.pc` is a `$` address. Each CPU flag is `cpu.flags.<flag>`, for example `cpu.flags.c`, with `true` or `false`.
|
|
126
|
+
|
|
127
|
+
The script refuses a snapshot that does not restore in its emulator. An example is a PAL snapshot with `--ntsc`. Give `--ntsc` only for snapshots of an NTSC machine.
|
|
128
|
+
|
|
129
|
+
`--gap N` joins ranges across N equal bytes. `--out` writes all ranges and all changed registers to a project file. The file uses the names of the result.
|
|
122
130
|
|
|
123
131
|
### Calling the script
|
|
124
132
|
|
|
@@ -180,6 +188,8 @@ PASS, FAIL or INCONCLUSIVE, the checkpoints and the smallest differences that ex
|
|
|
180
188
|
## Failure and conflicts
|
|
181
189
|
|
|
182
190
|
- `invalid-input`: the scenario file has an error. The message names the step. Change the step and run the test again.
|
|
191
|
+
- `invalid-input` that names the 2.0 form: a step, a memory item or a recording has a form of version 2.0. An example is `"at"` in place of `"address"`. The message gives the form to write. Change the file. Record a recording of version 2.0 again.
|
|
192
|
+
- `not-found` before the run: a file of the scenario or the run file is missing, for example the rebuild program. Build the rebuild, or change the path.
|
|
183
193
|
- 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.
|
|
184
194
|
- INCONCLUSIVE, "has no symbol": one side has no symbol with that name. Add the name to the knowledge or to the rebuild source.
|
|
185
195
|
- INCONCLUSIVE, "no emulator" or "could not run": the host runtime or the emulator failed. Tell the user the message.
|
|
@@ -101,6 +101,8 @@ The page sends input only between Start and End. The input of the page goes into
|
|
|
101
101
|
|
|
102
102
|
A site is the address of the instruction that reads. The values are hex. `<from>` and `<count>` are decimal.
|
|
103
103
|
|
|
104
|
+
`testing.ts replay` checks `recording.json` before the replay core starts. A recording of version 2.0 can have events or a comparison in the old form, for example `"at"` in place of `"address"`. The script refuses it with `invalid-input`. The message names the event, the old field and the form to write. Record the session again.
|
|
105
|
+
|
|
104
106
|
## Replay
|
|
105
107
|
|
|
106
108
|
`testing.ts replay` replays a recording folder:
|
|
@@ -123,3 +123,9 @@ Example: visit two rooms from the same start.
|
|
|
123
123
|
## Checks
|
|
124
124
|
|
|
125
125
|
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.
|
|
126
|
+
|
|
127
|
+
The script also examines each file before it starts the emulator: the program and the symbols of each side, and each snapshot file. A missing file stops the script with `not-found`. A program that is not a PRG, for example a disk image, stops the script with `invalid-input`.
|
|
128
|
+
|
|
129
|
+
A step in the form of version 2.0 stops the script with `invalid-input`. The message names the old field and gives the step to write. For example, the script refuses `{ "runUntil": { "at": "main_loop" }, "timeoutFrames": 300 }` and gives `{"runUntil": {"target": {"kind": "address", "address": "main_loop"}, "timeoutFrames": 300}}`. A memory item with `at` gets the same refusal: write `address`.
|
|
130
|
+
|
|
131
|
+
In a run, two steps must not write the same file name. The script refuses such a run before it starts. Names that differ only in upper and lower case are the same name.
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// run, or two observations of runs (also two of one run), compare as they are.
|
|
6
6
|
|
|
7
7
|
import { formatC64Address, parseC64Address } from "#src/c64.ts";
|
|
8
|
+
import { Refusal } from "#src/errors.ts";
|
|
9
|
+
import { isObject } from "#src/host-client/steps.ts";
|
|
8
10
|
import { WireFailure } from "#src/host-client/tools.ts";
|
|
9
11
|
import { readProjectFile, writeReported } from "#src/host-client/transfer.ts";
|
|
10
12
|
import { readMemoryInParts, ViceSessionClient } from "#src/host-client/vice-session.ts";
|
|
@@ -41,33 +43,80 @@ function observation(path: string): MachineState {
|
|
|
41
43
|
observed[at] = readProjectFile(`${folder}${(value as { file: string }).file}`).bytes.toString("hex");
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
return { observed, values: cpuValues(seen.registers ?? {}) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The CPU registers as values: each register by its name, the pc from a number or "$xxxx", and each flag as cpu.flags.<flag>. */
|
|
50
|
+
function cpuValues(registers: Record<string, unknown>): Record<string, number | boolean> {
|
|
51
|
+
const values: Record<string, number | boolean> = {};
|
|
52
|
+
for (const [register, value] of Object.entries(registers)) {
|
|
46
53
|
if (typeof value === "number") values[`cpu.${register}`] = value;
|
|
47
54
|
else if (typeof value === "string" && value.startsWith("$")) values[`cpu.${register}`] = parseC64Address(value);
|
|
55
|
+
else if (register === "flags" && isObject(value)) for (const [flag, set] of Object.entries(value)) if (typeof set === "boolean") values[`cpu.flags.${flag}`] = set;
|
|
48
56
|
}
|
|
49
|
-
return
|
|
57
|
+
return values;
|
|
50
58
|
}
|
|
51
59
|
|
|
52
|
-
/** The
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
/** The size of the header of a module in a VICE snapshot file: the name (16 bytes), the version (2 bytes) and the size (4 bytes). */
|
|
61
|
+
const MODULE_HEADER = 22;
|
|
62
|
+
const VSF_MAGIC = "VICE Snapshot File\x1a";
|
|
63
|
+
const VSF_VERSION = "VICE Version\x1a";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The 64 KiB of C64 RAM that a VICE snapshot file holds: the C64MEM module,
|
|
67
|
+
* after the CPU port and the cartridge lines (4 bytes). Undefined when the
|
|
68
|
+
* file has no such module.
|
|
69
|
+
*/
|
|
70
|
+
export function snapshotRam(file: Uint8Array): Uint8Array | undefined {
|
|
71
|
+
const bytes = Buffer.from(file);
|
|
72
|
+
if (bytes.subarray(0, VSF_MAGIC.length).toString("latin1") !== VSF_MAGIC) return undefined;
|
|
73
|
+
// The magic, the version (2 bytes) and the machine name (16 bytes); a newer VICE adds its own version (8 bytes).
|
|
74
|
+
let at = VSF_MAGIC.length + 2 + 16;
|
|
75
|
+
if (bytes.subarray(at, at + VSF_VERSION.length).toString("latin1") === VSF_VERSION) at += VSF_VERSION.length + 8;
|
|
76
|
+
while (at + MODULE_HEADER <= bytes.length) {
|
|
77
|
+
const name = bytes.subarray(at, at + 16).toString("latin1").replace(/\0+$/, "");
|
|
78
|
+
const size = bytes.readUInt32LE(at + 18);
|
|
79
|
+
if (size < MODULE_HEADER) return undefined;
|
|
80
|
+
if (name === "C64MEM") return at + MODULE_HEADER + 4 + 0x10000 <= bytes.length ? bytes.subarray(at + MODULE_HEADER + 4, at + MODULE_HEADER + 4 + 0x10000) : undefined;
|
|
81
|
+
at += size;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The state of a snapshot: restored in the session, then read with the
|
|
88
|
+
* readers of the runs. The RAM must be the RAM of the file: else the
|
|
89
|
+
* snapshot did not restore, and the state would be wrong.
|
|
90
|
+
*/
|
|
91
|
+
async function snapshotState(client: ViceSessionClient, path: string, ntsc: boolean): Promise<MachineState> {
|
|
92
|
+
const file = readProjectFile(path).bytes;
|
|
93
|
+
const saved = snapshotRam(file);
|
|
94
|
+
if (saved === undefined) throw new Refusal("invalid-input", `${path} is not a snapshot of a C64: it has no C64 memory. Give a file that c64_snapshot save or a save step of a run wrote.`);
|
|
95
|
+
const restored = await client.snapshotRestore(file);
|
|
55
96
|
const memory = await readMemoryInParts(client, 0x0000, 0x10000, "ram");
|
|
97
|
+
// $00 and $01 are the CPU port: the file keeps them apart from the RAM.
|
|
98
|
+
if (("restored" in restored && !restored.restored) || !memory.subarray(2).equals(Buffer.from(saved.subarray(2)))) {
|
|
99
|
+
throw new Refusal(
|
|
100
|
+
"invalid-input",
|
|
101
|
+
`${path} did not restore: the RAM after the restore is not the RAM of the file. A snapshot restores only in a machine of the video standard that saved it. ${ntsc ? "Leave out --ntsc for a PAL snapshot" : "Give --ntsc for an NTSC snapshot"}, or use the emulator version that saved it.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
56
104
|
const colorRam = Buffer.from((await client.ioRead(0xd800, 0x400)).data, "hex").map((value) => value & 0x0f);
|
|
57
105
|
const vic = Buffer.from((await client.ioRead(0xd000, 0x2f)).data, "hex");
|
|
58
|
-
const values: Record<string, number> = {};
|
|
106
|
+
const values: Record<string, number | boolean> = {};
|
|
59
107
|
vic.forEach((value, index) => (values[`vic.${formatC64Address(0xd000 + index)}`] = value));
|
|
60
108
|
for (const chip of (await client.cia("both")).chips) {
|
|
61
109
|
for (const field of ["portA", "portB", "ddrA", "ddrB", "timerA", "timerB", "latchA", "latchB", "controlA", "controlB", "interruptMask"] as const) values[`cia${chip.id}.${field}`] = chip[field];
|
|
62
110
|
}
|
|
63
111
|
const sid = await client.sid();
|
|
64
112
|
for (const voice of sid.voices) for (const [field, value] of Object.entries(voice)) if (field !== "index" && typeof value === "number") values[`sid.voice${voice.index}.${field}`] = value;
|
|
65
|
-
|
|
66
|
-
for (const [field, value] of Object.entries(registers)) if (typeof value === "number") values[`cpu.${field}`] = value;
|
|
113
|
+
Object.assign(values, cpuValues({ ...(await client.registersGet("c64")) }));
|
|
67
114
|
return { memory: { base: 0, bytes: memory }, colorRam, values };
|
|
68
115
|
}
|
|
69
116
|
|
|
70
117
|
const show = (range: ChangedRange) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end), ...(range.a === undefined ? {} : { a: range.a, b: range.b }) });
|
|
118
|
+
/** A changed value as a person reads it: the pc as "$xxxx". */
|
|
119
|
+
const showValue = (item: { name: string; a: number | boolean; b: number | boolean }) => (item.name === "cpu.pc" && typeof item.a === "number" && typeof item.b === "number" ? { name: item.name, a: formatC64Address(item.a), b: formatC64Address(item.b) } : item);
|
|
71
120
|
|
|
72
121
|
/** The differences of two states of one kind: snapshots, memory files or observations of runs. */
|
|
73
122
|
export async function diffFiles(first: string, second: string, options: { gap: number; max: number; ntsc: boolean; at?: number; out?: string }): Promise<Record<string, unknown>> {
|
|
@@ -90,8 +139,8 @@ export async function diffFiles(first: string, second: string, options: { gap: n
|
|
|
90
139
|
} else {
|
|
91
140
|
const client = await ViceSessionClient.open({ videoStandard: options.ntsc ? "ntsc" : "pal" });
|
|
92
141
|
try {
|
|
93
|
-
a = await snapshotState(client, first);
|
|
94
|
-
b = await snapshotState(client, second);
|
|
142
|
+
a = await snapshotState(client, first, options.ntsc);
|
|
143
|
+
b = await snapshotState(client, second, options.ntsc);
|
|
95
144
|
} finally {
|
|
96
145
|
await client.close();
|
|
97
146
|
}
|
|
@@ -101,7 +150,8 @@ export async function diffFiles(first: string, second: string, options: { gap: n
|
|
|
101
150
|
const part = (item: { changedBytes: number; ranges: ChangedRange[] } | undefined) => (item === undefined ? undefined : { changedBytes: item.changedBytes, ...listed(item.ranges, max, show, rest) });
|
|
102
151
|
let written: ReturnType<typeof writeReported> | undefined;
|
|
103
152
|
if (options.out !== undefined) {
|
|
104
|
-
|
|
153
|
+
// The names of the result: registers holds the changed values.
|
|
154
|
+
const all = { a: first, b: second, ...(diff.memory === undefined ? {} : { memory: diff.memory.ranges.map(show) }), ...(diff.colorRam === undefined ? {} : { colorRam: diff.colorRam.ranges.map(show) }), registers: (diff.values ?? []).map(showValue), observed: diff.observed ?? [] };
|
|
105
155
|
written = writeReported(options.out, `${JSON.stringify(all, null, 2)}\n`, { what: "--out" });
|
|
106
156
|
}
|
|
107
157
|
return {
|
|
@@ -109,7 +159,7 @@ export async function diffFiles(first: string, second: string, options: { gap: n
|
|
|
109
159
|
b: second,
|
|
110
160
|
...(diff.memory === undefined ? {} : { memory: part(diff.memory) }),
|
|
111
161
|
...(diff.colorRam === undefined ? {} : { colorRam: part(diff.colorRam) }),
|
|
112
|
-
...(diff.values === undefined ? {} : { registers: listed(diff.values, max,
|
|
162
|
+
...(diff.values === undefined ? {} : { registers: listed(diff.values, max, showValue, rest) }),
|
|
113
163
|
...(diff.observed === undefined ? {} : { observed: listed(diff.observed, max, undefined, rest) }),
|
|
114
164
|
...(written === undefined ? {} : written),
|
|
115
165
|
};
|
|
@@ -10,8 +10,8 @@ export interface MachineState {
|
|
|
10
10
|
memory?: { base: number; bytes: Uint8Array };
|
|
11
11
|
/** Colour RAM $D800-$DBFF, the low 4 bits of each byte. */
|
|
12
12
|
colorRam?: Uint8Array;
|
|
13
|
-
/** Named single values: VIC-II, CIA and SID registers and the CPU registers, as "vic.$d011", "cia1.timerA", "cpu.pc". */
|
|
14
|
-
values?: Record<string, number>;
|
|
13
|
+
/** Named single values: VIC-II, CIA and SID registers and the CPU registers, as "vic.$d011", "cia1.timerA", "cpu.pc", and each CPU flag as "cpu.flags.c". */
|
|
14
|
+
values?: Record<string, number | boolean>;
|
|
15
15
|
/** The memory items of an observation of a run: the address or name, and its bytes as hex. */
|
|
16
16
|
observed?: Record<string, string>;
|
|
17
17
|
}
|
|
@@ -27,7 +27,7 @@ export interface ChangedRange {
|
|
|
27
27
|
export interface StateDiff {
|
|
28
28
|
memory?: { changedBytes: number; ranges: ChangedRange[] };
|
|
29
29
|
colorRam?: { changedBytes: number; ranges: ChangedRange[] };
|
|
30
|
-
values?: Array<{ name: string; a: number; b: number }>;
|
|
30
|
+
values?: Array<{ name: string; a: number | boolean; b: number | boolean }>;
|
|
31
31
|
observed?: Array<{ address: string; a: string; b: string }>;
|
|
32
32
|
}
|
|
33
33
|
|