c64bridge 0.7.0 → 0.7.2
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.json +1 -1
- package/package.json +2 -2
- package/scripts/compare-rest-apis.mjs +1 -1
- package/scripts/extract-changelog-entry.mjs +52 -0
- package/scripts/generate-changelog.mjs +75 -3
- package/scripts/mockC64Server.mjs +677 -0
- package/scripts/start-mock-c64.mjs +1 -1
- package/scripts/start-mock.mjs +1 -1
package/mcp.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c64bridge",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Local MCP server for controlling a Commodore 64 via Ultimate 64 REST API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"mcp": "node scripts/invoke-bun.mjs src/mcp-server.ts",
|
|
18
18
|
"mcp:node": "node dist/mcp-server.js",
|
|
19
19
|
"mcp:build": "npm run build && node dist/mcp-server.js",
|
|
20
|
-
|
|
20
|
+
"build": "node scripts/prebuild.mjs && tsc -p tsconfig.json && node scripts/postbuild.mjs && node scripts/invoke-bun.mjs scripts/update-readme.ts",
|
|
21
21
|
"lint": "npm run check",
|
|
22
22
|
"test": "node scripts/invoke-bun.mjs scripts/run-tests.ts",
|
|
23
23
|
"test:node": "node scripts/run-tests.mjs",
|
|
@@ -15,7 +15,7 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
15
15
|
import { Buffer } from "node:buffer";
|
|
16
16
|
import { performance } from "node:perf_hooks";
|
|
17
17
|
import YAML from "yaml";
|
|
18
|
-
import { startMockC64Server } from "
|
|
18
|
+
import { startMockC64Server } from "./mockC64Server.mjs";
|
|
19
19
|
|
|
20
20
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
21
|
const ROOT_DIR = path.resolve(__dirname, "..");
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Extract a specific version's changelog entry from CHANGELOG.md
|
|
4
|
+
* Usage: node scripts/extract-changelog-entry.mjs <version>
|
|
5
|
+
* Outputs the changelog section for the given version to stdout.
|
|
6
|
+
*/
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
const version = process.argv[2];
|
|
11
|
+
if (!version) {
|
|
12
|
+
console.error('Error: version argument required');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function escapeRegex(str) {
|
|
17
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const changelogPath = path.resolve('CHANGELOG.md');
|
|
21
|
+
let content;
|
|
22
|
+
try {
|
|
23
|
+
content = await readFile(changelogPath, 'utf8');
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error(`Error reading CHANGELOG.md: ${err.message}`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Match the version header (e.g., "## 0.7.0 - 2025-11-01")
|
|
30
|
+
const versionPattern = new RegExp(`^## ${escapeRegex(version)} - \\d{4}-\\d{2}-\\d{2}$`, 'm');
|
|
31
|
+
const match = content.match(versionPattern);
|
|
32
|
+
|
|
33
|
+
if (!match) {
|
|
34
|
+
console.error(`Error: changelog entry for version ${version} not found`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const startIndex = match.index;
|
|
39
|
+
// Find the next version header or end of file
|
|
40
|
+
const nextHeaderPattern = /^## \d+\.\d+\.\d+ - \d{4}-\d{2}-\d{2}$/m;
|
|
41
|
+
const remainingContent = content.slice(startIndex + match[0].length);
|
|
42
|
+
const nextMatch = remainingContent.match(nextHeaderPattern);
|
|
43
|
+
|
|
44
|
+
let entry;
|
|
45
|
+
if (nextMatch) {
|
|
46
|
+
entry = content.slice(startIndex, startIndex + match[0].length + nextMatch.index);
|
|
47
|
+
} else {
|
|
48
|
+
entry = content.slice(startIndex);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Trim trailing whitespace
|
|
52
|
+
console.log(entry.trimEnd());
|
|
@@ -26,12 +26,84 @@ const run = (cmd) => execSync(cmd, { encoding: 'utf8' }).trim();
|
|
|
26
26
|
const nowDate = new Date().toISOString().slice(0, 10);
|
|
27
27
|
const root = process.cwd();
|
|
28
28
|
|
|
29
|
-
function
|
|
29
|
+
function normaliseVersion(value) {
|
|
30
|
+
return value.startsWith('v') ? value.slice(1) : value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseSemver(value) {
|
|
34
|
+
const cleaned = normaliseVersion(value);
|
|
35
|
+
const parts = cleaned.split('.').map((n) => Number.parseInt(n, 10));
|
|
36
|
+
if (parts.length !== 3 || parts.some(Number.isNaN)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return { tag: value, cleaned, parts };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function compareParts(a, b) {
|
|
43
|
+
for (let i = 0; i < 3; i += 1) {
|
|
44
|
+
if (a.parts[i] !== b.parts[i]) {
|
|
45
|
+
return a.parts[i] - b.parts[i];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getLastTag(skipVersion = '') {
|
|
52
|
+
let raw;
|
|
30
53
|
try {
|
|
31
|
-
|
|
54
|
+
raw = run('git tag --sort=version:refname');
|
|
32
55
|
} catch {
|
|
33
56
|
return '';
|
|
34
57
|
}
|
|
58
|
+
|
|
59
|
+
const entries = raw
|
|
60
|
+
.split('\n')
|
|
61
|
+
.map((line) => line.trim())
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.map((tag) => parseSemver(tag))
|
|
64
|
+
.filter((info) => info !== null);
|
|
65
|
+
|
|
66
|
+
if (entries.length === 0) {
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const grouped = new Map();
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (!grouped.has(entry.cleaned)) {
|
|
73
|
+
grouped.set(entry.cleaned, []);
|
|
74
|
+
}
|
|
75
|
+
grouped.get(entry.cleaned).push(entry);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const ordered = Array.from(grouped.values())
|
|
79
|
+
.map((group) => ({ cleaned: group[0].cleaned, parts: group[0].parts, group }))
|
|
80
|
+
.sort((a, b) => compareParts(a, b));
|
|
81
|
+
|
|
82
|
+
if (ordered.length === 0) {
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!skipVersion) {
|
|
87
|
+
const latest = ordered[ordered.length - 1];
|
|
88
|
+
return (latest.group.find((entry) => !entry.tag.startsWith('v')) || latest.group[latest.group.length - 1]).tag;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const skipCleaned = normaliseVersion(skipVersion);
|
|
92
|
+
let previous = null;
|
|
93
|
+
for (const entry of ordered) {
|
|
94
|
+
if (entry.cleaned === skipCleaned) {
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
previous = entry;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (previous) {
|
|
101
|
+
return (previous.group.find((entry) => !entry.tag.startsWith('v')) || previous.group[previous.group.length - 1]).tag;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// If skip version was not found among tags, fall back to latest tag.
|
|
105
|
+
const fallback = ordered[ordered.length - 1];
|
|
106
|
+
return (fallback.group.find((entry) => !entry.tag.startsWith('v')) || fallback.group[fallback.group.length - 1]).tag;
|
|
35
107
|
}
|
|
36
108
|
|
|
37
109
|
function getCommits(range) {
|
|
@@ -90,7 +162,7 @@ function renderSection(version, date, groups) {
|
|
|
90
162
|
|
|
91
163
|
async function main() {
|
|
92
164
|
const version = process.argv[2] || '';
|
|
93
|
-
const lastTag = getLastTag();
|
|
165
|
+
const lastTag = getLastTag(version);
|
|
94
166
|
const range = lastTag ? `${lastTag}..HEAD` : '';
|
|
95
167
|
const rawCommits = getCommits(range)
|
|
96
168
|
.filter((c) => c.subject && !/^Merge\s/.test(c.subject));
|
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
import { getChargenGlyphs } from "../dist/chargen.js";
|
|
5
|
+
|
|
6
|
+
function parseNumeric(value, defaultRadix = 16) {
|
|
7
|
+
if (!value) {
|
|
8
|
+
return 0;
|
|
9
|
+
}
|
|
10
|
+
const trimmed = value.trim().toLowerCase();
|
|
11
|
+
if (trimmed.startsWith("$")) {
|
|
12
|
+
return Number.parseInt(trimmed.slice(1), 16);
|
|
13
|
+
}
|
|
14
|
+
if (trimmed.startsWith("0x")) {
|
|
15
|
+
return Number.parseInt(trimmed.slice(2), 16);
|
|
16
|
+
}
|
|
17
|
+
if (trimmed.startsWith("%")) {
|
|
18
|
+
return Number.parseInt(trimmed.slice(1), 2);
|
|
19
|
+
}
|
|
20
|
+
return Number.parseInt(trimmed, defaultRadix);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normaliseHexString(input) {
|
|
24
|
+
if (!input) {
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
return input.replace(/[^0-9a-fA-F]/g, "").toUpperCase();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createDefaultConfigs() {
|
|
31
|
+
return {
|
|
32
|
+
Audio: {
|
|
33
|
+
Volume: "6",
|
|
34
|
+
Balance: "center",
|
|
35
|
+
},
|
|
36
|
+
Video: {
|
|
37
|
+
Mode: "PAL",
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function readRequestBody(req) {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
for await (const chunk of req) {
|
|
45
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
46
|
+
}
|
|
47
|
+
if (chunks.length === 0) {
|
|
48
|
+
return Buffer.alloc(0);
|
|
49
|
+
}
|
|
50
|
+
return Buffer.concat(chunks);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readJson(req) {
|
|
54
|
+
try {
|
|
55
|
+
const body = await readRequestBody(req);
|
|
56
|
+
if (body.length === 0) {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
return JSON.parse(body.toString("utf8"));
|
|
60
|
+
} catch {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sendJson(res, payload = {}, statusCode = 200) {
|
|
66
|
+
res.statusCode = statusCode;
|
|
67
|
+
res.setHeader("Content-Type", "application/json");
|
|
68
|
+
const body = { ...payload };
|
|
69
|
+
if (!Object.prototype.hasOwnProperty.call(body, "errors")) {
|
|
70
|
+
body.errors = [];
|
|
71
|
+
}
|
|
72
|
+
res.end(JSON.stringify(body));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function createInitialState() {
|
|
76
|
+
return {
|
|
77
|
+
lastPrg: null,
|
|
78
|
+
runCount: 0,
|
|
79
|
+
resets: 0,
|
|
80
|
+
reboots: 0,
|
|
81
|
+
poweroffs: 0,
|
|
82
|
+
memory: new Uint8Array(0x10000),
|
|
83
|
+
lastWrite: null,
|
|
84
|
+
lastRequest: null,
|
|
85
|
+
drives: {},
|
|
86
|
+
lastDriveOperation: null,
|
|
87
|
+
createdImages: [],
|
|
88
|
+
lastFileInfo: null,
|
|
89
|
+
sidplayCount: 0,
|
|
90
|
+
lastSidplay: null,
|
|
91
|
+
sidAttachmentCount: 0,
|
|
92
|
+
lastSidAttachment: null,
|
|
93
|
+
modplayCount: 0,
|
|
94
|
+
lastModplay: null,
|
|
95
|
+
paused: false,
|
|
96
|
+
lastPause: null,
|
|
97
|
+
lastResume: null,
|
|
98
|
+
lastPoweroff: null,
|
|
99
|
+
debugreg: "00",
|
|
100
|
+
configs: createDefaultConfigs(),
|
|
101
|
+
flashSnapshot: null,
|
|
102
|
+
lastConfigAction: null,
|
|
103
|
+
menuToggleCount: 0,
|
|
104
|
+
lastMenuTarget: null,
|
|
105
|
+
streams: {
|
|
106
|
+
video: { active: false, target: null },
|
|
107
|
+
audio: { active: false, target: null },
|
|
108
|
+
debug: { active: false, target: null },
|
|
109
|
+
},
|
|
110
|
+
lastStreamAction: null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const SCREEN_CODE_LOOKUP = (() => {
|
|
115
|
+
const map = new Map();
|
|
116
|
+
for (const glyph of getChargenGlyphs()) {
|
|
117
|
+
if (!glyph || typeof glyph.screenCode !== "number") {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const code = glyph.screenCode & 0xff;
|
|
121
|
+
if (glyph.basic && glyph.basic.length === 1 && !map.has(glyph.basic)) {
|
|
122
|
+
map.set(glyph.basic, code);
|
|
123
|
+
}
|
|
124
|
+
if (!map.has(String.fromCharCode(glyph.petsciiCode & 0xff))) {
|
|
125
|
+
map.set(String.fromCharCode(glyph.petsciiCode & 0xff), code);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!map.has(" ")) {
|
|
129
|
+
map.set(" ", 0x20);
|
|
130
|
+
}
|
|
131
|
+
return map;
|
|
132
|
+
})();
|
|
133
|
+
|
|
134
|
+
function toScreenCode(char) {
|
|
135
|
+
return SCREEN_CODE_LOOKUP.get(char) ?? SCREEN_CODE_LOOKUP.get(" ") ?? 0x20;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function seedReadyPrompt(state) {
|
|
139
|
+
const text = "READY.";
|
|
140
|
+
const buffer = new Uint8Array(text.length + 1);
|
|
141
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
142
|
+
buffer[i] = toScreenCode(text[i]);
|
|
143
|
+
}
|
|
144
|
+
buffer[text.length] = toScreenCode(" ");
|
|
145
|
+
state.memory.set(buffer, 0x0400);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function startMockC64Server() {
|
|
149
|
+
const state = createInitialState();
|
|
150
|
+
|
|
151
|
+
// seed memory with READY prompt at $0400 using screen codes
|
|
152
|
+
seedReadyPrompt(state);
|
|
153
|
+
|
|
154
|
+
function ensureDriveState(id) {
|
|
155
|
+
if (!state.drives[id]) {
|
|
156
|
+
state.drives[id] = {
|
|
157
|
+
mountedImage: null,
|
|
158
|
+
mode: "1541",
|
|
159
|
+
power: "off",
|
|
160
|
+
resetCount: 0,
|
|
161
|
+
lastRom: null,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return state.drives[id];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
ensureDriveState("drive8");
|
|
168
|
+
|
|
169
|
+
function resetState() {
|
|
170
|
+
const fresh = createInitialState();
|
|
171
|
+
Object.assign(state, fresh);
|
|
172
|
+
seedReadyPrompt(state);
|
|
173
|
+
ensureDriveState("drive8");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const server = createServer(async (req, res) => {
|
|
177
|
+
const { method, url } = req;
|
|
178
|
+
|
|
179
|
+
if (!method || !url) {
|
|
180
|
+
res.statusCode = 400;
|
|
181
|
+
res.end();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Track last request metadata
|
|
186
|
+
state.lastRequest = { method, url, headers: req.headers };
|
|
187
|
+
|
|
188
|
+
if (method === "GET" && (url === "/" || url.startsWith("/?"))) {
|
|
189
|
+
sendJson(res, { status: "ok", host: "mock" });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (method === "GET" && url === "/v1/version") {
|
|
194
|
+
sendJson(res, { version: "0.1-mock" });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (method === "GET" && url === "/v1/info") {
|
|
199
|
+
sendJson(res, { product: "U64-MOCK", firmware_version: "3.12-mock", hostname: "mockc64" });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (method === "GET" && url === "/v1/drives") {
|
|
204
|
+
const drives = {};
|
|
205
|
+
for (const [driveId, driveState] of Object.entries(state.drives)) {
|
|
206
|
+
drives[driveId] = {
|
|
207
|
+
enabled: driveState.power !== "off",
|
|
208
|
+
power: driveState.power,
|
|
209
|
+
mode: driveState.mode,
|
|
210
|
+
image: driveState.mountedImage,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
sendJson(res, { drives });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (method === "PUT" && url === "/v1/machine:pause") {
|
|
218
|
+
state.paused = true;
|
|
219
|
+
state.lastPause = Date.now();
|
|
220
|
+
sendJson(res, { result: "paused" });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (method === "PUT" && url === "/v1/machine:resume") {
|
|
225
|
+
state.paused = false;
|
|
226
|
+
state.lastResume = Date.now();
|
|
227
|
+
sendJson(res, { result: "resumed" });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (method === "PUT" && url === "/v1/machine:poweroff") {
|
|
232
|
+
state.poweroffs += 1;
|
|
233
|
+
state.lastPoweroff = Date.now();
|
|
234
|
+
sendJson(res, { result: "poweroff" });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (method === "PUT" && url === "/v1/machine:menu_button") {
|
|
239
|
+
state.menuToggleCount += 1;
|
|
240
|
+
const target = req.headers["x-target"] ?? null;
|
|
241
|
+
state.lastMenuTarget = target;
|
|
242
|
+
sendJson(res, { result: "menu", target });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (method === "GET" && url === "/v1/machine:debugreg") {
|
|
247
|
+
sendJson(res, { value: state.debugreg ?? "00" });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (method === "PUT" && url.startsWith("/v1/machine:debugreg")) {
|
|
252
|
+
const routeUrl = new URL(url, "http://mock.local");
|
|
253
|
+
const value = (routeUrl.searchParams.get("value") ?? "00").toUpperCase();
|
|
254
|
+
state.debugreg = value;
|
|
255
|
+
sendJson(res, { value });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (method === "POST" && url === "/v1/runners:run_prg") {
|
|
260
|
+
const chunks = [];
|
|
261
|
+
for await (const chunk of req) {
|
|
262
|
+
chunks.push(chunk);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const prg = Buffer.concat(chunks);
|
|
266
|
+
state.lastPrg = prg;
|
|
267
|
+
state.runCount += 1;
|
|
268
|
+
|
|
269
|
+
sendJson(res, { result: "ok", bytes: prg.length });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (url.startsWith("/v1/runners:sidplay")) {
|
|
274
|
+
let routeUrl;
|
|
275
|
+
try {
|
|
276
|
+
routeUrl = new URL(url, "http://mock.local");
|
|
277
|
+
} catch {
|
|
278
|
+
routeUrl = null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (routeUrl) {
|
|
282
|
+
const songnrParam = routeUrl.searchParams.get("songnr");
|
|
283
|
+
const songnr = songnrParam === null ? null : Number.parseInt(songnrParam, 10);
|
|
284
|
+
|
|
285
|
+
if (method === "PUT") {
|
|
286
|
+
const file = routeUrl.searchParams.get("file") ?? "";
|
|
287
|
+
state.sidplayCount += 1;
|
|
288
|
+
state.lastSidplay = { file, songnr };
|
|
289
|
+
sendJson(res, { result: "sidplay", file, songnr });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (method === "POST") {
|
|
294
|
+
const chunks = [];
|
|
295
|
+
for await (const chunk of req) {
|
|
296
|
+
chunks.push(chunk);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const attachment = Buffer.concat(chunks);
|
|
300
|
+
state.sidAttachmentCount += 1;
|
|
301
|
+
state.lastSidAttachment = { songnr, bytes: attachment.length };
|
|
302
|
+
sendJson(res, { result: "sidplay_attachment", bytes: attachment.length, songnr });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (url.startsWith("/v1/runners:modplay")) {
|
|
309
|
+
let routeUrl;
|
|
310
|
+
try {
|
|
311
|
+
routeUrl = new URL(url, "http://mock.local");
|
|
312
|
+
} catch {
|
|
313
|
+
routeUrl = null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (routeUrl) {
|
|
317
|
+
if (method === "PUT") {
|
|
318
|
+
const file = routeUrl.searchParams.get("file") ?? "";
|
|
319
|
+
state.modplayCount += 1;
|
|
320
|
+
state.lastModplay = { file };
|
|
321
|
+
sendJson(res, { result: "modplay", file });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (method === "POST") {
|
|
326
|
+
const chunks = [];
|
|
327
|
+
for await (const chunk of req) {
|
|
328
|
+
chunks.push(chunk);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const attachment = Buffer.concat(chunks);
|
|
332
|
+
state.modplayCount += 1;
|
|
333
|
+
state.lastModplay = { file: null, bytes: attachment.length };
|
|
334
|
+
sendJson(res, { result: "modplay_attachment", bytes: attachment.length });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (method === "GET" && url.startsWith("/v1/machine:readmem")) {
|
|
341
|
+
const routeUrl = new URL(url, "http://mock.local");
|
|
342
|
+
const addressValue = routeUrl.searchParams.get("address") ?? "0";
|
|
343
|
+
const lengthValue = routeUrl.searchParams.get("length") ?? "256";
|
|
344
|
+
const address = parseNumeric(addressValue);
|
|
345
|
+
const length = Math.max(0, parseNumeric(lengthValue, 10));
|
|
346
|
+
const bytes = state.memory.slice(address, address + length);
|
|
347
|
+
|
|
348
|
+
const accept = String(req.headers["accept"] || "");
|
|
349
|
+
if (accept.includes("application/octet-stream")) {
|
|
350
|
+
res.setHeader("Content-Type", "application/octet-stream");
|
|
351
|
+
res.end(Buffer.from(bytes));
|
|
352
|
+
} else {
|
|
353
|
+
const payload = Buffer.from(bytes).toString("base64");
|
|
354
|
+
sendJson(res, { data: payload });
|
|
355
|
+
}
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (method === "PUT" && url === "/v1/machine:reset") {
|
|
360
|
+
state.resets += 1;
|
|
361
|
+
sendJson(res, { result: "reset" });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (method === "PUT" && url === "/v1/machine:reboot") {
|
|
366
|
+
state.reboots += 1;
|
|
367
|
+
sendJson(res, { result: "reboot" });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (method === "PUT" && url.startsWith("/v1/machine:writemem")) {
|
|
372
|
+
const routeUrl = new URL(url, "http://mock.local");
|
|
373
|
+
const addressValue = routeUrl.searchParams.get("address") ?? "0";
|
|
374
|
+
const dataValue = normaliseHexString(routeUrl.searchParams.get("data") ?? "");
|
|
375
|
+
const address = parseNumeric(addressValue);
|
|
376
|
+
const bytes = Buffer.from(dataValue, "hex");
|
|
377
|
+
|
|
378
|
+
state.memory.set(bytes, address);
|
|
379
|
+
state.lastWrite = { address, bytes };
|
|
380
|
+
|
|
381
|
+
sendJson(res, { result: "wrote", address, length: bytes.length });
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (method === "POST" && url.startsWith("/v1/machine:writemem")) {
|
|
386
|
+
const routeUrl = new URL(url, "http://mock.local");
|
|
387
|
+
const addressValue = routeUrl.searchParams.get("address") ?? "0";
|
|
388
|
+
const address = parseNumeric(addressValue);
|
|
389
|
+
|
|
390
|
+
const chunks = [];
|
|
391
|
+
for await (const chunk of req) {
|
|
392
|
+
chunks.push(chunk);
|
|
393
|
+
}
|
|
394
|
+
const bytes = Buffer.concat(chunks);
|
|
395
|
+
|
|
396
|
+
state.memory.set(bytes, address);
|
|
397
|
+
state.lastWrite = { address, bytes };
|
|
398
|
+
|
|
399
|
+
sendJson(res, { result: "wrote", address, length: bytes.length });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (method === "GET" && url === "/v1/configs") {
|
|
404
|
+
sendJson(res, { categories: Object.keys(state.configs), configs: state.configs });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (method === "POST" && url === "/v1/configs") {
|
|
409
|
+
const payload = await readJson(req);
|
|
410
|
+
if (payload && typeof payload === "object") {
|
|
411
|
+
for (const [category, items] of Object.entries(payload)) {
|
|
412
|
+
if (!state.configs[category]) {
|
|
413
|
+
state.configs[category] = {};
|
|
414
|
+
}
|
|
415
|
+
if (items && typeof items === "object") {
|
|
416
|
+
for (const [item, value] of Object.entries(items)) {
|
|
417
|
+
state.configs[category][item] = String(value);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
state.lastConfigAction = { action: "batch_update", payload };
|
|
423
|
+
sendJson(res, { result: "batch_update", categories: Object.keys(payload ?? {}) });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (url.startsWith("/v1/configs:")) {
|
|
428
|
+
const routeUrl = new URL(url, "http://mock.local");
|
|
429
|
+
const action = routeUrl.pathname.slice("/v1/configs:".length);
|
|
430
|
+
|
|
431
|
+
if (method === "PUT" && action === "load_from_flash") {
|
|
432
|
+
if (state.flashSnapshot) {
|
|
433
|
+
state.configs = JSON.parse(JSON.stringify(state.flashSnapshot));
|
|
434
|
+
}
|
|
435
|
+
state.lastConfigAction = { action: "load_from_flash" };
|
|
436
|
+
sendJson(res, { result: "loaded", restored: Boolean(state.flashSnapshot) });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (method === "PUT" && action === "save_to_flash") {
|
|
441
|
+
state.flashSnapshot = JSON.parse(JSON.stringify(state.configs));
|
|
442
|
+
state.lastConfigAction = { action: "save_to_flash" };
|
|
443
|
+
sendJson(res, { result: "saved" });
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (method === "PUT" && action === "reset_to_default") {
|
|
448
|
+
state.configs = createDefaultConfigs();
|
|
449
|
+
state.lastConfigAction = { action: "reset_to_default" };
|
|
450
|
+
sendJson(res, { result: "reset" });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (url.startsWith("/v1/configs/")) {
|
|
456
|
+
const routeUrl = new URL(url, "http://mock.local");
|
|
457
|
+
const segments = routeUrl.pathname.split("/").filter(Boolean).slice(2); // remove v1 + configs
|
|
458
|
+
|
|
459
|
+
if (segments.length === 1) {
|
|
460
|
+
const [category] = segments;
|
|
461
|
+
|
|
462
|
+
if (method === "GET") {
|
|
463
|
+
const categoryData = state.configs[category] ?? {};
|
|
464
|
+
sendJson(res, { ...categoryData });
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (segments.length === 2) {
|
|
470
|
+
const [category, item] = segments;
|
|
471
|
+
if (method === "GET") {
|
|
472
|
+
const categoryData = state.configs[category] ?? {};
|
|
473
|
+
const value = categoryData[item];
|
|
474
|
+
sendJson(res, { value });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (method === "PUT") {
|
|
479
|
+
const queryValue = routeUrl.searchParams.get("value");
|
|
480
|
+
const body = await readJson(req);
|
|
481
|
+
const value = queryValue ?? body?.value ?? "";
|
|
482
|
+
if (!state.configs[category]) {
|
|
483
|
+
state.configs[category] = {};
|
|
484
|
+
}
|
|
485
|
+
state.configs[category][item] = String(value);
|
|
486
|
+
state.lastConfigAction = { action: "set", category, item, value: String(value) };
|
|
487
|
+
sendJson(res, { result: "updated", category, item, value: String(value) });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (url.startsWith("/v1/streams/")) {
|
|
494
|
+
const routeUrl = new URL(url, "http://mock.local");
|
|
495
|
+
const match = /^\/v1\/streams\/([^:]+):(start|stop)$/.exec(routeUrl.pathname);
|
|
496
|
+
if (match) {
|
|
497
|
+
const stream = decodeURIComponent(match[1]);
|
|
498
|
+
const action = match[2];
|
|
499
|
+
if (!state.streams[stream]) {
|
|
500
|
+
state.streams[stream] = { active: false, target: null };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (action === "start" && method === "PUT") {
|
|
504
|
+
const body = await readJson(req);
|
|
505
|
+
const target = routeUrl.searchParams.get("ip") ?? routeUrl.searchParams.get("target") ?? body?.ip ?? body?.target ?? null;
|
|
506
|
+
state.streams[stream] = { active: true, target };
|
|
507
|
+
state.lastStreamAction = { action: "start", stream, target };
|
|
508
|
+
sendJson(res, { result: "started", stream, target });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (action === "stop" && method === "PUT") {
|
|
513
|
+
state.streams[stream] = { active: false, target: null };
|
|
514
|
+
state.lastStreamAction = { action: "stop", stream };
|
|
515
|
+
sendJson(res, { result: "stopped", stream });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (url.startsWith("/v1/drives/")) {
|
|
522
|
+
let routeUrl;
|
|
523
|
+
try {
|
|
524
|
+
routeUrl = new URL(url, "http://mock.local");
|
|
525
|
+
} catch {
|
|
526
|
+
routeUrl = null;
|
|
527
|
+
}
|
|
528
|
+
if (routeUrl) {
|
|
529
|
+
const match = /^\/v1\/drives\/([^:]+):(mount|remove|reset|on|off|load_rom|set_mode)$/.exec(routeUrl.pathname);
|
|
530
|
+
if (match) {
|
|
531
|
+
const driveId = decodeURIComponent(match[1]);
|
|
532
|
+
const action = match[2];
|
|
533
|
+
const driveState = ensureDriveState(driveId);
|
|
534
|
+
|
|
535
|
+
const respond = (payload) => {
|
|
536
|
+
sendJson(res, payload);
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
state.lastDriveOperation = {
|
|
540
|
+
action,
|
|
541
|
+
drive: driveId,
|
|
542
|
+
params: Object.fromEntries(routeUrl.searchParams.entries()),
|
|
543
|
+
method,
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
if (action === "mount" && method === "PUT") {
|
|
547
|
+
const image = routeUrl.searchParams.get("image") ?? "";
|
|
548
|
+
const type = routeUrl.searchParams.get("type") ?? null;
|
|
549
|
+
const mode = routeUrl.searchParams.get("mode") ?? null;
|
|
550
|
+
driveState.mountedImage = {
|
|
551
|
+
image,
|
|
552
|
+
type,
|
|
553
|
+
mode,
|
|
554
|
+
};
|
|
555
|
+
respond({ result: "mounted", drive: driveId, image, type, mode });
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (action === "remove" && method === "PUT") {
|
|
560
|
+
driveState.mountedImage = null;
|
|
561
|
+
respond({ result: "removed", drive: driveId });
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (action === "reset" && method === "PUT") {
|
|
566
|
+
driveState.resetCount += 1;
|
|
567
|
+
respond({ result: "reset", drive: driveId, count: driveState.resetCount });
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (action === "on" && method === "PUT") {
|
|
572
|
+
driveState.power = "on";
|
|
573
|
+
respond({ result: "power_on", drive: driveId });
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (action === "off" && method === "PUT") {
|
|
578
|
+
driveState.power = "off";
|
|
579
|
+
respond({ result: "power_off", drive: driveId });
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (action === "load_rom" && (method === "PUT" || method === "POST")) {
|
|
584
|
+
const file = routeUrl.searchParams.get("file") ?? "";
|
|
585
|
+
driveState.lastRom = file;
|
|
586
|
+
respond({ result: "rom_loaded", drive: driveId, file });
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (action === "set_mode" && method === "PUT") {
|
|
591
|
+
const mode = routeUrl.searchParams.get("mode") ?? "1541";
|
|
592
|
+
driveState.mode = mode;
|
|
593
|
+
respond({ result: "mode_set", drive: driveId, mode });
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (url.startsWith("/v1/files/")) {
|
|
601
|
+
let routeUrl;
|
|
602
|
+
try {
|
|
603
|
+
routeUrl = new URL(url, "http://mock.local");
|
|
604
|
+
} catch {
|
|
605
|
+
routeUrl = null;
|
|
606
|
+
}
|
|
607
|
+
if (routeUrl) {
|
|
608
|
+
const match = /^\/v1\/files\/([^:]+):(info|create_d64|create_d71|create_d81|create_dnp)$/.exec(routeUrl.pathname);
|
|
609
|
+
if (match) {
|
|
610
|
+
const encodedPath = match[1];
|
|
611
|
+
const action = match[2];
|
|
612
|
+
const decodedPath = decodeURIComponent(encodedPath);
|
|
613
|
+
|
|
614
|
+
const respond = (payload) => {
|
|
615
|
+
sendJson(res, payload);
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
if (action === "info" && method === "GET") {
|
|
619
|
+
state.lastFileInfo = decodedPath;
|
|
620
|
+
respond({ path: decodedPath, size: 4096, type: "file" });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (action === "create_d64" && method === "PUT") {
|
|
625
|
+
const tracks = Number.parseInt(routeUrl.searchParams.get("tracks") ?? "35", 10);
|
|
626
|
+
const diskname = routeUrl.searchParams.get("diskname") ?? null;
|
|
627
|
+
state.createdImages.push({ type: "d64", path: decodedPath, tracks, diskname });
|
|
628
|
+
respond({ result: "created", type: "d64", path: decodedPath, tracks, diskname });
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (action === "create_d71" && method === "PUT") {
|
|
633
|
+
const diskname = routeUrl.searchParams.get("diskname") ?? null;
|
|
634
|
+
state.createdImages.push({ type: "d71", path: decodedPath, diskname });
|
|
635
|
+
respond({ result: "created", type: "d71", path: decodedPath, diskname });
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (action === "create_d81" && method === "PUT") {
|
|
640
|
+
const diskname = routeUrl.searchParams.get("diskname") ?? null;
|
|
641
|
+
state.createdImages.push({ type: "d81", path: decodedPath, diskname });
|
|
642
|
+
respond({ result: "created", type: "d81", path: decodedPath, diskname });
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (action === "create_dnp" && method === "PUT") {
|
|
647
|
+
const tracks = Number.parseInt(routeUrl.searchParams.get("tracks") ?? "0", 10);
|
|
648
|
+
const diskname = routeUrl.searchParams.get("diskname") ?? null;
|
|
649
|
+
state.createdImages.push({ type: "dnp", path: decodedPath, tracks, diskname });
|
|
650
|
+
respond({ result: "created", type: "dnp", path: decodedPath, tracks, diskname });
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
res.statusCode = 404;
|
|
658
|
+
res.end();
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
server.listen(0, "127.0.0.1");
|
|
662
|
+
await once(server, "listening");
|
|
663
|
+
const address = server.address();
|
|
664
|
+
if (!address || typeof address === "string") {
|
|
665
|
+
throw new Error("Unable to determine mock server address");
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return {
|
|
669
|
+
baseUrl: `http://127.0.0.1:${address.port}`,
|
|
670
|
+
state,
|
|
671
|
+
async close() {
|
|
672
|
+
server.close();
|
|
673
|
+
await once(server, "close");
|
|
674
|
+
},
|
|
675
|
+
reset: resetState,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
@@ -17,7 +17,7 @@ async function main() {
|
|
|
17
17
|
};
|
|
18
18
|
const outputFile = resolvePath(outputArg);
|
|
19
19
|
|
|
20
|
-
const moduleUrl = new URL('
|
|
20
|
+
const moduleUrl = new URL('./mockC64Server.mjs', import.meta.url);
|
|
21
21
|
const { startMockC64Server } = await import(moduleUrl.href);
|
|
22
22
|
const server = await startMockC64Server();
|
|
23
23
|
const baseUrl = server.baseUrl;
|
package/scripts/start-mock.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
const root = path.resolve(here, '..');
|
|
10
10
|
|
|
11
|
-
const { startMockC64Server } = await import(path.join(root, '
|
|
11
|
+
const { startMockC64Server } = await import(path.join(root, 'scripts', 'mockC64Server.mjs'));
|
|
12
12
|
|
|
13
13
|
const outPath = process.argv[2];
|
|
14
14
|
if (!outPath) {
|