@henols/c64-re-tools 2.0.0-rc.2 → 2.0.0-rc.21
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 +28 -62
- package/distribution/package.ts +1 -16
- package/distribution/plugin.ts +5 -3
- package/package.json +1 -1
- package/skills/c64-assembler/SKILL.md +25 -6
- package/skills/c64-assembler/scripts/assemble.ts +45 -10
- package/skills/c64-assembler/scripts/lib/compare.ts +48 -0
- package/skills/c64-basic/SKILL.md +15 -7
- package/skills/c64-basic/scripts/basic.ts +4 -4
- package/skills/c64-disk/SKILL.md +34 -11
- package/skills/c64-disk/scripts/disk.ts +65 -10
- package/skills/c64-disk/scripts/lib/containers.ts +124 -0
- package/skills/c64-docs/SKILL.md +138 -0
- package/skills/c64-docs/references/page-types.md +141 -0
- package/skills/c64-docs/references/style.md +43 -0
- package/skills/c64-docs/scripts/generate.ts +102 -0
- package/skills/c64-docs/scripts/lib/links.ts +155 -0
- package/skills/c64-docs/scripts/lib/markdown.ts +79 -0
- package/skills/c64-docs/scripts/lib/reference.ts +406 -0
- package/skills/c64-docs/scripts/lib/source-map.ts +81 -0
- package/skills/c64-docs/scripts/link.ts +207 -0
- package/skills/c64-emulator/SKILL.md +29 -8
- package/skills/c64-knowledge/SKILL.md +201 -15
- package/skills/c64-knowledge/scripts/knowledge.ts +927 -78
- package/skills/c64-knowledge/scripts/lib/listing.ts +162 -0
- package/skills/c64-memory-map/SKILL.md +14 -2
- package/skills/c64-memory-map/scripts/memmap.ts +15 -4
- package/skills/c64-provenance/SKILL.md +100 -11
- package/skills/c64-provenance/scripts/lib/compare.ts +152 -0
- package/skills/c64-provenance/scripts/lib/library.ts +584 -0
- package/skills/c64-provenance/scripts/lib/runtime.ts +191 -0
- package/skills/c64-provenance/scripts/lib/separate.ts +140 -0
- package/skills/c64-provenance/scripts/provenance.ts +477 -24
- package/skills/c64-reverse-engineering/SKILL.md +90 -11
- package/skills/c64-source/SKILL.md +174 -0
- package/skills/c64-source/scripts/generate.ts +788 -0
- package/skills/c64-source/scripts/lib/loader.ts +630 -0
- package/skills/c64-source/scripts/lib/source.ts +1121 -0
- package/skills/c64-static-analysis/SKILL.md +53 -13
- package/skills/c64-static-analysis/scripts/analyze.ts +260 -31
- package/skills/c64-static-analysis/scripts/lib/coverage.ts +72 -0
- package/skills/c64-static-analysis/scripts/{findings.ts → lib/findings.ts} +11 -5
- package/skills/c64-static-analysis/scripts/lib/idioms.ts +158 -0
- package/skills/c64-static-analysis/scripts/lib/instructions.ts +105 -0
- package/skills/c64-static-analysis/scripts/lib/macros.ts +233 -0
- package/skills/c64-static-analysis/scripts/lib/pitfalls.ts +68 -0
- package/skills/c64-static-analysis/scripts/lib/pointers.ts +108 -0
- package/skills/c64-testing/SKILL.md +57 -4
- package/skills/c64-testing/references/scenario-format.md +50 -6
- package/skills/c64-testing/scripts/checklist.ts +3 -3
- package/skills/c64-testing/scripts/diff.ts +167 -0
- package/skills/c64-testing/scripts/lib/diff.ts +70 -0
- package/skills/c64-testing/scripts/{scenario.ts → lib/scenario.ts} +183 -152
- package/skills/c64-testing/scripts/run.ts +241 -0
- package/skills/c64-testing/scripts/test.ts +20 -10
- package/skills/c64-unpacker/SKILL.md +88 -21
- package/skills/c64-unpacker/scripts/lib/evidence.ts +207 -0
- package/skills/c64-unpacker/scripts/lib/loads.ts +209 -0
- package/skills/c64-unpacker/scripts/lib/machine.ts +80 -0
- package/skills/c64-unpacker/scripts/lib/stages.ts +142 -0
- package/skills/c64-unpacker/scripts/unpack.ts +545 -61
- package/src/c64.ts +240 -12
- package/src/cli/cleanup.ts +1 -1
- package/src/cli/main.ts +14 -8
- package/src/cli/status.ts +105 -11
- package/src/graphics.ts +158 -0
- package/src/host/main.ts +57 -31
- package/src/host/server.ts +30 -47
- package/src/host/tools/c1541.ts +34 -5
- package/src/host/tools/index.ts +6 -2
- package/src/host/tools/petcat.ts +10 -3
- package/src/host/tools/status.ts +23 -6
- package/src/host/vice/adapter.ts +64 -20
- package/src/host/vice/chips.ts +11 -1
- package/src/host/vice/process.ts +19 -8
- package/src/host/vice/screen.ts +4 -44
- package/src/host/vice/session.ts +343 -60
- package/src/host-client/autostart.ts +55 -0
- package/src/host-client/connect.ts +14 -14
- package/src/host-client/coverage.ts +36 -0
- package/src/host-client/image.ts +63 -0
- package/src/host-client/steps.ts +175 -0
- package/src/host-client/tools.ts +7 -6
- package/src/host-client/transfer.ts +60 -10
- package/src/host-client/vice-session.ts +107 -28
- package/src/knowledge/conflicts.ts +161 -0
- package/src/knowledge/database.ts +150 -38
- package/src/knowledge/expression.ts +209 -0
- package/src/knowledge/graph.ts +104 -0
- package/src/knowledge/history.ts +105 -27
- package/src/knowledge/import.ts +141 -85
- package/src/knowledge/join.ts +211 -0
- package/src/knowledge/program.ts +258 -0
- package/src/knowledge/read.ts +445 -63
- package/src/knowledge/schema.ts +277 -65
- package/src/knowledge/text.ts +415 -0
- package/src/knowledge/write.ts +1145 -150
- package/src/mcp/main.ts +44 -19
- package/src/mcp/output.ts +39 -0
- package/src/mcp/server.ts +15 -4
- package/src/mcp/tools/debug.ts +202 -31
- package/src/mcp/tools/execution.ts +39 -5
- package/src/mcp/tools/input.ts +7 -4
- package/src/mcp/tools/machine.ts +14 -2
- package/src/mcp/tools/media.ts +44 -16
- package/src/mcp/tools/memory.ts +68 -12
- package/src/mcp/tools/video.ts +20 -9
- package/src/native/discover.ts +44 -6
- package/src/native/ghidra/analyze.ts +2 -2
- package/src/native/ghidra/index.ts +19 -3
- package/src/native/ghidra/language/c64rt_6510.slaspec +4 -1
- package/src/native/ghidra/scripts/C64Export.java +33 -1
- package/src/native/ghidra/scripts/C64Prepare.java +7 -1
- package/src/native/status.ts +15 -3
- package/src/native/types.ts +43 -8
- package/src/opcodes.ts +133 -0
- package/src/package.ts +21 -0
- package/{skills/c64-memory-map/scripts → src}/platform.ts +87 -7
- package/src/project.ts +166 -3
- package/src/protocol/messages.ts +17 -10
- package/src/protocol/tools.ts +27 -22
- package/src/protocol/vice.ts +255 -33
- package/src/script.ts +49 -9
- package/src/trace.ts +55 -23
- package/skills/c64-provenance/scripts/compare.ts +0 -92
- package/skills/c64-unpacker/scripts/evidence.ts +0 -135
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
// The c64-source script (D48): generates ACME source from the program and the
|
|
2
|
+
// knowledge (D83) and Ghidra's instructions, which it runs itself, and checks
|
|
3
|
+
// with ACME that the source assembles to the program byte by byte. With --loader it
|
|
4
|
+
// also writes a loader that recreates the entry state, simulates it, and
|
|
5
|
+
// checks it in the emulator. Each switch of the patches (D72) is built on
|
|
6
|
+
// alone and checked. A macro use (D73) that does not give the bytes of the
|
|
7
|
+
// image is written as its instructions. Exit status: 0 when every check
|
|
8
|
+
// passes, 1 when one fails, 2 for a bad call.
|
|
9
|
+
|
|
10
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { parseArgs } from "node:util";
|
|
13
|
+
|
|
14
|
+
import { COVERAGE, coverageSeedKinds, formatC64Address, parseC64Address, textToPetscii } from "#src/c64.ts";
|
|
15
|
+
import { autostartLoaded, RUN_LIMIT_FRAMES } from "#src/host-client/autostart.ts";
|
|
16
|
+
import { MAX_MEMORY_READ, WireFailure } from "#src/host-client/tools.ts";
|
|
17
|
+
import { writeProjectFile } from "#src/host-client/transfer.ts";
|
|
18
|
+
import { ViceSessionClient } from "#src/host-client/vice-session.ts";
|
|
19
|
+
import { conflicts } from "#src/knowledge/conflicts.ts";
|
|
20
|
+
import { KnowledgeError, openForRead } from "#src/knowledge/database.ts";
|
|
21
|
+
import { identifiers } from "#src/knowledge/expression.ts";
|
|
22
|
+
import { analyzedBytes, programCoverage, requireProgram, type ProgramImage } from "#src/knowledge/program.ts";
|
|
23
|
+
import { ANALYZER_NAME_ORDER, currentRevision, listAnalyzerNames, listComments, listMacros, listMacroUses, listModules, listOperands, listPatches, listReferences, listRegions, listSymbols, type SymbolRow } from "#src/knowledge/read.ts";
|
|
24
|
+
import { CODE_REGION_TYPES, DEFAULT_SPACE, SPACE, type Space } from "#src/knowledge/schema.ts";
|
|
25
|
+
import { assemble } from "#src/native/acme.ts";
|
|
26
|
+
import { analyze } from "#src/native/ghidra/analyze.ts";
|
|
27
|
+
import { localToolContext } from "#src/native/local.ts";
|
|
28
|
+
import { GHIDRA_LIMITS, type AssembledSymbol, type Diagnostic } from "#src/native/types.ts";
|
|
29
|
+
import { decode as decodeRegister, describeAddress, platformNamesFor, romUseOf, romUseOfPort, type RomUse } from "#src/platform.ts";
|
|
30
|
+
import { resolveProjectPath } from "#src/project.ts";
|
|
31
|
+
import { runScript, UsageError } from "#src/script.ts";
|
|
32
|
+
import { COPIER_SOURCE, LOADER_JUMP, loaderLimits, planLoader, rasterLine, readEntryState, simulate, stopCause, VIC_DISPLAY_REGISTERS, type EntryState, type LoaderPlan } from "./lib/loader.ts";
|
|
33
|
+
import { OPCODES } from "#src/opcodes.ts";
|
|
34
|
+
import { entriesInData, entryOutsideImage, instructionText, layout, literalOperand, probeForms, render, switchesOf, type Layout, type PatchBlock, type Range, type Rendered } from "./lib/source.ts";
|
|
35
|
+
|
|
36
|
+
const USAGE = `generate.ts --out <dir> [options]
|
|
37
|
+
|
|
38
|
+
The program, its entry state and the coverage come from the knowledge (D83):
|
|
39
|
+
put them in with knowledge.ts image and coverage of the c64-knowledge skill.
|
|
40
|
+
Code that no imported run executed is marked and listed; <dir>/not_executed.json
|
|
41
|
+
has all of its ranges.
|
|
42
|
+
|
|
43
|
+
--out <dir> the directory for the source files, relative to the project
|
|
44
|
+
--entry <address> an entry point for Ghidra, for example '$080d' (repeatable)
|
|
45
|
+
--space <space> the knowledge space of the program: c64 (the default), drive8,
|
|
46
|
+
or bank0 to bank255; the coverage and --loader are for c64 only
|
|
47
|
+
--loader also write loader.asm and loader.prg: the loader copies the
|
|
48
|
+
segments into place, sets the entry state of the knowledge,
|
|
49
|
+
and jumps to the entry; it is checked in the emulator
|
|
50
|
+
--analyzer-names name an address that has no name of the user or the LLM, and
|
|
51
|
+
no platform name, with its analyzer name (D74)
|
|
52
|
+
|
|
53
|
+
The result is one JSON object. Addresses are $ and four hex digits.`;
|
|
54
|
+
|
|
55
|
+
const MAX_LISTED = 20;
|
|
56
|
+
/** Rounds of assembly that move operand expressions to numbers (D65) and macro uses to instructions (D73). */
|
|
57
|
+
const ROUNDS = 6;
|
|
58
|
+
const PROBE_BASE = 0x1000;
|
|
59
|
+
const PROBE_SLOT = 4;
|
|
60
|
+
/** The copies of a full 64 KiB take about 140 frames. */
|
|
61
|
+
const LOADER_FRAMES = 1500;
|
|
62
|
+
|
|
63
|
+
/** The knowledge of one space that the source uses, read once. With `analyzerNames`, an address without a name takes its first analyzer name (D74). */
|
|
64
|
+
function readKnowledge(space: Space, analyzerNames = false) {
|
|
65
|
+
const db = openForRead();
|
|
66
|
+
try {
|
|
67
|
+
// With conflicts from a git merge, the source would depend on which of two rows comes first (D69).
|
|
68
|
+
const found = conflicts(db).length;
|
|
69
|
+
if (found > 0) throw new KnowledgeError("conflict", `The knowledge has ${found} conflict${found === 1 ? "" : "s"} from a git merge. Nothing was generated. Run c64-knowledge's knowledge.ts merge, then generate again.`);
|
|
70
|
+
const symbols = listSymbols(db, undefined, undefined, undefined, space);
|
|
71
|
+
const second = listAnalyzerNames(db, undefined, undefined, undefined, space);
|
|
72
|
+
return {
|
|
73
|
+
revision: currentRevision(db),
|
|
74
|
+
program: requireProgram(db, space),
|
|
75
|
+
// The coverage is of the C64: drive code and banks have none (D40).
|
|
76
|
+
coverage: space === DEFAULT_SPACE ? programCoverage(db)?.map : undefined,
|
|
77
|
+
symbols: analyzerNames ? withAnalyzerNames(symbols, second) : symbols,
|
|
78
|
+
// Analyzer routines are entry points for the decode, also without --analyzer-names.
|
|
79
|
+
routines: [...symbols, ...second].filter((symbol) => symbol.kind === "routine").map((symbol) => symbol.address),
|
|
80
|
+
regions: listRegions(db, undefined, undefined, undefined, space),
|
|
81
|
+
comments: listComments(db, undefined, undefined, space),
|
|
82
|
+
modules: listModules(db, undefined, undefined, space),
|
|
83
|
+
references: listReferences(db, undefined, undefined, undefined, space),
|
|
84
|
+
operands: listOperands(db, undefined, undefined, space),
|
|
85
|
+
patches: listPatches(db, undefined, undefined, undefined, space),
|
|
86
|
+
macros: listMacros(db, undefined, space),
|
|
87
|
+
macroUses: listMacroUses(db, undefined, undefined, undefined, space),
|
|
88
|
+
};
|
|
89
|
+
} finally {
|
|
90
|
+
db?.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The names of the user and the LLM, and for each address without one the
|
|
96
|
+
* first analyzer name in the order heuristic, Ghidra, DXA (D74). An analyzer
|
|
97
|
+
* name that another address has already is left out. The analyzer names keep
|
|
98
|
+
* their origin, so a platform name still wins over them (D61).
|
|
99
|
+
*/
|
|
100
|
+
function withAnalyzerNames(symbols: SymbolRow[], second: SymbolRow[]): SymbolRow[] {
|
|
101
|
+
const named = new Set(symbols.map((symbol) => symbol.address));
|
|
102
|
+
const taken = new Set(symbols.map((symbol) => symbol.name));
|
|
103
|
+
const added: SymbolRow[] = [];
|
|
104
|
+
for (const origin of ANALYZER_NAME_ORDER) {
|
|
105
|
+
for (const row of second.filter((candidate) => candidate.origin === origin)) {
|
|
106
|
+
if (named.has(row.address) || taken.has(row.name)) continue;
|
|
107
|
+
named.add(row.address);
|
|
108
|
+
taken.add(row.name);
|
|
109
|
+
added.push(row);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return [...symbols, ...added].sort((a, b) => a.address - b.address);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Ghidra's instruction starts and lengths inside the image, seeded from the
|
|
117
|
+
* entry, from knowledge of any origin, and from the coverage: a run's flow
|
|
118
|
+
* starts, also after a JSR, so code that an analyzer does not reach by itself
|
|
119
|
+
* is in the source (D74).
|
|
120
|
+
*/
|
|
121
|
+
async function decode(image: ProgramImage, knowledge: ReturnType<typeof readKnowledge>, entries: number[], coverage: Uint8Array | undefined): Promise<Map<number, number>> {
|
|
122
|
+
const inImage = (address: number) => image.segments.some((segment) => address >= segment.start && address <= segment.end);
|
|
123
|
+
const seeds = new Set<number>([...(image.entry === undefined ? [] : [image.entry]), ...entries]);
|
|
124
|
+
for (const address of knowledge.routines) seeds.add(address);
|
|
125
|
+
const flows = coverage === undefined ? { routines: [], codeStarts: [] } : coverageSeedKinds(coverage, (address) => (image.segments.some((segment) => address >= segment.start && address <= segment.end) ? image.byteAt(address) : undefined));
|
|
126
|
+
for (const address of flows.routines) seeds.add(address);
|
|
127
|
+
for (const region of knowledge.regions) {
|
|
128
|
+
if (CODE_REGION_TYPES.includes(region.type)) seeds.add(region.start);
|
|
129
|
+
if (region.type === "jumps") for (let at = region.start; at + 2 <= region.end; at += 3) seeds.add(at);
|
|
130
|
+
}
|
|
131
|
+
const entryPoints = [...seeds].filter(inImage).sort((a, b) => a - b);
|
|
132
|
+
if (entryPoints.length === 0) {
|
|
133
|
+
// An entry in no segment is a capture fault: the image is data only, and the result says entryOutside (R3-01).
|
|
134
|
+
if (image.entry !== undefined && !inImage(image.entry)) return new Map();
|
|
135
|
+
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");
|
|
136
|
+
}
|
|
137
|
+
const dataRanges = knowledge.regions
|
|
138
|
+
.filter((region) => !CODE_REGION_TYPES.includes(region.type) && region.type !== "dual-use")
|
|
139
|
+
.flatMap((region) => image.segments.filter((segment) => region.start <= segment.end && segment.start <= region.end).map((segment) => ({ start: Math.max(region.start, segment.start), end: Math.min(region.end, segment.end) })));
|
|
140
|
+
const { result } = await analyze(
|
|
141
|
+
{
|
|
142
|
+
imageKind: analyzedBytes(image).kind,
|
|
143
|
+
entryPoints: entryPoints.slice(0, GHIDRA_LIMITS.entryPoints),
|
|
144
|
+
codeStarts: flows.codeStarts.filter((address) => !seeds.has(address)).slice(0, GHIDRA_LIMITS.codeStarts),
|
|
145
|
+
dataRanges: dataRanges.slice(0, GHIDRA_LIMITS.dataRanges),
|
|
146
|
+
labels: [],
|
|
147
|
+
decompile: [],
|
|
148
|
+
},
|
|
149
|
+
analyzedBytes(image).bytes,
|
|
150
|
+
localToolContext(),
|
|
151
|
+
);
|
|
152
|
+
const instructions = new Map<number, number>();
|
|
153
|
+
for (const instruction of result.instructions) {
|
|
154
|
+
const bytes = Buffer.from(instruction.bytes, "hex");
|
|
155
|
+
const inside = Array.from({ length: bytes.length }, (_, offset) => instruction.address + offset).every((address, offset) => inImage(address) && image.byteAt(address) === bytes[offset]);
|
|
156
|
+
if (inside) instructions.set(instruction.address, bytes.length);
|
|
157
|
+
}
|
|
158
|
+
return instructions;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Assembles files given as text with ACME; the program or the diagnostics. */
|
|
162
|
+
async function assembleFiles(
|
|
163
|
+
files: Array<{ name: string; text: string }>,
|
|
164
|
+
entry: string,
|
|
165
|
+
defines: Record<string, number> = {},
|
|
166
|
+
): Promise<{ program?: Buffer; start?: number; symbols?: AssembledSymbol[]; diagnostics: Diagnostic[] }> {
|
|
167
|
+
const contents = files.map((file) => Buffer.from(file.text, "utf8"));
|
|
168
|
+
const { result, attachments } = await assemble(
|
|
169
|
+
{ files: files.map((file, index) => ({ path: file.name, size: contents[index]!.length })), entrySource: entry, includeDirs: [], defines },
|
|
170
|
+
contents,
|
|
171
|
+
localToolContext(),
|
|
172
|
+
);
|
|
173
|
+
if (!result.assembled) return { diagnostics: result.diagnostics };
|
|
174
|
+
return { program: attachments![0]!, start: result.loadRange!.start, ...(result.symbols === undefined ? {} : { symbols: result.symbols }), diagnostics: result.diagnostics };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Assembles one instruction of each opcode form that the source uses, each
|
|
179
|
+
* in its own slot. A form that ACME refuses, or writes with other bytes, is
|
|
180
|
+
* written as bytes in the source.
|
|
181
|
+
*/
|
|
182
|
+
async function probe(laid: Layout): Promise<Set<string>> {
|
|
183
|
+
const asBytes = new Set<string>();
|
|
184
|
+
for (let round = 0; round < 3; round++) {
|
|
185
|
+
const forms = probeForms(laid).filter((item) => !asBytes.has(item.form.key));
|
|
186
|
+
if (forms.length === 0) return asBytes;
|
|
187
|
+
const lines = ["!cpu 6510"];
|
|
188
|
+
const lineOf = new Map<number, string>();
|
|
189
|
+
forms.forEach((item, index) => {
|
|
190
|
+
const slot = PROBE_BASE + PROBE_SLOT * index;
|
|
191
|
+
const { mode } = OPCODES[item.form.opcode]!;
|
|
192
|
+
const operand =
|
|
193
|
+
mode === "imp"
|
|
194
|
+
? ""
|
|
195
|
+
: mode === "rel"
|
|
196
|
+
? formatC64Address((slot + 2 + (item.bytes[1]! < 0x80 ? item.bytes[1]! : item.bytes[1]! - 0x100)) & 0xffff)
|
|
197
|
+
: literalOperand(item.form.opcode, item.bytes.length === 3 ? item.bytes[1]! | (item.bytes[2]! << 8) : item.bytes[1]!);
|
|
198
|
+
lines.push(`* = ${formatC64Address(slot)}`);
|
|
199
|
+
lines.push(` ${instructionText(item.form.opcode, item.form.suffix, operand)}`);
|
|
200
|
+
lineOf.set(lines.length, item.form.key);
|
|
201
|
+
});
|
|
202
|
+
const built = await assembleFiles([{ name: "probe.asm", text: `${lines.join("\n")}\n` }], "probe.asm");
|
|
203
|
+
if (built.program === undefined) {
|
|
204
|
+
const refused = built.diagnostics.filter((diagnostic) => diagnostic.severity === "error" && diagnostic.line !== undefined && lineOf.has(diagnostic.line));
|
|
205
|
+
if (refused.length === 0) throw new WireFailure("operation-failed", `ACME refused the opcode probe: ${built.diagnostics.map((diagnostic) => diagnostic.message).join("; ")}`);
|
|
206
|
+
for (const diagnostic of refused) asBytes.add(lineOf.get(diagnostic.line!)!);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
forms.forEach((item, index) => {
|
|
210
|
+
const offset = 2 + PROBE_BASE + PROBE_SLOT * index - built.start!;
|
|
211
|
+
const same = item.bytes.every((byte, at) => built.program![offset + at] === byte);
|
|
212
|
+
if (!same) asBytes.add(item.form.key);
|
|
213
|
+
});
|
|
214
|
+
return asBytes;
|
|
215
|
+
}
|
|
216
|
+
throw new WireFailure("operation-failed", "ACME refused the opcode probe three times.");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The macro uses that an ACME error names: its line, or a line of the macro body in macros.asm, which every use of that macro shares. */
|
|
220
|
+
function usesInErrors(diagnostics: Diagnostic[], lineOf: ReadonlyMap<string, number>, uses: ReadonlyArray<{ start: number; macro: string }>, macroLines: Rendered["macroLines"], markLines: number): Map<number, string> {
|
|
221
|
+
const blamed = new Map<number, string>();
|
|
222
|
+
for (const diagnostic of diagnostics) {
|
|
223
|
+
if (diagnostic.severity !== "error" || diagnostic.file === undefined || diagnostic.line === undefined) continue;
|
|
224
|
+
const start = lineOf.get(`${diagnostic.file}:${diagnostic.line}`);
|
|
225
|
+
if (start !== undefined) blamed.set(start, `ACME refuses it: ${diagnostic.message}`);
|
|
226
|
+
if (diagnostic.file !== "macros.asm") continue;
|
|
227
|
+
for (const [macro, lines] of macroLines) {
|
|
228
|
+
if (diagnostic.line < lines.first + markLines || diagnostic.line > lines.last + markLines) continue;
|
|
229
|
+
for (const use of uses) if (use.macro === macro) blamed.set(use.start, `ACME refuses the macro ${macro}: ${diagnostic.message}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return blamed;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The first address where two byte runs differ, as a reason. */
|
|
236
|
+
function firstDifference(start: number, built: (address: number) => number, original: (address: number) => number, end: number): string | undefined {
|
|
237
|
+
for (let address = start; address <= end; address++) {
|
|
238
|
+
if (built(address) !== original(address)) return `ACME assembles it to other bytes than the image: at ${formatC64Address(address)} it gives $${built(address).toString(16).padStart(2, "0")}, and the image has $${original(address).toString(16).padStart(2, "0")}.`;
|
|
239
|
+
}
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Assembles each macro use alone at its own address, with the constants and
|
|
245
|
+
* the macros, and gives the uses that ACME refuses or assembles to other bytes
|
|
246
|
+
* than the image, with the reason (D73). A use of another length moves no
|
|
247
|
+
* other use, so each one is checked on its own, as the opcode probe does.
|
|
248
|
+
*/
|
|
249
|
+
async function probeUses(laid: Layout, rendered: Rendered, image: ProgramImage): Promise<Map<number, string>> {
|
|
250
|
+
const blamed = new Map<number, string>();
|
|
251
|
+
const support = rendered.files.filter((file) => file.name === "constants.asm" || file.name === "macros.asm");
|
|
252
|
+
for (let round = 0; round < 3; round++) {
|
|
253
|
+
const uses = [...laid.macroUses.values()].filter((use) => !blamed.has(use.start));
|
|
254
|
+
if (uses.length === 0) return blamed;
|
|
255
|
+
// The labels of the image that the args name, with their values: the probe has no other source.
|
|
256
|
+
const labels = new Map<string, number>();
|
|
257
|
+
for (const [address, name] of laid.names) if (laid.inImage(address)) labels.set(name, address);
|
|
258
|
+
const used = [...new Set(uses.flatMap((use) => use.args.flatMap((arg) => identifiers(arg))))].filter((name) => labels.has(name)).sort();
|
|
259
|
+
const lines = ["!cpu 6510", '!source "constants.asm"', '!source "macros.asm"', ...used.map((name) => `${name} = ${labels.get(name)! < 0x100 ? `$${labels.get(name)!.toString(16).padStart(2, "0")}` : formatC64Address(labels.get(name)!)}`), `* = ${formatC64Address(PROBE_BASE)}`];
|
|
260
|
+
const lineOf = new Map<string, number>();
|
|
261
|
+
uses.forEach((use, index) => {
|
|
262
|
+
lines.push(`!pseudopc ${formatC64Address(use.start)} {`, ` +${use.macro}${use.args.length === 0 ? "" : ` ${use.args.join(", ")}`}`);
|
|
263
|
+
lineOf.set(`probe.asm:${lines.length}`, use.start);
|
|
264
|
+
lines.push(`probe_end_${index}`, "}");
|
|
265
|
+
});
|
|
266
|
+
const built = await assembleFiles([...support, { name: "probe.asm", text: `${lines.join("\n")}\n` }], "probe.asm");
|
|
267
|
+
if (built.program === undefined) {
|
|
268
|
+
const refused = usesInErrors(built.diagnostics, lineOf, uses, rendered.macroLines, 0);
|
|
269
|
+
if (refused.size === 0) throw new WireFailure("operation-failed", `ACME refused the macro probe: ${built.diagnostics.map((diagnostic) => diagnostic.message).join("; ")}`);
|
|
270
|
+
for (const [start, reason] of refused) blamed.set(start, reason);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const ends = new Map((built.symbols ?? []).map((symbol) => [symbol.name, symbol.kind === "address" ? symbol.address : symbol.value]));
|
|
274
|
+
let offset = 2 + PROBE_BASE - built.start!;
|
|
275
|
+
uses.forEach((use, index) => {
|
|
276
|
+
const length = ends.get(`probe_end_${index}`)! - use.start;
|
|
277
|
+
const wanted = use.end - use.start + 1;
|
|
278
|
+
const program = built.program!;
|
|
279
|
+
const at = offset;
|
|
280
|
+
offset += length;
|
|
281
|
+
if (length !== wanted) {
|
|
282
|
+
blamed.set(use.start, `ACME assembles it to ${length} bytes, and the range has ${wanted}.`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const reason = firstDifference(use.start, (address) => program[at + address - use.start]!, image.byteAt, use.end);
|
|
286
|
+
if (reason !== undefined) blamed.set(use.start, reason);
|
|
287
|
+
});
|
|
288
|
+
return blamed;
|
|
289
|
+
}
|
|
290
|
+
throw new WireFailure("operation-failed", "ACME refused the macro probe three times.");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The operand expressions (D65) and macro uses (D73) that made the assembly
|
|
295
|
+
* fail or that ACME assembled to other bytes than the image, with the
|
|
296
|
+
* reason. `markLines` is the number of lines added before the rendered text of
|
|
297
|
+
* each file. A use of another length moves every byte after it: then only the
|
|
298
|
+
* first use that differs is blamed, and no operand.
|
|
299
|
+
*/
|
|
300
|
+
function blame(rendered: Rendered, built: Awaited<ReturnType<typeof assembleFiles>>, image: ProgramImage, markLines: number): { numbers: Map<number, string>; uses: Map<number, string> } {
|
|
301
|
+
const blamed = new Map<number, string>();
|
|
302
|
+
if (built.program === undefined) {
|
|
303
|
+
const lines = new Map(rendered.expressions.map((item) => [`${item.file}:${item.line + markLines}`, item.address]));
|
|
304
|
+
for (const diagnostic of built.diagnostics) {
|
|
305
|
+
if (diagnostic.severity !== "error" || diagnostic.file === undefined || diagnostic.line === undefined) continue;
|
|
306
|
+
const address = lines.get(`${diagnostic.file}:${diagnostic.line}`);
|
|
307
|
+
if (address !== undefined) blamed.set(address, `ACME refuses it: ${diagnostic.message}`);
|
|
308
|
+
}
|
|
309
|
+
const useLines = new Map(rendered.uses.map((use) => [`${use.file}:${use.line + markLines}`, use.start]));
|
|
310
|
+
return { numbers: blamed, uses: usesInErrors(built.diagnostics, useLines, rendered.uses, rendered.macroLines, markLines) };
|
|
311
|
+
}
|
|
312
|
+
const end = built.start! + built.program.length - 3;
|
|
313
|
+
const at = (address: number) => (address >= built.start! && address <= end ? built.program![2 + address - built.start!]! : -1);
|
|
314
|
+
const uses = new Map<number, string>();
|
|
315
|
+
const imageEnd = Math.max(...image.segments.map((segment) => segment.end));
|
|
316
|
+
for (const use of rendered.uses) {
|
|
317
|
+
const reason = firstDifference(use.start, at, image.byteAt, use.end);
|
|
318
|
+
if (reason === undefined) continue;
|
|
319
|
+
uses.set(use.start, reason);
|
|
320
|
+
if (end !== imageEnd) break;
|
|
321
|
+
}
|
|
322
|
+
if (uses.size > 0) return { numbers: blamed, uses };
|
|
323
|
+
const word = (at: (address: number) => number, item: Rendered["expressions"][number]) => (item.length === 3 ? at(item.address + 1) | (at(item.address + 2) << 8) : at(item.address + 1));
|
|
324
|
+
const show = (value: number, length: number) => `$${value.toString(16).padStart(length === 3 ? 4 : 2, "0")}`;
|
|
325
|
+
for (const item of rendered.expressions) {
|
|
326
|
+
if (item.address + item.length - 1 > end || item.address < built.start!) continue;
|
|
327
|
+
const assembled = word((address) => built.program![2 + address - built.start!]!, item);
|
|
328
|
+
const original = word(image.byteAt, item);
|
|
329
|
+
if (assembled !== original) blamed.set(item.address, `ACME assembles it to ${show(assembled, item.length)}, and the image has ${show(original, item.length)}.`);
|
|
330
|
+
}
|
|
331
|
+
return { numbers: blamed, uses };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
interface SegmentCheck {
|
|
335
|
+
start: string;
|
|
336
|
+
end: string;
|
|
337
|
+
result: "identical" | "different" | "not covered";
|
|
338
|
+
differences?: number;
|
|
339
|
+
firstDifference?: { address: string; built: number; image: number };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Compares the assembled program with the image, segment by segment. */
|
|
343
|
+
function check(program: Buffer, start: number, image: ProgramImage): { identical: boolean; segments: SegmentCheck[] } {
|
|
344
|
+
const end = start + program.length - 3;
|
|
345
|
+
const segments = image.segments.map((segment): SegmentCheck => {
|
|
346
|
+
const shown = { start: formatC64Address(segment.start), end: formatC64Address(segment.end) };
|
|
347
|
+
if (segment.start < start || segment.end > end) return { ...shown, result: "not covered" };
|
|
348
|
+
let differences = 0;
|
|
349
|
+
let first: SegmentCheck["firstDifference"];
|
|
350
|
+
for (let address = segment.start; address <= segment.end; address++) {
|
|
351
|
+
const built = program[2 + address - start]!;
|
|
352
|
+
if (built === image.byteAt(address)) continue;
|
|
353
|
+
differences++;
|
|
354
|
+
first ??= { address: formatC64Address(address), built, image: image.byteAt(address) };
|
|
355
|
+
}
|
|
356
|
+
return differences === 0 ? { ...shown, result: "identical" } : { ...shown, result: "different", differences, firstDifference: first! };
|
|
357
|
+
});
|
|
358
|
+
return { identical: segments.every((segment) => segment.result === "identical"), segments };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** What a build with one switch on gives (D72). */
|
|
362
|
+
interface SwitchCheck {
|
|
363
|
+
name: string;
|
|
364
|
+
define: string;
|
|
365
|
+
patches: Array<{ start: string; end: string; bytes: number; insideNames?: string[] }>;
|
|
366
|
+
result: "fits" | "overflow" | "changes outside" | "does not assemble";
|
|
367
|
+
changedBytes?: number;
|
|
368
|
+
outside?: { count: number; first: { address: string; built: number; default: number } };
|
|
369
|
+
overflow?: Array<{ start: string; message: string }>;
|
|
370
|
+
diagnostics?: Diagnostic[];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Builds the source with one switch on and compares it with the default
|
|
375
|
+
* build: only the ranges of its patches may change, and the program keeps its
|
|
376
|
+
* start and length. A patch longer than its range stops ACME with !error.
|
|
377
|
+
*/
|
|
378
|
+
async function checkSwitch(name: string, patches: PatchBlock[], files: Array<{ name: string; text: string }>, base: { program: Buffer; start: number }, names: Layout["names"]): Promise<SwitchCheck> {
|
|
379
|
+
const shown: SwitchCheck = {
|
|
380
|
+
name,
|
|
381
|
+
define: `--define ${name}=1`,
|
|
382
|
+
patches: patches.map((patch) => {
|
|
383
|
+
const inside = patch.inner.map((address) => names.get(address)!);
|
|
384
|
+
return { start: formatC64Address(patch.start), end: formatC64Address(patch.end), bytes: patch.end - patch.start + 1, ...(inside.length === 0 ? {} : { insideNames: inside }) };
|
|
385
|
+
}),
|
|
386
|
+
result: "fits",
|
|
387
|
+
};
|
|
388
|
+
const built = await assembleFiles(files, "main.asm", { [name]: 1 });
|
|
389
|
+
if (built.program === undefined) {
|
|
390
|
+
const overflow = built.diagnostics.flatMap((diagnostic) => {
|
|
391
|
+
const found = /The patch \S+ at (\$[0-9a-f]{4}) has .*/.exec(diagnostic.message);
|
|
392
|
+
return found === null ? [] : [{ start: found[1]!, message: found[0] }];
|
|
393
|
+
});
|
|
394
|
+
if (overflow.length > 0) return { ...shown, result: "overflow", overflow };
|
|
395
|
+
return { ...shown, result: "does not assemble", diagnostics: built.diagnostics.slice(0, MAX_LISTED) };
|
|
396
|
+
}
|
|
397
|
+
const inside = (address: number) => patches.some((patch) => address >= patch.start && address <= patch.end);
|
|
398
|
+
let changed = 0;
|
|
399
|
+
let outside: SwitchCheck["outside"];
|
|
400
|
+
const end = Math.max(base.start + base.program.length, built.start! + built.program.length) - 2;
|
|
401
|
+
for (let address = Math.min(base.start, built.start!); address < end; address++) {
|
|
402
|
+
const at = (program: Buffer, start: number) => (address >= start && address < start + program.length - 2 ? program[2 + address - start]! : -1);
|
|
403
|
+
const now = at(built.program, built.start!);
|
|
404
|
+
const before = at(base.program, base.start);
|
|
405
|
+
if (now === before) continue;
|
|
406
|
+
if (inside(address)) changed++;
|
|
407
|
+
else outside = { count: (outside?.count ?? 0) + 1, first: outside?.first ?? { address: formatC64Address(address), built: now, default: before } };
|
|
408
|
+
}
|
|
409
|
+
return outside === undefined ? { ...shown, changedBytes: changed } : { ...shown, result: "changes outside", changedBytes: changed, outside };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Reads RAM in parts. */
|
|
413
|
+
async function readRam(client: ViceSessionClient, start: number, size: number): Promise<Buffer> {
|
|
414
|
+
const parts: Buffer[] = [];
|
|
415
|
+
for (let at = start; at < start + size; at += MAX_MEMORY_READ) {
|
|
416
|
+
parts.push(Buffer.from((await client.memoryRead({ address: at, size: Math.min(MAX_MEMORY_READ, start + size - at), space: "c64", view: "ram" })).data, "hex"));
|
|
417
|
+
}
|
|
418
|
+
return Buffer.concat(parts);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Runs the loader in the emulator to the entry and compares memory, $00/$01 and the registers with the entry state. */
|
|
422
|
+
/**
|
|
423
|
+
* Runs the loader in the emulator to its jump to the entry and compares the
|
|
424
|
+
* machine with the entry state there. At the jump, every byte and register is
|
|
425
|
+
* set and no interrupt has run since the PLP: the 6502 takes one only after
|
|
426
|
+
* the JMP, as the original program can take one right at its entry (F-32).
|
|
427
|
+
*/
|
|
428
|
+
/** What the check of the loader gives. `stop`: why the loader did not get to its copier or its jump. */
|
|
429
|
+
interface LoaderRun {
|
|
430
|
+
verified: boolean;
|
|
431
|
+
differences: string[];
|
|
432
|
+
stop?: { pc: string; port: string; cause?: string; history: string[] };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The history entries that a stop of the loader gives, so the agent sees the last instructions. */
|
|
436
|
+
const STOP_HISTORY = 16;
|
|
437
|
+
|
|
438
|
+
async function runLoader(path: string, program: Buffer, plan: LoaderPlan, image: ProgramImage, state: EntryState, jump: number, copierSource: number, expected: (address: number) => number): Promise<LoaderRun> {
|
|
439
|
+
const client = await ViceSessionClient.open({ videoStandard: "pal" });
|
|
440
|
+
try {
|
|
441
|
+
await client.reset({ mode: "hard", run: false });
|
|
442
|
+
if (!(await autostartLoaded(client, path, program))) return { verified: false, differences: ["The loader was not in memory after autostart."] };
|
|
443
|
+
await client.keyboard(textToPetscii("RUN\n"));
|
|
444
|
+
// A stop before the copier or the jump gives $01, the last instructions and the first byte that is not as planned.
|
|
445
|
+
const stopped = async (pc: number, copied: boolean, what: string): Promise<LoaderRun> => {
|
|
446
|
+
const ram = await readRam(client, 0x0000, 0x10000);
|
|
447
|
+
const port = Buffer.from((await client.memoryRead({ address: 0x0001, size: 1, space: "c64", view: "cpu" })).data, "hex")[0]!;
|
|
448
|
+
const history = (await client.cpuHistory({ limit: STOP_HISTORY, space: "c64" })).entries.map((entry) => `${formatC64Address(entry.address)} ${entry.text}`);
|
|
449
|
+
const cause = stopCause(ram, plan, copierSource, (address) => program[2 + address - 0x0801]!, copied, expected);
|
|
450
|
+
const port01 = `$${port.toString(16).padStart(2, "0")}`;
|
|
451
|
+
return { verified: false, differences: [`The loader did not get to ${what}; it stopped at ${formatC64Address(pc)}.`], stop: { pc: formatC64Address(pc), port: port01, ...(cause === undefined ? {} : { cause }), history } };
|
|
452
|
+
};
|
|
453
|
+
const copier = await client.runUntil({ target: { kind: "address", address: plan.copier, space: "c64" }, timeoutFrames: RUN_LIMIT_FRAMES });
|
|
454
|
+
if (!copier.reached) return await stopped(copier.pc, false, `its copier at ${formatC64Address(plan.copier)}`);
|
|
455
|
+
const atJump = await client.runUntil({ target: { kind: "address", address: jump, space: "c64" }, timeoutFrames: LOADER_FRAMES });
|
|
456
|
+
if (!atJump.reached) return await stopped(atJump.pc, true, `its jump to the entry at ${formatC64Address(jump)}`);
|
|
457
|
+
const memory = await readRam(client, 0x0000, 0x10000);
|
|
458
|
+
if (memory[jump] !== 0x4c || (memory[jump + 1]! | (memory[jump + 2]! << 8)) !== state.pc) {
|
|
459
|
+
return { verified: false, differences: [`The instruction at ${formatC64Address(jump)} is not JMP ${formatC64Address(state.pc)}.`] };
|
|
460
|
+
}
|
|
461
|
+
const port = Buffer.from((await client.memoryRead({ address: 0x0000, size: 2, space: "c64", view: "cpu" })).data, "hex");
|
|
462
|
+
const registers = await client.registersGet("c64");
|
|
463
|
+
const differences: string[] = [];
|
|
464
|
+
const byte = (value: number) => `$${value.toString(16).padStart(2, "0")}`;
|
|
465
|
+
const differ = (what: string, got: number, wanted: number) => got !== wanted && differences.push(`${what} is ${byte(got)}, not ${byte(wanted)}`);
|
|
466
|
+
for (const segment of image.segments) {
|
|
467
|
+
for (let address = Math.max(segment.start, 2); address <= segment.end; address++) differ(formatC64Address(address), memory[address]!, image.byteAt(address));
|
|
468
|
+
}
|
|
469
|
+
for (let address = 2; address < 0x400; address++) if (address !== plan.stackByte) differ(formatC64Address(address), memory[address]!, state.pages[address]!);
|
|
470
|
+
differ("$00", port[0]!, state.port[0]);
|
|
471
|
+
differ("$01", port[1]!, state.port[1]);
|
|
472
|
+
differ("a", registers.a, state.a);
|
|
473
|
+
differ("x", registers.x, state.x);
|
|
474
|
+
differ("y", registers.y, state.y);
|
|
475
|
+
differ("sp", registers.sp, state.sp);
|
|
476
|
+
const flags = (["n", "v", "d", "i", "z", "c"] as const).reduce((value, name, index) => value | (registers.flags[name] ? [0x80, 0x40, 0x08, 0x04, 0x02, 0x01][index]! : 0), 0);
|
|
477
|
+
differ("flags", flags, state.flags & 0xcf);
|
|
478
|
+
if (state.vic !== undefined) {
|
|
479
|
+
// The display registers and the raster line (D60): the loader waited for the line, so the jump comes in it or the next one.
|
|
480
|
+
const vic = Buffer.from((await client.ioRead(0xd000, 0x2f)).data, "hex");
|
|
481
|
+
for (const { register, mask } of VIC_DISPLAY_REGISTERS) differ(formatC64Address(0xd000 + register), vic[register]! & mask, state.vic.registers[register]! & mask);
|
|
482
|
+
differ("the VIC bank", Buffer.from((await client.ioRead(0xdd00, 1)).data, "hex")[0]! & 0x03, state.vic.bank);
|
|
483
|
+
const line = rasterLine(vic);
|
|
484
|
+
const wanted = rasterLine(state.vic.registers);
|
|
485
|
+
if (line !== wanted && line !== wanted + 1) differences.push(`the raster line is ${line}, not ${wanted}`);
|
|
486
|
+
}
|
|
487
|
+
if (plan.cia !== undefined) {
|
|
488
|
+
// The CIA timers (D63): at the jump a running timer is 3 cycles, the JMP, before its value at the entry.
|
|
489
|
+
const chips = (await client.cia("both")).chips;
|
|
490
|
+
const word = (value: number) => `$${value.toString(16).padStart(4, "0")}`;
|
|
491
|
+
const differWord = (what: string, got: number, wanted: number) => got !== wanted && differences.push(`${what} is ${word(got)}, not ${word(wanted)}`);
|
|
492
|
+
for (const timer of plan.cia.timers) {
|
|
493
|
+
const chip = chips[timer.cia - 1]!;
|
|
494
|
+
const name = `CIA ${timer.cia} timer ${timer.timer}`;
|
|
495
|
+
const [counter, latch, control] = timer.timer === "A" ? [chip.timerA, chip.latchA, chip.controlA] : [chip.timerB, chip.latchB, chip.controlB];
|
|
496
|
+
differ(`${name} control`, control, timer.control);
|
|
497
|
+
differWord(`${name} latch`, latch, timer.latch);
|
|
498
|
+
if (timer.counterAtJump !== undefined) differWord(`${name} counter`, counter, timer.counterAtJump);
|
|
499
|
+
}
|
|
500
|
+
plan.cia.masks.forEach((mask, index) => differ(`CIA ${index + 1} interrupt mask`, chips[index]!.interruptMask, mask));
|
|
501
|
+
}
|
|
502
|
+
return { verified: differences.length === 0, differences: differences.slice(0, MAX_LISTED) };
|
|
503
|
+
} finally {
|
|
504
|
+
await client.close();
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* The ROMs the program uses (D75): from the coverage when the knowledge has
|
|
510
|
+
* it, else from $01 of the entry state, else both.
|
|
511
|
+
*/
|
|
512
|
+
function romsInUse(image: ProgramImage, coverage: Uint8Array | undefined): RomUse & { from: "coverage" | "entry state" | "none" } {
|
|
513
|
+
if (coverage !== undefined) return { ...romUseOf(coverage, COVERAGE.romExecute), from: "coverage" };
|
|
514
|
+
const port = (image.state?.cpuPort as Record<string, unknown> | undefined)?.$01;
|
|
515
|
+
if (typeof port === "number") return { ...romUseOfPort(port), from: "entry state" };
|
|
516
|
+
return { basic: true, kernal: true, from: "none" };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** The first words of every file that the script writes. */
|
|
520
|
+
const GENERATED_MARK = "; Generated by c64-source";
|
|
521
|
+
/** The file with every range of instructions that no run executed (D49). */
|
|
522
|
+
const NOT_EXECUTED_FILE = "not_executed.json";
|
|
523
|
+
/** The line of each name and address in the source, for links from the documentation (D80). */
|
|
524
|
+
const SOURCE_MAP_FILE = "source-map.json";
|
|
525
|
+
|
|
526
|
+
/** Removes the .asm files of an earlier run that this run does not write, and an earlier loader.prg and not_executed.json; other files stay. */
|
|
527
|
+
/**
|
|
528
|
+
* source-map.json (D80): the line of each name and of each address that the
|
|
529
|
+
* files write, after the mark line. One entry per line of the file, so a diff
|
|
530
|
+
* of two maps stays short.
|
|
531
|
+
*/
|
|
532
|
+
function sourceMapText(rendered: Rendered, space: string, revision: string | null, files: ReadonlyArray<{ name: string }>): string {
|
|
533
|
+
const labels = rendered.labels.map((label) => JSON.stringify({ address: formatC64Address(label.address), name: label.name, file: label.file, line: label.line + 1 }));
|
|
534
|
+
const items = rendered.items.map((item) => JSON.stringify({ start: formatC64Address(item.start), end: formatC64Address(item.end), file: item.file, line: item.line + 1 }));
|
|
535
|
+
const head = JSON.stringify({ space, revision, files: files.map((file) => file.name) }).slice(0, -1);
|
|
536
|
+
return `${head},\n"labels": [\n${labels.join(",\n")}\n],\n"items": [\n${items.join(",\n")}\n]}\n`;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function removeOldFiles(out: string, keep: ReadonlySet<string>): void {
|
|
540
|
+
const directory = resolveProjectPath(out);
|
|
541
|
+
if (!existsSync(directory)) return;
|
|
542
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
543
|
+
if (!entry.isFile()) continue;
|
|
544
|
+
const name = entry.name;
|
|
545
|
+
const path = join(directory, name);
|
|
546
|
+
if (name === "loader.prg" || name === NOT_EXECUTED_FILE || name === SOURCE_MAP_FILE) rmSync(path, { force: true });
|
|
547
|
+
else if (name.endsWith(".asm") && !keep.has(name) && readFileSync(path, "utf8").startsWith(GENERATED_MARK)) rmSync(path, { force: true });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function run(argv: string[]): Promise<{ output: Record<string, unknown>; failed: boolean }> {
|
|
552
|
+
const { values, positionals } = parseArgs({
|
|
553
|
+
args: argv,
|
|
554
|
+
strict: true,
|
|
555
|
+
allowPositionals: true,
|
|
556
|
+
options: {
|
|
557
|
+
out: { type: "string" },
|
|
558
|
+
entry: { type: "string", multiple: true },
|
|
559
|
+
space: { type: "string" },
|
|
560
|
+
loader: { type: "boolean" },
|
|
561
|
+
"analyzer-names": { type: "boolean" },
|
|
562
|
+
help: { type: "boolean" },
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
if (values.help) throw new UsageError("");
|
|
566
|
+
if (positionals.length !== 0) throw new UsageError("generate takes no image: it reads the program from the knowledge (knowledge.ts image of the c64-knowledge skill puts it in)");
|
|
567
|
+
if (values.out === undefined) throw new UsageError("generate needs --out <dir>");
|
|
568
|
+
try {
|
|
569
|
+
resolveProjectPath(values.out);
|
|
570
|
+
} catch {
|
|
571
|
+
throw new UsageError(`--out must be a path relative to the project folder that stays inside it, not ${values.out}`);
|
|
572
|
+
}
|
|
573
|
+
const entries = (values.entry ?? []).map((value) => {
|
|
574
|
+
try {
|
|
575
|
+
return parseC64Address(value);
|
|
576
|
+
} catch {
|
|
577
|
+
throw new UsageError(`--entry must be $ followed by four hex digits, not ${value}`);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
const space = values.space ?? DEFAULT_SPACE;
|
|
581
|
+
if (!SPACE.test(space)) throw new UsageError("--space must be c64, drive8, or bank0 to bank255");
|
|
582
|
+
if (space !== DEFAULT_SPACE && values.loader) throw new UsageError("--loader works only in space c64");
|
|
583
|
+
const knowledge = readKnowledge(space, values["analyzer-names"] === true);
|
|
584
|
+
const image = knowledge.program;
|
|
585
|
+
const coverage = knowledge.coverage;
|
|
586
|
+
if (values.loader && (image.state === undefined || image.entry === undefined)) {
|
|
587
|
+
throw new UsageError("--loader needs the entry and the entry state of a c64-unpacker capture in the knowledge: put the capture in with knowledge.ts image <image.json>");
|
|
588
|
+
}
|
|
589
|
+
// Read the entry state before the long steps, so a bad state stops the run at once.
|
|
590
|
+
const state = values.loader ? readEntryState(JSON.stringify(image.state), "the entry state of the knowledge") : undefined;
|
|
591
|
+
const romUse = romsInUse(image, coverage);
|
|
592
|
+
const instructions = await decode(image, knowledge, entries, coverage);
|
|
593
|
+
const laid = layout({
|
|
594
|
+
segments: image.segments,
|
|
595
|
+
byteAt: image.byteAt,
|
|
596
|
+
...(image.entry === undefined ? {} : { entry: image.entry }),
|
|
597
|
+
instructions,
|
|
598
|
+
symbols: knowledge.symbols,
|
|
599
|
+
regions: knowledge.regions,
|
|
600
|
+
comments: knowledge.comments,
|
|
601
|
+
modules: knowledge.modules,
|
|
602
|
+
referenceTargets: knowledge.references.map((reference) => reference.to),
|
|
603
|
+
operands: knowledge.operands,
|
|
604
|
+
patches: knowledge.patches,
|
|
605
|
+
macros: knowledge.macros,
|
|
606
|
+
macroUses: knowledge.macroUses,
|
|
607
|
+
...(coverage === undefined ? {} : { coverage }),
|
|
608
|
+
// The platform names are C64 names: drive code and banks get none. A ROM that the program does not use gives none (D75).
|
|
609
|
+
...(space === DEFAULT_SPACE ? { platform: platformNamesFor(romUse), describe: describeAddress, decode: decodeRegister } : {}),
|
|
610
|
+
});
|
|
611
|
+
const asBytes = await probe(laid);
|
|
612
|
+
const header = [`Generated by c64-source from knowledge revision ${knowledge.revision}${space === DEFAULT_SPACE ? "" : ` in space ${space}`}.`, "Change names, comments, regions and modules in knowledge, then generate again."];
|
|
613
|
+
let loader: LoaderPlan | undefined;
|
|
614
|
+
// An image without its entry has no program: the source is still written and checked, but the run fails (R2-02).
|
|
615
|
+
const missingEntry = entryOutsideImage(image.entry, image.segments);
|
|
616
|
+
// A loader that cannot be planned does not stop the source: the source is written and checked, and the result says why.
|
|
617
|
+
let loaderRefused: string | undefined = state === undefined ? undefined : missingEntry;
|
|
618
|
+
if (state !== undefined && missingEntry === undefined) {
|
|
619
|
+
const segments = image.segments.map((segment) => ({ ...segment, files: laid.pieces.filter((piece) => piece.start >= segment.start && piece.start <= segment.end).map((piece) => piece.file) }));
|
|
620
|
+
try {
|
|
621
|
+
const entryName = laid.names.get(image.entry!);
|
|
622
|
+
loader = planLoader({ segments, byteAt: image.byteAt, state, entry: image.entry!, ...(entryName === undefined ? {} : { entryName }), macros: laid.macros.length > 0, header: [`Loader generated by c64-source from knowledge revision ${knowledge.revision}.`, "It copies the segments into place, sets the entry state and jumps to the entry."] });
|
|
623
|
+
} catch (error) {
|
|
624
|
+
if (!(error instanceof WireFailure)) throw error;
|
|
625
|
+
loaderRefused = error.message;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
// Every generated file starts with the mark, so a later run knows its own files and removes the ones it does not write again (F-25).
|
|
629
|
+
const marked = (list: Array<{ name: string; text: string }>) => list.map((file) => ({ ...file, text: `${GENERATED_MARK}. Do not edit: change the knowledge and generate again.\n${file.text}` }));
|
|
630
|
+
const loaderFiles = loader === undefined ? [] : [{ name: "loader.asm", text: loader.text }];
|
|
631
|
+
// An operand expression that ACME refuses, or assembles to other bytes, is written as its number, and a macro use as its
|
|
632
|
+
// instructions; then the source is assembled again (D65, D73).
|
|
633
|
+
const numbers = new Map<number, string>();
|
|
634
|
+
const asInstructions = new Map<number, string>();
|
|
635
|
+
let rendered = render(laid, asBytes, header, numbers, asInstructions);
|
|
636
|
+
if (laid.macroUses.size > 0) for (const [start, reason] of await probeUses(laid, rendered, image)) asInstructions.set(start, reason);
|
|
637
|
+
if (asInstructions.size > 0) rendered = render(laid, asBytes, header, numbers, asInstructions);
|
|
638
|
+
let files = marked([...rendered.files, ...loaderFiles]);
|
|
639
|
+
let built = await assembleFiles(files, "main.asm");
|
|
640
|
+
for (let round = 0; round < ROUNDS && (rendered.expressions.length > 0 || rendered.uses.length > 0); round++) {
|
|
641
|
+
const blamed = blame(rendered, built, image, 1);
|
|
642
|
+
if (blamed.numbers.size === 0 && blamed.uses.size === 0) break;
|
|
643
|
+
for (const [address, reason] of blamed.numbers) numbers.set(address, reason);
|
|
644
|
+
for (const [start, reason] of blamed.uses) asInstructions.set(start, reason);
|
|
645
|
+
rendered = render(laid, asBytes, header, numbers, asInstructions);
|
|
646
|
+
files = marked([...rendered.files, ...loaderFiles]);
|
|
647
|
+
built = await assembleFiles(files, "main.asm");
|
|
648
|
+
}
|
|
649
|
+
removeOldFiles(values.out, new Set(files.map((file) => file.name)));
|
|
650
|
+
for (const file of files) writeProjectFile(`${values.out}/${file.name}`, Buffer.from(file.text, "utf8"), { what: "--out" });
|
|
651
|
+
writeProjectFile(`${values.out}/${SOURCE_MAP_FILE}`, Buffer.from(sourceMapText(rendered, space, knowledge.revision, files), "utf8"), { what: "--out" });
|
|
652
|
+
const showRange = (range: Range) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end) });
|
|
653
|
+
const inData = entriesInData(entries, knowledge.regions);
|
|
654
|
+
if (rendered.notExecuted !== undefined) {
|
|
655
|
+
// Each range with the file of the source that holds it and the routine before it, as a report groups them (M23.6).
|
|
656
|
+
const routines = knowledge.symbols.filter((symbol) => symbol.kind === "routine").sort((a, b) => a.address - b.address);
|
|
657
|
+
const placed = rendered.notExecuted.ranges.map((range) => {
|
|
658
|
+
const file = laid.pieces.find((piece) => range.start >= piece.start && range.start <= piece.end)?.file;
|
|
659
|
+
const routine = routines.filter((symbol) => symbol.address <= range.start).at(-1)?.name;
|
|
660
|
+
return { ...showRange(range), ...(file === undefined ? {} : { file }), ...(routine === undefined ? {} : { routine }) };
|
|
661
|
+
});
|
|
662
|
+
const files: Record<string, number> = {};
|
|
663
|
+
for (const range of placed) if (range.file !== undefined) files[range.file] = (files[range.file] ?? 0) + 1;
|
|
664
|
+
const all = { revision: knowledge.revision, instructions: rendered.notExecuted.instructions, files, ranges: placed };
|
|
665
|
+
writeProjectFile(`${values.out}/${NOT_EXECUTED_FILE}`, Buffer.from(`${JSON.stringify(all, null, 2)}\n`, "utf8"), { what: "--out" });
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const output: Record<string, unknown> = {
|
|
669
|
+
out: values.out,
|
|
670
|
+
files: files.map((file) => file.name),
|
|
671
|
+
sourceMap: `${values.out}/${SOURCE_MAP_FILE}`,
|
|
672
|
+
revision: knowledge.revision,
|
|
673
|
+
instructions: rendered.instructions,
|
|
674
|
+
dataBytes: rendered.dataBytes,
|
|
675
|
+
...(rendered.undecodedBytes === 0 ? {} : { undecodedBytes: rendered.undecodedBytes }),
|
|
676
|
+
// The ROM names that the source leaves out, and the evidence (D75).
|
|
677
|
+
...(space !== DEFAULT_SPACE || (romUse.basic && romUse.kernal) ? {} : { romNamesLeftOut: { roms: [...(romUse.basic ? [] : ["BASIC"]), ...(romUse.kernal ? [] : ["KERNAL"])], ...(romUse.basic || romUse.kernal ? {} : { systemArea: true }), from: romUse.from } }),
|
|
678
|
+
...(missingEntry === undefined ? {} : { entryOutside: { entry: formatC64Address(image.entry!), reason: missingEntry } }),
|
|
679
|
+
labels: { named: laid.namedLabels, generated: laid.generatedLabels },
|
|
680
|
+
// An --entry in a data region decodes nothing: the result says so, and a code region in the knowledge changes it (F-27).
|
|
681
|
+
...(inData.length === 0
|
|
682
|
+
? {}
|
|
683
|
+
: { entriesInData: inData.map(({ entry, region }) => ({ entry: formatC64Address(entry), region: { ...showRange(region), type: region.type }, origin: region.origin })) }),
|
|
684
|
+
...(rendered.asBytes.length === 0
|
|
685
|
+
? {}
|
|
686
|
+
: { asBytes: { count: rendered.asBytes.length, listed: rendered.asBytes.slice(0, MAX_LISTED).map((item) => ({ address: formatC64Address(item.address), instruction: item.text })) } }),
|
|
687
|
+
...(rendered.notExecuted === undefined
|
|
688
|
+
? {}
|
|
689
|
+
: {
|
|
690
|
+
notExecuted: {
|
|
691
|
+
instructions: rendered.notExecuted.instructions,
|
|
692
|
+
ranges: rendered.notExecuted.ranges.length,
|
|
693
|
+
listed: rendered.notExecuted.ranges.slice(0, MAX_LISTED).map(showRange),
|
|
694
|
+
all: `${values.out}/${NOT_EXECUTED_FILE}`,
|
|
695
|
+
},
|
|
696
|
+
}),
|
|
697
|
+
...(laid.unusedModules.length === 0 ? {} : { unusedModules: laid.unusedModules.map((module) => ({ start: formatC64Address(module.start), file: module.file })) }),
|
|
698
|
+
...(rendered.expressions.length === 0 && rendered.operandsAsNumbers.length === 0 ? {} : { operandExpressions: rendered.expressions.length }),
|
|
699
|
+
...(rendered.operandsAsNumbers.length === 0
|
|
700
|
+
? {}
|
|
701
|
+
: {
|
|
702
|
+
operandsAsNumbers: {
|
|
703
|
+
count: rendered.operandsAsNumbers.length,
|
|
704
|
+
listed: rendered.operandsAsNumbers.slice(0, MAX_LISTED).map((item) => ({ address: formatC64Address(item.address), expression: item.expression, reason: item.reason })),
|
|
705
|
+
},
|
|
706
|
+
}),
|
|
707
|
+
...(rendered.uses.length === 0 && rendered.macrosAsInstructions.length === 0 ? {} : { macroUses: rendered.uses.length }),
|
|
708
|
+
// A macro use that the source writes as its instructions does not fail the run: the source stays identical (D73).
|
|
709
|
+
...(rendered.macrosAsInstructions.length === 0
|
|
710
|
+
? {}
|
|
711
|
+
: {
|
|
712
|
+
macrosAsInstructions: {
|
|
713
|
+
count: rendered.macrosAsInstructions.length,
|
|
714
|
+
listed: rendered.macrosAsInstructions.slice(0, MAX_LISTED).map((item) => ({ start: formatC64Address(item.start), end: formatC64Address(item.end), macro: item.macro, reason: item.reason })),
|
|
715
|
+
},
|
|
716
|
+
}),
|
|
717
|
+
// A patch that the source cannot write fails the run: the switch would miss a part (D72).
|
|
718
|
+
...(laid.patchesNotWritten.length === 0
|
|
719
|
+
? {}
|
|
720
|
+
: {
|
|
721
|
+
patchesNotWritten: {
|
|
722
|
+
count: laid.patchesNotWritten.length,
|
|
723
|
+
listed: laid.patchesNotWritten.slice(0, MAX_LISTED).map((item) => ({ start: formatC64Address(item.start), end: formatC64Address(item.end), switch: item.switch, reason: item.reason })),
|
|
724
|
+
},
|
|
725
|
+
}),
|
|
726
|
+
};
|
|
727
|
+
// The loader includes the source, so it is built only from a source that is identical to the image.
|
|
728
|
+
const notBuilt = (reason: string) => (state === undefined ? {} : { loader: { verified: false, reason: loaderRefused ?? reason } });
|
|
729
|
+
if (built.program === undefined) {
|
|
730
|
+
return { output: { ...output, check: { identical: false, diagnostics: built.diagnostics.slice(0, MAX_LISTED) }, ...notBuilt("The source does not assemble, and the loader includes it.") }, failed: true };
|
|
731
|
+
}
|
|
732
|
+
const checked = check(built.program, built.start!, image);
|
|
733
|
+
output.check = checked;
|
|
734
|
+
if (!checked.identical) return { output: { ...output, ...notBuilt("The source does not assemble to the image, and the loader includes it.") }, failed: true };
|
|
735
|
+
// Each switch on alone, against the default build (D72); combinations are not built.
|
|
736
|
+
const switches: SwitchCheck[] = [];
|
|
737
|
+
for (const [name, patches] of switchesOf(laid)) switches.push(await checkSwitch(name, patches, files, { program: built.program, start: built.start! }, laid.names));
|
|
738
|
+
if (switches.length > 0) output.switches = switches;
|
|
739
|
+
const sourceFailed = laid.patchesNotWritten.length > 0 || switches.some((item) => item.result !== "fits");
|
|
740
|
+
if (loaderRefused !== undefined) return { output: { ...output, loader: { verified: false, reason: loaderRefused } }, failed: true };
|
|
741
|
+
if (loader === undefined || state === undefined) return { output, failed: missingEntry !== undefined || sourceFailed };
|
|
742
|
+
|
|
743
|
+
const loaderBuilt = await assembleFiles(files, "loader.asm");
|
|
744
|
+
if (loaderBuilt.program === undefined) return { output: { ...output, loader: { verified: false, reason: "loader.asm does not assemble.", diagnostics: loaderBuilt.diagnostics.slice(0, MAX_LISTED) } }, failed: true };
|
|
745
|
+
const program = loaderBuilt.program;
|
|
746
|
+
// The simulation: the loader in memory as it loads, then every copy in its order, byte by byte.
|
|
747
|
+
const memory = new Int16Array(0x10000).fill(-1);
|
|
748
|
+
program.subarray(2).forEach((byte, offset) => (memory[loaderBuilt.start! + offset] = byte));
|
|
749
|
+
const expected = (address: number) => (image.segments.some((segment) => address >= segment.start && address <= segment.end) ? image.byteAt(address) : state.pages[address]!);
|
|
750
|
+
const wrongPacking = loader.blocks.find((block) => {
|
|
751
|
+
for (let address = block.range.start; address <= block.range.end; address++) if (memory[block.packedAt + address - block.range.start] !== block.bytes(address)) return true;
|
|
752
|
+
return false;
|
|
753
|
+
});
|
|
754
|
+
if (loaderBuilt.start !== 0x0801 || loaderBuilt.start + program.length - 3 !== loader.end || wrongPacking !== undefined) {
|
|
755
|
+
throw new WireFailure("operation-failed", `The assembled loader does not have the planned layout${wrongPacking === undefined ? "" : ` (${wrongPacking.what})`}. This is a defect of the c64-source script.`);
|
|
756
|
+
}
|
|
757
|
+
const jumpSymbol = loaderBuilt.symbols?.find((symbol) => symbol.name === LOADER_JUMP);
|
|
758
|
+
if (jumpSymbol === undefined) throw new WireFailure("operation-failed", `The assembled loader has no ${LOADER_JUMP} label. This is a defect of the c64-source script.`);
|
|
759
|
+
const jump = jumpSymbol.kind === "address" ? jumpSymbol.address : jumpSymbol.value;
|
|
760
|
+
const wrong = simulate(memory, loader.copies, expected);
|
|
761
|
+
if (wrong !== undefined) throw new WireFailure("operation-failed", `The simulated loader writes a wrong byte at ${formatC64Address(wrong)}. This is a defect of the c64-source script.`);
|
|
762
|
+
const loaderPath = `${values.out}/loader.prg`;
|
|
763
|
+
writeProjectFile(loaderPath, program, { what: "--out" });
|
|
764
|
+
(output.files as string[]).push("loader.prg");
|
|
765
|
+
const copierSymbol = loaderBuilt.symbols?.find((symbol) => symbol.name === COPIER_SOURCE);
|
|
766
|
+
if (copierSymbol === undefined) throw new WireFailure("operation-failed", `The assembled loader has no ${COPIER_SOURCE} label. This is a defect of the c64-source script.`);
|
|
767
|
+
const copierSource = copierSymbol.kind === "address" ? copierSymbol.address : copierSymbol.value;
|
|
768
|
+
const ran = await runLoader(loaderPath, program, loader, image, state, jump, copierSource, expected);
|
|
769
|
+
output.loader = {
|
|
770
|
+
program: loaderPath,
|
|
771
|
+
copies: loader.copies.map((copy) => ({ what: copy.what, to: `${formatC64Address(copy.target.start)}-${formatC64Address(copy.target.end)}` })),
|
|
772
|
+
copier: formatC64Address(loader.copier),
|
|
773
|
+
stackByte: formatC64Address(loader.stackByte),
|
|
774
|
+
...ran,
|
|
775
|
+
// What the loader cannot set as it was at the entry (D63).
|
|
776
|
+
...(loaderLimits(loader).length === 0 ? {} : { limits: loaderLimits(loader) }),
|
|
777
|
+
};
|
|
778
|
+
return { output, failed: !ran.verified || sourceFailed };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
await runScript(
|
|
782
|
+
async (argv) => {
|
|
783
|
+
const { output, failed } = await run(argv);
|
|
784
|
+
if (failed) process.exitCode = 1;
|
|
785
|
+
return output;
|
|
786
|
+
},
|
|
787
|
+
{ usage: USAGE, refusals: [KnowledgeError] },
|
|
788
|
+
);
|