c64bridge 0.5.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.
- package/.github/prompts/README.md +6 -0
- package/.github/prompts/assembly-routine-session.prompt.md +13 -0
- package/.github/prompts/basic-program-session.prompt.md +13 -0
- package/.github/prompts/drive-management-session.prompt.md +13 -0
- package/.github/prompts/petscii-art-session.prompt.md +13 -0
- package/.github/prompts/play-classic-game.prompt.md +13 -0
- package/.github/prompts/sid-music-session.prompt.md +13 -0
- package/AGENTS.md +173 -0
- package/LICENSE +339 -0
- package/README.md +582 -0
- package/data/README.md +16 -0
- package/data/api/basic-api-spec.md +154 -0
- package/data/api/kernal-api-spec.md +68 -0
- package/data/assembly/assembly-spec.md +196 -0
- package/data/assembly/examples/effects/sinewave.asm +5 -0
- package/data/assembly/examples/graphics/sprite/mover.asm +15 -0
- package/data/assembly/examples/sid-file-structure.md +8 -0
- package/data/assembly/examples/sid-overview.md +11 -0
- package/data/audio/examples/Biosphere.sid +0 -0
- package/data/audio/sid-file-structure.md +91 -0
- package/data/audio/sid-programming-best-practices.md +198 -0
- package/data/audio/sid-spec.md +166 -0
- package/data/audio/sidwave.md +131 -0
- package/data/audio/sidwave.schema.json +64 -0
- package/data/basic/basic-spec.md +230 -0
- package/data/basic/examples/audio/alle-meine-entchen-expressive.bas +41 -0
- package/data/basic/examples/audio/c-major-scale-pleasant.bas +29 -0
- package/data/basic/examples/io/joystick.bas +5 -0
- package/data/basic/examples/video/bounce.bas +8 -0
- package/data/basic/examples/video/entchen-petscii.bas +16 -0
- package/data/basic/examples/video/hello-world.bas +4 -0
- package/data/basic/examples/video/wave.bas +7 -0
- package/data/context/bootstrap.md +148 -0
- package/data/context/chat.md +36 -0
- package/data/drive/drive-spec.md +215 -0
- package/data/embeddings_asm.json +1 -0
- package/data/embeddings_basic.json +1 -0
- package/data/embeddings_hardware.json +1 -0
- package/data/embeddings_mixed.json +1 -0
- package/data/embeddings_other.json +1 -0
- package/data/io/cia-spec.md +115 -0
- package/data/io/io-spec.md +124 -0
- package/data/memory/kernal-memory-map.md +227 -0
- package/data/memory/low-memory-map.md +133 -0
- package/data/memory/memory-map.md +186 -0
- package/data/printer/printer-commodore-bitmap.md +31 -0
- package/data/printer/printer-commodore.md +28 -0
- package/data/printer/printer-epson-bitmap.md +29 -0
- package/data/printer/printer-epson.md +30 -0
- package/data/printer/printer-prompts.md +29 -0
- package/data/printer/printer-spec.md +180 -0
- package/data/video/character-set.csv +257 -0
- package/data/video/vic-spec.md +276 -0
- package/dist/assemblyConverter.js +1299 -0
- package/dist/audio/record_and_analyze_audio.js +292 -0
- package/dist/basicConverter.js +193 -0
- package/dist/bootstrap/stdio-logger.js +32 -0
- package/dist/c64Client.js +1052 -0
- package/dist/chargen.js +197 -0
- package/dist/config.js +142 -0
- package/dist/context.js +157 -0
- package/dist/device.js +413 -0
- package/dist/index.js +7 -0
- package/dist/knowledge.js +454 -0
- package/dist/logger.js +182 -0
- package/dist/loggingHttpClient.js +88 -0
- package/dist/mcp-server.js +426 -0
- package/dist/petscii.js +135 -0
- package/dist/petsciiArt.js +356 -0
- package/dist/platform.js +82 -0
- package/dist/prompts/registry.js +560 -0
- package/dist/rag/discover.config.json +41 -0
- package/dist/rag/discover.js +401 -0
- package/dist/rag/embeddings.js +60 -0
- package/dist/rag/externalFetcher.js +934 -0
- package/dist/rag/indexer.js +770 -0
- package/dist/rag/init.js +139 -0
- package/dist/rag/knowledgeIndex.js +361 -0
- package/dist/rag/loggingRetriever.js +46 -0
- package/dist/rag/rateLimiter.js +127 -0
- package/dist/rag/retriever.js +74 -0
- package/dist/rag/sources.csv +31 -0
- package/dist/rag/types.js +5 -0
- package/dist/rag/urlUtils.js +38 -0
- package/dist/sidplayRunner.js +240 -0
- package/dist/sidwave.js +101 -0
- package/dist/sidwaveCompiler.js +329 -0
- package/dist/tools/audio.js +944 -0
- package/dist/tools/developer.js +515 -0
- package/dist/tools/errors.js +59 -0
- package/dist/tools/graphics.js +469 -0
- package/dist/tools/machineControl.js +267 -0
- package/dist/tools/memory.js +224 -0
- package/dist/tools/printer.js +482 -0
- package/dist/tools/programRunners.js +315 -0
- package/dist/tools/rag.js +153 -0
- package/dist/tools/registry.js +58 -0
- package/dist/tools/responses.js +36 -0
- package/dist/tools/schema.js +304 -0
- package/dist/tools/storage.js +740 -0
- package/dist/tools/streaming.js +132 -0
- package/dist/tools/types.js +99 -0
- package/dist/viceRunner.js +239 -0
- package/doc/MCP_SETUP.md +153 -0
- package/doc/README.md +7 -0
- package/doc/developer.md +312 -0
- package/doc/img/logo.png +0 -0
- package/doc/img/prompts/duck_petscii.png +0 -0
- package/doc/img/prompts/duck_song.png +0 -0
- package/doc/img/vscode/vscode-copilot-c64-chat-mode.png +0 -0
- package/doc/img/vscode/vscode-copilot-hello-world.png +0 -0
- package/doc/prompts/README.md +96 -0
- package/doc/prompts/user-prompt-ideas.md +147 -0
- package/doc/rest/c64-openapi.yaml +979 -0
- package/doc/rest/c64-rest-api.md +106 -0
- package/doc/troubleshooting-mcp.md +177 -0
- package/generated/c64/index.js +857 -0
- package/mcp.json +106 -0
- package/package.json +79 -0
- package/scripts/c64-cli.mjs +142 -0
- package/scripts/c64-tool.sh +83 -0
- package/scripts/check-package.mjs +89 -0
- package/scripts/cli.js +61 -0
- package/scripts/fetch-external-sources.mjs +67 -0
- package/scripts/generate-changelog.mjs +131 -0
- package/scripts/postbuild.mjs +101 -0
- package/scripts/prepare-release.mjs +58 -0
- package/scripts/rag-rebuild.mjs +53 -0
- package/scripts/register-ts-node.mjs +6 -0
- package/scripts/run-mcp-check.sh +104 -0
- package/scripts/run-tests.mjs +195 -0
- package/scripts/sid2wav.sh +94 -0
- package/scripts/smoke-run.sh +65 -0
- package/scripts/start-mock-c64.mjs +51 -0
- package/scripts/start-mock.mjs +31 -0
- package/scripts/start.mjs +72 -0
- package/scripts/update-readme.ts +164 -0
- package/scripts/verify-package.mjs +147 -0
|
@@ -0,0 +1,1052 @@
|
|
|
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
|
+
import { Buffer } from "node:buffer";
|
|
9
|
+
import axios from "axios";
|
|
10
|
+
import { basicToPrg } from "./basicConverter.js";
|
|
11
|
+
import { assemblyToPrg } from "./assemblyConverter.js";
|
|
12
|
+
import { petsciiToAscii } from "./petscii.js";
|
|
13
|
+
import { resolveAddressSymbol } from "./knowledge.js";
|
|
14
|
+
import { createFacade } from "./device.js";
|
|
15
|
+
import { Api } from "../generated/c64/index.js";
|
|
16
|
+
import { createLoggingHttpClient } from "./loggingHttpClient.js";
|
|
17
|
+
export class C64Client {
|
|
18
|
+
constructor(baseUrl) {
|
|
19
|
+
this.http = createLoggingHttpClient({ baseURL: baseUrl, timeout: 10000 });
|
|
20
|
+
this.api = new Api(this.http);
|
|
21
|
+
// Select backend once lazily; keep REST for hardware-specific fallbacks
|
|
22
|
+
this.facadePromise = createFacade(undefined, { preferredC64uBaseUrl: baseUrl }).then((sel) => sel.facade);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Generate a BASIC program that opens the printer (device 4), prints the provided text,
|
|
26
|
+
* and closes the channel. Assumes Commodore MPS (PETSCII) by default.
|
|
27
|
+
*/
|
|
28
|
+
async printTextOnPrinterAndRun(options) {
|
|
29
|
+
const program = buildPrinterBasicProgram(options);
|
|
30
|
+
return this.uploadAndRunBasic(program);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Generate and run a Commodore MPS Bit Image Mode (BIM) program for one bitmap row,
|
|
34
|
+
* optionally repeated.
|
|
35
|
+
*/
|
|
36
|
+
async printBitmapOnCommodoreAndRun(options) {
|
|
37
|
+
const program = buildCommodoreBitmapBasicProgram(options);
|
|
38
|
+
return this.uploadAndRunBasic(program);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Generate and run an Epson FX ESC/P bitmap program for one row (repeated lines).
|
|
42
|
+
*/
|
|
43
|
+
async printBitmapOnEpsonAndRun(options) {
|
|
44
|
+
const program = buildEpsonBitmapBasicProgram(options);
|
|
45
|
+
return this.uploadAndRunBasic(program);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Generate and run a Commodore MPS DLL (custom characters) program. On emulator this
|
|
49
|
+
* is ignored but we still verify generation and transmission.
|
|
50
|
+
*/
|
|
51
|
+
async defineCustomCharsOnCommodoreAndRun(options) {
|
|
52
|
+
const program = buildCommodoreDllBasicProgram(options);
|
|
53
|
+
return this.uploadAndRunBasic(program);
|
|
54
|
+
}
|
|
55
|
+
async uploadAndRunBasic(program) {
|
|
56
|
+
const prg = basicToPrg(program);
|
|
57
|
+
return this.runPrg(prg);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Build a simple sprite PRG from raw 63-byte sprite data and position/color attributes.
|
|
61
|
+
* Returns the REST result after uploading and running the generated PRG.
|
|
62
|
+
*/
|
|
63
|
+
async generateAndRunSpritePrg(options) {
|
|
64
|
+
const prg = buildSingleSpriteProgram(options);
|
|
65
|
+
return this.runPrg(prg);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Build a BASIC program that draws a PETSCII screen (optionally set border/bg colours),
|
|
69
|
+
* then upload and run it.
|
|
70
|
+
*/
|
|
71
|
+
async renderPetsciiScreenAndRun(options) {
|
|
72
|
+
const program = buildPetsciiScreenBasic(options);
|
|
73
|
+
return this.uploadAndRunBasic(program);
|
|
74
|
+
}
|
|
75
|
+
async uploadAndRunAsm(program) {
|
|
76
|
+
const prg = assemblyToPrg(program);
|
|
77
|
+
return this.runPrg(prg);
|
|
78
|
+
}
|
|
79
|
+
async runPrg(prg) {
|
|
80
|
+
try {
|
|
81
|
+
if (process.env.C64_TEST_TARGET === "mock") {
|
|
82
|
+
const payload = Buffer.isBuffer(prg) ? prg : Buffer.from(prg);
|
|
83
|
+
const response = await this.api.v1.runnersRunPrgCreate(":run_prg", payload, {
|
|
84
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
85
|
+
});
|
|
86
|
+
return { success: true, details: response.data };
|
|
87
|
+
}
|
|
88
|
+
const facade = await this.facadePromise;
|
|
89
|
+
const res = await facade.runPrg(prg);
|
|
90
|
+
return { success: res.success, details: res.details };
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
return { success: false, details: this.normaliseError(error) };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Upload a SID binary and instruct firmware to play it (attachment mode). */
|
|
97
|
+
async sidplayAttachment(sid, options) {
|
|
98
|
+
try {
|
|
99
|
+
const facade = await this.facadePromise;
|
|
100
|
+
const res = await facade.sidplayAttachment(sid, options);
|
|
101
|
+
return { success: res.success, details: res.details };
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
return { success: false, details: this.normaliseError(error) };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async loadPrgFile(path) {
|
|
108
|
+
try {
|
|
109
|
+
const facade = await this.facadePromise;
|
|
110
|
+
const res = await facade.loadPrgFile(path);
|
|
111
|
+
return { success: res.success, details: res.details };
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
return { success: false, details: this.normaliseError(error) };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async runPrgFile(path) {
|
|
118
|
+
try {
|
|
119
|
+
const facade = await this.facadePromise;
|
|
120
|
+
const res = await facade.runPrgFile(path);
|
|
121
|
+
return { success: res.success, details: res.details };
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return { success: false, details: this.normaliseError(error) };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async runCrtFile(path) {
|
|
128
|
+
try {
|
|
129
|
+
const facade = await this.facadePromise;
|
|
130
|
+
const res = await facade.runCrtFile(path);
|
|
131
|
+
return { success: res.success, details: res.details };
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
return { success: false, details: this.normaliseError(error) };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async sidplayFile(path, songnr) {
|
|
138
|
+
try {
|
|
139
|
+
const facade = await this.facadePromise;
|
|
140
|
+
const res = await facade.sidplayFile(path, songnr);
|
|
141
|
+
return { success: res.success, details: res.details };
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
return { success: false, details: this.normaliseError(error) };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async modplayFile(path) {
|
|
148
|
+
try {
|
|
149
|
+
const facade = await this.facadePromise;
|
|
150
|
+
if (!facade.modplayFile)
|
|
151
|
+
throw new Error("modplay not supported by selected backend");
|
|
152
|
+
const res = await facade.modplayFile(path);
|
|
153
|
+
return { success: res.success, details: res.details };
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
return { success: false, details: this.normaliseError(error) };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async readScreen() {
|
|
160
|
+
// Exposed as MCP tool: read_screen
|
|
161
|
+
const bytes = await this.readMemoryRaw(0x0400, 0x1000);
|
|
162
|
+
return petsciiToAscii(bytes);
|
|
163
|
+
}
|
|
164
|
+
async reset() {
|
|
165
|
+
try {
|
|
166
|
+
if (process.env.C64_TEST_TARGET === "mock") {
|
|
167
|
+
const response = await this.api.v1.machineResetUpdate(":reset");
|
|
168
|
+
return { success: true, details: response.data };
|
|
169
|
+
}
|
|
170
|
+
const facade = await this.facadePromise;
|
|
171
|
+
const res = await facade.reset();
|
|
172
|
+
return { success: res.success, details: res.details };
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
return { success: false, details: this.normaliseError(error) };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async reboot() {
|
|
179
|
+
try {
|
|
180
|
+
if (process.env.C64_TEST_TARGET === "mock") {
|
|
181
|
+
const response = await this.api.v1.machineRebootUpdate(":reboot");
|
|
182
|
+
return { success: true, details: response.data };
|
|
183
|
+
}
|
|
184
|
+
const facade = await this.facadePromise;
|
|
185
|
+
const res = await facade.reboot();
|
|
186
|
+
return { success: res.success, details: res.details };
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
return { success: false, details: this.normaliseError(error) };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async readMemory(addressInput, lengthInput) {
|
|
193
|
+
try {
|
|
194
|
+
const resolved = resolveAddressSymbol(addressInput);
|
|
195
|
+
const address = resolved ?? this.parseNumeric(addressInput);
|
|
196
|
+
const length = this.parseNumeric(lengthInput);
|
|
197
|
+
if (length <= 0) {
|
|
198
|
+
throw new Error("Length must be greater than zero");
|
|
199
|
+
}
|
|
200
|
+
const rawBytes = await this.readMemoryRaw(address, length);
|
|
201
|
+
const bytes = rawBytes.slice(0, length);
|
|
202
|
+
return {
|
|
203
|
+
success: true,
|
|
204
|
+
data: this.bytesToHex(bytes),
|
|
205
|
+
details: {
|
|
206
|
+
address: this.formatAddress(address),
|
|
207
|
+
length,
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
return {
|
|
213
|
+
success: false,
|
|
214
|
+
details: this.normaliseError(error),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async writeMemory(addressInput, bytesInput) {
|
|
219
|
+
try {
|
|
220
|
+
const resolved = resolveAddressSymbol(addressInput);
|
|
221
|
+
const address = resolved ?? this.parseNumeric(addressInput);
|
|
222
|
+
const dataBuffer = this.hexStringToBuffer(bytesInput);
|
|
223
|
+
if (dataBuffer.length === 0) {
|
|
224
|
+
throw new Error("No bytes provided");
|
|
225
|
+
}
|
|
226
|
+
// Prefer PUT with hex data for up to 128 bytes; fall back to POST binary for larger writes
|
|
227
|
+
const addrStr = this.formatAddress(address);
|
|
228
|
+
try {
|
|
229
|
+
const facade = await this.facadePromise;
|
|
230
|
+
await facade.writeMemory(address, dataBuffer);
|
|
231
|
+
return {
|
|
232
|
+
success: true,
|
|
233
|
+
details: {
|
|
234
|
+
address: addrStr,
|
|
235
|
+
bytes: this.bytesToHex(dataBuffer),
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
catch (facadeError) {
|
|
240
|
+
if (!(facadeError instanceof Error) || facadeError.code !== "UNSUPPORTED") {
|
|
241
|
+
throw facadeError;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
let response;
|
|
245
|
+
if (dataBuffer.length <= 128) {
|
|
246
|
+
const put = await this.api.v1.machineWritememUpdate(":writemem", {
|
|
247
|
+
address: addrStr,
|
|
248
|
+
data: this.bytesToHex(dataBuffer, false),
|
|
249
|
+
});
|
|
250
|
+
response = put.data;
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
const post = await this.api.v1.machineWritememCreate(":writemem", { address: addrStr }, Buffer.from(dataBuffer), { headers: { "Content-Type": "application/octet-stream" } });
|
|
254
|
+
response = post.data;
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
success: true,
|
|
258
|
+
details: {
|
|
259
|
+
address: addrStr,
|
|
260
|
+
bytes: this.bytesToHex(dataBuffer),
|
|
261
|
+
response,
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
return {
|
|
267
|
+
success: false,
|
|
268
|
+
details: this.normaliseError(error),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// --- SID/Music helpers ---
|
|
273
|
+
async sidSetVolume(volume) {
|
|
274
|
+
const clamped = Math.max(0, Math.min(15, Math.floor(volume)));
|
|
275
|
+
const byte = Buffer.from([clamped]);
|
|
276
|
+
return this.writeMemory("$D418", this.bytesToHex(byte));
|
|
277
|
+
}
|
|
278
|
+
async sidReset(hard = false) {
|
|
279
|
+
try {
|
|
280
|
+
const facade = await this.facadePromise;
|
|
281
|
+
if (hard) {
|
|
282
|
+
const span = 0x19;
|
|
283
|
+
const ff = Buffer.alloc(span, 0xff);
|
|
284
|
+
const zz = Buffer.alloc(span, 0x00);
|
|
285
|
+
await facade.writeMemory(0xd400, ff);
|
|
286
|
+
await facade.writeMemory(0xd400, zz);
|
|
287
|
+
return { success: true };
|
|
288
|
+
}
|
|
289
|
+
await facade.writeMemory(0xd404, Buffer.from([0x00]));
|
|
290
|
+
await facade.writeMemory(0xd40b, Buffer.from([0x00]));
|
|
291
|
+
await facade.writeMemory(0xd412, Buffer.from([0x00]));
|
|
292
|
+
await facade.writeMemory(0xd418, Buffer.from([0x00]));
|
|
293
|
+
return { success: true };
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
return { success: false, details: this.normaliseError(error) };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async sidNoteOn(options) {
|
|
300
|
+
const voice = options.voice ?? 1;
|
|
301
|
+
if (voice < 1 || voice > 3) {
|
|
302
|
+
return { success: false, details: { message: "Voice must be 1..3" } };
|
|
303
|
+
}
|
|
304
|
+
const system = options.system ?? "PAL";
|
|
305
|
+
const hz = options.frequencyHz ?? (options.note ? this.noteNameToHz(options.note) : 440);
|
|
306
|
+
const freq16 = this.hzToSidFrequency(hz, system);
|
|
307
|
+
const freqLo = freq16 & 0xff;
|
|
308
|
+
const freqHi = (freq16 >> 8) & 0xff;
|
|
309
|
+
const pulseWidth = Math.max(0, Math.min(0x0fff, Math.floor(options.pulseWidth ?? 0x0800)));
|
|
310
|
+
const pwLo = pulseWidth & 0xff;
|
|
311
|
+
const pwHi = (pulseWidth >> 8) & 0x0f; // upper 4 bits used
|
|
312
|
+
const waveform = options.waveform ?? "pulse";
|
|
313
|
+
let ctrl = 0x00;
|
|
314
|
+
if (waveform === "tri")
|
|
315
|
+
ctrl |= 1 << 4;
|
|
316
|
+
else if (waveform === "saw")
|
|
317
|
+
ctrl |= 1 << 5;
|
|
318
|
+
else if (waveform === "pulse")
|
|
319
|
+
ctrl |= 1 << 6;
|
|
320
|
+
else if (waveform === "noise")
|
|
321
|
+
ctrl |= 1 << 7;
|
|
322
|
+
ctrl |= 1 << 0; // GATE on
|
|
323
|
+
const attack = Math.max(0, Math.min(15, Math.floor(options.attack ?? 0x1)));
|
|
324
|
+
const decay = Math.max(0, Math.min(15, Math.floor(options.decay ?? 0x1)));
|
|
325
|
+
const sustain = Math.max(0, Math.min(15, Math.floor(options.sustain ?? 0xf)));
|
|
326
|
+
const release = Math.max(0, Math.min(15, Math.floor(options.release ?? 0x3)));
|
|
327
|
+
const ad = (attack << 4) | decay;
|
|
328
|
+
const sr = (sustain << 4) | release;
|
|
329
|
+
const base = 0xd400 + (voice - 1) * 7;
|
|
330
|
+
const bytes = Buffer.from([freqLo, freqHi, pwLo, pwHi, ctrl, ad, sr]);
|
|
331
|
+
try {
|
|
332
|
+
const facade = await this.facadePromise;
|
|
333
|
+
await facade.writeMemory(base, bytes);
|
|
334
|
+
return { success: true };
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
return { success: false, details: this.normaliseError(error) };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
async sidNoteOff(voice) {
|
|
341
|
+
if (voice < 1 || voice > 3) {
|
|
342
|
+
return { success: false, details: { message: "Voice must be 1..3" } };
|
|
343
|
+
}
|
|
344
|
+
const ctrlAddr = 0xd400 + (voice - 1) * 7 + 4;
|
|
345
|
+
try {
|
|
346
|
+
const facade = await this.facadePromise;
|
|
347
|
+
await facade.writeMemory(ctrlAddr, Buffer.from([0x00]));
|
|
348
|
+
return { success: true };
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
return { success: false, details: this.normaliseError(error) };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async sidSilenceAll() {
|
|
355
|
+
return this.sidReset(false);
|
|
356
|
+
}
|
|
357
|
+
// --- Additional API wrappers to cover full REST surface ---
|
|
358
|
+
async version() {
|
|
359
|
+
const res = await this.api.v1.versionList();
|
|
360
|
+
return res.data;
|
|
361
|
+
}
|
|
362
|
+
async info() {
|
|
363
|
+
const res = await this.api.v1.infoList();
|
|
364
|
+
return res.data;
|
|
365
|
+
}
|
|
366
|
+
async pause() {
|
|
367
|
+
try {
|
|
368
|
+
if (process.env.C64_TEST_TARGET === "mock") {
|
|
369
|
+
const res = await this.api.v1.machinePauseUpdate(":pause");
|
|
370
|
+
return { success: true, details: res.data };
|
|
371
|
+
}
|
|
372
|
+
const facade = await this.facadePromise;
|
|
373
|
+
return await facade.pause();
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
return { success: false, details: this.normaliseError(error) };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
async resume() {
|
|
380
|
+
try {
|
|
381
|
+
if (process.env.C64_TEST_TARGET === "mock") {
|
|
382
|
+
const res = await this.api.v1.machineResumeUpdate(":resume");
|
|
383
|
+
return { success: true, details: res.data };
|
|
384
|
+
}
|
|
385
|
+
const facade = await this.facadePromise;
|
|
386
|
+
return await facade.resume();
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
return { success: false, details: this.normaliseError(error) };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async poweroff() {
|
|
393
|
+
try {
|
|
394
|
+
const facade = await this.facadePromise;
|
|
395
|
+
return await facade.poweroff();
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
return { success: false, details: this.normaliseError(error) };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async menuButton() {
|
|
402
|
+
try {
|
|
403
|
+
if (process.env.C64_TEST_TARGET === "mock") {
|
|
404
|
+
const res = await this.api.v1.machineMenuButtonUpdate(":menu_button");
|
|
405
|
+
return { success: true, details: res.data };
|
|
406
|
+
}
|
|
407
|
+
const facade = await this.facadePromise;
|
|
408
|
+
return await facade.menuButton();
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
return { success: false, details: this.normaliseError(error) };
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
async debugregRead() {
|
|
415
|
+
try {
|
|
416
|
+
if (process.env.C64_TEST_TARGET === "mock") {
|
|
417
|
+
const res = await this.api.v1.machineDebugregList(":debugreg");
|
|
418
|
+
return { success: true, value: res.data.value, details: res.data };
|
|
419
|
+
}
|
|
420
|
+
const facade = await this.facadePromise;
|
|
421
|
+
return await facade.debugregRead();
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
return { success: false, details: this.normaliseError(error) };
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
async debugregWrite(value) {
|
|
428
|
+
try {
|
|
429
|
+
if (process.env.C64_TEST_TARGET === "mock") {
|
|
430
|
+
const res = await this.api.v1.machineDebugregUpdate(":debugreg", { value });
|
|
431
|
+
return { success: true, value: res.data.value, details: res.data };
|
|
432
|
+
}
|
|
433
|
+
const facade = await this.facadePromise;
|
|
434
|
+
return await facade.debugregWrite(value);
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
return { success: false, details: this.normaliseError(error) };
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
async drivesList() {
|
|
441
|
+
const facade = await this.facadePromise;
|
|
442
|
+
return facade.drivesList();
|
|
443
|
+
}
|
|
444
|
+
async driveMount(drive, imagePath, options) {
|
|
445
|
+
try {
|
|
446
|
+
const facade = await this.facadePromise;
|
|
447
|
+
return await facade.driveMount(drive, imagePath, options);
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
return { success: false, details: this.normaliseError(error) };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
async driveRemove(drive) {
|
|
454
|
+
try {
|
|
455
|
+
const facade = await this.facadePromise;
|
|
456
|
+
return await facade.driveRemove(drive);
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
return { success: false, details: this.normaliseError(error) };
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
async driveReset(drive) {
|
|
463
|
+
try {
|
|
464
|
+
const facade = await this.facadePromise;
|
|
465
|
+
return await facade.driveReset(drive);
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
return { success: false, details: this.normaliseError(error) };
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
async driveOn(drive) {
|
|
472
|
+
try {
|
|
473
|
+
const facade = await this.facadePromise;
|
|
474
|
+
return await facade.driveOn(drive);
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
return { success: false, details: this.normaliseError(error) };
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
async driveOff(drive) {
|
|
481
|
+
try {
|
|
482
|
+
const facade = await this.facadePromise;
|
|
483
|
+
return await facade.driveOff(drive);
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
return { success: false, details: this.normaliseError(error) };
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
async driveLoadRom(drive, path) {
|
|
490
|
+
try {
|
|
491
|
+
if (!drive || !path)
|
|
492
|
+
throw new Error("Drive and path are required");
|
|
493
|
+
if (process.env.C64_TEST_TARGET === "mock") {
|
|
494
|
+
const res = await this.api.v1.drivesLoadRomUpdate(drive, ":load_rom", { file: path });
|
|
495
|
+
return { success: true, details: res.data };
|
|
496
|
+
}
|
|
497
|
+
const facade = await this.facadePromise;
|
|
498
|
+
const result = await facade.driveLoadRom(drive, path);
|
|
499
|
+
return { success: result.success, details: result.details };
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
return { success: false, details: this.normaliseError(error) };
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
async driveSetMode(drive, mode) {
|
|
506
|
+
try {
|
|
507
|
+
const facade = await this.facadePromise;
|
|
508
|
+
return await facade.driveSetMode(drive, mode);
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
return { success: false, details: this.normaliseError(error) };
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
async streamStart(stream, ip) {
|
|
515
|
+
try {
|
|
516
|
+
const facade = await this.facadePromise;
|
|
517
|
+
return await facade.streamStart(stream, ip);
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
return { success: false, details: this.normaliseError(error) };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
async streamStop(stream) {
|
|
524
|
+
try {
|
|
525
|
+
const facade = await this.facadePromise;
|
|
526
|
+
return await facade.streamStop(stream);
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
return { success: false, details: this.normaliseError(error) };
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
async configsList() {
|
|
533
|
+
const facade = await this.facadePromise;
|
|
534
|
+
return facade.configsList();
|
|
535
|
+
}
|
|
536
|
+
async configGet(category, item) {
|
|
537
|
+
const facade = await this.facadePromise;
|
|
538
|
+
return facade.configGet(category, item);
|
|
539
|
+
}
|
|
540
|
+
async configSet(category, item, value) {
|
|
541
|
+
try {
|
|
542
|
+
const facade = await this.facadePromise;
|
|
543
|
+
return await facade.configSet(category, item, value);
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
return { success: false, details: this.normaliseError(error) };
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
async configBatchUpdate(payload) {
|
|
550
|
+
try {
|
|
551
|
+
const facade = await this.facadePromise;
|
|
552
|
+
return await facade.configBatchUpdate(payload);
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
return { success: false, details: this.normaliseError(error) };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
async configLoadFromFlash() {
|
|
559
|
+
try {
|
|
560
|
+
const facade = await this.facadePromise;
|
|
561
|
+
return await facade.configLoadFromFlash();
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
return { success: false, details: this.normaliseError(error) };
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
async configSaveToFlash() {
|
|
568
|
+
try {
|
|
569
|
+
const facade = await this.facadePromise;
|
|
570
|
+
return await facade.configSaveToFlash();
|
|
571
|
+
}
|
|
572
|
+
catch (error) {
|
|
573
|
+
return { success: false, details: this.normaliseError(error) };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
async configResetToDefault() {
|
|
577
|
+
try {
|
|
578
|
+
const facade = await this.facadePromise;
|
|
579
|
+
return await facade.configResetToDefault();
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
return { success: false, details: this.normaliseError(error) };
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
async filesInfo(path) {
|
|
586
|
+
const facade = await this.facadePromise;
|
|
587
|
+
return facade.filesInfo(path);
|
|
588
|
+
}
|
|
589
|
+
async filesCreateD64(path, options) {
|
|
590
|
+
try {
|
|
591
|
+
const facade = await this.facadePromise;
|
|
592
|
+
return await facade.filesCreateD64(path, options);
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
return { success: false, details: this.normaliseError(error) };
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
async filesCreateD71(path, options) {
|
|
599
|
+
try {
|
|
600
|
+
const facade = await this.facadePromise;
|
|
601
|
+
return await facade.filesCreateD71(path, options);
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
return { success: false, details: this.normaliseError(error) };
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
async filesCreateD81(path, options) {
|
|
608
|
+
try {
|
|
609
|
+
const facade = await this.facadePromise;
|
|
610
|
+
return await facade.filesCreateD81(path, options);
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
return { success: false, details: this.normaliseError(error) };
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async filesCreateDnp(path, tracks, options) {
|
|
617
|
+
try {
|
|
618
|
+
const facade = await this.facadePromise;
|
|
619
|
+
return await facade.filesCreateDnp(path, tracks, options);
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
return { success: false, details: this.normaliseError(error) };
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
extractBytes(data) {
|
|
626
|
+
if (!data) {
|
|
627
|
+
return new Uint8Array();
|
|
628
|
+
}
|
|
629
|
+
// Raw binary (ArrayBuffer) response
|
|
630
|
+
if (data instanceof ArrayBuffer) {
|
|
631
|
+
return new Uint8Array(data);
|
|
632
|
+
}
|
|
633
|
+
// Node.js Buffer
|
|
634
|
+
if (Buffer.isBuffer(data)) {
|
|
635
|
+
return new Uint8Array(data);
|
|
636
|
+
}
|
|
637
|
+
// Already a Uint8Array
|
|
638
|
+
if (data instanceof Uint8Array) {
|
|
639
|
+
return data;
|
|
640
|
+
}
|
|
641
|
+
if (typeof data === "string") {
|
|
642
|
+
try {
|
|
643
|
+
return Uint8Array.from(Buffer.from(data, "base64"));
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
return Uint8Array.from(Buffer.from(data, "hex"));
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (Array.isArray(data.data)) {
|
|
650
|
+
return Uint8Array.from((data.data) ?? []);
|
|
651
|
+
}
|
|
652
|
+
if (Array.isArray(data)) {
|
|
653
|
+
return Uint8Array.from(data);
|
|
654
|
+
}
|
|
655
|
+
if (typeof data === "object" && data !== null) {
|
|
656
|
+
const maybe = data.data;
|
|
657
|
+
if (typeof maybe === "string") {
|
|
658
|
+
return Uint8Array.from(Buffer.from(maybe, "base64"));
|
|
659
|
+
}
|
|
660
|
+
if (Array.isArray(maybe)) {
|
|
661
|
+
return Uint8Array.from(maybe);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return new Uint8Array();
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Low-level memory read that transparently handles devices returning either
|
|
668
|
+
* raw binary bytes or JSON with a base64 payload.
|
|
669
|
+
*/
|
|
670
|
+
async readMemoryRaw(address, length) {
|
|
671
|
+
try {
|
|
672
|
+
const facade = await this.facadePromise;
|
|
673
|
+
return await facade.readMemory(address, length);
|
|
674
|
+
}
|
|
675
|
+
catch (facadeError) {
|
|
676
|
+
if (!(facadeError instanceof Error) || facadeError.code !== "UNSUPPORTED") {
|
|
677
|
+
throw facadeError;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const addrStr = this.formatAddress(address);
|
|
681
|
+
const response = await this.api.v1.machineReadmemList(":readmem", { address: addrStr, length }, { format: "arraybuffer", headers: { Accept: "application/octet-stream, application/json" } });
|
|
682
|
+
const contentType = (response.headers?.["content-type"] ?? "").toString().toLowerCase();
|
|
683
|
+
const body = response.data;
|
|
684
|
+
if (contentType.includes("application/json")) {
|
|
685
|
+
const text = Buffer.from(body).toString("utf8");
|
|
686
|
+
try {
|
|
687
|
+
const parsed = JSON.parse(text);
|
|
688
|
+
return this.extractBytes(parsed?.data ?? parsed);
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
return this.extractBytes(text);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return body instanceof ArrayBuffer ? new Uint8Array(body) : this.extractBytes(body);
|
|
695
|
+
}
|
|
696
|
+
normaliseError(error) {
|
|
697
|
+
if (axios.isAxiosError(error)) {
|
|
698
|
+
return {
|
|
699
|
+
message: error.message,
|
|
700
|
+
status: error.response?.status,
|
|
701
|
+
data: error.response?.data,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
if (error instanceof Error) {
|
|
705
|
+
return { message: error.message };
|
|
706
|
+
}
|
|
707
|
+
return error;
|
|
708
|
+
}
|
|
709
|
+
parseNumeric(value) {
|
|
710
|
+
if (typeof value !== "string") {
|
|
711
|
+
throw new Error("Expected string input");
|
|
712
|
+
}
|
|
713
|
+
const trimmed = value.trim();
|
|
714
|
+
if (trimmed.length === 0) {
|
|
715
|
+
throw new Error("Empty numeric value");
|
|
716
|
+
}
|
|
717
|
+
const lower = trimmed.toLowerCase();
|
|
718
|
+
let radix = 10;
|
|
719
|
+
let literal = lower;
|
|
720
|
+
if (lower.startsWith("$")) {
|
|
721
|
+
radix = 16;
|
|
722
|
+
literal = lower.slice(1);
|
|
723
|
+
}
|
|
724
|
+
else if (lower.startsWith("0x")) {
|
|
725
|
+
radix = 16;
|
|
726
|
+
literal = lower.slice(2);
|
|
727
|
+
}
|
|
728
|
+
else if (lower.startsWith("%")) {
|
|
729
|
+
radix = 2;
|
|
730
|
+
literal = lower.slice(1);
|
|
731
|
+
}
|
|
732
|
+
const parsed = Number.parseInt(literal, radix);
|
|
733
|
+
if (!Number.isFinite(parsed) || Number.isNaN(parsed) || parsed < 0) {
|
|
734
|
+
throw new Error(`Unable to parse numeric value "${value}"`);
|
|
735
|
+
}
|
|
736
|
+
return parsed;
|
|
737
|
+
}
|
|
738
|
+
formatAddress(address) {
|
|
739
|
+
if (!Number.isInteger(address) || address < 0 || address > 0xffff) {
|
|
740
|
+
throw new Error("Address must be within 0x0000 - 0xFFFF");
|
|
741
|
+
}
|
|
742
|
+
return address.toString(16).toUpperCase().padStart(4, "0");
|
|
743
|
+
}
|
|
744
|
+
bytesToHex(bytes, withPrefix = true) {
|
|
745
|
+
const hex = Buffer.from(bytes).toString("hex").toUpperCase();
|
|
746
|
+
return withPrefix ? `$${hex}` : hex;
|
|
747
|
+
}
|
|
748
|
+
hexStringToBuffer(input) {
|
|
749
|
+
if (typeof input !== "string") {
|
|
750
|
+
throw new Error("Expected byte string");
|
|
751
|
+
}
|
|
752
|
+
const trimmed = input.trim();
|
|
753
|
+
if (trimmed.length === 0) {
|
|
754
|
+
throw new Error("Expected non-empty byte string");
|
|
755
|
+
}
|
|
756
|
+
const withoutPrefix = trimmed.startsWith("$") ? trimmed.slice(1) : trimmed;
|
|
757
|
+
const cleaned = withoutPrefix.replace(/[\s_]/g, "").toLowerCase();
|
|
758
|
+
if (cleaned.length === 0) {
|
|
759
|
+
throw new Error("No hexadecimal data provided");
|
|
760
|
+
}
|
|
761
|
+
if (cleaned.length % 2 !== 0) {
|
|
762
|
+
throw new Error("Hex string must contain an even number of characters");
|
|
763
|
+
}
|
|
764
|
+
return Buffer.from(cleaned, "hex");
|
|
765
|
+
}
|
|
766
|
+
hzToSidFrequency(hz, system = "PAL") {
|
|
767
|
+
const phi2 = system === "PAL" ? 985248 : 1022727;
|
|
768
|
+
const value = Math.round((hz * 65536) / phi2);
|
|
769
|
+
// Clamp to 16-bit
|
|
770
|
+
return Math.max(0, Math.min(0xffff, value));
|
|
771
|
+
}
|
|
772
|
+
noteNameToHz(note) {
|
|
773
|
+
// Parse note like C#4, Db3, A4
|
|
774
|
+
const m = /^([A-Ga-g])([#b]?)(-?\d+)$/.exec(note.trim());
|
|
775
|
+
if (!m)
|
|
776
|
+
return 440; // default A4
|
|
777
|
+
const letter = m[1].toUpperCase();
|
|
778
|
+
const accidental = m[2];
|
|
779
|
+
const octave = Number(m[3]);
|
|
780
|
+
const semitoneMap = {
|
|
781
|
+
C: 0,
|
|
782
|
+
D: 2,
|
|
783
|
+
E: 4,
|
|
784
|
+
F: 5,
|
|
785
|
+
G: 7,
|
|
786
|
+
A: 9,
|
|
787
|
+
B: 11,
|
|
788
|
+
};
|
|
789
|
+
let semitone = semitoneMap[letter] ?? 9;
|
|
790
|
+
if (accidental === "#")
|
|
791
|
+
semitone += 1;
|
|
792
|
+
if (accidental === "b")
|
|
793
|
+
semitone -= 1;
|
|
794
|
+
const midi = (octave + 1) * 12 + semitone; // MIDI note number (C-1 => 0)
|
|
795
|
+
const hz = 440 * Math.pow(2, (midi - 69) / 12);
|
|
796
|
+
return hz;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
// --- Helpers to synthesize tiny programs for sprites and PETSCII screens ---
|
|
800
|
+
function toByte(value, fallback = 0) {
|
|
801
|
+
const v = value ?? fallback;
|
|
802
|
+
return Math.max(0, Math.min(255, v)) & 0xff;
|
|
803
|
+
}
|
|
804
|
+
function buildSingleSpriteProgram(opts) {
|
|
805
|
+
const index = Math.max(0, Math.min(7, opts.spriteIndex ?? 0));
|
|
806
|
+
const mx = Math.max(0, Math.min(511, opts.x ?? 100));
|
|
807
|
+
const xLo = mx & 0xff;
|
|
808
|
+
const xMsbBit = (mx & 0x100) ? (1 << index) : 0;
|
|
809
|
+
const y = toByte(opts.y, 100);
|
|
810
|
+
const color = toByte(opts.color, 1);
|
|
811
|
+
const multicolour = !!opts.multicolour;
|
|
812
|
+
const spriteData = Buffer.from(opts.spriteBytes);
|
|
813
|
+
if (spriteData.length !== 63) {
|
|
814
|
+
throw new Error("spriteBytes must be exactly 63 bytes");
|
|
815
|
+
}
|
|
816
|
+
// We'll assemble a tiny machine-code program that:
|
|
817
|
+
// - Copies 63 bytes to a safe sprite data page ($2000 by default)
|
|
818
|
+
// - Sets screen memory base to $0400, sprite pointer to point into $2000
|
|
819
|
+
// - Positions and enables the sprite
|
|
820
|
+
// - Loops forever
|
|
821
|
+
// This avoids relying on KERNAL calls and works from a cold start.
|
|
822
|
+
const SPRITE_BASE = 0x2000; // must be 64-byte aligned
|
|
823
|
+
const POINTER_PAGE = 0x07f8 + index; // sprite pointer table location
|
|
824
|
+
const pointerValue = (SPRITE_BASE >> 6) & 0xff;
|
|
825
|
+
// Place code starting at $0801 so it runs as a program via SYS.
|
|
826
|
+
// We'll create an assembler source and reuse assemblyToPrg.
|
|
827
|
+
const lines = [];
|
|
828
|
+
lines.push("* = $0801");
|
|
829
|
+
// Tiny BASIC loader header not needed; we will use pure ML and jump via RESET runner which executes by SYS 2061
|
|
830
|
+
// Build code at $0810 to avoid conflicting with potential KERNAL vectors
|
|
831
|
+
lines.push("* = $0810");
|
|
832
|
+
lines.push("\nstart:");
|
|
833
|
+
// Copy 63 bytes from inlined table to SPRITE_BASE
|
|
834
|
+
lines.push(" LDY #$00");
|
|
835
|
+
lines.push("copy_loop:");
|
|
836
|
+
lines.push(" LDA sprite_data,Y");
|
|
837
|
+
lines.push(` STA $${hex16(SPRITE_BASE)},Y`);
|
|
838
|
+
lines.push(" INY");
|
|
839
|
+
lines.push(" CPY #$3F");
|
|
840
|
+
lines.push(" BNE copy_loop");
|
|
841
|
+
// Set sprite pointer, color, coordinates, enable
|
|
842
|
+
lines.push(` LDA #$${pointerValue.toString(16).toUpperCase().padStart(2, "0")}`);
|
|
843
|
+
lines.push(` STA $${(POINTER_PAGE).toString(16).toUpperCase()}`);
|
|
844
|
+
lines.push(` LDA #$${color.toString(16).toUpperCase().padStart(2, "0")}`);
|
|
845
|
+
lines.push(` STA $${hex16(0xD027 + index)}`);
|
|
846
|
+
lines.push(` LDA #$${xLo.toString(16).toUpperCase().padStart(2, "0")}`);
|
|
847
|
+
lines.push(` STA $${(0xD000 + index * 2).toString(16).toUpperCase()}`);
|
|
848
|
+
lines.push(` LDA #$${y.toString(16).toUpperCase().padStart(2, "0")}`);
|
|
849
|
+
lines.push(` STA $${(0xD001 + index * 2).toString(16).toUpperCase()}`);
|
|
850
|
+
// MSB X if needed
|
|
851
|
+
if (xMsbBit) {
|
|
852
|
+
const bit = xMsbBit;
|
|
853
|
+
lines.push(` LDA $D010`);
|
|
854
|
+
lines.push(` ORA #$${bit.toString(16).toUpperCase().padStart(2, "0")}`);
|
|
855
|
+
lines.push(` STA $D010`);
|
|
856
|
+
}
|
|
857
|
+
// Multicolour toggle per-sprite
|
|
858
|
+
if (multicolour) {
|
|
859
|
+
const bit = 1 << index;
|
|
860
|
+
lines.push(` LDA $D01C`);
|
|
861
|
+
lines.push(` ORA #$${bit.toString(16).toUpperCase().padStart(2, "0")}`);
|
|
862
|
+
lines.push(` STA $D01C`);
|
|
863
|
+
}
|
|
864
|
+
// Enable sprite
|
|
865
|
+
{
|
|
866
|
+
const bit = 1 << index;
|
|
867
|
+
lines.push(` LDA $D015`);
|
|
868
|
+
lines.push(` ORA #$${bit.toString(16).toUpperCase().padStart(2, "0")}`);
|
|
869
|
+
lines.push(` STA $D015`);
|
|
870
|
+
}
|
|
871
|
+
// Idle loop
|
|
872
|
+
lines.push("forever: JMP forever");
|
|
873
|
+
// Sprite data table
|
|
874
|
+
lines.push("\nsprite_data:");
|
|
875
|
+
for (let i = 0; i < 63; i += 3) {
|
|
876
|
+
const a = spriteData[i] ?? 0;
|
|
877
|
+
const b = spriteData[i + 1] ?? 0;
|
|
878
|
+
const c = spriteData[i + 2] ?? 0;
|
|
879
|
+
lines.push(` .byte $${hex2(a)},$${hex2(b)},$${hex2(c)}`);
|
|
880
|
+
}
|
|
881
|
+
const source = lines.join("\n");
|
|
882
|
+
return assemblyToPrg(source, { fileName: "sprite_gen.asm", loadAddress: 0x0801 });
|
|
883
|
+
}
|
|
884
|
+
function buildPetsciiScreenBasic(opts) {
|
|
885
|
+
const border = toByte(opts.borderColor ?? 6); // default blue-ish
|
|
886
|
+
const bg = toByte(opts.backgroundColor ?? 0); // default black
|
|
887
|
+
// Clear screen, set colours, print text starting at 1,1
|
|
888
|
+
// Note: CHR$(147) clears the screen.
|
|
889
|
+
const sanitized = opts.text.replace(/\r\n?|\n/g, "\\n");
|
|
890
|
+
const program = [
|
|
891
|
+
`10 POKE 53280,${border}:POKE 53281,${bg}:PRINT CHR$(147)`,
|
|
892
|
+
`20 PRINT "${sanitized}"`,
|
|
893
|
+
`30 GETA$:IFA$<>""THENEND:REM wait for key then end`,
|
|
894
|
+
].join("\n");
|
|
895
|
+
return program;
|
|
896
|
+
}
|
|
897
|
+
export function buildPrinterBasicProgram(opts) {
|
|
898
|
+
const target = opts.target ?? "commodore";
|
|
899
|
+
const saddr = typeof opts.secondaryAddress === "number" ? opts.secondaryAddress : undefined;
|
|
900
|
+
const lines = [];
|
|
901
|
+
// OPEN printer device (#1 to device 4 with optional secondary address)
|
|
902
|
+
if (saddr === 0 || saddr === 7) {
|
|
903
|
+
lines.push(`10 OPEN1,4,${saddr}`);
|
|
904
|
+
}
|
|
905
|
+
else {
|
|
906
|
+
lines.push("10 OPEN1,4");
|
|
907
|
+
}
|
|
908
|
+
// Prepare text: split by CR/LF and emit PRINT# statements.
|
|
909
|
+
// We chunk long logical lines to avoid very long BASIC lines; join chunks with ';' to avoid extra CRs.
|
|
910
|
+
const raw = opts.text ?? "";
|
|
911
|
+
const logicalLines = raw.split(/\r\n|\r|\n/);
|
|
912
|
+
let ln = 20;
|
|
913
|
+
for (const logical of logicalLines) {
|
|
914
|
+
if (logical.length === 0) {
|
|
915
|
+
lines.push(`${ln} PRINT#1`);
|
|
916
|
+
ln += 10;
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
const chunks = chunkString(logical, 60);
|
|
920
|
+
for (let i = 0; i < chunks.length; i += 1) {
|
|
921
|
+
const chunk = escapeBasicQuotes(chunks[i]);
|
|
922
|
+
const tail = i < chunks.length - 1 ? ";" : ""; // avoid CR between chunks within the same logical line
|
|
923
|
+
lines.push(`${ln} PRINT#1,"${chunk}"${tail}`);
|
|
924
|
+
ln += 10;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
if (opts.formFeed) {
|
|
928
|
+
lines.push(`${ln} PRINT#1,CHR$(12)`);
|
|
929
|
+
ln += 10;
|
|
930
|
+
}
|
|
931
|
+
// Minimal target-specific toggles could be inserted here in future
|
|
932
|
+
// (e.g., ESC/P mode selections for Epson). Default is raw text output.
|
|
933
|
+
lines.push(`${ln} CLOSE1`);
|
|
934
|
+
return lines.join("\n");
|
|
935
|
+
}
|
|
936
|
+
function chunkString(input, maxLen) {
|
|
937
|
+
if (input.length <= maxLen)
|
|
938
|
+
return [input];
|
|
939
|
+
const parts = [];
|
|
940
|
+
let i = 0;
|
|
941
|
+
while (i < input.length) {
|
|
942
|
+
parts.push(input.slice(i, i + maxLen));
|
|
943
|
+
i += maxLen;
|
|
944
|
+
}
|
|
945
|
+
return parts;
|
|
946
|
+
}
|
|
947
|
+
function escapeBasicQuotes(input) {
|
|
948
|
+
// In Commodore BASIC, embed a double quote by doubling it
|
|
949
|
+
return input.replace(/"/g, '""');
|
|
950
|
+
}
|
|
951
|
+
export function buildCommodoreBitmapBasicProgram(opts) {
|
|
952
|
+
const repeats = Math.max(1, Math.floor(opts.repeats ?? 1));
|
|
953
|
+
const saddr = typeof opts.secondaryAddress === "number" ? opts.secondaryAddress : 7;
|
|
954
|
+
const ensureMsb = opts.ensureMsb !== false;
|
|
955
|
+
const cols = (opts.columns ?? []).map((v) => {
|
|
956
|
+
const n = Math.max(0, Math.min(255, Math.floor(v)));
|
|
957
|
+
return ensureMsb ? (n | 0x80) : n;
|
|
958
|
+
});
|
|
959
|
+
const lines = [];
|
|
960
|
+
lines.push(`10 OPEN1,4,${saddr}`);
|
|
961
|
+
lines.push(`20 A$=""`);
|
|
962
|
+
lines.push(`30 FOR I=1 TO ${cols.length}`);
|
|
963
|
+
lines.push(`40 READ A:A$=A$+CHR$(A)`);
|
|
964
|
+
lines.push(`50 NEXT I`);
|
|
965
|
+
lines.push(`60 FOR J=1 TO ${repeats}`);
|
|
966
|
+
if (typeof opts.useSubRepeat === "number") {
|
|
967
|
+
const r = Math.max(0, Math.min(255, Math.floor(opts.useSubRepeat)));
|
|
968
|
+
lines.push(`70 PRINT#1,CHR$(8);CHR$(26);CHR$(${r});A$`);
|
|
969
|
+
}
|
|
970
|
+
else {
|
|
971
|
+
lines.push(`70 PRINT#1,CHR$(8);A$`);
|
|
972
|
+
}
|
|
973
|
+
lines.push(`80 NEXT J`);
|
|
974
|
+
lines.push(`90 CLOSE1`);
|
|
975
|
+
lines.push(`100 END`);
|
|
976
|
+
let ln = 110;
|
|
977
|
+
for (let i = 0; i < cols.length; i += 8) {
|
|
978
|
+
const group = cols.slice(i, i + 8);
|
|
979
|
+
lines.push(`${ln} DATA ${group.join(",")}`);
|
|
980
|
+
ln += 10;
|
|
981
|
+
}
|
|
982
|
+
return lines.join("\n");
|
|
983
|
+
}
|
|
984
|
+
export function buildEpsonBitmapBasicProgram(opts) {
|
|
985
|
+
const cols = (opts.columns ?? []).map((v) => Math.max(0, Math.min(255, Math.floor(v))));
|
|
986
|
+
const len = cols.length;
|
|
987
|
+
const n = len & 0xff;
|
|
988
|
+
const m = (len >> 8) & 0xff;
|
|
989
|
+
const mode = (opts.mode ?? "K").toUpperCase();
|
|
990
|
+
const repeats = Math.max(1, Math.floor(opts.repeats ?? 1));
|
|
991
|
+
const timesPerLine = Math.max(1, Math.floor(opts.timesPerLine ?? 4));
|
|
992
|
+
function modeCode(mo) {
|
|
993
|
+
const map = { K: 75, L: 76, Y: 89, Z: 90 };
|
|
994
|
+
return map[mo] ?? null;
|
|
995
|
+
}
|
|
996
|
+
const lines = [];
|
|
997
|
+
lines.push(`10 OPEN1,4`);
|
|
998
|
+
if (mode === "*") {
|
|
999
|
+
const density = Math.max(0, Math.min(6, Math.floor(opts.density ?? 0)));
|
|
1000
|
+
lines.push(`20 A$=CHR$(27)+"*"+CHR$(${density})+CHR$(${n})+CHR$(${m})`);
|
|
1001
|
+
}
|
|
1002
|
+
else {
|
|
1003
|
+
const mc = modeCode(mode);
|
|
1004
|
+
lines.push(`20 A$=CHR$(27)+CHR$(${mc})+CHR$(${n})+CHR$(${m})`);
|
|
1005
|
+
}
|
|
1006
|
+
lines.push(`30 FOR I=1 TO ${cols.length}`);
|
|
1007
|
+
lines.push(`40 READ A:A$=A$+CHR$(A)`);
|
|
1008
|
+
lines.push(`50 NEXT I`);
|
|
1009
|
+
lines.push(`60 PRINT#1,CHR$(27);CHR$(65);CHR$(8);CHR$(10);CHR$(13)`);
|
|
1010
|
+
lines.push(`70 FOR J=1 TO ${repeats}`);
|
|
1011
|
+
const seg = Array.from({ length: timesPerLine }).map(() => "A$").join(";");
|
|
1012
|
+
lines.push(`80 PRINT#1,${seg};CHR$(10);CHR$(13)`);
|
|
1013
|
+
lines.push(`90 NEXT J`);
|
|
1014
|
+
lines.push(`100 CLOSE1`);
|
|
1015
|
+
lines.push(`110 END`);
|
|
1016
|
+
let ln = 120;
|
|
1017
|
+
for (let i = 0; i < cols.length; i += 8) {
|
|
1018
|
+
const group = cols.slice(i, i + 8);
|
|
1019
|
+
lines.push(`${ln} DATA ${group.join(",")}`);
|
|
1020
|
+
ln += 10;
|
|
1021
|
+
}
|
|
1022
|
+
return lines.join("\n");
|
|
1023
|
+
}
|
|
1024
|
+
export function buildCommodoreDllBasicProgram(opts) {
|
|
1025
|
+
const firstChar = Math.max(33, Math.min(126, Math.floor(opts.firstChar)));
|
|
1026
|
+
const numChars = Math.max(1, Math.floor(opts.chars?.length ?? 0));
|
|
1027
|
+
const saddr = typeof opts.secondaryAddress === "number" ? opts.secondaryAddress : 0;
|
|
1028
|
+
const t = numChars * 13 + 2;
|
|
1029
|
+
const n = Math.floor(t / 256);
|
|
1030
|
+
const m = t - n * 256;
|
|
1031
|
+
const s = 32;
|
|
1032
|
+
const a = Math.max(0, Math.min(1, Math.floor(opts.chars?.[0]?.a ?? 0)));
|
|
1033
|
+
const lines = [];
|
|
1034
|
+
lines.push(`10 OPEN1,4${saddr === 0 ? "" : "," + saddr}`);
|
|
1035
|
+
lines.push(`20 PRINT#1,CHR$(27);"=";CHR$(${m});CHR$(${n});CHR$(${firstChar});CHR$(${s});CHR$(${a})`);
|
|
1036
|
+
let ln = 30;
|
|
1037
|
+
for (let idx = 0; idx < numChars; idx += 1) {
|
|
1038
|
+
const cols = (opts.chars[idx]?.columns ?? []).slice(0, 11).map((v) => Math.max(0, Math.min(255, Math.floor(v))));
|
|
1039
|
+
while (cols.length < 11)
|
|
1040
|
+
cols.push(0);
|
|
1041
|
+
lines.push(`${ln} PRINT#1${cols.map((v) => `,CHR$(${v})`).join("")}`);
|
|
1042
|
+
ln += 10;
|
|
1043
|
+
}
|
|
1044
|
+
lines.push(`${ln} CLOSE1`);
|
|
1045
|
+
return lines.join("\n");
|
|
1046
|
+
}
|
|
1047
|
+
function hex2(n) {
|
|
1048
|
+
return (n & 0xff).toString(16).toUpperCase().padStart(2, "0");
|
|
1049
|
+
}
|
|
1050
|
+
function hex16(n) {
|
|
1051
|
+
return (n & 0xffff).toString(16).toUpperCase().padStart(4, "0");
|
|
1052
|
+
}
|