@henols/c64-re-tools 2.0.0-rc.8 → 2.0.0
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 +98 -115
- package/package.json +1 -1
- package/skills/c64-assembler/SKILL.md +22 -6
- package/skills/c64-assembler/scripts/assemble.ts +42 -14
- package/skills/c64-assembler/scripts/lib/compare.ts +48 -0
- package/skills/c64-basic/SKILL.md +10 -1
- package/skills/c64-basic/scripts/basic.ts +3 -3
- package/skills/c64-disk/SKILL.md +13 -4
- package/skills/c64-disk/scripts/disk.ts +7 -7
- package/skills/c64-disk/scripts/{containers.ts → lib/containers.ts} +2 -2
- 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 +109 -0
- package/skills/c64-docs/scripts/lib/links.ts +167 -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 +239 -0
- package/skills/c64-emulator/SKILL.md +16 -5
- package/skills/c64-knowledge/SKILL.md +140 -18
- package/skills/c64-knowledge/scripts/knowledge.ts +737 -112
- package/skills/c64-knowledge/scripts/lib/listing.ts +162 -0
- package/skills/c64-memory-map/SKILL.md +11 -2
- package/skills/c64-memory-map/scripts/memmap.ts +2 -2
- package/skills/c64-provenance/SKILL.md +66 -11
- package/skills/c64-provenance/scripts/{compare.ts → lib/compare.ts} +3 -3
- package/skills/c64-provenance/scripts/lib/library.ts +584 -0
- package/skills/c64-provenance/scripts/{runtime.ts → lib/runtime.ts} +2 -2
- package/skills/c64-provenance/scripts/{separate.ts → lib/separate.ts} +3 -3
- package/skills/c64-provenance/scripts/provenance.ts +242 -40
- package/skills/c64-reverse-engineering/SKILL.md +51 -26
- package/skills/c64-source/SKILL.md +76 -28
- package/skills/c64-source/scripts/generate.ts +425 -127
- package/skills/c64-source/scripts/{loader.ts → lib/loader.ts} +84 -25
- package/skills/c64-source/scripts/lib/source.ts +1121 -0
- package/skills/c64-static-analysis/SKILL.md +35 -18
- package/skills/c64-static-analysis/scripts/analyze.ts +184 -55
- package/skills/c64-static-analysis/scripts/{coverage.ts → lib/coverage.ts} +19 -25
- package/skills/c64-static-analysis/scripts/{findings.ts → lib/findings.ts} +6 -2
- package/skills/c64-static-analysis/scripts/{idioms.ts → lib/idioms.ts} +21 -12
- package/skills/c64-static-analysis/scripts/{instructions.ts → lib/instructions.ts} +1 -1
- 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 +36 -7
- package/skills/c64-testing/references/scenario-format.md +12 -8
- 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} +45 -34
- package/skills/c64-testing/scripts/run.ts +73 -21
- package/skills/c64-testing/scripts/test.ts +5 -5
- package/skills/c64-unpacker/SKILL.md +23 -9
- package/skills/c64-unpacker/scripts/{evidence.ts → lib/evidence.ts} +1 -1
- package/skills/c64-unpacker/scripts/{loads.ts → lib/loads.ts} +4 -4
- package/skills/c64-unpacker/scripts/{machine.ts → lib/machine.ts} +4 -4
- package/skills/c64-unpacker/scripts/{stages.ts → lib/stages.ts} +17 -5
- package/skills/c64-unpacker/scripts/unpack.ts +41 -24
- package/src/c64.ts +103 -5
- package/src/cli/cleanup.ts +1 -1
- package/src/cli/main.ts +7 -4
- package/src/cli/status.ts +70 -3
- package/src/graphics.ts +158 -0
- package/src/host/main.ts +43 -8
- package/src/host/server.ts +10 -3
- package/src/host/tools/petcat.ts +1 -1
- package/src/host/tools/status.ts +1 -1
- package/src/host/vice/adapter.ts +4 -3
- package/src/host/vice/process.ts +2 -2
- package/src/host/vice/screen.ts +1 -42
- package/src/host/vice/session.ts +196 -23
- package/src/host-client/autostart.ts +1 -1
- package/src/host-client/connect.ts +1 -1
- package/src/host-client/coverage.ts +2 -29
- package/src/host-client/image.ts +66 -0
- package/src/host-client/steps.ts +9 -4
- package/src/host-client/tools.ts +1 -0
- package/src/host-client/transfer.ts +39 -11
- package/src/host-client/vice-session.ts +33 -16
- 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 +70 -22
- package/src/knowledge/import.ts +64 -55
- package/src/knowledge/join.ts +211 -0
- package/src/knowledge/program.ts +266 -0
- package/src/knowledge/read.ts +258 -27
- package/src/knowledge/schema.ts +199 -75
- package/src/knowledge/text.ts +415 -0
- package/src/knowledge/write.ts +740 -57
- package/src/mcp/main.ts +1 -1
- package/src/mcp/output.ts +15 -8
- package/src/mcp/server.ts +3 -1
- package/src/mcp/tools/debug.ts +97 -16
- package/src/mcp/tools/execution.ts +38 -5
- package/src/mcp/tools/machine.ts +11 -2
- package/src/mcp/tools/media.ts +18 -13
- package/src/mcp/tools/memory.ts +17 -8
- package/src/mcp/tools/video.ts +9 -9
- package/src/native/discover.ts +2 -2
- package/src/native/ghidra/analyze.ts +2 -2
- package/src/native/ghidra/index.ts +1 -1
- package/src/native/ghidra/language/c64rt_6510.slaspec +4 -1
- package/src/native/ghidra/scripts/C64Export.java +1 -1
- package/src/native/ghidra/scripts/C64Prepare.java +7 -1
- package/src/native/types.ts +14 -8
- package/{skills/c64-source/scripts → src}/opcodes.ts +75 -3
- package/src/platform.ts +70 -7
- package/src/project.ts +180 -3
- package/src/protocol/messages.ts +1 -1
- package/src/protocol/tools.ts +2 -16
- package/src/protocol/vice.ts +124 -15
- package/src/script.ts +46 -7
- package/src/trace.ts +55 -23
- package/skills/c64-assembler/scripts/compare.ts +0 -84
- package/skills/c64-source/scripts/source.ts +0 -595
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// cruncher whose bytes look like code. "capture" runs the program in an
|
|
5
5
|
// emulator until it reaches a given address (the unpacked entry point) and
|
|
6
6
|
// writes the memory into the project. "loads" captures each part of a
|
|
7
|
-
// multi-load program
|
|
7
|
+
// multi-load program. No native unpack tool is used.
|
|
8
8
|
|
|
9
9
|
import { parseArgs } from "node:util";
|
|
10
10
|
|
|
@@ -17,14 +17,14 @@ import { readProjectFile, writeProjectFile } from "#src/host-client/transfer.ts"
|
|
|
17
17
|
import { ViceSessionClient } from "#src/host-client/vice-session.ts";
|
|
18
18
|
import { resolveProjectPath } from "#src/project.ts";
|
|
19
19
|
import { runScript, UsageError } from "#src/script.ts";
|
|
20
|
-
import { basicStart, inspect, PackingError, readMemoryMap, underRom, writtenThenExecuted } from "./evidence.ts";
|
|
21
|
-
import { loads, readLoadSteps } from "./loads.ts";
|
|
22
|
-
import { callReturn, captureSegments, executedIn, inRanges, mergeMaps, stageCandidates, stagePoints, type Range } from "./stages.ts";
|
|
23
|
-
import { entryState, IO_AREAS, json, machineState, readRam, READY_FRAMES, vectorsOf } from "./machine.ts";
|
|
20
|
+
import { basicStart, inspect, PackingError, readMemoryMap, underRom, writtenThenExecuted } from "./lib/evidence.ts";
|
|
21
|
+
import { loads, readLoadSteps } from "./lib/loads.ts";
|
|
22
|
+
import { callReturn, captureSegments, executedIn, inRanges, mergeMaps, stageCandidates, stagePoints, withoutRanges, type Range } from "./lib/stages.ts";
|
|
23
|
+
import { entryState, IO_AREAS, json, machineState, readRam, READY_FRAMES, vectorsOf } from "./lib/machine.ts";
|
|
24
24
|
|
|
25
25
|
const USAGE = `unpack.ts inspect <program.prg>
|
|
26
26
|
unpack.ts trace <program.prg> [--frames <n>] [--entry <address>] [--steps <file>]
|
|
27
|
-
unpack.ts capture <program.prg> --until <address> --out <dir> [--range <start> <end>] [--timeout-frames <n>] [--entry <address>] [--steps <file>] [--min-stage <bytes>]
|
|
27
|
+
unpack.ts capture <program.prg> --until <address> --out <dir> [--range <start> <end>] [--exclude <start>-<end>]... [--timeout-frames <n>] [--entry <address>] [--steps <file>] [--min-stage <bytes>]
|
|
28
28
|
unpack.ts stages <program.prg> [--frames <n>] [--entry <address>] [--steps <file>] [--min-stage <bytes>]
|
|
29
29
|
unpack.ts loads <program> --steps <file> --out <dir> [--drive] [--min-block <bytes>]
|
|
30
30
|
|
|
@@ -37,6 +37,8 @@ capture run the program until the CPU gets to <address> after the steps, and w
|
|
|
37
37
|
for each segment, and entry_state.json (registers, $00/$01, pages
|
|
38
38
|
0-3, VIC, SID and CIA registers, vectors, and what differs from a
|
|
39
39
|
clean reset). --range gives one segment in place of the found ones.
|
|
40
|
+
--exclude (more than once) leaves a range out of the segments, for
|
|
41
|
+
example bytes that a crack left and the program never reads.
|
|
40
42
|
--entry starts it there instead of RUN.
|
|
41
43
|
|
|
42
44
|
stages run the program for <n> frames (default 3000) and report each stage:
|
|
@@ -74,7 +76,7 @@ const MAX_ADVANCE = 10_000;
|
|
|
74
76
|
/** The most ranges one memory map read gives. */
|
|
75
77
|
const MAX_RANGES = 1000;
|
|
76
78
|
|
|
77
|
-
/** Reads and checks a steps file: action steps with $ addresses only
|
|
79
|
+
/** Reads and checks a steps file: action steps with $ addresses only. */
|
|
78
80
|
function readSteps(path: string): ActionStep[] {
|
|
79
81
|
let value: unknown;
|
|
80
82
|
try {
|
|
@@ -102,7 +104,6 @@ async function runSteps(client: ViceSessionClient, steps: ActionStep[]): Promise
|
|
|
102
104
|
async function trace(program: string, options: { frames: number; entry?: number; steps: ActionStep[] }): Promise<{ output: Record<string, unknown>; failed: boolean }> {
|
|
103
105
|
const { bytes } = readProjectFile(program);
|
|
104
106
|
if (bytes.length < 3) throw new UsageError("the file is too short for a PRG");
|
|
105
|
-
const load = bytes[0]! | (bytes[1]! << 8);
|
|
106
107
|
if (options.entry === undefined && basicStart(bytes) === undefined) throw new UsageError(NO_BASIC);
|
|
107
108
|
const client = await ViceSessionClient.open({ videoStandard: "pal" });
|
|
108
109
|
try {
|
|
@@ -190,7 +191,7 @@ const SYS_FRAMES = 50;
|
|
|
190
191
|
* BASIC computes: an exec breakpoint over that part of the program stops there.
|
|
191
192
|
*/
|
|
192
193
|
/**
|
|
193
|
-
* Starts the program as a c64-testing scenario does
|
|
194
|
+
* Starts the program as a c64-testing scenario does: hard reset, load,
|
|
194
195
|
* an empty memory map, then RUN up to the first BASIC line, or the pc at
|
|
195
196
|
* `entry`. Gives why it did not start.
|
|
196
197
|
*/
|
|
@@ -203,7 +204,7 @@ async function startLoaded(client: ViceSessionClient, program: string, bytes: Ui
|
|
|
203
204
|
return undefined;
|
|
204
205
|
}
|
|
205
206
|
|
|
206
|
-
/** The raster position now: where a step's frames end
|
|
207
|
+
/** The raster position now: where a step's frames end. */
|
|
207
208
|
async function rasterAt(client: ViceSessionClient): Promise<{ line: number; cycle: number }> {
|
|
208
209
|
const { timing } = await client.observe({ timing: true });
|
|
209
210
|
return { line: timing!.rasterLine, cycle: timing!.rasterCycle };
|
|
@@ -219,7 +220,7 @@ const MAX_STAGES = 16;
|
|
|
219
220
|
/** Most stops of one re-run of a chunk: own code and writes over it each stop once. */
|
|
220
221
|
const MAX_STAGE_STOPS = 4096;
|
|
221
222
|
const STAGE_SNAPSHOT = "stage-chunk";
|
|
222
|
-
/** The snapshot at a call into written code, while the run checks if the call returns
|
|
223
|
+
/** The snapshot at a call into written code, while the run checks if the call returns. */
|
|
223
224
|
const CALL_SNAPSHOT = "stage-call";
|
|
224
225
|
|
|
225
226
|
interface Stage {
|
|
@@ -245,7 +246,7 @@ function trackedRunUntil(step: ActionStep): step is { runUntil: { at: string };
|
|
|
245
246
|
}
|
|
246
247
|
|
|
247
248
|
/**
|
|
248
|
-
* Starts the program and follows its stages
|
|
249
|
+
* Starts the program and follows its stages. The memory map holds what
|
|
249
250
|
* the current stage wrote; when the CPU executed a block of at least
|
|
250
251
|
* `minStage` written bytes during a chunk of frames, the chunk runs again from
|
|
251
252
|
* its snapshot with exec breakpoints over those blocks, which stop at the
|
|
@@ -266,7 +267,7 @@ async function follow(
|
|
|
266
267
|
if (options.entry === undefined && basic === undefined) throw new UsageError(NO_BASIC);
|
|
267
268
|
const notStarted = await startLoaded(client, program, bytes, options.entry);
|
|
268
269
|
if (notStarted !== undefined) return { stages: [], frames: 0, reached: false, failed: notStarted };
|
|
269
|
-
// The frames of each step end where a scenario's do: the first step counts from RUN, a later one from where it starts
|
|
270
|
+
// The frames of each step end where a scenario's do: the first step counts from RUN, a later one from where it starts.
|
|
270
271
|
let anchor: { line: number; cycle: number } | undefined = await rasterAt(client);
|
|
271
272
|
const framed = () => (anchor === undefined ? {} : { anchor });
|
|
272
273
|
const found: Stage[] = [];
|
|
@@ -276,10 +277,10 @@ async function follow(
|
|
|
276
277
|
let firstFrame = 0;
|
|
277
278
|
let total = 0;
|
|
278
279
|
let ended = false;
|
|
279
|
-
/** Where the run stops: the target of a runUntil step while it runs, then until
|
|
280
|
+
/** Where the run stops: the target of a runUntil step while it runs, then until. */
|
|
280
281
|
let stopAt: number | undefined;
|
|
281
282
|
const atTarget = (pc: number) => stopAt !== undefined && pc === stopAt;
|
|
282
|
-
// The current stage: its memory map since it started, chunk by chunk, its own code, and the written code it calls
|
|
283
|
+
// The current stage: its memory map since it started, chunk by chunk, its own code, and the written code it calls.
|
|
283
284
|
let stage = { map: new Uint8Array(0x10000), own: new Set<number>(), called: [] as Range[] };
|
|
284
285
|
const startStage = async (next: number, last: Uint8Array) => {
|
|
285
286
|
mergeMaps(stage.map, last);
|
|
@@ -355,7 +356,7 @@ async function follow(
|
|
|
355
356
|
left -= advanced;
|
|
356
357
|
const pc = run.pc ?? (await client.registersGet("c64")).pc;
|
|
357
358
|
map = await readCoverage(client);
|
|
358
|
-
// A frame can end in written code that the stage called before
|
|
359
|
+
// A frame can end in written code that the stage called before: that stop starts no stage.
|
|
359
360
|
if (inRanges(candidates, pc) && !inRanges(stage.called, pc)) {
|
|
360
361
|
if (written(pc)) {
|
|
361
362
|
const back = left > 0 && !atTarget(pc) ? await returns(map, left) : undefined;
|
|
@@ -443,7 +444,7 @@ async function follow(
|
|
|
443
444
|
stopAt = undefined;
|
|
444
445
|
}
|
|
445
446
|
};
|
|
446
|
-
// The steps run first and in the tracked time: a frames step and a runUntil step to an address
|
|
447
|
+
// The steps run first and in the tracked time: a frames step and a runUntil step to an address. The others act between two chunks.
|
|
447
448
|
for (const [index, step] of options.steps.entries()) {
|
|
448
449
|
if ("frames" in step) await track(step.frames);
|
|
449
450
|
else if (trackedRunUntil(step)) {
|
|
@@ -496,7 +497,7 @@ async function stages(program: string, options: { frames: number; entry?: number
|
|
|
496
497
|
|
|
497
498
|
async function capture(
|
|
498
499
|
program: string,
|
|
499
|
-
options: { until: number; out: string; range?: [number, number]; timeoutFrames: number; entry?: number; steps: ActionStep[]; minStage: number },
|
|
500
|
+
options: { until: number; out: string; range?: [number, number]; exclude: Array<{ start: number; end: number }>; timeoutFrames: number; entry?: number; steps: ActionStep[]; minStage: number },
|
|
500
501
|
): Promise<{ output: Record<string, unknown>; failed: boolean }> {
|
|
501
502
|
const client = await ViceSessionClient.open({ videoStandard: "pal" });
|
|
502
503
|
try {
|
|
@@ -513,7 +514,12 @@ async function capture(
|
|
|
513
514
|
const memory = await readRam(client, 0x0000, 0x10000);
|
|
514
515
|
const state = await machineState(client);
|
|
515
516
|
// The segments: what the stages wrote, without loader leftovers, with no overlap.
|
|
516
|
-
const
|
|
517
|
+
const found = options.range !== undefined ? [{ start: options.range[0], end: options.range[1] }] : captureSegments(followed.stages, registers.pc);
|
|
518
|
+
const inside = options.exclude.find((range) => registers.pc >= range.start && registers.pc <= range.end);
|
|
519
|
+
if (inside !== undefined) {
|
|
520
|
+
return { output: { captured: false, reason: `The entry ${formatC64Address(registers.pc)} is in --exclude ${formatC64Address(inside.start)}-${formatC64Address(inside.end)}. A segment must hold the entry.` }, failed: true };
|
|
521
|
+
}
|
|
522
|
+
const segments = withoutRanges(found, options.exclude);
|
|
517
523
|
const file = (path: string) => `${options.out}/${path}`;
|
|
518
524
|
const write = (path: string, bytes: Uint8Array) => writeProjectFile(file(path), bytes, { what: "--out" });
|
|
519
525
|
write("image.bin", memory);
|
|
@@ -530,6 +536,7 @@ async function capture(
|
|
|
530
536
|
{
|
|
531
537
|
entry: formatC64Address(registers.pc),
|
|
532
538
|
segments: segments.map((segment, index) => ({ ...showRange(segment), ...("writtenBy" in segment ? { writtenBy: segment.writtenBy } : {}), file: files[index] })),
|
|
539
|
+
...(options.exclude.length === 0 ? {} : { excluded: options.exclude.map(showRange) }),
|
|
533
540
|
vectors,
|
|
534
541
|
stages: showStages(followed.stages),
|
|
535
542
|
image: "image.bin",
|
|
@@ -556,6 +563,7 @@ async function capture(
|
|
|
556
563
|
out: options.out,
|
|
557
564
|
files: ["image.json", "image.bin", "entry_state.json", ...files],
|
|
558
565
|
segments: segments.map(showRange),
|
|
566
|
+
...(options.exclude.length === 0 ? {} : { excluded: options.exclude.map(showRange) }),
|
|
559
567
|
stages: followed.stages.length,
|
|
560
568
|
differsFromReset: differs.length,
|
|
561
569
|
},
|
|
@@ -571,7 +579,7 @@ const OPTIONS_OF: Record<string, string[]> = {
|
|
|
571
579
|
inspect: [],
|
|
572
580
|
trace: ["frames", "entry", "steps"],
|
|
573
581
|
stages: ["frames", "entry", "steps", "min-stage"],
|
|
574
|
-
capture: ["until", "out", "range", "timeout-frames", "entry", "steps", "min-stage"],
|
|
582
|
+
capture: ["until", "out", "range", "exclude", "timeout-frames", "entry", "steps", "min-stage"],
|
|
575
583
|
loads: ["steps", "out", "drive", "min-block"],
|
|
576
584
|
};
|
|
577
585
|
|
|
@@ -584,6 +592,7 @@ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }
|
|
|
584
592
|
until: { type: "string" },
|
|
585
593
|
out: { type: "string" },
|
|
586
594
|
range: { type: "string" },
|
|
595
|
+
exclude: { type: "string", multiple: true },
|
|
587
596
|
"timeout-frames": { type: "string" },
|
|
588
597
|
frames: { type: "string" },
|
|
589
598
|
entry: { type: "string" },
|
|
@@ -636,7 +645,7 @@ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }
|
|
|
636
645
|
try {
|
|
637
646
|
resolveProjectPath(values.out);
|
|
638
647
|
} catch {
|
|
639
|
-
throw new UsageError(`--out must be a path relative to the project
|
|
648
|
+
throw new UsageError(`--out must be a path relative to the project folder that stays inside it, not ${values.out}`);
|
|
640
649
|
}
|
|
641
650
|
const minBlock = values["min-block"] === undefined ? 64 : Number(values["min-block"]);
|
|
642
651
|
if (!Number.isInteger(minBlock) || minBlock < 1 || minBlock > 0x10000) throw new UsageError("--min-block must be from 1 to 65536");
|
|
@@ -652,10 +661,17 @@ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }
|
|
|
652
661
|
} else if (rest.length > 0) {
|
|
653
662
|
throw new UsageError("too many arguments");
|
|
654
663
|
}
|
|
664
|
+
const exclude = (values.exclude ?? []).map((value) => {
|
|
665
|
+
const parts = value.split("-");
|
|
666
|
+
if (parts.length !== 2) throw new UsageError(`--exclude takes <start>-<end>, not ${value}`);
|
|
667
|
+
const range = { start: address(parts[0]!, "--exclude"), end: address(parts[1]!, "--exclude") };
|
|
668
|
+
if (range.end < range.start) throw new UsageError(`the --exclude end must not be before its start: ${value}`);
|
|
669
|
+
return range;
|
|
670
|
+
});
|
|
655
671
|
try {
|
|
656
672
|
resolveProjectPath(values.out);
|
|
657
673
|
} catch {
|
|
658
|
-
throw new UsageError(`--out must be a path relative to the project
|
|
674
|
+
throw new UsageError(`--out must be a path relative to the project folder that stays inside it, not ${values.out}`);
|
|
659
675
|
}
|
|
660
676
|
const timeoutFrames = values["timeout-frames"] === undefined ? 3000 : Number(values["timeout-frames"]);
|
|
661
677
|
if (!Number.isInteger(timeoutFrames) || timeoutFrames < 1 || timeoutFrames > 30_000) throw new UsageError("--timeout-frames must be from 1 to 30000");
|
|
@@ -666,6 +682,7 @@ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }
|
|
|
666
682
|
until: address(values.until, "--until"),
|
|
667
683
|
out: values.out,
|
|
668
684
|
...(range === undefined ? {} : { range }),
|
|
685
|
+
exclude,
|
|
669
686
|
timeoutFrames,
|
|
670
687
|
steps,
|
|
671
688
|
minStage,
|
|
@@ -674,8 +691,8 @@ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }
|
|
|
674
691
|
}
|
|
675
692
|
|
|
676
693
|
await runScript(
|
|
677
|
-
async () => {
|
|
678
|
-
const { output, failed } = await run(
|
|
694
|
+
async (argv) => {
|
|
695
|
+
const { output, failed } = await run(argv);
|
|
679
696
|
if (failed) process.exitCode = 1;
|
|
680
697
|
return output;
|
|
681
698
|
},
|
package/src/c64.ts
CHANGED
|
@@ -20,6 +20,16 @@ export function formatC64Address(value: number): string {
|
|
|
20
20
|
return `$${checkAddress(value).toString(16).padStart(4, "0")}`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** VIC-II state with its addresses as $xxxx; the other values stay numbers. */
|
|
24
|
+
export function formatVicii<T extends { screenAddress: number; graphicsAddress: number }>(state: T): Omit<T, "screenAddress" | "graphicsAddress"> & { screenAddress: string; graphicsAddress: string } {
|
|
25
|
+
return { ...state, screenAddress: formatC64Address(state.screenAddress), graphicsAddress: formatC64Address(state.graphicsAddress) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A sprite with its data address as $xxxx; the other values stay numbers. */
|
|
29
|
+
export function formatSprite<T extends { dataAddress: number }>(sprite: T): Omit<T, "dataAddress"> & { dataAddress: string } {
|
|
30
|
+
return { ...sprite, dataAddress: formatC64Address(sprite.dataAddress) };
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
/** A byte as $xx. Throws RangeError outside 0-255. */
|
|
24
34
|
export function formatC64Byte(value: number): string {
|
|
25
35
|
if (!Number.isInteger(value) || value < 0 || value > 0xff) throw new RangeError(`C64 byte out of range: ${value}`);
|
|
@@ -37,8 +47,25 @@ export function prgRange(prg: Uint8Array): { start: number; end: number; body: U
|
|
|
37
47
|
return { start, end: start + prg.length - 3, body: prg.subarray(2) };
|
|
38
48
|
}
|
|
39
49
|
|
|
40
|
-
/**
|
|
41
|
-
export const
|
|
50
|
+
/** The tables of addresses: words, or words minus 1 for an RTS dispatch; a split table has all low bytes, then all high bytes. */
|
|
51
|
+
export const POINTER_TABLE_TYPES = ["addresses", "addresses-split", "rts-addresses", "rts-addresses-split"] as const;
|
|
52
|
+
export type PointerTableType = (typeof POINTER_TABLE_TYPES)[number];
|
|
53
|
+
|
|
54
|
+
/** The entries of a table of addresses in [start, end]: the address of each low and high byte, and the target that the entry names. */
|
|
55
|
+
export function pointerTableEntries(table: { start: number; end: number; type: PointerTableType }, byteAt: (address: number) => number): Array<{ low: number; high: number; target: number }> {
|
|
56
|
+
const length = table.end - table.start + 1;
|
|
57
|
+
const split = table.type === "addresses-split" || table.type === "rts-addresses-split";
|
|
58
|
+
const minus = table.type === "rts-addresses" || table.type === "rts-addresses-split" ? 1 : 0;
|
|
59
|
+
const count = Math.floor(length / 2);
|
|
60
|
+
return Array.from({ length: count }, (_, index) => {
|
|
61
|
+
const low = split ? table.start + index : table.start + 2 * index;
|
|
62
|
+
const high = split ? table.start + count + index : low + 1;
|
|
63
|
+
return { low, high, target: ((byteAt(low) | (byteAt(high) << 8)) + minus) & 0xffff };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** How 6502 code refers to an address. A pointer is code that builds the address from immediates. */
|
|
68
|
+
export const REFERENCE_KINDS = ["call", "jump", "read", "write", "reference", "pointer"] as const;
|
|
42
69
|
export type ReferenceKind = (typeof REFERENCE_KINDS)[number];
|
|
43
70
|
|
|
44
71
|
/** A symbol or label name: assembler label syntax, at most 64 characters; a leading dot marks a local label. */
|
|
@@ -163,9 +190,9 @@ const MIN_BASIC_LOAD = 0x0400;
|
|
|
163
190
|
* zero, the "marker" is the first machine code after the BASIC text (SYS
|
|
164
191
|
* lines that jump right behind themselves). The stored links are not read:
|
|
165
192
|
* BASIC relinks a loaded program, and a crack can store odd links to hide
|
|
166
|
-
* lines from LIST. A load address below $0400 is a memory dump
|
|
193
|
+
* lines from LIST. A load address below $0400 is a memory dump, and so
|
|
167
194
|
* is a file whose bytes run past $ffff: a 64 KiB dump from $0000 always does
|
|
168
|
-
|
|
195
|
+
*.
|
|
169
196
|
*/
|
|
170
197
|
export function parseTokenized(program: Uint8Array): TokenizedProgram {
|
|
171
198
|
if (program.length < 4) return { reason: "The file is too short for a BASIC program." };
|
|
@@ -347,7 +374,7 @@ export function findHandoffs(lines: TokenizedLine[]): BasicHandoff[] {
|
|
|
347
374
|
return handoffs;
|
|
348
375
|
}
|
|
349
376
|
|
|
350
|
-
// Execution coverage
|
|
377
|
+
// Execution coverage: the byte format of a coverage map, and the 6502
|
|
351
378
|
// instruction lengths that turn the instruction starts in it into code ranges.
|
|
352
379
|
// VICE marks only the opcode byte of an executed instruction as executed.
|
|
353
380
|
|
|
@@ -409,3 +436,74 @@ export function executedRamCode(map: Uint8Array, base: number, memory: Uint8Arra
|
|
|
409
436
|
}
|
|
410
437
|
return ranges;
|
|
411
438
|
}
|
|
439
|
+
|
|
440
|
+
// Coverage flows: the seeds that a coverage map gives a static analysis.
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Instructions after which the next instruction starts a flow for the seeds: the
|
|
444
|
+
* ones that do not fall through, and JSR, because an analyzer that takes the
|
|
445
|
+
* called routine for one that does not return stops there (the Bruce Lee run).
|
|
446
|
+
*/
|
|
447
|
+
const FLOW_ENDS = new Set([0x00, 0x20, 0x40, 0x4c, 0x60, 0x6c]);
|
|
448
|
+
/**
|
|
449
|
+
* The executed instruction starts in RAM that begin a flow: the first after
|
|
450
|
+
* a gap, or the first after an instruction that does not fall through or is a
|
|
451
|
+
* JSR. The analyzers follow the rest of a flow themselves.
|
|
452
|
+
*/
|
|
453
|
+
export function coverageEntries(map: Uint8Array, image: (address: number) => number | undefined): number[] {
|
|
454
|
+
const entries: number[] = [];
|
|
455
|
+
let falls: number | undefined;
|
|
456
|
+
for (let address = 0; address < 0x10000; address++) {
|
|
457
|
+
if ((map[address]! & COVERAGE.ramExecute) === 0) continue;
|
|
458
|
+
const opcode = image(address);
|
|
459
|
+
if (opcode === undefined) continue;
|
|
460
|
+
if (falls !== address) entries.push(address);
|
|
461
|
+
falls = FLOW_ENDS.has(opcode) ? undefined : address + instructionLength(opcode);
|
|
462
|
+
}
|
|
463
|
+
return entries;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Every executed instruction start in RAM inside the image: VICE marks the opcode byte of an instruction as executed, not its operand. */
|
|
467
|
+
export function executedStarts(map: Uint8Array, image: (address: number) => number | undefined): number[] {
|
|
468
|
+
const starts: number[] = [];
|
|
469
|
+
for (let address = 0; address < 0x10000; address++) if ((map[address]! & COVERAGE.ramExecute) !== 0 && image(address) !== undefined) starts.push(address);
|
|
470
|
+
return starts;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const BRANCHES = new Set([0x10, 0x30, 0x50, 0x70, 0x90, 0xb0, 0xd0, 0xf0]);
|
|
474
|
+
|
|
475
|
+
/** Instructions that never fall through to the next one. */
|
|
476
|
+
const NO_FALL = new Set([0x00, 0x40, 0x4c, 0x60, 0x6c]);
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* The coverage flow starts split for Ghidra. A start is a code start, a
|
|
480
|
+
* place in a routine, when it follows an executed JSR, or when an executed
|
|
481
|
+
* branch or JMP from outside its own flow reaches it (the loop of a routine
|
|
482
|
+
* that jumps back to its own start does not count). Any other start, for
|
|
483
|
+
* example a vector or table target, is a routine entry.
|
|
484
|
+
*/
|
|
485
|
+
export function coverageSeedKinds(map: Uint8Array, image: (address: number) => number | undefined): { routines: number[]; codeStarts: number[] } {
|
|
486
|
+
const entries = coverageEntries(map, image);
|
|
487
|
+
const flowOf = new Map<number, number>();
|
|
488
|
+
for (const entry of entries) {
|
|
489
|
+
// A flow runs over the executed instructions that fall through, also over a JSR.
|
|
490
|
+
for (let at = entry; ; ) {
|
|
491
|
+
const opcode = image(at);
|
|
492
|
+
if (opcode === undefined || (map[at]! & COVERAGE.ramExecute) === 0 || flowOf.has(at)) break;
|
|
493
|
+
flowOf.set(at, entry);
|
|
494
|
+
if (NO_FALL.has(opcode)) break;
|
|
495
|
+
at += instructionLength(opcode);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const inside = new Set<number>();
|
|
499
|
+
for (const address of executedStarts(map, image)) {
|
|
500
|
+
const opcode = image(address)!;
|
|
501
|
+
const low = image(address + 1);
|
|
502
|
+
const high = image(address + 2);
|
|
503
|
+
const reach = (target: number) => flowOf.get(address) !== target && inside.add(target);
|
|
504
|
+
if (BRANCHES.has(opcode) && low !== undefined) reach((address + 2 + (low < 0x80 ? low : low - 0x100)) & 0xffff);
|
|
505
|
+
if (opcode === 0x4c && low !== undefined && high !== undefined) reach(low | (high << 8));
|
|
506
|
+
if (opcode === 0x20) inside.add(address + 3);
|
|
507
|
+
}
|
|
508
|
+
return { routines: entries.filter((entry) => !inside.has(entry)), codeStarts: entries.filter((entry) => inside.has(entry)) };
|
|
509
|
+
}
|
package/src/cli/cleanup.ts
CHANGED
|
@@ -29,7 +29,7 @@ interface Manifest {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** The directory that the manifest and relative item paths belong to. */
|
|
32
|
-
function scopeRoot(scope: Scope, cwd = process.cwd()): string {
|
|
32
|
+
export function scopeRoot(scope: Scope, cwd = process.cwd()): string {
|
|
33
33
|
return scope === "global" ? homedir() : cwd;
|
|
34
34
|
}
|
|
35
35
|
|
package/src/cli/main.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { WireFailure } from "../protocol/messages.ts";
|
|
|
14
14
|
import { startTrace } from "../trace.ts";
|
|
15
15
|
import { runApSdk } from "./ap-sdk.ts";
|
|
16
16
|
import { installedItems, parseTargets, tidyAfterUninstall, undoWindsurfProjectMcp, windsurfBefore, withoutWindsurfMcp, type Scope } from "./cleanup.ts";
|
|
17
|
-
import { hostStatusLines, toolLines } from "./status.ts";
|
|
17
|
+
import { hostStatusLines, installedLines, toolLines } from "./status.ts";
|
|
18
18
|
|
|
19
19
|
const HELP = `c64-re-tools: Commodore 64 reverse engineering and development
|
|
20
20
|
|
|
@@ -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
|
|
@@ -50,6 +52,7 @@ async function status(): Promise<number> {
|
|
|
50
52
|
console.log(`c64-re-tools ${PACKAGE.version}`);
|
|
51
53
|
console.log(`Package: ${join(here, "..", "..")}`);
|
|
52
54
|
console.log(`Node: ${process.version} (${process.execPath})`);
|
|
55
|
+
for (const line of installedLines()) console.log(line);
|
|
53
56
|
// The skill scripts run these tools here, on this machine.
|
|
54
57
|
const local = await localToolStatus(localToolContext());
|
|
55
58
|
for (const line of toolLines("Tools here:", local)) console.log(line);
|
package/src/cli/status.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
// The status report of the CLI: the
|
|
2
|
-
// Runtime with its tools.
|
|
1
|
+
// The status report of the CLI: the installed skills and MCP declarations,
|
|
2
|
+
// the tools on this machine and the Host Runtime with its tools.
|
|
3
3
|
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { isAbsolute, join } from "node:path";
|
|
6
|
+
|
|
7
|
+
import { installedItems, scopeRoot, type InstallItem, type Scope } from "./cleanup.ts";
|
|
4
8
|
import { hostTools, type ToolCallOptions } from "../host-client/tools.ts";
|
|
5
9
|
import { viceVersionOf } from "../native/discover.ts";
|
|
6
10
|
import { PACKAGE } from "../package.ts";
|
|
@@ -34,7 +38,7 @@ export async function hostStatusLines(options: ToolCallOptions = {}): Promise<{
|
|
|
34
38
|
try {
|
|
35
39
|
({ host, tools } = await hostTools(options));
|
|
36
40
|
} catch (error) {
|
|
37
|
-
// status is read by a person: the instruction for an agent
|
|
41
|
+
// status is read by a person: the instruction for an agent stays out.
|
|
38
42
|
return { lines: [`Host Runtime: not reachable. ${(error as Error).message.replace(` ${STOP_AND_ASK}`, "")}`], ready: false };
|
|
39
43
|
}
|
|
40
44
|
const mixed = mixedViceBuilds(tools);
|
|
@@ -59,3 +63,66 @@ export function mixedViceBuilds(tools: readonly ToolStatus[]): string | undefine
|
|
|
59
63
|
if (new Set(builds).size <= 1) return undefined;
|
|
60
64
|
return `the VICE tools come from different VICE builds: ${running.map((tool) => `${tool.version} (${tool.path})`).join(", ")}. Put x64sc, c1541 and petcat of one VICE first on PATH, or set C64RT_VICE, C64RT_C1541 and C64RT_PETCAT.`;
|
|
61
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The skills and MCP declarations that install wrote for this project and
|
|
69
|
+
* globally: the c64-re-tools version of each harness's skills,
|
|
70
|
+
* from their package.json, and the command that starts the MCP server. A
|
|
71
|
+
* warning names skills of another version than this one.
|
|
72
|
+
*/
|
|
73
|
+
export function installedLines(cwd = process.cwd()): string[] {
|
|
74
|
+
const lines: string[] = [];
|
|
75
|
+
for (const scope of ["project", "global"] as const satisfies readonly Scope[]) {
|
|
76
|
+
const items = installedItems(scope, undefined, cwd);
|
|
77
|
+
if (items.length === 0) continue;
|
|
78
|
+
const root = scopeRoot(scope, cwd);
|
|
79
|
+
lines.push(`Installed ${scope === "project" ? `in this project (${root})` : "globally"}:`);
|
|
80
|
+
for (const harness of [...new Set(items.map((item) => item.harness))]) {
|
|
81
|
+
const skills = items.filter((item) => item.harness === harness && item.kind === "skill");
|
|
82
|
+
const versions = [...new Set(skills.map((item) => skillVersion(item, root)).filter((version) => version !== undefined))];
|
|
83
|
+
if (skills.length > 0) {
|
|
84
|
+
lines.push(` ${harness}: ${skills.length} skills, c64-re-tools ${versions.length === 0 ? "of an unknown version" : versions.join(" and ")}`);
|
|
85
|
+
const other = versions.filter((version) => version !== PACKAGE.version);
|
|
86
|
+
if (other.length > 0) lines.push(` Warning: the ${harness} skills are c64-re-tools ${other.join(" and ")} and this is ${PACKAGE.version}. Run npx -y ${PACKAGE.name}@${PACKAGE.tag} update${scope === "global" ? " --global" : ""} to install this version.`);
|
|
87
|
+
}
|
|
88
|
+
for (const item of items.filter((candidate) => candidate.harness === harness && candidate.kind === "mcp")) {
|
|
89
|
+
lines.push(` ${harness} MCP server: ${mcpCommand(item, root) ?? `declared in ${item.files.join(", ")}`}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (lines.length === 0) return ["Installed: nothing in this project or globally."];
|
|
94
|
+
lines.push(" The MCP server gives its own version in the result of c64_status.");
|
|
95
|
+
return lines;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function itemPath(file: string, root: string): string {
|
|
99
|
+
return isAbsolute(file) ? file : join(root, file);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The version in the package.json of an installed skill; a skill without scripts has none. */
|
|
103
|
+
function skillVersion(item: InstallItem, root: string): string | undefined {
|
|
104
|
+
const file = item.files.find((candidate) => candidate.endsWith("/package.json") || candidate.endsWith("\\package.json"));
|
|
105
|
+
if (file === undefined) return undefined;
|
|
106
|
+
try {
|
|
107
|
+
const { version } = JSON.parse(readFileSync(itemPath(file, root), "utf8")) as { version?: unknown };
|
|
108
|
+
return typeof version === "string" ? version : undefined;
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The command of the MCP server in a JSON configuration file, or undefined for another format. */
|
|
115
|
+
function mcpCommand(item: InstallItem, root: string): string | undefined {
|
|
116
|
+
const key = item.detail?.mergeKey;
|
|
117
|
+
const [file] = item.files;
|
|
118
|
+
if (key === undefined || file === undefined) return undefined;
|
|
119
|
+
try {
|
|
120
|
+
const servers = (JSON.parse(readFileSync(itemPath(file, root), "utf8")) as Record<string, unknown>)[key] as Record<string, { command?: unknown; args?: unknown }> | undefined;
|
|
121
|
+
const server = servers?.[item.name];
|
|
122
|
+
if (typeof server?.command !== "string") return undefined;
|
|
123
|
+
const args = Array.isArray(server.args) ? server.args.filter((arg): arg is string => typeof arg === "string") : [];
|
|
124
|
+
return [server.command, ...args].join(" ");
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
package/src/graphics.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Indexed images: the PNG encoder of emulator frames, and the C64 graphics
|
|
2
|
+
// in memory as images for the documentation. A region of the knowledge
|
|
3
|
+
// gives no display mode and no colours, so a sprite, a bitmap or a charset is read
|
|
4
|
+
// both in hires and in multicolour, with fixed pattern palettes.
|
|
5
|
+
|
|
6
|
+
import { crc32, deflateSync } from "node:zlib";
|
|
7
|
+
|
|
8
|
+
export interface IndexedFrame {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
/** One palette index per pixel, row by row. */
|
|
12
|
+
pixels: Uint8Array;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type Rgb = readonly [number, number, number];
|
|
16
|
+
|
|
17
|
+
function chunk(type: string, data: Uint8Array): Buffer {
|
|
18
|
+
const length = Buffer.alloc(4);
|
|
19
|
+
length.writeUInt32BE(data.length, 0);
|
|
20
|
+
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
|
|
21
|
+
const crc = Buffer.alloc(4);
|
|
22
|
+
crc.writeUInt32BE(crc32(body), 0);
|
|
23
|
+
return Buffer.concat([length, body, crc]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Encodes an indexed frame as an 8-bit palette PNG. */
|
|
27
|
+
export function encodePng(frame: IndexedFrame, palette: ReadonlyArray<Rgb>): Buffer {
|
|
28
|
+
for (const index of frame.pixels) {
|
|
29
|
+
if (index >= palette.length) throw new Error(`pixel colour ${index} is not in the ${palette.length}-colour palette`);
|
|
30
|
+
}
|
|
31
|
+
const header = Buffer.alloc(13);
|
|
32
|
+
header.writeUInt32BE(frame.width, 0);
|
|
33
|
+
header.writeUInt32BE(frame.height, 4);
|
|
34
|
+
header[8] = 8; // bit depth
|
|
35
|
+
header[9] = 3; // indexed colour
|
|
36
|
+
const rows = Buffer.alloc((frame.width + 1) * frame.height);
|
|
37
|
+
for (let row = 0; row < frame.height; row++) {
|
|
38
|
+
// Filter byte 0 (none), then the row.
|
|
39
|
+
rows.set(frame.pixels.subarray(row * frame.width, (row + 1) * frame.width), row * (frame.width + 1) + 1);
|
|
40
|
+
}
|
|
41
|
+
return Buffer.concat([
|
|
42
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
43
|
+
chunk("IHDR", header),
|
|
44
|
+
chunk("PLTE", Buffer.from(palette.flat())),
|
|
45
|
+
chunk("IDAT", deflateSync(rows)),
|
|
46
|
+
chunk("IEND", new Uint8Array(0)),
|
|
47
|
+
]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type GraphicsMode = "hires" | "multicolour";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The pattern palettes: the index is the pixel value (hires: the bit; multicolour:
|
|
54
|
+
* the bit pair, 0 = background $d021, 1 = $d022 or screen high nibble, 2 = $d023 or
|
|
55
|
+
* screen low nibble, 3 = colour RAM), and the last index is the gap between sprites.
|
|
56
|
+
*/
|
|
57
|
+
export const PATTERN_PALETTES: Record<GraphicsMode, readonly Rgb[]> = {
|
|
58
|
+
hires: [
|
|
59
|
+
[0x00, 0x00, 0x00],
|
|
60
|
+
[0xff, 0xff, 0xff],
|
|
61
|
+
[0x35, 0x28, 0x79],
|
|
62
|
+
],
|
|
63
|
+
multicolour: [
|
|
64
|
+
[0x00, 0x00, 0x00],
|
|
65
|
+
[0xad, 0xad, 0xad],
|
|
66
|
+
[0x6c, 0x6c, 0x6c],
|
|
67
|
+
[0xed, 0xf1, 0x71],
|
|
68
|
+
[0x35, 0x28, 0x79],
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The pixel values of one byte, left to right: 8 hires pixels, or 4 multicolour pixels of 2 each. */
|
|
73
|
+
function bytePixels(byte: number, mode: GraphicsMode): number[] {
|
|
74
|
+
if (mode === "hires") return Array.from({ length: 8 }, (_, bit) => (byte >> (7 - bit)) & 1);
|
|
75
|
+
return Array.from({ length: 8 }, (_, pixel) => (byte >> (6 - 2 * (pixel >> 1))) & 3);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const SPRITE = { width: 24, height: 21, bytes: 63 } as const;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The sprites of `bytes` as one sheet, `perRow` sprites wide with a gap of one
|
|
82
|
+
* pixel. Each sprite takes `stride` bytes (64 in a VIC bank, 63 when packed).
|
|
83
|
+
*/
|
|
84
|
+
export function spriteSheet(bytes: Uint8Array, mode: GraphicsMode, stride = 64, perRow = 8): IndexedFrame {
|
|
85
|
+
const count = Math.max(1, Math.floor((bytes.length + stride - SPRITE.bytes) / stride));
|
|
86
|
+
const columns = Math.min(count, perRow);
|
|
87
|
+
const rows = Math.ceil(count / perRow);
|
|
88
|
+
const width = columns * (SPRITE.width + 1) + 1;
|
|
89
|
+
const height = rows * (SPRITE.height + 1) + 1;
|
|
90
|
+
const gap = PATTERN_PALETTES[mode].length - 1;
|
|
91
|
+
const pixels = new Uint8Array(width * height).fill(gap);
|
|
92
|
+
for (let sprite = 0; sprite < count; sprite++) {
|
|
93
|
+
const left = (sprite % perRow) * (SPRITE.width + 1) + 1;
|
|
94
|
+
const top = Math.floor(sprite / perRow) * (SPRITE.height + 1) + 1;
|
|
95
|
+
for (let row = 0; row < SPRITE.height; row++) {
|
|
96
|
+
for (let column = 0; column < 3; column++) {
|
|
97
|
+
const values = bytePixels(bytes[sprite * stride + row * 3 + column] ?? 0, mode);
|
|
98
|
+
values.forEach((value, x) => (pixels[(top + row) * width + left + column * 8 + x] = value));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { width, height, pixels };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Cells of 8 bytes as 8x8 pixels, `perRow` cells to a row, cell 0 at the top left. */
|
|
106
|
+
function cellFrame(bytes: Uint8Array, mode: GraphicsMode, perRow: number): IndexedFrame {
|
|
107
|
+
const cells = Math.ceil(bytes.length / 8);
|
|
108
|
+
const width = Math.min(cells, perRow) * 8;
|
|
109
|
+
const height = Math.ceil(cells / perRow) * 8;
|
|
110
|
+
const pixels = new Uint8Array(width * height);
|
|
111
|
+
for (let cell = 0; cell < cells; cell++) {
|
|
112
|
+
const left = (cell % perRow) * 8;
|
|
113
|
+
const top = Math.floor(cell / perRow) * 8;
|
|
114
|
+
for (let row = 0; row < 8; row++) {
|
|
115
|
+
bytePixels(bytes[cell * 8 + row] ?? 0, mode).forEach((value, x) => (pixels[(top + row) * width + left + x] = value));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { width, height, pixels };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A bitmap of 8x8 cells of 8 bytes, 40 cells to a row, as the VIC reads it. */
|
|
122
|
+
export function bitmapFrame(bytes: Uint8Array, mode: GraphicsMode): IndexedFrame {
|
|
123
|
+
return cellFrame(bytes, mode, 40);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A character set: characters of 8 bytes, 16 to a row, so character N is in row N / 16 and column N % 16. */
|
|
127
|
+
export function charsetFrame(bytes: Uint8Array, mode: GraphicsMode): IndexedFrame {
|
|
128
|
+
return cellFrame(bytes, mode, 16);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Each pixel as a square of `factor` pixels, so a small image is readable on a page. */
|
|
132
|
+
export function scaleFrame(frame: IndexedFrame, factor: number): IndexedFrame {
|
|
133
|
+
const width = frame.width * factor;
|
|
134
|
+
const height = frame.height * factor;
|
|
135
|
+
const pixels = new Uint8Array(width * height);
|
|
136
|
+
for (let y = 0; y < height; y++) {
|
|
137
|
+
for (let x = 0; x < width; x++) pixels[y * width + x] = frame.pixels[Math.floor(y / factor) * frame.width + Math.floor(x / factor)]!;
|
|
138
|
+
}
|
|
139
|
+
return { width, height, pixels };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Screen codes as text rows of 40, as the character ROM shows the upper-case
|
|
144
|
+
* set: @, A-Z, the signs and digits; a reversed code (bit 7) shows as its
|
|
145
|
+
* code without bit 7, and a graphics code as a dot.
|
|
146
|
+
*/
|
|
147
|
+
export function screenText(bytes: Uint8Array): string[] {
|
|
148
|
+
const rows: string[] = [];
|
|
149
|
+
for (let start = 0; start < bytes.length; start += 40) {
|
|
150
|
+
let row = "";
|
|
151
|
+
for (const code of bytes.subarray(start, start + 40)) {
|
|
152
|
+
const plain = code & 0x7f;
|
|
153
|
+
row += plain === 0 ? "@" : plain <= 26 ? String.fromCharCode(0x40 + plain) : plain <= 31 ? "[£]^<"[plain - 27]! : plain <= 63 ? String.fromCharCode(plain) : ".";
|
|
154
|
+
}
|
|
155
|
+
rows.push(row);
|
|
156
|
+
}
|
|
157
|
+
return rows;
|
|
158
|
+
}
|