@henols/c64-re-tools 2.0.0-rc.11 → 2.0.0-rc.13
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/README.md +7 -5
- package/package.json +1 -1
- package/skills/c64-assembler/SKILL.md +11 -2
- package/skills/c64-assembler/scripts/assemble.ts +4 -4
- package/skills/c64-basic/SKILL.md +8 -1
- package/skills/c64-basic/scripts/basic.ts +3 -3
- package/skills/c64-disk/SKILL.md +10 -3
- package/skills/c64-disk/scripts/disk.ts +4 -4
- package/skills/c64-emulator/SKILL.md +4 -0
- package/skills/c64-knowledge/SKILL.md +62 -12
- package/skills/c64-knowledge/scripts/knowledge.ts +352 -102
- package/skills/c64-memory-map/SKILL.md +7 -0
- package/skills/c64-memory-map/scripts/memmap.ts +1 -1
- package/skills/c64-provenance/SKILL.md +56 -3
- package/skills/c64-provenance/scripts/library.ts +584 -0
- package/skills/c64-provenance/scripts/provenance.ts +201 -9
- package/skills/c64-reverse-engineering/SKILL.md +16 -2
- package/skills/c64-source/SKILL.md +38 -7
- package/skills/c64-source/scripts/generate.ts +213 -29
- package/skills/c64-source/scripts/loader.ts +3 -1
- package/skills/c64-source/scripts/opcodes.ts +19 -0
- package/skills/c64-source/scripts/source.ts +387 -35
- package/skills/c64-static-analysis/SKILL.md +11 -2
- package/skills/c64-static-analysis/scripts/analyze.ts +95 -8
- package/skills/c64-static-analysis/scripts/macros.ts +233 -0
- package/skills/c64-testing/SKILL.md +8 -1
- package/skills/c64-testing/references/scenario-format.md +1 -1
- package/skills/c64-testing/scripts/checklist.ts +2 -2
- package/skills/c64-testing/scripts/run.ts +4 -4
- package/skills/c64-testing/scripts/scenario.ts +1 -1
- package/skills/c64-testing/scripts/test.ts +4 -4
- package/skills/c64-unpacker/SKILL.md +11 -4
- package/skills/c64-unpacker/scripts/unpack.ts +4 -4
- package/src/cli/main.ts +5 -3
- package/src/host-client/image.ts +1 -1
- package/src/host-client/transfer.ts +34 -10
- package/src/host-client/vice-session.ts +9 -9
- package/src/knowledge/conflicts.ts +157 -0
- package/src/knowledge/database.ts +150 -38
- package/src/knowledge/history.ts +54 -20
- package/src/knowledge/import.ts +3 -2
- package/src/knowledge/read.ts +169 -20
- package/src/knowledge/schema.ts +115 -72
- package/src/knowledge/text.ts +391 -0
- package/src/knowledge/write.ts +508 -51
- package/src/mcp/output.ts +14 -7
- package/src/mcp/tools/debug.ts +8 -6
- package/src/mcp/tools/media.ts +17 -13
- package/src/mcp/tools/memory.ts +9 -5
- package/src/mcp/tools/video.ts +6 -5
- package/src/platform.ts +28 -5
- package/src/project.ts +166 -3
- package/src/protocol/tools.ts +1 -1
- package/src/script.ts +46 -7
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The c64-static-analysis script: analyzes a project program with DXA (fast
|
|
2
2
|
// first pass) or Ghidra (deeper), which it runs itself, seeded from
|
|
3
3
|
// current knowledge, and imports the structural findings into
|
|
4
|
-
// .c64-re-tools/knowledge
|
|
4
|
+
// .c64-re-tools/knowledge/.
|
|
5
5
|
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
@@ -18,20 +18,21 @@ import { localToolContext } from "#src/native/local.ts";
|
|
|
18
18
|
import { GHIDRA_LIMITS } from "#src/native/types.ts";
|
|
19
19
|
import { KnowledgeError, openForRead, withWrite } from "#src/knowledge/database.ts";
|
|
20
20
|
import { importFindings, runProvenance, type Analyzer, type ImportConflict, type ImportContext, type ImportResult, type NormalizedFindings } from "#src/knowledge/import.ts";
|
|
21
|
-
import { currentRevision, listRegions } from "#src/knowledge/read.ts";
|
|
21
|
+
import { currentRevision, listMacros, listMacroUses, listPatches, listReferences, listRegions, listSymbols } from "#src/knowledge/read.ts";
|
|
22
22
|
import { DEFAULT_SPACE, SPACE } from "#src/knowledge/schema.ts";
|
|
23
23
|
import { platformNames } from "#src/platform.ts";
|
|
24
24
|
import { resolveProjectPath } from "#src/project.ts";
|
|
25
25
|
import { runScript, UsageError } from "#src/script.ts";
|
|
26
|
-
import { codeEntries, coverageConflicts, coverageEntries, imageReader, readCoverageMaps } from "./coverage.ts";
|
|
26
|
+
import { codeEntries, coverageConflicts, coverageEntries, imageReader, readCoverageMaps, type ImageByte } from "./coverage.ts";
|
|
27
27
|
import { dxaFindings, ghidraFindings, seedsFromKnowledge } from "./findings.ts";
|
|
28
28
|
import { findIdioms } from "./idioms.ts";
|
|
29
29
|
import { instructionList } from "./instructions.ts";
|
|
30
|
+
import { CATALOGUE, findMacroUses, type MacroCandidate } from "./macros.ts";
|
|
30
31
|
import { findPointers, type PointerCandidate } from "./pointers.ts";
|
|
31
32
|
|
|
32
33
|
const USAGE = `analyze.ts <image> [options]
|
|
33
34
|
|
|
34
|
-
<image> a PRG file, relative to the project
|
|
35
|
+
<image> a PRG file, relative to the project folder
|
|
35
36
|
--analyzer <name> dxa (fast first pass) or ghidra (deeper, the default)
|
|
36
37
|
--flat64k the image is 64 KiB of memory from $0000, not a PRG
|
|
37
38
|
--entry <address> an entry point, for example '$080d' (repeatable)
|
|
@@ -46,6 +47,8 @@ const USAGE = `analyze.ts <image> [options]
|
|
|
46
47
|
all labels and functions
|
|
47
48
|
--space <space> the knowledge space of the image: c64 (the default), drive8
|
|
48
49
|
for 1541 drive code, or bank0 to bank255 for a cartridge bank
|
|
50
|
+
--macro-batch <file> write the macro candidates as an apply batch of c64-knowledge
|
|
51
|
+
(the macros and their uses) to this project file
|
|
49
52
|
|
|
50
53
|
Routine symbols in knowledge are entry points too. Ghidra needs at least one
|
|
51
54
|
entry point; dxa finds the SYS of a BASIC start by itself. The result is one
|
|
@@ -67,6 +70,73 @@ function showPointer(candidate: PointerCandidate) {
|
|
|
67
70
|
return { kind: candidate.kind, to: hex(candidate.to), target: hex(candidate.target), low: hex(candidate.low), high: hex(candidate.high) };
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
/** A macro candidate with $ addresses and its source line (D73). */
|
|
74
|
+
function showMacro(candidate: MacroCandidate) {
|
|
75
|
+
return { macro: candidate.macro, start: formatC64Address(candidate.start), end: formatC64Address(candidate.end), args: candidate.args, text: `+${candidate.macro} ${candidate.args.join(", ")}` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The macro candidates (D73) in the current code regions of every origin,
|
|
80
|
+
* with the names of the knowledge in their args. A candidate is left out when
|
|
81
|
+
* a name or a reference target is inside it, or a patch or a use overlaps it:
|
|
82
|
+
* c64-source would write its instructions. The same use in the knowledge is
|
|
83
|
+
* known. With `batchPath`, the candidates go to an apply batch with the
|
|
84
|
+
* macros they need; a macro of the catalogue that the knowledge has with
|
|
85
|
+
* another body leaves its candidates out of the batch.
|
|
86
|
+
*/
|
|
87
|
+
function macroCandidates(space: string, image: string, byteAt: ImageByte, batchPath: string | undefined) {
|
|
88
|
+
const db = openForRead();
|
|
89
|
+
try {
|
|
90
|
+
const platform = space === DEFAULT_SPACE ? new Map(platformNames().map((entry) => [entry.address, entry.name])) : new Map<number, string>();
|
|
91
|
+
const semantic = new Map(listSymbols(db, undefined, undefined, ["user", "llm"], space).map((symbol) => [symbol.address, symbol.name]));
|
|
92
|
+
const code = listRegions(db, undefined, "code", undefined, space);
|
|
93
|
+
const found = findMacroUses(byteAt, code, (address) => semantic.get(address) ?? platform.get(address));
|
|
94
|
+
const named = new Set([...listSymbols(db, undefined, undefined, undefined, space).map((symbol) => symbol.address), ...listReferences(db, undefined, undefined, undefined, space).map((reference) => reference.to)]);
|
|
95
|
+
const patches = listPatches(db, undefined, undefined, undefined, space);
|
|
96
|
+
const uses = listMacroUses(db, undefined, undefined, undefined, space);
|
|
97
|
+
const overlaps = (a: { start: number; end: number }, b: { start: number; end: number }) => a.start <= b.end && b.start <= a.end;
|
|
98
|
+
const candidates: MacroCandidate[] = [];
|
|
99
|
+
let known = 0;
|
|
100
|
+
let leftOut = 0;
|
|
101
|
+
for (const candidate of found) {
|
|
102
|
+
const same = uses.find((use) => use.start === candidate.start && use.end === candidate.end && use.macro === candidate.macro && use.args.join(",") === candidate.args.join(","));
|
|
103
|
+
if (same !== undefined) known++;
|
|
104
|
+
else if ([...named].some((address) => address > candidate.start && address <= candidate.end) || patches.some((patch) => overlaps(patch, candidate)) || uses.some((use) => overlaps(use, candidate))) leftOut++;
|
|
105
|
+
else candidates.push(candidate);
|
|
106
|
+
}
|
|
107
|
+
if (batchPath === undefined) return { candidates, known, leftOut };
|
|
108
|
+
// A macro of the catalogue goes into the batch unless the knowledge has it; with another body, its uses stay out.
|
|
109
|
+
const macros = new Map(listMacros(db, undefined, space).map((macro) => [macro.name, macro]));
|
|
110
|
+
const batch: Array<Record<string, unknown>> = [];
|
|
111
|
+
const batched: MacroCandidate[] = [];
|
|
112
|
+
const other = new Set<string>();
|
|
113
|
+
for (const idiom of CATALOGUE.filter((item) => candidates.some((candidate) => candidate.macro === item.name))) {
|
|
114
|
+
const existing = macros.get(idiom.name);
|
|
115
|
+
if (existing === undefined) batch.push({ op: "macro", name: idiom.name, params: idiom.params, body: idiom.body, comment: idiom.comment, ...(space === DEFAULT_SPACE ? {} : { space }) });
|
|
116
|
+
else if (existing.params.join(",") !== idiom.params.join(",") || existing.body !== idiom.body) other.add(idiom.name);
|
|
117
|
+
}
|
|
118
|
+
for (const candidate of candidates) {
|
|
119
|
+
if (other.has(candidate.macro)) continue;
|
|
120
|
+
batched.push(candidate);
|
|
121
|
+
batch.push({ op: "use", start: formatC64Address(candidate.start), end: formatC64Address(candidate.end), macro: candidate.macro, args: candidate.args, ...(space === DEFAULT_SPACE ? {} : { space }) });
|
|
122
|
+
}
|
|
123
|
+
if (batch.length > 0) writeProjectFile(batchPath, Buffer.from(`${JSON.stringify(batch, null, 1)}\n`), { what: "--macro-batch" });
|
|
124
|
+
return {
|
|
125
|
+
candidates,
|
|
126
|
+
known,
|
|
127
|
+
leftOut,
|
|
128
|
+
batch: {
|
|
129
|
+
out: batchPath,
|
|
130
|
+
writes: batch.length,
|
|
131
|
+
...(other.size === 0 ? {} : { otherMacros: [...other].map((name) => `The knowledge has another macro ${name}: its candidates are not in the batch.`) }),
|
|
132
|
+
...(batch.length === 0 ? {} : { apply: `knowledge.ts apply ${batchPath} --image ${image} --reason "macro uses of 6502 idioms"` }),
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
} finally {
|
|
136
|
+
db?.close();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
70
140
|
/** A decompiler that did not start gets the remedy: Ghidra ships no native decompiler for macOS. */
|
|
71
141
|
function withRemedy(reason: string): string {
|
|
72
142
|
return reason.startsWith("the Ghidra decompiler did not start")
|
|
@@ -104,10 +174,10 @@ function showConflict(conflict: ImportConflict, analyzer: Analyzer): Record<stri
|
|
|
104
174
|
|
|
105
175
|
/**
|
|
106
176
|
* Imports one analyzer's findings as one revision, refused when knowledge
|
|
107
|
-
* changed since the seeds were read. A refused import creates no knowledge.
|
|
177
|
+
* changed since the seeds were read. A refused import creates no knowledge.
|
|
108
178
|
* The revision records the image hash and the analyzer version.
|
|
109
179
|
*/
|
|
110
|
-
function record(findings: NormalizedFindings, revision:
|
|
180
|
+
function record(findings: NormalizedFindings, revision: string, description: string, provenance: Pick<ImportContext, "inputHash" | "toolVersion" | "evidence">) {
|
|
111
181
|
let imported: ImportResult;
|
|
112
182
|
try {
|
|
113
183
|
imported = withWrite((db) => importFindings(db, findings, { expectedRevision: revision, description, ...provenance }));
|
|
@@ -139,6 +209,7 @@ async function run(argv: string[]): Promise<unknown> {
|
|
|
139
209
|
coverage: { type: "string", multiple: true },
|
|
140
210
|
instructions: { type: "string" },
|
|
141
211
|
space: { type: "string" },
|
|
212
|
+
"macro-batch": { type: "string" },
|
|
142
213
|
help: { type: "boolean" },
|
|
143
214
|
},
|
|
144
215
|
});
|
|
@@ -161,7 +232,15 @@ async function run(argv: string[]): Promise<unknown> {
|
|
|
161
232
|
try {
|
|
162
233
|
listingPath = resolveProjectPath(values.listing);
|
|
163
234
|
} catch {
|
|
164
|
-
throw new UsageError(`--listing must be a path relative to the project
|
|
235
|
+
throw new UsageError(`--listing must be a path relative to the project folder that stays inside it, not ${values.listing}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (values["macro-batch"] !== undefined) {
|
|
240
|
+
try {
|
|
241
|
+
resolveProjectPath(values["macro-batch"]);
|
|
242
|
+
} catch {
|
|
243
|
+
throw new UsageError(`--macro-batch must be a path relative to the project folder that stays inside it, not ${values["macro-batch"]}`);
|
|
165
244
|
}
|
|
166
245
|
}
|
|
167
246
|
|
|
@@ -206,6 +285,8 @@ async function run(argv: string[]): Promise<unknown> {
|
|
|
206
285
|
/** Imports the idioms as their own revision, after the analyzer's, and gives their part of the result. */
|
|
207
286
|
const idiomsResult = () => {
|
|
208
287
|
const imported = withWrite((db) => importFindings(db, { ...idioms.findings, space }, { description: `Idioms in ${image}` }));
|
|
288
|
+
// The macro candidates read the code regions after both imports (D73).
|
|
289
|
+
const macros = macroCandidates(space, image, byteAt, values["macro-batch"]);
|
|
209
290
|
return {
|
|
210
291
|
idioms: {
|
|
211
292
|
...idioms.counts,
|
|
@@ -217,6 +298,12 @@ async function run(argv: string[]): Promise<unknown> {
|
|
|
217
298
|
dop: listed(idioms.candidates.dop, max, formatC64Address, "a larger --max-listed"),
|
|
218
299
|
selfModified: listed(idioms.candidates.selfModified, max, formatC64Address, "a larger --max-listed"),
|
|
219
300
|
pointers: listed(pointers, max, showPointer, "a larger --max-listed"),
|
|
301
|
+
macros: {
|
|
302
|
+
...listed(macros.candidates, max, showMacro, "a larger --max-listed, or --macro-batch <file>"),
|
|
303
|
+
...(macros.known === 0 ? {} : { known: macros.known }),
|
|
304
|
+
...(macros.leftOut === 0 ? {} : { leftOut: macros.leftOut }),
|
|
305
|
+
...(macros.batch === undefined ? {} : { batch: macros.batch }),
|
|
306
|
+
},
|
|
220
307
|
},
|
|
221
308
|
},
|
|
222
309
|
};
|
|
@@ -290,4 +377,4 @@ async function run(argv: string[]): Promise<unknown> {
|
|
|
290
377
|
};
|
|
291
378
|
}
|
|
292
379
|
|
|
293
|
-
await runScript(() => run(
|
|
380
|
+
await runScript((argv) => run(argv), { usage: USAGE, refusals: [KnowledgeError] });
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Macro candidates (D73): runs of instructions that are a known 6502 idiom,
|
|
2
|
+
// such as a 16-bit increment, and that a macro can write as one line. They
|
|
3
|
+
// are only reported, never written: the LLM applies them as macro uses with
|
|
4
|
+
// c64-knowledge, and c64-source writes a use only when it gives the bytes of
|
|
5
|
+
// the image.
|
|
6
|
+
//
|
|
7
|
+
// The catalogue is fixed, so each candidate has a name and a body that is
|
|
8
|
+
// known to be correct. A search for any repeated sequence was weighed and not
|
|
9
|
+
// chosen: it gives many candidates without a name (D73).
|
|
10
|
+
|
|
11
|
+
import { instructionLength } from "#src/c64.ts";
|
|
12
|
+
import type { ImageByte } from "./coverage.ts";
|
|
13
|
+
|
|
14
|
+
/** A macro of the catalogue: its params and its ACME body, which names a param with a dot. */
|
|
15
|
+
export interface MacroIdiom {
|
|
16
|
+
name: string;
|
|
17
|
+
params: string[];
|
|
18
|
+
body: string;
|
|
19
|
+
comment: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const CATALOGUE: readonly MacroIdiom[] = [
|
|
23
|
+
{ name: "inc16", params: ["dst"], body: [" inc .dst", " bne +", " inc .dst+1", "+"].join("\n"), comment: "Adds 1 to the 16-bit value at dst." },
|
|
24
|
+
{ name: "dec16", params: ["dst"], body: [" lda .dst", " bne +", " dec .dst+1", "+ dec .dst"].join("\n"), comment: "Subtracts 1 from the 16-bit value at dst. It changes A." },
|
|
25
|
+
{
|
|
26
|
+
name: "add16_8",
|
|
27
|
+
params: ["dst", "value"],
|
|
28
|
+
body: [" clc", " lda .dst", " adc #.value", " sta .dst", " bcc +", " inc .dst+1", "+"].join("\n"),
|
|
29
|
+
comment: "Adds the 8-bit value to the 16-bit value at dst. It changes A.",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "add16",
|
|
33
|
+
params: ["dst", "value"],
|
|
34
|
+
body: [" clc", " lda .dst", " adc #<.value", " sta .dst", " lda .dst+1", " adc #>.value", " sta .dst+1"].join("\n"),
|
|
35
|
+
comment: "Adds the 16-bit value to the 16-bit value at dst. It changes A.",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "sub16",
|
|
39
|
+
params: ["dst", "value"],
|
|
40
|
+
body: [" sec", " lda .dst", " sbc #<.value", " sta .dst", " lda .dst+1", " sbc #>.value", " sta .dst+1"].join("\n"),
|
|
41
|
+
comment: "Subtracts the 16-bit value from the 16-bit value at dst. It changes A.",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "set16",
|
|
45
|
+
params: ["dst", "value"],
|
|
46
|
+
body: [" lda #<.value", " sta .dst", " lda #>.value", " sta .dst+1"].join("\n"),
|
|
47
|
+
comment: "Puts the 16-bit value at dst. It changes A.",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "mov16",
|
|
51
|
+
params: ["dst", "src"],
|
|
52
|
+
body: [" lda .src", " sta .dst", " lda .src+1", " sta .dst+1"].join("\n"),
|
|
53
|
+
comment: "Copies the 16-bit value at src to dst. It changes A.",
|
|
54
|
+
},
|
|
55
|
+
{ name: "wait_line", params: ["line"], body: [" lda #.line", "- cmp $d012", " bne -"].join("\n"), comment: "Waits until the raster line (bits 0-7) is line. It changes A." },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/** A run of instructions that one macro of the catalogue can write, with its args as ACME expressions. */
|
|
59
|
+
export interface MacroCandidate {
|
|
60
|
+
macro: string;
|
|
61
|
+
start: number;
|
|
62
|
+
end: number;
|
|
63
|
+
args: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One instruction: its opcode, its length and its operand value. */
|
|
67
|
+
interface Instruction {
|
|
68
|
+
opcode: number;
|
|
69
|
+
length: number;
|
|
70
|
+
value: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** An address operand: zero page or absolute, of the same memory in one idiom. */
|
|
74
|
+
interface Operand {
|
|
75
|
+
value: number;
|
|
76
|
+
absolute: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Opcodes by mnemonic, zero page first, then absolute. */
|
|
80
|
+
const MEMORY = {
|
|
81
|
+
lda: [0xa5, 0xad],
|
|
82
|
+
sta: [0x85, 0x8d],
|
|
83
|
+
inc: [0xe6, 0xee],
|
|
84
|
+
dec: [0xc6, 0xce],
|
|
85
|
+
} as const;
|
|
86
|
+
const IMMEDIATE = { lda: 0xa9, adc: 0x69, sbc: 0xe9 } as const;
|
|
87
|
+
const CLC = 0x18;
|
|
88
|
+
const SEC = 0x38;
|
|
89
|
+
const BNE = 0xd0;
|
|
90
|
+
const BCC = 0x90;
|
|
91
|
+
const CMP_ABSOLUTE = 0xcd;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Names the args of a candidate: `address` gives the name of an address in
|
|
95
|
+
* the knowledge, or undefined. An absolute operand below $0100 keeps four
|
|
96
|
+
* digits, so ACME writes the absolute form again.
|
|
97
|
+
*/
|
|
98
|
+
export type Namer = (address: number) => string | undefined;
|
|
99
|
+
|
|
100
|
+
const hex2 = (value: number) => `$${value.toString(16).padStart(2, "0")}`;
|
|
101
|
+
const hex4 = (value: number) => `$${value.toString(16).padStart(4, "0")}`;
|
|
102
|
+
|
|
103
|
+
function addressArg(operand: Operand, name: Namer): string {
|
|
104
|
+
const named = name(operand.value);
|
|
105
|
+
// A name of a zero-page address gives the zero-page form: an absolute operand there keeps its number.
|
|
106
|
+
if (named !== undefined && (operand.value >= 0x100 || !operand.absolute)) return named;
|
|
107
|
+
return operand.absolute ? hex4(operand.value) : hex2(operand.value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The memory operand of `instruction` when it is one of the opcodes of the mnemonic. */
|
|
111
|
+
function memory(instruction: Instruction | undefined, mnemonic: keyof typeof MEMORY): Operand | undefined {
|
|
112
|
+
if (instruction === undefined) return undefined;
|
|
113
|
+
const index = (MEMORY[mnemonic] as readonly number[]).indexOf(instruction.opcode);
|
|
114
|
+
return index < 0 ? undefined : { value: instruction.value, absolute: index === 1 };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const same = (a: Operand | undefined, b: Operand | undefined, offset = 0): boolean => a !== undefined && b !== undefined && a.absolute === b.absolute && b.value === a.value + offset;
|
|
118
|
+
const immediate = (instruction: Instruction | undefined, opcode: number) => (instruction?.opcode === opcode ? instruction.value : undefined);
|
|
119
|
+
/** A branch past the next instruction only: its distance is the length of that instruction. */
|
|
120
|
+
const skips = (branch: Instruction | undefined, opcode: number, next: Instruction | undefined) => branch?.opcode === opcode && next !== undefined && branch.value === next.length;
|
|
121
|
+
|
|
122
|
+
/** Each idiom: how many instructions it takes, and its args when the instructions match. */
|
|
123
|
+
const MATCHERS: Record<string, { count: number; match: (run: Instruction[], name: Namer) => string[] | undefined }> = {
|
|
124
|
+
inc16: {
|
|
125
|
+
count: 3,
|
|
126
|
+
match: ([low, branch, high], name) => {
|
|
127
|
+
const dst = memory(low, "inc");
|
|
128
|
+
return same(dst, memory(high, "inc"), 1) && skips(branch, BNE, high) ? [addressArg(dst!, name)] : undefined;
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
dec16: {
|
|
132
|
+
count: 4,
|
|
133
|
+
match: ([load, branch, high, low], name) => {
|
|
134
|
+
const dst = memory(load, "lda");
|
|
135
|
+
return same(dst, memory(low, "dec")) && same(dst, memory(high, "dec"), 1) && skips(branch, BNE, high) ? [addressArg(dst!, name)] : undefined;
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
add16_8: {
|
|
139
|
+
count: 6,
|
|
140
|
+
match: ([clc, load, add, store, branch, high], name) => {
|
|
141
|
+
const dst = memory(load, "lda");
|
|
142
|
+
const value = immediate(add, IMMEDIATE.adc);
|
|
143
|
+
const ok = clc?.opcode === CLC && value !== undefined && same(dst, memory(store, "sta")) && same(dst, memory(high, "inc"), 1) && skips(branch, BCC, high);
|
|
144
|
+
return ok ? [addressArg(dst!, name), hex2(value)] : undefined;
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
add16: { count: 7, match: (run, name) => wide(run, name, CLC, IMMEDIATE.adc) },
|
|
148
|
+
sub16: { count: 7, match: (run, name) => wide(run, name, SEC, IMMEDIATE.sbc) },
|
|
149
|
+
set16: {
|
|
150
|
+
count: 4,
|
|
151
|
+
match: ([loadLow, storeLow, loadHigh, storeHigh], name) => {
|
|
152
|
+
const low = immediate(loadLow, IMMEDIATE.lda);
|
|
153
|
+
const high = immediate(loadHigh, IMMEDIATE.lda);
|
|
154
|
+
const dst = memory(storeLow, "sta");
|
|
155
|
+
if (low === undefined || high === undefined || !same(dst, memory(storeHigh, "sta"), 1)) return undefined;
|
|
156
|
+
const value = low | (high << 8);
|
|
157
|
+
return [addressArg(dst!, name), name(value) ?? hex4(value)];
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
mov16: {
|
|
161
|
+
count: 4,
|
|
162
|
+
match: ([loadLow, storeLow, loadHigh, storeHigh], name) => {
|
|
163
|
+
const src = memory(loadLow, "lda");
|
|
164
|
+
const dst = memory(storeLow, "sta");
|
|
165
|
+
if (!same(src, memory(loadHigh, "lda"), 1) || !same(dst, memory(storeHigh, "sta"), 1)) return undefined;
|
|
166
|
+
return [addressArg(dst!, name), addressArg(src!, name)];
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
wait_line: {
|
|
170
|
+
count: 3,
|
|
171
|
+
match: ([load, compare, branch]) => {
|
|
172
|
+
const line = immediate(load, IMMEDIATE.lda);
|
|
173
|
+
// BNE back to the CMP: 5 bytes back from the end of the BNE.
|
|
174
|
+
return line !== undefined && compare?.opcode === CMP_ABSOLUTE && compare.value === 0xd012 && branch?.opcode === BNE && branch.value === 0xfb ? [hex2(line)] : undefined;
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** add16 and sub16: the carry, then the low and the high byte, each with an immediate of the 16-bit value. */
|
|
180
|
+
function wide([carry, loadLow, low, storeLow, loadHigh, high, storeHigh]: Instruction[], name: Namer, carryOpcode: number, opcode: number): string[] | undefined {
|
|
181
|
+
const dst = memory(loadLow, "lda");
|
|
182
|
+
const lowValue = immediate(low, opcode);
|
|
183
|
+
const highValue = immediate(high, opcode);
|
|
184
|
+
const ok =
|
|
185
|
+
carry?.opcode === carryOpcode && lowValue !== undefined && highValue !== undefined && same(dst, memory(storeLow, "sta")) && same(dst, memory(loadHigh, "lda"), 1) && same(dst, memory(storeHigh, "sta"), 1);
|
|
186
|
+
return ok ? [addressArg(dst!, name), hex4(lowValue | (highValue << 8))] : undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The instruction at `at`, or undefined when a byte of it is outside the range or the image. */
|
|
190
|
+
function instructionAt(byteAt: ImageByte, at: number, end: number): Instruction | undefined {
|
|
191
|
+
const opcode = byteAt(at);
|
|
192
|
+
if (opcode === undefined) return undefined;
|
|
193
|
+
const length = instructionLength(opcode);
|
|
194
|
+
if (at + length - 1 > end) return undefined;
|
|
195
|
+
const bytes = Array.from({ length: length - 1 }, (_, offset) => byteAt(at + 1 + offset));
|
|
196
|
+
if (bytes.some((byte) => byte === undefined)) return undefined;
|
|
197
|
+
return { opcode, length, value: length === 1 ? 0 : length === 2 ? bytes[0]! : bytes[0]! | (bytes[1]! << 8) };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Finds the macro candidates in code regions. Each region is read as
|
|
202
|
+
* instructions from its start; at each instruction the idioms of the
|
|
203
|
+
* catalogue are tried in order, and a match is a candidate. Candidates do not
|
|
204
|
+
* overlap: the scan goes on after the end of a match.
|
|
205
|
+
*/
|
|
206
|
+
export function findMacroUses(byteAt: ImageByte, regions: ReadonlyArray<{ start: number; end: number }>, name: Namer): MacroCandidate[] {
|
|
207
|
+
const candidates: MacroCandidate[] = [];
|
|
208
|
+
for (const region of [...regions].sort((a, b) => a.start - b.start)) {
|
|
209
|
+
const run: Array<Instruction & { at: number }> = [];
|
|
210
|
+
for (let at = region.start; at <= region.end; ) {
|
|
211
|
+
const instruction = instructionAt(byteAt, at, region.end);
|
|
212
|
+
if (instruction === undefined) break;
|
|
213
|
+
run.push({ ...instruction, at });
|
|
214
|
+
at += instruction.length;
|
|
215
|
+
}
|
|
216
|
+
for (let index = 0; index < run.length; ) {
|
|
217
|
+
const found = CATALOGUE.flatMap((idiom) => {
|
|
218
|
+
const matcher = MATCHERS[idiom.name]!;
|
|
219
|
+
const part = run.slice(index, index + matcher.count);
|
|
220
|
+
const args = part.length === matcher.count ? matcher.match(part, name) : undefined;
|
|
221
|
+
return args === undefined ? [] : [{ idiom, count: matcher.count, args }];
|
|
222
|
+
})[0];
|
|
223
|
+
if (found === undefined) {
|
|
224
|
+
index++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const last = run[index + found.count - 1]!;
|
|
228
|
+
candidates.push({ macro: found.idiom.name, start: run[index]!.at, end: last.at + last.length - 1, args: found.args });
|
|
229
|
+
index += found.count;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return candidates;
|
|
233
|
+
}
|
|
@@ -50,12 +50,19 @@ The run file gives where to start (a program, or a snapshot file), the steps, an
|
|
|
50
50
|
|
|
51
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
52
|
|
|
53
|
+
### Project folder
|
|
54
|
+
|
|
55
|
+
- Paths are relative to the project folder. The project folder is the nearest folder at or above the working directory that holds `.c64-re-tools/`.
|
|
56
|
+
- If the working directory is not in a project and has projects below it, the script refuses and lists them. Then give `--project <folder>` to each script.
|
|
57
|
+
- Keep the results, batches and step files that you save for yourself in `.c64-re-tools/local/` of the project. Git ignores that folder.
|
|
58
|
+
- A shell redirect (`>`) is relative to the working directory, not to the project folder. From the folder above the project, write `<folder>/.c64-re-tools/local/result.json`.
|
|
59
|
+
|
|
53
60
|
## Workflow
|
|
54
61
|
|
|
55
62
|
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
63
|
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
64
|
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
|
|
65
|
+
4. Save the c64-assembler output of the rebuild to the symbol file, for example `build/game.json` in the project folder. From the folder above the project, write `assemble.ts --project <folder> ... > <folder>/build/game.json`.
|
|
59
66
|
5. Run the test script.
|
|
60
67
|
6. Read the result:
|
|
61
68
|
- `PASS`: all observations are equal, or the differences are in the allowed tolerance.
|
|
@@ -23,7 +23,7 @@ A scenario is one JSON object in a project file.
|
|
|
23
23
|
|
|
24
24
|
`original` and `rebuild` each have:
|
|
25
25
|
|
|
26
|
-
- `program`: a PRG file, relative to the project
|
|
26
|
+
- `program`: a PRG file, relative to the project folder.
|
|
27
27
|
- `symbols`: where the names come from:
|
|
28
28
|
- `knowledge`: the project knowledge.
|
|
29
29
|
- A JSON file: the output of the c64-assembler skill, or an object of names and `$` addresses.
|
|
@@ -19,7 +19,7 @@ import { loadSymbols } from "./scenario.ts";
|
|
|
19
19
|
|
|
20
20
|
const USAGE = `run.ts <run.json> --out <dir>
|
|
21
21
|
|
|
22
|
-
<run.json> is a run file relative to the project
|
|
22
|
+
<run.json> is a run file relative to the project folder: where to start,
|
|
23
23
|
optional symbols, and the steps. See references/scenario-format.md, section
|
|
24
24
|
Runs. The files that the steps write, and results.json, go into <dir>. The
|
|
25
25
|
result is one JSON object.`;
|
|
@@ -171,15 +171,15 @@ async function runFile(path: string, out: string): Promise<{ output: Record<stri
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
await runScript(
|
|
174
|
-
async () => {
|
|
175
|
-
const { values, positionals } = parseArgs({ args:
|
|
174
|
+
async (argv) => {
|
|
175
|
+
const { values, positionals } = parseArgs({ args: argv, strict: true, allowPositionals: true, options: { out: { type: "string" }, help: { type: "boolean" } } });
|
|
176
176
|
if (values.help) throw new UsageError("");
|
|
177
177
|
if (positionals.length !== 1) throw new UsageError("give one run file");
|
|
178
178
|
if (values.out === undefined) throw new UsageError("run needs --out <dir>");
|
|
179
179
|
try {
|
|
180
180
|
resolveProjectPath(values.out);
|
|
181
181
|
} catch {
|
|
182
|
-
throw new UsageError(`--out must be a path relative to the project
|
|
182
|
+
throw new UsageError(`--out must be a path relative to the project folder that stays inside it, not ${values.out}`);
|
|
183
183
|
}
|
|
184
184
|
const { output, failed } = await runFile(positionals[0]!, values.out);
|
|
185
185
|
if (failed) process.exitCode = 1;
|
|
@@ -51,7 +51,7 @@ export type Step = StepBody & { side?: Side };
|
|
|
51
51
|
|
|
52
52
|
export interface Subject {
|
|
53
53
|
program: string;
|
|
54
|
-
/** "knowledge" for knowledge
|
|
54
|
+
/** "knowledge" for the project knowledge, a project JSON file (c64-assembler output or a name → "$xxxx" map), or "none". */
|
|
55
55
|
symbols: string;
|
|
56
56
|
}
|
|
57
57
|
|
|
@@ -12,7 +12,7 @@ import { readScenario, runScenario, ScenarioError, type ScenarioResult } from ".
|
|
|
12
12
|
|
|
13
13
|
const USAGE = `test.ts <scenario.json> [--out <dir>]
|
|
14
14
|
|
|
15
|
-
<scenario.json> is a scenario file relative to the project
|
|
15
|
+
<scenario.json> is a scenario file relative to the project folder. See
|
|
16
16
|
references/scenario-format.md. With --out, a FAIL writes both screens, the
|
|
17
17
|
difference image and memory.json with each differing byte into <dir>. The
|
|
18
18
|
result is one JSON object.`;
|
|
@@ -27,7 +27,7 @@ async function run(argv: string[]): Promise<ScenarioResult> {
|
|
|
27
27
|
try {
|
|
28
28
|
resolveProjectPath(values.out);
|
|
29
29
|
} catch {
|
|
30
|
-
throw new UsageError(`--out must be a path relative to the project
|
|
30
|
+
throw new UsageError(`--out must be a path relative to the project folder that stays inside it, not ${values.out}`);
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
try {
|
|
@@ -56,8 +56,8 @@ async function runScenarioFile(path: string, out: string | undefined): Promise<S
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
await runScript(
|
|
59
|
-
async () => {
|
|
60
|
-
const result = await run(
|
|
59
|
+
async (argv) => {
|
|
60
|
+
const result = await run(argv);
|
|
61
61
|
process.exitCode = EXIT[result.result];
|
|
62
62
|
return result;
|
|
63
63
|
},
|
|
@@ -13,7 +13,7 @@ This skill does not change project knowledge.
|
|
|
13
13
|
|
|
14
14
|
## Inputs
|
|
15
15
|
|
|
16
|
-
- The program: a PRG file, relative to the project
|
|
16
|
+
- The program: a PRG file, relative to the project folder.
|
|
17
17
|
- For a capture: the address where the unpacked program starts, and the directory for the result.
|
|
18
18
|
|
|
19
19
|
## Tools / execution path
|
|
@@ -22,8 +22,8 @@ This skill does not change project knowledge.
|
|
|
22
22
|
node <skill>/scripts/unpack.ts inspect original/game.prg
|
|
23
23
|
node <skill>/scripts/unpack.ts trace original/game.prg
|
|
24
24
|
node <skill>/scripts/unpack.ts capture original/game.prg --until '$c000' --out unpacked
|
|
25
|
-
node <skill>/scripts/unpack.ts stages original/game.prg --steps input.json
|
|
26
|
-
node <skill>/scripts/unpack.ts loads original/game.d64 --steps parts.json --out parts --drive
|
|
25
|
+
node <skill>/scripts/unpack.ts stages original/game.prg --steps .c64-re-tools/local/input.json
|
|
26
|
+
node <skill>/scripts/unpack.ts loads original/game.d64 --steps .c64-re-tools/local/parts.json --out parts --drive
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
`inspect` examines the file locally. `trace` and `capture` start their own emulator through the host runtime and start the program. `trace` runs the program and finds the memory that the program wrote and then executed. `capture` stops the program when the CPU gets to the `--until` address. `stages` runs the program and finds each stage: a decruncher, an intro, a copier, and then the program. For each stage it gives the entry, the frames, the written memory (`wrote`) and the entry of the next stage (`next`). In `stages` and `capture`, a `frames` step and a `runUntil` step with only `at` count as run time. `stages` refuses a `runUntil` step with `raster` or `memory`.
|
|
@@ -31,7 +31,7 @@ node <skill>/scripts/unpack.ts loads original/game.d64 --steps parts.json --out
|
|
|
31
31
|
| Option | Meaning |
|
|
32
32
|
|---|---|
|
|
33
33
|
| `--until '$c000'` | The address where the unpacked program starts. The capture stops there after the steps. To capture a later arrival at the address, add a `runUntil` step to an address after the first arrival. |
|
|
34
|
-
| `--out unpacked/game.prg` | Where to write the result, relative to the project
|
|
34
|
+
| `--out unpacked/game.prg` | Where to write the result, relative to the project folder. |
|
|
35
35
|
| `--range '$c000' '$cfff'` | Write this range as a PRG. Without it, the script writes all 64 KiB of RAM as a flat file. |
|
|
36
36
|
| `--timeout-frames 3000` | How many frames the program can run before the capture stops (default 3000). |
|
|
37
37
|
| `--frames 3000` | `trace` only: how many frames the program runs (default 3000). |
|
|
@@ -51,6 +51,13 @@ A steps file has the steps `write`, `registers`, `joystick`, `type`, `frames` an
|
|
|
51
51
|
|
|
52
52
|
A `type` step puts keys in 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. A wait for any key that reads all keyboard columns can end without a key.
|
|
53
53
|
|
|
54
|
+
### Project folder
|
|
55
|
+
|
|
56
|
+
- Paths are relative to the project folder. The project folder is the nearest folder at or above the working directory that holds `.c64-re-tools/`.
|
|
57
|
+
- If the working directory is not in a project and has projects below it, the script refuses and lists them. Then give `--project <folder>` to each script.
|
|
58
|
+
- Keep the results, batches and step files that you save for yourself in `.c64-re-tools/local/` of the project. Git ignores that folder.
|
|
59
|
+
- A shell redirect (`>`) is relative to the working directory, not to the project folder. From the folder above the project, write `<folder>/.c64-re-tools/local/result.json`.
|
|
60
|
+
|
|
54
61
|
## Workflow
|
|
55
62
|
|
|
56
63
|
1. Run `inspect`. Read `basicStart` and `packing`. `basicStart` gives the first SYS line and its address. When the SYS argument needs values at run time, `sys` is null and `expression` gives the argument. Then `trace`, `stages` and `capture` stop at the first instruction after the BASIC text:
|
|
@@ -636,7 +636,7 @@ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }
|
|
|
636
636
|
try {
|
|
637
637
|
resolveProjectPath(values.out);
|
|
638
638
|
} catch {
|
|
639
|
-
throw new UsageError(`--out must be a path relative to the project
|
|
639
|
+
throw new UsageError(`--out must be a path relative to the project folder that stays inside it, not ${values.out}`);
|
|
640
640
|
}
|
|
641
641
|
const minBlock = values["min-block"] === undefined ? 64 : Number(values["min-block"]);
|
|
642
642
|
if (!Number.isInteger(minBlock) || minBlock < 1 || minBlock > 0x10000) throw new UsageError("--min-block must be from 1 to 65536");
|
|
@@ -655,7 +655,7 @@ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }
|
|
|
655
655
|
try {
|
|
656
656
|
resolveProjectPath(values.out);
|
|
657
657
|
} catch {
|
|
658
|
-
throw new UsageError(`--out must be a path relative to the project
|
|
658
|
+
throw new UsageError(`--out must be a path relative to the project folder that stays inside it, not ${values.out}`);
|
|
659
659
|
}
|
|
660
660
|
const timeoutFrames = values["timeout-frames"] === undefined ? 3000 : Number(values["timeout-frames"]);
|
|
661
661
|
if (!Number.isInteger(timeoutFrames) || timeoutFrames < 1 || timeoutFrames > 30_000) throw new UsageError("--timeout-frames must be from 1 to 30000");
|
|
@@ -674,8 +674,8 @@ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }
|
|
|
674
674
|
}
|
|
675
675
|
|
|
676
676
|
await runScript(
|
|
677
|
-
async () => {
|
|
678
|
-
const { output, failed } = await run(
|
|
677
|
+
async (argv) => {
|
|
678
|
+
const { output, failed } = await run(argv);
|
|
679
679
|
if (failed) process.exitCode = 1;
|
|
680
680
|
return output;
|
|
681
681
|
},
|
package/src/cli/main.ts
CHANGED
|
@@ -27,10 +27,12 @@ Usage:
|
|
|
27
27
|
|
|
28
28
|
Harnesses: claude, codex, pi, opencode, gemini, copilot, cursor, windsurf
|
|
29
29
|
(comma-separated; default: all). Without --global the files go into the
|
|
30
|
-
current
|
|
30
|
+
current folder; with --global into your home directory.
|
|
31
31
|
|
|
32
|
-
Run it
|
|
33
|
-
|
|
32
|
+
Run it once at the root of the repository:
|
|
33
|
+
npx -y ${PACKAGE.name}@${PACKAGE.tag} install. Each project folder below
|
|
34
|
+
the root uses that install; a project is a folder with .c64-re-tools/. The
|
|
35
|
+
VICE MCP declaration starts the latest c64-re-tools-mcp through npx. The Host
|
|
34
36
|
Runtime runs on the machine with VICE: start it there with
|
|
35
37
|
npx -y --package=${PACKAGE.name}@${PACKAGE.tag} c64-re-tools-host.
|
|
36
38
|
It runs VICE and the tools that come with it (c1541, petcat). The skill
|
package/src/host-client/image.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface Image {
|
|
|
23
23
|
statePath?: string;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/** Reads an image.json or a PRG, relative to the project
|
|
26
|
+
/** Reads an image.json or a PRG, relative to the project folder. */
|
|
27
27
|
export function readImage(path: string): Image {
|
|
28
28
|
const { bytes } = readProjectFile(path);
|
|
29
29
|
if (!path.endsWith(".json")) {
|