c64bridge 1.0.3 → 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 (58) hide show
  1. package/README.md +12 -12
  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/dist/.tsbuildinfo +1 -1
  8. package/dist/c64Client.js +68 -61
  9. package/dist/config.js +75 -38
  10. package/dist/device.js +154 -120
  11. package/dist/knowledge.js +2 -1
  12. package/dist/petscii.js +18 -15
  13. package/dist/petsciiArt.js +40 -14
  14. package/dist/rag/indexer.js +2 -1
  15. package/dist/sidwave.js +1 -1
  16. package/dist/sidwaveCompiler.js +3 -9
  17. package/dist/tools/audio.js +34 -8
  18. package/dist/tools/debug.js +9 -9
  19. package/dist/tools/errors.js +12 -1
  20. package/dist/tools/hex.js +9 -0
  21. package/dist/tools/input.js +13 -7
  22. package/dist/tools/memory.js +5 -4
  23. package/dist/tools/meta/artifacts.js +8 -3
  24. package/dist/tools/meta/audio.js +25 -28
  25. package/dist/tools/meta/background.js +6 -1
  26. package/dist/tools/meta/config.js +12 -4
  27. package/dist/tools/meta/configInventory.js +4 -1
  28. package/dist/tools/meta/filesystem.js +37 -10
  29. package/dist/tools/meta/graphics.js +10 -15
  30. package/dist/tools/meta/memory.js +12 -18
  31. package/dist/tools/meta/program.js +44 -16
  32. package/dist/tools/pollValidator.js +30 -2
  33. package/dist/tools/programRunners.js +5 -4
  34. package/dist/tools/registry/batch.js +4 -2
  35. package/dist/tools/registry/config.js +1 -1
  36. package/dist/tools/registry/disk.js +5 -0
  37. package/dist/tools/registry/extract.js +3 -0
  38. package/dist/tools/registry/graphics.js +1 -0
  39. package/dist/tools/registry/program.js +1 -1
  40. package/dist/tools/registry/sound.js +3 -3
  41. package/dist/tools/registry/system.js +3 -3
  42. package/dist/tools/storage.js +2 -2
  43. package/dist/tools/translation/assembler.js +150 -47
  44. package/dist/tools/translation/basicTokenizer.js +39 -9
  45. package/dist/tools/types.js +11 -4
  46. package/dist/vicBitmap.js +16 -0
  47. package/dist/vice/mockServer.js +7 -3
  48. package/dist/vice/viceClient.js +16 -6
  49. package/mcp/schemas/c64_config.schema.json +2 -2
  50. package/mcp/schemas/c64_program.schema.json +10 -18
  51. package/mcp/schemas/c64_sound.schema.json +8 -6
  52. package/mcp/schemas/c64_system.schema.json +9 -7
  53. package/mcp/server.json +1 -1
  54. package/mcp/tools.json +58 -43
  55. package/mcp.json +1 -1
  56. package/package.json +1 -1
  57. package/scripts/mockC64Server.mjs +98 -19
  58. package/server.json +2 -2
@@ -71,19 +71,15 @@ export function compileSidwaveToPrg(doc) {
71
71
  }
72
72
  if (pat.type === "arpeggio" && Array.isArray(pat.notes) && pat.notes.length) {
73
73
  const stepFrames = Math.max(1, Math.round((pat.frame_rate ? fps / pat.frame_rate : framesPerBeat)));
74
- let idx = 0;
75
74
  for (let f = 0; f < totalFramesInSection; f += 1) {
76
- if (f % stepFrames === 0)
77
- idx = (idx + 1) % pat.notes.length;
75
+ const idx = Math.floor(f / stepFrames) % pat.notes.length;
78
76
  notesForSong.push(String(pat.notes[idx]));
79
77
  }
80
78
  }
81
79
  else if (Array.isArray(pat.groove) && pat.groove.length) {
82
80
  const stepFrames = framesPerBeat; // one note per beat
83
- let gi = 0;
84
81
  for (let f = 0; f < totalFramesInSection; f += 1) {
85
- if (f % stepFrames === 0)
86
- gi = (gi + 1) % pat.groove.length;
82
+ const gi = Math.floor(f / stepFrames) % pat.groove.length;
87
83
  notesForSong.push(String(pat.groove[gi]));
88
84
  }
89
85
  }
@@ -95,10 +91,8 @@ export function compileSidwaveToPrg(doc) {
95
91
  const lengthBeats = Math.max(1, Math.floor(pat.length ?? 1));
96
92
  const stepFrames = framesPerBeat * lengthBeats;
97
93
  const baseNote = "C3";
98
- let mi = 0;
99
94
  for (let f = 0; f < totalFramesInSection; f += 1) {
100
- if (f % stepFrames === 0)
101
- mi = (mi + 1) % Math.max(1, intervals.length);
95
+ const mi = Math.floor(f / stepFrames) % Math.max(1, intervals.length);
102
96
  const iv = intervals.length ? intervals[mi] : 0;
103
97
  notesForSong.push(transposeNote(baseNote, iv));
104
98
  }
@@ -4,12 +4,14 @@ import { recordAndAnalyzeAudio } from "../audio/record_and_analyze_audio.js";
4
4
  import { defineToolModule } from "./types.js";
5
5
  import { booleanSchema, numberSchema, objectSchema, optionalSchema, stringSchema, } from "./schema.js";
6
6
  import { jsonResult, textResult } from "./responses.js";
7
- import { ToolError, ToolExecutionError, ToolValidationError, toolErrorResult, unknownErrorResult, } from "./errors.js";
7
+ import { ToolError, ToolExecutionError, ToolValidationError, ToolUnsupportedPlatformError, toolErrorResult, unknownErrorResult, } from "./errors.js";
8
+ import { getPlatformStatus } from "../platform.js";
8
9
  const NOTE_PATTERN = /^([A-Ga-g])([#b]?)(-?\d+)$/;
9
10
  const DEFAULT_SILENCE_VERIFY_DURATION_SECONDS = 1.5;
10
11
  const DEFAULT_SILENCE_VERIFY_RMS_THRESHOLD = 0.02;
11
12
  const DEFAULT_SILENCE_VERIFY_WAIT_MS = 150;
12
13
  const generatedPlaybacks = new Set();
14
+ let lastGeneratedPlayback = Promise.resolve();
13
15
  export function cancelGeneratedSidPlayback() {
14
16
  for (const controller of generatedPlaybacks)
15
17
  controller.abort();
@@ -244,7 +246,7 @@ const musicCompileArgsSchema = objectSchema({
244
246
  enum: ["yaml", "json"],
245
247
  })),
246
248
  output: optionalSchema(stringSchema({
247
- description: "Playback target format.",
249
+ description: "Playback target format. PRG works on all backends; SID playback requires C64U/U64 or U2 (SID export with dryRun works everywhere).",
248
250
  enum: ["prg", "sid"],
249
251
  default: "prg",
250
252
  }), "prg"),
@@ -316,8 +318,20 @@ function transposeNote(note, semitones) {
316
318
  const newOctave = Math.floor(midi / 12) - 1;
317
319
  return `${names[wrapped]}${newOctave}`;
318
320
  }
319
- function sleep(ms) {
320
- 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
+ });
321
335
  }
322
336
  function shouldAutoAnalyze(request) {
323
337
  const lowered = request.toLowerCase();
@@ -824,8 +838,12 @@ export const audioModule = defineToolModule({
824
838
  const controller = new AbortController();
825
839
  generatedPlaybacks.add(controller);
826
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).
827
- 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 () => {
828
845
  try {
846
+ await previousPlayback;
829
847
  const pinnedFacade = await pinnedFacadePromise;
830
848
  await ctx.client.sidSetVolume(8, pinnedFacade);
831
849
  for (let i = 0; i < parsed.steps; i += 1) {
@@ -844,10 +862,14 @@ export const audioModule = defineToolModule({
844
862
  release: 0,
845
863
  }, pinnedFacade);
846
864
  const durationMs = parsed.preset === "expression" ? expressiveDurations[i % expressiveDurations.length] : parsed.tempoMs;
847
- 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;
848
871
  await ctx.client.sidNoteOff(1, pinnedFacade);
849
- if (!controller.signal.aborted)
850
- await sleep(Math.max(0, durationMs - Math.max(1, Math.min(20, Math.floor(durationMs / 8)))));
872
+ await sleep(gateGapMs, controller.signal);
851
873
  }
852
874
  await ctx.client.sidNoteOff(1, pinnedFacade);
853
875
  }
@@ -902,6 +924,10 @@ export const audioModule = defineToolModule({
902
924
  throw new ToolValidationError("Provide sidwave or cpg source", { path: "$.sidwave" });
903
925
  }
904
926
  const outputFormat = parsed.output ?? "prg";
927
+ const platform = ctx.platform?.id ?? getPlatformStatus().id;
928
+ if (outputFormat === "sid" && !parsed.dryRun && platform === "vice") {
929
+ throw new ToolUnsupportedPlatformError("sidplay_attachment (use output: prg or dryRun: true on VICE)", platform, ["c64u", "u2"]);
930
+ }
905
931
  ctx.logger.info("Compiling SIDWAVE composition", {
906
932
  output: outputFormat,
907
933
  dryRun: parsed.dryRun,
@@ -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: {
@@ -1,6 +1,7 @@
1
1
  import { objectSchema, stringSchema, arraySchema, numberSchema, optionalSchema, booleanSchema } from "../schema.js";
2
2
  import { jsonResult } from "../responses.js";
3
- import { ToolError, ToolValidationError, toolErrorResult, unknownErrorResult } from "../errors.js";
3
+ import { ToolError, ToolValidationError, ToolUnsupportedPlatformError, toolErrorResult, unknownErrorResult } from "../errors.js";
4
+ import { getPlatformStatus } from "../../platform.js";
4
5
  import { promises as fs } from "node:fs";
5
6
  import { resolve as resolvePath, join as joinPath, relative, sep, isAbsolute } from "node:path";
6
7
  const bundleRunArtifactsArgsSchema = objectSchema({
@@ -18,7 +19,7 @@ const bundleRunArtifactsArgsSchema = objectSchema({
18
19
  required: ["address", "length"],
19
20
  additionalProperties: false,
20
21
  }))),
21
- captureDebugReg: optionalSchema(booleanSchema({ description: "Capture debugreg state", default: true }), true),
22
+ captureDebugReg: optionalSchema(booleanSchema({ description: "Capture Ultimate debug registers (C64U/U64 only). Defaults to true on C64U/U64 and false on U2/VICE." })),
22
23
  },
23
24
  required: ["runId", "outputPath"],
24
25
  additionalProperties: false,
@@ -46,7 +47,11 @@ export const tools = [
46
47
  }
47
48
  const outputPath = resolvePath(String(parsed.outputPath));
48
49
  const captureScreen = parsed.captureScreen !== false;
49
- const captureDebugReg = parsed.captureDebugReg !== false;
50
+ const platform = ctx.platform?.id ?? getPlatformStatus().id;
51
+ const captureDebugReg = parsed.captureDebugReg ?? (platform === "c64u");
52
+ if (captureDebugReg && platform !== "c64u") {
53
+ throw new ToolUnsupportedPlatformError("debugreg_read", platform, ["c64u"]);
54
+ }
50
55
  const memoryRanges = (parsed.memoryRanges ?? []);
51
56
  await fs.mkdir(outputPath, { recursive: true });
52
57
  const runPath = containedPath(outputPath, runId);
@@ -2,7 +2,8 @@ import { compileSidwaveToPrg, compileSidwaveToSid } from "../../sidwaveCompiler.
2
2
  import { parseSidwave } from "../../sidwave.js";
3
3
  import { recordAndAnalyzeAudio } from "../../audio/record_and_analyze_audio.js";
4
4
  import { jsonResult } from "../responses.js";
5
- import { ToolExecutionError, toolErrorResult, unknownErrorResult } from "../errors.js";
5
+ import { ToolExecutionError, ToolUnsupportedPlatformError, toolErrorResult, unknownErrorResult } from "../errors.js";
6
+ import { getPlatformStatus } from "../../platform.js";
6
7
  import { sleep } from "./util.js";
7
8
  import { arraySchema, booleanSchema, numberSchema, objectSchema, optionalSchema, stringSchema } from "../schema.js";
8
9
  const DEFAULT_SILENCE_DURATION_SECONDS = 1.5;
@@ -68,7 +69,7 @@ const musicCompilePlayAnalyzeArgsSchema = objectSchema({
68
69
  properties: {
69
70
  sidwave: optionalSchema(stringSchema({ description: "SIDWAVE source in YAML or JSON format.", minLength: 1 })),
70
71
  cpg: optionalSchema(stringSchema({ description: "Legacy CPG input format.", minLength: 1 })),
71
- output: optionalSchema(stringSchema({ description: "Playback artifact format.", enum: ["prg", "sid"], default: "prg" }), "prg"),
72
+ output: optionalSchema(stringSchema({ description: "Playback artifact format. PRG works on all backends; SID attachment playback requires C64U/U64 or U2.", enum: ["prg", "sid"], default: "prg" }), "prg"),
72
73
  waitBeforeCaptureMs: optionalSchema(numberSchema({ description: "Delay between starting playback and beginning analysis capture (milliseconds).", minimum: 0, maximum: 5000 }), DEFAULT_PLAYBACK_WAIT_MS),
73
74
  analysisDurationSeconds: optionalSchema(numberSchema({ description: "Audio capture length in seconds.", minimum: 0.5, maximum: 20 }), DEFAULT_ANALYSIS_DURATION_SECONDS),
74
75
  expectedSidwave: optionalSchema(stringSchema({ description: "Optional expected SIDWAVE used to refine analysis comparisons.", minLength: 1 })),
@@ -91,11 +92,11 @@ const musicPlayPresetArgsSchema = objectSchema({
91
92
  }), "fuer_elise"),
92
93
  platforms: optionalSchema(arraySchema(stringSchema({
93
94
  description: "Backends to target in sequence.",
94
- enum: ["vice", "c64u"],
95
+ enum: ["vice", "c64u", "u2"],
95
96
  minLength: 1,
96
97
  }))),
97
98
  verify: optionalSchema(booleanSchema({
98
- description: "Capture and analyze audio on supported backends after playback starts.",
99
+ description: "Capture and analyze audio after playback starts: native streaming on C64U/U64, host audio input on U2/VICE (PortAudio required).",
99
100
  default: true,
100
101
  }), true),
101
102
  analysisDurationSeconds: optionalSchema(numberSchema({
@@ -147,7 +148,7 @@ function normalizeSidwaveInput(input) {
147
148
  return undefined;
148
149
  }
149
150
  function canonicalPresetBackends() {
150
- return ["vice", "c64u"];
151
+ return ["vice", "c64u", "u2"];
151
152
  }
152
153
  function uniquePresetBackends(backends) {
153
154
  return backends.filter((backend, index) => backends.indexOf(backend) === index);
@@ -285,30 +286,22 @@ export const tools = [
285
286
  }
286
287
  let verification = null;
287
288
  if (verify) {
288
- if (backend === "c64u") {
289
- if (waitBeforeCaptureMs > 0) {
290
- await sleep(waitBeforeCaptureMs);
291
- }
292
- const analysis = await analyzer({
293
- durationSeconds: analysisDurationSeconds,
294
- expectedSidwave: preset.sidwave,
295
- });
296
- const metrics = extractRmsMetrics((analysis?.analysis?.global_metrics ?? {}));
297
- verification = {
298
- mode: "audio-analysis",
299
- durationSeconds: analysis.analysis?.durationSeconds ?? analysisDurationSeconds,
300
- averageRms: metrics.average,
301
- maxRms: metrics.max,
302
- voices: analysis.analysis?.voices ?? [],
303
- analysis,
304
- };
305
- }
306
- else {
307
- verification = {
308
- mode: "playback-launch",
309
- note: "Audio analysis is only available on c64u; verified that playback launched on this backend.",
310
- };
289
+ if (waitBeforeCaptureMs > 0) {
290
+ await sleep(waitBeforeCaptureMs);
311
291
  }
292
+ const analysis = await analyzer({
293
+ durationSeconds: analysisDurationSeconds,
294
+ expectedSidwave: preset.sidwave,
295
+ });
296
+ const metrics = extractRmsMetrics((analysis?.analysis?.global_metrics ?? {}));
297
+ verification = {
298
+ mode: "audio-analysis",
299
+ durationSeconds: analysis.analysis?.durationSeconds ?? analysisDurationSeconds,
300
+ averageRms: metrics.average,
301
+ maxRms: metrics.max,
302
+ voices: analysis.analysis?.voices ?? [],
303
+ analysis,
304
+ };
312
305
  }
313
306
  await trySilenceSid(context);
314
307
  backendResult.verification = verification;
@@ -409,6 +402,10 @@ export const tools = [
409
402
  throw new ToolExecutionError("Provide sidwave or cpg source");
410
403
  }
411
404
  const format = (parsed.output ?? "prg");
405
+ const platform = context.platform?.id ?? getPlatformStatus().id;
406
+ if (format === "sid" && platform === "vice") {
407
+ throw new ToolUnsupportedPlatformError("sidplay_attachment (use output: prg on VICE)", platform, ["c64u", "u2"]);
408
+ }
412
409
  const waitBeforeCaptureMs = parsed.waitBeforeCaptureMs ?? DEFAULT_PLAYBACK_WAIT_MS;
413
410
  const analysisDurationSeconds = parsed.analysisDurationSeconds ?? DEFAULT_ANALYSIS_DURATION_SECONDS;
414
411
  const verifySilenceBefore = parsed.verifySilenceBefore ?? true;
@@ -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),
@@ -1,6 +1,7 @@
1
1
  import { objectSchema, stringSchema, optionalSchema, booleanSchema } from "../schema.js";
2
2
  import { jsonResult } from "../responses.js";
3
- import { ToolError, ToolExecutionError, ToolValidationError, toolErrorResult, unknownErrorResult } from "../errors.js";
3
+ import { ToolError, ToolUnsupportedPlatformError, ToolExecutionError, ToolValidationError, toolErrorResult, unknownErrorResult } from "../errors.js";
4
+ import { getPlatformStatus } from "../../platform.js";
4
5
  import { promises as fs } from "node:fs";
5
6
  import { dirname, resolve as resolvePath } from "node:path";
6
7
  import { captureConfigSnapshot, validateSnapshotCategories } from "./configInventory.js";
@@ -10,7 +11,7 @@ const configSnapshotAndRestoreArgsSchema = objectSchema({
10
11
  properties: {
11
12
  action: stringSchema({ description: "One of: snapshot|restore|diff", enum: ["snapshot", "restore", "diff"] }),
12
13
  path: stringSchema({ description: "Snapshot file path", minLength: 1 }),
13
- applyToFlash: optionalSchema(booleanSchema({ description: "Save configuration to flash after restore", default: false }), false),
14
+ applyToFlash: optionalSchema(booleanSchema({ description: "Save configuration to flash after restore (C64U/U64 and U2 only)", default: false }), false),
14
15
  },
15
16
  required: ["action", "path"],
16
17
  additionalProperties: false,
@@ -26,9 +27,13 @@ export const tools = [
26
27
  try {
27
28
  const parsed = configSnapshotAndRestoreArgsSchema.parse(args ?? {});
28
29
  const action = parsed.action;
30
+ const platform = ctx.platform?.id ?? getPlatformStatus().id;
31
+ if (action === "restore" && parsed.applyToFlash && platform === "vice") {
32
+ throw new ToolUnsupportedPlatformError("config_save_to_flash", platform, ["c64u", "u2"]);
33
+ }
29
34
  const path = resolvePath(String(parsed.path));
30
- await fs.mkdir(dirname(path), { recursive: true });
31
35
  if (action === "snapshot") {
36
+ await fs.mkdir(dirname(path), { recursive: true });
32
37
  const [version, info, snapshotData] = await Promise.all([
33
38
  ctx.client.version(),
34
39
  ctx.client.info(),
@@ -66,7 +71,10 @@ export const tools = [
66
71
  throw new ToolExecutionError("Batch update failed", { details: normalizeErrorDetails(result.details) });
67
72
  }
68
73
  if (parsed.applyToFlash) {
69
- await ctx.client.configSaveToFlash();
74
+ const saved = await ctx.client.configSaveToFlash();
75
+ if (!saved.success) {
76
+ throw new ToolExecutionError("Configuration restored, but saving to flash failed", { details: normalizeErrorDetails(saved.details) });
77
+ }
70
78
  }
71
79
  return jsonResult({ restored: true, categories: Object.keys(payload).length }, { success: true });
72
80
  }
@@ -96,7 +96,10 @@ function normalizeSnapshotValue(value, entry) {
96
96
  if (value
97
97
  && typeof value === "object"
98
98
  && !Array.isArray(value)
99
- && Object.keys(value).length === 1
99
+ && (Object.keys(value).length === 1
100
+ || (value.category === entry.category
101
+ && value.item === entry.item
102
+ && ["int", "string"].includes(String(value.type))))
100
103
  && Object.prototype.hasOwnProperty.call(value, "value")) {
101
104
  return value.value;
102
105
  }
@@ -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
  }