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.
@@ -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 ctx.client.sidSetVolume(8);
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,
@@ -91,13 +91,9 @@ function expandPetsciiTokens(text) {
91
91
  }
92
92
  // ---------------------------------------------------------------------------
93
93
  // Joystick helpers
94
- // Port 2 → CIA1 Port A ($DC00), Port 1 → CIA1 Port B ($DC01)
95
- // Bits 0-4: Up / Down / Left / Right / Fire, active LOW (0 = pressed)
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 (a C64U firmware version that provides it, or U64 3.15+); VICE writes CIA1 registers.",
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 = $DC01, 2 = $DC00).",
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.viceMemSet(addr, Uint8Array.of(releasedByte));
318
- ctx.logger.info("Released joystick", { port, address: `$${addr.toString(16).toUpperCase()}` });
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.viceMemSet(addr, Uint8Array.of(pressedByte));
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.viceMemSet(addr, Uint8Array.of(pressedByte));
322
+ await ctx.client.viceJoyportSet(port, pressedByte);
328
323
  await new Promise((res) => setTimeout(res, durationMs));
329
- await ctx.client.viceMemSet(addr, Uint8Array.of(releasedByte));
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
  }
@@ -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 = resolvePath(joinPath(outputPath, runId));
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 = resolvePath(joinPath(runPath, "screen.txt"));
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 = resolvePath(joinPath(runPath, "memory"));
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 = resolvePath(joinPath(memoryPath, `range_${i}_${range.address}.hex`));
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 = resolvePath(joinPath(runPath, "debugreg.json"));
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 = resolvePath(joinPath(runPath, "manifest.json"));
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 upperScreen = screen.toUpperCase();
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
- // Fallback: just "ERROR" appears
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
- // If RUN never appeared, assume instant execution (success)
182
- if (!runDetected) {
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
- // Now poll hardware regions to detect activity
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
- // Read I/O regions in optimized batches as per comment #3466803675
198
- // All of $D000-$DFFF in a single call (covers VIC-II, SID, CIA1, CIA2)
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 (prevIoSignature !== null && ioSignature !== prevIoSignature) {
212
- logger.debug("Hardware or screen activity detected", {
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
- if (prevJiffyClock !== null && !buffersEqual(jiffyClock, prevJiffyClock)) {
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 VIC/CIA/TI/screen progression within window",
217
+ reason: "no program-visible screen progression within window",
250
218
  };
251
219
  }
252
220
  /**
@@ -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"],
@@ -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);
@@ -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)) {