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.
- package/README.md +12 -12
- package/data/embeddings_basic.json +1 -1
- package/data/embeddings_hardware.json +1 -1
- package/data/embeddings_mixed.json +1 -1
- package/data/embeddings_other.json +1 -1
- package/data/graphics/character-set.csv +64 -64
- package/dist/.tsbuildinfo +1 -1
- package/dist/c64Client.js +68 -61
- package/dist/config.js +75 -38
- package/dist/device.js +154 -120
- package/dist/knowledge.js +2 -1
- package/dist/petscii.js +18 -15
- package/dist/petsciiArt.js +40 -14
- package/dist/rag/indexer.js +2 -1
- package/dist/sidwave.js +1 -1
- package/dist/sidwaveCompiler.js +3 -9
- package/dist/tools/audio.js +34 -8
- package/dist/tools/debug.js +9 -9
- package/dist/tools/errors.js +12 -1
- package/dist/tools/hex.js +9 -0
- package/dist/tools/input.js +13 -7
- package/dist/tools/memory.js +5 -4
- package/dist/tools/meta/artifacts.js +8 -3
- package/dist/tools/meta/audio.js +25 -28
- package/dist/tools/meta/background.js +6 -1
- package/dist/tools/meta/config.js +12 -4
- package/dist/tools/meta/configInventory.js +4 -1
- package/dist/tools/meta/filesystem.js +37 -10
- package/dist/tools/meta/graphics.js +10 -15
- package/dist/tools/meta/memory.js +12 -18
- package/dist/tools/meta/program.js +44 -16
- package/dist/tools/pollValidator.js +30 -2
- package/dist/tools/programRunners.js +5 -4
- package/dist/tools/registry/batch.js +4 -2
- package/dist/tools/registry/config.js +1 -1
- package/dist/tools/registry/disk.js +5 -0
- package/dist/tools/registry/extract.js +3 -0
- package/dist/tools/registry/graphics.js +1 -0
- package/dist/tools/registry/program.js +1 -1
- package/dist/tools/registry/sound.js +3 -3
- package/dist/tools/registry/system.js +3 -3
- package/dist/tools/storage.js +2 -2
- package/dist/tools/translation/assembler.js +150 -47
- package/dist/tools/translation/basicTokenizer.js +39 -9
- package/dist/tools/types.js +11 -4
- package/dist/vicBitmap.js +16 -0
- package/dist/vice/mockServer.js +7 -3
- package/dist/vice/viceClient.js +16 -6
- package/mcp/schemas/c64_config.schema.json +2 -2
- package/mcp/schemas/c64_program.schema.json +10 -18
- package/mcp/schemas/c64_sound.schema.json +8 -6
- package/mcp/schemas/c64_system.schema.json +9 -7
- package/mcp/server.json +1 -1
- package/mcp/tools.json +58 -43
- package/mcp.json +1 -1
- package/package.json +1 -1
- package/scripts/mockC64Server.mjs +98 -19
- package/server.json +2 -2
package/dist/sidwaveCompiler.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/tools/audio.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/tools/debug.js
CHANGED
|
@@ -572,17 +572,17 @@ function formatAddress(value) {
|
|
|
572
572
|
}
|
|
573
573
|
function parseAddress(value, label) {
|
|
574
574
|
const trimmed = value.trim();
|
|
575
|
-
|
|
576
|
-
let
|
|
577
|
-
if (
|
|
578
|
-
|
|
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 (
|
|
582
|
-
|
|
583
|
-
|
|
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
|
}
|
package/dist/tools/errors.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/tools/input.js
CHANGED
|
@@ -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
|
|
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; //
|
|
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
|
})();
|
package/dist/tools/memory.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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);
|
package/dist/tools/meta/audio.js
CHANGED
|
@@ -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
|
|
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 (
|
|
289
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
17
|
-
let
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
else if (trimmed
|
|
23
|
-
|
|
24
|
-
|
|
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
|
}
|