@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
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 (D80). 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
|
+
}
|
package/src/host/main.ts
CHANGED
|
@@ -5,31 +5,39 @@ import { parseArgs } from "node:util";
|
|
|
5
5
|
import { DEFAULT_HOST_PORT } from "../protocol/messages.ts";
|
|
6
6
|
import { ProcessSupervisor } from "../native/processes.ts";
|
|
7
7
|
import { startTrace, trace } from "../trace.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { ListenError, parsePort, startHostServer, stopSignal } from "./server.ts";
|
|
9
|
+
import { viceFlaws, type ViceVersion } from "../native/discover.ts";
|
|
10
|
+
import { inspect } from "./tools/c1541.ts";
|
|
9
11
|
import { createToolDispatcher } from "./tools/index.ts";
|
|
10
12
|
import { checkViceStarts, findVice } from "./vice/process.ts";
|
|
11
13
|
import { viceSessionFactory } from "./vice/session.ts";
|
|
14
|
+
import { PACKAGE } from "../package.ts";
|
|
12
15
|
|
|
13
16
|
const HELP = `c64-re-tools-host
|
|
14
17
|
|
|
15
18
|
Usage:
|
|
16
|
-
npx -y --package
|
|
19
|
+
npx -y --package=${PACKAGE.name}@${PACKAGE.tag} c64-re-tools-host [--port <port>] [--log-console] [--log-dir <directory>]
|
|
17
20
|
c64-re-tools-host --help
|
|
18
21
|
|
|
19
22
|
Runs the c64-re-tools Host Runtime in the foreground. It first starts VICE
|
|
20
23
|
once (x64sc on PATH, or C64RT_VICE) and does not start if VICE cannot run.
|
|
21
|
-
It listens on
|
|
24
|
+
It listens on port ${DEFAULT_HOST_PORT} on every address, so a dev container
|
|
25
|
+
reaches it as host.docker.internal. It starts one VICE emulator for each
|
|
22
26
|
connected c64-re-tools-mcp process, and runs the tools that come with VICE
|
|
23
27
|
(c1541, petcat) for skill scripts. Stop it with Ctrl+C; that stops every
|
|
24
28
|
emulator and tool it started.
|
|
25
29
|
|
|
26
30
|
Options:
|
|
27
|
-
--port <port>
|
|
28
|
-
--
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
--help
|
|
31
|
+
--port <port> Listen on this port instead (0 picks a free port).
|
|
32
|
+
--log-console Also print each request with its result, and each run
|
|
33
|
+
of c1541 or petcat, to the console.
|
|
34
|
+
--log-dir <directory> Write the same events as JSON lines to a file in this
|
|
35
|
+
directory, with a copy of each VICE log.
|
|
36
|
+
--help Show this help.
|
|
37
|
+
|
|
38
|
+
The host always logs its start and stop, connections, failures, and each call
|
|
39
|
+
from a skill script (c1541, petcat) with its outcome. --log-console adds the
|
|
40
|
+
calls of the MCP server and the tool runs. C64RT_TRACE does not apply to the host.`;
|
|
33
41
|
|
|
34
42
|
function log(line: string): void {
|
|
35
43
|
process.stderr.write(`c64-re-tools-host: ${line}\n`);
|
|
@@ -37,9 +45,12 @@ function log(line: string): void {
|
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
async function main(): Promise<number> {
|
|
40
|
-
let values: { help?: boolean; port?: string;
|
|
48
|
+
let values: { help?: boolean; port?: string; "log-console"?: boolean; "log-dir"?: string };
|
|
41
49
|
try {
|
|
42
|
-
({ values } = parseArgs({
|
|
50
|
+
({ values } = parseArgs({
|
|
51
|
+
options: { help: { type: "boolean" }, port: { type: "string" }, "log-console": { type: "boolean" }, "log-dir": { type: "string" } },
|
|
52
|
+
strict: true,
|
|
53
|
+
}));
|
|
43
54
|
} catch (error) {
|
|
44
55
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${HELP}\n`);
|
|
45
56
|
return 2;
|
|
@@ -48,7 +59,18 @@ async function main(): Promise<number> {
|
|
|
48
59
|
process.stdout.write(`${HELP}\n`);
|
|
49
60
|
return 0;
|
|
50
61
|
}
|
|
51
|
-
|
|
62
|
+
const logDir = values["log-dir"];
|
|
63
|
+
if (logDir === "") {
|
|
64
|
+
process.stderr.write("--log-dir needs a directory.\n");
|
|
65
|
+
return 2;
|
|
66
|
+
}
|
|
67
|
+
startTrace("host", {
|
|
68
|
+
target: {
|
|
69
|
+
...(logDir === undefined ? {} : { dir: { path: logDir, given: `--log-dir ${logDir}` } }),
|
|
70
|
+
...(values["log-console"] ? { echo: (line: string) => process.stderr.write(`c64-re-tools-host: ${line}\n`) } : {}),
|
|
71
|
+
},
|
|
72
|
+
warn: log,
|
|
73
|
+
});
|
|
52
74
|
|
|
53
75
|
const port = values.port === undefined ? DEFAULT_HOST_PORT : parsePort(values.port);
|
|
54
76
|
if (port === undefined) {
|
|
@@ -56,12 +78,8 @@ async function main(): Promise<number> {
|
|
|
56
78
|
return 2;
|
|
57
79
|
}
|
|
58
80
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (extraHosts.some((address) => !isLoopback(address)) && (token === undefined || token.length < 16)) {
|
|
62
|
-
process.stderr.write("--listen with an address that is not loopback needs C64RT_HOST_TOKEN with at least 16 characters, so that only your clients can connect.\n");
|
|
63
|
-
return 2;
|
|
64
|
-
}
|
|
81
|
+
// First, so that every start, also a refused one, tells which runtime runs.
|
|
82
|
+
log(`${PACKAGE.name} ${PACKAGE.version} (Node.js ${process.version}, ${process.platform} ${process.arch})`);
|
|
65
83
|
|
|
66
84
|
// Without an emulator the runtime is useless: refuse to start, by name and with the remedy.
|
|
67
85
|
try {
|
|
@@ -78,39 +96,47 @@ async function main(): Promise<number> {
|
|
|
78
96
|
supervisor.startWatchdog("host");
|
|
79
97
|
|
|
80
98
|
// Finding x64sc is not enough: VICE without its ROM files exits at once.
|
|
99
|
+
let vice: ViceVersion;
|
|
81
100
|
try {
|
|
82
|
-
await checkViceStarts({ supervisor });
|
|
101
|
+
vice = await checkViceStarts({ supervisor });
|
|
83
102
|
} catch (error) {
|
|
84
103
|
process.stderr.write(`${(error as Error).message}\n`);
|
|
85
104
|
await supervisor.stopAll();
|
|
86
105
|
return 1;
|
|
87
106
|
}
|
|
88
|
-
log(
|
|
107
|
+
log(`VICE ${vice.major}.${vice.minor}${vice.revision === undefined ? "" : ` r${vice.revision}`} starts`);
|
|
108
|
+
const flaws = viceFlaws(vice);
|
|
109
|
+
// A known VICE flaw is named, not worked around (D56).
|
|
110
|
+
for (const flaw of flaws) log(`Known VICE flaw: ${flaw}`);
|
|
89
111
|
|
|
90
112
|
let server;
|
|
91
113
|
try {
|
|
92
114
|
server = await startHostServer({
|
|
115
|
+
host: "0.0.0.0",
|
|
93
116
|
port,
|
|
94
|
-
extraHosts,
|
|
95
|
-
...(token === undefined || token === "" ? {} : { token }),
|
|
96
117
|
log,
|
|
97
|
-
createViceSession: viceSessionFactory({
|
|
98
|
-
|
|
118
|
+
createViceSession: viceSessionFactory({
|
|
119
|
+
supervisor,
|
|
120
|
+
log,
|
|
121
|
+
flaws,
|
|
122
|
+
// c1541 lists the disk, so an autostart index selects a program (R2-08).
|
|
123
|
+
directory: async (imageType, image) => {
|
|
124
|
+
const { result } = await inspect({ action: "directory", imageType }, image, { supervisor, signal: new AbortController().signal, log });
|
|
125
|
+
return result.action === "directory" ? result.entries : [];
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
tools: createToolDispatcher({ supervisor, log, vice }),
|
|
99
129
|
});
|
|
100
130
|
} catch (error) {
|
|
101
131
|
if (error instanceof ListenError && error.code === "EADDRINUSE") {
|
|
102
132
|
process.stderr.write(`Port ${error.port} on ${error.address} is in use. Is another c64-re-tools-host running?\n`);
|
|
103
133
|
return 1;
|
|
104
134
|
}
|
|
105
|
-
if (error instanceof ListenError && error.code === "EADDRNOTAVAIL") {
|
|
106
|
-
process.stderr.write(`The address ${error.address} in --listen does not belong to this machine.\n`);
|
|
107
|
-
return 1;
|
|
108
|
-
}
|
|
109
135
|
throw error;
|
|
110
136
|
}
|
|
111
|
-
const
|
|
112
|
-
process.stdout.write(`c64-re-tools-host listening on ${
|
|
113
|
-
trace().event("host.listening", { addresses });
|
|
137
|
+
const address = `${server.host}:${server.port}`;
|
|
138
|
+
process.stdout.write(`c64-re-tools-host listening on ${address}\n`);
|
|
139
|
+
trace().event("host.listening", { addresses: [address] });
|
|
114
140
|
|
|
115
141
|
trace().event("host.stop", { signal: await stopSignal(log) });
|
|
116
142
|
await server.close();
|
package/src/host/server.ts
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
1
|
import { createServer, type Server, type Socket } from "node:net";
|
|
3
2
|
|
|
4
3
|
import { encodeFrame, MessageReader } from "../protocol/framing.ts";
|
|
5
4
|
import { checkHello, isOneOf, HEARTBEAT_TIMEOUT_MS, parseClientMessage, ProtocolError, type HostMessage, type ReceivedRequest, type VideoStandard, type WireError, WireFailure } from "../protocol/messages.ts";
|
|
6
5
|
import { TOOL_OPERATIONS, validateToolParams, type ToolOperation, type ToolOperations } from "../protocol/tools.ts";
|
|
7
6
|
import { attachmentCount, VICE_OPERATIONS, validateViceParams, type ViceOperation, type ViceOperations } from "../protocol/vice.ts";
|
|
8
|
-
import { elapsedMs, trace } from "../trace.ts";
|
|
7
|
+
import { elapsedMs, summarize, trace } from "../trace.ts";
|
|
9
8
|
|
|
10
9
|
/** One live VICE session, owned by one connection. */
|
|
11
10
|
export interface ViceSessionHandle {
|
|
12
11
|
/**
|
|
13
12
|
* Runs one operation. The session serializes its own work. Throws
|
|
14
13
|
* WireFailure for domain failures. `attachments` holds the bytes the
|
|
15
|
-
* operation takes (see attachmentCount)
|
|
14
|
+
* operation takes (see attachmentCount); bytes the operation sends back go
|
|
15
|
+
* into `reply`.
|
|
16
16
|
*/
|
|
17
|
-
handle<O extends ViceOperation>(op: O, params: ViceOperations[O]["params"], attachments?: Buffer[]): Promise<ViceOperations[O]["result"]>;
|
|
17
|
+
handle<O extends ViceOperation>(op: O, params: ViceOperations[O]["params"], attachments?: Buffer[], reply?: Buffer[]): Promise<ViceOperations[O]["result"]>;
|
|
18
18
|
/** Stops the emulator and drops queued work. Safe to call more than once. */
|
|
19
19
|
close(): Promise<void>;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/** Starts a VICE session. Throws WireFailure with an actionable code when it cannot. */
|
|
23
|
-
export type ViceSessionFactory = (options: { videoStandard: VideoStandard }) => Promise<ViceSessionHandle>;
|
|
23
|
+
export type ViceSessionFactory = (options: { videoStandard: VideoStandard; log?: (line: string) => void }) => Promise<ViceSessionHandle>;
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Runs one native-tool request. `signal` aborts when the client goes away; the
|
|
@@ -37,16 +37,8 @@ export interface HostServerOptions {
|
|
|
37
37
|
createViceSession: ViceSessionFactory;
|
|
38
38
|
/** Native-tool requests on "tool" connections; without it they are refused. */
|
|
39
39
|
tools?: ToolDispatcher;
|
|
40
|
-
/** Defaults to 127.0.0.1.
|
|
40
|
+
/** Defaults to 127.0.0.1; 0.0.0.0 listens on every address, so a container reaches it too. */
|
|
41
41
|
host?: string;
|
|
42
|
-
/** Extra addresses to listen on, on the same port as `host`. */
|
|
43
|
-
extraHosts?: string[];
|
|
44
|
-
/**
|
|
45
|
-
* The shared secret. A connection that arrives on an address that is
|
|
46
|
-
* not loopback must send it in its hello; without a token such a
|
|
47
|
-
* connection is refused.
|
|
48
|
-
*/
|
|
49
|
-
token?: string;
|
|
50
42
|
/** 0 picks a free port. */
|
|
51
43
|
port: number;
|
|
52
44
|
/** How long a new connection may stay silent before it must send hello. */
|
|
@@ -67,10 +59,6 @@ const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
|
67
59
|
/** How long a refused client may keep its side of the connection open after the host has ended its side. */
|
|
68
60
|
const REFUSED_CLOSE_MS = 1_000;
|
|
69
61
|
|
|
70
|
-
function digest(text: string): Buffer {
|
|
71
|
-
return createHash("sha256").update(text, "utf8").digest();
|
|
72
|
-
}
|
|
73
|
-
|
|
74
62
|
/** A caught value for the log, with its stack when it has one. */
|
|
75
63
|
function describe(error: unknown): string {
|
|
76
64
|
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
@@ -95,18 +83,6 @@ class Connection {
|
|
|
95
83
|
#isTool = false;
|
|
96
84
|
readonly #reader = new MessageReader();
|
|
97
85
|
|
|
98
|
-
/** A connection on a loopback address needs no token; any other needs the host's token. */
|
|
99
|
-
#checkToken(token: string | undefined): WireError | undefined {
|
|
100
|
-
if (isLoopback(this.#socket.localAddress ?? "")) return undefined;
|
|
101
|
-
const expected = this.#options.token;
|
|
102
|
-
// Equal-length digests: the comparison takes the same time for any token, also one with multibyte characters.
|
|
103
|
-
const matches = expected !== undefined && token !== undefined && timingSafeEqual(digest(token), digest(expected));
|
|
104
|
-
if (matches) return undefined;
|
|
105
|
-
return {
|
|
106
|
-
code: "installation-incomplete",
|
|
107
|
-
message: "The c64-re-tools host runtime refused this client: a connection from outside the host needs C64RT_HOST_TOKEN with the same value on both sides.",
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
86
|
readonly #socket: Socket;
|
|
111
87
|
readonly #options: HostServerOptions;
|
|
112
88
|
readonly #log: (line: string) => void;
|
|
@@ -119,9 +95,12 @@ class Connection {
|
|
|
119
95
|
constructor(socket: Socket, options: HostServerOptions, log: (line: string) => void) {
|
|
120
96
|
this.#socket = socket;
|
|
121
97
|
this.#options = options;
|
|
122
|
-
|
|
98
|
+
// Every line names its connection, so the console shows which client starts and stops what.
|
|
99
|
+
this.#log = (line) => log(`connection ${this.#id}: ${line}`);
|
|
123
100
|
this.closed = new Promise((resolve) => (this.#resolveClosed = resolve));
|
|
124
|
-
|
|
101
|
+
const remote = `${socket.remoteAddress}:${socket.remotePort}`;
|
|
102
|
+
this.#log(`opened from ${remote}`);
|
|
103
|
+
trace().event("connection.open", { connection: this.#id, remote, local: socket.localAddress });
|
|
125
104
|
this.#handshakeTimer = setTimeout(
|
|
126
105
|
() => this.#abort("no hello before the handshake timeout"),
|
|
127
106
|
options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS,
|
|
@@ -191,7 +170,7 @@ class Connection {
|
|
|
191
170
|
if (this.#state === "handshake") {
|
|
192
171
|
if (message.type !== "hello") throw new ProtocolError("first message is not hello");
|
|
193
172
|
clearTimeout(this.#handshakeTimer);
|
|
194
|
-
const mismatch = checkHello(message)
|
|
173
|
+
const mismatch = checkHello(message);
|
|
195
174
|
if (mismatch !== undefined) {
|
|
196
175
|
this.#log(`refusing a client: ${mismatch.message}`);
|
|
197
176
|
trace().event("connection.refused", { connection: this.#id, role: message.role, ...mismatch });
|
|
@@ -204,11 +183,13 @@ class Connection {
|
|
|
204
183
|
this.#isTool = true;
|
|
205
184
|
this.#state = "open";
|
|
206
185
|
this.#send({ type: "ready" });
|
|
186
|
+
this.#log("ready for tool requests");
|
|
207
187
|
trace().event("connection.ready", { connection: this.#id, role: "tool" });
|
|
208
188
|
this.#restartHeartbeat();
|
|
209
189
|
return;
|
|
210
190
|
}
|
|
211
191
|
this.#state = "starting";
|
|
192
|
+
this.#log(`starts a VICE session (${message.videoStandard ?? "pal"})`);
|
|
212
193
|
this.#contain(this.#startSession(message.videoStandard ?? "pal"));
|
|
213
194
|
return;
|
|
214
195
|
}
|
|
@@ -230,6 +211,7 @@ class Connection {
|
|
|
230
211
|
this.#session = session;
|
|
231
212
|
this.#state = "open";
|
|
232
213
|
this.#send({ type: "ready" });
|
|
214
|
+
this.#log(`VICE session ready after ${Math.round(performance.now() - started)} ms`);
|
|
233
215
|
trace().event("connection.ready", { connection: this.#id, role: "vice-session", videoStandard, ms: elapsedMs(started) });
|
|
234
216
|
this.#restartHeartbeat();
|
|
235
217
|
}
|
|
@@ -237,7 +219,7 @@ class Connection {
|
|
|
237
219
|
/** The new session, or undefined after the failure is sent to the client. Never rejects, also when the factory throws at once. */
|
|
238
220
|
async #createSession(videoStandard: VideoStandard): Promise<ViceSessionHandle | undefined> {
|
|
239
221
|
try {
|
|
240
|
-
return await this.#options.createViceSession({ videoStandard });
|
|
222
|
+
return await this.#options.createViceSession({ videoStandard, log: this.#log });
|
|
241
223
|
} catch (error) {
|
|
242
224
|
this.#log(`VICE session failed to start: ${error instanceof Error ? error.message : String(error)}`);
|
|
243
225
|
trace().event("connection.session-failed", { connection: this.#id, error });
|
|
@@ -252,8 +234,15 @@ class Connection {
|
|
|
252
234
|
|
|
253
235
|
async #answer(request: ReceivedRequest, attachments: Buffer[]): Promise<void> {
|
|
254
236
|
const started = performance.now();
|
|
255
|
-
const record = (outcome: Record<string, unknown>) =>
|
|
256
|
-
|
|
237
|
+
const record = (outcome: Record<string, unknown>) => {
|
|
238
|
+
const ms = elapsedMs(started);
|
|
239
|
+
trace().event("request", { connection: this.#id, op: request.op, id: request.id, params: request.params, attachments: attachments.length, ms, ...outcome });
|
|
240
|
+
// A call from a skill script (c1541, petcat, status) is always logged; the many calls of a VICE session only in the trace.
|
|
241
|
+
if (this.#isTool) {
|
|
242
|
+
const what = outcome.ok === true ? "ok" : `${String(outcome.code)}: ${String(outcome.message)}`;
|
|
243
|
+
this.#log(`${request.op} ${JSON.stringify(summarize(request.params) ?? {})}: ${what} (${ms} ms)`);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
257
246
|
try {
|
|
258
247
|
let result: unknown;
|
|
259
248
|
let replyAttachments: Buffer[] = [];
|
|
@@ -270,10 +259,10 @@ class Connection {
|
|
|
270
259
|
const session = this.#session;
|
|
271
260
|
if (session === undefined || !isOneOf(VICE_OPERATIONS, op)) throw new WireFailure("invalid-input", `unknown operation: ${op}`);
|
|
272
261
|
const params = validateViceParams(op, request.params);
|
|
273
|
-
if (attachments.length !== attachmentCount(op)) {
|
|
274
|
-
throw new WireFailure("invalid-input", `${op} takes ${attachmentCount(op)} file attachment(s)`);
|
|
262
|
+
if (attachments.length !== attachmentCount(op, params)) {
|
|
263
|
+
throw new WireFailure("invalid-input", `${op} takes ${attachmentCount(op, params)} file attachment(s)`);
|
|
275
264
|
}
|
|
276
|
-
result = await session.handle(op, params, attachments);
|
|
265
|
+
result = await session.handle(op, params, attachments, replyAttachments);
|
|
277
266
|
}
|
|
278
267
|
this.#send({ type: "reply", id: request.id, result }, replyAttachments);
|
|
279
268
|
record({ ok: true, result });
|
|
@@ -299,21 +288,17 @@ class Connection {
|
|
|
299
288
|
this.#toolAbort.abort();
|
|
300
289
|
const session = this.#session ?? (await this.#starting);
|
|
301
290
|
this.#session = undefined;
|
|
291
|
+
if (session !== undefined) this.#log("the client is gone; stopping its VICE session");
|
|
302
292
|
try {
|
|
303
293
|
await session?.close();
|
|
304
294
|
} catch (error) {
|
|
305
295
|
this.#log(`session close failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
306
296
|
}
|
|
297
|
+
this.#log("closed");
|
|
307
298
|
this.#resolveClosed();
|
|
308
299
|
}
|
|
309
300
|
}
|
|
310
301
|
|
|
311
|
-
/** True for 127.0.0.0/8 and ::1, also in their IPv4-mapped IPv6 form. */
|
|
312
|
-
export function isLoopback(address: string): boolean {
|
|
313
|
-
const plain = address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
314
|
-
return plain === "::1" || /^127\.\d+\.\d+\.\d+$/.test(plain);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
302
|
/** A port as the user gives it: decimal digits only, 0 to 65535. Anything else gives undefined. */
|
|
318
303
|
export function parsePort(text: string): number | undefined {
|
|
319
304
|
if (!/^\d{1,5}$/.test(text)) return undefined;
|
|
@@ -386,8 +371,6 @@ export async function startHostServer(options: HostServerOptions): Promise<HostS
|
|
|
386
371
|
let port: number;
|
|
387
372
|
try {
|
|
388
373
|
port = await listen(host, options.port);
|
|
389
|
-
// The extra addresses share the port, so one endpoint setting reaches every address.
|
|
390
|
-
for (const extra of options.extraHosts ?? []) await listen(extra, port);
|
|
391
374
|
} catch (error) {
|
|
392
375
|
await Promise.all(servers.map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
|
393
376
|
throw error;
|
package/src/host/tools/c1541.ts
CHANGED
|
@@ -37,11 +37,23 @@ const LAYOUTS: Record<string, Layout> = {
|
|
|
37
37
|
"1581": { header: { track: 40, sector: 0 }, name: 0x04, id: 0x16, dos: 0x19 },
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
/** Used sectors of the format outside the header and directory chain: the 1571 BAM track 53, the two 1581 BAM blocks. */
|
|
41
|
+
const RESERVED: Record<string, DiskSector[]> = {
|
|
42
|
+
"1541": [],
|
|
43
|
+
"1571": Array.from({ length: 19 }, (_, sector) => ({ track: 53, sector })),
|
|
44
|
+
"1581": [
|
|
45
|
+
{ track: 40, sector: 1 },
|
|
46
|
+
{ track: 40, sector: 2 },
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
|
|
40
50
|
export interface DiskEntry extends DiskFile {
|
|
41
51
|
/** The name's PETSCII bytes, up to the first shifted space ($a0). */
|
|
42
52
|
nameBytes: Uint8Array;
|
|
43
53
|
startTrack: number;
|
|
44
54
|
startSector: number;
|
|
55
|
+
/** REL only: the first side sector. */
|
|
56
|
+
sideStart?: DiskSector;
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
const damaged = (message: string) => new WireFailure("media-error", `The disk image is damaged: ${message}`);
|
|
@@ -115,7 +127,7 @@ export function parseDirectory(blocks: Buffer[], layout: Layout): { diskName: st
|
|
|
115
127
|
// Type 0 is an empty or scratched slot; the DOS lists no file there.
|
|
116
128
|
if (typeByte === 0) continue;
|
|
117
129
|
const nameBytes = nameField(block, offset + 5, 16);
|
|
118
|
-
|
|
130
|
+
const entry: DiskEntry = {
|
|
119
131
|
name: petsciiNameToText(nameBytes),
|
|
120
132
|
nameBytes,
|
|
121
133
|
type: fileType(typeByte),
|
|
@@ -124,7 +136,10 @@ export function parseDirectory(blocks: Buffer[], layout: Layout): { diskName: st
|
|
|
124
136
|
locked: (typeByte & 0x40) !== 0,
|
|
125
137
|
startTrack: block.readUInt8(offset + 3),
|
|
126
138
|
startSector: block.readUInt8(offset + 4),
|
|
127
|
-
}
|
|
139
|
+
};
|
|
140
|
+
const side = { track: block.readUInt8(offset + 0x15), sector: block.readUInt8(offset + 0x16) };
|
|
141
|
+
if (entry.type === "rel" && side.track !== 0) entry.sideStart = side;
|
|
142
|
+
entries.push(entry);
|
|
128
143
|
}
|
|
129
144
|
}
|
|
130
145
|
return {
|
|
@@ -151,6 +166,13 @@ export function fileData(blocks: Buffer[], chain: DiskSector[]): Buffer {
|
|
|
151
166
|
return Buffer.concat(parts);
|
|
152
167
|
}
|
|
153
168
|
|
|
169
|
+
/** The used sectors of the BAM that are not in `referenced`, in track and sector order. */
|
|
170
|
+
export function unreferencedSectors(tracks: ReadonlyArray<{ track: number; usedSectors: number[] }>, referenced: Iterable<DiskSector>): DiskSector[] {
|
|
171
|
+
const known = new Set<string>();
|
|
172
|
+
for (const at of referenced) known.add(`${at.track}/${at.sector}`);
|
|
173
|
+
return tracks.flatMap(({ track, usedSectors }) => usedSectors.filter((sector) => !known.has(`${track}/${sector}`)).map((sector) => ({ track, sector })));
|
|
174
|
+
}
|
|
175
|
+
|
|
154
176
|
const toFile = (entry: DiskEntry): DiskFile => ({ name: entry.name, type: entry.type, blocks: entry.blocks, closed: entry.closed, locked: entry.locked });
|
|
155
177
|
|
|
156
178
|
const sameBytes = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((byte, index) => byte === b[index]);
|
|
@@ -226,11 +248,18 @@ export async function inspect(params: C1541Params, image: Buffer, context: ToolC
|
|
|
226
248
|
const layout = LAYOUTS[info.format];
|
|
227
249
|
if (layout === undefined) throw new WireFailure("media-error", `The disk image has the drive format ${info.format}; only 1541, 1571 and 1581 disks are supported.`);
|
|
228
250
|
|
|
229
|
-
if (params.action === "bam") {
|
|
230
|
-
return { result: { action: "bam", tracks: parseBam(await c1541.run(["-bam"]), info.tracks) } };
|
|
231
|
-
}
|
|
232
251
|
const headerChain = await c1541.chain(layout.header);
|
|
233
252
|
const directory = parseDirectory(await c1541.blocks(headerChain), layout);
|
|
253
|
+
if (params.action === "bam") {
|
|
254
|
+
const tracks = parseBam(await c1541.run(["-bam"]), info.tracks);
|
|
255
|
+
// Every sector that the header, the directory or a file holds; a file without blocks has no chain.
|
|
256
|
+
const referenced = [...headerChain, ...(RESERVED[info.format] ?? [])];
|
|
257
|
+
for (const entry of directory.entries) {
|
|
258
|
+
if (entry.startTrack !== 0) referenced.push(...(await c1541.chain({ track: entry.startTrack, sector: entry.startSector })));
|
|
259
|
+
if (entry.sideStart !== undefined) referenced.push(...(await c1541.chain(entry.sideStart)));
|
|
260
|
+
}
|
|
261
|
+
return { result: { action: "bam", tracks, unreferenced: unreferencedSectors(tracks, referenced) } };
|
|
262
|
+
}
|
|
234
263
|
if (params.action === "directory") {
|
|
235
264
|
const { diskName, diskId, dosType, entries } = directory;
|
|
236
265
|
return { result: { action: "directory", diskName, diskId, dosType, freeBlocks: parseFreeBlocks(overview), entries: entries.map(toFile) } };
|
package/src/host/tools/index.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// ACME, DXA and Ghidra themselves. One entry per operation, never a
|
|
4
4
|
// generic executable route.
|
|
5
5
|
|
|
6
|
+
import type { ViceVersion } from "../../native/discover.ts";
|
|
6
7
|
import type { ProcessSupervisor } from "../../native/processes.ts";
|
|
7
8
|
import type { ToolContext } from "../../native/run.ts";
|
|
8
9
|
import type { ToolOperation, ToolOperations } from "../../protocol/tools.ts";
|
|
@@ -24,8 +25,11 @@ const ADAPTERS: { [O in ToolOperation]: Adapter<O> } = {
|
|
|
24
25
|
"petcat.decode": (_params, attachments, context) => decode(attachments[0]!, context),
|
|
25
26
|
};
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
/** `vice`: the version that the host read from the emulator at start, for host.status. */
|
|
29
|
+
export function createToolDispatcher(options: { supervisor: ProcessSupervisor; env?: NodeJS.ProcessEnv; log?: (line: string) => void; vice?: ViceVersion }): ToolDispatcher {
|
|
28
30
|
return (op, params, attachments, signal) => {
|
|
29
|
-
|
|
31
|
+
const context = { supervisor: options.supervisor, signal, env: options.env, log: options.log };
|
|
32
|
+
if (op === "host.status") return hostStatus(context, options.vice) as ReturnType<ToolDispatcher>;
|
|
33
|
+
return ADAPTERS[op](params, attachments, context);
|
|
30
34
|
};
|
|
31
35
|
}
|
package/src/host/tools/petcat.ts
CHANGED
|
@@ -16,14 +16,17 @@ import { runToolOrFail, type ToolContext } from "../../native/run.ts";
|
|
|
16
16
|
|
|
17
17
|
const TIMEOUT_MS = 30_000;
|
|
18
18
|
const LISTING = "listing.txt";
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* petcat's listing lines (" 10 sys2061"), as number and text. petcat
|
|
21
|
+
* prints π ($ff) as `~`, which is no PETSCII character: the text has π (F-13).
|
|
22
|
+
*/
|
|
20
23
|
export function parseListing(listing: string): Array<{ number: number; text: string }> {
|
|
21
24
|
const lines: Array<{ number: number; text: string }> = [];
|
|
22
25
|
for (const line of listing.split(/\r?\n/)) {
|
|
23
26
|
if (line.trim() === "") continue;
|
|
24
27
|
const match = /^\s*(\d+) ?(.*)$/.exec(line);
|
|
25
28
|
if (match === null) throw new WireFailure("operation-failed", "petcat printed a listing line without a line number.");
|
|
26
|
-
lines.push({ number: Number(match[1]), text: match[2]
|
|
29
|
+
lines.push({ number: Number(match[1]), text: match[2]!.replaceAll("~", "π") });
|
|
27
30
|
}
|
|
28
31
|
return lines;
|
|
29
32
|
}
|
|
@@ -36,9 +39,13 @@ export async function decode(
|
|
|
36
39
|
if ("reason" in tokenized) return { result: { decoded: false, reason: tokenized.reason } };
|
|
37
40
|
const executable = findTool(PETCAT, context.env);
|
|
38
41
|
await requireMinimumVersion(PETCAT, executable, context);
|
|
42
|
+
// petcat gets the BASIC text only, closed with a real end marker: after a
|
|
43
|
+
// link that only ends BASIC (machine code right after the last line), petcat
|
|
44
|
+
// prints a comment line where a listing line belongs.
|
|
45
|
+
const text = Buffer.concat([program.subarray(0, tokenized.textBytes), Buffer.of(0, 0)]);
|
|
39
46
|
const workspace = Workspace.create(context.supervisor);
|
|
40
47
|
try {
|
|
41
|
-
workspace.materialize("input", [{ path: "program.prg", size:
|
|
48
|
+
workspace.materialize("input", [{ path: "program.prg", size: text.length }], [text]);
|
|
42
49
|
const listingPath = join(workspace.directory("out"), LISTING);
|
|
43
50
|
const run = await runToolOrFail(
|
|
44
51
|
"petcat",
|