c64bridge 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +17 -5
  2. package/data/assembly/assembly-spec.md +24 -0
  3. package/data/assembly/examples/video/hello_world.asm +39 -0
  4. package/data/basic/basic-pitfalls.md +236 -0
  5. package/data/basic/basic-spec.md +507 -198
  6. package/data/video/petscii-style-guide.md +105 -0
  7. package/data/video/sprite-charset-best-practices.md +292 -0
  8. package/dist/c64Client.js +1 -0
  9. package/dist/prompts/registry.js +10 -2
  10. package/dist/rag/knowledgeIndex.js +111 -24
  11. package/dist/rag/retriever.js +110 -4
  12. package/dist/tools/audio.js +26 -13
  13. package/dist/tools/developer.js +34 -0
  14. package/dist/tools/graphics.js +6 -5
  15. package/dist/tools/memory.js +1 -1
  16. package/dist/tools/pollValidator.js +256 -0
  17. package/dist/tools/programRunners.js +352 -12
  18. package/dist/tools/rag.js +1 -7
  19. package/doc/MCP_SETUP.md +7 -1
  20. package/doc/bun/baseline-metrics.md +16 -0
  21. package/doc/bun/migration-checklist.md +55 -0
  22. package/doc/bun/migration.md +83 -0
  23. package/doc/developer.md +72 -10
  24. package/doc/improve-knowledge/notes.md +29 -0
  25. package/doc/improve-knowledge/plan.md +28 -3
  26. package/doc/improve-knowledge/rollout.md +39 -5
  27. package/doc/meta-tools/README.md +101 -0
  28. package/doc/meta-tools/analysis.md +255 -0
  29. package/doc/{meta-tools.md → meta-tools/catalog.md} +2 -2
  30. package/doc/meta-tools/notes.md +174 -0
  31. package/doc/meta-tools/rollout.md +343 -0
  32. package/mcp.json +1 -1
  33. package/package.json +17 -10
  34. package/scripts/c64-cli.mjs +42 -7
  35. package/scripts/c64-tool.sh +7 -1
  36. package/scripts/check-node-compat.mjs +107 -0
  37. package/scripts/invoke-bun.mjs +58 -0
  38. package/scripts/rag-rebuild.mjs +11 -13
  39. package/scripts/run-mcp-check.sh +10 -2
  40. package/scripts/run-tests.mjs +3 -3
  41. package/scripts/run-tests.ts +187 -0
  42. package/scripts/start.mjs +3 -26
  43. package/scripts/update-readme.ts +17 -1
  44. package/scripts/verify-package.mjs +0 -1
  45. package/data/assembly/examples/effects/sinewave.asm +0 -5
  46. package/doc/meta-tools-rollout.md +0 -211
  47. package/scripts/register-ts-node.mjs +0 -6
  48. /package/data/assembly/examples/{graphics/sprite/mover.asm → video/sprite_mover.asm} +0 -0
@@ -3,9 +3,24 @@ C64 Bridge - Local RAG Retriever
3
3
  GPL-2.0-only
4
4
  */
5
5
  import { cosineSimilarity } from "./embeddings.js";
6
+ import { listKnowledgeResources } from "./knowledgeIndex.js";
6
7
  const BASIC_SIGNAL_RE = /(^\s*\d{1,5}\s)|\b(PRINT|POKE|GOTO|GOSUB|RESTORE|READ|DATA|INPUT|CHR\$|TI\$|TAB\()/im;
7
8
  const ASM_SIGNAL_RE = /(^\s*(?:[A-Z_][\w]*:)?\s*(?:\.?[A-Z]{2,4})\b)|\$[0-9A-F]{2,4}/im;
8
9
  const PROVENANCE_COMMENT_RE = /^\s*<!--\s*Source:\s*(.*?)\s*-->\s*/i;
10
+ // Build a mapping from file paths to resource URIs
11
+ function buildPathToUriMap() {
12
+ const resources = listKnowledgeResources();
13
+ const map = new Map();
14
+ for (const resource of resources) {
15
+ if (resource.relativePath) {
16
+ // Normalize the path for matching
17
+ const normalizedPath = resource.relativePath.replace(/\\/g, "/");
18
+ map.set(normalizedPath, resource.uri);
19
+ }
20
+ }
21
+ return map;
22
+ }
23
+ const PATH_TO_URI_MAP = buildPathToUriMap();
9
24
  export class LocalRagRetriever {
10
25
  constructor(model, opts) {
11
26
  this.model = model;
@@ -78,7 +93,9 @@ export class LocalRagRetriever {
78
93
  if (!normalized || normalized === "other")
79
94
  push(this.other);
80
95
  candidates.sort((a, b) => b.score - a.score);
81
- return candidates.slice(0, topK).map((candidate) => ({
96
+ // Apply duplicate suppression and diversity
97
+ const deduplicated = deduplicateAndDiversify(candidates, topK);
98
+ return deduplicated.map((candidate) => ({
82
99
  snippet: candidate.snippet,
83
100
  origin: candidate.origin,
84
101
  uri: candidate.uri,
@@ -86,14 +103,82 @@ export class LocalRagRetriever {
86
103
  sourcePath: candidate.record.sourcePath,
87
104
  sourceUrl: candidate.record.sourceUrl,
88
105
  sourceRepoUrl: candidate.record.sourceRepoUrl,
89
- license: candidate.record.license,
90
106
  licenseSpdxId: candidate.record.licenseSpdxId,
91
- licenseName: candidate.record.licenseName,
92
- licenseUrl: candidate.record.licenseUrl,
93
107
  attribution: candidate.record.attribution,
94
108
  }));
95
109
  }
96
110
  }
111
+ function deduplicateAndDiversify(candidates, topK) {
112
+ // Step 1: Remove near-duplicates based on snippet similarity
113
+ const results = [];
114
+ const seenSnippets = new Set();
115
+ for (const candidate of candidates) {
116
+ // Normalize snippet for comparison (lowercase, trim whitespace)
117
+ const normalizedSnippet = candidate.snippet
118
+ .toLowerCase()
119
+ .replace(/\s+/g, " ")
120
+ .trim()
121
+ .slice(0, 100); // Compare first 100 chars
122
+ // Skip if we've seen this snippet before
123
+ if (seenSnippets.has(normalizedSnippet)) {
124
+ continue;
125
+ }
126
+ // Check for high similarity with existing results
127
+ let isDuplicate = false;
128
+ for (const existing of results) {
129
+ const existingNormalized = existing.snippet
130
+ .toLowerCase()
131
+ .replace(/\s+/g, " ")
132
+ .trim()
133
+ .slice(0, 100);
134
+ // Simple Jaccard similarity on words
135
+ const words1 = new Set(normalizedSnippet.split(/\s+/));
136
+ const words2 = new Set(existingNormalized.split(/\s+/));
137
+ const intersection = new Set([...words1].filter((w) => words2.has(w)));
138
+ const union = new Set([...words1, ...words2]);
139
+ const similarity = intersection.size / union.size;
140
+ if (similarity > 0.85) {
141
+ isDuplicate = true;
142
+ break;
143
+ }
144
+ }
145
+ if (!isDuplicate) {
146
+ results.push(candidate);
147
+ seenSnippets.add(normalizedSnippet);
148
+ }
149
+ // Stop if we have enough results
150
+ if (results.length >= topK) {
151
+ break;
152
+ }
153
+ }
154
+ // Step 2: Ensure diversity in origins/resources
155
+ // If we have fewer results than topK, try to add more from different origins
156
+ if (results.length < topK && results.length < candidates.length) {
157
+ const usedOrigins = new Set(results.map((r) => r.origin || r.uri || "unknown"));
158
+ const remaining = candidates.filter((c) => !results.includes(c));
159
+ for (const candidate of remaining) {
160
+ const candidateOrigin = candidate.origin || candidate.uri || "unknown";
161
+ // Prefer candidates from different origins
162
+ if (!usedOrigins.has(candidateOrigin)) {
163
+ results.push(candidate);
164
+ usedOrigins.add(candidateOrigin);
165
+ if (results.length >= topK) {
166
+ break;
167
+ }
168
+ }
169
+ }
170
+ // Fill remaining slots if still under topK
171
+ for (const candidate of remaining) {
172
+ if (!results.includes(candidate)) {
173
+ results.push(candidate);
174
+ if (results.length >= topK) {
175
+ break;
176
+ }
177
+ }
178
+ }
179
+ }
180
+ return results.slice(0, topK);
181
+ }
97
182
  function extractSnippetAndOrigin(text) {
98
183
  const match = PROVENANCE_COMMENT_RE.exec(text);
99
184
  if (!match) {
@@ -107,9 +192,30 @@ function extractSnippetAndOrigin(text) {
107
192
  };
108
193
  }
109
194
  function deriveUri(record, origin) {
195
+ // If origin is already a c64:// URI, use it
110
196
  if (origin && origin.startsWith("c64://")) {
111
197
  return origin;
112
198
  }
199
+ // Try to map the file path from origin to a resource URI
200
+ if (origin) {
201
+ // Extract the file path (before any '#' anchor)
202
+ const filePath = origin.split("#")[0];
203
+ const resourceUri = PATH_TO_URI_MAP.get(filePath);
204
+ if (resourceUri) {
205
+ // Preserve any anchor/section from the original origin
206
+ const anchor = origin.includes("#") ? "#" + origin.split("#")[1] : "";
207
+ return resourceUri + anchor;
208
+ }
209
+ }
210
+ // Try to map the source path from the record
211
+ if (record.sourcePath) {
212
+ const normalizedPath = record.sourcePath.replace(/\\/g, "/");
213
+ const resourceUri = PATH_TO_URI_MAP.get(normalizedPath);
214
+ if (resourceUri) {
215
+ return resourceUri;
216
+ }
217
+ }
218
+ // Fallback to source URLs
113
219
  if (record.sourceUrl) {
114
220
  return record.sourceUrl;
115
221
  }
@@ -191,8 +191,12 @@ const musicGenerateArgsSchema = objectSchema({
191
191
  waveform: stringSchema({
192
192
  description: "Waveform for playback.",
193
193
  enum: ["pulse", "saw", "tri", "noise"],
194
- default: "pulse",
194
+ default: "tri",
195
195
  }),
196
+ preset: optionalSchema(stringSchema({
197
+ description: "Optional musical preset to influence timing/expression.",
198
+ enum: ["classic", "expression"],
199
+ }), "classic"),
196
200
  },
197
201
  additionalProperties: false,
198
202
  });
@@ -474,7 +478,7 @@ export const audioModule = defineToolModule({
474
478
  description: "Trigger a SID voice with configurable waveform, pulse width, and ADSR envelope. See c64://specs/sid.",
475
479
  summary: "Resolves note or frequency, clamps parameters, and writes the SID voice registers.",
476
480
  inputSchema: sidNoteOnArgsSchema.jsonSchema,
477
- tags: ["sid", "control", "music"],
481
+ tags: ["sid", "control", "music", "pal-ntsc"],
478
482
  prerequisites: ["sid_volume"],
479
483
  examples: [
480
484
  { name: "Note on", description: "Voice 1 C4 triangle", arguments: { voice: 1, note: "C4", waveform: "tri" } },
@@ -511,12 +515,16 @@ export const audioModule = defineToolModule({
511
515
  });
512
516
  }
513
517
  const detailRecord = toRecord(result.details) ?? {};
514
- return textResult(`Started SID voice ${voice}${parsed.note ? ` with note ${parsed.note.toUpperCase()}` : ""}.`, {
518
+ const systemNote = parsed.system
519
+ ? `Using ${parsed.system} timing.`
520
+ : "Using default PAL timing. For NTSC, pass system: 'NTSC'. Check $02A6 memory location to verify system.";
521
+ return textResult(`Started SID voice ${voice}${parsed.note ? ` with note ${parsed.note.toUpperCase()}` : ""}. ${systemNote}`, {
515
522
  success: true,
516
523
  voice,
517
524
  note: parsed.note ?? null,
518
525
  frequencyHz: parsed.frequencyHz ?? null,
519
526
  system: parsed.system ?? "PAL",
527
+ systemReminder: "$02A6 contains PAL/NTSC flag (1=NTSC, 0=PAL)",
520
528
  waveform: parsed.waveform ?? "pulse",
521
529
  pulseWidth: parsed.pulseWidth,
522
530
  envelope: {
@@ -699,10 +707,11 @@ export const audioModule = defineToolModule({
699
707
  inputSchema: musicGenerateArgsSchema.jsonSchema,
700
708
  relatedResources: ["c64://specs/sid"],
701
709
  relatedPrompts: ["sid-music"],
702
- tags: ["sid", "music", "generator"],
710
+ tags: ["sid", "music", "generator", "pal-ntsc"],
703
711
  prerequisites: ["sid_volume"],
704
712
  examples: [
705
- { name: "C major", description: "C4 arpeggio", arguments: { root: "C4", pattern: "0,4,7", steps: 8, tempoMs: 120, waveform: "pulse" } },
713
+ { name: "C major", description: "C4 arpeggio (triangle)", arguments: { root: "C4", pattern: "0,4,7", steps: 8, tempoMs: 120, waveform: "tri" } },
714
+ { name: "Expressive C major", description: "C4 arpeggio with phrasing", arguments: { root: "C4", pattern: "0,4,7", steps: 8, preset: "expression" } },
706
715
  ],
707
716
  workflowHints: [
708
717
  "Offer as a quick inspiration loop when the user wants to hear something immediately; explain how to tweak pattern or tempo.",
@@ -713,11 +722,13 @@ export const audioModule = defineToolModule({
713
722
  const intervals = parseIntervals(parsed.pattern);
714
723
  const timeline = [];
715
724
  let timestamp = 0;
725
+ const expressiveDurations = [250, 180, 180, 400];
716
726
  for (let i = 0; i < parsed.steps; i += 1) {
717
727
  const iv = intervals[i % intervals.length];
718
728
  const note = transposeNote(parsed.root, iv);
719
- timeline.push({ t: timestamp, note });
720
- timestamp += parsed.tempoMs;
729
+ const durationMs = parsed.preset === "expression" ? expressiveDurations[i % expressiveDurations.length] : parsed.tempoMs;
730
+ timeline.push({ t: timestamp, note, durationMs });
731
+ timestamp += durationMs;
721
732
  }
722
733
  ctx.logger.info("Scheduling SID arpeggio", {
723
734
  root: parsed.root,
@@ -735,14 +746,15 @@ export const audioModule = defineToolModule({
735
746
  await ctx.client.sidNoteOn({
736
747
  voice: 1,
737
748
  note,
738
- waveform: parsed.waveform,
749
+ waveform: (parsed.waveform ?? "tri"),
739
750
  pulseWidth: 0x0800,
740
751
  attack: 1,
741
- decay: 2,
742
- sustain: 8,
743
- release: 3,
752
+ decay: 7,
753
+ sustain: 15,
754
+ release: 0,
744
755
  });
745
- await sleep(parsed.tempoMs);
756
+ const durationMs = parsed.preset === "expression" ? expressiveDurations[i % expressiveDurations.length] : parsed.tempoMs;
757
+ await sleep(durationMs);
746
758
  }
747
759
  await ctx.client.sidNoteOff(1);
748
760
  }
@@ -757,7 +769,8 @@ export const audioModule = defineToolModule({
757
769
  intervals,
758
770
  steps: parsed.steps,
759
771
  tempoMs: parsed.tempoMs,
760
- waveform: parsed.waveform,
772
+ waveform: parsed.waveform ?? "tri",
773
+ preset: parsed.preset ?? "classic",
761
774
  timeline,
762
775
  });
763
776
  }
@@ -140,6 +140,9 @@ export const developerModule = defineToolModule({
140
140
  inputSchema: noArgsSchema.jsonSchema,
141
141
  relatedResources: ["c64://context/bootstrap"],
142
142
  tags: ["config", "list"],
143
+ examples: [
144
+ { name: "List configs", description: "Enumerate configuration categories", arguments: {} },
145
+ ],
143
146
  workflowHints: [
144
147
  "Run first to enumerate categories before reading or writing specific settings; summarise the top-level sections for the user.",
145
148
  ],
@@ -171,6 +174,10 @@ export const developerModule = defineToolModule({
171
174
  inputSchema: configGetArgsSchema.jsonSchema,
172
175
  relatedResources: ["c64://context/bootstrap"],
173
176
  tags: ["config", "read"],
177
+ examples: [
178
+ { name: "Get audio config", description: "Read audio category", arguments: { category: "Audio" } },
179
+ { name: "Get volume", description: "Read specific setting", arguments: { category: "Audio", item: "Volume" } },
180
+ ],
174
181
  workflowHints: [
175
182
  "Use after config_list to fetch details; restate category/item so the user can cross-check values before changes.",
176
183
  ],
@@ -203,6 +210,9 @@ export const developerModule = defineToolModule({
203
210
  inputSchema: configSetArgsSchema.jsonSchema,
204
211
  relatedResources: ["c64://context/bootstrap"],
205
212
  tags: ["config", "write"],
213
+ examples: [
214
+ { name: "Set volume", description: "Adjust audio volume", arguments: { category: "Audio", item: "Volume", value: 75 } },
215
+ ],
206
216
  workflowHints: [
207
217
  "Confirm the user-supplied value before writing; mention whether a reboot is required for the change to take effect.",
208
218
  ],
@@ -243,6 +253,9 @@ export const developerModule = defineToolModule({
243
253
  inputSchema: configBatchSchema.jsonSchema,
244
254
  relatedResources: ["c64://context/bootstrap"],
245
255
  tags: ["config", "write"],
256
+ examples: [
257
+ { name: "Batch update", description: "Update multiple settings", arguments: { Audio: { Volume: 80, DACMode: "stereo" } } },
258
+ ],
246
259
  workflowHints: [
247
260
  "Use when multiple categories must be updated together; remind the user to review validation errors closely.",
248
261
  ],
@@ -283,6 +296,9 @@ export const developerModule = defineToolModule({
283
296
  inputSchema: noArgsSchema.jsonSchema,
284
297
  relatedResources: ["c64://context/bootstrap"],
285
298
  tags: ["config", "flash"],
299
+ examples: [
300
+ { name: "Load from flash", description: "Restore saved configuration", arguments: {} },
301
+ ],
286
302
  workflowHints: [
287
303
  "Use when the user wants to revert to saved flash configuration; warn that unsaved RAM changes are discarded.",
288
304
  ],
@@ -316,6 +332,9 @@ export const developerModule = defineToolModule({
316
332
  inputSchema: noArgsSchema.jsonSchema,
317
333
  relatedResources: ["c64://context/bootstrap"],
318
334
  tags: ["config", "flash"],
335
+ examples: [
336
+ { name: "Save to flash", description: "Persist current config", arguments: {} },
337
+ ],
319
338
  workflowHints: [
320
339
  "Run after configuration changes the user wants to persist; mention that flash writes take a moment.",
321
340
  ],
@@ -349,6 +368,9 @@ export const developerModule = defineToolModule({
349
368
  inputSchema: noArgsSchema.jsonSchema,
350
369
  relatedResources: ["c64://context/bootstrap"],
351
370
  tags: ["config", "reset"],
371
+ examples: [
372
+ { name: "Reset config", description: "Restore factory defaults", arguments: {} },
373
+ ],
352
374
  workflowHints: [
353
375
  "Confirm with the user before wiping settings; note that the firmware must restart to apply defaults fully.",
354
376
  ],
@@ -382,6 +404,9 @@ export const developerModule = defineToolModule({
382
404
  inputSchema: noArgsSchema.jsonSchema,
383
405
  relatedResources: ["c64://context/bootstrap"],
384
406
  tags: ["diagnostics", "version"],
407
+ examples: [
408
+ { name: "Get version", description: "Check firmware version", arguments: {} },
409
+ ],
385
410
  workflowHints: [
386
411
  "Run when the user wonders which firmware build is active; include API and FPGA versions if available.",
387
412
  ],
@@ -410,6 +435,9 @@ export const developerModule = defineToolModule({
410
435
  inputSchema: noArgsSchema.jsonSchema,
411
436
  relatedResources: ["c64://context/bootstrap"],
412
437
  tags: ["diagnostics", "info"],
438
+ examples: [
439
+ { name: "Get hardware info", description: "Query hardware status", arguments: {} },
440
+ ],
413
441
  workflowHints: [
414
442
  "Use to answer hardware capability questions; highlight serials, board revisions, or temperature if present.",
415
443
  ],
@@ -438,6 +466,9 @@ export const developerModule = defineToolModule({
438
466
  inputSchema: noArgsSchema.jsonSchema,
439
467
  relatedResources: ["c64://context/bootstrap"],
440
468
  tags: ["debug"],
469
+ examples: [
470
+ { name: "Read debug register", description: "Check $D7FF value", arguments: {} },
471
+ ],
441
472
  workflowHints: [
442
473
  "Read the register when debugging DMA or cartridge interactions; translate the hex value into context for the user.",
443
474
  ],
@@ -474,6 +505,9 @@ export const developerModule = defineToolModule({
474
505
  inputSchema: debugWriteArgsSchema.jsonSchema,
475
506
  relatedResources: ["c64://context/bootstrap"],
476
507
  tags: ["debug"],
508
+ examples: [
509
+ { name: "Write debug register", description: "Set $D7FF to $42", arguments: { value: "42" } },
510
+ ],
477
511
  workflowHints: [
478
512
  "Confirm with the user before writing arbitrary values; mention side effects on cartridge or DMA behaviour.",
479
513
  ],
@@ -273,7 +273,7 @@ export const graphicsModule = defineToolModule({
273
273
  inputSchema: spriteArgsSchema.jsonSchema,
274
274
  relatedResources: ["c64://specs/vic"],
275
275
  relatedPrompts: ["graphics-demo", "assembly-program"],
276
- tags: ["sprite", "assembly"],
276
+ tags: ["sprite", "assembly", "pal-ntsc"],
277
277
  prerequisites: ["upload_and_run_asm"],
278
278
  examples: [
279
279
  {
@@ -380,12 +380,12 @@ export const graphicsModule = defineToolModule({
380
380
  },
381
381
  {
382
382
  name: "create_petscii_image",
383
- description: "Create PETSCII art from prompts or text, optionally run it on the C64, and return metadata. See c64://specs/basic and c64://specs/vic.",
384
- summary: "Synthesises PETSCII art, generates a BASIC program, and uploads it unless dry-run is requested.",
383
+ description: "Create PETSCII art from prompts or text, optionally run it on the C64, and return metadata including PETSCII codes and glyphs. See c64://specs/basic, c64://specs/vic, and c64://specs/charset.",
384
+ summary: "Synthesises PETSCII art, generates a BASIC program with preview metadata (petsciiCodes, glyphs, dimensions), and uploads it unless dry-run is requested.",
385
385
  inputSchema: petsciiImageArgsSchema.jsonSchema,
386
- relatedResources: ["c64://specs/basic", "c64://specs/vic"],
386
+ relatedResources: ["c64://specs/basic", "c64://specs/vic", "c64://specs/charset"],
387
387
  relatedPrompts: ["graphics-demo", "basic-program"],
388
- tags: ["petscii", "basic"],
388
+ tags: ["petscii", "basic", "pal-ntsc"],
389
389
  prerequisites: ["upload_and_run_basic"],
390
390
  examples: [
391
391
  {
@@ -396,6 +396,7 @@ export const graphicsModule = defineToolModule({
396
396
  ],
397
397
  workflowHints: [
398
398
  "Trigger when the user provides creative prompts; clarify whether you ran the art or left it as a dry run.",
399
+ "Response includes petsciiCodes array and glyphs for character-level inspection.",
399
400
  "Provide follow-up suggestions like saving the PRG or capturing the screen after rendering.",
400
401
  ],
401
402
  async execute(args, ctx) {
@@ -79,7 +79,7 @@ export const memoryModule = defineToolModule({
79
79
  relatedResources: ["c64://context/bootstrap", "c64://specs/basic"],
80
80
  relatedPrompts: ["memory-debug", "basic-program", "assembly-program"],
81
81
  tags: ["screen", "memory"],
82
- prerequisites: ["upload_and_run_basic"],
82
+ prerequisites: [],
83
83
  examples: [
84
84
  {
85
85
  name: "Capture screen",
@@ -0,0 +1,256 @@
1
+ /*
2
+ C64 Bridge - An MCP Server for the Commodore 64 Ultimate
3
+ Copyright (C) 2025 Christian Gleissner
4
+
5
+ Licensed under the GNU General Public License v2.0 or later.
6
+ See <https://www.gnu.org/licenses/> for details.
7
+ */
8
+ /**
9
+ * Load poll configuration from environment variables with defaults.
10
+ */
11
+ export function loadPollConfig() {
12
+ // In test environments with mock target, use shorter timeouts to avoid interfering with test screen queues
13
+ const isTestMode = process.env.C64_TEST_TARGET === "mock" || process.env.NODE_ENV === "test";
14
+ const defaultMaxMs = isTestMode ? 100 : 2000;
15
+ const defaultIntervalMs = isTestMode ? 30 : 200;
16
+ const maxMs = parseInt(process.env.C64BRIDGE_POLL_MAX_MS ?? String(defaultMaxMs), 10);
17
+ const intervalMs = parseInt(process.env.C64BRIDGE_POLL_INTERVAL_MS ?? String(defaultIntervalMs), 10);
18
+ return {
19
+ maxMs: Number.isFinite(maxMs) && maxMs > 0 ? maxMs : defaultMaxMs,
20
+ intervalMs: Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : defaultIntervalMs,
21
+ };
22
+ }
23
+ /**
24
+ * Wait for a specified duration.
25
+ */
26
+ function delay(ms) {
27
+ return new Promise((resolve) => setTimeout(resolve, ms));
28
+ }
29
+ /**
30
+ * Compute SHA-1 hash of screen content for change detection.
31
+ */
32
+ /**
33
+ * Compute fast 32-bit CRC32 checksum of buffer for change detection.
34
+ */
35
+ function computeCrc32(buffer) {
36
+ let crc = 0xFFFFFFFF;
37
+ for (let i = 0; i < buffer.length; i++) {
38
+ crc ^= buffer[i];
39
+ for (let j = 0; j < 8; j++) {
40
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0);
41
+ }
42
+ }
43
+ return (crc ^ 0xFFFFFFFF) >>> 0;
44
+ }
45
+ /**
46
+ * Compare two Uint8Arrays for equality.
47
+ */
48
+ function buffersEqual(a, b) {
49
+ if (a.length !== b.length)
50
+ return false;
51
+ for (let i = 0; i < a.length; i++) {
52
+ if (a[i] !== b[i])
53
+ return false;
54
+ }
55
+ return true;
56
+ }
57
+ /**
58
+ * Concatenate multiple Uint8Arrays into a single buffer.
59
+ */
60
+ function concatBuffers(...buffers) {
61
+ const totalLength = buffers.reduce((sum, buf) => sum + buf.length, 0);
62
+ const result = new Uint8Array(totalLength);
63
+ let offset = 0;
64
+ for (const buf of buffers) {
65
+ result.set(buf, offset);
66
+ offset += buf.length;
67
+ }
68
+ return result;
69
+ }
70
+ /**
71
+ * Extract BASIC error information from screen text.
72
+ * Looks for patterns like "?SYNTAX ERROR" or "SYNTAX ERROR IN 120".
73
+ */
74
+ function extractBasicError(screen) {
75
+ const upperScreen = screen.toUpperCase();
76
+ // Check if ERROR appears on screen
77
+ if (!upperScreen.includes("ERROR")) {
78
+ return null;
79
+ }
80
+ // Try to match "?ERROR_TYPE ERROR" or "ERROR_TYPE ERROR IN LINE"
81
+ const errorMatch = /\?([A-Z\s]+)\s+ERROR(?:\s+IN\s+(\d+))?/i.exec(screen);
82
+ if (errorMatch) {
83
+ const errorType = errorMatch[1]?.trim().replace(/\s+/g, " ");
84
+ const lineStr = errorMatch[2];
85
+ const line = lineStr ? parseInt(lineStr, 10) : undefined;
86
+ return {
87
+ message: errorType,
88
+ line: Number.isFinite(line) ? line : undefined,
89
+ };
90
+ }
91
+ // Fallback: just "ERROR" appears
92
+ return { message: "UNKNOWN ERROR" };
93
+ }
94
+ /**
95
+ * Poll for BASIC program outcome by detecting errors on screen.
96
+ * First waits for RUN or ERROR to appear, then continues polling for errors.
97
+ */
98
+ async function pollBasicOutcome(client, logger, config) {
99
+ const startTime = Date.now();
100
+ let pollCount = 0;
101
+ let runDetected = false;
102
+ logger.debug("Starting BASIC outcome polling", { maxMs: config.maxMs, intervalMs: config.intervalMs });
103
+ while (Date.now() - startTime < config.maxMs) {
104
+ pollCount++;
105
+ try {
106
+ const screen = await client.readScreen();
107
+ const upperScreen = screen.toUpperCase();
108
+ // Check if RUN or ERROR appeared (indicating execution started or failed)
109
+ if (!runDetected && (upperScreen.includes("RUN") || upperScreen.includes("ERROR"))) {
110
+ runDetected = true;
111
+ logger.debug("Program execution detected", { pollCount, elapsed: Date.now() - startTime });
112
+ }
113
+ // Check for errors
114
+ const errorInfo = extractBasicError(screen);
115
+ if (errorInfo) {
116
+ logger.debug("BASIC error detected", { errorInfo, pollCount, elapsed: Date.now() - startTime });
117
+ return {
118
+ status: "error",
119
+ type: "BASIC",
120
+ message: errorInfo.message,
121
+ line: errorInfo.line,
122
+ };
123
+ }
124
+ // If we detected RUN and no error yet, continue polling
125
+ if (runDetected) {
126
+ // Continue polling for a bit to catch any delayed errors
127
+ // but if we've been polling for a while without errors, consider it successful
128
+ }
129
+ }
130
+ catch (error) {
131
+ logger.debug("Failed to read screen during BASIC polling", { error, pollCount });
132
+ }
133
+ await delay(config.intervalMs);
134
+ }
135
+ logger.debug("BASIC polling completed without errors", { pollCount, elapsed: Date.now() - startTime });
136
+ return {
137
+ status: "ok",
138
+ type: "BASIC",
139
+ };
140
+ }
141
+ /**
142
+ * Poll for Assembly program outcome by detecting hardware and screen activity.
143
+ * Monitors VIC-II, SID, CIA, jiffy clock, screen memory, and low memory (zero page + stack)
144
+ * to determine if program is alive or crashed.
145
+ * First waits for RUN to appear, then monitors for activity across multiple memory regions.
146
+ */
147
+ async function pollAsmOutcome(client, logger, config) {
148
+ const startTime = Date.now();
149
+ let pollCount = 0;
150
+ let runDetected = false;
151
+ logger.debug("Starting ASM outcome polling with hardware monitoring", { maxMs: config.maxMs, intervalMs: config.intervalMs });
152
+ // Wait for RUN to appear first
153
+ while (Date.now() - startTime < config.maxMs && !runDetected) {
154
+ try {
155
+ const screen = await client.readScreen();
156
+ const upperScreen = screen.toUpperCase();
157
+ if (upperScreen.includes("RUN") || upperScreen.includes("ERROR")) {
158
+ runDetected = true;
159
+ logger.debug("RUN detected, starting 100ms stabilization", { elapsed: Date.now() - startTime });
160
+ // Wait 100ms for stabilization as per spec
161
+ await delay(100);
162
+ break;
163
+ }
164
+ }
165
+ catch (error) {
166
+ logger.debug("Failed to read screen while waiting for RUN", { error });
167
+ }
168
+ await delay(config.intervalMs);
169
+ }
170
+ // If RUN never appeared, assume instant execution (success)
171
+ if (!runDetected) {
172
+ logger.debug("RUN never detected, assuming instant execution");
173
+ return {
174
+ status: "ok",
175
+ type: "ASM",
176
+ };
177
+ }
178
+ // Now poll hardware regions to detect activity
179
+ let prevIoSignature = null;
180
+ let prevJiffyClock = null;
181
+ let alive = false;
182
+ const pollDeadline = Date.now() + config.maxMs;
183
+ while (Date.now() < pollDeadline && !alive) {
184
+ pollCount++;
185
+ try {
186
+ // Read I/O regions in optimized batches as per comment #3466803675
187
+ // All of $D000-$DFFF in a single call (covers VIC-II, SID, CIA1, CIA2)
188
+ const ioRegions = await client.readMemoryRaw(0xD000, 0x1000); // $D000-$DFFF
189
+ // Read low memory through screen RAM in a single call: $0000-$07E7
190
+ // This covers zero page, stack, jiffy clock ($00A0-$00A2), and screen memory
191
+ const lowMemAndScreen = await client.readMemoryRaw(0x0000, 0x7E8); // $0000-$07E7
192
+ // Extract jiffy clock for separate comparison (at offset $00A0 in the buffer)
193
+ const jiffyClock = lowMemAndScreen.slice(0xA0, 0xA3);
194
+ // Concatenate all regions and compute signature
195
+ const combinedBuffer = concatBuffers(ioRegions, lowMemAndScreen);
196
+ const ioSignature = computeCrc32(combinedBuffer);
197
+ // Check for any activity
198
+ if (prevIoSignature !== null && ioSignature !== prevIoSignature) {
199
+ logger.debug("Hardware or screen activity detected", {
200
+ pollCount,
201
+ elapsed: Date.now() - startTime,
202
+ signatureChanged: true
203
+ });
204
+ alive = true;
205
+ break;
206
+ }
207
+ if (prevJiffyClock !== null && !buffersEqual(jiffyClock, prevJiffyClock)) {
208
+ logger.debug("Jiffy clock advanced", {
209
+ pollCount,
210
+ elapsed: Date.now() - startTime,
211
+ clockChanged: true
212
+ });
213
+ alive = true;
214
+ break;
215
+ }
216
+ prevIoSignature = ioSignature;
217
+ prevJiffyClock = jiffyClock;
218
+ }
219
+ catch (error) {
220
+ logger.debug("Failed to read memory during ASM polling", { error, pollCount });
221
+ }
222
+ await delay(config.intervalMs);
223
+ }
224
+ if (alive) {
225
+ logger.debug("ASM program alive - hardware or screen progressing", { pollCount, elapsed: Date.now() - startTime });
226
+ return {
227
+ status: "ok",
228
+ type: "ASM",
229
+ };
230
+ }
231
+ // No activity detected - consider it crashed
232
+ logger.debug("ASM program crashed - no VIC/CIA/TI/screen progression", { pollCount, elapsed: Date.now() - startTime });
233
+ return {
234
+ status: "crashed",
235
+ type: "ASM",
236
+ reason: "no VIC/CIA/TI/screen progression within window",
237
+ };
238
+ }
239
+ /**
240
+ * Main polling function that polls for program outcome.
241
+ *
242
+ * @param type - Type of program (BASIC or ASM)
243
+ * @param client - C64 client for screen reading
244
+ * @param logger - Logger for debug output
245
+ * @param config - Optional poll configuration (uses environment defaults if not provided)
246
+ * @returns Promise resolving to poll result
247
+ */
248
+ export async function pollForProgramOutcome(type, client, logger, config) {
249
+ const pollConfig = config ?? loadPollConfig();
250
+ logger.debug("Starting program outcome polling", { type, maxMs: pollConfig.maxMs, intervalMs: pollConfig.intervalMs });
251
+ // Poll for program outcome based on type
252
+ if (type === "BASIC") {
253
+ return pollBasicOutcome(client, logger, pollConfig);
254
+ }
255
+ return pollAsmOutcome(client, logger, pollConfig);
256
+ }