c64bridge 1.0.1 → 1.0.3

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/mcp/tools.json CHANGED
@@ -1969,7 +1969,7 @@
1969
1969
  "required": [
1970
1970
  "op"
1971
1971
  ],
1972
- "description": "Sound operations available via the c64_sound tool.\n\nOperations:\n- capture_samples: Capture raw stereo PCM samples from the C64 Ultimate audio UDP stream. Required inputs: none. Optional inputs: count, encoding.\n- set_volume: Set the SID master volume register at $D418 (0-15). Required inputs: volume.\n- reset: Soft or hard reset of SID registers to clear glitches. Required inputs: none. Optional inputs: hard.\n- note_on: Trigger a SID voice with configurable waveform, ADSR, and pitch. Required inputs: none. Optional inputs: voice, note, frequencyHz, system, waveform, pulseWidth, attack, decay, sustain, release.\n- note_off: Release a SID voice by clearing its gate bit. Required inputs: voice.\n- silence_all: Silence all SID voices with optional audio verification. Required inputs: none. Optional inputs: verify.\n- play_sid_file: Play a SID file stored on the Ultimate filesystem. Required inputs: path. Optional inputs: songnr.\n- play_mod_file: Play a MOD tracker module via the Ultimate SID player. Required inputs: path.\n- generate: Generate a lightweight SID arpeggio playback sequence. Required inputs: none. Optional inputs: root, pattern, steps, tempoMs, waveform, preset.\n- compile_play: Compile SIDWAVE or CPG source and optionally play it immediately. Required inputs: none. Optional inputs: sidwave, cpg, format, output, dryRun.\n- play_preset: Compile and play a built-in SID preset such as Für Elise by Beethoven. Required inputs: none. Optional inputs: preset, platforms, verify, analysisDurationSeconds, waitBeforeCaptureMs, restoreActiveBackend.\n- pipeline: Compile a SIDWAVE score, play it, and analyze the recording. Required inputs: none. Optional inputs: sidwave, cpg, output, waitBeforeCaptureMs, analysisDurationSeconds, expectedSidwave, verifySilenceBefore, verifySilenceAfter, silenceDurationSeconds, silenceRmsThreshold, postSilenceWaitMs, silenceWaitMs.\n- analyze: Automatically analyze SID playback when verification is requested. Required inputs: request. Optional inputs: durationSeconds, expectedSidwave.\n- record_analyze: Record audio for a fixed duration and return SID analysis metrics. Required inputs: durationSeconds. Optional inputs: expectedSidwave.",
1972
+ "description": "Sound operations available via the c64_sound tool.\n\nOperations:\n- capture_samples: Capture raw stereo PCM samples from the C64 Ultimate audio UDP stream. Required inputs: none. Optional inputs: count, encoding.\n- set_volume: Set the SID master volume register at $D418 (0-15). Required inputs: volume.\n- reset: Soft or hard reset of SID registers to clear glitches. Required inputs: none. Optional inputs: hard.\n- note_on: Trigger a SID voice with configurable waveform, ADSR, and pitch. Required inputs: none. Optional inputs: voice, note, frequencyHz, system, waveform, pulseWidth, attack, decay, sustain, release.\n- note_off: Release a SID voice by clearing its gate bit. Required inputs: voice.\n- silence_all: Silence all SID voices with optional audio verification. Required inputs: none. Optional inputs: verify.\n- play_sid_file: Play a SID file stored on the Ultimate filesystem. Required inputs: path. Optional inputs: songnr.\n- play_mod_file: Play a MOD tracker module via the Ultimate SID player. Required inputs: path.\n- generate: Generate a lightweight SID arpeggio playback sequence. Required inputs: none. Optional inputs: root, pattern, steps, tempoMs, waveform, preset.\n- compile_play: Compile SIDWAVE or CPG source and optionally play it immediately. Required inputs: none. Optional inputs: sidwave, cpg, format, output, dryRun.\n- play_preset: Compile and play a built-in SID preset such as Für Elise by Beethoven. Required inputs: none. Optional inputs: preset, platforms, verify, analysisDurationSeconds, waitBeforeCaptureMs, restoreActiveBackend.\n- pipeline: Compile a SIDWAVE score, play it, and analyze the recording. Required inputs: none. Optional inputs: sidwave, cpg, output, waitBeforeCaptureMs, analysisDurationSeconds, expectedSidwave, verifySilenceBefore, verifySilenceAfter, silenceDurationSeconds, silenceRmsThreshold, postSilenceWaitMs, silenceWaitMs.\n- analyze: Automatically analyze SID playback when verification is requested. Required inputs: request. Optional inputs: durationSeconds, expectedSidwave.\n- record_analyze: Record audio for a fixed duration and return SID analysis metrics. Uses the Ultimate audio stream on c64u and the host microphone on other backends. Required inputs: durationSeconds. Optional inputs: expectedSidwave.",
1973
1973
  "additionalProperties": false,
1974
1974
  "x-c64bridge-operations": [
1975
1975
  {
@@ -2725,7 +2725,7 @@
2725
2725
  },
2726
2726
  {
2727
2727
  "op": "record_analyze",
2728
- "description": "Record audio for a fixed duration and return SID analysis metrics.",
2728
+ "description": "Record audio for a fixed duration and return SID analysis metrics. Uses the Ultimate audio stream on c64u and the host microphone on other backends.",
2729
2729
  "required": [
2730
2730
  "durationSeconds"
2731
2731
  ],
@@ -2734,7 +2734,7 @@
2734
2734
  ],
2735
2735
  "inputSchema": {
2736
2736
  "type": "object",
2737
- "description": "Record audio for a fixed duration and return SID analysis metrics.",
2737
+ "description": "Record audio for a fixed duration and return SID analysis metrics. Uses the Ultimate audio stream on c64u and the host microphone on other backends.",
2738
2738
  "properties": {
2739
2739
  "durationSeconds": {
2740
2740
  "type": "number",
package/mcp.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c64bridge",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Stdio-based Model Context Protocol (MCP) server that bridges Commodore 64 Ultimate hardware with AI agents.",
5
5
  "entry": "dist/index.js",
6
6
  "type": "stdio",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c64bridge",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "mcpName": "io.github.chrisgleissner/c64bridge",
5
5
  "description": "C64 Bridge MCP server for Commodore 64 Ultimate hardware and VICE emulator control",
6
6
  "repository": {
@@ -68,10 +68,10 @@
68
68
  "node": ">=24"
69
69
  },
70
70
  "dependencies": {
71
- "@modelcontextprotocol/sdk": "^1.29.0",
72
- "axios": "^1.18.1",
71
+ "@modelcontextprotocol/sdk": "^1.32.1",
72
+ "axios": "^1.20.0",
73
73
  "date-fns": "^4.4.0",
74
- "fastify": "^5.10.0",
74
+ "fastify": "^5.12.5",
75
75
  "jimp": "^1.6.1",
76
76
  "meyda": "^5.6.3",
77
77
  "octokit": "^5.0.5",
@@ -84,7 +84,7 @@
84
84
  },
85
85
  "devDependencies": {
86
86
  "@types/node": "^25.9.5",
87
- "tsx": "^4.23.0",
87
+ "tsx": "^4.23.11",
88
88
  "typescript": "^6.0.3"
89
89
  },
90
90
  "files": [
@@ -44,6 +44,9 @@ function createDefaultConfigs() {
44
44
  Video: {
45
45
  Mode: "PAL",
46
46
  },
47
+ "U64 Specific Settings": {
48
+ "System Mode": "PAL",
49
+ },
47
50
  };
48
51
  }
49
52
 
@@ -127,6 +130,14 @@ function createInitialState() {
127
130
  },
128
131
  lastStreamAction: null,
129
132
  streamActionLog: [],
133
+ // Number of upcoming stream:start requests answered like firmware with an empty ARP entry.
134
+ streamHostResolveFailures: 0,
135
+ // When set, the audio emitter stops after this many packets to imitate UDP packet loss.
136
+ audioPacketLimit: null,
137
+ audioSignal: null,
138
+ streamStartResponseDelayMs: 0,
139
+ // Unlike the one-shot failure counter, this gate stays closed until a test's ping stub primes it.
140
+ streamNeighborReady: true,
130
141
  };
131
142
  }
132
143
 
@@ -329,7 +340,10 @@ export async function startMockC64Server(options = {}) {
329
340
  }
330
341
 
331
342
  const sendAudioPacket = () => {
332
- const packet = buildMockAudioPacket(runtime.sequence, runtime.sampleIndex);
343
+ if (state.audioPacketLimit !== null && state.streams.audio.packetsSent >= state.audioPacketLimit) {
344
+ return;
345
+ }
346
+ const packet = buildMockAudioPacket(runtime.sequence, runtime.sampleIndex, state.audioSignal);
333
347
  runtime.sequence = (runtime.sequence + 1) & 0xffff;
334
348
  runtime.sampleIndex += 192;
335
349
  socket.send(packet, port, host);
@@ -713,7 +727,7 @@ export async function startMockC64Server(options = {}) {
713
727
 
714
728
  if (url.startsWith("/v1/configs/")) {
715
729
  const routeUrl = new URL(url, "http://mock.local");
716
- const segments = routeUrl.pathname.split("/").filter(Boolean).slice(2); // remove v1 + configs
730
+ const segments = routeUrl.pathname.split("/").filter(Boolean).slice(2).map((segment) => decodeURIComponent(segment)); // remove v1 + configs
717
731
 
718
732
  if (segments.length === 1) {
719
733
  const [category] = segments;
@@ -730,7 +744,8 @@ export async function startMockC64Server(options = {}) {
730
744
  if (method === "GET") {
731
745
  const categoryData = state.configs[category] ?? {};
732
746
  const value = categoryData[item];
733
- sendJson(res, { value });
747
+ // Firmware nests the item as { "<category>": { "<item>": { current } } }; `value` is kept for older callers.
748
+ sendJson(res, { value, [category]: { [item]: { current: value } } });
734
749
  return;
735
750
  }
736
751
 
@@ -762,10 +777,20 @@ export async function startMockC64Server(options = {}) {
762
777
  if (action === "start" && method === "PUT") {
763
778
  const body = await readJson(req);
764
779
  const target = routeUrl.searchParams.get("ip") ?? routeUrl.searchParams.get("target") ?? body?.ip ?? body?.target ?? null;
780
+ const neighborReady = typeof state.streamNeighborReady === "function" ? state.streamNeighborReady(target) : state.streamNeighborReady;
781
+ if (!neighborReady || state.streamHostResolveFailures > 0) {
782
+ if (state.streamHostResolveFailures > 0) state.streamHostResolveFailures -= 1;
783
+ state.streamActionLog.push({ action: "start-rejected", stream, target });
784
+ sendJson(res, { errors: ["Network Host Resolve Error"] }, 404);
785
+ return;
786
+ }
765
787
  state.streams[stream] = { active: true, target, packetsSent: 0 };
766
788
  state.lastStreamAction = { action: "start", stream, target };
767
789
  state.streamActionLog.push({ action: "start", stream, target });
768
790
  startStreamEmitter(stream, target);
791
+ if (state.streamStartResponseDelayMs > 0) {
792
+ await new Promise((resolve) => setTimeout(resolve, state.streamStartResponseDelayMs));
793
+ }
769
794
  sendJson(res, { result: "started", stream, target });
770
795
  return;
771
796
  }
@@ -994,15 +1019,17 @@ function buildMockVideoFramePackets(frameNumber, sequenceStart) {
994
1019
  return packets;
995
1020
  }
996
1021
 
997
- function buildMockAudioPacket(sequence, sampleIndex) {
1022
+ function buildMockAudioPacket(sequence, sampleIndex, signal = null) {
998
1023
  const header = Buffer.alloc(2);
999
1024
  header.writeUInt16LE(sequence & 0xffff, 0);
1000
1025
 
1001
1026
  const payload = Buffer.alloc(768);
1002
1027
  for (let index = 0; index < 192; index += 1) {
1003
1028
  const phase = ((sampleIndex + index) % 256) - 128;
1004
- const left = phase * 128;
1005
- const right = -left;
1029
+ // Both channels carry the same sawtooth plus a different constant offset, like the real stream.
1030
+ const wave = signal ? Math.sin(2 * Math.PI * signal.frequency * (sampleIndex + index) / signal.sampleRate) : phase * 128;
1031
+ const left = signal ? Math.round(wave * signal.amplitude + signal.leftOffset) : wave + 8000;
1032
+ const right = signal ? Math.round(wave * signal.amplitude * signal.rightGain + signal.rightOffset) : wave + 5000;
1006
1033
  payload.writeInt16LE(left, index * 4);
1007
1034
  payload.writeInt16LE(right, index * 4 + 2);
1008
1035
  }
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
7
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
8
+ import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
9
+ import { validateReleaseMetadata } from './validate-release.mjs';
10
+ import { startMockC64Server } from './mockC64Server.mjs';
11
+
12
+ const packageDir = path.resolve(process.argv[2]);
13
+ const version = process.argv[3];
14
+ validateReleaseMetadata(packageDir, version, { packed: true });
15
+ const temp = mkdtempSync(path.join(os.tmpdir(), 'c64bridge-release-smoke-'));
16
+ const mock = await startMockC64Server();
17
+ const config = path.join(temp, 'config.json');
18
+ writeFileSync(config, JSON.stringify({ c64u: { baseUrl: mock.baseUrl } }));
19
+ mock.state.audioSignal = { frequency: 440, sampleRate: 47982.8869047619, amplitude: 4000, leftOffset: 26900, rightOffset: 20000, rightGain: -1 };
20
+ const client = new Client({ name: 'c64bridge-release-validation', version: '1.0.0' }, { capabilities: {} });
21
+ const transport = new StdioClientTransport({
22
+ command: process.execPath,
23
+ args: [path.join(packageDir, 'scripts/cli.js')],
24
+ // Launch from a foreign directory, as an installed npm consumer would.
25
+ cwd: temp,
26
+ env: { ...process.env, C64BRIDGE_CONFIG: config, C64_MODE: 'c64u', C64BRIDGE_DISABLE_DIAGNOSTICS: '1' },
27
+ stderr: 'pipe',
28
+ });
29
+ let stderr = '';
30
+ transport.stderr?.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-16384); });
31
+ try {
32
+ await client.connect(transport);
33
+ assert.equal(client.getServerVersion().version, version, 'MCP runtime version differs from artifact');
34
+ const tools = await client.listTools();
35
+ for (const name of ['c64_program', 'c64_memory', 'c64_sound', 'c64_graphics']) {
36
+ assert.ok(tools.tools.some((tool) => tool.name === name), `Missing packaged tool ${name}`);
37
+ }
38
+ const call = async (name, args) => {
39
+ const result = await client.request({ method: 'tools/call', params: { name, arguments: args } }, CallToolResultSchema);
40
+ assert.notEqual(result.isError, true, JSON.stringify(result));
41
+ return result;
42
+ };
43
+ mock.state.streamHostResolveFailures = 1;
44
+ const samples = await call('c64_sound', { op: 'capture_samples', count: 256 });
45
+ assert.equal(samples.metadata.samplePairs, 256);
46
+ await call('c64_graphics', { op: 'capture_frame' });
47
+ const recorded = await call('c64_sound', { op: 'record_analyze', durationSeconds: 0.5 });
48
+ const analysis = JSON.parse(recorded.content.find((entry) => entry.type === 'text').text).analysis;
49
+ assert.equal(analysis.source, 'ultimate-stream');
50
+ assert.ok(analysis.global_metrics.average_rms > 0.08, 'Stereo audio cancelled to silence');
51
+ assert.equal(analysis.voices[0].detected_notes.find((note) => note.note)?.note, 'A4');
52
+ assert.equal(mock.state.streams.audio.active, false);
53
+ assert.equal(mock.state.streams.video.active, false);
54
+ console.log(`Packed c64bridge@${version}: CLI, MCP version/discovery, video, ARP retry, and stereo audio verified.`);
55
+ } catch (error) {
56
+ console.error(stderr);
57
+ throw error;
58
+ } finally {
59
+ await client.close();
60
+ await mock.close();
61
+ rmSync(temp, { recursive: true, force: true });
62
+ }
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ export function validateReleaseMetadata(root, version, { packed = false } = {}) {
8
+ assert.match(version, /^\d+\.\d+\.\d+$/, 'Expected a stable release version');
9
+ const read = (file) => JSON.parse(readFileSync(path.join(root, file), 'utf8'));
10
+ const pkg = read('package.json');
11
+ assert.equal(pkg.name, 'c64bridge', 'Wrong npm package');
12
+ assert.equal(pkg.version, version, 'package.json version does not match release tag');
13
+ assert.equal(pkg.private, undefined, 'Release package must be public');
14
+ assert.equal(pkg.main, 'dist/index.js', 'Wrong compiled entry point');
15
+ assert.equal(pkg.bin.c64bridge, 'scripts/cli.js', 'Wrong CLI entry point');
16
+ assert.equal(read('mcp.json').version, version, 'mcp.json version mismatch');
17
+ assert.equal(read('mcp/server.json').version, version, 'Generated MCP metadata version mismatch');
18
+ const server = read('server.json');
19
+ assert.equal(server.name, pkg.mcpName, 'MCP Registry server name mismatch');
20
+ assert.equal(server.version, version, 'MCP Registry version mismatch');
21
+ assert.equal(server.packages[0].identifier, pkg.name, 'MCP Registry npm name mismatch');
22
+ assert.equal(server.packages[0].version, version, 'MCP Registry npm version mismatch');
23
+ if (!packed) {
24
+ const lock = read('package-lock.json');
25
+ assert.equal(lock.name, pkg.name, 'Lockfile package name mismatch');
26
+ assert.equal(lock.version, version, 'Lockfile version mismatch');
27
+ assert.equal(lock.packages[''].version, version, 'Lockfile root version mismatch');
28
+ for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
29
+ assert.deepEqual(lock.packages[''][section], pkg[section], `Lockfile ${section} differs from package.json`);
30
+ }
31
+ const changelog = readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8');
32
+ assert.match(changelog, new RegExp(`^## ${version.replaceAll('.', '\\.')} - \\d{4}-\\d{2}-\\d{2}$`, 'm'), 'Missing release changelog entry');
33
+ }
34
+ return pkg;
35
+ }
36
+
37
+ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
38
+ validateReleaseMetadata(process.cwd(), process.argv[2]);
39
+ console.log(`Release metadata agrees on c64bridge@${process.argv[2]}.`);
40
+ }
package/server.json CHANGED
@@ -8,12 +8,12 @@
8
8
  "source": "github"
9
9
  },
10
10
  "websiteUrl": "https://github.com/chrisgleissner/c64bridge",
11
- "version": "1.0.1",
11
+ "version": "1.0.3",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "c64bridge",
16
- "version": "1.0.1",
16
+ "version": "1.0.3",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  },