@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.
Files changed (126) hide show
  1. package/README.md +28 -62
  2. package/distribution/package.ts +1 -16
  3. package/distribution/plugin.ts +5 -3
  4. package/package.json +1 -1
  5. package/skills/c64-assembler/SKILL.md +25 -6
  6. package/skills/c64-assembler/scripts/assemble.ts +45 -10
  7. package/skills/c64-assembler/scripts/lib/compare.ts +48 -0
  8. package/skills/c64-basic/SKILL.md +15 -7
  9. package/skills/c64-basic/scripts/basic.ts +4 -4
  10. package/skills/c64-disk/SKILL.md +34 -11
  11. package/skills/c64-disk/scripts/disk.ts +65 -10
  12. package/skills/c64-disk/scripts/lib/containers.ts +124 -0
  13. package/skills/c64-docs/SKILL.md +138 -0
  14. package/skills/c64-docs/references/page-types.md +141 -0
  15. package/skills/c64-docs/references/style.md +43 -0
  16. package/skills/c64-docs/scripts/generate.ts +102 -0
  17. package/skills/c64-docs/scripts/lib/links.ts +155 -0
  18. package/skills/c64-docs/scripts/lib/markdown.ts +79 -0
  19. package/skills/c64-docs/scripts/lib/reference.ts +406 -0
  20. package/skills/c64-docs/scripts/lib/source-map.ts +81 -0
  21. package/skills/c64-docs/scripts/link.ts +207 -0
  22. package/skills/c64-emulator/SKILL.md +29 -8
  23. package/skills/c64-knowledge/SKILL.md +201 -15
  24. package/skills/c64-knowledge/scripts/knowledge.ts +927 -78
  25. package/skills/c64-knowledge/scripts/lib/listing.ts +162 -0
  26. package/skills/c64-memory-map/SKILL.md +14 -2
  27. package/skills/c64-memory-map/scripts/memmap.ts +15 -4
  28. package/skills/c64-provenance/SKILL.md +100 -11
  29. package/skills/c64-provenance/scripts/lib/compare.ts +152 -0
  30. package/skills/c64-provenance/scripts/lib/library.ts +584 -0
  31. package/skills/c64-provenance/scripts/lib/runtime.ts +191 -0
  32. package/skills/c64-provenance/scripts/lib/separate.ts +140 -0
  33. package/skills/c64-provenance/scripts/provenance.ts +477 -24
  34. package/skills/c64-reverse-engineering/SKILL.md +90 -11
  35. package/skills/c64-source/SKILL.md +174 -0
  36. package/skills/c64-source/scripts/generate.ts +788 -0
  37. package/skills/c64-source/scripts/lib/loader.ts +630 -0
  38. package/skills/c64-source/scripts/lib/source.ts +1121 -0
  39. package/skills/c64-static-analysis/SKILL.md +53 -13
  40. package/skills/c64-static-analysis/scripts/analyze.ts +260 -31
  41. package/skills/c64-static-analysis/scripts/lib/coverage.ts +72 -0
  42. package/skills/c64-static-analysis/scripts/{findings.ts → lib/findings.ts} +11 -5
  43. package/skills/c64-static-analysis/scripts/lib/idioms.ts +158 -0
  44. package/skills/c64-static-analysis/scripts/lib/instructions.ts +105 -0
  45. package/skills/c64-static-analysis/scripts/lib/macros.ts +233 -0
  46. package/skills/c64-static-analysis/scripts/lib/pitfalls.ts +68 -0
  47. package/skills/c64-static-analysis/scripts/lib/pointers.ts +108 -0
  48. package/skills/c64-testing/SKILL.md +57 -4
  49. package/skills/c64-testing/references/scenario-format.md +50 -6
  50. package/skills/c64-testing/scripts/checklist.ts +3 -3
  51. package/skills/c64-testing/scripts/diff.ts +167 -0
  52. package/skills/c64-testing/scripts/lib/diff.ts +70 -0
  53. package/skills/c64-testing/scripts/{scenario.ts → lib/scenario.ts} +183 -152
  54. package/skills/c64-testing/scripts/run.ts +241 -0
  55. package/skills/c64-testing/scripts/test.ts +20 -10
  56. package/skills/c64-unpacker/SKILL.md +88 -21
  57. package/skills/c64-unpacker/scripts/lib/evidence.ts +207 -0
  58. package/skills/c64-unpacker/scripts/lib/loads.ts +209 -0
  59. package/skills/c64-unpacker/scripts/lib/machine.ts +80 -0
  60. package/skills/c64-unpacker/scripts/lib/stages.ts +142 -0
  61. package/skills/c64-unpacker/scripts/unpack.ts +545 -61
  62. package/src/c64.ts +240 -12
  63. package/src/cli/cleanup.ts +1 -1
  64. package/src/cli/main.ts +14 -8
  65. package/src/cli/status.ts +105 -11
  66. package/src/graphics.ts +158 -0
  67. package/src/host/main.ts +57 -31
  68. package/src/host/server.ts +30 -47
  69. package/src/host/tools/c1541.ts +34 -5
  70. package/src/host/tools/index.ts +6 -2
  71. package/src/host/tools/petcat.ts +10 -3
  72. package/src/host/tools/status.ts +23 -6
  73. package/src/host/vice/adapter.ts +64 -20
  74. package/src/host/vice/chips.ts +11 -1
  75. package/src/host/vice/process.ts +19 -8
  76. package/src/host/vice/screen.ts +4 -44
  77. package/src/host/vice/session.ts +343 -60
  78. package/src/host-client/autostart.ts +55 -0
  79. package/src/host-client/connect.ts +14 -14
  80. package/src/host-client/coverage.ts +36 -0
  81. package/src/host-client/image.ts +63 -0
  82. package/src/host-client/steps.ts +175 -0
  83. package/src/host-client/tools.ts +7 -6
  84. package/src/host-client/transfer.ts +60 -10
  85. package/src/host-client/vice-session.ts +107 -28
  86. package/src/knowledge/conflicts.ts +161 -0
  87. package/src/knowledge/database.ts +150 -38
  88. package/src/knowledge/expression.ts +209 -0
  89. package/src/knowledge/graph.ts +104 -0
  90. package/src/knowledge/history.ts +105 -27
  91. package/src/knowledge/import.ts +141 -85
  92. package/src/knowledge/join.ts +211 -0
  93. package/src/knowledge/program.ts +258 -0
  94. package/src/knowledge/read.ts +445 -63
  95. package/src/knowledge/schema.ts +277 -65
  96. package/src/knowledge/text.ts +415 -0
  97. package/src/knowledge/write.ts +1145 -150
  98. package/src/mcp/main.ts +44 -19
  99. package/src/mcp/output.ts +39 -0
  100. package/src/mcp/server.ts +15 -4
  101. package/src/mcp/tools/debug.ts +202 -31
  102. package/src/mcp/tools/execution.ts +39 -5
  103. package/src/mcp/tools/input.ts +7 -4
  104. package/src/mcp/tools/machine.ts +14 -2
  105. package/src/mcp/tools/media.ts +44 -16
  106. package/src/mcp/tools/memory.ts +68 -12
  107. package/src/mcp/tools/video.ts +20 -9
  108. package/src/native/discover.ts +44 -6
  109. package/src/native/ghidra/analyze.ts +2 -2
  110. package/src/native/ghidra/index.ts +19 -3
  111. package/src/native/ghidra/language/c64rt_6510.slaspec +4 -1
  112. package/src/native/ghidra/scripts/C64Export.java +33 -1
  113. package/src/native/ghidra/scripts/C64Prepare.java +7 -1
  114. package/src/native/status.ts +15 -3
  115. package/src/native/types.ts +43 -8
  116. package/src/opcodes.ts +133 -0
  117. package/src/package.ts +21 -0
  118. package/{skills/c64-memory-map/scripts → src}/platform.ts +87 -7
  119. package/src/project.ts +166 -3
  120. package/src/protocol/messages.ts +17 -10
  121. package/src/protocol/tools.ts +27 -22
  122. package/src/protocol/vice.ts +255 -33
  123. package/src/script.ts +49 -9
  124. package/src/trace.ts +55 -23
  125. package/skills/c64-provenance/scripts/compare.ts +0 -92
  126. package/skills/c64-unpacker/scripts/evidence.ts +0 -135
@@ -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 { isLoopback, ListenError, parsePort, startHostServer, stopSignal } from "./server.ts";
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=@henols/c64-re-tools@latest c64-re-tools-host [--port <port>] [--listen <address> ...]
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 127.0.0.1:${DEFAULT_HOST_PORT}, starts one VICE emulator for each
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> Listen on this port instead (0 picks a free port).
28
- --listen <address> Also listen on this address, on the same port, for
29
- example the container bridge (172.17.0.1). An address
30
- that is not loopback needs C64RT_HOST_TOKEN: set it to
31
- the same secret for this program and for the clients.
32
- --help Show this 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; listen?: string[] };
48
+ let values: { help?: boolean; port?: string; "log-console"?: boolean; "log-dir"?: string };
41
49
  try {
42
- ({ values } = parseArgs({ options: { help: { type: "boolean" }, port: { type: "string" }, listen: { type: "string", multiple: true } }, strict: true }));
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
- startTrace("host", { warn: log });
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
- const extraHosts = values.listen ?? [];
60
- const token = process.env.C64RT_HOST_TOKEN;
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("VICE starts");
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({ supervisor, log }),
98
- tools: createToolDispatcher({ supervisor, log }),
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 addresses = [server.host, ...extraHosts].map((address) => `${address}:${server.port}`);
112
- process.stdout.write(`c64-re-tools-host listening on ${addresses.join(", ")}\n`);
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();
@@ -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. More addresses listen on the same port, for example a container bridge. */
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
- this.#log = log;
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
- trace().event("connection.open", { connection: this.#id, remote: `${socket.remoteAddress}:${socket.remotePort}`, local: socket.localAddress });
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) ?? this.#checkToken(message.token);
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
- trace().event("request", { connection: this.#id, op: request.op, id: request.id, params: request.params, attachments: attachments.length, ms: elapsedMs(started), ...outcome });
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;
@@ -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
- entries.push({
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) } };
@@ -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
- export function createToolDispatcher(options: { supervisor: ProcessSupervisor; env?: NodeJS.ProcessEnv; log?: (line: string) => void }): ToolDispatcher {
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
- return ADAPTERS[op](params, attachments, { supervisor: options.supervisor, signal, env: options.env, log: options.log });
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
  }
@@ -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
- /** petcat's listing lines (" 10 sys2061"), as number and text. */
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: program.length }], [program]);
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",