wasmcart 0.2.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.
@@ -0,0 +1,1713 @@
1
+ import { readFile, stat } from 'fs/promises';
2
+ import { openSync, readSync, closeSync, readdirSync, statSync } from 'fs';
3
+ import { join, extname } from 'path';
4
+ import { open as yauzlOpen } from 'yauzl';
5
+ import { inflateRawSync } from 'zlib';
6
+ import { Worker } from 'worker_threads';
7
+ import {
8
+ ABI_VERSION,
9
+ MIN_ABI_VERSION,
10
+ INFO_FIELDS,
11
+ HOST_INFO_FIELDS,
12
+ PAD_SIZE,
13
+ MAX_PADS,
14
+ TIME_SIZE,
15
+ FLAG_NET_WS,
16
+ FLAG_NET_DC,
17
+ FLAG_POINTER,
18
+ FLAG_KEYBOARD,
19
+ POINTER_SIZE,
20
+ MAX_POINTERS,
21
+ KEYS_STATE_SIZE,
22
+ } from './abi.js';
23
+ import { createWebGLImports } from './webgl_imports.js';
24
+
25
+ // --- Path validation for asset security ---
26
+
27
+ function validateAssetPath(path) {
28
+ // Reject absolute paths
29
+ if (path.startsWith('/') || path.startsWith('\\')) return false;
30
+ // Reject Windows drive letters
31
+ if (/^[a-zA-Z]:/.test(path)) return false;
32
+ // Reject path traversal
33
+ if (path.includes('..')) return false;
34
+ // Reject null bytes (C string truncation attacks)
35
+ if (path.includes('\0')) return false;
36
+ // Reject backslashes (normalize to forward slash only)
37
+ if (path.includes('\\')) return false;
38
+ return true;
39
+ }
40
+
41
+ // --- ZIP central directory parser for random-access reads ---
42
+
43
+ function parseZipCentralDirectory(fd, fileSize) {
44
+ // Find End of Central Directory record (last 65KB max)
45
+ const searchSize = Math.min(fileSize, 65536 + 22);
46
+ const searchBuf = Buffer.alloc(searchSize);
47
+ readSync(fd, searchBuf, 0, searchSize, fileSize - searchSize);
48
+
49
+ // Find EOCD signature (0x06054b50)
50
+ let eocdOffset = -1;
51
+ for (let i = searchBuf.length - 22; i >= 0; i--) {
52
+ if (searchBuf[i] === 0x50 && searchBuf[i + 1] === 0x4b &&
53
+ searchBuf[i + 2] === 0x05 && searchBuf[i + 3] === 0x06) {
54
+ eocdOffset = i;
55
+ break;
56
+ }
57
+ }
58
+ if (eocdOffset === -1) throw new Error('Not a valid ZIP file (EOCD not found)');
59
+
60
+ const entryCount = searchBuf.readUInt16LE(eocdOffset + 10);
61
+ const cdSize = searchBuf.readUInt32LE(eocdOffset + 12);
62
+ const cdOffset = searchBuf.readUInt32LE(eocdOffset + 16);
63
+
64
+ // Read entire central directory
65
+ const cdBuf = Buffer.alloc(cdSize);
66
+ readSync(fd, cdBuf, 0, cdSize, cdOffset);
67
+
68
+ const index = new Map();
69
+ let pos = 0;
70
+
71
+ for (let i = 0; i < entryCount; i++) {
72
+ // Central directory file header signature (0x02014b50)
73
+ if (cdBuf.readUInt32LE(pos) !== 0x02014b50) break;
74
+
75
+ const compressionMethod = cdBuf.readUInt16LE(pos + 10);
76
+ const compressedSize = cdBuf.readUInt32LE(pos + 20);
77
+ const uncompressedSize = cdBuf.readUInt32LE(pos + 24);
78
+ const nameLen = cdBuf.readUInt16LE(pos + 28);
79
+ const extraLen = cdBuf.readUInt16LE(pos + 30);
80
+ const commentLen = cdBuf.readUInt16LE(pos + 32);
81
+ const externalAttrs = cdBuf.readUInt32LE(pos + 38);
82
+ const localHeaderOffset = cdBuf.readUInt32LE(pos + 42);
83
+
84
+ const fileName = cdBuf.toString('utf8', pos + 46, pos + 46 + nameLen);
85
+
86
+ // Skip directories and symlinks
87
+ const isDir = fileName.endsWith('/');
88
+ const isSymlink = ((externalAttrs >> 16) & 0xF000) === 0xA000;
89
+
90
+ if (!isDir && !isSymlink) {
91
+ index.set(fileName, {
92
+ compressionMethod,
93
+ compressedSize,
94
+ uncompressedSize,
95
+ localHeaderOffset,
96
+ });
97
+ }
98
+
99
+ pos += 46 + nameLen + extraLen + commentLen;
100
+ }
101
+
102
+ return index;
103
+ }
104
+
105
+ function readZipEntry(fd, entry) {
106
+ // Read local file header to get actual data offset
107
+ const localBuf = Buffer.alloc(30);
108
+ readSync(fd, localBuf, 0, 30, entry.localHeaderOffset);
109
+
110
+ const nameLen = localBuf.readUInt16LE(26);
111
+ const extraLen = localBuf.readUInt16LE(28);
112
+ const dataOffset = entry.localHeaderOffset + 30 + nameLen + extraLen;
113
+
114
+ // Read compressed data
115
+ const compressedBuf = Buffer.alloc(entry.compressedSize);
116
+ readSync(fd, compressedBuf, 0, entry.compressedSize, dataOffset);
117
+
118
+ // Decompress if needed
119
+ if (entry.compressionMethod === 0) {
120
+ // Stored (no compression)
121
+ return compressedBuf;
122
+ } else if (entry.compressionMethod === 8) {
123
+ // Deflate
124
+ return inflateRawSync(compressedBuf);
125
+ } else {
126
+ throw new Error(`Unsupported ZIP compression method: ${entry.compressionMethod}`);
127
+ }
128
+ }
129
+
130
+ // --- In-memory ZIP parser for ArrayBuffer-based loading ---
131
+
132
+ function parseZipFromBuffer(buf) {
133
+ // Find EOCD
134
+ let eocdOffset = -1;
135
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65558); i--) {
136
+ if (buf[i] === 0x50 && buf[i + 1] === 0x4b &&
137
+ buf[i + 2] === 0x05 && buf[i + 3] === 0x06) {
138
+ eocdOffset = i;
139
+ break;
140
+ }
141
+ }
142
+ if (eocdOffset === -1) throw new Error('Not a valid ZIP file');
143
+
144
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
145
+ const entryCount = view.getUint16(eocdOffset + 10, true);
146
+ const cdSize = view.getUint32(eocdOffset + 12, true);
147
+ const cdOffset = view.getUint32(eocdOffset + 16, true);
148
+
149
+ const index = new Map();
150
+ let pos = cdOffset;
151
+
152
+ for (let i = 0; i < entryCount; i++) {
153
+ if (view.getUint32(pos, true) !== 0x02014b50) break;
154
+
155
+ const compressionMethod = view.getUint16(pos + 10, true);
156
+ const compressedSize = view.getUint32(pos + 20, true);
157
+ const uncompressedSize = view.getUint32(pos + 24, true);
158
+ const nameLen = view.getUint16(pos + 28, true);
159
+ const extraLen = view.getUint16(pos + 30, true);
160
+ const commentLen = view.getUint16(pos + 32, true);
161
+ const externalAttrs = view.getUint32(pos + 38, true);
162
+ const localHeaderOffset = view.getUint32(pos + 42, true);
163
+
164
+ const decoder = new TextDecoder();
165
+ const fileName = decoder.decode(buf.subarray(pos + 46, pos + 46 + nameLen));
166
+
167
+ const isDir = fileName.endsWith('/');
168
+ const isSymlink = ((externalAttrs >> 16) & 0xF000) === 0xA000;
169
+
170
+ if (!isDir && !isSymlink) {
171
+ index.set(fileName, {
172
+ compressionMethod,
173
+ compressedSize,
174
+ uncompressedSize,
175
+ localHeaderOffset,
176
+ });
177
+ }
178
+
179
+ pos += 46 + nameLen + extraLen + commentLen;
180
+ }
181
+
182
+ return index;
183
+ }
184
+
185
+ function readZipEntryFromBuffer(buf, entry) {
186
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
187
+ const nameLen = view.getUint16(entry.localHeaderOffset + 26, true);
188
+ const extraLen = view.getUint16(entry.localHeaderOffset + 28, true);
189
+ const dataOffset = entry.localHeaderOffset + 30 + nameLen + extraLen;
190
+
191
+ const compressedData = buf.subarray(dataOffset, dataOffset + entry.compressedSize);
192
+
193
+ if (entry.compressionMethod === 0) {
194
+ return compressedData;
195
+ } else if (entry.compressionMethod === 8) {
196
+ return inflateRawSync(compressedData);
197
+ } else {
198
+ throw new Error(`Unsupported ZIP compression method: ${entry.compressionMethod}`);
199
+ }
200
+ }
201
+
202
+ // Max single asset size (256MB)
203
+ const MAX_ASSET_SIZE = 256 * 1024 * 1024;
204
+ // Max entries in a .wasc archive
205
+ const MAX_ARCHIVE_ENTRIES = 100000;
206
+
207
+ export class CartHost {
208
+ constructor() {
209
+ this.instance = null;
210
+ this.memory = null;
211
+ this.info = null; // parsed WCInfo
212
+ this.frameCount = 0;
213
+ this.startTime = 0;
214
+ this.lastFrameTime = 0;
215
+ this.audioReadCursor = 0;
216
+
217
+ // Views into cart memory (set after init)
218
+ this._u8 = null;
219
+ this._u16 = null;
220
+ this._i16 = null;
221
+ this._u32 = null;
222
+ this._f64 = null;
223
+ this._lastByteLength = 0;
224
+
225
+ // Thread support (WASI threads)
226
+ this.isThreaded = false;
227
+ this._sharedMemory = null;
228
+ this._compiledModule = null;
229
+ this._workers = new Map(); // tid → Worker
230
+ this._nextTid = 1;
231
+
232
+ // GL state
233
+ this.usesGL = false; // true if cart imports from 'gl' module
234
+
235
+ // Asset index for .wasc carts
236
+ this._assetIndex = null; // Map<path, entry>
237
+ this._assetFd = null; // file descriptor for on-disk zip reading
238
+ this._assetBuf = null; // in-memory zip buffer (for Uint8Array source)
239
+ this._assetDir = null; // directory path for dev-mode loading
240
+ this._hasAssets = false;
241
+
242
+ // Networking (ABI v3)
243
+ this._manifest = null; // parsed manifest.json
244
+ this._wsConnections = new Map(); // conn_id → { ws, eventQueue: [] }
245
+ this._wsNextId = 0;
246
+ this._dcPeers = new Map(); // peer_id → { dc, label, eventQueue: [] }
247
+
248
+ // Pointer input (ABI v3)
249
+ this._pointerState = new Array(MAX_POINTERS).fill(null).map(() => ({
250
+ x: 0, y: 0, buttons: 0, active: 0,
251
+ }));
252
+ this._pointerEvents = []; // { type, id, x, y, button }
253
+
254
+ // Keyboard input (ABI v3)
255
+ this._keyState = new Uint8Array(KEYS_STATE_SIZE); // 256-bit bitmask
256
+ this._keyEvents = []; // { type, keycode, modifiers }
257
+
258
+ // Pad names (populated each frame from pad objects)
259
+ this._padNames = ['', '', '', ''];
260
+ }
261
+
262
+ /**
263
+ * Load and instantiate a .wasc cart file.
264
+ * @param {string|Uint8Array} source - file path (.wasc), directory path (dev mode), or .wasc zip bytes
265
+ * @param {object} [options]
266
+ * @param {Uint8Array} [options.saveData] - existing save data to load
267
+ * @param {object} [options.glBackend] - WebGL2RenderingContext (from webgl-node or browser). Required if cart uses GL.
268
+ */
269
+ async load(source, options = {}) {
270
+ let wasmBytes;
271
+
272
+ if (typeof source === 'string') {
273
+ const s = await stat(source);
274
+
275
+ if (s.isDirectory()) {
276
+ // Dev mode: load from directory
277
+ wasmBytes = await this._loadFromDirectory(source);
278
+ } else {
279
+ wasmBytes = await this._loadFromWasc(source);
280
+ }
281
+ } else {
282
+ // Uint8Array - must be a .wasc zip
283
+ if (source.length >= 4 && source[0] === 0x50 && source[1] === 0x4b &&
284
+ source[2] === 0x03 && source[3] === 0x04) {
285
+ wasmBytes = this._loadFromWascBuffer(source);
286
+ } else {
287
+ throw new Error('Invalid cart data: expected .wasc (ZIP) format');
288
+ }
289
+ }
290
+
291
+ // Validate module before instantiation
292
+ const module = await WebAssembly.compile(wasmBytes);
293
+ this._validateModule(module);
294
+
295
+ // Detect thread usage (WASI threads model)
296
+ const threadAnalysis = this._analyzeModule(module);
297
+ this.isThreaded = threadAnalysis.isThreaded;
298
+
299
+ // For threaded carts: create shared memory and store module for worker reuse
300
+ if (this.isThreaded) {
301
+ const memLimits = CartHost._parseMemoryImportLimits(wasmBytes);
302
+ if (!memLimits || !memLimits.shared) {
303
+ throw new Error('Threaded cart must have shared memory import (compile with --shared-memory)');
304
+ }
305
+ this._sharedMemory = new WebAssembly.Memory({
306
+ initial: memLimits.initial,
307
+ maximum: memLimits.maximum,
308
+ shared: true,
309
+ });
310
+ this._compiledModule = module;
311
+ }
312
+
313
+ // Detect GL usage: check gpu_api field first, fall back to import scanning for old carts
314
+ const moduleImports = WebAssembly.Module.imports(module);
315
+ const importsGL = moduleImports.some(imp =>
316
+ imp.module === 'gl' ||
317
+ (imp.module === 'env' && imp.kind === 'function' && /^gl[A-Z]/.test(imp.name))
318
+ );
319
+ // gpu_api will be read after wc_get_info - for now detect from imports
320
+ this._importsGL = importsGL;
321
+ this.usesGL = importsGL;
322
+
323
+ if (this.usesGL && !options.glBackend) {
324
+ // Cart imports GL but no GL backend provided - stub GL imports.
325
+ // Cart can still use 2D framebuffer.
326
+ this.usesGL = false;
327
+ }
328
+ // If glBackend IS provided, keep usesGL = true even with fbPtr (hybrid cart)
329
+
330
+ // Eagerly resolve WebSocket implementation for Node.js (must be sync at call time)
331
+ if (this._manifest?.net?.websocket) {
332
+ if (globalThis.WebSocket) {
333
+ this._WebSocketImpl = globalThis.WebSocket;
334
+ } else {
335
+ try {
336
+ const ws = await import('ws');
337
+ this._WebSocketImpl = ws.default || ws.WebSocket;
338
+ } catch {
339
+ // No WebSocket support - _wsOpen will return -1
340
+ }
341
+ }
342
+ }
343
+
344
+ // Minimal host imports
345
+ const imports = {
346
+ env: {
347
+ wc_log: (ptr, len) => {
348
+ this._updateViews();
349
+ if (this._u8) {
350
+ const bytes = this._u8.slice(ptr, ptr + len);
351
+ const text = new TextDecoder().decode(bytes);
352
+ console.error('[cart]', text);
353
+ }
354
+ },
355
+ // Asset API (v2) - always provided, returns -1 if no assets loaded
356
+ wc_asset_size: (pathPtr, pathLen) => {
357
+ return this._assetSize(pathPtr, pathLen);
358
+ },
359
+ wc_load_asset: (pathPtr, pathLen, destPtr, maxSize) => {
360
+ return this._loadAsset(pathPtr, pathLen, destPtr, maxSize);
361
+ },
362
+ // Pad name query - returns the device name for a given pad slot
363
+ wc_pad_name: (padId, bufPtr, bufLen) => {
364
+ return this._padName(padId, bufPtr, bufLen);
365
+ },
366
+ // --- WebSocket API (ABI v3) ---
367
+ wc_ws_open: (urlPtr, urlLen) => {
368
+ return this._wsOpen(urlPtr, urlLen);
369
+ },
370
+ wc_ws_close: (connId, code) => {
371
+ this._wsClose(connId, code);
372
+ },
373
+ wc_ws_send: (connId, dataPtr, len) => {
374
+ return this._wsSend(connId, dataPtr, len, false);
375
+ },
376
+ wc_ws_send_text: (connId, strPtr, len) => {
377
+ return this._wsSend(connId, strPtr, len, true);
378
+ },
379
+ wc_ws_state: (connId) => {
380
+ return this._wsState(connId);
381
+ },
382
+ // --- Data Channel API (ABI v3) ---
383
+ wc_dc_peer_count: () => {
384
+ return this._dcPeers.size;
385
+ },
386
+ wc_dc_peer_info: (index, destPtr, maxLen) => {
387
+ return this._dcPeerInfo(index, destPtr, maxLen);
388
+ },
389
+ wc_dc_send: (peerId, dataPtr, len) => {
390
+ return this._dcSend(peerId, dataPtr, len);
391
+ },
392
+ wc_dc_broadcast: (dataPtr, len) => {
393
+ return this._dcBroadcast(dataPtr, len);
394
+ },
395
+ // memfs - in-memory filesystem for engine carts (Godot etc.)
396
+ // The cart calls memfs_register_file to map a name to a region of
397
+ // its own WASM linear memory. We record the pointer+size so the
398
+ // cart's filesystem layer can read from it later.
399
+ memfs_register_file: (namePtr, dataPtr, size) => {
400
+ try {
401
+ const name = new TextDecoder().decode(
402
+ new Uint8Array(this.memory.buffer, namePtr,
403
+ new Uint8Array(this.memory.buffer).indexOf(0, namePtr) - namePtr));
404
+ if (!this._memfsFiles) this._memfsFiles = new Map();
405
+ this._memfsFiles.set(name, { ptr: dataPtr, size });
406
+ return 0;
407
+ } catch(e) { return -1; }
408
+ },
409
+ // Emscripten memory growth notification
410
+ emscripten_notify_memory_growth: () => { this._updateViews(); },
411
+ // Emscripten stubs (used by gl4es and emscripten libc)
412
+ emscripten_asm_const_int: () => 0,
413
+ emscripten_asm_const_double: () => 0.0,
414
+ emscripten_get_element_css_size: (targetPtr, widthPtr, heightPtr) => {
415
+ try {
416
+ const view = new DataView(this.memory.buffer);
417
+ view.setFloat64(widthPtr, this.info ? this.info.width : 800, true);
418
+ view.setFloat64(heightPtr, this.info ? this.info.height : 600, true);
419
+ } catch(e) {}
420
+ return 0;
421
+ },
422
+ __syscall_getcwd: () => -1,
423
+ __syscall_getdents64: () => -1,
424
+ },
425
+ // Safe no-op WASI stubs (emscripten libc may require these for snprintf/math)
426
+ wasi_snapshot_preview1: {
427
+ fd_close: () => 0,
428
+ fd_write: (fd, iovs, iovs_len, nwritten_ptr) => {
429
+ try {
430
+ this._updateViews();
431
+ const view = new DataView(this.memory.buffer);
432
+ let totalWritten = 0;
433
+ let text = '';
434
+ for (let i = 0; i < iovs_len; i++) {
435
+ const ptr = view.getUint32(iovs + i * 8, true);
436
+ const len = view.getUint32(iovs + i * 8 + 4, true);
437
+ if (this._u8 && len > 0) {
438
+ text += new TextDecoder().decode(this._u8.slice(ptr, ptr + len));
439
+ }
440
+ totalWritten += len;
441
+ }
442
+ if (text && (fd === 1 || fd === 2)) {
443
+ // stdout or stderr
444
+ const lines = text.split('\n');
445
+ for (const line of lines) {
446
+ if (line.length > 0) process.stderr.write('[cart] ' + line + '\n');
447
+ }
448
+ }
449
+ if (nwritten_ptr) view.setUint32(nwritten_ptr, totalWritten, true);
450
+ return 0;
451
+ } catch(e) { return 0; }
452
+ },
453
+ fd_seek: () => 0,
454
+ fd_read: () => 0,
455
+ environ_get: () => 0,
456
+ environ_sizes_get: () => 0,
457
+ proc_exit: () => {},
458
+ clock_time_get: (id, precision, resultPtr) => {
459
+ // Return monotonic time in nanoseconds (used by SDL_GetTicks)
460
+ try {
461
+ const ns = BigInt(Math.round(performance.now() * 1e6));
462
+ const view = new DataView(this.memory.buffer);
463
+ view.setBigUint64(resultPtr, ns, true);
464
+ } catch (e) { /* memory not ready yet */ }
465
+ return 0;
466
+ },
467
+ sched_yield: () => 0,
468
+ },
469
+ };
470
+
471
+ // Provide no-op stubs for any wasi_snapshot_preview1 imports not explicitly handled
472
+ for (const imp of moduleImports) {
473
+ if (imp.module === 'wasi_snapshot_preview1' && imp.kind === 'function') {
474
+ if (!(imp.name in imports.wasi_snapshot_preview1)) {
475
+ imports.wasi_snapshot_preview1[imp.name] = () => 0;
476
+ }
477
+ }
478
+ }
479
+
480
+ // Auto-stub any env functions not explicitly handled (syscalls, emscripten,
481
+ // pthread, networking, etc.). These appear in engine-level carts like Godot.
482
+ for (const imp of moduleImports) {
483
+ if (imp.module === 'env' && imp.kind === 'function') {
484
+ if (!(imp.name in imports.env)) {
485
+ imports.env[imp.name] = () => -1; // -1 = ENOSYS/error for syscalls
486
+ }
487
+ }
488
+ }
489
+
490
+ // Stub GL imports for hybrid carts that import GL but don't use it
491
+ if (!this.usesGL) {
492
+ const glStubs = {};
493
+ for (const imp of moduleImports) {
494
+ if (imp.module === 'gl' && imp.kind === 'function') {
495
+ glStubs[imp.name] = () => 0;
496
+ }
497
+ }
498
+ if (Object.keys(glStubs).length > 0) {
499
+ imports.gl = glStubs;
500
+ }
501
+ }
502
+
503
+ // Wire up GL imports if cart uses GL
504
+ if (this.usesGL) {
505
+ const glFuncs = createWebGLImports({
506
+ getMemory: () => this.memory,
507
+ ctx: options.glBackend,
508
+ getMalloc: () => this.instance?.exports?.malloc || null,
509
+ nativeGL: options.nativeGL || null,
510
+ });
511
+ imports.gl = glFuncs;
512
+ // Auto-stub any GL imports not covered by webgl_imports.js
513
+ for (const imp of moduleImports) {
514
+ if (imp.module === 'gl' && imp.kind === 'function' && !(imp.name in glFuncs)) {
515
+ glFuncs[imp.name] = () => 0;
516
+ }
517
+ }
518
+ // Also provide GL functions under 'env' module for carts that use
519
+ // gl4es (which imports GLES2 functions from 'env' internally).
520
+ // Covers both env.glXxx and env.emscripten_glXxx patterns.
521
+ for (const imp of moduleImports) {
522
+ if (imp.module !== 'env' || imp.kind !== 'function') continue;
523
+ // Direct GL function in env (e.g. env.glStencilFunc)
524
+ // Note: must overwrite auto-stubs (which run before GL wiring)
525
+ if (imp.name.startsWith('gl') && imp.name in glFuncs) {
526
+ imports.env[imp.name] = glFuncs[imp.name];
527
+ }
528
+ // Emscripten GL wrapper (e.g. env.emscripten_glEnable -> glEnable)
529
+ else if (imp.name.startsWith('emscripten_gl')) {
530
+ const glName = imp.name.replace('emscripten_', '');
531
+ // Strip OES/EXT/ANGLE/WEBGL suffixes to find base function
532
+ const baseName = glName.replace(/(OES|EXT|ANGLE|WEBGL)$/, '');
533
+ if (glName in glFuncs) {
534
+ imports.env[imp.name] = glFuncs[glName];
535
+ } else if (baseName in glFuncs) {
536
+ imports.env[imp.name] = glFuncs[baseName];
537
+ } else {
538
+ // Stub for unsupported emscripten GL extensions
539
+ imports.env[imp.name] = () => 0;
540
+ }
541
+ }
542
+ }
543
+ }
544
+
545
+ // For threaded carts: provide shared memory as import and thread-spawn
546
+ if (this.isThreaded) {
547
+ imports.env.memory = this._sharedMemory;
548
+ imports.wasi = imports.wasi || {};
549
+ imports.wasi['thread-spawn'] = (startArg) => this._spawnThread(startArg);
550
+ }
551
+
552
+ // Instantiate
553
+ this.instance = await WebAssembly.instantiate(module, imports);
554
+ const exports = this.instance.exports;
555
+
556
+ // Memory access - threaded carts use the shared memory we created,
557
+ // non-threaded carts use the module's exported memory
558
+ if (this.isThreaded) {
559
+ this.memory = exports.memory || this._sharedMemory;
560
+ } else {
561
+ this.memory = exports.memory;
562
+ if (!this.memory) {
563
+ throw new Error('Cart must export memory');
564
+ }
565
+ }
566
+
567
+ this._updateViews();
568
+
569
+ // Read info struct BEFORE wc_init so we can load save data first
570
+ if (typeof exports.wc_get_info !== 'function') {
571
+ throw new Error('Cart must export wc_get_info');
572
+ }
573
+
574
+ this._infoPtr = exports.wc_get_info();
575
+ this.info = this._readInfo(this._infoPtr);
576
+
577
+ // Validate ABI version (accept v1 and v2)
578
+ if (this.info.version < MIN_ABI_VERSION || this.info.version > ABI_VERSION) {
579
+ throw new Error(`ABI version mismatch: cart=${this.info.version}, host supports ${MIN_ABI_VERSION}-${ABI_VERSION}`);
580
+ }
581
+
582
+
583
+ // Load save data BEFORE wc_init so cart can read it during init
584
+ if (options.saveData && this.info.saveSize > 0) {
585
+ const saveRegion = this._u8.subarray(this.info.savePtr, this.info.savePtr + this.info.saveSize);
586
+ const copyLen = Math.min(options.saveData.length, this.info.saveSize);
587
+ saveRegion.set(options.saveData.subarray(0, copyLen));
588
+ }
589
+
590
+ // Write host info BEFORE wc_init so cart can read preferred resolution etc.
591
+ if (this.info.hostInfoPtr) {
592
+ this._writeHostInfo(this.info.hostInfoPtr, options);
593
+ }
594
+
595
+ // Call WASI reactor _initialize (emscripten static constructors) if present
596
+ if (typeof exports._initialize === 'function') {
597
+ exports._initialize();
598
+ this._updateViews(); // memory may have grown
599
+ }
600
+
601
+ // Call wc_init after save data and host info are loaded
602
+ if (typeof exports.wc_init === 'function') {
603
+ exports.wc_init();
604
+ this._updateViews(); // memory may have grown
605
+ }
606
+
607
+ // Re-read info after wc_init - cart may have changed resolution based on host prefs
608
+ this.info = this._readInfo(this._infoPtr);
609
+
610
+ // Update GL detection from gpu_api field (authoritative) with import fallback for old carts
611
+ if (this.info.gpuApi > 0) {
612
+ this.usesGL = true;
613
+ } else if (this.info.gpuApi === 0 && this._importsGL) {
614
+ // Old cart that imports GL but doesn't set gpu_api - use import detection
615
+ // (will be removed once all carts set gpu_api)
616
+ this.usesGL = !!options.glBackend;
617
+ }
618
+
619
+ // Initialize timing
620
+ this.startTime = performance.now();
621
+ this.lastFrameTime = this.startTime;
622
+ this.frameCount = 0;
623
+ this.audioReadCursor = 0;
624
+ }
625
+
626
+ /**
627
+ * Run one frame: write time + input, call wc_render, return frame data.
628
+ * @param {Array} [pads] - array of up to 4 pad objects
629
+ * @returns {{ framebuffer: Uint8Array, width: number, height: number, audio: Int16Array|Float32Array|null }}
630
+ */
631
+ runFrame(pads) {
632
+ const now = performance.now();
633
+ const deltaMs = now - this.lastFrameTime;
634
+ const timeMs = now - this.startTime;
635
+ this.lastFrameTime = now;
636
+
637
+ this._updateViews(); // in case memory grew
638
+
639
+ // Write time
640
+ this._writeTime(timeMs, deltaMs, this.frameCount);
641
+
642
+ // Write input pads
643
+ this._writePads(pads || []);
644
+
645
+ // Write pointer/keyboard state and deliver events before render
646
+ this._writePointerState();
647
+ this._writeKeyState();
648
+ this._deliverNetEvents();
649
+ this._deliverPointerEvents();
650
+ this._deliverKeyEvents();
651
+
652
+ // Call wc_render
653
+ this.instance.exports.wc_render();
654
+ this._updateViews(); // in case memory grew during render
655
+
656
+ // Re-read width/height from WASM memory (cart may update during deferred init)
657
+ const base = this._infoPtr >> 2;
658
+ const newW = this._u32[base + 1];
659
+ const newH = this._u32[base + 2];
660
+ if (newW > 0 && newH > 0 && (newW !== this.info.width || newH !== this.info.height)) {
661
+ this.info.width = newW;
662
+ this.info.height = newH;
663
+ }
664
+
665
+ this.frameCount++;
666
+
667
+ // Read framebuffer
668
+ const fbSize = this.info.width * this.info.height * 4;
669
+ const framebuffer = this._u8.subarray(this.info.fbPtr, this.info.fbPtr + fbSize);
670
+
671
+ // Drain audio ring buffer
672
+ const audio = this._drainAudio();
673
+
674
+ return {
675
+ framebuffer,
676
+ width: this.info.width,
677
+ height: this.info.height,
678
+ audio,
679
+ };
680
+ }
681
+
682
+ /**
683
+ * Get the current save data.
684
+ * @returns {Uint8Array|null}
685
+ */
686
+ getSaveData() {
687
+ if (!this.info || this.info.saveSize === 0) return null;
688
+ // Return a copy so caller owns the buffer
689
+ return new Uint8Array(
690
+ this._u8.slice(this.info.savePtr, this.info.savePtr + this.info.saveSize)
691
+ );
692
+ }
693
+
694
+ /**
695
+ * Get cart info (dimensions, save size, etc.)
696
+ */
697
+ getInfo() {
698
+ return this.info ? { ...this.info } : null;
699
+ }
700
+
701
+ /**
702
+ * Get the parsed manifest (includes players, net, etc.)
703
+ */
704
+ getManifest() {
705
+ return this._manifest ? { ...this._manifest } : null;
706
+ }
707
+
708
+ /**
709
+ * Spawn a thread for WASI threads model.
710
+ * Called when the cart invokes wasi.thread-spawn(start_arg).
711
+ * Returns a positive tid on success, or negative on error.
712
+ */
713
+ _spawnThread(startArg) {
714
+ if (!this.isThreaded || !this._compiledModule || !this._sharedMemory) return -1;
715
+
716
+ const tid = this._nextTid++;
717
+
718
+ // Serialize asset config for the worker (so it can do its own asset reads)
719
+ const assetConfig = {};
720
+ if (this._assetFd !== null) {
721
+ // ZIP-based .wasc - pass file path and serialized index
722
+ assetConfig.type = 'zip';
723
+ assetConfig.filePath = this._assetFilePath;
724
+ assetConfig.index = this._assetIndex ? [...this._assetIndex.entries()] : [];
725
+ } else if (this._assetBuf) {
726
+ // In-memory buffer - pass the buffer (SharedArrayBuffer compatible)
727
+ assetConfig.type = 'buffer';
728
+ assetConfig.buffer = this._assetBuf;
729
+ assetConfig.index = this._assetIndex ? [...this._assetIndex.entries()] : [];
730
+ } else if (this._assetDir) {
731
+ assetConfig.type = 'dir';
732
+ assetConfig.dir = this._assetDir;
733
+ }
734
+
735
+ const workerURL = new URL('./cartWorker.js', import.meta.url);
736
+ const worker = new Worker(workerURL, {
737
+ workerData: {
738
+ module: this._compiledModule,
739
+ memory: this._sharedMemory,
740
+ tid,
741
+ startArg,
742
+ assetConfig,
743
+ },
744
+ });
745
+
746
+ worker.on('message', (msg) => {
747
+ if (msg.type === 'spawn') {
748
+ // Nested thread spawning: worker thread requested a new thread
749
+ const nestedTid = this._spawnThread(msg.startArg);
750
+ worker.postMessage({ type: 'spawned', tid: nestedTid, requestId: msg.requestId });
751
+ } else if (msg.type === 'exit') {
752
+ this._workers.delete(msg.tid);
753
+ }
754
+ });
755
+
756
+ worker.on('error', (err) => {
757
+ console.error(`[thread ${tid}] error:`, err.message);
758
+ this._workers.delete(tid);
759
+ });
760
+
761
+ worker.on('exit', () => {
762
+ this._workers.delete(tid);
763
+ });
764
+
765
+ this._workers.set(tid, worker);
766
+ return tid;
767
+ }
768
+
769
+ /**
770
+ * Clean up resources (close file descriptor if open, terminate threads)
771
+ */
772
+ destroy() {
773
+ // Terminate all worker threads
774
+ for (const [tid, worker] of this._workers) {
775
+ worker.terminate();
776
+ }
777
+ this._workers.clear();
778
+
779
+ // Close all WebSocket connections
780
+ for (const [, conn] of this._wsConnections) {
781
+ try { conn.ws.close(); } catch {}
782
+ }
783
+ this._wsConnections.clear();
784
+ this._dcPeers.clear();
785
+
786
+ if (this._assetFd !== null) {
787
+ try { closeSync(this._assetFd); } catch {}
788
+ this._assetFd = null;
789
+ }
790
+ this._assetIndex = null;
791
+ this._assetBuf = null;
792
+ this._assetDir = null;
793
+ this._sharedMemory = null;
794
+ this._compiledModule = null;
795
+ }
796
+
797
+ // --- .wasc loading ---
798
+
799
+ async _loadFromWasc(filePath) {
800
+ const fd = openSync(filePath, 'r');
801
+ const fileStats = statSync(filePath);
802
+ const fileSize = fileStats.size;
803
+
804
+ // Parse ZIP central directory
805
+ const index = parseZipCentralDirectory(fd, fileSize);
806
+
807
+ if (index.size > MAX_ARCHIVE_ENTRIES) {
808
+ closeSync(fd);
809
+ throw new Error(`Archive has too many entries (${index.size} > ${MAX_ARCHIVE_ENTRIES})`);
810
+ }
811
+
812
+ // Read manifest.json
813
+ const manifestEntry = index.get('manifest.json');
814
+ if (!manifestEntry) {
815
+ closeSync(fd);
816
+ throw new Error('.wasc archive missing manifest.json');
817
+ }
818
+ const manifestBuf = readZipEntry(fd, manifestEntry);
819
+ const manifest = JSON.parse(manifestBuf.toString('utf8'));
820
+ this._manifest = manifest;
821
+
822
+ // Read cart.wasm (or whatever entry is specified)
823
+ const wasmName = manifest.entry || 'cart.wasm';
824
+ const wasmEntry = index.get(wasmName);
825
+ if (!wasmEntry) {
826
+ closeSync(fd);
827
+ throw new Error(`.wasc archive missing ${wasmName}`);
828
+ }
829
+ const wasmBytes = readZipEntry(fd, wasmEntry);
830
+
831
+ // Build asset index (strip 'assets/' prefix if the manifest specifies an assets root)
832
+ const assetsPrefix = manifest.assets || 'assets/';
833
+ this._assetIndex = new Map();
834
+ for (const [path, entry] of index) {
835
+ if (path === 'manifest.json' || path === wasmName) continue;
836
+
837
+ // Validate entry sizes
838
+ if (entry.uncompressedSize > MAX_ASSET_SIZE) continue;
839
+
840
+ // Store with prefix stripped for lookup
841
+ let assetPath = path;
842
+ if (assetsPrefix && path.startsWith(assetsPrefix)) {
843
+ assetPath = path.slice(assetsPrefix.length);
844
+ }
845
+ this._assetIndex.set(assetPath, entry);
846
+ // Also store with full path for carts that use full paths
847
+ if (assetPath !== path) {
848
+ this._assetIndex.set(path, entry);
849
+ }
850
+ }
851
+
852
+ // Generate virtual _filelist.txt with all asset paths
853
+ const fileList = [...this._assetIndex.keys()].filter(p => !p.startsWith('assets/')).join('\n');
854
+ this._fileListBuf = Buffer.from(fileList, 'utf8');
855
+
856
+ this._assetFd = fd;
857
+ this._assetFilePath = filePath; // stored for worker thread asset access
858
+ this._hasAssets = this._assetIndex.size > 0;
859
+
860
+ return wasmBytes;
861
+ }
862
+
863
+ _loadFromWascBuffer(buf) {
864
+ const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
865
+ const index = parseZipFromBuffer(u8);
866
+
867
+ if (index.size > MAX_ARCHIVE_ENTRIES) {
868
+ throw new Error(`Archive has too many entries (${index.size} > ${MAX_ARCHIVE_ENTRIES})`);
869
+ }
870
+
871
+ // Read manifest
872
+ const manifestEntry = index.get('manifest.json');
873
+ if (!manifestEntry) throw new Error('.wasc archive missing manifest.json');
874
+ const manifestBuf = readZipEntryFromBuffer(u8, manifestEntry);
875
+ const manifest = JSON.parse(new TextDecoder().decode(manifestBuf));
876
+ this._manifest = manifest;
877
+
878
+ // Read wasm
879
+ const wasmName = manifest.entry || 'cart.wasm';
880
+ const wasmEntry = index.get(wasmName);
881
+ if (!wasmEntry) throw new Error(`.wasc archive missing ${wasmName}`);
882
+ const wasmBytes = readZipEntryFromBuffer(u8, wasmEntry);
883
+
884
+ // Build asset index
885
+ const assetsPrefix = manifest.assets || 'assets/';
886
+ this._assetIndex = new Map();
887
+ for (const [path, entry] of index) {
888
+ if (path === 'manifest.json' || path === wasmName) continue;
889
+ if (entry.uncompressedSize > MAX_ASSET_SIZE) continue;
890
+
891
+ let assetPath = path;
892
+ if (assetsPrefix && path.startsWith(assetsPrefix)) {
893
+ assetPath = path.slice(assetsPrefix.length);
894
+ }
895
+ this._assetIndex.set(assetPath, entry);
896
+ if (assetPath !== path) {
897
+ this._assetIndex.set(path, entry);
898
+ }
899
+ }
900
+
901
+ // Generate virtual _filelist.txt with all asset paths
902
+ const fileList = [...this._assetIndex.keys()].filter(p => !p.startsWith('assets/')).join('\n');
903
+ this._fileListBuf = new TextEncoder().encode(fileList);
904
+
905
+ this._assetBuf = u8;
906
+ this._hasAssets = this._assetIndex.size > 0;
907
+
908
+ return wasmBytes;
909
+ }
910
+
911
+ async _loadFromDirectory(dirPath) {
912
+ // Dev mode: load manifest.json + cart.wasm + assets from a directory
913
+ const manifestPath = join(dirPath, 'manifest.json');
914
+ const manifestBuf = await readFile(manifestPath);
915
+ const manifest = JSON.parse(manifestBuf.toString('utf8'));
916
+ this._manifest = manifest;
917
+
918
+ const wasmName = manifest.entry || 'cart.wasm';
919
+ const wasmBytes = await readFile(join(dirPath, wasmName));
920
+
921
+ // Set up directory-based asset loading
922
+ const assetsDir = join(dirPath, manifest.assets || 'assets');
923
+ this._assetDir = assetsDir;
924
+ this._hasAssets = true;
925
+ // No index needed - we'll read files directly from disk
926
+
927
+ return wasmBytes;
928
+ }
929
+
930
+ // --- Asset API implementation ---
931
+
932
+ _readPath(pathPtr, pathLen) {
933
+ this._updateViews();
934
+ if (!this._u8 || pathLen === 0 || pathLen > 4096) return null;
935
+ const bytes = this._u8.slice(pathPtr, pathPtr + pathLen);
936
+ const path = new TextDecoder().decode(bytes);
937
+ if (!validateAssetPath(path)) return null;
938
+ return path;
939
+ }
940
+
941
+ _assetSize(pathPtr, pathLen) {
942
+ if (!this._hasAssets) return -1;
943
+ const path = this._readPath(pathPtr, pathLen);
944
+ if (!path) return -1;
945
+
946
+ // Virtual _filelist.txt
947
+ if (path === '_filelist.txt' && this._fileListBuf) {
948
+ return this._fileListBuf.length;
949
+ }
950
+
951
+ // Directory-based dev mode
952
+ if (this._assetDir) {
953
+ try {
954
+ const s = statSync(join(this._assetDir, path));
955
+ return s.size;
956
+ } catch {
957
+ return -1;
958
+ }
959
+ }
960
+
961
+ // ZIP-based
962
+ const entry = this._assetIndex.get(path);
963
+ if (!entry) return -1;
964
+ return entry.uncompressedSize;
965
+ }
966
+
967
+ _loadAsset(pathPtr, pathLen, destPtr, maxSize) {
968
+ if (!this._hasAssets) return -1;
969
+ const path = this._readPath(pathPtr, pathLen);
970
+ if (!path) return -1;
971
+
972
+ let data;
973
+
974
+ // Virtual _filelist.txt
975
+ if (path === '_filelist.txt' && this._fileListBuf) {
976
+ data = this._fileListBuf;
977
+ } else if (this._assetDir) {
978
+ // Directory-based dev mode - read file directly
979
+ try {
980
+ const filePath = join(this._assetDir, path);
981
+ const fd = openSync(filePath, 'r');
982
+ const s = statSync(filePath);
983
+ const size = Math.min(s.size, maxSize);
984
+ const buf = Buffer.alloc(size);
985
+ readSync(fd, buf, 0, size, 0);
986
+ closeSync(fd);
987
+ data = buf;
988
+ } catch {
989
+ return -1;
990
+ }
991
+ } else if (this._assetFd !== null) {
992
+ // On-disk ZIP - read just the requested entry
993
+ const entry = this._assetIndex.get(path);
994
+ if (!entry) return -1;
995
+ try {
996
+ data = readZipEntry(this._assetFd, entry);
997
+ } catch {
998
+ return -1;
999
+ }
1000
+ } else if (this._assetBuf) {
1001
+ // In-memory ZIP
1002
+ const entry = this._assetIndex.get(path);
1003
+ if (!entry) return -1;
1004
+ try {
1005
+ data = readZipEntryFromBuffer(this._assetBuf, entry);
1006
+ } catch {
1007
+ return -1;
1008
+ }
1009
+ } else {
1010
+ return -1;
1011
+ }
1012
+
1013
+ // Copy into cart memory
1014
+ const copyLen = Math.min(data.length, maxSize);
1015
+ this._updateViews();
1016
+ this._u8.set(data.subarray ? data.subarray(0, copyLen) : new Uint8Array(data.buffer || data, 0, copyLen), destPtr);
1017
+
1018
+ return copyLen;
1019
+ }
1020
+
1021
+ // --- Private ---
1022
+
1023
+ /**
1024
+ * Analyze module for threading support (WASI threads model).
1025
+ * Returns { isThreaded, importsMemory } without throwing.
1026
+ */
1027
+ _analyzeModule(module) {
1028
+ const imports = WebAssembly.Module.imports(module);
1029
+ const exports = WebAssembly.Module.exports(module);
1030
+
1031
+ const hasThreadSpawn = imports.some(
1032
+ i => i.module === 'wasi' && i.name === 'thread-spawn' && i.kind === 'function'
1033
+ );
1034
+ const hasThreadStart = exports.some(
1035
+ e => e.name === 'wasi_thread_start' && e.kind === 'function'
1036
+ );
1037
+ const importsMemory = imports.some(i => i.kind === 'memory');
1038
+
1039
+ // Detect Emscripten pthreads (different from WASI threads)
1040
+ const hasEmscriptenThreads = imports.some(
1041
+ i => i.module === 'env' && i.name === '_emscripten_thread_init' && i.kind === 'function'
1042
+ );
1043
+
1044
+ // Validate: thread-spawn and wasi_thread_start must come in pairs
1045
+ if (hasThreadSpawn && !hasThreadStart) {
1046
+ throw new Error(
1047
+ 'Cart imports wasi.thread-spawn but does not export wasi_thread_start. ' +
1048
+ 'Both are required for WASI threads.'
1049
+ );
1050
+ }
1051
+ if (hasThreadStart && !hasThreadSpawn) {
1052
+ throw new Error(
1053
+ 'Cart exports wasi_thread_start but does not import wasi.thread-spawn. ' +
1054
+ 'Both are required for WASI threads.'
1055
+ );
1056
+ }
1057
+
1058
+ return {
1059
+ isThreaded: (hasThreadSpawn && hasThreadStart) || hasEmscriptenThreads,
1060
+ importsMemory,
1061
+ };
1062
+ }
1063
+
1064
+ _validateModule(module) {
1065
+ const moduleImports = WebAssembly.Module.imports(module);
1066
+ const moduleExports = WebAssembly.Module.exports(module);
1067
+
1068
+ // Check imports - allow env, WASI stubs, and gl
1069
+ for (const imp of moduleImports) {
1070
+ if (imp.module === 'wasi_snapshot_preview1' || imp.module === 'wasi') {
1071
+ // Allow WASI imports - we provide safe no-op stubs
1072
+ continue;
1073
+ }
1074
+ if (imp.module === 'gl') {
1075
+ // Allow GL imports - provided by createWebGLImports()
1076
+ continue;
1077
+ }
1078
+ if (imp.module !== 'env') {
1079
+ throw new Error(`Cart imports unknown module: "${imp.module}"`);
1080
+ }
1081
+ // Allow memory imports (used by threaded carts with shared memory)
1082
+ if (imp.kind === 'memory') continue;
1083
+ // Allow known env imports + GL/emscripten functions (for gl4es carts)
1084
+ if (imp.kind === 'function') {
1085
+ // GL, emscripten GL, gl4es-internal, syscalls, emscripten, and
1086
+ // common libc functions are all allowed under env
1087
+ if (imp.name.startsWith('gl') || imp.name.startsWith('gles_')
1088
+ || imp.name.startsWith('emscripten_gl')
1089
+ || imp.name.startsWith('emscripten_')
1090
+ || imp.name.startsWith('__syscall_')
1091
+ || imp.name.startsWith('wc_')
1092
+ || imp.name.startsWith('memfs_')
1093
+ || imp.name.startsWith('pthread_')
1094
+ || ['getaddrinfo', 'getnameinfo'].includes(imp.name)) {
1095
+ continue;
1096
+ }
1097
+ // For large engine carts (Godot, etc.), allow any env function -
1098
+ // we auto-stub unknowns below
1099
+ }
1100
+ }
1101
+
1102
+ // Require wc_render export
1103
+ const exportNames = moduleExports.map(e => e.name);
1104
+ if (!exportNames.includes('wc_render')) {
1105
+ throw new Error('Cart must export wc_render');
1106
+ }
1107
+ if (!exportNames.includes('wc_get_info')) {
1108
+ throw new Error('Cart must export wc_get_info');
1109
+ }
1110
+ // Threaded carts may import memory instead of exporting it
1111
+ // (they can also re-export it, but it's not required)
1112
+ const analysis = this._analyzeModule(module);
1113
+ if (!exportNames.includes('memory') && !analysis.importsMemory) {
1114
+ throw new Error('Cart must export memory');
1115
+ }
1116
+ }
1117
+
1118
+ /**
1119
+ * Parse WASM binary import section to find memory import limits.
1120
+ * Returns { initial, maximum, shared } or null if no memory import.
1121
+ */
1122
+ static _parseMemoryImportLimits(wasmBytes) {
1123
+ const buf = wasmBytes instanceof Uint8Array ? wasmBytes : new Uint8Array(wasmBytes);
1124
+ let pos = 8; // skip 8-byte header (magic + version)
1125
+
1126
+ function readLEB128() {
1127
+ let result = 0, shift = 0;
1128
+ while (pos < buf.length) {
1129
+ const byte = buf[pos++];
1130
+ result |= (byte & 0x7F) << shift;
1131
+ if (!(byte & 0x80)) break;
1132
+ shift += 7;
1133
+ }
1134
+ return result;
1135
+ }
1136
+
1137
+ function skipBytes(n) { pos += n; }
1138
+
1139
+ while (pos < buf.length) {
1140
+ const sectionId = buf[pos++];
1141
+ const sectionSize = readLEB128();
1142
+ const sectionEnd = pos + sectionSize;
1143
+
1144
+ if (sectionId === 2) { // Import section
1145
+ const count = readLEB128();
1146
+ for (let i = 0; i < count; i++) {
1147
+ const modLen = readLEB128();
1148
+ skipBytes(modLen); // module name
1149
+ const fieldLen = readLEB128();
1150
+ skipBytes(fieldLen); // field name
1151
+ const kind = buf[pos++];
1152
+
1153
+ if (kind === 0x02) { // memory import
1154
+ const flags = buf[pos++];
1155
+ const shared = !!(flags & 0x02);
1156
+ const hasMax = !!(flags & 0x01);
1157
+ const initial = readLEB128();
1158
+ const maximum = hasMax ? readLEB128() : undefined;
1159
+ return { initial, maximum, shared };
1160
+ } else if (kind === 0x00) { // function import
1161
+ readLEB128(); // type index
1162
+ } else if (kind === 0x01) { // table import
1163
+ pos++; // reftype
1164
+ const tFlags = buf[pos++];
1165
+ readLEB128(); // initial
1166
+ if (tFlags & 0x01) readLEB128(); // maximum
1167
+ } else if (kind === 0x03) { // global import
1168
+ pos++; // valtype
1169
+ pos++; // mutability
1170
+ }
1171
+ }
1172
+ return null; // no memory import in section
1173
+ }
1174
+
1175
+ pos = sectionEnd; // skip to next section
1176
+ }
1177
+ return null;
1178
+ }
1179
+
1180
+ _updateViews() {
1181
+ const buf = this.memory.buffer;
1182
+ // For SharedArrayBuffer, reference stays the same after grow but byteLength changes
1183
+ if (buf === this._lastBuffer && buf.byteLength === this._lastByteLength) return;
1184
+ this._lastBuffer = buf;
1185
+ this._lastByteLength = buf.byteLength;
1186
+ this._u8 = new Uint8Array(buf);
1187
+ this._u16 = new Uint16Array(buf);
1188
+ this._i16 = new Int16Array(buf);
1189
+ this._u32 = new Uint32Array(buf);
1190
+ this._f32 = new Float32Array(buf);
1191
+ this._f64 = new Float64Array(buf);
1192
+ }
1193
+
1194
+ // --- Networking (ABI v3) ---
1195
+
1196
+ /**
1197
+ * Write binary data to a temporary location in WASM memory and invoke a callback.
1198
+ * Uses malloc if available, otherwise uses a scratch region after the stack.
1199
+ */
1200
+ _withTempWasmData(data, callback) {
1201
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
1202
+ const len = bytes.length;
1203
+ const malloc = this.instance.exports.malloc;
1204
+ const free = this.instance.exports.free;
1205
+
1206
+ if (malloc && free) {
1207
+ const ptr = malloc(len);
1208
+ if (ptr === 0) return;
1209
+ this._updateViews();
1210
+ this._u8.set(bytes, ptr);
1211
+ try { callback(ptr, len); } finally { free(ptr); }
1212
+ } else {
1213
+ // Fallback: write to end of memory (risky but acceptable for small payloads)
1214
+ // Use the last 64KB of memory as scratch space
1215
+ const memSize = this.memory.buffer.byteLength;
1216
+ const scratchStart = memSize - 65536;
1217
+ if (len > 65536 || len === 0) return;
1218
+ this._updateViews();
1219
+ this._u8.set(bytes, scratchStart);
1220
+ callback(scratchStart, len);
1221
+ }
1222
+ }
1223
+
1224
+ _wsOpen(urlPtr, urlLen) {
1225
+ const allowlist = this._manifest?.net?.websocket;
1226
+ if (!allowlist) return -1;
1227
+ if (!this._WebSocketImpl) return -1;
1228
+
1229
+ this._updateViews();
1230
+ const url = new TextDecoder().decode(this._u8.slice(urlPtr, urlPtr + urlLen));
1231
+
1232
+ // Validate against manifest allowlist
1233
+ let hostname;
1234
+ try {
1235
+ hostname = new URL(url).hostname;
1236
+ } catch {
1237
+ return -1;
1238
+ }
1239
+ if (!allowlist.includes(hostname)) return -1;
1240
+
1241
+ const id = this._wsNextId++;
1242
+ try {
1243
+ const ws = new this._WebSocketImpl(url);
1244
+ if (ws.binaryType !== undefined) ws.binaryType = 'arraybuffer';
1245
+
1246
+ const conn = { ws, eventQueue: [] };
1247
+ ws.onopen = () => conn.eventQueue.push({ type: 'open' });
1248
+ ws.onmessage = (e) => {
1249
+ const data = e.data;
1250
+ if (typeof data === 'string') {
1251
+ conn.eventQueue.push({ type: 'text', data });
1252
+ } else {
1253
+ conn.eventQueue.push({ type: 'binary', data });
1254
+ }
1255
+ };
1256
+ ws.onclose = (e) => conn.eventQueue.push({ type: 'close', code: e.code || 1000 });
1257
+ ws.onerror = () => conn.eventQueue.push({ type: 'error' });
1258
+
1259
+ this._wsConnections.set(id, conn);
1260
+ return id;
1261
+ } catch {
1262
+ return -1;
1263
+ }
1264
+ }
1265
+
1266
+ _wsClose(connId, code) {
1267
+ const conn = this._wsConnections.get(connId);
1268
+ if (!conn) return;
1269
+ try { conn.ws.close(code || 1000); } catch {}
1270
+ }
1271
+
1272
+ _wsSend(connId, dataPtr, len, isText) {
1273
+ const conn = this._wsConnections.get(connId);
1274
+ if (!conn) return -1;
1275
+ try {
1276
+ if (conn.ws.readyState !== 1) return -1; // not OPEN
1277
+ this._updateViews();
1278
+ if (isText) {
1279
+ const str = new TextDecoder().decode(this._u8.slice(dataPtr, dataPtr + len));
1280
+ conn.ws.send(str);
1281
+ } else {
1282
+ const bytes = this._u8.slice(dataPtr, dataPtr + len);
1283
+ conn.ws.send(bytes);
1284
+ }
1285
+ return len;
1286
+ } catch {
1287
+ return -1;
1288
+ }
1289
+ }
1290
+
1291
+ _wsState(connId) {
1292
+ const conn = this._wsConnections.get(connId);
1293
+ if (!conn) return 3; // CLOSED
1294
+ return conn.ws.readyState;
1295
+ }
1296
+
1297
+ _dcPeerInfo(index, destPtr, maxLen) {
1298
+ const peers = [...this._dcPeers.entries()];
1299
+ if (index >= peers.length) return -1;
1300
+ const [peerId, peer] = peers[index];
1301
+ this._updateViews();
1302
+ const labelBytes = new TextEncoder().encode(peer.label + '\0');
1303
+ const copyLen = Math.min(labelBytes.length, maxLen);
1304
+ this._u8.set(labelBytes.subarray(0, copyLen), destPtr);
1305
+ return peerId;
1306
+ }
1307
+
1308
+ _dcSend(peerId, dataPtr, len) {
1309
+ const peer = this._dcPeers.get(peerId);
1310
+ if (!peer || !peer.dc) return -1;
1311
+ try {
1312
+ this._updateViews();
1313
+ const bytes = this._u8.slice(dataPtr, dataPtr + len);
1314
+ peer.dc.send(bytes);
1315
+ return len;
1316
+ } catch {
1317
+ return -1;
1318
+ }
1319
+ }
1320
+
1321
+ _dcBroadcast(dataPtr, len) {
1322
+ this._updateViews();
1323
+ const bytes = this._u8.slice(dataPtr, dataPtr + len);
1324
+ let count = 0;
1325
+ for (const [, peer] of this._dcPeers) {
1326
+ if (!peer.dc) continue;
1327
+ try {
1328
+ peer.dc.send(bytes);
1329
+ count++;
1330
+ } catch {}
1331
+ }
1332
+ return count || -1;
1333
+ }
1334
+
1335
+ /**
1336
+ * Deliver buffered network events as callbacks into WASM.
1337
+ * Called before each wc_render().
1338
+ */
1339
+ _deliverNetEvents() {
1340
+ const exports = this.instance.exports;
1341
+
1342
+ // WebSocket events
1343
+ for (const [id, conn] of this._wsConnections) {
1344
+ while (conn.eventQueue.length > 0) {
1345
+ const evt = conn.eventQueue.shift();
1346
+ if (evt.type === 'open' && exports.wc_ws_on_open) {
1347
+ exports.wc_ws_on_open(id);
1348
+ } else if (evt.type === 'binary' && exports.wc_ws_on_message) {
1349
+ const buf = evt.data instanceof ArrayBuffer ? new Uint8Array(evt.data)
1350
+ : evt.data instanceof Uint8Array ? evt.data
1351
+ : new Uint8Array(evt.data);
1352
+ this._withTempWasmData(buf, (ptr, len) => {
1353
+ exports.wc_ws_on_message(id, ptr, len);
1354
+ });
1355
+ } else if (evt.type === 'text' && exports.wc_ws_on_message_text) {
1356
+ const bytes = new TextEncoder().encode(evt.data);
1357
+ this._withTempWasmData(bytes, (ptr, len) => {
1358
+ exports.wc_ws_on_message_text(id, ptr, len);
1359
+ });
1360
+ } else if (evt.type === 'close' && exports.wc_ws_on_close) {
1361
+ exports.wc_ws_on_close(id, evt.code);
1362
+ } else if (evt.type === 'error' && exports.wc_ws_on_error) {
1363
+ exports.wc_ws_on_error(id);
1364
+ }
1365
+ }
1366
+ }
1367
+
1368
+ // Data channel events
1369
+ for (const [peerId, peer] of this._dcPeers) {
1370
+ while (peer.eventQueue.length > 0) {
1371
+ const evt = peer.eventQueue.shift();
1372
+ if (evt.type === 'connect' && exports.wc_dc_on_connect) {
1373
+ const labelBytes = new TextEncoder().encode(peer.label);
1374
+ this._withTempWasmData(labelBytes, (ptr, len) => {
1375
+ exports.wc_dc_on_connect(peerId, ptr, len);
1376
+ });
1377
+ } else if (evt.type === 'message' && exports.wc_dc_on_message) {
1378
+ const buf = evt.data instanceof ArrayBuffer ? new Uint8Array(evt.data)
1379
+ : evt.data instanceof Uint8Array ? evt.data
1380
+ : new Uint8Array(evt.data);
1381
+ this._withTempWasmData(buf, (ptr, len) => {
1382
+ exports.wc_dc_on_message(peerId, ptr, len);
1383
+ });
1384
+ } else if (evt.type === 'disconnect' && exports.wc_dc_on_disconnect) {
1385
+ exports.wc_dc_on_disconnect(peerId);
1386
+ }
1387
+ }
1388
+ }
1389
+ }
1390
+
1391
+ /**
1392
+ * Add a data channel peer (called by the host application managing signaling).
1393
+ * @param {number} peerId - unique peer ID
1394
+ * @param {string} label - username or identifier
1395
+ * @param {object} dc - data channel object with send(), onmessage, onclose
1396
+ */
1397
+ addDataChannelPeer(peerId, label, dc) {
1398
+ const peer = { dc, label, eventQueue: [{ type: 'connect' }] };
1399
+ dc.onmessage = (e) => {
1400
+ peer.eventQueue.push({ type: 'message', data: e.data });
1401
+ };
1402
+ dc.onclose = () => {
1403
+ peer.eventQueue.push({ type: 'disconnect' });
1404
+ };
1405
+ this._dcPeers.set(peerId, peer);
1406
+ }
1407
+
1408
+ /**
1409
+ * Remove a data channel peer.
1410
+ */
1411
+ removeDataChannelPeer(peerId) {
1412
+ const peer = this._dcPeers.get(peerId);
1413
+ if (peer) {
1414
+ peer.eventQueue.push({ type: 'disconnect' });
1415
+ }
1416
+ }
1417
+
1418
+ // --- Pointer Input (ABI v3) ---
1419
+
1420
+ /**
1421
+ * Update pointer state. Called by the host application.
1422
+ * @param {number} id - pointer index (0=mouse, 1+=touch)
1423
+ * @param {number} x - cart-space X
1424
+ * @param {number} y - cart-space Y
1425
+ * @param {number} buttons - button bitmask
1426
+ * @param {boolean} active - whether pointer exists
1427
+ */
1428
+ setPointer(id, x, y, buttons, active) {
1429
+ if (id < 0 || id >= MAX_POINTERS) return;
1430
+ this._pointerState[id] = { x, y, buttons, active: active ? 1 : 0 };
1431
+ }
1432
+
1433
+ /**
1434
+ * Queue a pointer event. Called by the host application.
1435
+ */
1436
+ pointerDown(id, x, y, button) {
1437
+ if (id < 0 || id >= MAX_POINTERS) return;
1438
+ this._pointerState[id].active = 1;
1439
+ this._pointerState[id].x = x;
1440
+ this._pointerState[id].y = y;
1441
+ this._pointerState[id].buttons |= (1 << button);
1442
+ this._pointerEvents.push({ type: 'down', id, x, y, button });
1443
+ }
1444
+
1445
+ pointerMove(id, x, y) {
1446
+ if (id < 0 || id >= MAX_POINTERS) return;
1447
+ this._pointerState[id].x = x;
1448
+ this._pointerState[id].y = y;
1449
+ this._pointerEvents.push({ type: 'move', id, x, y });
1450
+ }
1451
+
1452
+ pointerUp(id, button) {
1453
+ if (id < 0 || id >= MAX_POINTERS) return;
1454
+ this._pointerState[id].buttons &= ~(1 << button);
1455
+ if (this._pointerState[id].buttons === 0 && id > 0) {
1456
+ // Touch: deactivate when all buttons released
1457
+ this._pointerState[id].active = 0;
1458
+ }
1459
+ this._pointerEvents.push({ type: 'up', id, button });
1460
+ }
1461
+
1462
+ _writePointerState() {
1463
+ if (!this.info || !this.info.pointerPtr || !this.info.wantsPointer) return;
1464
+ if (!this._manifest?.pointer) return;
1465
+ this._updateViews();
1466
+ const base = this.info.pointerPtr;
1467
+ for (let i = 0; i < MAX_POINTERS; i++) {
1468
+ const p = this._pointerState[i];
1469
+ const off = base + i * POINTER_SIZE;
1470
+ this._i16[off >> 1] = p.x;
1471
+ this._i16[(off + 2) >> 1] = p.y;
1472
+ this._u8[off + 4] = p.buttons;
1473
+ this._u8[off + 5] = p.active;
1474
+ this._u8[off + 6] = 0;
1475
+ this._u8[off + 7] = 0;
1476
+ }
1477
+ }
1478
+
1479
+ _deliverPointerEvents() {
1480
+ if (!this.info?.wantsPointer || !this._manifest?.pointer) return;
1481
+ const exports = this.instance.exports;
1482
+ while (this._pointerEvents.length > 0) {
1483
+ const evt = this._pointerEvents.shift();
1484
+ if (evt.type === 'down' && exports.wc_ptr_on_down) {
1485
+ exports.wc_ptr_on_down(evt.id, evt.x, evt.y, evt.button);
1486
+ } else if (evt.type === 'move' && exports.wc_ptr_on_move) {
1487
+ exports.wc_ptr_on_move(evt.id, evt.x, evt.y);
1488
+ } else if (evt.type === 'up' && exports.wc_ptr_on_up) {
1489
+ exports.wc_ptr_on_up(evt.id, evt.button);
1490
+ }
1491
+ }
1492
+ }
1493
+
1494
+ // --- Keyboard Input (ABI v3) ---
1495
+
1496
+ /**
1497
+ * Queue a key down event. Called by the host application.
1498
+ * @param {number} keycode - USB HID scancode
1499
+ * @param {number} modifiers - modifier bitmask
1500
+ */
1501
+ keyDown(keycode, modifiers) {
1502
+ if (keycode < 0 || keycode > 255) return;
1503
+ this._keyState[keycode >> 3] |= (1 << (keycode & 7));
1504
+ this._keyEvents.push({ type: 'down', keycode, modifiers: modifiers || 0 });
1505
+ }
1506
+
1507
+ /**
1508
+ * Queue a key up event. Called by the host application.
1509
+ */
1510
+ keyUp(keycode, modifiers) {
1511
+ if (keycode < 0 || keycode > 255) return;
1512
+ this._keyState[keycode >> 3] &= ~(1 << (keycode & 7));
1513
+ this._keyEvents.push({ type: 'up', keycode, modifiers: modifiers || 0 });
1514
+ }
1515
+
1516
+ _writeKeyState() {
1517
+ if (!this.info || !this.info.keysPtr || !this.info.wantsKeyboard) return;
1518
+ if (!this._manifest?.keyboard) return;
1519
+ this._updateViews();
1520
+ this._u8.set(this._keyState, this.info.keysPtr);
1521
+ }
1522
+
1523
+ _deliverKeyEvents() {
1524
+ if (!this.info?.wantsKeyboard || !this._manifest?.keyboard) return;
1525
+ const exports = this.instance.exports;
1526
+ while (this._keyEvents.length > 0) {
1527
+ const evt = this._keyEvents.shift();
1528
+ if (evt.type === 'down' && exports.wc_kb_on_down) {
1529
+ exports.wc_kb_on_down(evt.keycode, evt.modifiers);
1530
+ } else if (evt.type === 'up' && exports.wc_kb_on_up) {
1531
+ exports.wc_kb_on_up(evt.keycode, evt.modifiers);
1532
+ }
1533
+ }
1534
+ }
1535
+
1536
+ _readInfo(ptr) {
1537
+ const u32 = this._u32;
1538
+ const base = ptr >> 2; // byte offset to u32 index
1539
+
1540
+ const info = {
1541
+ version: u32[base + 0],
1542
+ width: u32[base + 1],
1543
+ height: u32[base + 2],
1544
+ fbPtr: u32[base + 3],
1545
+ audioPtr: u32[base + 4],
1546
+ audioCap: u32[base + 5],
1547
+ audioWritePtr: u32[base + 6], // pointer to cart's write cursor
1548
+ inputPtr: u32[base + 7],
1549
+ savePtr: u32[base + 8],
1550
+ saveSize: u32[base + 9],
1551
+ timePtr: u32[base + 10],
1552
+ hostInfoPtr: 0,
1553
+ };
1554
+
1555
+ // Read host_info_ptr if cart provides it (ABI v2+, field at offset 44)
1556
+ // Validate: must be non-zero, 4-byte aligned, reasonable WASM address
1557
+ if (info.version >= 2) {
1558
+ const hip = u32[base + 11];
1559
+ if (hip > 0 && hip < 0x10000000 && (hip & 3) === 0) {
1560
+ info.hostInfoPtr = hip;
1561
+ }
1562
+ }
1563
+
1564
+ // Read flags (offset 48, u32 index 12) - 0 for old carts (WASM zero-init)
1565
+ info.flags = u32[base + 12] || 0;
1566
+ info.audioIsF32 = !!(info.flags & 1); // WC_FLAG_AUDIO_F32
1567
+
1568
+ // Read audio_sample_rate (offset 52, u32 index 13) - 0 = host decides
1569
+ info.audioSampleRate = u32[base + 13] || 0;
1570
+
1571
+ // Read v3 fields (offset 56, 60)
1572
+ info.pointerPtr = 0;
1573
+ info.keysPtr = 0;
1574
+ if (info.version >= 3) {
1575
+ const pp = u32[base + 14];
1576
+ if (pp > 0 && pp < 0x10000000 && (pp & 1) === 0) {
1577
+ info.pointerPtr = pp;
1578
+ }
1579
+ const kp = u32[base + 15];
1580
+ if (kp > 0 && kp < 0x10000000) {
1581
+ info.keysPtr = kp;
1582
+ }
1583
+ }
1584
+ info.wantsPointer = !!(info.flags & FLAG_POINTER);
1585
+ info.wantsKeyboard = !!(info.flags & FLAG_KEYBOARD);
1586
+
1587
+ // Read gpu_api (offset 64, u32 index 16) - 0=2D, 1=WebGL2, 2=WebGPU, 3=Vulkan
1588
+ info.gpuApi = u32[base + 16] || 0;
1589
+
1590
+ return info;
1591
+ }
1592
+
1593
+ _writeHostInfo(hostInfoPtr, options) {
1594
+ if (!hostInfoPtr) return;
1595
+ const u32 = this._u32;
1596
+ const base = hostInfoPtr >> 2;
1597
+ u32[base + 0] = options.preferredWidth || 0;
1598
+ u32[base + 1] = options.preferredHeight || 0;
1599
+ u32[base + 2] = 0; // reserved (was hostFps - unused, carts use delta_ms)
1600
+ u32[base + 3] = options.audioSampleRate || 48000;
1601
+ u32[base + 4] = options.flags || 0;
1602
+ }
1603
+
1604
+ _writeTime(timeMs, deltaMs, frame) {
1605
+ const ptr = this.info.timePtr;
1606
+ // f64 at byte offset requires 8-byte alignment
1607
+ const f64Base = ptr >> 3;
1608
+ this._f64[f64Base + 0] = timeMs;
1609
+ this._f64[f64Base + 1] = deltaMs;
1610
+ // u32 frame at byte offset ptr + 16
1611
+ this._u32[(ptr + 16) >> 2] = frame;
1612
+ }
1613
+
1614
+ _writePads(pads) {
1615
+ const basePtr = this.info.inputPtr;
1616
+
1617
+ for (let i = 0; i < MAX_PADS; i++) {
1618
+ const pad = pads[i];
1619
+ const offset = basePtr + (i * PAD_SIZE);
1620
+
1621
+ // Capture pad name (if provided by caller)
1622
+ this._padNames[i] = (pad && pad.name) ? pad.name : '';
1623
+
1624
+ if (!pad || !pad.connected) {
1625
+ // Zero the pad
1626
+ this._u8.fill(0, offset, offset + PAD_SIZE);
1627
+ continue;
1628
+ }
1629
+
1630
+ this._u16[offset >> 1] = pad.buttons || 0;
1631
+ this._i16[(offset + 2) >> 1] = pad.leftX || 0;
1632
+ this._i16[(offset + 4) >> 1] = pad.leftY || 0;
1633
+ this._i16[(offset + 6) >> 1] = pad.rightX || 0;
1634
+ this._i16[(offset + 8) >> 1] = pad.rightY || 0;
1635
+ this._u8[offset + 10] = pad.leftTrigger || 0;
1636
+ this._u8[offset + 11] = pad.rightTrigger || 0;
1637
+ this._u8[offset + 12] = 1; // connected
1638
+ this._u8[offset + 13] = 0; // padding
1639
+ }
1640
+ }
1641
+
1642
+ _padName(padId, bufPtr, bufLen) {
1643
+ if (padId >= MAX_PADS || !bufLen) return 0;
1644
+ const name = this._padNames[padId] || '';
1645
+ if (!name.length) return 0;
1646
+ this._updateViews();
1647
+ const encoded = new TextEncoder().encode(name);
1648
+ const len = Math.min(encoded.length, bufLen);
1649
+ this._u8.set(encoded.subarray(0, len), bufPtr);
1650
+ return len;
1651
+ }
1652
+
1653
+ _drainAudio() {
1654
+ if (!this.info.audioPtr || this.info.audioCap === 0) return null;
1655
+
1656
+ // Read cart's write cursor
1657
+ const writeCursor = this._u32[this.info.audioWritePtr >> 2];
1658
+ const readCursor = this.audioReadCursor;
1659
+
1660
+ if (writeCursor === readCursor) return null; // nothing to drain
1661
+
1662
+ const cap = this.info.audioCap;
1663
+ const audioBase = this.info.audioPtr;
1664
+
1665
+ // Calculate how many stereo frames to read
1666
+ let available;
1667
+ if (writeCursor >= readCursor) {
1668
+ available = writeCursor - readCursor;
1669
+ } else {
1670
+ // Wrapped around
1671
+ available = cap - readCursor + writeCursor;
1672
+ }
1673
+
1674
+ if (available === 0) return null;
1675
+
1676
+ const needed = available * 2; // 2 samples per stereo frame
1677
+
1678
+ if (this.info.audioIsF32) {
1679
+ // Float32 audio path
1680
+ if (!this._audioBufF32 || this._audioBufF32.length < needed) {
1681
+ this._audioBufF32 = new Float32Array(needed);
1682
+ }
1683
+ const samples = this._audioBufF32;
1684
+ const ringF32Base = audioBase >> 2; // byte offset to f32 index
1685
+
1686
+ for (let i = 0; i < available; i++) {
1687
+ const ringIdx = ((readCursor + i) % cap) * 2;
1688
+ samples[i * 2] = this._f32[ringF32Base + ringIdx];
1689
+ samples[i * 2 + 1] = this._f32[ringF32Base + ringIdx + 1];
1690
+ }
1691
+
1692
+ this.audioReadCursor = writeCursor;
1693
+ return samples.subarray(0, needed);
1694
+ }
1695
+
1696
+ // Int16 audio path (default)
1697
+ if (!this._audioBuf || this._audioBuf.length < needed) {
1698
+ this._audioBuf = new Int16Array(needed);
1699
+ }
1700
+ const samples = this._audioBuf;
1701
+ const ringI16Base = audioBase >> 1;
1702
+
1703
+ for (let i = 0; i < available; i++) {
1704
+ const ringIdx = ((readCursor + i) % cap) * 2;
1705
+ samples[i * 2] = this._i16[ringI16Base + ringIdx];
1706
+ samples[i * 2 + 1] = this._i16[ringI16Base + ringIdx + 1];
1707
+ }
1708
+
1709
+ this.audioReadCursor = writeCursor;
1710
+
1711
+ return samples.subarray(0, needed);
1712
+ }
1713
+ }