c64bridge 0.7.4 → 0.8.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 (107) hide show
  1. package/.github/prompts/README.md +14 -4
  2. package/.github/prompts/assembly-routine-session.prompt.md +9 -9
  3. package/.github/prompts/basic-program-session.prompt.md +11 -9
  4. package/.github/prompts/drive-management-session.prompt.md +9 -9
  5. package/.github/prompts/hello-world-session.prompt.md +17 -0
  6. package/.github/prompts/petscii-art-session.prompt.md +9 -9
  7. package/.github/prompts/play-classic-game.prompt.md +9 -9
  8. package/.github/prompts/pr-converge.prompt.md +13 -0
  9. package/.github/prompts/sid-music-session.prompt.md +9 -9
  10. package/AGENTS.md +59 -60
  11. package/README.md +550 -191
  12. package/data/assembly/assembly-spec.md +290 -1
  13. package/data/context/bootstrap.md +26 -2
  14. package/data/context/fast-paths.md +44 -0
  15. package/data/graphics/petscii-style-guide.md +6 -2
  16. package/data/graphics/sprite-charset-best-practices.md +5 -5
  17. package/dist/.tsbuildinfo +1 -1
  18. package/dist/c64Client.js +990 -26
  19. package/dist/config.js +5 -1
  20. package/dist/device.js +680 -57
  21. package/dist/diagnostics.js +371 -0
  22. package/dist/mcp/metadata.js +39 -0
  23. package/dist/mcp-server.js +170 -20
  24. package/dist/platform.js +10 -2
  25. package/dist/prompts/registry.js +166 -309
  26. package/dist/rag/init.js +37 -1
  27. package/dist/rag/knowledgeIndex.js +18 -6
  28. package/dist/rag/rateLimiter.js +2 -2
  29. package/dist/streamCapture.js +135 -0
  30. package/dist/tools/audio.js +3 -1
  31. package/dist/tools/debug.js +470 -0
  32. package/dist/tools/developer.js +10 -4
  33. package/dist/tools/graphics.js +183 -17
  34. package/dist/tools/machineControl.js +11 -5
  35. package/dist/tools/memory.js +19 -10
  36. package/dist/tools/meta/audio.js +240 -1
  37. package/dist/tools/meta/config.js +20 -28
  38. package/dist/tools/meta/configInventory.js +104 -0
  39. package/dist/tools/meta/diagnostics.js +45 -2
  40. package/dist/tools/meta/graphics.js +14 -6
  41. package/dist/tools/meta/index.js +1 -0
  42. package/dist/tools/meta/memory.js +20 -6
  43. package/dist/tools/meta/program.js +473 -0
  44. package/dist/tools/pollValidator.js +4 -2
  45. package/dist/tools/programRunners.js +8 -4
  46. package/dist/tools/registry/config.js +17 -0
  47. package/dist/tools/registry/disk.js +7 -0
  48. package/dist/tools/registry/drive.js +3 -0
  49. package/dist/tools/registry/graphics.js +111 -27
  50. package/dist/tools/registry/index.js +6 -0
  51. package/dist/tools/registry/memory.js +1 -0
  52. package/dist/tools/registry/platform.js +121 -0
  53. package/dist/tools/registry/program.js +26 -8
  54. package/dist/tools/registry/sound.js +96 -0
  55. package/dist/tools/registry/system.js +15 -2
  56. package/dist/tools/storage.js +1 -0
  57. package/dist/tools/streaming.js +1 -0
  58. package/dist/tools/types.js +38 -4
  59. package/dist/tools/vice.js +160 -0
  60. package/dist/vicBitmap.js +317 -0
  61. package/dist/vice/mockServer.js +439 -0
  62. package/dist/vice/process.js +310 -0
  63. package/dist/vice/readiness.js +18 -2
  64. package/dist/vice/viceClient.js +382 -22
  65. package/doc/developer.md +62 -24
  66. package/doc/img/backend-switch/hello-c64u.png +0 -0
  67. package/doc/img/backend-switch/hello-vice-and-c64u.png +0 -0
  68. package/doc/img/backend-switch/hello-vice.png +0 -0
  69. package/doc/img/backend-switch/metadata.json +50 -0
  70. package/doc/plans/vice-backend-switch/plan.md +486 -0
  71. package/doc/plans/vice-backend-switch-2/plan.md +48 -0
  72. package/doc/plans/vice-support/notes.md +1 -0
  73. package/doc/plans/vice-support/plan.md +5 -0
  74. package/doc/plans/vice-support/rollout.md +23 -17
  75. package/doc/reviews/vice-support-review.md +185 -0
  76. package/doc/troubleshooting-mcp.md +35 -6
  77. package/doc/vice/support-matrix.md +54 -0
  78. package/mcp/prompts.json +292 -0
  79. package/mcp/protocol-examples.json +46 -0
  80. package/mcp/resources.json +940 -0
  81. package/mcp/schemas/c64_config.schema.json +350 -0
  82. package/mcp/schemas/c64_debug.schema.json +380 -0
  83. package/mcp/schemas/c64_disk.schema.json +278 -0
  84. package/mcp/schemas/c64_drive.schema.json +120 -0
  85. package/mcp/schemas/c64_extract.schema.json +302 -0
  86. package/mcp/schemas/c64_graphics.schema.json +333 -0
  87. package/mcp/schemas/c64_memory.schema.json +155 -0
  88. package/mcp/schemas/c64_printer.schema.json +218 -0
  89. package/mcp/schemas/c64_program.schema.json +391 -0
  90. package/mcp/schemas/c64_rag.schema.json +67 -0
  91. package/mcp/schemas/c64_select_backend.schema.json +32 -0
  92. package/mcp/schemas/c64_sound.schema.json +644 -0
  93. package/mcp/schemas/c64_stream.schema.json +61 -0
  94. package/mcp/schemas/c64_system.schema.json +227 -0
  95. package/mcp/schemas/c64_vice.schema.json +63 -0
  96. package/mcp/server.json +15 -0
  97. package/mcp/tools.json +4517 -0
  98. package/mcp.json +83 -9
  99. package/package.json +8 -3
  100. package/scripts/capture-backend-switch-screenshots.mjs +276 -0
  101. package/scripts/generate-mcp-interface.ts +289 -0
  102. package/scripts/invoke-bun.mjs +67 -21
  103. package/scripts/mockC64Server.mjs +162 -6
  104. package/scripts/run-coverage.mjs +284 -0
  105. package/scripts/run-tests.mjs +95 -39
  106. package/scripts/run-tests.ts +378 -73
  107. package/scripts/update-readme.ts +147 -13
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { accessSync, constants } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import process from "node:process";
7
+ import { fileURLToPath } from "node:url";
8
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
9
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
10
+ import {
11
+ LATEST_PROTOCOL_VERSION,
12
+ ListPromptsResultSchema,
13
+ ListResourcesResultSchema,
14
+ ListToolsResultSchema,
15
+ } from "@modelcontextprotocol/sdk/types.js";
16
+ import { startMockC64Server } from "./mockC64Server.mjs";
17
+ import { getMcpProjectMetadata } from "../src/mcp/metadata.js";
18
+
19
+ type JsonObject = Record<string, unknown>;
20
+
21
+ interface ToolDescriptorLike {
22
+ readonly name: string;
23
+ readonly description?: string;
24
+ readonly inputSchema?: unknown;
25
+ readonly metadata?: {
26
+ readonly examples?: readonly {
27
+ readonly name?: string;
28
+ readonly description?: string;
29
+ readonly arguments?: Record<string, unknown>;
30
+ }[];
31
+ };
32
+ }
33
+
34
+ interface GenerateOptions {
35
+ readonly outputDir?: string;
36
+ }
37
+
38
+ const __filename = fileURLToPath(import.meta.url);
39
+ const __dirname = path.dirname(__filename);
40
+ const PROJECT_ROOT = path.resolve(__dirname, "..");
41
+ const DEFAULT_OUTPUT_DIR = path.join(PROJECT_ROOT, "mcp");
42
+
43
+ function fileExists(filePath: string): boolean {
44
+ try {
45
+ accessSync(filePath, constants.F_OK);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ function resolveBunExecutable(): string {
53
+ if (typeof Bun !== "undefined") {
54
+ return process.execPath;
55
+ }
56
+ const candidates = [
57
+ process.env.BUN_BIN,
58
+ process.env.C64BRIDGE_TEST_BUN_BIN,
59
+ process.env.C64BRIDGE_BUN_BIN,
60
+ ];
61
+
62
+ for (const candidate of candidates) {
63
+ if (candidate && candidate.trim().length > 0) {
64
+ return candidate.trim();
65
+ }
66
+ }
67
+
68
+ return "bun";
69
+ }
70
+
71
+ function resolveNodeExecutable(): string {
72
+ const candidates = [
73
+ process.env.C64BRIDGE_TEST_NODE_BIN,
74
+ process.env.C64BRIDGE_NODE_BIN,
75
+ process.env.NODE_BINARY,
76
+ process.env.NODE_EXEC_PATH,
77
+ process.env.npm_node_execpath,
78
+ ];
79
+
80
+ for (const candidate of candidates) {
81
+ if (candidate && candidate.trim().length > 0) {
82
+ return candidate.trim();
83
+ }
84
+ }
85
+
86
+ return typeof Bun !== "undefined" ? "node" : process.execPath;
87
+ }
88
+
89
+ function resolveServerProcess(): { command: string; args: string[] } {
90
+ const sourceEntrypoint = path.join(PROJECT_ROOT, "src", "mcp-server.ts");
91
+ const distEntrypoint = path.join(PROJECT_ROOT, "dist", "mcp-server.js");
92
+
93
+ if (typeof Bun !== "undefined" && fileExists(sourceEntrypoint)) {
94
+ return { command: process.execPath, args: [sourceEntrypoint] };
95
+ }
96
+
97
+ if (fileExists(distEntrypoint)) {
98
+ return { command: resolveNodeExecutable(), args: [distEntrypoint] };
99
+ }
100
+
101
+ if (fileExists(sourceEntrypoint)) {
102
+ return { command: resolveBunExecutable(), args: [sourceEntrypoint] };
103
+ }
104
+
105
+ throw new Error("Unable to locate MCP server entry point (src/mcp-server.ts or dist/mcp-server.js).");
106
+ }
107
+
108
+ function json(value: unknown): string {
109
+ return `${JSON.stringify(value, null, 2)}\n`;
110
+ }
111
+
112
+ async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
113
+ await mkdir(path.dirname(filePath), { recursive: true });
114
+ await writeFile(filePath, json(value), "utf8");
115
+ }
116
+
117
+ function sanitizeFilename(input: string): string {
118
+ return input.replace(/[^a-zA-Z0-9._-]+/g, "-");
119
+ }
120
+
121
+ function buildProtocolExamples(tools: readonly ToolDescriptorLike[]) {
122
+ const selectedTool = tools.find((tool) => Array.isArray(tool.metadata?.examples) && tool.metadata.examples.length > 0)
123
+ ?? tools[0];
124
+ const selectedExample = selectedTool?.metadata?.examples?.[0];
125
+
126
+ return {
127
+ protocolVersion: LATEST_PROTOCOL_VERSION,
128
+ examples: {
129
+ initialize: {
130
+ description: "Client initialization request sent immediately after connecting to the stdio MCP server.",
131
+ request: {
132
+ jsonrpc: "2.0",
133
+ id: 1,
134
+ method: "initialize",
135
+ params: {
136
+ protocolVersion: LATEST_PROTOCOL_VERSION,
137
+ capabilities: {
138
+ resources: {},
139
+ tools: {},
140
+ prompts: {},
141
+ },
142
+ clientInfo: {
143
+ name: "c64bridge-static-interface-generator",
144
+ version: "1.0.0",
145
+ },
146
+ },
147
+ },
148
+ },
149
+ "tools/list": {
150
+ description: "Enumerates the full tool catalog and input schemas exposed by the server.",
151
+ request: {
152
+ jsonrpc: "2.0",
153
+ id: 2,
154
+ method: "tools/list",
155
+ params: {},
156
+ },
157
+ },
158
+ "tools/call": {
159
+ description: selectedExample?.description
160
+ ?? `Invokes the ${selectedTool?.name ?? "tool"} tool with deterministic example arguments.`,
161
+ request: {
162
+ jsonrpc: "2.0",
163
+ id: 3,
164
+ method: "tools/call",
165
+ params: {
166
+ name: selectedTool?.name ?? "c64_program",
167
+ arguments: selectedExample?.arguments ?? {},
168
+ },
169
+ },
170
+ },
171
+ },
172
+ };
173
+ }
174
+
175
+ async function discoverMcpInterface() {
176
+ const mockServer = await startMockC64Server();
177
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "c64bridge-mcp-interface-"));
178
+ const configPath = path.join(tempDir, "config.json");
179
+ const serverProcess = resolveServerProcess();
180
+ const mockUrl = new URL(mockServer.baseUrl);
181
+ const configPayload = {
182
+ c64u: {
183
+ host: mockUrl.hostname,
184
+ port: mockUrl.port ? Number(mockUrl.port) : 80,
185
+ },
186
+ };
187
+
188
+ await writeFile(configPath, json(configPayload), "utf8");
189
+
190
+ const transport = new StdioClientTransport({
191
+ command: serverProcess.command,
192
+ args: serverProcess.args,
193
+ cwd: PROJECT_ROOT,
194
+ env: {
195
+ ...process.env,
196
+ NODE_ENV: "test",
197
+ LOG_LEVEL: "error",
198
+ C64_TEST_TARGET: "mock",
199
+ C64BRIDGE_CONFIG: configPath,
200
+ },
201
+ stderr: "pipe",
202
+ });
203
+
204
+ const client = new Client(
205
+ { name: "c64bridge-static-interface-generator", version: "1.0.0" },
206
+ {
207
+ capabilities: {
208
+ resources: {},
209
+ tools: {},
210
+ prompts: {},
211
+ },
212
+ },
213
+ );
214
+
215
+ try {
216
+ await client.connect(transport);
217
+
218
+ const toolsList = await client.request({ method: "tools/list", params: {} }, ListToolsResultSchema);
219
+ const resourcesList = await client.request({ method: "resources/list", params: {} }, ListResourcesResultSchema);
220
+ const promptsList = await client.request({ method: "prompts/list", params: {} }, ListPromptsResultSchema);
221
+
222
+ return {
223
+ serverInfo: client.getServerVersion(),
224
+ capabilities: client.getServerCapabilities(),
225
+ toolsList,
226
+ resourcesList,
227
+ promptsList,
228
+ };
229
+ } finally {
230
+ await client.close().catch(() => undefined);
231
+ await mockServer.close().catch(() => undefined);
232
+ await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
233
+ }
234
+ }
235
+
236
+ export async function generateMcpInterface(options: GenerateOptions = {}): Promise<void> {
237
+ const outputDir = options.outputDir ? path.resolve(options.outputDir) : DEFAULT_OUTPUT_DIR;
238
+ const schemasDir = path.join(outputDir, "schemas");
239
+ const snapshot = await discoverMcpInterface();
240
+ const projectMetadata = getMcpProjectMetadata();
241
+
242
+ await rm(outputDir, { recursive: true, force: true });
243
+ await mkdir(schemasDir, { recursive: true });
244
+
245
+ const serverJson: JsonObject = {
246
+ name: snapshot.serverInfo?.name ?? projectMetadata.name,
247
+ version: snapshot.serverInfo?.version ?? projectMetadata.version,
248
+ description: projectMetadata.description,
249
+ transports: [...projectMetadata.transports],
250
+ capabilities: snapshot.capabilities ?? {},
251
+ };
252
+
253
+ if (projectMetadata.repository) {
254
+ serverJson.repository = projectMetadata.repository;
255
+ }
256
+ if (projectMetadata.license) {
257
+ serverJson.license = projectMetadata.license;
258
+ }
259
+
260
+ await writeJsonFile(path.join(outputDir, "server.json"), serverJson);
261
+ await writeJsonFile(path.join(outputDir, "tools.json"), snapshot.toolsList);
262
+ await writeJsonFile(path.join(outputDir, "resources.json"), snapshot.resourcesList);
263
+ await writeJsonFile(path.join(outputDir, "prompts.json"), snapshot.promptsList);
264
+ await writeJsonFile(
265
+ path.join(outputDir, "protocol-examples.json"),
266
+ buildProtocolExamples((snapshot.toolsList.tools ?? []) as readonly ToolDescriptorLike[]),
267
+ );
268
+
269
+ for (const tool of snapshot.toolsList.tools ?? []) {
270
+ if (tool.inputSchema === undefined) {
271
+ continue;
272
+ }
273
+ await writeJsonFile(
274
+ path.join(schemasDir, `${sanitizeFilename(tool.name)}.schema.json`),
275
+ tool.inputSchema,
276
+ );
277
+ }
278
+ }
279
+
280
+ async function main(): Promise<void> {
281
+ await generateMcpInterface();
282
+ }
283
+
284
+ if (import.meta.main) {
285
+ await main().catch((error) => {
286
+ console.error("[generate-mcp-interface] Failed:", error);
287
+ process.exitCode = 1;
288
+ });
289
+ }
@@ -3,13 +3,7 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { spawn } from "node:child_process";
6
-
7
- if (process.argv.length < 3) {
8
- console.error("Usage: node scripts/invoke-bun.mjs <command> [args...]");
9
- process.exit(1);
10
- }
11
-
12
- const bunArgs = process.argv.slice(2);
6
+ import { fileURLToPath } from "node:url";
13
7
 
14
8
  function resolveBunExecutable() {
15
9
  const candidates = [
@@ -41,18 +35,70 @@ function resolveBunExecutable() {
41
35
  return "bun";
42
36
  }
43
37
 
44
- const bunExecutable = resolveBunExecutable();
45
- const child = spawn(bunExecutable, bunArgs, {
46
- stdio: "inherit",
47
- env: {
48
- ...process.env,
49
- },
50
- });
51
-
52
- child.on("exit", (code, signal) => {
53
- if (signal) {
54
- process.kill(process.pid, signal);
55
- return;
38
+ const BUN_SUBCOMMANDS = new Set([
39
+ "test",
40
+ "run",
41
+ "x",
42
+ "install",
43
+ "add",
44
+ "remove",
45
+ "update",
46
+ "pm",
47
+ "build",
48
+ "create",
49
+ "exec",
50
+ "repl",
51
+ "help",
52
+ ]);
53
+
54
+ function looksLikeScriptPath(value) {
55
+ return typeof value === "string"
56
+ && value.length > 0
57
+ && !value.startsWith("-")
58
+ && /\.(?:[cm]?[jt]s|tsx?)$/i.test(value);
59
+ }
60
+
61
+ export function buildBunArgs(args) {
62
+ const [firstArg, ...rest] = args;
63
+ if (!firstArg) {
64
+ return [];
65
+ }
66
+
67
+ if (BUN_SUBCOMMANDS.has(firstArg) || !looksLikeScriptPath(firstArg)) {
68
+ return args;
56
69
  }
57
- process.exit(code ?? 1);
58
- });
70
+
71
+ return ["run", firstArg, ...rest];
72
+ }
73
+
74
+ function isMainModule(moduleUrl) {
75
+ const entry = process.argv[1];
76
+ if (!entry) {
77
+ return false;
78
+ }
79
+ return fileURLToPath(moduleUrl) === path.resolve(entry);
80
+ }
81
+
82
+ if (isMainModule(import.meta.url)) {
83
+ if (process.argv.length < 3) {
84
+ console.error("Usage: node scripts/invoke-bun.mjs <command> [args...]");
85
+ process.exit(1);
86
+ }
87
+
88
+ const rawArgs = process.argv.slice(2);
89
+ const bunExecutable = resolveBunExecutable();
90
+ const child = spawn(bunExecutable, buildBunArgs(rawArgs), {
91
+ stdio: "inherit",
92
+ env: {
93
+ ...process.env,
94
+ },
95
+ });
96
+
97
+ child.on("exit", (code, signal) => {
98
+ if (signal) {
99
+ process.kill(process.pid, signal);
100
+ return;
101
+ }
102
+ process.exit(code ?? 1);
103
+ });
104
+ }
@@ -1,7 +1,15 @@
1
1
  import { createServer } from "node:http";
2
+ import dgram from "node:dgram";
2
3
  import { once } from "node:events";
3
4
  import { Buffer } from "node:buffer";
4
- import { getChargenGlyphs } from "../dist/chargen.js";
5
+ import { existsSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const moduleDir = new URL(".", import.meta.url);
9
+ const distUrl = new URL("../dist/chargen.js", moduleDir);
10
+ const srcUrl = new URL("../src/chargen.js", moduleDir);
11
+ const chargenModuleUrl = existsSync(fileURLToPath(distUrl)) ? distUrl : srcUrl;
12
+ const { getChargenGlyphs } = await import(chargenModuleUrl.href);
5
13
 
6
14
  function parseNumeric(value, defaultRadix = 16) {
7
15
  if (!value) {
@@ -82,6 +90,7 @@ function createInitialState() {
82
90
  poweroffs: 0,
83
91
  memory: new Uint8Array(0x10000),
84
92
  lastWrite: null,
93
+ writeLog: [],
85
94
  lastRequest: null,
86
95
  drives: {},
87
96
  lastDriveOperation: null,
@@ -104,11 +113,20 @@ function createInitialState() {
104
113
  menuToggleCount: 0,
105
114
  lastMenuTarget: null,
106
115
  streams: {
107
- video: { active: false, target: null },
108
- audio: { active: false, target: null },
109
- debug: { active: false, target: null },
116
+ video: { active: false, target: null, packetsSent: 0 },
117
+ audio: { active: false, target: null, packetsSent: 0 },
118
+ debug: { active: false, target: null, packetsSent: 0 },
110
119
  },
111
120
  lastStreamAction: null,
121
+ streamActionLog: [],
122
+ };
123
+ }
124
+
125
+ function createStreamRuntime() {
126
+ return {
127
+ video: { interval: null, socket: null, sequence: 0, frameNumber: 0, sampleIndex: 0 },
128
+ audio: { interval: null, socket: null, sequence: 0, frameNumber: 0, sampleIndex: 0 },
129
+ debug: { interval: null, socket: null, sequence: 0, frameNumber: 0, sampleIndex: 0 },
112
130
  };
113
131
  }
114
132
 
@@ -148,6 +166,7 @@ function seedReadyPrompt(state) {
148
166
 
149
167
  export async function startMockC64Server(options = {}) {
150
168
  const state = createInitialState();
169
+ const streamRuntime = createStreamRuntime();
151
170
  state.networkPassword = typeof options.networkPassword === "string" && options.networkPassword.trim()
152
171
  ? options.networkPassword
153
172
  : null;
@@ -171,12 +190,72 @@ export async function startMockC64Server(options = {}) {
171
190
  ensureDriveState("drive8");
172
191
 
173
192
  function resetState() {
193
+ stopAllStreams();
174
194
  const fresh = createInitialState();
175
195
  Object.assign(state, fresh);
176
196
  seedReadyPrompt(state);
177
197
  ensureDriveState("drive8");
178
198
  }
179
199
 
200
+ function stopAllStreams() {
201
+ for (const stream of Object.keys(streamRuntime)) {
202
+ stopStreamEmitter(stream);
203
+ }
204
+ }
205
+
206
+ function stopStreamEmitter(stream) {
207
+ const runtime = streamRuntime[stream];
208
+ if (!runtime) {
209
+ return;
210
+ }
211
+ if (runtime.interval) {
212
+ clearInterval(runtime.interval);
213
+ runtime.interval = null;
214
+ }
215
+ if (runtime.socket) {
216
+ runtime.socket.close();
217
+ runtime.socket = null;
218
+ }
219
+ }
220
+
221
+ function startStreamEmitter(stream, target) {
222
+ stopStreamEmitter(stream);
223
+ if (!target || (stream !== "video" && stream !== "audio")) {
224
+ return;
225
+ }
226
+
227
+ const runtime = streamRuntime[stream];
228
+ const { host, port } = parseStreamTarget(target, stream === "video" ? 11000 : 11001);
229
+ const socket = dgram.createSocket("udp4");
230
+ runtime.socket = socket;
231
+
232
+ if (stream === "video") {
233
+ const sendFrame = () => {
234
+ const packets = buildMockVideoFramePackets(runtime.frameNumber, runtime.sequence);
235
+ runtime.frameNumber = (runtime.frameNumber + 1) & 0xffff;
236
+ runtime.sequence = (runtime.sequence + packets.length) & 0xffff;
237
+ for (const packet of packets) {
238
+ socket.send(packet, port, host);
239
+ state.streams.video.packetsSent += 1;
240
+ }
241
+ };
242
+ sendFrame();
243
+ runtime.interval = setInterval(sendFrame, 30);
244
+ return;
245
+ }
246
+
247
+ const sendAudioPacket = () => {
248
+ const packet = buildMockAudioPacket(runtime.sequence, runtime.sampleIndex);
249
+ runtime.sequence = (runtime.sequence + 1) & 0xffff;
250
+ runtime.sampleIndex += 192;
251
+ socket.send(packet, port, host);
252
+ state.streams.audio.packetsSent += 1;
253
+ };
254
+
255
+ sendAudioPacket();
256
+ runtime.interval = setInterval(sendAudioPacket, 4);
257
+ }
258
+
180
259
  const server = createServer(async (req, res) => {
181
260
  const { method, url } = req;
182
261
 
@@ -386,6 +465,7 @@ export async function startMockC64Server(options = {}) {
386
465
 
387
466
  state.memory.set(bytes, address);
388
467
  state.lastWrite = { address, bytes };
468
+ state.writeLog.push({ address, bytes: Buffer.from(bytes) });
389
469
 
390
470
  sendJson(res, { result: "wrote", address, length: bytes.length });
391
471
  return;
@@ -404,6 +484,7 @@ export async function startMockC64Server(options = {}) {
404
484
 
405
485
  state.memory.set(bytes, address);
406
486
  state.lastWrite = { address, bytes };
487
+ state.writeLog.push({ address, bytes: Buffer.from(bytes) });
407
488
 
408
489
  sendJson(res, { result: "wrote", address, length: bytes.length });
409
490
  return;
@@ -512,15 +593,19 @@ export async function startMockC64Server(options = {}) {
512
593
  if (action === "start" && method === "PUT") {
513
594
  const body = await readJson(req);
514
595
  const target = routeUrl.searchParams.get("ip") ?? routeUrl.searchParams.get("target") ?? body?.ip ?? body?.target ?? null;
515
- state.streams[stream] = { active: true, target };
596
+ state.streams[stream] = { active: true, target, packetsSent: 0 };
516
597
  state.lastStreamAction = { action: "start", stream, target };
598
+ state.streamActionLog.push({ action: "start", stream, target });
599
+ startStreamEmitter(stream, target);
517
600
  sendJson(res, { result: "started", stream, target });
518
601
  return;
519
602
  }
520
603
 
521
604
  if (action === "stop" && method === "PUT") {
522
- state.streams[stream] = { active: false, target: null };
605
+ stopStreamEmitter(stream);
606
+ state.streams[stream] = { active: false, target: null, packetsSent: state.streams[stream]?.packetsSent ?? 0 };
523
607
  state.lastStreamAction = { action: "stop", stream };
608
+ state.streamActionLog.push({ action: "stop", stream });
524
609
  sendJson(res, { result: "stopped", stream });
525
610
  return;
526
611
  }
@@ -678,9 +763,80 @@ export async function startMockC64Server(options = {}) {
678
763
  baseUrl: `http://127.0.0.1:${address.port}`,
679
764
  state,
680
765
  async close() {
766
+ stopAllStreams();
681
767
  server.close();
682
768
  await once(server, "close");
683
769
  },
684
770
  reset: resetState,
685
771
  };
686
772
  }
773
+
774
+ function parseStreamTarget(value, defaultPort) {
775
+ const trimmed = String(value ?? "").trim();
776
+ if (!trimmed) {
777
+ return { host: "127.0.0.1", port: defaultPort };
778
+ }
779
+ const separator = trimmed.lastIndexOf(":");
780
+ if (separator <= 0 || separator === trimmed.length - 1) {
781
+ return { host: trimmed, port: defaultPort };
782
+ }
783
+ const host = trimmed.slice(0, separator);
784
+ const port = Number.parseInt(trimmed.slice(separator + 1), 10);
785
+ return {
786
+ host,
787
+ port: Number.isFinite(port) && port > 0 && port <= 65535 ? port : defaultPort,
788
+ };
789
+ }
790
+
791
+ function buildMockVideoFramePackets(frameNumber, sequenceStart) {
792
+ const width = 384;
793
+ const height = 272;
794
+ const linesPerPacket = 4;
795
+ const bytesPerLine = width / 2;
796
+ const packets = [];
797
+ let sequence = sequenceStart & 0xffff;
798
+
799
+ for (let line = 0; line < height; line += linesPerPacket) {
800
+ const header = Buffer.alloc(12);
801
+ header.writeUInt16LE(sequence, 0);
802
+ header.writeUInt16LE(frameNumber & 0xffff, 2);
803
+ const isLastPacket = line + linesPerPacket >= height;
804
+ header.writeUInt16LE(isLastPacket ? (line | 0x8000) : line, 4);
805
+ header.writeUInt16LE(width, 6);
806
+ header.writeUInt8(linesPerPacket, 8);
807
+ header.writeUInt8(4, 9);
808
+ header.writeUInt16LE(0, 10);
809
+
810
+ const payload = Buffer.alloc(bytesPerLine * linesPerPacket);
811
+ let offset = 0;
812
+ for (let y = 0; y < linesPerPacket; y += 1) {
813
+ const absoluteLine = line + y;
814
+ for (let x = 0; x < width; x += 2) {
815
+ const low = (frameNumber + absoluteLine + x) & 0x0f;
816
+ const high = (frameNumber + absoluteLine + x + 1) & 0x0f;
817
+ payload[offset++] = low | (high << 4);
818
+ }
819
+ }
820
+
821
+ packets.push(Buffer.concat([header, payload]));
822
+ sequence = (sequence + 1) & 0xffff;
823
+ }
824
+
825
+ return packets;
826
+ }
827
+
828
+ function buildMockAudioPacket(sequence, sampleIndex) {
829
+ const header = Buffer.alloc(2);
830
+ header.writeUInt16LE(sequence & 0xffff, 0);
831
+
832
+ const payload = Buffer.alloc(768);
833
+ for (let index = 0; index < 192; index += 1) {
834
+ const phase = ((sampleIndex + index) % 256) - 128;
835
+ const left = phase * 128;
836
+ const right = -left;
837
+ payload.writeInt16LE(left, index * 4);
838
+ payload.writeInt16LE(right, index * 4 + 2);
839
+ }
840
+
841
+ return Buffer.concat([header, payload]);
842
+ }