c64bridge 0.7.4 → 0.8.1
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 +14 -4
- package/.github/prompts/assembly-routine-session.prompt.md +9 -9
- package/.github/prompts/basic-program-session.prompt.md +11 -9
- package/.github/prompts/drive-management-session.prompt.md +9 -9
- package/.github/prompts/hello-world-session.prompt.md +17 -0
- package/.github/prompts/petscii-art-session.prompt.md +9 -9
- package/.github/prompts/play-classic-game.prompt.md +9 -9
- package/.github/prompts/pr-converge.prompt.md +13 -0
- package/.github/prompts/sid-music-session.prompt.md +9 -9
- package/AGENTS.md +59 -60
- package/README.md +550 -191
- package/data/assembly/assembly-spec.md +290 -1
- package/data/context/bootstrap.md +26 -2
- package/data/context/fast-paths.md +44 -0
- package/data/graphics/petscii-style-guide.md +6 -2
- package/data/graphics/sprite-charset-best-practices.md +5 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/c64Client.js +990 -26
- package/dist/config.js +5 -1
- package/dist/device.js +680 -57
- package/dist/diagnostics.js +371 -0
- package/dist/mcp/metadata.js +39 -0
- package/dist/mcp-server.js +170 -20
- package/dist/platform.js +10 -2
- package/dist/prompts/registry.js +166 -309
- package/dist/rag/init.js +37 -1
- package/dist/rag/knowledgeIndex.js +18 -6
- package/dist/rag/rateLimiter.js +2 -2
- package/dist/streamCapture.js +135 -0
- package/dist/tools/audio.js +3 -1
- package/dist/tools/debug.js +470 -0
- package/dist/tools/developer.js +10 -4
- package/dist/tools/graphics.js +183 -17
- package/dist/tools/machineControl.js +11 -5
- package/dist/tools/memory.js +19 -10
- package/dist/tools/meta/audio.js +240 -1
- package/dist/tools/meta/config.js +20 -28
- package/dist/tools/meta/configInventory.js +104 -0
- package/dist/tools/meta/diagnostics.js +45 -2
- package/dist/tools/meta/graphics.js +14 -6
- package/dist/tools/meta/index.js +1 -0
- package/dist/tools/meta/memory.js +20 -6
- package/dist/tools/meta/program.js +473 -0
- package/dist/tools/pollValidator.js +4 -2
- package/dist/tools/programRunners.js +8 -4
- package/dist/tools/registry/config.js +17 -0
- package/dist/tools/registry/disk.js +7 -0
- package/dist/tools/registry/drive.js +3 -0
- package/dist/tools/registry/graphics.js +111 -27
- package/dist/tools/registry/index.js +6 -0
- package/dist/tools/registry/memory.js +1 -0
- package/dist/tools/registry/platform.js +121 -0
- package/dist/tools/registry/program.js +26 -8
- package/dist/tools/registry/sound.js +96 -0
- package/dist/tools/registry/system.js +15 -2
- package/dist/tools/storage.js +1 -0
- package/dist/tools/streaming.js +1 -0
- package/dist/tools/types.js +38 -4
- package/dist/tools/vice.js +160 -0
- package/dist/vicBitmap.js +317 -0
- package/dist/vice/mockServer.js +439 -0
- package/dist/vice/process.js +310 -0
- package/dist/vice/readiness.js +18 -2
- package/dist/vice/viceClient.js +382 -22
- package/doc/developer.md +71 -25
- package/doc/img/backend-switch/hello-c64u.png +0 -0
- package/doc/img/backend-switch/hello-vice-and-c64u.png +0 -0
- package/doc/img/backend-switch/hello-vice.png +0 -0
- package/doc/img/backend-switch/metadata.json +50 -0
- package/doc/plans/vice-backend-switch/plan.md +486 -0
- package/doc/plans/vice-backend-switch-2/plan.md +48 -0
- package/doc/plans/vice-support/notes.md +1 -0
- package/doc/plans/vice-support/plan.md +5 -0
- package/doc/plans/vice-support/rollout.md +23 -17
- package/doc/reviews/vice-support-review.md +185 -0
- package/doc/troubleshooting-mcp.md +35 -6
- package/doc/vice/support-matrix.md +54 -0
- package/mcp/prompts.json +292 -0
- package/mcp/protocol-examples.json +46 -0
- package/mcp/resources.json +940 -0
- package/mcp/schemas/c64_config.schema.json +350 -0
- package/mcp/schemas/c64_debug.schema.json +380 -0
- package/mcp/schemas/c64_disk.schema.json +278 -0
- package/mcp/schemas/c64_drive.schema.json +120 -0
- package/mcp/schemas/c64_extract.schema.json +302 -0
- package/mcp/schemas/c64_graphics.schema.json +333 -0
- package/mcp/schemas/c64_memory.schema.json +155 -0
- package/mcp/schemas/c64_printer.schema.json +218 -0
- package/mcp/schemas/c64_program.schema.json +391 -0
- package/mcp/schemas/c64_rag.schema.json +67 -0
- package/mcp/schemas/c64_select_backend.schema.json +32 -0
- package/mcp/schemas/c64_sound.schema.json +644 -0
- package/mcp/schemas/c64_stream.schema.json +61 -0
- package/mcp/schemas/c64_system.schema.json +227 -0
- package/mcp/schemas/c64_vice.schema.json +63 -0
- package/mcp/server.json +15 -0
- package/mcp/tools.json +4517 -0
- package/mcp.json +90 -15
- package/package.json +11 -4
- package/scripts/capture-backend-switch-screenshots.mjs +276 -0
- package/scripts/generate-mcp-interface.ts +289 -0
- package/scripts/invoke-bun.mjs +67 -21
- package/scripts/mockC64Server.mjs +162 -6
- package/scripts/prepare-release.mjs +59 -2
- package/scripts/run-coverage.mjs +284 -0
- package/scripts/run-tests.mjs +95 -39
- package/scripts/run-tests.ts +378 -73
- package/scripts/update-readme.ts +147 -13
- package/server.json +94 -0
package/dist/c64Client.js
CHANGED
|
@@ -6,24 +6,127 @@ Licensed under the GNU General Public License v2.0 or later.
|
|
|
6
6
|
See <https://www.gnu.org/licenses/> for details.
|
|
7
7
|
*/
|
|
8
8
|
import { Buffer } from "node:buffer";
|
|
9
|
+
import { createSocket } from "node:dgram";
|
|
9
10
|
import axios from "axios";
|
|
10
11
|
import { basicToPrg } from "./basicConverter.js";
|
|
11
12
|
import { assemblyToPrg } from "./assemblyConverter.js";
|
|
12
13
|
import { screenCodesToAscii } from "./petscii.js";
|
|
13
14
|
import { resolveAddressSymbol } from "./knowledge.js";
|
|
14
|
-
import { createFacade } from "./device.js";
|
|
15
|
+
import { createAllFacades, createFacade } from "./device.js";
|
|
15
16
|
import { Api } from "../generated/c64/index.js";
|
|
16
17
|
import { createLoggingHttpClient } from "./loggingHttpClient.js";
|
|
18
|
+
import { withDiagnosticSpan, writeDiagnosticEvent } from "./diagnostics.js";
|
|
19
|
+
import { C64U_NTSC_AUDIO_SAMPLE_RATE, C64U_PAL_AUDIO_SAMPLE_RATE, collectCompleteVideoFrames, parseAudioPacket, parseVideoPacket, } from "./streamCapture.js";
|
|
20
|
+
import { buildVicBitmapRegisters, resolveVicBitmapMemoryLayout, } from "./vicBitmap.js";
|
|
21
|
+
const TEXT_SCREEN_COLUMNS = 40;
|
|
22
|
+
const TEXT_SCREEN_ROWS = 25;
|
|
23
|
+
const TEXT_SCREEN_SIZE = TEXT_SCREEN_COLUMNS * TEXT_SCREEN_ROWS;
|
|
24
|
+
const TEXT_SCREEN_ADDRESS = 0x0400;
|
|
25
|
+
const TEXT_COLOR_RAM_ADDRESS = 0xD800;
|
|
26
|
+
const SPRITE_DATA_BASE_ADDRESS = 0x2000;
|
|
27
|
+
const DEFAULT_TEXT_FOREGROUND = 1;
|
|
28
|
+
const DEFAULT_BORDER_COLOR = 6;
|
|
29
|
+
const DEFAULT_BACKGROUND_COLOR = 0;
|
|
30
|
+
const SPACE_SCREEN_CODE = 0x20;
|
|
17
31
|
export class C64Client {
|
|
18
32
|
constructor(baseUrl, options = {}) {
|
|
33
|
+
this.allFacades = new Map();
|
|
34
|
+
this.warmupPromises = new Map();
|
|
35
|
+
this.greetingDd00Cache = new Map();
|
|
36
|
+
this.greetingDd00Warmups = new Map();
|
|
37
|
+
this.c64uVideoCaptureSession = null;
|
|
38
|
+
this.activeType = "c64u";
|
|
39
|
+
this.baseUrl = baseUrl;
|
|
19
40
|
const headers = options.networkPassword ? { "X-Password": options.networkPassword } : undefined;
|
|
20
41
|
this.http = createLoggingHttpClient({ baseURL: baseUrl, timeout: 10000, headers });
|
|
21
42
|
this.api = new Api(this.http);
|
|
22
|
-
|
|
23
|
-
|
|
43
|
+
const forceC64uFacade = options.forceC64uFacade ?? true;
|
|
44
|
+
if (forceC64uFacade) {
|
|
45
|
+
this.facadePromise = createFacade(undefined, {
|
|
46
|
+
preferredC64uBaseUrl: baseUrl,
|
|
47
|
+
preferredC64uNetworkPassword: options.networkPassword,
|
|
48
|
+
}).then((sel) => sel.facade);
|
|
49
|
+
this.allFacades.set("c64u", this.facadePromise);
|
|
50
|
+
this.initPromise = Promise.resolve();
|
|
51
|
+
void this.primeGreetingDd00Cache(["c64u"]);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const allFacadesPromise = createAllFacades(undefined, {
|
|
24
55
|
preferredC64uBaseUrl: baseUrl,
|
|
25
56
|
preferredC64uNetworkPassword: options.networkPassword,
|
|
26
|
-
})
|
|
57
|
+
});
|
|
58
|
+
this.facadePromise = allFacadesPromise.then(({ primary }) => primary.facade);
|
|
59
|
+
this.initPromise = allFacadesPromise.then(({ primary, secondary, secondaryType }) => {
|
|
60
|
+
const primaryPromise = Promise.resolve(primary.facade);
|
|
61
|
+
this.activeType = primary.selected;
|
|
62
|
+
this.facadePromise = primaryPromise;
|
|
63
|
+
this.allFacades.set(primary.selected, primaryPromise);
|
|
64
|
+
if (secondary && secondaryType) {
|
|
65
|
+
this.allFacades.set(secondaryType, Promise.resolve(secondary));
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
void this.initPromise.then(async () => {
|
|
69
|
+
if (this.allFacades.has("vice")) {
|
|
70
|
+
await this.prewarmBackends(["vice"]);
|
|
71
|
+
}
|
|
72
|
+
void this.primeGreetingDd00Cache();
|
|
73
|
+
}).catch(() => { });
|
|
74
|
+
}
|
|
75
|
+
async requireViceBackend() {
|
|
76
|
+
const facade = await this.facadePromise;
|
|
77
|
+
if (facade.type !== "vice") {
|
|
78
|
+
throw new Error("VICE-specific operation requested while connected to Ultimate hardware");
|
|
79
|
+
}
|
|
80
|
+
return facade;
|
|
81
|
+
}
|
|
82
|
+
async getBackendType() {
|
|
83
|
+
return this.getActiveBackendType();
|
|
84
|
+
}
|
|
85
|
+
async getActiveBackendType() {
|
|
86
|
+
await this.initPromise;
|
|
87
|
+
return this.activeType;
|
|
88
|
+
}
|
|
89
|
+
getAvailableBackends() {
|
|
90
|
+
return Array.from(this.allFacades.keys());
|
|
91
|
+
}
|
|
92
|
+
async prewarmBackends(types) {
|
|
93
|
+
return withDiagnosticSpan("client", "prewarm_backends", { types: types ?? null }, async () => {
|
|
94
|
+
await this.initPromise;
|
|
95
|
+
const targets = (types && types.length > 0 ? [...types] : this.getAvailableBackends()).filter((type, index, all) => all.indexOf(type) === index);
|
|
96
|
+
const entries = await Promise.all(targets.map(async (type) => {
|
|
97
|
+
let warmup = this.warmupPromises.get(type);
|
|
98
|
+
if (!warmup) {
|
|
99
|
+
const facadePromise = this.allFacades.get(type);
|
|
100
|
+
if (!facadePromise) {
|
|
101
|
+
throw new Error(`Backend '${type}' is not configured`);
|
|
102
|
+
}
|
|
103
|
+
warmup = facadePromise.then((facade) => facade.ping()).catch(() => false);
|
|
104
|
+
this.warmupPromises.set(type, warmup);
|
|
105
|
+
}
|
|
106
|
+
return [type, await warmup];
|
|
107
|
+
}));
|
|
108
|
+
return Object.fromEntries(entries);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
switchBackend(type) {
|
|
112
|
+
const nextFacade = this.allFacades.get(type);
|
|
113
|
+
if (!nextFacade) {
|
|
114
|
+
throw new Error(`Backend '${type}' is not configured`);
|
|
115
|
+
}
|
|
116
|
+
// Platform state is owned by the caller so tool-level flows can update global routing explicitly.
|
|
117
|
+
this.facadePromise = nextFacade;
|
|
118
|
+
this.activeType = type;
|
|
119
|
+
if (type !== "c64u" && this.c64uVideoCaptureSession) {
|
|
120
|
+
void this.releaseVideoCapture().catch(() => { });
|
|
121
|
+
}
|
|
122
|
+
writeDiagnosticEvent("client_switch_backend", { backend: type });
|
|
123
|
+
}
|
|
124
|
+
async shouldUseC64uMockBypass() {
|
|
125
|
+
return process.env.C64_TEST_TARGET === "mock" && (await this.getBackendType()) === "c64u";
|
|
126
|
+
}
|
|
127
|
+
async withViceMonitor(fn) {
|
|
128
|
+
const backend = await this.requireViceBackend();
|
|
129
|
+
return backend.withMonitor(fn);
|
|
27
130
|
}
|
|
28
131
|
/**
|
|
29
132
|
* Generate a BASIC program that opens the printer (device 4), prints the provided text,
|
|
@@ -57,16 +160,16 @@ export class C64Client {
|
|
|
57
160
|
return this.uploadAndRunBasic(program);
|
|
58
161
|
}
|
|
59
162
|
async uploadAndRunBasic(program) {
|
|
60
|
-
|
|
61
|
-
|
|
163
|
+
return withDiagnosticSpan("client", "upload_run_basic", { programLength: program.length }, async () => {
|
|
164
|
+
const prg = basicToPrg(program);
|
|
165
|
+
return this.runPrg(prg);
|
|
166
|
+
});
|
|
62
167
|
}
|
|
63
168
|
/**
|
|
64
|
-
*
|
|
65
|
-
* Returns the REST result after uploading and running the generated PRG.
|
|
169
|
+
* Render a sprite directly by writing sprite data, screen memory, and VIC-II registers.
|
|
66
170
|
*/
|
|
67
171
|
async generateAndRunSpritePrg(options) {
|
|
68
|
-
|
|
69
|
-
return this.runPrg(prg);
|
|
172
|
+
return this.displaySprite(options);
|
|
70
173
|
}
|
|
71
174
|
/**
|
|
72
175
|
* Build a BASIC program that draws a PETSCII screen (optionally set border/bg colours),
|
|
@@ -76,13 +179,192 @@ export class C64Client {
|
|
|
76
179
|
const program = buildPetsciiScreenBasic(options);
|
|
77
180
|
return this.uploadAndRunBasic(program);
|
|
78
181
|
}
|
|
182
|
+
async renderGreetingScreen(options) {
|
|
183
|
+
return withDiagnosticSpan("client", "render_greeting_screen", { messageLength: options.message.length }, async () => {
|
|
184
|
+
try {
|
|
185
|
+
const facade = await this.facadePromise;
|
|
186
|
+
const currentDd00 = await this.getGreetingDd00(facade.type, facade);
|
|
187
|
+
const textRegisters = buildVicTextRegisters({
|
|
188
|
+
currentDd00,
|
|
189
|
+
borderColor: options.borderColor ?? DEFAULT_BORDER_COLOR,
|
|
190
|
+
backgroundColor: options.backgroundColor ?? DEFAULT_BACKGROUND_COLOR,
|
|
191
|
+
});
|
|
192
|
+
const screenRam = buildGreetingScreenRam(options.message);
|
|
193
|
+
const colorRam = new Uint8Array(TEXT_SCREEN_SIZE).fill(DEFAULT_TEXT_FOREGROUND);
|
|
194
|
+
const writes = [
|
|
195
|
+
{ address: TEXT_SCREEN_ADDRESS, bytes: screenRam },
|
|
196
|
+
{ address: TEXT_COLOR_RAM_ADDRESS, bytes: colorRam },
|
|
197
|
+
{ address: 0xDD00, bytes: Uint8Array.of(textRegisters.dd00) },
|
|
198
|
+
{ address: 0xD011, bytes: Uint8Array.of(textRegisters.d011) },
|
|
199
|
+
{ address: 0xD016, bytes: Uint8Array.of(textRegisters.d016) },
|
|
200
|
+
{ address: 0xD018, bytes: Uint8Array.of(textRegisters.d018) },
|
|
201
|
+
{ address: 0xD020, bytes: Uint8Array.of(textRegisters.d020) },
|
|
202
|
+
{ address: 0xD021, bytes: Uint8Array.of(textRegisters.d021) },
|
|
203
|
+
];
|
|
204
|
+
if (typeof facade.writeMemoryBlocks === "function") {
|
|
205
|
+
await facade.writeMemoryBlocks(writes);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
for (const write of writes) {
|
|
209
|
+
await facade.writeMemory(write.address, write.bytes);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
success: true,
|
|
214
|
+
details: {
|
|
215
|
+
mode: "direct_screen_write",
|
|
216
|
+
screenAddress: this.formatAddress(TEXT_SCREEN_ADDRESS),
|
|
217
|
+
colorRamAddress: this.formatAddress(TEXT_COLOR_RAM_ADDRESS),
|
|
218
|
+
registers: textRegisters,
|
|
219
|
+
message: options.message,
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
return {
|
|
225
|
+
success: false,
|
|
226
|
+
details: this.normaliseError(error),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
async displaySprite(options) {
|
|
232
|
+
try {
|
|
233
|
+
const index = Math.max(0, Math.min(7, Math.floor(options.spriteIndex ?? 0)));
|
|
234
|
+
const x = Math.max(0, Math.min(511, Math.floor(options.x ?? 100)));
|
|
235
|
+
const y = Math.max(0, Math.min(255, Math.floor(options.y ?? 100)));
|
|
236
|
+
const color = normaliseColorNibble(options.color ?? 1);
|
|
237
|
+
const multicolour = options.multicolour === true;
|
|
238
|
+
const spriteBytes = Buffer.from(options.spriteBytes);
|
|
239
|
+
if (spriteBytes.length !== 63) {
|
|
240
|
+
throw new Error("spriteBytes must be exactly 63 bytes");
|
|
241
|
+
}
|
|
242
|
+
const spriteSlot = Buffer.alloc(64, 0x00);
|
|
243
|
+
spriteBytes.copy(spriteSlot, 0, 0, 63);
|
|
244
|
+
const spriteAddress = SPRITE_DATA_BASE_ADDRESS + index * 0x40;
|
|
245
|
+
const pointerAddress = TEXT_SCREEN_ADDRESS + 0x03F8 + index;
|
|
246
|
+
const pointerValue = (spriteAddress & 0x3FFF) >> 6;
|
|
247
|
+
const bitMask = 1 << index;
|
|
248
|
+
const screenRam = new Uint8Array(TEXT_SCREEN_SIZE).fill(SPACE_SCREEN_CODE);
|
|
249
|
+
screenRam[0x03F8 + index] = pointerValue & 0xFF;
|
|
250
|
+
const colorRam = new Uint8Array(TEXT_SCREEN_SIZE).fill(DEFAULT_TEXT_FOREGROUND);
|
|
251
|
+
const facade = await this.facadePromise;
|
|
252
|
+
const currentDd00 = await this.readByteOrDefault(facade, 0xDD00, 0);
|
|
253
|
+
const currentBorder = await this.readByteOrDefault(facade, 0xD020, DEFAULT_BORDER_COLOR);
|
|
254
|
+
const currentBackground = await this.readByteOrDefault(facade, 0xD021, DEFAULT_BACKGROUND_COLOR);
|
|
255
|
+
const currentD010 = await this.readByteOrDefault(facade, 0xD010, 0);
|
|
256
|
+
const currentD015 = await this.readByteOrDefault(facade, 0xD015, 0);
|
|
257
|
+
const currentD01C = await this.readByteOrDefault(facade, 0xD01C, 0);
|
|
258
|
+
const textRegisters = buildVicTextRegisters({
|
|
259
|
+
currentDd00,
|
|
260
|
+
borderColor: currentBorder,
|
|
261
|
+
backgroundColor: currentBackground,
|
|
262
|
+
});
|
|
263
|
+
const d010 = (currentD010 & ~bitMask) | (x > 0xFF ? bitMask : 0);
|
|
264
|
+
const d015 = currentD015 | bitMask;
|
|
265
|
+
const d01c = (currentD01C & ~bitMask) | (multicolour ? bitMask : 0);
|
|
266
|
+
await facade.writeMemory(spriteAddress, spriteSlot);
|
|
267
|
+
await facade.writeMemory(TEXT_SCREEN_ADDRESS, screenRam);
|
|
268
|
+
await facade.writeMemory(TEXT_COLOR_RAM_ADDRESS, colorRam);
|
|
269
|
+
await facade.writeMemory(0xDD00, Uint8Array.of(textRegisters.dd00));
|
|
270
|
+
await facade.writeMemory(0xD011, Uint8Array.of(textRegisters.d011));
|
|
271
|
+
await facade.writeMemory(0xD016, Uint8Array.of(textRegisters.d016));
|
|
272
|
+
await facade.writeMemory(0xD018, Uint8Array.of(textRegisters.d018));
|
|
273
|
+
await facade.writeMemory(0xD020, Uint8Array.of(textRegisters.d020));
|
|
274
|
+
await facade.writeMemory(0xD021, Uint8Array.of(textRegisters.d021));
|
|
275
|
+
await facade.writeMemory(0xD000 + index * 2, Uint8Array.of(x & 0xFF));
|
|
276
|
+
await facade.writeMemory(0xD001 + index * 2, Uint8Array.of(y & 0xFF));
|
|
277
|
+
await facade.writeMemory(0xD010, Uint8Array.of(d010));
|
|
278
|
+
await facade.writeMemory(0xD015, Uint8Array.of(d015));
|
|
279
|
+
await facade.writeMemory(0xD01C, Uint8Array.of(d01c));
|
|
280
|
+
await facade.writeMemory(0xD027 + index, Uint8Array.of(color));
|
|
281
|
+
return {
|
|
282
|
+
success: true,
|
|
283
|
+
details: {
|
|
284
|
+
bank: 0,
|
|
285
|
+
spriteAddress: this.formatAddress(spriteAddress),
|
|
286
|
+
screenAddress: this.formatAddress(TEXT_SCREEN_ADDRESS),
|
|
287
|
+
colorRamAddress: this.formatAddress(TEXT_COLOR_RAM_ADDRESS),
|
|
288
|
+
pointerAddress: this.formatAddress(pointerAddress),
|
|
289
|
+
pointerValue,
|
|
290
|
+
registers: {
|
|
291
|
+
...textRegisters,
|
|
292
|
+
d010,
|
|
293
|
+
d015,
|
|
294
|
+
d01c,
|
|
295
|
+
},
|
|
296
|
+
index,
|
|
297
|
+
x,
|
|
298
|
+
y,
|
|
299
|
+
color,
|
|
300
|
+
multicolour,
|
|
301
|
+
spriteByteLength: spriteBytes.length,
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
return {
|
|
307
|
+
success: false,
|
|
308
|
+
details: this.normaliseError(error),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async displayBitmap(bitmap, options) {
|
|
313
|
+
try {
|
|
314
|
+
const layout = resolveVicBitmapMemoryLayout(options?.bitmapAddress ?? 0x2000, options?.screenAddress ?? 0x0400);
|
|
315
|
+
const facade = await this.facadePromise;
|
|
316
|
+
await facade.writeMemory(layout.bitmapAddress, bitmap.bitmapData);
|
|
317
|
+
await facade.writeMemory(layout.screenAddress, bitmap.screenRam);
|
|
318
|
+
await facade.writeMemory(layout.colorRamAddress, bitmap.colorRam);
|
|
319
|
+
let currentDd00 = 0;
|
|
320
|
+
try {
|
|
321
|
+
const dd00 = await facade.readMemory(0xDD00, 1);
|
|
322
|
+
currentDd00 = dd00[0] ?? 0;
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
currentDd00 = 0;
|
|
326
|
+
}
|
|
327
|
+
const registers = buildVicBitmapRegisters(layout, {
|
|
328
|
+
mode: bitmap.mode,
|
|
329
|
+
backgroundColor: bitmap.backgroundColor,
|
|
330
|
+
borderColor: bitmap.borderColor,
|
|
331
|
+
currentDd00,
|
|
332
|
+
});
|
|
333
|
+
await facade.writeMemory(0xDD00, Uint8Array.of(registers.dd00));
|
|
334
|
+
await facade.writeMemory(0xD011, Uint8Array.of(registers.d011));
|
|
335
|
+
await facade.writeMemory(0xD016, Uint8Array.of(registers.d016));
|
|
336
|
+
await facade.writeMemory(0xD018, Uint8Array.of(registers.d018));
|
|
337
|
+
await facade.writeMemory(0xD020, Uint8Array.of(registers.d020));
|
|
338
|
+
await facade.writeMemory(0xD021, Uint8Array.of(registers.d021));
|
|
339
|
+
return {
|
|
340
|
+
success: true,
|
|
341
|
+
details: {
|
|
342
|
+
mode: bitmap.mode,
|
|
343
|
+
bank: layout.bank,
|
|
344
|
+
bitmapAddress: this.formatAddress(layout.bitmapAddress),
|
|
345
|
+
screenAddress: this.formatAddress(layout.screenAddress),
|
|
346
|
+
colorRamAddress: this.formatAddress(layout.colorRamAddress),
|
|
347
|
+
registers,
|
|
348
|
+
bitmapBytes: bitmap.bitmapData.length,
|
|
349
|
+
screenBytes: bitmap.screenRam.length,
|
|
350
|
+
colorRamBytes: bitmap.colorRam.length,
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
return {
|
|
356
|
+
success: false,
|
|
357
|
+
details: this.normaliseError(error),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
79
361
|
async uploadAndRunAsm(program) {
|
|
80
362
|
const prg = assemblyToPrg(program);
|
|
81
363
|
return this.runPrg(prg);
|
|
82
364
|
}
|
|
83
365
|
async runPrg(prg) {
|
|
84
366
|
try {
|
|
85
|
-
if (
|
|
367
|
+
if (await this.shouldUseC64uMockBypass()) {
|
|
86
368
|
const payload = Buffer.isBuffer(prg) ? prg : Buffer.from(prg);
|
|
87
369
|
const response = await this.api.v1.runnersRunPrgCreate(":run_prg", payload, {
|
|
88
370
|
headers: { "Content-Type": "application/octet-stream" },
|
|
@@ -161,13 +443,14 @@ export class C64Client {
|
|
|
161
443
|
}
|
|
162
444
|
}
|
|
163
445
|
async readScreen() {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
446
|
+
return withDiagnosticSpan("client", "read_screen", {}, async () => {
|
|
447
|
+
const bytes = await this.readMemoryRaw(0x0400, 0x03e8);
|
|
448
|
+
return screenCodesToAscii(bytes, { columns: 40, rows: 25 });
|
|
449
|
+
});
|
|
167
450
|
}
|
|
168
451
|
async reset() {
|
|
169
452
|
try {
|
|
170
|
-
if (
|
|
453
|
+
if (await this.shouldUseC64uMockBypass()) {
|
|
171
454
|
const response = await this.api.v1.machineResetUpdate(":reset");
|
|
172
455
|
return { success: true, details: response.data };
|
|
173
456
|
}
|
|
@@ -181,7 +464,7 @@ export class C64Client {
|
|
|
181
464
|
}
|
|
182
465
|
async reboot() {
|
|
183
466
|
try {
|
|
184
|
-
if (
|
|
467
|
+
if (await this.shouldUseC64uMockBypass()) {
|
|
185
468
|
const response = await this.api.v1.machineRebootUpdate(":reboot");
|
|
186
469
|
return { success: true, details: response.data };
|
|
187
470
|
}
|
|
@@ -360,16 +643,16 @@ export class C64Client {
|
|
|
360
643
|
}
|
|
361
644
|
// --- Additional API wrappers to cover full REST surface ---
|
|
362
645
|
async version() {
|
|
363
|
-
const
|
|
364
|
-
return
|
|
646
|
+
const facade = await this.facadePromise;
|
|
647
|
+
return facade.version();
|
|
365
648
|
}
|
|
366
649
|
async info() {
|
|
367
|
-
const
|
|
368
|
-
return
|
|
650
|
+
const facade = await this.facadePromise;
|
|
651
|
+
return facade.info();
|
|
369
652
|
}
|
|
370
653
|
async pause() {
|
|
371
654
|
try {
|
|
372
|
-
if (
|
|
655
|
+
if (await this.shouldUseC64uMockBypass()) {
|
|
373
656
|
const res = await this.api.v1.machinePauseUpdate(":pause");
|
|
374
657
|
return { success: true, details: res.data };
|
|
375
658
|
}
|
|
@@ -382,7 +665,7 @@ export class C64Client {
|
|
|
382
665
|
}
|
|
383
666
|
async resume() {
|
|
384
667
|
try {
|
|
385
|
-
if (
|
|
668
|
+
if (await this.shouldUseC64uMockBypass()) {
|
|
386
669
|
const res = await this.api.v1.machineResumeUpdate(":resume");
|
|
387
670
|
return { success: true, details: res.data };
|
|
388
671
|
}
|
|
@@ -404,7 +687,7 @@ export class C64Client {
|
|
|
404
687
|
}
|
|
405
688
|
async menuButton() {
|
|
406
689
|
try {
|
|
407
|
-
if (
|
|
690
|
+
if (await this.shouldUseC64uMockBypass()) {
|
|
408
691
|
const res = await this.api.v1.machineMenuButtonUpdate(":menu_button");
|
|
409
692
|
return { success: true, details: res.data };
|
|
410
693
|
}
|
|
@@ -417,7 +700,7 @@ export class C64Client {
|
|
|
417
700
|
}
|
|
418
701
|
async debugregRead() {
|
|
419
702
|
try {
|
|
420
|
-
if (
|
|
703
|
+
if (await this.shouldUseC64uMockBypass()) {
|
|
421
704
|
const res = await this.api.v1.machineDebugregList(":debugreg");
|
|
422
705
|
return { success: true, value: res.data.value, details: res.data };
|
|
423
706
|
}
|
|
@@ -430,7 +713,7 @@ export class C64Client {
|
|
|
430
713
|
}
|
|
431
714
|
async debugregWrite(value) {
|
|
432
715
|
try {
|
|
433
|
-
if (
|
|
716
|
+
if (await this.shouldUseC64uMockBypass()) {
|
|
434
717
|
const res = await this.api.v1.machineDebugregUpdate(":debugreg", { value });
|
|
435
718
|
return { success: true, value: res.data.value, details: res.data };
|
|
436
719
|
}
|
|
@@ -494,7 +777,7 @@ export class C64Client {
|
|
|
494
777
|
try {
|
|
495
778
|
if (!drive || !path)
|
|
496
779
|
throw new Error("Drive and path are required");
|
|
497
|
-
if (
|
|
780
|
+
if (await this.shouldUseC64uMockBypass()) {
|
|
498
781
|
const res = await this.api.v1.drivesLoadRomUpdate(drive, ":load_rom", { file: path });
|
|
499
782
|
return { success: true, details: res.data };
|
|
500
783
|
}
|
|
@@ -533,6 +816,39 @@ export class C64Client {
|
|
|
533
816
|
return { success: false, details: this.normaliseError(error) };
|
|
534
817
|
}
|
|
535
818
|
}
|
|
819
|
+
async prepareVideoCapture(options) {
|
|
820
|
+
const keepAliveMs = Math.max(0, Math.trunc(options?.keepAliveMs ?? 1000));
|
|
821
|
+
const facade = await this.facadePromise;
|
|
822
|
+
if (facade.type !== "c64u") {
|
|
823
|
+
throw new Error("Reusable video capture is only available on C64 Ultimate");
|
|
824
|
+
}
|
|
825
|
+
await this.ensureC64uVideoCaptureSession(facade);
|
|
826
|
+
this.scheduleC64uVideoCaptureStop(keepAliveMs);
|
|
827
|
+
}
|
|
828
|
+
async releaseVideoCapture() {
|
|
829
|
+
await this.stopC64uVideoCaptureSession();
|
|
830
|
+
}
|
|
831
|
+
async captureFrames(options) {
|
|
832
|
+
return withDiagnosticSpan("client", "capture_frames", { count: options?.count ?? 1, reuseSession: options?.reuseSession === true }, async () => {
|
|
833
|
+
const requestedCount = Math.max(1, Math.min(32, Math.trunc(options?.count ?? 1)));
|
|
834
|
+
const facade = await this.facadePromise;
|
|
835
|
+
if (facade.type === "vice") {
|
|
836
|
+
return this.captureViceFrames(requestedCount);
|
|
837
|
+
}
|
|
838
|
+
return this.captureC64uVideoFrames(facade, requestedCount, {
|
|
839
|
+
reuseSession: options?.reuseSession === true,
|
|
840
|
+
keepAliveMs: Math.max(0, Math.trunc(options?.keepAliveMs ?? 0)),
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
async captureSamples(options) {
|
|
845
|
+
const requestedPairs = Math.max(1, Math.min(65536, Math.trunc(options?.count ?? 256)));
|
|
846
|
+
const facade = await this.facadePromise;
|
|
847
|
+
if (facade.type !== "c64u") {
|
|
848
|
+
throw new Error("Audio sample capture is only available on C64 Ultimate");
|
|
849
|
+
}
|
|
850
|
+
return this.captureC64uAudioSamples(facade, requestedPairs);
|
|
851
|
+
}
|
|
536
852
|
async configsList() {
|
|
537
853
|
const facade = await this.facadePromise;
|
|
538
854
|
return facade.configsList();
|
|
@@ -667,6 +983,484 @@ export class C64Client {
|
|
|
667
983
|
}
|
|
668
984
|
return new Uint8Array();
|
|
669
985
|
}
|
|
986
|
+
async captureC64uVideoFrames(facade, count, options = {}) {
|
|
987
|
+
if (options.reuseSession) {
|
|
988
|
+
const session = await this.ensureC64uVideoCaptureSession(facade);
|
|
989
|
+
const frames = await this.collectFramesFromC64uVideoCaptureSession(session, count, { fresh: true });
|
|
990
|
+
this.scheduleC64uVideoCaptureStop(options.keepAliveMs ?? 0);
|
|
991
|
+
return { backend: "c64u", frames };
|
|
992
|
+
}
|
|
993
|
+
const host = new URL(this.baseUrl).hostname;
|
|
994
|
+
const bindAddress = await this.resolveLocalCaptureAddress(host);
|
|
995
|
+
const socket = createSocket("udp4");
|
|
996
|
+
const packets = [];
|
|
997
|
+
let stopError = null;
|
|
998
|
+
try {
|
|
999
|
+
await this.bindCaptureSocket(socket, bindAddress);
|
|
1000
|
+
const target = this.socketEndpoint(socket, bindAddress);
|
|
1001
|
+
await this.ensureStreamSuccess(await facade.streamStart("video", target), "start video stream");
|
|
1002
|
+
const frames = await new Promise((resolve, reject) => {
|
|
1003
|
+
const timeoutMs = Math.max(1500, count * 750);
|
|
1004
|
+
const timer = setTimeout(() => {
|
|
1005
|
+
reject(new Error(`Timed out after ${timeoutMs}ms while capturing ${count} video frame(s)`));
|
|
1006
|
+
}, timeoutMs);
|
|
1007
|
+
socket.on("message", (msg) => {
|
|
1008
|
+
try {
|
|
1009
|
+
const packet = this.parseVideoPacketOrNull(Buffer.from(msg));
|
|
1010
|
+
if (!packet) {
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
packets.push(packet);
|
|
1014
|
+
const frames = collectCompleteVideoFrames(packets, count);
|
|
1015
|
+
if (frames.length >= count) {
|
|
1016
|
+
clearTimeout(timer);
|
|
1017
|
+
resolve(frames);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
catch (error) {
|
|
1021
|
+
clearTimeout(timer);
|
|
1022
|
+
reject(error);
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
socket.once("error", (error) => {
|
|
1026
|
+
clearTimeout(timer);
|
|
1027
|
+
reject(error);
|
|
1028
|
+
});
|
|
1029
|
+
});
|
|
1030
|
+
return { backend: "c64u", frames };
|
|
1031
|
+
}
|
|
1032
|
+
finally {
|
|
1033
|
+
try {
|
|
1034
|
+
await this.ensureStreamSuccess(await facade.streamStop("video"), "stop video stream");
|
|
1035
|
+
}
|
|
1036
|
+
catch (error) {
|
|
1037
|
+
stopError = error instanceof Error ? error : new Error(String(error));
|
|
1038
|
+
}
|
|
1039
|
+
socket.close();
|
|
1040
|
+
if (stopError) {
|
|
1041
|
+
throw stopError;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
async ensureC64uVideoCaptureSession(facade) {
|
|
1046
|
+
const existing = this.c64uVideoCaptureSession;
|
|
1047
|
+
if (existing && !existing.closed && existing.facade === facade) {
|
|
1048
|
+
if (existing.stopTimer) {
|
|
1049
|
+
clearTimeout(existing.stopTimer);
|
|
1050
|
+
existing.stopTimer = null;
|
|
1051
|
+
}
|
|
1052
|
+
if (existing.error) {
|
|
1053
|
+
throw existing.error;
|
|
1054
|
+
}
|
|
1055
|
+
return existing;
|
|
1056
|
+
}
|
|
1057
|
+
await this.stopC64uVideoCaptureSession();
|
|
1058
|
+
const host = new URL(this.baseUrl).hostname;
|
|
1059
|
+
const bindAddress = await this.resolveLocalCaptureAddress(host);
|
|
1060
|
+
const socket = createSocket("udp4");
|
|
1061
|
+
await this.bindCaptureSocket(socket, bindAddress);
|
|
1062
|
+
const target = this.socketEndpoint(socket, bindAddress);
|
|
1063
|
+
const session = {
|
|
1064
|
+
facade,
|
|
1065
|
+
socket,
|
|
1066
|
+
bindAddress,
|
|
1067
|
+
target,
|
|
1068
|
+
packets: [],
|
|
1069
|
+
waiters: [],
|
|
1070
|
+
stopTimer: null,
|
|
1071
|
+
closed: false,
|
|
1072
|
+
error: null,
|
|
1073
|
+
};
|
|
1074
|
+
socket.on("message", (msg) => {
|
|
1075
|
+
if (session.closed) {
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
const packet = this.parseVideoPacketOrNull(Buffer.from(msg));
|
|
1079
|
+
if (!packet) {
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
session.packets.push(packet);
|
|
1083
|
+
this.flushC64uVideoCaptureWaiters(session);
|
|
1084
|
+
});
|
|
1085
|
+
socket.on("error", (error) => {
|
|
1086
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1087
|
+
session.error = err;
|
|
1088
|
+
this.rejectC64uVideoCaptureWaiters(session, err);
|
|
1089
|
+
});
|
|
1090
|
+
try {
|
|
1091
|
+
await this.ensureStreamSuccess(await facade.streamStart("video", target), "start video stream");
|
|
1092
|
+
}
|
|
1093
|
+
catch (error) {
|
|
1094
|
+
session.closed = true;
|
|
1095
|
+
socket.close();
|
|
1096
|
+
throw error;
|
|
1097
|
+
}
|
|
1098
|
+
this.c64uVideoCaptureSession = session;
|
|
1099
|
+
return session;
|
|
1100
|
+
}
|
|
1101
|
+
async collectFramesFromC64uVideoCaptureSession(session, count, options = {}) {
|
|
1102
|
+
if (session.error) {
|
|
1103
|
+
throw session.error;
|
|
1104
|
+
}
|
|
1105
|
+
const startIndex = options.fresh ? session.packets.length : 0;
|
|
1106
|
+
const existingFrames = collectCompleteVideoFrames(session.packets.slice(startIndex), count);
|
|
1107
|
+
if (existingFrames.length >= count) {
|
|
1108
|
+
return existingFrames;
|
|
1109
|
+
}
|
|
1110
|
+
return await new Promise((resolve, reject) => {
|
|
1111
|
+
const timeoutMs = Math.max(1500, count * 750);
|
|
1112
|
+
const timer = setTimeout(() => {
|
|
1113
|
+
const waiterIndex = session.waiters.indexOf(waiter);
|
|
1114
|
+
if (waiterIndex >= 0) {
|
|
1115
|
+
session.waiters.splice(waiterIndex, 1);
|
|
1116
|
+
}
|
|
1117
|
+
reject(new Error(`Timed out after ${timeoutMs}ms while capturing ${count} video frame(s)`));
|
|
1118
|
+
}, timeoutMs);
|
|
1119
|
+
const waiter = {
|
|
1120
|
+
count,
|
|
1121
|
+
startIndex,
|
|
1122
|
+
resolve: (frames) => {
|
|
1123
|
+
clearTimeout(timer);
|
|
1124
|
+
resolve(frames);
|
|
1125
|
+
},
|
|
1126
|
+
reject: (error) => {
|
|
1127
|
+
clearTimeout(timer);
|
|
1128
|
+
reject(error);
|
|
1129
|
+
},
|
|
1130
|
+
timer,
|
|
1131
|
+
};
|
|
1132
|
+
session.waiters.push(waiter);
|
|
1133
|
+
this.flushC64uVideoCaptureWaiters(session);
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
flushC64uVideoCaptureWaiters(session) {
|
|
1137
|
+
for (let index = session.waiters.length - 1; index >= 0; index -= 1) {
|
|
1138
|
+
const waiter = session.waiters[index];
|
|
1139
|
+
const frames = collectCompleteVideoFrames(session.packets.slice(waiter.startIndex), waiter.count);
|
|
1140
|
+
if (frames.length >= waiter.count) {
|
|
1141
|
+
session.waiters.splice(index, 1);
|
|
1142
|
+
waiter.resolve(frames);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
rejectC64uVideoCaptureWaiters(session, error) {
|
|
1147
|
+
while (session.waiters.length > 0) {
|
|
1148
|
+
const waiter = session.waiters.pop();
|
|
1149
|
+
if (waiter) {
|
|
1150
|
+
waiter.reject(error);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
scheduleC64uVideoCaptureStop(keepAliveMs) {
|
|
1155
|
+
const session = this.c64uVideoCaptureSession;
|
|
1156
|
+
if (!session || session.closed) {
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (session.stopTimer) {
|
|
1160
|
+
clearTimeout(session.stopTimer);
|
|
1161
|
+
session.stopTimer = null;
|
|
1162
|
+
}
|
|
1163
|
+
if (keepAliveMs <= 0) {
|
|
1164
|
+
void this.stopC64uVideoCaptureSession().catch(() => { });
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
session.stopTimer = setTimeout(() => {
|
|
1168
|
+
void this.stopC64uVideoCaptureSession().catch(() => { });
|
|
1169
|
+
}, keepAliveMs);
|
|
1170
|
+
}
|
|
1171
|
+
async stopC64uVideoCaptureSession() {
|
|
1172
|
+
const session = this.c64uVideoCaptureSession;
|
|
1173
|
+
if (!session) {
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
this.c64uVideoCaptureSession = null;
|
|
1177
|
+
session.closed = true;
|
|
1178
|
+
if (session.stopTimer) {
|
|
1179
|
+
clearTimeout(session.stopTimer);
|
|
1180
|
+
session.stopTimer = null;
|
|
1181
|
+
}
|
|
1182
|
+
let stopError = null;
|
|
1183
|
+
try {
|
|
1184
|
+
await this.ensureStreamSuccess(await session.facade.streamStop("video"), "stop video stream");
|
|
1185
|
+
}
|
|
1186
|
+
catch (error) {
|
|
1187
|
+
stopError = error instanceof Error ? error : new Error(String(error));
|
|
1188
|
+
}
|
|
1189
|
+
finally {
|
|
1190
|
+
session.socket.close();
|
|
1191
|
+
this.rejectC64uVideoCaptureWaiters(session, stopError ?? new Error("Video capture session closed"));
|
|
1192
|
+
}
|
|
1193
|
+
if (stopError) {
|
|
1194
|
+
throw stopError;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
async captureViceFrames(count) {
|
|
1198
|
+
const viceFacade = await this.requireViceBackend();
|
|
1199
|
+
const frames = [];
|
|
1200
|
+
for (let index = 0; index < count; index += 1) {
|
|
1201
|
+
const snapshot = await this.captureViceDisplaySnapshot(viceFacade);
|
|
1202
|
+
frames.push(this.normaliseViceDisplaySnapshot(snapshot));
|
|
1203
|
+
}
|
|
1204
|
+
return { backend: "vice", frames };
|
|
1205
|
+
}
|
|
1206
|
+
async captureViceDisplaySnapshot(viceFacade) {
|
|
1207
|
+
let lastError;
|
|
1208
|
+
for (let attempt = 1; attempt <= 8; attempt += 1) {
|
|
1209
|
+
try {
|
|
1210
|
+
const snapshot = await viceFacade.withMonitor((client) => client.displayGet({}));
|
|
1211
|
+
if (snapshot.pixels.length > 0) {
|
|
1212
|
+
return snapshot;
|
|
1213
|
+
}
|
|
1214
|
+
lastError = new Error("VICE display capture returned an empty frame");
|
|
1215
|
+
}
|
|
1216
|
+
catch (error) {
|
|
1217
|
+
lastError = error;
|
|
1218
|
+
}
|
|
1219
|
+
if (attempt < 8) {
|
|
1220
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(1000, 150 * attempt)));
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "VICE display capture failed"));
|
|
1224
|
+
}
|
|
1225
|
+
async captureC64uAudioSamples(facade, samplePairs) {
|
|
1226
|
+
const host = new URL(this.baseUrl).hostname;
|
|
1227
|
+
const bindAddress = await this.resolveLocalCaptureAddress(host);
|
|
1228
|
+
const socket = createSocket("udp4");
|
|
1229
|
+
const chunks = [];
|
|
1230
|
+
let collectedValues = 0;
|
|
1231
|
+
let stopError = null;
|
|
1232
|
+
try {
|
|
1233
|
+
await this.bindCaptureSocket(socket, bindAddress);
|
|
1234
|
+
const target = this.socketEndpoint(socket, bindAddress);
|
|
1235
|
+
await this.ensureStreamSuccess(await facade.streamStart("audio", target), "start audio stream");
|
|
1236
|
+
const neededValues = samplePairs * 2;
|
|
1237
|
+
await new Promise((resolve, reject) => {
|
|
1238
|
+
const timeoutMs = Math.max(1000, Math.ceil(samplePairs / 192) * 500);
|
|
1239
|
+
const timer = setTimeout(() => {
|
|
1240
|
+
reject(new Error(`Timed out after ${timeoutMs}ms while capturing ${samplePairs} audio sample pair(s)`));
|
|
1241
|
+
}, timeoutMs);
|
|
1242
|
+
socket.on("message", (msg) => {
|
|
1243
|
+
try {
|
|
1244
|
+
const packet = parseAudioPacket(Buffer.from(msg));
|
|
1245
|
+
chunks.push(packet.samples);
|
|
1246
|
+
collectedValues += packet.samples.length;
|
|
1247
|
+
if (collectedValues >= neededValues) {
|
|
1248
|
+
clearTimeout(timer);
|
|
1249
|
+
resolve();
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
catch (error) {
|
|
1253
|
+
clearTimeout(timer);
|
|
1254
|
+
reject(error);
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
socket.once("error", (error) => {
|
|
1258
|
+
clearTimeout(timer);
|
|
1259
|
+
reject(error);
|
|
1260
|
+
});
|
|
1261
|
+
});
|
|
1262
|
+
const samples = new Int16Array(samplePairs * 2);
|
|
1263
|
+
let offset = 0;
|
|
1264
|
+
for (const chunk of chunks) {
|
|
1265
|
+
const remaining = samples.length - offset;
|
|
1266
|
+
if (remaining <= 0) {
|
|
1267
|
+
break;
|
|
1268
|
+
}
|
|
1269
|
+
samples.set(chunk.subarray(0, remaining), offset);
|
|
1270
|
+
offset += Math.min(chunk.length, remaining);
|
|
1271
|
+
}
|
|
1272
|
+
return {
|
|
1273
|
+
backend: "c64u",
|
|
1274
|
+
channels: 2,
|
|
1275
|
+
sampleRateHz: await this.getC64uAudioSampleRate(facade),
|
|
1276
|
+
samplePairs,
|
|
1277
|
+
samples,
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
finally {
|
|
1281
|
+
try {
|
|
1282
|
+
await this.ensureStreamSuccess(await facade.streamStop("audio"), "stop audio stream");
|
|
1283
|
+
}
|
|
1284
|
+
catch (error) {
|
|
1285
|
+
stopError = error instanceof Error ? error : new Error(String(error));
|
|
1286
|
+
}
|
|
1287
|
+
socket.close();
|
|
1288
|
+
if (stopError) {
|
|
1289
|
+
throw stopError;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
async getC64uAudioSampleRate(facade) {
|
|
1294
|
+
try {
|
|
1295
|
+
const response = await facade.configGet("Video", "Mode");
|
|
1296
|
+
const raw = response?.value ?? response;
|
|
1297
|
+
const mode = typeof raw === "string" ? raw.trim().toUpperCase() : "";
|
|
1298
|
+
if (mode.includes("NTSC")) {
|
|
1299
|
+
return C64U_NTSC_AUDIO_SAMPLE_RATE;
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
catch {
|
|
1303
|
+
// Fall back to PAL below.
|
|
1304
|
+
}
|
|
1305
|
+
return C64U_PAL_AUDIO_SAMPLE_RATE;
|
|
1306
|
+
}
|
|
1307
|
+
async resolveLocalCaptureAddress(remoteHost) {
|
|
1308
|
+
const socket = createSocket("udp4");
|
|
1309
|
+
try {
|
|
1310
|
+
await new Promise((resolve, reject) => {
|
|
1311
|
+
socket.once("error", reject);
|
|
1312
|
+
socket.connect(64, remoteHost, () => {
|
|
1313
|
+
socket.off("error", reject);
|
|
1314
|
+
resolve();
|
|
1315
|
+
});
|
|
1316
|
+
});
|
|
1317
|
+
const address = socket.address();
|
|
1318
|
+
if (typeof address === "object" && address.address) {
|
|
1319
|
+
return address.address;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
catch {
|
|
1323
|
+
// Fall back below.
|
|
1324
|
+
}
|
|
1325
|
+
finally {
|
|
1326
|
+
socket.close();
|
|
1327
|
+
}
|
|
1328
|
+
return "127.0.0.1";
|
|
1329
|
+
}
|
|
1330
|
+
async bindCaptureSocket(socket, bindAddress) {
|
|
1331
|
+
await new Promise((resolve, reject) => {
|
|
1332
|
+
socket.once("error", reject);
|
|
1333
|
+
socket.bind(0, bindAddress, () => {
|
|
1334
|
+
socket.off("error", reject);
|
|
1335
|
+
resolve();
|
|
1336
|
+
});
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
socketEndpoint(socket, bindAddress) {
|
|
1340
|
+
const address = socket.address();
|
|
1341
|
+
if (!address || typeof address === "string") {
|
|
1342
|
+
throw new Error("Unable to determine UDP capture socket endpoint");
|
|
1343
|
+
}
|
|
1344
|
+
return `${bindAddress}:${address.port}`;
|
|
1345
|
+
}
|
|
1346
|
+
async ensureStreamSuccess(result, action) {
|
|
1347
|
+
if (result.success) {
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
throw new Error(`Failed to ${action}: ${JSON.stringify(result.details ?? null)}`);
|
|
1351
|
+
}
|
|
1352
|
+
parseVideoPacketOrNull(payload) {
|
|
1353
|
+
try {
|
|
1354
|
+
return parseVideoPacket(payload);
|
|
1355
|
+
}
|
|
1356
|
+
catch (error) {
|
|
1357
|
+
writeDiagnosticEvent("capture_video_packet_ignored", {
|
|
1358
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1359
|
+
bytes: payload.length,
|
|
1360
|
+
});
|
|
1361
|
+
return null;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
normaliseViceDisplaySnapshot(snapshot) {
|
|
1365
|
+
const bytesPerPixel = snapshot.bitsPerPixel > 0 && snapshot.bitsPerPixel % 8 === 0
|
|
1366
|
+
? snapshot.bitsPerPixel / 8
|
|
1367
|
+
: 0;
|
|
1368
|
+
const pixels = Uint8Array.from(snapshot.pixels);
|
|
1369
|
+
const canCrop = bytesPerPixel > 0
|
|
1370
|
+
&& snapshot.debugWidth > 0
|
|
1371
|
+
&& snapshot.debugHeight > 0
|
|
1372
|
+
&& pixels.length >= snapshot.debugWidth * snapshot.debugHeight * bytesPerPixel;
|
|
1373
|
+
if (!canCrop) {
|
|
1374
|
+
return {
|
|
1375
|
+
frameNumber: null,
|
|
1376
|
+
width: snapshot.debugWidth,
|
|
1377
|
+
height: snapshot.debugHeight,
|
|
1378
|
+
bitsPerPixel: snapshot.bitsPerPixel,
|
|
1379
|
+
pixels,
|
|
1380
|
+
complete: true,
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
const innerWidth = typeof snapshot.innerWidth === "number" && snapshot.innerWidth > 0
|
|
1384
|
+
? snapshot.innerWidth
|
|
1385
|
+
: snapshot.debugWidth;
|
|
1386
|
+
const innerHeight = typeof snapshot.innerHeight === "number" && snapshot.innerHeight > 0
|
|
1387
|
+
? snapshot.innerHeight
|
|
1388
|
+
: snapshot.debugHeight;
|
|
1389
|
+
const rowThreshold = Math.max(1, Math.min(innerWidth, snapshot.debugWidth, 32));
|
|
1390
|
+
const columnThreshold = Math.max(1, Math.min(innerHeight, snapshot.debugHeight, 32));
|
|
1391
|
+
const isPixelVisible = (pixelIndex) => {
|
|
1392
|
+
const offset = pixelIndex * bytesPerPixel;
|
|
1393
|
+
for (let byteIndex = 0; byteIndex < bytesPerPixel; byteIndex += 1) {
|
|
1394
|
+
if ((pixels[offset + byteIndex] ?? 0) !== 0) {
|
|
1395
|
+
return true;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
return false;
|
|
1399
|
+
};
|
|
1400
|
+
const rowVisibleCounts = new Array(snapshot.debugHeight).fill(0);
|
|
1401
|
+
for (let row = 0; row < snapshot.debugHeight; row += 1) {
|
|
1402
|
+
let visiblePixels = 0;
|
|
1403
|
+
const rowStart = row * snapshot.debugWidth;
|
|
1404
|
+
for (let column = 0; column < snapshot.debugWidth; column += 1) {
|
|
1405
|
+
if (isPixelVisible(rowStart + column)) {
|
|
1406
|
+
visiblePixels += 1;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
rowVisibleCounts[row] = visiblePixels;
|
|
1410
|
+
}
|
|
1411
|
+
const top = rowVisibleCounts.findIndex((count) => count >= rowThreshold);
|
|
1412
|
+
const bottom = rowVisibleCounts.length - 1 - [...rowVisibleCounts].reverse().findIndex((count) => count >= rowThreshold);
|
|
1413
|
+
if (top < 0 || bottom < top) {
|
|
1414
|
+
return {
|
|
1415
|
+
frameNumber: null,
|
|
1416
|
+
width: snapshot.debugWidth,
|
|
1417
|
+
height: snapshot.debugHeight,
|
|
1418
|
+
bitsPerPixel: snapshot.bitsPerPixel,
|
|
1419
|
+
pixels,
|
|
1420
|
+
complete: true,
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
const columnVisibleCounts = new Array(snapshot.debugWidth).fill(0);
|
|
1424
|
+
for (let column = 0; column < snapshot.debugWidth; column += 1) {
|
|
1425
|
+
let visiblePixels = 0;
|
|
1426
|
+
for (let row = top; row <= bottom; row += 1) {
|
|
1427
|
+
if (isPixelVisible((row * snapshot.debugWidth) + column)) {
|
|
1428
|
+
visiblePixels += 1;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
columnVisibleCounts[column] = visiblePixels;
|
|
1432
|
+
}
|
|
1433
|
+
const left = columnVisibleCounts.findIndex((count) => count >= columnThreshold);
|
|
1434
|
+
const right = columnVisibleCounts.length - 1 - [...columnVisibleCounts].reverse().findIndex((count) => count >= columnThreshold);
|
|
1435
|
+
if (left < 0 || right < left) {
|
|
1436
|
+
return {
|
|
1437
|
+
frameNumber: null,
|
|
1438
|
+
width: snapshot.debugWidth,
|
|
1439
|
+
height: snapshot.debugHeight,
|
|
1440
|
+
bitsPerPixel: snapshot.bitsPerPixel,
|
|
1441
|
+
pixels,
|
|
1442
|
+
complete: true,
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
const croppedWidth = right - left + 1;
|
|
1446
|
+
const croppedHeight = bottom - top + 1;
|
|
1447
|
+
const rowStride = snapshot.debugWidth * bytesPerPixel;
|
|
1448
|
+
const croppedRowStride = croppedWidth * bytesPerPixel;
|
|
1449
|
+
const cropped = new Uint8Array(croppedRowStride * croppedHeight);
|
|
1450
|
+
for (let row = 0; row < croppedHeight; row += 1) {
|
|
1451
|
+
const sourceStart = ((top + row) * rowStride) + (left * bytesPerPixel);
|
|
1452
|
+
const sourceEnd = sourceStart + croppedRowStride;
|
|
1453
|
+
cropped.set(pixels.subarray(sourceStart, sourceEnd), row * croppedRowStride);
|
|
1454
|
+
}
|
|
1455
|
+
return {
|
|
1456
|
+
frameNumber: null,
|
|
1457
|
+
width: croppedWidth,
|
|
1458
|
+
height: croppedHeight,
|
|
1459
|
+
bitsPerPixel: snapshot.bitsPerPixel,
|
|
1460
|
+
pixels: cropped,
|
|
1461
|
+
complete: true,
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
670
1464
|
/**
|
|
671
1465
|
* Low-level memory read that transparently handles devices returning either
|
|
672
1466
|
* raw binary bytes or JSON with a base64 payload.
|
|
@@ -698,6 +1492,68 @@ export class C64Client {
|
|
|
698
1492
|
}
|
|
699
1493
|
return body instanceof ArrayBuffer ? new Uint8Array(body) : this.extractBytes(body);
|
|
700
1494
|
}
|
|
1495
|
+
async viceCheckpointList() {
|
|
1496
|
+
return this.withViceMonitor((client) => client.checkpointList());
|
|
1497
|
+
}
|
|
1498
|
+
async viceCheckpointGet(id) {
|
|
1499
|
+
return this.withViceMonitor((client) => client.checkpointGet(id));
|
|
1500
|
+
}
|
|
1501
|
+
async viceCheckpointCreate(options) {
|
|
1502
|
+
const payload = options.memspace === undefined
|
|
1503
|
+
? options
|
|
1504
|
+
: { ...options, memspace: this.normaliseMemspace(options.memspace) };
|
|
1505
|
+
return this.withViceMonitor((client) => client.checkpointCreate(payload));
|
|
1506
|
+
}
|
|
1507
|
+
async viceCheckpointDelete(id) {
|
|
1508
|
+
await this.withViceMonitor((client) => client.checkpointDelete(id));
|
|
1509
|
+
}
|
|
1510
|
+
async viceCheckpointToggle(id, enabled) {
|
|
1511
|
+
await this.withViceMonitor((client) => client.checkpointToggle(id, enabled));
|
|
1512
|
+
}
|
|
1513
|
+
async viceCheckpointSetCondition(id, expression) {
|
|
1514
|
+
await this.withViceMonitor((client) => client.checkpointSetCondition(id, expression));
|
|
1515
|
+
}
|
|
1516
|
+
async viceRegistersAvailable(memspace = 0) {
|
|
1517
|
+
const space = this.normaliseMemspace(memspace);
|
|
1518
|
+
return this.withViceMonitor((client) => client.registersAvailable(space));
|
|
1519
|
+
}
|
|
1520
|
+
async viceRegistersGet(memspace = 0) {
|
|
1521
|
+
const space = this.normaliseMemspace(memspace);
|
|
1522
|
+
return this.withViceMonitor((client) => client.registersGet(space));
|
|
1523
|
+
}
|
|
1524
|
+
async viceRegistersSet(writes, options) {
|
|
1525
|
+
let payload;
|
|
1526
|
+
if (options) {
|
|
1527
|
+
payload = {
|
|
1528
|
+
...(options.metadata ? { metadata: options.metadata } : {}),
|
|
1529
|
+
...(options.memspace !== undefined ? { memspace: this.normaliseMemspace(options.memspace) } : {}),
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
return this.withViceMonitor((client) => client.registersSet(writes, payload));
|
|
1533
|
+
}
|
|
1534
|
+
async viceStepInstructions(count = 1, options) {
|
|
1535
|
+
await this.withViceMonitor((client) => client.stepInstructions(count, options));
|
|
1536
|
+
}
|
|
1537
|
+
async viceStepReturn() {
|
|
1538
|
+
await this.withViceMonitor((client) => client.stepReturn());
|
|
1539
|
+
}
|
|
1540
|
+
async viceResourceGet(name) {
|
|
1541
|
+
return this.withViceMonitor((client) => client.resourceGet(name));
|
|
1542
|
+
}
|
|
1543
|
+
async viceResourceSet(name, value) {
|
|
1544
|
+
await this.withViceMonitor((client) => client.resourceSet(name, value));
|
|
1545
|
+
}
|
|
1546
|
+
normaliseMemspace(value) {
|
|
1547
|
+
const allowed = [0, 1, 2, 3, 4];
|
|
1548
|
+
if (value === undefined) {
|
|
1549
|
+
return 0;
|
|
1550
|
+
}
|
|
1551
|
+
const numeric = Number(value);
|
|
1552
|
+
if (allowed.includes(numeric)) {
|
|
1553
|
+
return numeric;
|
|
1554
|
+
}
|
|
1555
|
+
return 0;
|
|
1556
|
+
}
|
|
701
1557
|
normaliseError(error) {
|
|
702
1558
|
if (axios.isAxiosError(error)) {
|
|
703
1559
|
return {
|
|
@@ -711,6 +1567,49 @@ export class C64Client {
|
|
|
711
1567
|
}
|
|
712
1568
|
return error;
|
|
713
1569
|
}
|
|
1570
|
+
async readByteOrDefault(facade, address, fallback) {
|
|
1571
|
+
try {
|
|
1572
|
+
const bytes = await facade.readMemory(address, 1);
|
|
1573
|
+
return bytes[0] ?? fallback;
|
|
1574
|
+
}
|
|
1575
|
+
catch {
|
|
1576
|
+
return fallback;
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
async getGreetingDd00(type, facade) {
|
|
1580
|
+
const cached = this.greetingDd00Cache.get(type);
|
|
1581
|
+
if (cached !== undefined) {
|
|
1582
|
+
return cached;
|
|
1583
|
+
}
|
|
1584
|
+
let warmup = this.greetingDd00Warmups.get(type);
|
|
1585
|
+
if (!warmup) {
|
|
1586
|
+
warmup = this.readByteOrDefault(facade, 0xDD00, 0).then((value) => {
|
|
1587
|
+
this.greetingDd00Cache.set(type, value);
|
|
1588
|
+
return value;
|
|
1589
|
+
}).finally(() => {
|
|
1590
|
+
this.greetingDd00Warmups.delete(type);
|
|
1591
|
+
});
|
|
1592
|
+
this.greetingDd00Warmups.set(type, warmup);
|
|
1593
|
+
}
|
|
1594
|
+
return warmup;
|
|
1595
|
+
}
|
|
1596
|
+
async primeGreetingDd00Cache(types) {
|
|
1597
|
+
await this.initPromise;
|
|
1598
|
+
const targets = (types && types.length > 0 ? [...types] : this.getAvailableBackends()).filter((type, index, all) => all.indexOf(type) === index);
|
|
1599
|
+
await Promise.all(targets.map(async (type) => {
|
|
1600
|
+
const facadePromise = this.allFacades.get(type);
|
|
1601
|
+
if (!facadePromise) {
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
try {
|
|
1605
|
+
const facade = await facadePromise;
|
|
1606
|
+
await this.getGreetingDd00(type, facade);
|
|
1607
|
+
}
|
|
1608
|
+
catch {
|
|
1609
|
+
// Keep greeting prewarm best-effort; renderGreetingScreen still falls back safely.
|
|
1610
|
+
}
|
|
1611
|
+
}));
|
|
1612
|
+
}
|
|
714
1613
|
parseNumeric(value) {
|
|
715
1614
|
if (typeof value !== "string") {
|
|
716
1615
|
throw new Error("Expected string input");
|
|
@@ -806,6 +1705,71 @@ function toByte(value, fallback = 0) {
|
|
|
806
1705
|
const v = value ?? fallback;
|
|
807
1706
|
return Math.max(0, Math.min(255, v)) & 0xff;
|
|
808
1707
|
}
|
|
1708
|
+
function normaliseColorNibble(value) {
|
|
1709
|
+
return Math.max(0, Math.min(0x0F, Math.floor(value))) & 0x0F;
|
|
1710
|
+
}
|
|
1711
|
+
function buildVicTextRegisters(options) {
|
|
1712
|
+
return {
|
|
1713
|
+
dd00: ((options.currentDd00 ?? 0) & 0xFC) | 0x03,
|
|
1714
|
+
d011: 0x1B,
|
|
1715
|
+
d016: 0x08,
|
|
1716
|
+
d018: 0x14,
|
|
1717
|
+
d020: normaliseColorNibble(options.borderColor ?? DEFAULT_BORDER_COLOR),
|
|
1718
|
+
d021: normaliseColorNibble(options.backgroundColor ?? DEFAULT_BACKGROUND_COLOR),
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
function asciiCharToScreenCode(char) {
|
|
1722
|
+
if (char.length !== 1) {
|
|
1723
|
+
return SPACE_SCREEN_CODE;
|
|
1724
|
+
}
|
|
1725
|
+
const upper = char.toUpperCase();
|
|
1726
|
+
if (upper >= "A" && upper <= "Z") {
|
|
1727
|
+
return upper.charCodeAt(0) - 64;
|
|
1728
|
+
}
|
|
1729
|
+
if (upper >= "0" && upper <= "9") {
|
|
1730
|
+
return upper.charCodeAt(0);
|
|
1731
|
+
}
|
|
1732
|
+
switch (upper) {
|
|
1733
|
+
case " ":
|
|
1734
|
+
return SPACE_SCREEN_CODE;
|
|
1735
|
+
case ".":
|
|
1736
|
+
return 0x2E;
|
|
1737
|
+
case ",":
|
|
1738
|
+
return 0x2C;
|
|
1739
|
+
case "!":
|
|
1740
|
+
return 0x21;
|
|
1741
|
+
case ":":
|
|
1742
|
+
return 0x3A;
|
|
1743
|
+
case ";":
|
|
1744
|
+
return 0x3B;
|
|
1745
|
+
case "-":
|
|
1746
|
+
return 0x2D;
|
|
1747
|
+
case "'":
|
|
1748
|
+
return 0x27;
|
|
1749
|
+
case "/":
|
|
1750
|
+
return 0x2F;
|
|
1751
|
+
case "?":
|
|
1752
|
+
return 0x3F;
|
|
1753
|
+
default:
|
|
1754
|
+
return SPACE_SCREEN_CODE;
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
function writeScreenLine(screenRam, row, text) {
|
|
1758
|
+
if (row < 0 || row >= TEXT_SCREEN_ROWS) {
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
const offset = row * TEXT_SCREEN_COLUMNS;
|
|
1762
|
+
const clipped = text.slice(0, TEXT_SCREEN_COLUMNS);
|
|
1763
|
+
for (let index = 0; index < clipped.length; index += 1) {
|
|
1764
|
+
screenRam[offset + index] = asciiCharToScreenCode(clipped[index] ?? " ");
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
function buildGreetingScreenRam(message) {
|
|
1768
|
+
const screenRam = new Uint8Array(TEXT_SCREEN_SIZE).fill(SPACE_SCREEN_CODE);
|
|
1769
|
+
writeScreenLine(screenRam, 1, message);
|
|
1770
|
+
writeScreenLine(screenRam, 3, "READY.");
|
|
1771
|
+
return screenRam;
|
|
1772
|
+
}
|
|
809
1773
|
function buildSingleSpriteProgram(opts) {
|
|
810
1774
|
const index = Math.max(0, Math.min(7, opts.spriteIndex ?? 0));
|
|
811
1775
|
const mx = Math.max(0, Math.min(511, opts.x ?? 100));
|