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.
- package/LICENSE +21 -0
- package/README.md +410 -0
- package/SPEC.md +477 -0
- package/bin/wasmcart-pack.js +257 -0
- package/docs/bind_framebuffer.md +275 -0
- package/docs/fetch.md +105 -0
- package/docs/gl-surface.md +111 -0
- package/docs/input.md +102 -0
- package/docs/networking.md +78 -0
- package/docs/porting.md +88 -0
- package/include/wc_cart.h +144 -0
- package/include/wc_fb.h +275 -0
- package/include/wc_gl.h +224 -0
- package/include/wc_gl_blit.h +129 -0
- package/include/wc_mat4.h +210 -0
- package/include/wc_math.h +116 -0
- package/include/wc_pcm_mixer.h +487 -0
- package/include/wc_vec3.h +80 -0
- package/index.js +3 -0
- package/package.json +55 -0
- package/src/CartHost.js +1713 -0
- package/src/CartHostWeb.js +1381 -0
- package/src/abi.js +94 -0
- package/src/cartWorker.js +201 -0
- package/src/cartWorkerWeb.js +170 -0
- package/src/webgl_imports.js +1483 -0
- package/web.js +3 -0
package/src/CartHost.js
ADDED
|
@@ -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
|
+
}
|