c64bridge 1.0.4 → 1.0.6

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 (63) hide show
  1. package/README.md +2 -2
  2. package/data/embeddings_basic.json +1 -1
  3. package/data/embeddings_hardware.json +1 -1
  4. package/data/embeddings_mixed.json +1 -1
  5. package/data/embeddings_other.json +1 -1
  6. package/data/graphics/character-set.csv +64 -64
  7. package/data/sound/sidwave.md +113 -36
  8. package/data/sound/sidwave.schema.json +138 -28
  9. package/dist/.tsbuildinfo +1 -1
  10. package/dist/c64Client.js +144 -150
  11. package/dist/config.js +93 -39
  12. package/dist/device.js +252 -130
  13. package/dist/diagnostics.js +10 -2
  14. package/dist/knowledge.js +2 -1
  15. package/dist/mcp-server.js +12 -4
  16. package/dist/petscii.js +18 -15
  17. package/dist/petsciiArt.js +40 -14
  18. package/dist/rag/indexer.js +2 -1
  19. package/dist/sidwave.js +453 -61
  20. package/dist/sidwaveCompiler.js +1424 -275
  21. package/dist/tools/audio.js +97 -18
  22. package/dist/tools/debug.js +9 -9
  23. package/dist/tools/developer.js +7 -7
  24. package/dist/tools/errors.js +19 -1
  25. package/dist/tools/graphics.js +4 -4
  26. package/dist/tools/hex.js +9 -0
  27. package/dist/tools/input.js +21 -18
  28. package/dist/tools/machineControl.js +6 -6
  29. package/dist/tools/memory.js +11 -10
  30. package/dist/tools/meta/audio.js +8 -2
  31. package/dist/tools/meta/background.js +6 -1
  32. package/dist/tools/meta/filesystem.js +37 -10
  33. package/dist/tools/meta/graphics.js +11 -16
  34. package/dist/tools/meta/memory.js +16 -22
  35. package/dist/tools/meta/program.js +28 -9
  36. package/dist/tools/pollValidator.js +30 -2
  37. package/dist/tools/printer.js +4 -4
  38. package/dist/tools/programRunners.js +10 -9
  39. package/dist/tools/registry/disk.js +7 -5
  40. package/dist/tools/storage.js +21 -17
  41. package/dist/tools/streaming.js +2 -2
  42. package/dist/tools/translation/assembler.js +192 -57
  43. package/dist/tools/translation/basicTokenizer.js +44 -10
  44. package/dist/tools/translation/opcodeInventory.js +1 -1
  45. package/dist/tools/types.js +11 -4
  46. package/dist/tools/vice.js +1 -1
  47. package/dist/vicBitmap.js +16 -0
  48. package/dist/vice/mockServer.js +99 -8
  49. package/dist/vice/readiness.js +4 -9
  50. package/dist/vice/viceClient.js +34 -6
  51. package/mcp/schemas/c64_disk.schema.json +2 -2
  52. package/mcp/schemas/c64_input.schema.json +3 -3
  53. package/mcp/schemas/c64_program.schema.json +2 -2
  54. package/mcp/schemas/c64_system.schema.json +6 -4
  55. package/mcp/schemas/c64_vice.schema.json +3 -3
  56. package/mcp/server.json +1 -1
  57. package/mcp/tools.json +20 -14
  58. package/mcp.json +1 -1
  59. package/package.json +1 -1
  60. package/scripts/mockC64Server.mjs +113 -20
  61. package/scripts/run-coverage.mjs +31 -1
  62. package/scripts/update-readme.ts +3 -2
  63. package/server.json +2 -2
@@ -8,6 +8,7 @@ import { ToolError, ToolExecutionError, ToolValidationError, toolErrorResult, un
8
8
  import { disassemble, formatDisassembly } from "./translation/disassembler.js";
9
9
  import { getViceSymbols } from "./translation/symbolRegistry.js";
10
10
  import { resolveAddressSymbol } from "../knowledge.js";
11
+ import { stripHexByteSeparators } from "./hex.js";
11
12
  function toRecord(details) {
12
13
  if (details && typeof details === "object") {
13
14
  return details;
@@ -24,9 +25,7 @@ function normaliseFailure(details) {
24
25
  return { value: details };
25
26
  }
26
27
  function cleanHex(input) {
27
- const trimmed = input.trim();
28
- const withoutPrefix = trimmed.startsWith("$") ? trimmed.slice(1) : trimmed;
29
- return withoutPrefix.replace(/[\s_]/g, "").toUpperCase();
28
+ return stripHexByteSeparators(input).toUpperCase();
30
29
  }
31
30
  function parseHexInternal(value, path) {
32
31
  const cleaned = cleanHex(value);
@@ -187,7 +186,7 @@ async function executeReadMemory(rawArgs, ctx) {
187
186
  ctx.logger.info("Reading C64 memory", { address: parsed.address, length: parsed.length });
188
187
  const result = await ctx.client.readMemory(parsed.address, String(parsed.length));
189
188
  if (!result.success) {
190
- throw new ToolExecutionError("C64 firmware reported failure while reading memory", {
189
+ throw new ToolExecutionError("C64 backend reported failure while reading memory", {
191
190
  details: normaliseFailure(result.details),
192
191
  });
193
192
  }
@@ -201,7 +200,9 @@ async function executeReadMemory(rawArgs, ctx) {
201
200
  hexData: result.data ?? null,
202
201
  details: detailRecord,
203
202
  };
204
- const base = textResult(`Read ${resolvedLength} bytes starting at ${resolvedAddress}.`, data);
203
+ // Many clients show only the text content, so it carries the bytes too.
204
+ const hexSummary = typeof result.data === "string" && result.data.length > 0 ? `\n${result.data}` : "";
205
+ const base = textResult(`Read ${resolvedLength} bytes starting at ${resolvedAddress}.${hexSummary}`, data);
205
206
  return {
206
207
  ...base,
207
208
  structuredContent: {
@@ -228,7 +229,7 @@ async function executeWriteMemory(rawArgs, ctx) {
228
229
  if (!shouldVerify) {
229
230
  const result = await ctx.client.writeMemory(parsed.address, writeInfo.canonical);
230
231
  if (!result.success) {
231
- throw new ToolExecutionError("C64 firmware reported failure while writing memory", {
232
+ throw new ToolExecutionError("C64 backend reported failure while writing memory", {
232
233
  details: normaliseFailure(result.details),
233
234
  });
234
235
  }
@@ -250,7 +251,7 @@ async function executeWriteMemory(rawArgs, ctx) {
250
251
  if (canPause) {
251
252
  const pauseResult = await ctx.client.pause(pinnedFacade);
252
253
  if (!pauseResult.success) {
253
- throw new ToolExecutionError("C64 firmware reported failure while pausing", {
254
+ throw new ToolExecutionError("C64 backend reported failure while pausing", {
254
255
  details: normaliseFailure(pauseResult.details),
255
256
  });
256
257
  }
@@ -261,7 +262,7 @@ async function executeWriteMemory(rawArgs, ctx) {
261
262
  const readLength = Math.max(1, Math.max(writeInfo.bytes.length, expectedBytes.length));
262
263
  const preRead = await ctx.client.readMemory(parsed.address, String(readLength), pinnedFacade);
263
264
  if (!preRead.success) {
264
- throw new ToolExecutionError("C64 firmware reported failure while reading memory", {
265
+ throw new ToolExecutionError("C64 backend reported failure while reading memory", {
265
266
  details: normaliseFailure(preRead.details),
266
267
  });
267
268
  }
@@ -289,13 +290,13 @@ async function executeWriteMemory(rawArgs, ctx) {
289
290
  }
290
291
  const writeResult = await ctx.client.writeMemory(parsed.address, writeInfo.canonical, pinnedFacade);
291
292
  if (!writeResult.success) {
292
- throw new ToolExecutionError("C64 firmware reported failure while writing memory", {
293
+ throw new ToolExecutionError("C64 backend reported failure while writing memory", {
293
294
  details: normaliseFailure(writeResult.details),
294
295
  });
295
296
  }
296
297
  const postRead = await ctx.client.readMemory(parsed.address, String(Math.max(1, writeInfo.bytes.length)), pinnedFacade);
297
298
  if (!postRead.success) {
298
- throw new ToolExecutionError("C64 firmware reported failure while reading back memory", {
299
+ throw new ToolExecutionError("C64 backend reported failure while reading back memory", {
299
300
  details: normaliseFailure(postRead.details),
300
301
  });
301
302
  }
@@ -267,7 +267,7 @@ export const tools = [
267
267
  const results = [];
268
268
  let restoreError;
269
269
  try {
270
- for (const backend of requestedBackends) {
270
+ for (const [backendIndex, backend] of requestedBackends.entries()) {
271
271
  context.client.switchBackend(backend);
272
272
  context.setPlatform(backend);
273
273
  await trySilenceSid(context);
@@ -303,7 +303,11 @@ export const tools = [
303
303
  analysis,
304
304
  };
305
305
  }
306
- await trySilenceSid(context);
306
+ // Stop this backend's playback only before moving on to the next one;
307
+ // the last backend keeps playing so the preset can be heard in full.
308
+ if (backendIndex < requestedBackends.length - 1) {
309
+ await trySilenceSid(context);
310
+ }
307
311
  backendResult.verification = verification;
308
312
  backendResult.success = true;
309
313
  results.push(backendResult);
@@ -493,6 +497,8 @@ export const tools = [
493
497
  entryAddress: compiled.entryAddress,
494
498
  prgBytes: compiled.prg.length,
495
499
  title: document.song?.title ?? null,
500
+ basicFreeBytes: compiled.basicFreeBytes,
501
+ warnings: compiled.warnings,
496
502
  },
497
503
  playback: {
498
504
  method: playbackMethod,
@@ -231,7 +231,12 @@ const noArgsSchema = objectSchema({ description: "No arguments", properties: {},
231
231
  const startBackgroundTaskArgsSchema = objectSchema({
232
232
  description: "Start a named background task that invokes a simple operation at fixed intervals.",
233
233
  properties: {
234
- name: stringSchema({ description: "Unique task name", minLength: 1 }),
234
+ // The name becomes part of the task folder path, so it must be a single safe path segment.
235
+ name: stringSchema({
236
+ description: "Unique task name (letters, digits, spaces, '_', '-', '.'; must not start with '.' or a space).",
237
+ minLength: 1,
238
+ pattern: /^[A-Za-z0-9_][A-Za-z0-9_. -]*$/,
239
+ }),
235
240
  operation: stringSchema({ description: "Operation name (e.g., read, read_screen)", minLength: 1 }),
236
241
  arguments: optionalSchema(objectSchema({ description: "Operation-specific arguments", properties: {}, additionalProperties: true }), {}),
237
242
  intervalMs: optionalSchema(numberSchema({ description: "Interval milliseconds", integer: true, minimum: 1, default: 1000 }), 1000),
@@ -45,6 +45,28 @@ const filesystemStatsArgsSchema = objectSchema({
45
45
  required: [],
46
46
  additionalProperties: false,
47
47
  });
48
+ /**
49
+ * Find a drive in drivesList() output. Ultimate firmware lists drives as a/b
50
+ * with a configurable bus_id, so "drive8" or "8" is matched by bus number.
51
+ */
52
+ function findListedDrive(drives, drive) {
53
+ const wanted = drive.trim().toLowerCase();
54
+ const exact = drives.find((d) => String(d?.id ?? "").toLowerCase() === wanted);
55
+ if (exact)
56
+ return exact;
57
+ // VICE lists drives as driveN; the Ultimate letters a and b default to buses 8 and 9 there.
58
+ const letterBus = wanted === "a" ? 8 : wanted === "b" ? 9 : undefined;
59
+ const bus = /^(?:drive)?\s*(\d+)$/.exec(wanted);
60
+ if (!bus && letterBus === undefined)
61
+ return undefined;
62
+ const busId = letterBus ?? Number(bus[1]);
63
+ if (letterBus !== undefined) {
64
+ return drives.find((d) => String(d?.id ?? "").toLowerCase() === `drive${busId}`);
65
+ }
66
+ const onBus = drives.filter((d) => Number(d?.raw?.bus_id) === busId || String(d?.id ?? "").toLowerCase() === `drive${busId}`);
67
+ // Prefer the enabled drive; a disabled drive can keep the same bus_id setting.
68
+ return onBus.find((d) => d?.raw?.enabled !== false) ?? onBus[0];
69
+ }
48
70
  function extractPaths(value) {
49
71
  const acc = [];
50
72
  const stack = [value];
@@ -651,7 +673,7 @@ export const tools = [
651
673
  if (powerOnIfNeeded) {
652
674
  try {
653
675
  const drives = await ctx.client.drivesList();
654
- const targetDrive = Array.isArray(drives) ? drives.find((d) => d.id === drive) : null;
676
+ const targetDrive = Array.isArray(drives) ? findListedDrive(drives, drive) : null;
655
677
  if (!targetDrive) {
656
678
  log.push({ step: "check_drive_exists", success: false, error: `Drive ${drive} not found` });
657
679
  throw new ToolExecutionError(`Drive ${drive} not found in firmware drive list`, { details: { drive } });
@@ -676,18 +698,23 @@ export const tools = [
676
698
  throw new ToolExecutionError("Failed to check or power on drive", { details: { error: error instanceof Error ? error.message : String(error) } });
677
699
  }
678
700
  }
679
- // Step 2: Mount with retries
701
+ // Step 2: Switch the drive emulation mode. The mount's own "mode"
702
+ // option is the read/write attachment mode, not the drive type.
703
+ if (mode) {
704
+ const setMode = await ctx.client.driveSetMode(drive, mode);
705
+ log.push({ step: "set_mode", success: setMode.success === true, details: setMode.details });
706
+ if (!setMode.success) {
707
+ throw new ToolExecutionError(`Failed to set drive ${drive} to ${mode} mode`, { details: setMode.details });
708
+ }
709
+ }
710
+ // Step 3: Mount with retries
680
711
  let mountSuccess = false;
681
712
  let lastMountError = null;
682
713
  let attempts = 0;
683
714
  for (let attempt = 0; attempt <= maxRetries && !mountSuccess; attempt += 1) {
684
715
  attempts = attempt + 1;
685
716
  try {
686
- const mountOptions = {};
687
- if (mode) {
688
- mountOptions.mode = mode;
689
- }
690
- const mount = await ctx.client.driveMount(drive, imagePath, mountOptions);
717
+ const mount = await ctx.client.driveMount(drive, imagePath);
691
718
  if (mount.success) {
692
719
  mountSuccess = true;
693
720
  log.push({ step: `mount_attempt_${attempts}`, success: true, details: mount.details });
@@ -717,7 +744,7 @@ export const tools = [
717
744
  details: { lastError: lastMountError }
718
745
  });
719
746
  }
720
- // Step 3: Reset drive if requested
747
+ // Step 4: Reset drive if requested
721
748
  if (resetAfterMount) {
722
749
  try {
723
750
  const reset = await ctx.client.driveReset(drive);
@@ -735,12 +762,12 @@ export const tools = [
735
762
  ctx.logger.warn?.("Drive reset threw error", { error });
736
763
  }
737
764
  }
738
- // Step 4: Verify mount
765
+ // Step 5: Verify mount
739
766
  let verificationResult = null;
740
767
  if (verifyMount) {
741
768
  try {
742
769
  const drives = await ctx.client.drivesList();
743
- const targetDrive = Array.isArray(drives) ? drives.find((d) => d.id === drive) : null;
770
+ const targetDrive = Array.isArray(drives) ? findListedDrive(drives, drive) : null;
744
771
  if (!targetDrive) {
745
772
  log.push({ step: "verify", success: false, error: "Drive not found in list" });
746
773
  throw new ToolExecutionError("Drive disappeared after mount", { details: { drive } });
@@ -13,21 +13,16 @@ function parseAddressNumeric(value) {
13
13
  throw new ToolValidationError("Address must be a non-empty string", { path: "$.address" });
14
14
  }
15
15
  const trimmed = value.trim();
16
- let literal = trimmed;
17
- let radix = 10;
18
- if (trimmed.startsWith("$")) {
19
- radix = 16;
20
- literal = trimmed.slice(1);
21
- }
22
- else if (trimmed.startsWith("0x")) {
23
- radix = 16;
24
- literal = trimmed.slice(2);
25
- }
26
- else if (trimmed.startsWith("%")) {
27
- radix = 2;
28
- literal = trimmed.slice(1);
29
- }
30
- const parsed = Number.parseInt(literal, radix);
16
+ // Anchored patterns: Number.parseInt alone would read "$08G0" as $0008.
17
+ let parsed = Number.NaN;
18
+ if (/^\$[0-9a-f]+$/i.test(trimmed))
19
+ parsed = Number.parseInt(trimmed.slice(1), 16);
20
+ else if (/^0x[0-9a-f]+$/i.test(trimmed))
21
+ parsed = Number.parseInt(trimmed.slice(2), 16);
22
+ else if (/^%[01]+$/.test(trimmed))
23
+ parsed = Number.parseInt(trimmed.slice(1), 2);
24
+ else if (/^[0-9]+$/.test(trimmed))
25
+ parsed = Number.parseInt(trimmed, 10);
31
26
  if (!Number.isFinite(parsed) || Number.isNaN(parsed)) {
32
27
  throw new ToolValidationError("Invalid address value", { path: "$.address", details: { value } });
33
28
  }
@@ -228,7 +223,7 @@ export const tools = [
228
223
  try {
229
224
  const read = await ctx.client.readMemory(`$${formatAddressHex(startAddress)}`, String(length));
230
225
  if (!read.success || typeof read.data !== "string") {
231
- throw new ToolExecutionError("C64 firmware reported failure while reading memory", { details: normalizeErrorDetails(read.details) });
226
+ throw new ToolExecutionError("C64 backend reported failure while reading memory", { details: normalizeErrorDetails(read.details) });
232
227
  }
233
228
  const bytes = hexToBytes(read.data);
234
229
  if (bytes.length < length) {
@@ -5,14 +5,13 @@ import { promises as fs } from "node:fs";
5
5
  import { dirname, resolve as resolvePath } from "node:path";
6
6
  import { createHash } from "node:crypto";
7
7
  import { normalizeErrorDetails } from "./util.js";
8
+ import { stripHexByteSeparators } from "../hex.js";
8
9
  function supportsMachinePause(ctx) {
9
10
  const platformId = ctx.platform?.id ?? ctx.client?.type;
10
11
  return platformId !== "vice";
11
12
  }
12
13
  function hexClean(input) {
13
- const trimmed = input.trim();
14
- const withoutPrefix = trimmed.startsWith("$") ? trimmed.slice(1) : trimmed;
15
- return withoutPrefix.replace(/[\s_]/g, "").toLowerCase();
14
+ return stripHexByteSeparators(input).toLowerCase();
16
15
  }
17
16
  function hexToBytes(input) {
18
17
  const cleaned = hexClean(input);
@@ -31,21 +30,16 @@ function parseAddressNumeric(value) {
31
30
  throw new ToolValidationError("Address must be a non-empty string", { path: "$.address" });
32
31
  }
33
32
  const lower = value.trim().toLowerCase();
34
- let radix = 10;
35
- let literal = lower;
36
- if (lower.startsWith("$")) {
37
- radix = 16;
38
- literal = lower.slice(1);
39
- }
40
- else if (lower.startsWith("0x")) {
41
- radix = 16;
42
- literal = lower.slice(2);
43
- }
44
- else if (lower.startsWith("%")) {
45
- radix = 2;
46
- literal = lower.slice(1);
47
- }
48
- const parsed = Number.parseInt(literal, radix);
33
+ // Anchored patterns: Number.parseInt alone would read "$20ZZ" as $0020.
34
+ let parsed = Number.NaN;
35
+ if (/^\$[0-9a-f]+$/.test(lower))
36
+ parsed = Number.parseInt(lower.slice(1), 16);
37
+ else if (/^0x[0-9a-f]+$/.test(lower))
38
+ parsed = Number.parseInt(lower.slice(2), 16);
39
+ else if (/^%[01]+$/.test(lower))
40
+ parsed = Number.parseInt(lower.slice(1), 2);
41
+ else if (/^[0-9]+$/.test(lower))
42
+ parsed = Number.parseInt(lower, 10);
49
43
  if (!Number.isFinite(parsed) || Number.isNaN(parsed) || parsed < 0 || parsed > 0xFFFF) {
50
44
  throw new ToolValidationError("Invalid address value", { path: "$.address", details: { value } });
51
45
  }
@@ -103,7 +97,7 @@ export const tools = [
103
97
  if (canPause) {
104
98
  const pauseResult = await ctx.client.pause();
105
99
  if (!pauseResult.success) {
106
- throw new ToolExecutionError("C64 firmware reported failure while pausing", { details: normalizeErrorDetails(pauseResult.details) });
100
+ throw new ToolExecutionError("C64 backend reported failure while pausing", { details: normalizeErrorDetails(pauseResult.details) });
107
101
  }
108
102
  paused = true;
109
103
  }
@@ -111,7 +105,7 @@ export const tools = [
111
105
  try {
112
106
  const pre = await ctx.client.readMemory(parsed.address, String(Math.max(1, readLen)));
113
107
  if (!pre.success) {
114
- throw new ToolExecutionError("C64 firmware reported failure while reading memory", { details: normalizeErrorDetails(pre.details) });
108
+ throw new ToolExecutionError("C64 backend reported failure while reading memory", { details: normalizeErrorDetails(pre.details) });
115
109
  }
116
110
  preReadHex = pre.data ?? "$";
117
111
  const preBytes = hexToBytes(preReadHex);
@@ -131,11 +125,11 @@ export const tools = [
131
125
  }
132
126
  const write = await ctx.client.writeMemory(parsed.address, bytesToHex(writeBytes));
133
127
  if (!write.success) {
134
- throw new ToolExecutionError("C64 firmware reported failure while writing memory", { details: normalizeErrorDetails(write.details) });
128
+ throw new ToolExecutionError("C64 backend reported failure while writing memory", { details: normalizeErrorDetails(write.details) });
135
129
  }
136
130
  const post = await ctx.client.readMemory(parsed.address, String(Math.max(1, writeBytes.length)));
137
131
  if (!post.success) {
138
- throw new ToolExecutionError("C64 firmware reported failure while reading back memory", { details: normalizeErrorDetails(post.details) });
132
+ throw new ToolExecutionError("C64 backend reported failure while reading back memory", { details: normalizeErrorDetails(post.details) });
139
133
  }
140
134
  const postBytes = hexToBytes(post.data ?? "$");
141
135
  const diffs = [];
@@ -1,6 +1,7 @@
1
1
  import { objectSchema, stringSchema, arraySchema, numberSchema, optionalSchema, booleanSchema } from "../schema.js";
2
2
  import { jsonResult } from "../responses.js";
3
3
  import { ToolError, ToolUnsupportedPlatformError, ToolExecutionError, toolErrorResult, unknownErrorResult } from "../errors.js";
4
+ import { measureSidSilence } from "../audio.js";
4
5
  import { getPlatformStatus } from "../../platform.js";
5
6
  import { promises as fs } from "node:fs";
6
7
  import { resolve as resolvePath, join as joinPath } from "node:path";
@@ -8,6 +9,7 @@ import { sleep, formatTimestampSpec } from "./util.js";
8
9
  import { getTasksHomeDir } from "./background.js";
9
10
  import { Jimp } from "jimp";
10
11
  import { withDiagnosticSpan } from "../../diagnostics.js";
12
+ import { stripHexByteSeparators } from "../hex.js";
11
13
  const C64_SCREENSHOT_PALETTE = [
12
14
  0x000000ff,
13
15
  0xffffffff,
@@ -62,6 +64,9 @@ function canonicalGreetingBackends() {
62
64
  function uniqueGreetingBackends(backends) {
63
65
  return backends.filter((backend, index) => backends.indexOf(backend) === index);
64
66
  }
67
+ function normaliseAssertionHex(value) {
68
+ return stripHexByteSeparators(value).toUpperCase();
69
+ }
65
70
  function normaliseScreenText(screen) {
66
71
  return screen.replace(/\r\n?/g, "\n").replace(/\s+/g, " ").trim().toUpperCase();
67
72
  }
@@ -202,7 +207,7 @@ const batchRunWithAssertionsArgsSchema = objectSchema({
202
207
  assertions: optionalSchema(arraySchema(objectSchema({
203
208
  description: "Assertion to check after run",
204
209
  properties: {
205
- type: stringSchema({ description: "Assertion type", enum: ["screen_contains", "memory_equals", "sid_silent"] }),
210
+ type: stringSchema({ description: "Assertion type; sid_silent needs the C64 Ultimate audio stream (c64u backend).", enum: ["screen_contains", "memory_equals", "sid_silent"] }),
206
211
  pattern: optionalSchema(stringSchema({ description: "Pattern for screen_contains", minLength: 1 })),
207
212
  address: optionalSchema(stringSchema({ description: "Address for memory_equals", minLength: 1 })),
208
213
  expected: optionalSchema(stringSchema({ description: "Expected value (hex)", minLength: 1 })),
@@ -696,9 +701,17 @@ export const tools = [
696
701
  else if (assertion.type === "memory_equals") {
697
702
  const addr = assertion.address ?? "$0400";
698
703
  const expected = assertion.expected ?? "$00";
699
- const result = await ctx.client.readMemory(addr, "1");
700
- const actual = result.data ?? "$00";
701
- const matched = actual.toLowerCase() === expected.toLowerCase();
704
+ // Compare every expected byte, and treat a failed read as an error rather than as $00.
705
+ const expectedHex = normaliseAssertionHex(expected);
706
+ if (expectedHex.length === 0 || expectedHex.length % 2 !== 0 || !/^[0-9A-F]+$/.test(expectedHex)) {
707
+ throw new Error(`memory_equals expected value ${expected} is not a whole number of hex bytes`);
708
+ }
709
+ const result = await ctx.client.readMemory(addr, String(expectedHex.length / 2));
710
+ if (!result.success || typeof result.data !== "string") {
711
+ throw new Error(`Failed to read memory at ${addr} for memory_equals assertion`);
712
+ }
713
+ const actual = result.data;
714
+ const matched = normaliseAssertionHex(actual) === expectedHex;
702
715
  assertionResults.push({
703
716
  type: assertion.type,
704
717
  status: matched ? "pass" : "fail",
@@ -708,14 +721,20 @@ export const tools = [
708
721
  status = "fail";
709
722
  }
710
723
  else if (assertion.type === "sid_silent") {
711
- // Check SID gate bits are off (simple check)
712
- const result = await ctx.client.readMemory("$D404", "1");
713
- const gate1 = parseInt((result.data ?? "$00").slice(1), 16) & 0x01;
714
- const isSilent = gate1 === 0;
724
+ // SID control registers are write-only (an Ultimate 64 reads $D404 as $00
725
+ // whatever was written), so silence is measured from the Ultimate audio
726
+ // stream. Other backends would fall back to the host microphone, which
727
+ // measures the room rather than the C64, so they report an error instead.
728
+ const backend = ctx.platform?.id ?? await ctx.client.getActiveBackendType?.();
729
+ if (backend !== undefined && backend !== "c64u") {
730
+ throw new Error(`sid_silent needs the C64 Ultimate audio stream and is not available on ${backend}`);
731
+ }
732
+ const silence = await measureSidSilence(ctx);
733
+ const isSilent = silence.silent;
715
734
  assertionResults.push({
716
735
  type: assertion.type,
717
736
  status: isSilent ? "pass" : "fail",
718
- message: isSilent ? undefined : "SID voice 1 gate is on",
737
+ message: isSilent ? undefined : `SID output is not silent (peak RMS ${silence.maxRms.toFixed(3)} > ${silence.threshold})`,
719
738
  });
720
739
  if (!isSilent)
721
740
  status = "fail";
@@ -75,7 +75,7 @@ function concatBuffers(...buffers) {
75
75
  * Looks for patterns like "?SYNTAX ERROR" or "SYNTAX ERROR IN 120".
76
76
  */
77
77
  function extractBasicError(screen) {
78
- const errorMatch = /(?:^|\n)\s*\?([A-Z\s]+?)\s+ERROR(?:\s+IN\s+(\d+))?(?=\s|$)/im.exec(screen); // A real error has a leading "?"; ordinary output like "0 ERRORS FOUND" must never match.
78
+ const errorMatch = /(?:^|\n)\s*\?([A-Z'\s]+?)\s+ERROR(?:\s+IN\s+(\d+))?(?=\s|$)/im.exec(screen); // A real error has a leading "?"; ordinary output like "0 ERRORS FOUND" must never match. The apostrophe covers UNDEF'D STATEMENT, REDIM'D ARRAY and CAN'T CONTINUE.
79
79
  if (errorMatch) {
80
80
  const errorType = errorMatch[1]?.trim().replace(/\s+/g, " ");
81
81
  const lineStr = errorMatch[2];
@@ -87,6 +87,19 @@ function extractBasicError(screen) {
87
87
  }
88
88
  return null;
89
89
  }
90
+ /**
91
+ * True when the most recent typed SYS command is followed by a READY. prompt,
92
+ * which is how a machine-code routine that ends with RTS hands back to BASIC.
93
+ */
94
+ function returnedToBasicAfterSys(screen) {
95
+ const lines = screen.split("\n").map((line) => line.trim().toUpperCase());
96
+ let sysLine = -1;
97
+ lines.forEach((line, index) => {
98
+ if (/^SYS\s*\d+$/.test(line))
99
+ sysLine = index;
100
+ });
101
+ return sysLine >= 0 && lines.slice(sysLine + 1).some((line) => line === "READY.");
102
+ }
90
103
  /**
91
104
  * Poll for BASIC program outcome by detecting errors on screen.
92
105
  * First waits for RUN or ERROR to appear, then continues polling for errors.
@@ -157,7 +170,12 @@ async function pollAsmOutcome(client, logger, config) {
157
170
  try {
158
171
  const screen = await client.readScreen();
159
172
  const upperScreen = screen.toUpperCase();
160
- if (upperScreen.includes("RUN") || upperScreen.includes("ERROR")) {
173
+ if (returnedToBasicAfterSys(screen)) {
174
+ // The typed SYS line is followed by READY., so the routine ran and returned with RTS.
175
+ logger.debug("ASM program returned to BASIC", { elapsed: Date.now() - startTime });
176
+ return { status: "ok", type: "ASM" };
177
+ }
178
+ if (upperScreen.includes("RUN") || upperScreen.includes("SYS") || upperScreen.includes("ERROR")) {
161
179
  runDetected = true;
162
180
  logger.debug("RUN detected, starting stabilization window", {
163
181
  elapsed: Date.now() - startTime,
@@ -182,6 +200,16 @@ async function pollAsmOutcome(client, logger, config) {
182
200
  const pollDeadline = Date.now() + config.maxMs;
183
201
  while (Date.now() < pollDeadline && !alive) {
184
202
  pollCount++;
203
+ try {
204
+ // A routine that works for a while before RTS shows READY. only after the first screen read.
205
+ if (returnedToBasicAfterSys(await client.readScreen())) {
206
+ logger.debug("ASM program returned to BASIC", { pollCount, elapsed: Date.now() - startTime });
207
+ return { status: "ok", type: "ASM" };
208
+ }
209
+ }
210
+ catch (error) {
211
+ logger.debug("Failed to read screen during ASM polling", { error, pollCount });
212
+ }
185
213
  try {
186
214
  const screenRam = await client.readMemoryRaw(0x0400, 0x03e8);
187
215
  const signature = computeCrc32(screenRam);
@@ -278,7 +278,7 @@ export const printerModule = defineToolModule({
278
278
  formFeed: parsed.formFeed,
279
279
  });
280
280
  if (!result.success) {
281
- throw new ToolExecutionError("C64 firmware reported failure while printing text", {
281
+ throw new ToolExecutionError("C64 backend reported failure while printing text", {
282
282
  details: normaliseFailure(result.details),
283
283
  });
284
284
  }
@@ -337,7 +337,7 @@ export const printerModule = defineToolModule({
337
337
  ensureMsb: parsed.ensureMsb,
338
338
  });
339
339
  if (!result.success) {
340
- throw new ToolExecutionError("C64 firmware reported failure while printing Commodore bitmap", {
340
+ throw new ToolExecutionError("C64 backend reported failure while printing Commodore bitmap", {
341
341
  details: normaliseFailure(result.details),
342
342
  });
343
343
  }
@@ -398,7 +398,7 @@ export const printerModule = defineToolModule({
398
398
  timesPerLine: parsed.timesPerLine,
399
399
  });
400
400
  if (!result.success) {
401
- throw new ToolExecutionError("C64 firmware reported failure while printing Epson bitmap", {
401
+ throw new ToolExecutionError("C64 backend reported failure while printing Epson bitmap", {
402
402
  details: normaliseFailure(result.details),
403
403
  });
404
404
  }
@@ -458,7 +458,7 @@ export const printerModule = defineToolModule({
458
458
  secondaryAddress: parsed.secondaryAddress,
459
459
  });
460
460
  if (!result.success) {
461
- throw new ToolExecutionError("C64 firmware reported failure while defining DLL characters", {
461
+ throw new ToolExecutionError("C64 backend reported failure while defining DLL characters", {
462
462
  details: normaliseFailure(result.details),
463
463
  });
464
464
  }
@@ -52,7 +52,8 @@ function parseBasicRuntimeErrors(screen) {
52
52
  continue;
53
53
  }
54
54
  seen.add(lineNumber);
55
- const typeMatch = /\?([A-Z ?]+?)\s+ERROR\s+IN/i.exec(row.toUpperCase());
55
+ // The apostrophe covers UNDEF'D STATEMENT, REDIM'D ARRAY and UNDEF'D FUNCTION.
56
+ const typeMatch = /\?([A-Z' ?]+?)\s+ERROR\s+IN/i.exec(row.toUpperCase());
56
57
  const type = typeMatch?.[1]?.trim().replace(/\s+/g, " ") || undefined;
57
58
  errors.push({
58
59
  line: lineNumber,
@@ -266,7 +267,7 @@ async function executeUploadRunBasic(rawArgs, ctx) {
266
267
  const runBasic = async (source) => ctx.client.uploadAndRunBasic(source);
267
268
  let result = await runBasic(activeProgram);
268
269
  if (!result.success) {
269
- throw new ToolExecutionError("C64 firmware reported failure while running BASIC program", {
270
+ throw new ToolExecutionError("C64 backend reported failure while running BASIC program", {
270
271
  details: extractFailureDetails(result.details),
271
272
  });
272
273
  }
@@ -441,13 +442,13 @@ async function executeUploadRunAsm(rawArgs, ctx) {
441
442
  // Assemble locally to expose structured metadata and capture symbols
442
443
  const asmResult = assemblyToPrgDetailed(parsed.program);
443
444
  const prg = asmResult.prg;
444
- const entryAddress = prg.readUInt16LE(0);
445
+ const entryAddress = asmResult.entryAddress;
445
446
  if (ctx.platform.id === "vice") {
446
447
  setViceSymbols(asmResult.symbols.entries());
447
448
  }
448
449
  const result = await ctx.client.uploadAndRunAsm(parsed.program);
449
450
  if (!result.success) {
450
- return toolErrorResult(new ToolExecutionError("C64 firmware reported failure while running assembly program", {
451
+ return toolErrorResult(new ToolExecutionError("C64 backend reported failure while running assembly program", {
451
452
  details: extractFailureDetails(result.details),
452
453
  }));
453
454
  }
@@ -515,7 +516,7 @@ async function executeLoadPrg(rawArgs, ctx) {
515
516
  ctx.logger.info("Loading PRG file", { path: parsed.path });
516
517
  const result = await ctx.client.loadPrgFile(parsed.path);
517
518
  if (!result.success) {
518
- throw new ToolExecutionError("C64 firmware reported failure while loading PRG", {
519
+ throw new ToolExecutionError("C64 backend reported failure while loading PRG", {
519
520
  details: extractFailureDetails(result.details),
520
521
  });
521
522
  }
@@ -565,7 +566,7 @@ async function executeRunPrg(rawArgs, ctx) {
565
566
  }
566
567
  const result = await ctx.client.runPrgFile(parsed.path);
567
568
  if (!result.success) {
568
- throw new ToolExecutionError("C64 firmware reported failure while running PRG", {
569
+ throw new ToolExecutionError("C64 backend reported failure while running PRG", {
569
570
  details: extractFailureDetails(result.details),
570
571
  });
571
572
  }
@@ -600,7 +601,7 @@ async function executeRunCrt(rawArgs, ctx) {
600
601
  ctx.logger.info("Running CRT file", { path: parsed.path });
601
602
  const result = await ctx.client.runCrtFile(parsed.path);
602
603
  if (!result.success) {
603
- throw new ToolExecutionError("C64 firmware reported failure while running CRT", {
604
+ throw new ToolExecutionError("C64 backend reported failure while running CRT", {
604
605
  details: extractFailureDetails(result.details),
605
606
  });
606
607
  }
@@ -686,7 +687,7 @@ export const programRunnersModule = defineToolModule({
686
687
  {
687
688
  name: "Set screen char",
688
689
  description: "Write 1 to $0400 then RTS",
689
- arguments: { program: ".org $0801\nstart: lda #$01\n sta $0400\n rts" },
690
+ arguments: { program: "start: lda #$01\n sta $0400\n rts" },
690
691
  },
691
692
  ],
692
693
  workflowHints: [
@@ -772,7 +773,7 @@ export const programRunnersModule = defineToolModule({
772
773
  ctx.logger.info("Running CRT file", { path: parsed.path });
773
774
  const result = await ctx.client.runCrtFile(parsed.path);
774
775
  if (!result.success) {
775
- throw new ToolExecutionError("C64 firmware reported failure while running CRT", {
776
+ throw new ToolExecutionError("C64 backend reported failure while running CRT", {
776
777
  details: extractFailureDetails(result.details),
777
778
  });
778
779
  }
@@ -76,7 +76,7 @@ const createImageArgsSchema = objectSchema({
76
76
  maxLength: 16,
77
77
  })),
78
78
  tracks: optionalSchema(numberSchema({
79
- description: "Track count (D64 supports 35 or 40; DNP requires explicit tracks).",
79
+ description: "Track count (D64 supports 35 to 41; DNP requires explicit tracks).",
80
80
  integer: true,
81
81
  minimum: 1,
82
82
  maximum: 255,
@@ -210,10 +210,10 @@ const diskOperations = [
210
210
  const { path, diskname, tracks } = parsed;
211
211
  switch (format) {
212
212
  case "d64":
213
- if (tracks !== undefined && tracks !== 35 && tracks !== 40) {
214
- throw new ToolValidationError("D64 images support 35 or 40 tracks", {
213
+ if (tracks !== undefined && (tracks < 35 || tracks > 41)) {
214
+ throw new ToolValidationError("D64 images support 35 to 41 tracks", {
215
215
  path: "$.tracks",
216
- details: { allowed: [35, 40], received: tracks },
216
+ details: { minimum: 35, maximum: 41, received: tracks },
217
217
  });
218
218
  }
219
219
  return storageModule.invoke("create_d64", { path, tracks, diskname }, ctx);
@@ -274,8 +274,10 @@ export const diskModuleGroup = defineToolModule({
274
274
  variants: diskOperations.map((operation) => operation.schema),
275
275
  }),
276
276
  tags: ["storage", "drive", "grouped"],
277
- operationPlatforms: { file_info: ["c64u", "u2"], create_image: ["c64u", "u2"], find_and_run: ["c64u", "u2"] },
277
+ // The VICE Binary Monitor has no command to detach a disk image, so unmount is Ultimate-only.
278
+ operationPlatforms: { unmount: ["c64u", "u2"], file_info: ["c64u", "u2"], create_image: ["c64u", "u2"], find_and_run: ["c64u", "u2"] },
278
279
  operationToolNames: {
280
+ unmount: "drive_remove",
279
281
  file_info: "file_info",
280
282
  create_image: "create_image",
281
283
  find_and_run: "find_and_run_program_by_name",