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.
- 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 +65 -61
- package/dist/config.js +75 -38
- package/dist/device.js +122 -106
- 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 +27 -6
- 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/background.js +6 -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 +19 -4
- package/dist/tools/pollValidator.js +30 -2
- package/dist/tools/programRunners.js +5 -4
- 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_system.schema.json +6 -4
- package/mcp/server.json +1 -1
- package/mcp/tools.json +6 -4
- package/mcp.json +1 -1
- package/package.json +1 -1
- package/scripts/mockC64Server.mjs +98 -19
- package/server.json +2 -2
package/dist/tools/audio.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
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: {
|
|
@@ -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),
|
|
@@ -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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
35
|
-
let
|
|
36
|
-
if (lower
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
else if (lower
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
700
|
-
const
|
|
701
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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: "
|
|
690
|
+
arguments: { program: "start: lda #$01\n sta $0400\n rts" },
|
|
690
691
|
},
|
|
691
692
|
],
|
|
692
693
|
workflowHints: [
|