c64bridge 1.0.0 → 1.0.1
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 +5 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/c64Client.js +151 -37
- package/dist/config.js +18 -10
- package/dist/device.js +123 -87
- package/dist/knowledge.js +1 -1
- package/dist/loggingHttpClient.js +15 -3
- package/dist/mcp-server.js +56 -15
- package/dist/rag/indexer.js +11 -8
- package/dist/rag/init.js +11 -8
- package/dist/sidwaveCompiler.js +2 -2
- package/dist/tools/audio.js +26 -5
- package/dist/tools/input.js +9 -14
- package/dist/tools/memory.js +20 -5
- package/dist/tools/meta/artifacts.js +20 -8
- package/dist/tools/meta/background.js +11 -1
- package/dist/tools/pollValidator.js +19 -51
- package/dist/tools/printer.js +1 -0
- package/dist/tools/registry/platform.js +2 -0
- package/dist/tools/registry/printer.js +1 -0
- package/dist/vice/mockServer.js +33 -0
- package/dist/vice/process.js +11 -1
- package/dist/vice/viceClient.js +35 -6
- package/doc/reviews/hardening/01-fable/prompt-handover.md +125 -0
- package/doc/reviews/hardening/01-fable/prompt.md +98 -0
- package/doc/reviews/hardening/01-fable/review-prompt.md +468 -0
- package/doc/reviews/hardening/01-fable/review.md +1234 -0
- package/mcp/schemas/c64_input.schema.json +5 -5
- package/mcp/server.json +1 -1
- package/mcp/tools.json +7 -6
- package/mcp.json +1 -1
- package/package.json +1 -1
- package/scripts/mockC64Server.mjs +131 -2
- package/server.json +2 -2
package/dist/tools/audio.js
CHANGED
|
@@ -9,6 +9,12 @@ const NOTE_PATTERN = /^([A-Ga-g])([#b]?)(-?\d+)$/;
|
|
|
9
9
|
const DEFAULT_SILENCE_VERIFY_DURATION_SECONDS = 1.5;
|
|
10
10
|
const DEFAULT_SILENCE_VERIFY_RMS_THRESHOLD = 0.02;
|
|
11
11
|
const DEFAULT_SILENCE_VERIFY_WAIT_MS = 150;
|
|
12
|
+
const generatedPlaybacks = new Set();
|
|
13
|
+
export function cancelGeneratedSidPlayback() {
|
|
14
|
+
for (const controller of generatedPlaybacks)
|
|
15
|
+
controller.abort();
|
|
16
|
+
generatedPlaybacks.clear();
|
|
17
|
+
}
|
|
12
18
|
function toRecord(details) {
|
|
13
19
|
if (details && typeof details === "object") {
|
|
14
20
|
return details;
|
|
@@ -473,6 +479,7 @@ export const audioModule = defineToolModule({
|
|
|
473
479
|
try {
|
|
474
480
|
const parsed = sidResetArgsSchema.parse(args ?? {});
|
|
475
481
|
ctx.logger.info("Resetting SID", { mode: parsed.hard ? "hard" : "soft" });
|
|
482
|
+
cancelGeneratedSidPlayback(); // Reset is the documented way to stop a generated arpeggio.
|
|
476
483
|
const result = await ctx.client.sidReset(parsed.hard);
|
|
477
484
|
if (!result.success) {
|
|
478
485
|
throw new ToolExecutionError("C64 firmware reported failure while resetting SID", {
|
|
@@ -622,6 +629,7 @@ export const audioModule = defineToolModule({
|
|
|
622
629
|
const parsed = sidSilenceArgsSchema.parse(args ?? {});
|
|
623
630
|
const verify = Boolean(parsed.verify);
|
|
624
631
|
ctx.logger.info("Silencing all SID voices", { verify });
|
|
632
|
+
cancelGeneratedSidPlayback(); // Otherwise its remaining notes would resume writing right after this silences the chip.
|
|
625
633
|
const result = await ctx.client.sidSilenceAll();
|
|
626
634
|
if (!result.success) {
|
|
627
635
|
throw new ToolExecutionError("C64 firmware reported failure while silencing SID", {
|
|
@@ -812,10 +820,17 @@ export const audioModule = defineToolModule({
|
|
|
812
820
|
tempoMs: parsed.tempoMs,
|
|
813
821
|
waveform: parsed.waveform,
|
|
814
822
|
});
|
|
823
|
+
cancelGeneratedSidPlayback(); // Replace any prior generated arpeggio rather than letting two run at once.
|
|
824
|
+
const controller = new AbortController();
|
|
825
|
+
generatedPlaybacks.add(controller);
|
|
826
|
+
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).
|
|
815
827
|
void (async () => {
|
|
816
828
|
try {
|
|
817
|
-
await
|
|
829
|
+
const pinnedFacade = await pinnedFacadePromise;
|
|
830
|
+
await ctx.client.sidSetVolume(8, pinnedFacade);
|
|
818
831
|
for (let i = 0; i < parsed.steps; i += 1) {
|
|
832
|
+
if (controller.signal.aborted)
|
|
833
|
+
break;
|
|
819
834
|
const iv = intervals[i % intervals.length];
|
|
820
835
|
const note = transposeNote(parsed.root, iv);
|
|
821
836
|
await ctx.client.sidNoteOn({
|
|
@@ -827,18 +842,24 @@ export const audioModule = defineToolModule({
|
|
|
827
842
|
decay: 7,
|
|
828
843
|
sustain: 15,
|
|
829
844
|
release: 0,
|
|
830
|
-
});
|
|
845
|
+
}, pinnedFacade);
|
|
831
846
|
const durationMs = parsed.preset === "expression" ? expressiveDurations[i % expressiveDurations.length] : parsed.tempoMs;
|
|
832
|
-
await sleep(durationMs);
|
|
847
|
+
await sleep(Math.max(1, Math.min(20, Math.floor(durationMs / 8)))); // Clear GATE between notes so every note retriggers ADSR.
|
|
848
|
+
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)))));
|
|
833
851
|
}
|
|
834
|
-
await ctx.client.sidNoteOff(1);
|
|
852
|
+
await ctx.client.sidNoteOff(1, pinnedFacade);
|
|
835
853
|
}
|
|
836
854
|
catch (playbackError) {
|
|
837
855
|
const message = playbackError instanceof Error ? playbackError.message : String(playbackError);
|
|
838
856
|
ctx.logger.warn("music_generate playback failed", { error: message });
|
|
839
857
|
}
|
|
858
|
+
finally {
|
|
859
|
+
generatedPlaybacks.delete(controller);
|
|
860
|
+
}
|
|
840
861
|
})();
|
|
841
|
-
return textResult(`Scheduled ${parsed.steps} note arpeggio starting on ${parsed.root}.`, {
|
|
862
|
+
return textResult(`Scheduled ${parsed.steps} note arpeggio starting on ${parsed.root}. Playback is running and will be replaced by the next generated sequence.`, {
|
|
842
863
|
success: true,
|
|
843
864
|
root: parsed.root,
|
|
844
865
|
intervals,
|
package/dist/tools/input.js
CHANGED
|
@@ -91,13 +91,9 @@ function expandPetsciiTokens(text) {
|
|
|
91
91
|
}
|
|
92
92
|
// ---------------------------------------------------------------------------
|
|
93
93
|
// Joystick helpers
|
|
94
|
-
//
|
|
95
|
-
//
|
|
94
|
+
// Bits 0-4: Up / Down / Left / Right / Fire, active LOW (0 = pressed).
|
|
95
|
+
// VICE must use Binary Monitor Joyport Set; CIA register writes are not input.
|
|
96
96
|
// ---------------------------------------------------------------------------
|
|
97
|
-
const JOYSTICK_PORT_ADDRESS = {
|
|
98
|
-
1: 0xDC01,
|
|
99
|
-
2: 0xDC00,
|
|
100
|
-
};
|
|
101
97
|
const JOYSTICK_BIT = {
|
|
102
98
|
up: 0,
|
|
103
99
|
down: 1,
|
|
@@ -174,11 +170,11 @@ export const keyArgsSchema = objectSchema({
|
|
|
174
170
|
additionalProperties: false,
|
|
175
171
|
});
|
|
176
172
|
export const joystickArgsSchema = objectSchema({
|
|
177
|
-
description: "Simulate joystick input. On C64U/U64 this uses machine:input
|
|
173
|
+
description: "Simulate joystick input. On C64U/U64 this uses machine:input; VICE uses Binary Monitor Joyport Set.",
|
|
178
174
|
properties: {
|
|
179
175
|
op: literalSchema("joystick"),
|
|
180
176
|
port: numberSchema({
|
|
181
|
-
description: "Joystick port (1
|
|
177
|
+
description: "Joystick port (1 or 2).",
|
|
182
178
|
integer: true,
|
|
183
179
|
minimum: 1,
|
|
184
180
|
maximum: 2,
|
|
@@ -309,24 +305,23 @@ const inputOperationHandlers = {
|
|
|
309
305
|
success: true, port, action: parsed.action, controls: parsed.controls, state,
|
|
310
306
|
});
|
|
311
307
|
}
|
|
312
|
-
const addr = JOYSTICK_PORT_ADDRESS[port];
|
|
313
308
|
if (parsed.controls.some((control) => !(control in JOYSTICK_BIT))) {
|
|
314
309
|
throw new ToolValidationError("VICE joystick supports up, down, left, right, and fire only.", { path: "$.controls" });
|
|
315
310
|
}
|
|
316
311
|
if (parsed.action === "release") {
|
|
317
|
-
await ctx.client.
|
|
318
|
-
ctx.logger.info("Released joystick", { port
|
|
312
|
+
await ctx.client.viceJoyportSet(port, releasedByte);
|
|
313
|
+
ctx.logger.info("Released joystick", { port });
|
|
319
314
|
return textResult(`Joystick port ${port} released.`, { success: true, port, action: "release" });
|
|
320
315
|
}
|
|
321
316
|
if (parsed.action === "press") {
|
|
322
|
-
await ctx.client.
|
|
317
|
+
await ctx.client.viceJoyportSet(port, pressedByte);
|
|
323
318
|
ctx.logger.info("Pressed joystick", { port, controls: parsed.controls, byte: pressedByte });
|
|
324
319
|
return textResult(`Joystick port ${port} pressed: ${parsed.controls.join(", ") || "none"}.`, { success: true, port, action: "press", controls: parsed.controls });
|
|
325
320
|
}
|
|
326
321
|
// tap: press → wait → release
|
|
327
|
-
await ctx.client.
|
|
322
|
+
await ctx.client.viceJoyportSet(port, pressedByte);
|
|
328
323
|
await new Promise((res) => setTimeout(res, durationMs));
|
|
329
|
-
await ctx.client.
|
|
324
|
+
await ctx.client.viceJoyportSet(port, releasedByte);
|
|
330
325
|
ctx.logger.info("Tapped joystick", { port, controls: parsed.controls, durationMs });
|
|
331
326
|
return textResult(`Joystick port ${port} tapped: ${parsed.controls.join(", ") || "none"} for ${durationMs}ms.`, { success: true, port, action: "tap", controls: parsed.controls, durationMs });
|
|
332
327
|
}
|
package/dist/tools/memory.js
CHANGED
|
@@ -243,11 +243,12 @@ async function executeWriteMemory(rawArgs, ctx) {
|
|
|
243
243
|
details: detailRecord,
|
|
244
244
|
});
|
|
245
245
|
}
|
|
246
|
+
const pinnedFacade = ctx.client.pinFacade ? await ctx.client.pinFacade() : undefined; // Pin for this whole pause/read/write/resume sequence so a concurrent select_backend can't retarget it (HARD01-016).
|
|
246
247
|
const canPause = supportsMachinePause(ctx);
|
|
247
248
|
let paused = false;
|
|
248
249
|
try {
|
|
249
250
|
if (canPause) {
|
|
250
|
-
const pauseResult = await ctx.client.pause();
|
|
251
|
+
const pauseResult = await ctx.client.pause(pinnedFacade);
|
|
251
252
|
if (!pauseResult.success) {
|
|
252
253
|
throw new ToolExecutionError("C64 firmware reported failure while pausing", {
|
|
253
254
|
details: normaliseFailure(pauseResult.details),
|
|
@@ -258,7 +259,7 @@ async function executeWriteMemory(rawArgs, ctx) {
|
|
|
258
259
|
const expectedBytes = expectedInfo?.bytes ?? new Uint8Array();
|
|
259
260
|
const maskBytes = maskInfo?.bytes;
|
|
260
261
|
const readLength = Math.max(1, Math.max(writeInfo.bytes.length, expectedBytes.length));
|
|
261
|
-
const preRead = await ctx.client.readMemory(parsed.address, String(readLength));
|
|
262
|
+
const preRead = await ctx.client.readMemory(parsed.address, String(readLength), pinnedFacade);
|
|
262
263
|
if (!preRead.success) {
|
|
263
264
|
throw new ToolExecutionError("C64 firmware reported failure while reading memory", {
|
|
264
265
|
details: normaliseFailure(preRead.details),
|
|
@@ -286,13 +287,13 @@ async function executeWriteMemory(rawArgs, ctx) {
|
|
|
286
287
|
});
|
|
287
288
|
}
|
|
288
289
|
}
|
|
289
|
-
const writeResult = await ctx.client.writeMemory(parsed.address, writeInfo.canonical);
|
|
290
|
+
const writeResult = await ctx.client.writeMemory(parsed.address, writeInfo.canonical, pinnedFacade);
|
|
290
291
|
if (!writeResult.success) {
|
|
291
292
|
throw new ToolExecutionError("C64 firmware reported failure while writing memory", {
|
|
292
293
|
details: normaliseFailure(writeResult.details),
|
|
293
294
|
});
|
|
294
295
|
}
|
|
295
|
-
const postRead = await ctx.client.readMemory(parsed.address, String(Math.max(1, writeInfo.bytes.length)));
|
|
296
|
+
const postRead = await ctx.client.readMemory(parsed.address, String(Math.max(1, writeInfo.bytes.length)), pinnedFacade);
|
|
296
297
|
if (!postRead.success) {
|
|
297
298
|
throw new ToolExecutionError("C64 firmware reported failure while reading back memory", {
|
|
298
299
|
details: normaliseFailure(postRead.details),
|
|
@@ -336,6 +337,20 @@ async function executeWriteMemory(rawArgs, ctx) {
|
|
|
336
337
|
if (preMismatches.length > 0) {
|
|
337
338
|
verificationMetadata.preReadMismatches = preMismatches;
|
|
338
339
|
}
|
|
340
|
+
if (paused) { // A failed resume here leaves the machine paused: a recoverable operation failure, not a log.
|
|
341
|
+
const resumeResult = await ctx.client.resume(pinnedFacade);
|
|
342
|
+
if (!resumeResult.success) {
|
|
343
|
+
throw new ToolExecutionError("Write completed but the machine could not be resumed", {
|
|
344
|
+
details: {
|
|
345
|
+
machinePaused: true,
|
|
346
|
+
resume: normaliseFailure(resumeResult.details),
|
|
347
|
+
recovery: "Call c64_system with op 'resume' before further machine operations.",
|
|
348
|
+
verification: verificationMetadata,
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
paused = false;
|
|
353
|
+
}
|
|
339
354
|
return textResult(`Wrote ${resolvedLength ?? "the provided"} bytes starting at ${resolvedAddress} (verified).`, {
|
|
340
355
|
success: true,
|
|
341
356
|
address: resolvedAddress,
|
|
@@ -350,7 +365,7 @@ async function executeWriteMemory(rawArgs, ctx) {
|
|
|
350
365
|
finally {
|
|
351
366
|
if (paused) {
|
|
352
367
|
try {
|
|
353
|
-
const resumeResult = await ctx.client.resume();
|
|
368
|
+
const resumeResult = await ctx.client.resume(pinnedFacade);
|
|
354
369
|
if (!resumeResult.success) {
|
|
355
370
|
ctx.logger.warn("C64 resume reported failure after write", {
|
|
356
371
|
details: normaliseFailure(resumeResult.details),
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { objectSchema, stringSchema, arraySchema, numberSchema, optionalSchema, booleanSchema } from "../schema.js";
|
|
2
2
|
import { jsonResult } from "../responses.js";
|
|
3
|
-
import { ToolError, toolErrorResult, unknownErrorResult } from "../errors.js";
|
|
3
|
+
import { ToolError, ToolValidationError, toolErrorResult, unknownErrorResult } from "../errors.js";
|
|
4
4
|
import { promises as fs } from "node:fs";
|
|
5
|
-
import { resolve as resolvePath, join as joinPath } from "node:path";
|
|
5
|
+
import { resolve as resolvePath, join as joinPath, relative, sep, isAbsolute } from "node:path";
|
|
6
6
|
const bundleRunArtifactsArgsSchema = objectSchema({
|
|
7
7
|
description: "Gather screen capture, memory snapshot, and debugreg into a structured bundle.",
|
|
8
8
|
properties: {
|
|
@@ -41,29 +41,32 @@ export const tools = [
|
|
|
41
41
|
try {
|
|
42
42
|
const parsed = bundleRunArtifactsArgsSchema.parse(args ?? {});
|
|
43
43
|
const runId = parsed.runId;
|
|
44
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(runId) || runId === "." || runId === "..") {
|
|
45
|
+
throw new ToolValidationError("runId must be a non-empty safe filename (letters, digits, dot, underscore, and dash only).", { path: "$.runId" });
|
|
46
|
+
}
|
|
44
47
|
const outputPath = resolvePath(String(parsed.outputPath));
|
|
45
48
|
const captureScreen = parsed.captureScreen !== false;
|
|
46
49
|
const captureDebugReg = parsed.captureDebugReg !== false;
|
|
47
50
|
const memoryRanges = (parsed.memoryRanges ?? []);
|
|
48
51
|
await fs.mkdir(outputPath, { recursive: true });
|
|
49
|
-
const runPath =
|
|
52
|
+
const runPath = containedPath(outputPath, runId);
|
|
50
53
|
await fs.mkdir(runPath, { recursive: true });
|
|
51
54
|
const artifacts = {};
|
|
52
55
|
// Capture screen
|
|
53
56
|
if (captureScreen) {
|
|
54
57
|
const screen = await ctx.client.readScreen();
|
|
55
|
-
const screenPath =
|
|
58
|
+
const screenPath = containedPath(runPath, "screen.txt");
|
|
56
59
|
await fs.writeFile(screenPath, screen, "utf8");
|
|
57
60
|
artifacts.screen = screenPath;
|
|
58
61
|
}
|
|
59
62
|
// Capture memory ranges
|
|
60
63
|
if (memoryRanges.length > 0) {
|
|
61
|
-
const memoryPath =
|
|
64
|
+
const memoryPath = containedPath(runPath, "memory");
|
|
62
65
|
await fs.mkdir(memoryPath, { recursive: true });
|
|
63
66
|
for (let i = 0; i < memoryRanges.length; i++) {
|
|
64
67
|
const range = memoryRanges[i];
|
|
65
68
|
const result = await ctx.client.readMemory(range.address, String(range.length));
|
|
66
|
-
const rangePath =
|
|
69
|
+
const rangePath = containedPath(memoryPath, `range_${i}.hex`);
|
|
67
70
|
await fs.writeFile(rangePath, result.data ?? "", "utf8");
|
|
68
71
|
artifacts[`memory_range_${i}`] = rangePath;
|
|
69
72
|
}
|
|
@@ -71,12 +74,12 @@ export const tools = [
|
|
|
71
74
|
// Capture debugreg
|
|
72
75
|
if (captureDebugReg) {
|
|
73
76
|
const debugreg = await ctx.client.debugregRead();
|
|
74
|
-
const debugPath =
|
|
77
|
+
const debugPath = containedPath(runPath, "debugreg.json");
|
|
75
78
|
await fs.writeFile(debugPath, JSON.stringify(debugreg, null, 2), "utf8");
|
|
76
79
|
artifacts.debugreg = debugPath;
|
|
77
80
|
}
|
|
78
81
|
// Write manifest
|
|
79
|
-
const manifestPath =
|
|
82
|
+
const manifestPath = containedPath(runPath, "manifest.json");
|
|
80
83
|
const manifest = {
|
|
81
84
|
runId,
|
|
82
85
|
createdAt: new Date().toISOString(),
|
|
@@ -93,3 +96,12 @@ export const tools = [
|
|
|
93
96
|
},
|
|
94
97
|
},
|
|
95
98
|
];
|
|
99
|
+
function containedPath(base, name) {
|
|
100
|
+
if (!name || isAbsolute(name) || name.includes("/") || name.includes("\\"))
|
|
101
|
+
throw new ToolValidationError("Unsafe artifact filename.");
|
|
102
|
+
const candidate = resolvePath(joinPath(base, name));
|
|
103
|
+
const rel = relative(base, candidate);
|
|
104
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
105
|
+
throw new ToolValidationError("Artifact path escapes its output directory.");
|
|
106
|
+
return candidate;
|
|
107
|
+
}
|
|
@@ -86,6 +86,13 @@ async function ensureTasksLoaded() {
|
|
|
86
86
|
if (parsed && Array.isArray(parsed.tasks)) {
|
|
87
87
|
for (const pt of parsed.tasks) {
|
|
88
88
|
const t = fromPersistedTask(pt);
|
|
89
|
+
if (t.status === "running") {
|
|
90
|
+
t.status = "stopped";
|
|
91
|
+
t.stoppedAt = new Date();
|
|
92
|
+
t.updatedAt = new Date();
|
|
93
|
+
t.lastError = "Interrupted by MCP server restart; start the task again to resume monitoring.";
|
|
94
|
+
t.nextRunAt = null;
|
|
95
|
+
}
|
|
89
96
|
TASKS.set(t.name, t);
|
|
90
97
|
}
|
|
91
98
|
}
|
|
@@ -184,7 +191,10 @@ function scheduleNextRun(task, ctx) {
|
|
|
184
191
|
if (task.status !== "running")
|
|
185
192
|
return;
|
|
186
193
|
try {
|
|
187
|
-
await runOperation(task.operation, task.args, ctx);
|
|
194
|
+
const result = await runOperation(task.operation, task.args, ctx);
|
|
195
|
+
if (result && typeof result === "object" && "success" in result && result.success === false) {
|
|
196
|
+
throw new Error(`Operation returned failure: ${JSON.stringify(result.details ?? result)}`);
|
|
197
|
+
}
|
|
188
198
|
task.iterations += 1;
|
|
189
199
|
task.updatedAt = new Date();
|
|
190
200
|
if (task.maxIterations && task.iterations >= task.maxIterations) {
|
|
@@ -75,13 +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
|
|
79
|
-
// Check if ERROR appears on screen
|
|
80
|
-
if (!upperScreen.includes("ERROR")) {
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
// Try to match "?ERROR_TYPE ERROR" or "ERROR_TYPE ERROR IN LINE"
|
|
84
|
-
const errorMatch = /\?([A-Z\s]+)\s+ERROR(?:\s+IN\s+(\d+))?/i.exec(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.
|
|
85
79
|
if (errorMatch) {
|
|
86
80
|
const errorType = errorMatch[1]?.trim().replace(/\s+/g, " ");
|
|
87
81
|
const lineStr = errorMatch[2];
|
|
@@ -91,8 +85,7 @@ function extractBasicError(screen) {
|
|
|
91
85
|
line: Number.isFinite(line) ? line : undefined,
|
|
92
86
|
};
|
|
93
87
|
}
|
|
94
|
-
|
|
95
|
-
return { message: "UNKNOWN ERROR" };
|
|
88
|
+
return null;
|
|
96
89
|
}
|
|
97
90
|
/**
|
|
98
91
|
* Poll for BASIC program outcome by detecting errors on screen.
|
|
@@ -124,17 +117,20 @@ async function pollBasicOutcome(client, logger, config) {
|
|
|
124
117
|
line: errorInfo.line,
|
|
125
118
|
};
|
|
126
119
|
}
|
|
127
|
-
// If we detected RUN and no error yet, continue polling
|
|
128
|
-
if (runDetected) {
|
|
129
|
-
// Continue polling for a bit to catch any delayed errors
|
|
130
|
-
// but if we've been polling for a while without errors, consider it successful
|
|
131
|
-
}
|
|
132
120
|
}
|
|
133
121
|
catch (error) {
|
|
134
122
|
logger.debug("Failed to read screen during BASIC polling", { error, pollCount });
|
|
135
123
|
}
|
|
136
124
|
await delay(config.intervalMs);
|
|
137
125
|
}
|
|
126
|
+
if (!runDetected) { // Neither "RUN" nor an error ever appeared for the whole window. On working backends the firmware's own LOAD/RUN echo shows this text, so its total absence means execution never started despite the reported success (HARD01-011/036).
|
|
127
|
+
logger.debug("BASIC polling completed without ever observing RUN or an error", { pollCount, elapsed: Date.now() - startTime });
|
|
128
|
+
return {
|
|
129
|
+
status: "error",
|
|
130
|
+
type: "BASIC",
|
|
131
|
+
message: "No execution activity detected: the firmware reported success, but the program does not appear to have run.",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
138
134
|
logger.debug("BASIC polling completed without errors", { pollCount, elapsed: Date.now() - startTime });
|
|
139
135
|
return {
|
|
140
136
|
status: "ok",
|
|
@@ -178,38 +174,20 @@ async function pollAsmOutcome(client, logger, config) {
|
|
|
178
174
|
}
|
|
179
175
|
await delay(config.intervalMs);
|
|
180
176
|
}
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
logger.debug("RUN never detected, assuming instant execution");
|
|
184
|
-
return {
|
|
185
|
-
status: "ok",
|
|
186
|
-
type: "ASM",
|
|
187
|
-
};
|
|
177
|
+
if (!runDetected) { // Fall through to the liveness check rather than assuming success. Skipping it here used to report false success for any run that never types anything visible, e.g. a firmware run_prg that silently no-ops instead of running the PRG (HARD01-036).
|
|
178
|
+
logger.debug("RUN never detected; falling through to hardware liveness check instead of assuming success");
|
|
188
179
|
}
|
|
189
|
-
//
|
|
190
|
-
let prevIoSignature = null;
|
|
191
|
-
let prevJiffyClock = null;
|
|
180
|
+
let previousSignature = null; // Only stable RAM: reading I/O can ack CIA interrupts on Ultimate, and raster/timer values aren't evidence of liveness.
|
|
192
181
|
let alive = false;
|
|
193
182
|
const pollDeadline = Date.now() + config.maxMs;
|
|
194
183
|
while (Date.now() < pollDeadline && !alive) {
|
|
195
184
|
pollCount++;
|
|
196
185
|
try {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const ioRegions = await client.readMemoryRaw(0xD000, 0x1000); // $D000-$DFFF
|
|
200
|
-
// Read low memory through screen RAM in a single call: $0000-$07E7
|
|
201
|
-
// This covers zero page, stack, jiffy clock ($00A0-$00A2), and screen memory
|
|
202
|
-
const lowMemAndScreen = await client.readMemoryRaw(0x0000, 0x7E8); // $0000-$07E7
|
|
203
|
-
// Extract jiffy clock for separate comparison (at offset $00A0 in the buffer)
|
|
204
|
-
const jiffyClock = lowMemAndScreen.slice(0xA0, 0xA3);
|
|
205
|
-
// Exclude the jiffy clock from the broad signature so it can be tracked separately.
|
|
206
|
-
const lowMemSignatureBuffer = lowMemAndScreen.slice();
|
|
207
|
-
lowMemSignatureBuffer.fill(0, 0xA0, 0xA3);
|
|
208
|
-
const combinedBuffer = concatBuffers(ioRegions, lowMemSignatureBuffer);
|
|
209
|
-
const ioSignature = computeCrc32(combinedBuffer);
|
|
186
|
+
const screenRam = await client.readMemoryRaw(0x0400, 0x03e8);
|
|
187
|
+
const signature = computeCrc32(screenRam);
|
|
210
188
|
// Check for any activity
|
|
211
|
-
if (
|
|
212
|
-
logger.debug("
|
|
189
|
+
if (previousSignature !== null && signature !== previousSignature) {
|
|
190
|
+
logger.debug("Program-visible screen activity detected", {
|
|
213
191
|
pollCount,
|
|
214
192
|
elapsed: Date.now() - startTime,
|
|
215
193
|
signatureChanged: true
|
|
@@ -217,17 +195,7 @@ async function pollAsmOutcome(client, logger, config) {
|
|
|
217
195
|
alive = true;
|
|
218
196
|
break;
|
|
219
197
|
}
|
|
220
|
-
|
|
221
|
-
logger.debug("Jiffy clock advanced", {
|
|
222
|
-
pollCount,
|
|
223
|
-
elapsed: Date.now() - startTime,
|
|
224
|
-
clockChanged: true
|
|
225
|
-
});
|
|
226
|
-
alive = true;
|
|
227
|
-
break;
|
|
228
|
-
}
|
|
229
|
-
prevIoSignature = ioSignature;
|
|
230
|
-
prevJiffyClock = jiffyClock;
|
|
198
|
+
previousSignature = signature;
|
|
231
199
|
}
|
|
232
200
|
catch (error) {
|
|
233
201
|
logger.debug("Failed to read memory during ASM polling", { error, pollCount });
|
|
@@ -246,7 +214,7 @@ async function pollAsmOutcome(client, logger, config) {
|
|
|
246
214
|
return {
|
|
247
215
|
status: "crashed",
|
|
248
216
|
type: "ASM",
|
|
249
|
-
reason: "no
|
|
217
|
+
reason: "no program-visible screen progression within window",
|
|
250
218
|
};
|
|
251
219
|
}
|
|
252
220
|
/**
|
package/dist/tools/printer.js
CHANGED
|
@@ -225,6 +225,7 @@ function validateEpsonDensity(args) {
|
|
|
225
225
|
}
|
|
226
226
|
export const printerModule = defineToolModule({
|
|
227
227
|
domain: "printer",
|
|
228
|
+
supportedPlatforms: ["c64u", "u2"],
|
|
228
229
|
summary: "Printer workflow helpers for Commodore MPS and Epson FX devices, including prompt templates.",
|
|
229
230
|
resources: [
|
|
230
231
|
"c64://printer/spec",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describePlatformCapabilities } from "../../platform.js";
|
|
2
|
+
import { cancelGeneratedSidPlayback } from "../audio.js";
|
|
2
3
|
import { debugModuleGroup as debugModule } from "../debug.js";
|
|
3
4
|
import { viceModuleGroup as viceModule } from "../vice.js";
|
|
4
5
|
import { jsonResult } from "../responses.js";
|
|
@@ -73,6 +74,7 @@ const platformOperationHandlers = {
|
|
|
73
74
|
isError: true,
|
|
74
75
|
};
|
|
75
76
|
}
|
|
77
|
+
cancelGeneratedSidPlayback(); // A backend switch is the documented cue to stop it rather than let it keep playing on the old backend.
|
|
76
78
|
ctx.client.switchBackend(backend);
|
|
77
79
|
ctx.setPlatform(backend);
|
|
78
80
|
const { availableTools, unavailableTools } = describeTargetPlatform(backend);
|
|
@@ -116,6 +116,7 @@ function invokePrintTool(toolName, rawArgs, ctx) {
|
|
|
116
116
|
}
|
|
117
117
|
export const printerModuleGroup = defineToolModule({
|
|
118
118
|
domain: "printer",
|
|
119
|
+
supportedPlatforms: ["c64u", "u2"],
|
|
119
120
|
summary: "Grouped printer text, bitmap, and character definition helpers.",
|
|
120
121
|
resources: ["c64://guide/bootstrap"],
|
|
121
122
|
defaultTags: ["printer", "device"],
|
package/dist/vice/mockServer.js
CHANGED
|
@@ -2,6 +2,10 @@ import net from "node:net";
|
|
|
2
2
|
import { asciiToScreenCodes } from "./readiness.js";
|
|
3
3
|
const SCREEN_BASE = 0x0400;
|
|
4
4
|
const SCREEN_SIZE = 40 * 25;
|
|
5
|
+
const KEYBOARD_BUFFER = 0x0277; // KERNAL keyboard buffer base ($0277), where a typed SYS command lands.
|
|
6
|
+
const SYS_COMMAND_PATTERN = /SYS\s*\d+/i; // upload_run_asm enters an assembled program via a typed SYSnnnnn.
|
|
7
|
+
const HEARTBEAT_GRANULARITY_MS = 10; // Well below the poll interval so consecutive liveness reads always differ.
|
|
8
|
+
const HEARTBEAT_DURATION_MS = 1000; // How long a SYS-triggered program keeps "running".
|
|
5
9
|
function buildDefaultRegisterMetadata() {
|
|
6
10
|
return Object.freeze([
|
|
7
11
|
{ id: 0, name: "PC", bits: 16 },
|
|
@@ -30,6 +34,7 @@ export class ViceMockServer {
|
|
|
30
34
|
this.server = null;
|
|
31
35
|
this.memory = new Uint8Array(0x10000);
|
|
32
36
|
this.helloReady = false;
|
|
37
|
+
this.heartbeat = null;
|
|
33
38
|
this.checkpoints = new Map();
|
|
34
39
|
this.nextCheckpointId = 1;
|
|
35
40
|
this.registerMetadata = buildDefaultRegisterMetadata();
|
|
@@ -186,6 +191,7 @@ export class ViceMockServer {
|
|
|
186
191
|
for (let i = 0; i < length; i += 1) {
|
|
187
192
|
payload[2 + i] = this.memory[(start + i) & 0xffff];
|
|
188
193
|
}
|
|
194
|
+
this.applyHeartbeatOverlay(start, length, payload);
|
|
189
195
|
return buildResponse(cmd, reqId, payload);
|
|
190
196
|
}
|
|
191
197
|
case 0x02: { // mem set
|
|
@@ -197,6 +203,12 @@ export class ViceMockServer {
|
|
|
197
203
|
}
|
|
198
204
|
if (start <= SCREEN_BASE && end >= SCREEN_BASE) {
|
|
199
205
|
this.helloReady = false;
|
|
206
|
+
this.heartbeat = null; // A client writing screen RAM has taken over; end any simulated run.
|
|
207
|
+
}
|
|
208
|
+
this.maybeStartAsmHeartbeat(start, end, payload);
|
|
209
|
+
const KEYBOARD_NDX = 0x00c6; // This mock runs no real 6502 code, so simulate the KERNAL IRQ draining $00C6 shortly after each write.
|
|
210
|
+
if (start <= KEYBOARD_NDX && end >= KEYBOARD_NDX) {
|
|
211
|
+
setTimeout(() => { this.memory[KEYBOARD_NDX] = 0; }, 5).unref();
|
|
200
212
|
}
|
|
201
213
|
return buildResponse(cmd, reqId);
|
|
202
214
|
}
|
|
@@ -411,6 +423,7 @@ export class ViceMockServer {
|
|
|
411
423
|
this.memory[0x31] = 0x03;
|
|
412
424
|
this.memory[0x32] = 0x08;
|
|
413
425
|
this.helloReady = false;
|
|
426
|
+
this.heartbeat = null;
|
|
414
427
|
this.checkpoints.clear();
|
|
415
428
|
this.nextCheckpointId = 1;
|
|
416
429
|
this.registerValues.clear();
|
|
@@ -431,6 +444,26 @@ export class ViceMockServer {
|
|
|
431
444
|
hello.copy(Buffer.from(this.memory.buffer, offset, hello.length));
|
|
432
445
|
this.helloReady = true;
|
|
433
446
|
}
|
|
447
|
+
maybeStartAsmHeartbeat(start, end, payload) {
|
|
448
|
+
if (start > KEYBOARD_BUFFER || end < KEYBOARD_BUFFER)
|
|
449
|
+
return; // Only a write covering the KERNAL keyboard buffer can be a typed SYS trigger.
|
|
450
|
+
if (!SYS_COMMAND_PATTERN.test(payload.toString("ascii")))
|
|
451
|
+
return;
|
|
452
|
+
this.heartbeat = { startedAt: Date.now(), baseValue: this.memory[SCREEN_BASE] };
|
|
453
|
+
}
|
|
454
|
+
applyHeartbeatOverlay(start, length, payload) {
|
|
455
|
+
const heartbeat = this.heartbeat;
|
|
456
|
+
if (!heartbeat)
|
|
457
|
+
return;
|
|
458
|
+
const elapsed = Date.now() - heartbeat.startedAt;
|
|
459
|
+
if (elapsed < 0 || elapsed >= HEARTBEAT_DURATION_MS)
|
|
460
|
+
return;
|
|
461
|
+
const offset = SCREEN_BASE - start;
|
|
462
|
+
if (offset < 0 || offset >= length)
|
|
463
|
+
return;
|
|
464
|
+
const ticks = Math.floor(elapsed / HEARTBEAT_GRANULARITY_MS);
|
|
465
|
+
payload[2 + offset] = (heartbeat.baseValue + ticks) & 0xff;
|
|
466
|
+
}
|
|
434
467
|
}
|
|
435
468
|
export async function startViceMockServer(options) {
|
|
436
469
|
const server = new ViceMockServer(options);
|
package/dist/vice/process.js
CHANGED
|
@@ -378,10 +378,20 @@ export async function startViceProcess(options) {
|
|
|
378
378
|
await terminateProcess(xvfb, "SIGKILL", 200);
|
|
379
379
|
}
|
|
380
380
|
};
|
|
381
|
+
const stopSync = () => {
|
|
382
|
+
for (const process of [child, xvfb]) {
|
|
383
|
+
if (!process || process.exitCode !== null || process.signalCode !== null)
|
|
384
|
+
continue;
|
|
385
|
+
try {
|
|
386
|
+
process.kill("SIGKILL");
|
|
387
|
+
}
|
|
388
|
+
catch { }
|
|
389
|
+
}
|
|
390
|
+
};
|
|
381
391
|
child.once("exit", async () => {
|
|
382
392
|
await terminateProcess(xvfb, "SIGTERM", 0);
|
|
383
393
|
});
|
|
384
|
-
return { host: options.host, port: options.port, process: child, stop };
|
|
394
|
+
return { host: options.host, port: options.port, process: child, stop, stopSync };
|
|
385
395
|
}
|
|
386
396
|
function isIgnorableSocketDirError(error) {
|
|
387
397
|
if (!error || typeof error !== "object" || !("code" in error)) {
|