c64bridge 1.0.4 → 1.0.5

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 (41) hide show
  1. package/data/embeddings_basic.json +1 -1
  2. package/data/embeddings_hardware.json +1 -1
  3. package/data/embeddings_mixed.json +1 -1
  4. package/data/embeddings_other.json +1 -1
  5. package/data/graphics/character-set.csv +64 -64
  6. package/dist/.tsbuildinfo +1 -1
  7. package/dist/c64Client.js +65 -61
  8. package/dist/config.js +75 -38
  9. package/dist/device.js +122 -106
  10. package/dist/knowledge.js +2 -1
  11. package/dist/petscii.js +18 -15
  12. package/dist/petsciiArt.js +40 -14
  13. package/dist/rag/indexer.js +2 -1
  14. package/dist/sidwave.js +1 -1
  15. package/dist/sidwaveCompiler.js +3 -9
  16. package/dist/tools/audio.js +27 -6
  17. package/dist/tools/debug.js +9 -9
  18. package/dist/tools/errors.js +12 -1
  19. package/dist/tools/hex.js +9 -0
  20. package/dist/tools/input.js +13 -7
  21. package/dist/tools/memory.js +5 -4
  22. package/dist/tools/meta/background.js +6 -1
  23. package/dist/tools/meta/filesystem.js +37 -10
  24. package/dist/tools/meta/graphics.js +10 -15
  25. package/dist/tools/meta/memory.js +12 -18
  26. package/dist/tools/meta/program.js +19 -4
  27. package/dist/tools/pollValidator.js +30 -2
  28. package/dist/tools/programRunners.js +5 -4
  29. package/dist/tools/translation/assembler.js +150 -47
  30. package/dist/tools/translation/basicTokenizer.js +39 -9
  31. package/dist/tools/types.js +11 -4
  32. package/dist/vicBitmap.js +16 -0
  33. package/dist/vice/mockServer.js +7 -3
  34. package/dist/vice/viceClient.js +16 -6
  35. package/mcp/schemas/c64_system.schema.json +6 -4
  36. package/mcp/server.json +1 -1
  37. package/mcp/tools.json +6 -4
  38. package/mcp.json +1 -1
  39. package/package.json +1 -1
  40. package/scripts/mockC64Server.mjs +98 -19
  41. package/server.json +2 -2
@@ -11,6 +11,7 @@ const DEFAULT_SILENCE_VERIFY_DURATION_SECONDS = 1.5;
11
11
  const DEFAULT_SILENCE_VERIFY_RMS_THRESHOLD = 0.02;
12
12
  const DEFAULT_SILENCE_VERIFY_WAIT_MS = 150;
13
13
  const generatedPlaybacks = new Set();
14
+ let lastGeneratedPlayback = Promise.resolve();
14
15
  export function cancelGeneratedSidPlayback() {
15
16
  for (const controller of generatedPlaybacks)
16
17
  controller.abort();
@@ -317,8 +318,20 @@ function transposeNote(note, semitones) {
317
318
  const newOctave = Math.floor(midi / 12) - 1;
318
319
  return `${names[wrapped]}${newOctave}`;
319
320
  }
320
- function sleep(ms) {
321
- return new Promise((resolve) => setTimeout(resolve, ms));
321
+ function sleep(ms, signal) {
322
+ return new Promise((resolve) => {
323
+ if (signal?.aborted) {
324
+ resolve();
325
+ return;
326
+ }
327
+ const timer = setTimeout(done, ms);
328
+ function done() {
329
+ clearTimeout(timer);
330
+ signal?.removeEventListener("abort", done);
331
+ resolve();
332
+ }
333
+ signal?.addEventListener("abort", done, { once: true });
334
+ });
322
335
  }
323
336
  function shouldAutoAnalyze(request) {
324
337
  const lowered = request.toLowerCase();
@@ -825,8 +838,12 @@ export const audioModule = defineToolModule({
825
838
  const controller = new AbortController();
826
839
  generatedPlaybacks.add(controller);
827
840
  const pinnedFacadePromise = ctx.client.pinFacade ? ctx.client.pinFacade() : Promise.resolve(undefined); // Pin once so a later select_backend can't retarget notes mid-arpeggio (HARD01-030).
828
- void (async () => {
841
+ // A replaced sequence sends its final gate-off on voice 1; waiting for it
842
+ // keeps that gate-off from cutting off this sequence's first note.
843
+ const previousPlayback = lastGeneratedPlayback;
844
+ lastGeneratedPlayback = (async () => {
829
845
  try {
846
+ await previousPlayback;
830
847
  const pinnedFacade = await pinnedFacadePromise;
831
848
  await ctx.client.sidSetVolume(8, pinnedFacade);
832
849
  for (let i = 0; i < parsed.steps; i += 1) {
@@ -845,10 +862,14 @@ export const audioModule = defineToolModule({
845
862
  release: 0,
846
863
  }, pinnedFacade);
847
864
  const durationMs = parsed.preset === "expression" ? expressiveDurations[i % expressiveDurations.length] : parsed.tempoMs;
848
- await sleep(Math.max(1, Math.min(20, Math.floor(durationMs / 8)))); // Clear GATE between notes so every note retriggers ADSR.
865
+ // Hold GATE for most of the note, then clear it for a short gap so the next note retriggers ADSR.
866
+ const gateGapMs = Math.max(1, Math.min(20, Math.floor(durationMs / 8)));
867
+ // Cancellation ends the sleep at once, so the final gate-off below follows promptly.
868
+ await sleep(Math.max(0, durationMs - gateGapMs), controller.signal);
869
+ if (controller.signal.aborted)
870
+ break;
849
871
  await ctx.client.sidNoteOff(1, pinnedFacade);
850
- if (!controller.signal.aborted)
851
- await sleep(Math.max(0, durationMs - Math.max(1, Math.min(20, Math.floor(durationMs / 8)))));
872
+ await sleep(gateGapMs, controller.signal);
852
873
  }
853
874
  await ctx.client.sidNoteOff(1, pinnedFacade);
854
875
  }
@@ -572,17 +572,17 @@ function formatAddress(value) {
572
572
  }
573
573
  function parseAddress(value, label) {
574
574
  const trimmed = value.trim();
575
- let radix = 10;
576
- let literal = trimmed;
577
- if (trimmed.startsWith("$")) {
578
- radix = 16;
579
- literal = trimmed.slice(1);
575
+ // Anchored patterns: Number.parseInt alone would read "$08G0" as $0008.
576
+ let parsed = Number.NaN;
577
+ if (/^\$[0-9a-f]+$/i.test(trimmed)) {
578
+ parsed = Number.parseInt(trimmed.slice(1), 16);
580
579
  }
581
- else if (trimmed.toLowerCase().startsWith("0x")) {
582
- radix = 16;
583
- literal = trimmed.slice(2);
580
+ else if (/^0x[0-9a-f]+$/i.test(trimmed)) {
581
+ parsed = Number.parseInt(trimmed.slice(2), 16);
582
+ }
583
+ else if (/^[0-9]+$/.test(trimmed)) {
584
+ parsed = Number.parseInt(trimmed, 10);
584
585
  }
585
- const parsed = Number.parseInt(literal, radix);
586
586
  if (!Number.isInteger(parsed) || parsed < 0 || parsed > 0xffff) {
587
587
  throw new ToolValidationError(`Invalid ${label}`, { path: `$.${label}` });
588
588
  }
@@ -44,10 +44,21 @@ export function toolErrorResult(error) {
44
44
  ...(error.code !== undefined ? { code: error.code } : {}),
45
45
  ...(error.details !== undefined ? { details: error.details } : {}),
46
46
  };
47
- const message = error.path ? `${error.message} (at ${error.path})` : error.message;
47
+ const located = error.path ? `${error.message} (at ${error.path})` : error.message;
48
+ // Clients often show only the text content, so surface the underlying cause
49
+ // (for example a range check or firmware error string) next to the summary.
50
+ const detailMessage = describeDetailMessage(error.details);
51
+ const message = detailMessage && !located.includes(detailMessage) ? `${located}: ${detailMessage}` : located;
48
52
  const base = textResult(message, { error: metadata });
49
53
  return { ...base, isError: true };
50
54
  }
55
+ function describeDetailMessage(details) {
56
+ if (!details || typeof details !== "object") {
57
+ return undefined;
58
+ }
59
+ const message = details.message;
60
+ return typeof message === "string" && message.trim().length > 0 ? message.trim() : undefined;
61
+ }
51
62
  export function unknownErrorResult(error) {
52
63
  if (error instanceof ToolError) {
53
64
  return toolErrorResult(error);
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Normalise a user-supplied hex byte string such as "$A9 $00", "A9_00" or
3
+ * "$A900" to bare hex digits. A "$" may prefix the whole string or each
4
+ * whitespace-separated byte group; whitespace and "_" separators are removed.
5
+ * The result is not validated, so callers still check length and digits.
6
+ */
7
+ export function stripHexByteSeparators(input) {
8
+ return input.trim().replace(/(^|\s)\$/g, "$1").replace(/[\s_]/g, "");
9
+ }
@@ -68,25 +68,31 @@ const PETSCII_TOKENS = {
68
68
  CURSOR_LEFT: 157,
69
69
  CURSOR_RIGHT: 29,
70
70
  };
71
+ // ASCII a-z are shifted graphics characters in PETSCII, so typed "print" would
72
+ // reach BASIC as graphics and give ?SYNTAX ERROR. Literal letters are therefore
73
+ // sent as the unshifted PETSCII letters $41-$5A; {tokens} keep their codes.
71
74
  function expandPetsciiTokens(text) {
72
- return text.replace(/\{([^}]+)\}/g, (_match, token) => {
75
+ return text.replace(/\{([^}]+)\}|[a-z]/g, (_match, token) => {
76
+ if (token === undefined) {
77
+ return _match.toUpperCase();
78
+ }
73
79
  const upper = token.trim().toUpperCase();
74
80
  const code = PETSCII_TOKENS[upper];
75
81
  if (code !== undefined) {
76
82
  return String.fromCharCode(code);
77
83
  }
78
- // Numeric form: {$1D} or {29}
79
- let num;
80
- if (upper.startsWith("$")) {
84
+ // Numeric form: {$1D} or {29}. Anchored, so {12AB} is not read as {12}.
85
+ let num = NaN;
86
+ if (/^\$[0-9A-F]{1,2}$/.test(upper)) {
81
87
  num = parseInt(upper.slice(1), 16);
82
88
  }
83
- else {
89
+ else if (/^\d{1,3}$/.test(upper)) {
84
90
  num = parseInt(upper, 10);
85
91
  }
86
92
  if (!isNaN(num) && num >= 0 && num <= 255) {
87
93
  return String.fromCharCode(num);
88
94
  }
89
- return _match; // leave unrecognised tokens as-is
95
+ return _match.toUpperCase(); // type unrecognised tokens literally
90
96
  });
91
97
  }
92
98
  // ---------------------------------------------------------------------------
@@ -266,7 +272,7 @@ const inputOperationHandlers = {
266
272
  const keyByte = tokenCode !== undefined
267
273
  ? tokenCode
268
274
  : parsed.key.length === 1
269
- ? parsed.key.charCodeAt(0) & 0xff
275
+ ? parsed.key.toUpperCase().charCodeAt(0) & 0xff
270
276
  : (() => {
271
277
  throw new ToolValidationError(`Unrecognised key: ${parsed.key}. Use a single character or a PETSCII token name.`, { path: "$.key" });
272
278
  })();
@@ -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);
@@ -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: {
@@ -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
  }
@@ -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
  }
@@ -8,6 +8,7 @@ import { sleep, formatTimestampSpec } from "./util.js";
8
8
  import { getTasksHomeDir } from "./background.js";
9
9
  import { Jimp } from "jimp";
10
10
  import { withDiagnosticSpan } from "../../diagnostics.js";
11
+ import { stripHexByteSeparators } from "../hex.js";
11
12
  const C64_SCREENSHOT_PALETTE = [
12
13
  0x000000ff,
13
14
  0xffffffff,
@@ -62,6 +63,9 @@ function canonicalGreetingBackends() {
62
63
  function uniqueGreetingBackends(backends) {
63
64
  return backends.filter((backend, index) => backends.indexOf(backend) === index);
64
65
  }
66
+ function normaliseAssertionHex(value) {
67
+ return stripHexByteSeparators(value).toUpperCase();
68
+ }
65
69
  function normaliseScreenText(screen) {
66
70
  return screen.replace(/\r\n?/g, "\n").replace(/\s+/g, " ").trim().toUpperCase();
67
71
  }
@@ -696,9 +700,17 @@ export const tools = [
696
700
  else if (assertion.type === "memory_equals") {
697
701
  const addr = assertion.address ?? "$0400";
698
702
  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();
703
+ // Compare every expected byte, and treat a failed read as an error rather than as $00.
704
+ const expectedHex = normaliseAssertionHex(expected);
705
+ if (expectedHex.length === 0 || expectedHex.length % 2 !== 0 || !/^[0-9A-F]+$/.test(expectedHex)) {
706
+ throw new Error(`memory_equals expected value ${expected} is not a whole number of hex bytes`);
707
+ }
708
+ const result = await ctx.client.readMemory(addr, String(expectedHex.length / 2));
709
+ if (!result.success || typeof result.data !== "string") {
710
+ throw new Error(`Failed to read memory at ${addr} for memory_equals assertion`);
711
+ }
712
+ const actual = result.data;
713
+ const matched = normaliseAssertionHex(actual) === expectedHex;
702
714
  assertionResults.push({
703
715
  type: assertion.type,
704
716
  status: matched ? "pass" : "fail",
@@ -710,7 +722,10 @@ export const tools = [
710
722
  else if (assertion.type === "sid_silent") {
711
723
  // Check SID gate bits are off (simple check)
712
724
  const result = await ctx.client.readMemory("$D404", "1");
713
- const gate1 = parseInt((result.data ?? "$00").slice(1), 16) & 0x01;
725
+ if (!result.success || typeof result.data !== "string") {
726
+ throw new Error("Failed to read SID voice 1 control register for sid_silent assertion");
727
+ }
728
+ const gate1 = parseInt(result.data.slice(1), 16) & 0x01;
714
729
  const isSilent = gate1 === 0;
715
730
  assertionResults.push({
716
731
  type: assertion.type,
@@ -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);
@@ -1,5 +1,5 @@
1
1
  import fs from "node:fs";
2
- import { assemblyToPrgDetailed, AssemblyError } from "./translation/assembler.js";
2
+ import { assemblyToPrgDetailed, AssemblyError, prgEntryAddress } from "./translation/assembler.js";
3
3
  import { basicToPrg } from "./translation/basicTokenizer.js";
4
4
  import { defineToolModule, OPERATION_DISCRIMINATOR, } from "./types.js";
5
5
  import { booleanSchema, objectSchema, optionalSchema, stringSchema } from "./schema.js";
@@ -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,
@@ -441,7 +442,7 @@ 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 = prgEntryAddress(prg);
445
446
  if (ctx.platform.id === "vice") {
446
447
  setViceSymbols(asmResult.symbols.entries());
447
448
  }
@@ -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: [