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
|
@@ -0,0 +1,1381 @@
|
|
|
1
|
+
// CartHostWeb.js - Browser version of CartHost
|
|
2
|
+
// No Node.js dependencies. Uses fflate for sync inflate.
|
|
3
|
+
// Accepts Uint8Array of .wasc (ZIP) or bare .wasm bytes.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ABI_VERSION,
|
|
7
|
+
MIN_ABI_VERSION,
|
|
8
|
+
INFO_FIELDS,
|
|
9
|
+
HOST_INFO_FIELDS,
|
|
10
|
+
PAD_SIZE,
|
|
11
|
+
MAX_PADS,
|
|
12
|
+
TIME_SIZE,
|
|
13
|
+
FLAG_NET_WS,
|
|
14
|
+
FLAG_NET_DC,
|
|
15
|
+
FLAG_POINTER,
|
|
16
|
+
FLAG_KEYBOARD,
|
|
17
|
+
POINTER_SIZE,
|
|
18
|
+
MAX_POINTERS,
|
|
19
|
+
KEYS_STATE_SIZE,
|
|
20
|
+
} from './abi.js';
|
|
21
|
+
import { createWebGLImports } from './webgl_imports.js';
|
|
22
|
+
import { inflateSync } from 'fflate';
|
|
23
|
+
|
|
24
|
+
// --- Path validation for asset security ---
|
|
25
|
+
|
|
26
|
+
function validateAssetPath(path) {
|
|
27
|
+
if (path.startsWith('/') || path.startsWith('\\')) return false;
|
|
28
|
+
if (/^[a-zA-Z]:/.test(path)) return false;
|
|
29
|
+
if (path.includes('..')) return false;
|
|
30
|
+
if (path.includes('\0')) return false;
|
|
31
|
+
if (path.includes('\\')) return false;
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// --- In-memory ZIP parser ---
|
|
36
|
+
|
|
37
|
+
function parseZipFromBuffer(buf) {
|
|
38
|
+
let eocdOffset = -1;
|
|
39
|
+
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65558); i--) {
|
|
40
|
+
if (buf[i] === 0x50 && buf[i + 1] === 0x4b &&
|
|
41
|
+
buf[i + 2] === 0x05 && buf[i + 3] === 0x06) {
|
|
42
|
+
eocdOffset = i;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (eocdOffset === -1) throw new Error('Not a valid ZIP file');
|
|
47
|
+
|
|
48
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
49
|
+
const entryCount = view.getUint16(eocdOffset + 10, true);
|
|
50
|
+
const cdSize = view.getUint32(eocdOffset + 12, true);
|
|
51
|
+
const cdOffset = view.getUint32(eocdOffset + 16, true);
|
|
52
|
+
|
|
53
|
+
const index = new Map();
|
|
54
|
+
let pos = cdOffset;
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < entryCount; i++) {
|
|
57
|
+
if (view.getUint32(pos, true) !== 0x02014b50) break;
|
|
58
|
+
|
|
59
|
+
const compressionMethod = view.getUint16(pos + 10, true);
|
|
60
|
+
const compressedSize = view.getUint32(pos + 20, true);
|
|
61
|
+
const uncompressedSize = view.getUint32(pos + 24, true);
|
|
62
|
+
const nameLen = view.getUint16(pos + 28, true);
|
|
63
|
+
const extraLen = view.getUint16(pos + 30, true);
|
|
64
|
+
const commentLen = view.getUint16(pos + 32, true);
|
|
65
|
+
const externalAttrs = view.getUint32(pos + 38, true);
|
|
66
|
+
const localHeaderOffset = view.getUint32(pos + 42, true);
|
|
67
|
+
|
|
68
|
+
const decoder = new TextDecoder();
|
|
69
|
+
const fileName = decoder.decode(buf.subarray(pos + 46, pos + 46 + nameLen));
|
|
70
|
+
|
|
71
|
+
const isDir = fileName.endsWith('/');
|
|
72
|
+
const isSymlink = ((externalAttrs >> 16) & 0xF000) === 0xA000;
|
|
73
|
+
|
|
74
|
+
if (!isDir && !isSymlink) {
|
|
75
|
+
index.set(fileName, {
|
|
76
|
+
compressionMethod,
|
|
77
|
+
compressedSize,
|
|
78
|
+
uncompressedSize,
|
|
79
|
+
localHeaderOffset,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
pos += 46 + nameLen + extraLen + commentLen;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return index;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readZipEntryFromBuffer(buf, entry) {
|
|
90
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
91
|
+
const nameLen = view.getUint16(entry.localHeaderOffset + 26, true);
|
|
92
|
+
const extraLen = view.getUint16(entry.localHeaderOffset + 28, true);
|
|
93
|
+
const dataOffset = entry.localHeaderOffset + 30 + nameLen + extraLen;
|
|
94
|
+
|
|
95
|
+
const compressedData = buf.subarray(dataOffset, dataOffset + entry.compressedSize);
|
|
96
|
+
|
|
97
|
+
if (entry.compressionMethod === 0) {
|
|
98
|
+
return compressedData;
|
|
99
|
+
} else if (entry.compressionMethod === 8) {
|
|
100
|
+
return inflateSync(compressedData);
|
|
101
|
+
} else {
|
|
102
|
+
throw new Error(`Unsupported ZIP compression method: ${entry.compressionMethod}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Max single asset size (256MB)
|
|
107
|
+
const MAX_ASSET_SIZE = 256 * 1024 * 1024;
|
|
108
|
+
// Max entries in a .wasc archive
|
|
109
|
+
const MAX_ARCHIVE_ENTRIES = 100000;
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
export class CartHostWeb {
|
|
113
|
+
constructor() {
|
|
114
|
+
this.instance = null;
|
|
115
|
+
this.memory = null;
|
|
116
|
+
this.info = null;
|
|
117
|
+
this.frameCount = 0;
|
|
118
|
+
this.startTime = 0;
|
|
119
|
+
this.lastFrameTime = 0;
|
|
120
|
+
this.audioReadCursor = 0;
|
|
121
|
+
|
|
122
|
+
// Views into cart memory
|
|
123
|
+
this._u8 = null;
|
|
124
|
+
this._u16 = null;
|
|
125
|
+
this._i16 = null;
|
|
126
|
+
this._u32 = null;
|
|
127
|
+
this._f32 = null;
|
|
128
|
+
this._f64 = null;
|
|
129
|
+
this._lastBuffer = null;
|
|
130
|
+
this._lastByteLength = 0;
|
|
131
|
+
|
|
132
|
+
// Thread support (WASI threads)
|
|
133
|
+
this.isThreaded = false;
|
|
134
|
+
this._sharedMemory = null;
|
|
135
|
+
this._compiledModule = null;
|
|
136
|
+
this._workers = new Map();
|
|
137
|
+
this._nextTid = 1;
|
|
138
|
+
|
|
139
|
+
// GL state
|
|
140
|
+
this.usesGL = false;
|
|
141
|
+
|
|
142
|
+
// Asset index for .wasc carts
|
|
143
|
+
this._assetIndex = null;
|
|
144
|
+
this._assetBuf = null;
|
|
145
|
+
this._hasAssets = false;
|
|
146
|
+
|
|
147
|
+
// Networking (ABI v3)
|
|
148
|
+
this._manifest = null;
|
|
149
|
+
this._wsConnections = new Map();
|
|
150
|
+
this._wsNextId = 0;
|
|
151
|
+
this._dcPeers = new Map();
|
|
152
|
+
|
|
153
|
+
// Pointer input (ABI v3)
|
|
154
|
+
this._pointerState = [];
|
|
155
|
+
for (let i = 0; i < MAX_POINTERS; i++) {
|
|
156
|
+
this._pointerState.push({ x: 0, y: 0, buttons: 0, active: 0 });
|
|
157
|
+
}
|
|
158
|
+
this._pointerEvents = [];
|
|
159
|
+
|
|
160
|
+
// Keyboard input (ABI v3)
|
|
161
|
+
this._keyState = new Uint8Array(KEYS_STATE_SIZE);
|
|
162
|
+
this._keyEvents = [];
|
|
163
|
+
|
|
164
|
+
// Pad names (populated each frame from pad objects)
|
|
165
|
+
this._padNames = ['', '', '', ''];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Load and instantiate a cart.
|
|
170
|
+
* @param {Uint8Array} source - .wasc (ZIP) bytes or bare .wasm bytes
|
|
171
|
+
* @param {object} [options]
|
|
172
|
+
* @param {Uint8Array} [options.saveData] - existing save data to load
|
|
173
|
+
* @param {WebGL2RenderingContext} [options.glBackend] - required if cart uses GL
|
|
174
|
+
* @param {number} [options.preferredWidth] - hint for cart resolution
|
|
175
|
+
* @param {number} [options.preferredHeight] - hint for cart resolution
|
|
176
|
+
* @param {number} [options.audioSampleRate] - host audio sample rate (default 48000)
|
|
177
|
+
*/
|
|
178
|
+
// Draw a progress bar on the GL canvas during loading.
|
|
179
|
+
// Uses simple scissor+clear - no shaders or buffers needed.
|
|
180
|
+
_drawProgress(ctx, progress, label) {
|
|
181
|
+
if (!ctx || !ctx.canvas) return;
|
|
182
|
+
const w = ctx.canvas.width || 320;
|
|
183
|
+
const h = ctx.canvas.height || 240;
|
|
184
|
+
|
|
185
|
+
ctx.viewport(0, 0, w, h);
|
|
186
|
+
ctx.disable(ctx.SCISSOR_TEST);
|
|
187
|
+
ctx.clearColor(0.07, 0.07, 0.07, 1.0);
|
|
188
|
+
ctx.clear(ctx.COLOR_BUFFER_BIT);
|
|
189
|
+
|
|
190
|
+
// Bar dimensions: 60% width, 6px tall, centered
|
|
191
|
+
const barW = Math.floor(w * 0.6);
|
|
192
|
+
const barH = Math.max(4, Math.floor(h * 0.02));
|
|
193
|
+
const barX = Math.floor((w - barW) / 2);
|
|
194
|
+
const barY = Math.floor(h / 2 - barH / 2);
|
|
195
|
+
|
|
196
|
+
// Background track
|
|
197
|
+
ctx.enable(ctx.SCISSOR_TEST);
|
|
198
|
+
ctx.scissor(barX, barY, barW, barH);
|
|
199
|
+
ctx.clearColor(0.2, 0.2, 0.2, 1.0);
|
|
200
|
+
ctx.clear(ctx.COLOR_BUFFER_BIT);
|
|
201
|
+
|
|
202
|
+
// Filled portion
|
|
203
|
+
const fillW = Math.max(1, Math.floor(barW * Math.min(progress, 1)));
|
|
204
|
+
ctx.scissor(barX, barY, fillW, barH);
|
|
205
|
+
ctx.clearColor(0.3, 0.7, 1.0, 1.0);
|
|
206
|
+
ctx.clear(ctx.COLOR_BUFFER_BIT);
|
|
207
|
+
|
|
208
|
+
ctx.disable(ctx.SCISSOR_TEST);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async load(source, options = {}) {
|
|
212
|
+
const glCtx = options.glBackend || null;
|
|
213
|
+
this._drawProgress(glCtx, 0);
|
|
214
|
+
|
|
215
|
+
const u8 = source instanceof Uint8Array ? source : new Uint8Array(source);
|
|
216
|
+
let wasmBytes;
|
|
217
|
+
|
|
218
|
+
// Detect ZIP vs bare WASM
|
|
219
|
+
if (u8.length >= 4 && u8[0] === 0x50 && u8[1] === 0x4b &&
|
|
220
|
+
u8[2] === 0x03 && u8[3] === 0x04) {
|
|
221
|
+
wasmBytes = this._loadFromWascBuffer(u8);
|
|
222
|
+
} else if (u8.length >= 4 && u8[0] === 0x00 && u8[1] === 0x61 &&
|
|
223
|
+
u8[2] === 0x73 && u8[3] === 0x6d) {
|
|
224
|
+
// Bare .wasm (magic: \0asm)
|
|
225
|
+
wasmBytes = u8;
|
|
226
|
+
} else {
|
|
227
|
+
throw new Error('Invalid cart data: expected .wasc (ZIP) or .wasm bytes');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Compile and validate
|
|
231
|
+
this._drawProgress(glCtx, 0.1);
|
|
232
|
+
const module = await WebAssembly.compile(wasmBytes);
|
|
233
|
+
this._validateModule(module);
|
|
234
|
+
|
|
235
|
+
// Detect thread usage
|
|
236
|
+
const threadAnalysis = this._analyzeModule(module);
|
|
237
|
+
this.isThreaded = threadAnalysis.isThreaded;
|
|
238
|
+
|
|
239
|
+
// For threaded carts: create shared memory and store module for worker reuse
|
|
240
|
+
if (this.isThreaded) {
|
|
241
|
+
const memLimits = CartHostWeb._parseMemoryImportLimits(wasmBytes);
|
|
242
|
+
if (!memLimits || !memLimits.shared) {
|
|
243
|
+
throw new Error('Threaded cart must have shared memory import (compile with --shared-memory)');
|
|
244
|
+
}
|
|
245
|
+
this._sharedMemory = new WebAssembly.Memory({
|
|
246
|
+
initial: memLimits.initial,
|
|
247
|
+
maximum: memLimits.maximum,
|
|
248
|
+
shared: true,
|
|
249
|
+
});
|
|
250
|
+
this._compiledModule = module;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Detect GL usage
|
|
254
|
+
const moduleImports = WebAssembly.Module.imports(module);
|
|
255
|
+
this.usesGL = moduleImports.some(imp =>
|
|
256
|
+
imp.module === 'gl' ||
|
|
257
|
+
(imp.module === 'env' && imp.kind === 'function' && /^gl[A-Z]/.test(imp.name))
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
if (this.usesGL && !options.glBackend) {
|
|
261
|
+
// Cart imports GL but no GL backend provided - stub GL imports.
|
|
262
|
+
// Cart can still use 2D framebuffer or GL blit internally.
|
|
263
|
+
this.usesGL = false;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Build imports
|
|
267
|
+
const imports = {
|
|
268
|
+
env: {
|
|
269
|
+
wc_log: (ptr, len) => {
|
|
270
|
+
this._updateViews();
|
|
271
|
+
if (this._u8) {
|
|
272
|
+
const bytes = this._u8.slice(ptr, ptr + len);
|
|
273
|
+
const text = new TextDecoder().decode(bytes);
|
|
274
|
+
console.warn('[cart]', text);
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
wc_asset_size: (pathPtr, pathLen) => {
|
|
278
|
+
return this._assetSize(pathPtr, pathLen);
|
|
279
|
+
},
|
|
280
|
+
wc_load_asset: (pathPtr, pathLen, destPtr, maxSize) => {
|
|
281
|
+
return this._loadAsset(pathPtr, pathLen, destPtr, maxSize);
|
|
282
|
+
},
|
|
283
|
+
// Pad name query
|
|
284
|
+
wc_pad_name: (padId, bufPtr, bufLen) => {
|
|
285
|
+
return this._padName(padId, bufPtr, bufLen);
|
|
286
|
+
},
|
|
287
|
+
// --- WebSocket API (ABI v3) ---
|
|
288
|
+
wc_ws_open: (urlPtr, urlLen) => {
|
|
289
|
+
return this._wsOpen(urlPtr, urlLen);
|
|
290
|
+
},
|
|
291
|
+
wc_ws_close: (connId, code) => {
|
|
292
|
+
this._wsClose(connId, code);
|
|
293
|
+
},
|
|
294
|
+
wc_ws_send: (connId, dataPtr, len) => {
|
|
295
|
+
return this._wsSend(connId, dataPtr, len, false);
|
|
296
|
+
},
|
|
297
|
+
wc_ws_send_text: (connId, strPtr, len) => {
|
|
298
|
+
return this._wsSend(connId, strPtr, len, true);
|
|
299
|
+
},
|
|
300
|
+
wc_ws_state: (connId) => {
|
|
301
|
+
return this._wsState(connId);
|
|
302
|
+
},
|
|
303
|
+
// --- Data Channel API (ABI v3) ---
|
|
304
|
+
wc_dc_peer_count: () => {
|
|
305
|
+
return this._dcPeers.size;
|
|
306
|
+
},
|
|
307
|
+
wc_dc_peer_info: (index, destPtr, maxLen) => {
|
|
308
|
+
return this._dcPeerInfo(index, destPtr, maxLen);
|
|
309
|
+
},
|
|
310
|
+
wc_dc_send: (peerId, dataPtr, len) => {
|
|
311
|
+
return this._dcSend(peerId, dataPtr, len);
|
|
312
|
+
},
|
|
313
|
+
wc_dc_broadcast: (dataPtr, len) => {
|
|
314
|
+
return this._dcBroadcast(dataPtr, len);
|
|
315
|
+
},
|
|
316
|
+
memfs_register_file: (namePtr, dataPtr, size) => {
|
|
317
|
+
try {
|
|
318
|
+
const name = new TextDecoder().decode(
|
|
319
|
+
new Uint8Array(this.memory.buffer, namePtr,
|
|
320
|
+
new Uint8Array(this.memory.buffer).indexOf(0, namePtr) - namePtr));
|
|
321
|
+
if (!this._memfsFiles) this._memfsFiles = new Map();
|
|
322
|
+
this._memfsFiles.set(name, { ptr: dataPtr, size });
|
|
323
|
+
return 0;
|
|
324
|
+
} catch(e) { return -1; }
|
|
325
|
+
},
|
|
326
|
+
emscripten_notify_memory_growth: () => { this._updateViews(); },
|
|
327
|
+
emscripten_asm_const_int: () => 0,
|
|
328
|
+
emscripten_asm_const_double: () => 0.0,
|
|
329
|
+
emscripten_get_element_css_size: (targetPtr, widthPtr, heightPtr) => {
|
|
330
|
+
try {
|
|
331
|
+
const view = new DataView(this.memory.buffer);
|
|
332
|
+
view.setFloat64(widthPtr, this.info ? this.info.width : 800, true);
|
|
333
|
+
view.setFloat64(heightPtr, this.info ? this.info.height : 600, true);
|
|
334
|
+
} catch(e) {}
|
|
335
|
+
return 0;
|
|
336
|
+
},
|
|
337
|
+
__syscall_getcwd: () => -1,
|
|
338
|
+
__syscall_getdents64: () => -1,
|
|
339
|
+
},
|
|
340
|
+
wasi_snapshot_preview1: {
|
|
341
|
+
fd_close: () => 0,
|
|
342
|
+
fd_write: (fd, iovs, iovs_len, nwritten_ptr) => {
|
|
343
|
+
try {
|
|
344
|
+
this._updateViews();
|
|
345
|
+
const view = new DataView(this.memory.buffer);
|
|
346
|
+
let totalWritten = 0;
|
|
347
|
+
let text = '';
|
|
348
|
+
for (let i = 0; i < iovs_len; i++) {
|
|
349
|
+
const ptr = view.getUint32(iovs + i * 8, true);
|
|
350
|
+
const len = view.getUint32(iovs + i * 8 + 4, true);
|
|
351
|
+
if (this._u8 && len > 0) {
|
|
352
|
+
text += new TextDecoder().decode(this._u8.slice(ptr, ptr + len));
|
|
353
|
+
}
|
|
354
|
+
totalWritten += len;
|
|
355
|
+
}
|
|
356
|
+
if (text && (fd === 1 || fd === 2)) {
|
|
357
|
+
console.warn('[cart]', text);
|
|
358
|
+
}
|
|
359
|
+
if (nwritten_ptr) view.setUint32(nwritten_ptr, totalWritten, true);
|
|
360
|
+
return 0;
|
|
361
|
+
} catch(e) { return 0; }
|
|
362
|
+
},
|
|
363
|
+
fd_seek: () => 0,
|
|
364
|
+
fd_read: () => 0,
|
|
365
|
+
environ_get: () => 0,
|
|
366
|
+
environ_sizes_get: () => 0,
|
|
367
|
+
proc_exit: () => {},
|
|
368
|
+
clock_time_get: (id, precision, resultPtr) => {
|
|
369
|
+
try {
|
|
370
|
+
const ns = BigInt(Math.round(performance.now() * 1e6));
|
|
371
|
+
const view = new DataView(this.memory.buffer);
|
|
372
|
+
view.setBigUint64(resultPtr, ns, true);
|
|
373
|
+
} catch (e) {}
|
|
374
|
+
return 0;
|
|
375
|
+
},
|
|
376
|
+
sched_yield: () => 0,
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// Auto-stub missing WASI imports
|
|
381
|
+
for (const imp of moduleImports) {
|
|
382
|
+
if (imp.module === 'wasi_snapshot_preview1' && imp.kind === 'function') {
|
|
383
|
+
if (!(imp.name in imports.wasi_snapshot_preview1)) {
|
|
384
|
+
imports.wasi_snapshot_preview1[imp.name] = () => 0;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Auto-stub missing env functions
|
|
390
|
+
for (const imp of moduleImports) {
|
|
391
|
+
if (imp.module === 'env' && imp.kind === 'function') {
|
|
392
|
+
if (!(imp.name in imports.env)) {
|
|
393
|
+
imports.env[imp.name] = () => -1;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Stub GL imports for carts that import GL but no backend was provided
|
|
399
|
+
if (!this.usesGL) {
|
|
400
|
+
const glStubs = {};
|
|
401
|
+
for (const imp of moduleImports) {
|
|
402
|
+
if (imp.module === 'gl' && imp.kind === 'function') {
|
|
403
|
+
glStubs[imp.name] = () => 0;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (Object.keys(glStubs).length > 0) {
|
|
407
|
+
imports.gl = glStubs;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Wire GL imports
|
|
412
|
+
if (this.usesGL) {
|
|
413
|
+
const glFuncs = createWebGLImports({
|
|
414
|
+
getMemory: () => this.memory,
|
|
415
|
+
ctx: options.glBackend,
|
|
416
|
+
getMalloc: () => this.instance?.exports?.malloc || null,
|
|
417
|
+
});
|
|
418
|
+
this._glFuncs = glFuncs;
|
|
419
|
+
imports.gl = glFuncs;
|
|
420
|
+
// Auto-stub any GL imports not covered by webgl_imports.js
|
|
421
|
+
for (const imp of moduleImports) {
|
|
422
|
+
if (imp.module === 'gl' && imp.kind === 'function' && !(imp.name in glFuncs)) {
|
|
423
|
+
glFuncs[imp.name] = () => 0;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
for (const imp of moduleImports) {
|
|
427
|
+
if (imp.module !== 'env' || imp.kind !== 'function') continue;
|
|
428
|
+
if (imp.name.startsWith('gl') && imp.name in glFuncs) {
|
|
429
|
+
imports.env[imp.name] = glFuncs[imp.name];
|
|
430
|
+
} else if (imp.name.startsWith('emscripten_gl')) {
|
|
431
|
+
const glName = imp.name.replace('emscripten_', '');
|
|
432
|
+
const baseName = glName.replace(/(OES|EXT|ANGLE|WEBGL)$/, '');
|
|
433
|
+
if (glName in glFuncs) {
|
|
434
|
+
imports.env[imp.name] = glFuncs[glName];
|
|
435
|
+
} else if (baseName in glFuncs) {
|
|
436
|
+
imports.env[imp.name] = glFuncs[baseName];
|
|
437
|
+
} else {
|
|
438
|
+
imports.env[imp.name] = () => 0;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// For threaded carts: provide shared memory as import and thread-spawn
|
|
445
|
+
if (this.isThreaded) {
|
|
446
|
+
imports.env.memory = this._sharedMemory;
|
|
447
|
+
imports.wasi = imports.wasi || {};
|
|
448
|
+
imports.wasi['thread-spawn'] = (startArg) => this._spawnThread(startArg);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Instantiate
|
|
452
|
+
this._drawProgress(glCtx, 0.6);
|
|
453
|
+
this.instance = await WebAssembly.instantiate(module, imports);
|
|
454
|
+
const exports = this.instance.exports;
|
|
455
|
+
|
|
456
|
+
// Memory access - threaded carts use the shared memory we created,
|
|
457
|
+
// non-threaded carts use the module's exported memory
|
|
458
|
+
if (this.isThreaded) {
|
|
459
|
+
this.memory = exports.memory || this._sharedMemory;
|
|
460
|
+
} else {
|
|
461
|
+
this.memory = exports.memory;
|
|
462
|
+
if (!this.memory) {
|
|
463
|
+
throw new Error('Cart must export memory');
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
this._updateViews();
|
|
468
|
+
|
|
469
|
+
// Read info
|
|
470
|
+
this._infoPtr = exports.wc_get_info();
|
|
471
|
+
this.info = this._readInfo(this._infoPtr);
|
|
472
|
+
|
|
473
|
+
if (this.info.version < MIN_ABI_VERSION || this.info.version > ABI_VERSION) {
|
|
474
|
+
throw new Error(`ABI version mismatch: cart=${this.info.version}, host supports ${MIN_ABI_VERSION}-${ABI_VERSION}`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Load save data before init
|
|
478
|
+
if (options.saveData && this.info.saveSize > 0) {
|
|
479
|
+
const saveRegion = this._u8.subarray(this.info.savePtr, this.info.savePtr + this.info.saveSize);
|
|
480
|
+
const copyLen = Math.min(options.saveData.length, this.info.saveSize);
|
|
481
|
+
saveRegion.set(options.saveData.subarray(0, copyLen));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Write host info before init
|
|
485
|
+
if (this.info.hostInfoPtr) {
|
|
486
|
+
this._writeHostInfo(this.info.hostInfoPtr, options);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// WASI reactor init
|
|
490
|
+
if (typeof exports._initialize === 'function') {
|
|
491
|
+
exports._initialize();
|
|
492
|
+
this._updateViews();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Cart init
|
|
496
|
+
this._drawProgress(glCtx, 0.95);
|
|
497
|
+
if (typeof exports.wc_init === 'function') {
|
|
498
|
+
exports.wc_init();
|
|
499
|
+
this._updateViews();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Re-read info (cart may have changed resolution)
|
|
503
|
+
this.info = this._readInfo(this._infoPtr);
|
|
504
|
+
|
|
505
|
+
// Set up FBO redirect for GL carts (same as wasmcart-native)
|
|
506
|
+
if (this.usesGL && this._glFuncs?._setupRedirectFBO) {
|
|
507
|
+
this._glFuncs._setupRedirectFBO(this.info.width, this.info.height);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Free the wasm bytes from the ZIP buffer (keep assets, drop the wasm entry)
|
|
511
|
+
// The compiled module holds the code now.
|
|
512
|
+
|
|
513
|
+
// Initialize timing
|
|
514
|
+
this.startTime = performance.now();
|
|
515
|
+
this.lastFrameTime = this.startTime;
|
|
516
|
+
this.frameCount = 0;
|
|
517
|
+
this.audioReadCursor = 0;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Run one frame.
|
|
522
|
+
* @param {Array} [pads] - array of up to 4 pad objects
|
|
523
|
+
* @returns {{ framebuffer: Uint8Array|null, width: number, height: number, audio: Int16Array|Float32Array|null }}
|
|
524
|
+
*/
|
|
525
|
+
runFrame(pads) {
|
|
526
|
+
const now = performance.now();
|
|
527
|
+
const deltaMs = now - this.lastFrameTime;
|
|
528
|
+
const timeMs = now - this.startTime;
|
|
529
|
+
this.lastFrameTime = now;
|
|
530
|
+
|
|
531
|
+
this._updateViews();
|
|
532
|
+
|
|
533
|
+
this._writeTime(timeMs, deltaMs, this.frameCount);
|
|
534
|
+
this._writePads(pads || []);
|
|
535
|
+
|
|
536
|
+
// Write pointer/keyboard state and deliver events before render
|
|
537
|
+
this._writePointerState();
|
|
538
|
+
this._writeKeyState();
|
|
539
|
+
this._deliverNetEvents();
|
|
540
|
+
this._deliverPointerEvents();
|
|
541
|
+
this._deliverKeyEvents();
|
|
542
|
+
|
|
543
|
+
this.instance.exports.wc_render();
|
|
544
|
+
this._updateViews();
|
|
545
|
+
|
|
546
|
+
// Blit redirect FBO → canvas (GL carts render to redirect, not canvas directly)
|
|
547
|
+
if (this._glFuncs?._blitToCanvas) {
|
|
548
|
+
this._glFuncs._blitToCanvas();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Re-read width/height from WASM memory (cart may update during deferred init)
|
|
552
|
+
const base = this._infoPtr >> 2;
|
|
553
|
+
const newW = this._u32[base + 1];
|
|
554
|
+
const newH = this._u32[base + 2];
|
|
555
|
+
if (newW > 0 && newH > 0 && (newW !== this.info.width || newH !== this.info.height)) {
|
|
556
|
+
this.info.width = newW;
|
|
557
|
+
this.info.height = newH;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
this.frameCount++;
|
|
561
|
+
|
|
562
|
+
// Read framebuffer (null for GL carts - they render to canvas directly)
|
|
563
|
+
let framebuffer = null;
|
|
564
|
+
if (this.info.fbPtr && !this.usesGL) {
|
|
565
|
+
const fbSize = this.info.width * this.info.height * 4;
|
|
566
|
+
framebuffer = this._u8.subarray(this.info.fbPtr, this.info.fbPtr + fbSize);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const audio = this._drainAudio();
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
framebuffer,
|
|
573
|
+
width: this.info.width,
|
|
574
|
+
height: this.info.height,
|
|
575
|
+
audio,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Get the current save data.
|
|
581
|
+
*/
|
|
582
|
+
getSaveData() {
|
|
583
|
+
if (!this.info || this.info.saveSize === 0) return null;
|
|
584
|
+
return new Uint8Array(
|
|
585
|
+
this._u8.slice(this.info.savePtr, this.info.savePtr + this.info.saveSize)
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
getInfo() {
|
|
590
|
+
return this.info ? { ...this.info } : null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
destroy() {
|
|
594
|
+
// Close all WebSocket connections
|
|
595
|
+
for (const [, conn] of this._wsConnections) {
|
|
596
|
+
try { conn.ws.close(); } catch {}
|
|
597
|
+
}
|
|
598
|
+
this._wsConnections.clear();
|
|
599
|
+
this._dcPeers.clear();
|
|
600
|
+
|
|
601
|
+
// Terminate all worker threads
|
|
602
|
+
for (const [tid, worker] of this._workers) {
|
|
603
|
+
worker.terminate();
|
|
604
|
+
}
|
|
605
|
+
this._workers.clear();
|
|
606
|
+
|
|
607
|
+
this._assetIndex = null;
|
|
608
|
+
this._assetBuf = null;
|
|
609
|
+
this._sharedMemory = null;
|
|
610
|
+
this._compiledModule = null;
|
|
611
|
+
this.instance = null;
|
|
612
|
+
this.memory = null;
|
|
613
|
+
this._u8 = null;
|
|
614
|
+
this._u16 = null;
|
|
615
|
+
this._i16 = null;
|
|
616
|
+
this._u32 = null;
|
|
617
|
+
this._f32 = null;
|
|
618
|
+
this._f64 = null;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// --- .wasc loading ---
|
|
622
|
+
|
|
623
|
+
_loadFromWascBuffer(buf) {
|
|
624
|
+
const index = parseZipFromBuffer(buf);
|
|
625
|
+
|
|
626
|
+
if (index.size > MAX_ARCHIVE_ENTRIES) {
|
|
627
|
+
throw new Error(`Archive has too many entries (${index.size} > ${MAX_ARCHIVE_ENTRIES})`);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Read manifest
|
|
631
|
+
const manifestEntry = index.get('manifest.json');
|
|
632
|
+
if (!manifestEntry) throw new Error('.wasc archive missing manifest.json');
|
|
633
|
+
const manifestBuf = readZipEntryFromBuffer(buf, manifestEntry);
|
|
634
|
+
const manifest = JSON.parse(new TextDecoder().decode(manifestBuf));
|
|
635
|
+
this._manifest = manifest;
|
|
636
|
+
|
|
637
|
+
// Read wasm
|
|
638
|
+
const wasmName = manifest.entry || 'cart.wasm';
|
|
639
|
+
const wasmEntry = index.get(wasmName);
|
|
640
|
+
if (!wasmEntry) throw new Error(`.wasc archive missing ${wasmName}`);
|
|
641
|
+
const wasmBytes = readZipEntryFromBuffer(buf, wasmEntry);
|
|
642
|
+
|
|
643
|
+
// Build asset index
|
|
644
|
+
const assetsPrefix = manifest.assets || 'assets/';
|
|
645
|
+
this._assetIndex = new Map();
|
|
646
|
+
for (const [path, entry] of index) {
|
|
647
|
+
if (path === 'manifest.json' || path === wasmName) continue;
|
|
648
|
+
if (entry.uncompressedSize > MAX_ASSET_SIZE) continue;
|
|
649
|
+
|
|
650
|
+
let assetPath = path;
|
|
651
|
+
if (assetsPrefix && path.startsWith(assetsPrefix)) {
|
|
652
|
+
assetPath = path.slice(assetsPrefix.length);
|
|
653
|
+
}
|
|
654
|
+
this._assetIndex.set(assetPath, entry);
|
|
655
|
+
if (assetPath !== path) {
|
|
656
|
+
this._assetIndex.set(path, entry);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Virtual _filelist.txt
|
|
661
|
+
const fileList = [...this._assetIndex.keys()].filter(p => !p.startsWith('assets/')).join('\n');
|
|
662
|
+
this._fileListBuf = new TextEncoder().encode(fileList);
|
|
663
|
+
|
|
664
|
+
this._assetBuf = buf;
|
|
665
|
+
this._hasAssets = this._assetIndex.size > 0;
|
|
666
|
+
|
|
667
|
+
return wasmBytes;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// --- Asset API ---
|
|
671
|
+
|
|
672
|
+
_readPath(pathPtr, pathLen) {
|
|
673
|
+
this._updateViews();
|
|
674
|
+
if (!this._u8 || pathLen === 0 || pathLen > 4096) return null;
|
|
675
|
+
const bytes = this._u8.slice(pathPtr, pathPtr + pathLen);
|
|
676
|
+
const path = new TextDecoder().decode(bytes);
|
|
677
|
+
if (!validateAssetPath(path)) return null;
|
|
678
|
+
return path;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
_assetSize(pathPtr, pathLen) {
|
|
682
|
+
if (!this._hasAssets) return -1;
|
|
683
|
+
const path = this._readPath(pathPtr, pathLen);
|
|
684
|
+
if (!path) return -1;
|
|
685
|
+
|
|
686
|
+
if (path === '_filelist.txt' && this._fileListBuf) {
|
|
687
|
+
return this._fileListBuf.length;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const entry = this._assetIndex.get(path);
|
|
691
|
+
if (!entry) return -1;
|
|
692
|
+
return entry.uncompressedSize;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
_loadAsset(pathPtr, pathLen, destPtr, maxSize) {
|
|
696
|
+
if (!this._hasAssets) return -1;
|
|
697
|
+
const path = this._readPath(pathPtr, pathLen);
|
|
698
|
+
if (!path) return -1;
|
|
699
|
+
|
|
700
|
+
let data;
|
|
701
|
+
|
|
702
|
+
if (path === '_filelist.txt' && this._fileListBuf) {
|
|
703
|
+
data = this._fileListBuf;
|
|
704
|
+
} else {
|
|
705
|
+
const entry = this._assetIndex.get(path);
|
|
706
|
+
if (!entry) return -1;
|
|
707
|
+
try {
|
|
708
|
+
data = readZipEntryFromBuffer(this._assetBuf, entry);
|
|
709
|
+
} catch {
|
|
710
|
+
return -1;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const copyLen = Math.min(data.length, maxSize);
|
|
715
|
+
this._updateViews();
|
|
716
|
+
this._u8.set(data.subarray ? data.subarray(0, copyLen) : new Uint8Array(data, 0, copyLen), destPtr);
|
|
717
|
+
|
|
718
|
+
return copyLen;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// --- Threading ---
|
|
722
|
+
|
|
723
|
+
_spawnThread(startArg) {
|
|
724
|
+
if (!this.isThreaded || !this._compiledModule || !this._sharedMemory) return -1;
|
|
725
|
+
|
|
726
|
+
const tid = this._nextTid++;
|
|
727
|
+
|
|
728
|
+
// Serialize asset config for the worker
|
|
729
|
+
const assetConfig = {};
|
|
730
|
+
if (this._assetBuf) {
|
|
731
|
+
// SharedArrayBuffer is needed to pass to worker.
|
|
732
|
+
// If _assetBuf is on a regular ArrayBuffer, copy to SharedArrayBuffer.
|
|
733
|
+
let sharedBuf = this._assetBuf.buffer;
|
|
734
|
+
if (!(sharedBuf instanceof SharedArrayBuffer)) {
|
|
735
|
+
// Can't share regular ArrayBuffer with worker via structured clone
|
|
736
|
+
// in all browsers. Pass as transferable copy.
|
|
737
|
+
assetConfig.type = 'buffer';
|
|
738
|
+
assetConfig.buffer = this._assetBuf.buffer;
|
|
739
|
+
assetConfig.index = this._assetIndex ? [...this._assetIndex.entries()] : [];
|
|
740
|
+
} else {
|
|
741
|
+
assetConfig.type = 'buffer';
|
|
742
|
+
assetConfig.buffer = sharedBuf;
|
|
743
|
+
assetConfig.index = this._assetIndex ? [...this._assetIndex.entries()] : [];
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const workerURL = new URL('./cartWorkerWeb.js', import.meta.url);
|
|
748
|
+
const worker = new Worker(workerURL, { type: 'module' });
|
|
749
|
+
|
|
750
|
+
worker.onmessage = (e) => {
|
|
751
|
+
const msg = e.data;
|
|
752
|
+
if (msg.type === 'spawn') {
|
|
753
|
+
const nestedTid = this._spawnThread(msg.startArg);
|
|
754
|
+
worker.postMessage({ type: 'spawned', tid: nestedTid, requestId: msg.requestId });
|
|
755
|
+
} else if (msg.type === 'exit') {
|
|
756
|
+
this._workers.delete(msg.tid);
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
worker.onerror = (err) => {
|
|
761
|
+
console.error(`[thread ${tid}] error:`, err.message);
|
|
762
|
+
this._workers.delete(tid);
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
worker.postMessage({
|
|
766
|
+
module: this._compiledModule,
|
|
767
|
+
memory: this._sharedMemory,
|
|
768
|
+
tid,
|
|
769
|
+
startArg,
|
|
770
|
+
assetConfig,
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
this._workers.set(tid, worker);
|
|
774
|
+
return tid;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
_analyzeModule(module) {
|
|
778
|
+
const imports = WebAssembly.Module.imports(module);
|
|
779
|
+
const exports = WebAssembly.Module.exports(module);
|
|
780
|
+
|
|
781
|
+
const hasThreadSpawn = imports.some(
|
|
782
|
+
i => i.module === 'wasi' && i.name === 'thread-spawn' && i.kind === 'function'
|
|
783
|
+
);
|
|
784
|
+
const hasThreadStart = exports.some(
|
|
785
|
+
e => e.name === 'wasi_thread_start' && e.kind === 'function'
|
|
786
|
+
);
|
|
787
|
+
const importsMemory = imports.some(i => i.kind === 'memory');
|
|
788
|
+
|
|
789
|
+
if (hasThreadSpawn && !hasThreadStart) {
|
|
790
|
+
throw new Error(
|
|
791
|
+
'Cart imports wasi.thread-spawn but does not export wasi_thread_start. ' +
|
|
792
|
+
'Both are required for WASI threads.'
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
if (hasThreadStart && !hasThreadSpawn) {
|
|
796
|
+
throw new Error(
|
|
797
|
+
'Cart exports wasi_thread_start but does not import wasi.thread-spawn. ' +
|
|
798
|
+
'Both are required for WASI threads.'
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return {
|
|
803
|
+
isThreaded: hasThreadSpawn && hasThreadStart,
|
|
804
|
+
importsMemory,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
static _parseMemoryImportLimits(wasmBytes) {
|
|
809
|
+
const buf = wasmBytes instanceof Uint8Array ? wasmBytes : new Uint8Array(wasmBytes);
|
|
810
|
+
let pos = 8;
|
|
811
|
+
|
|
812
|
+
function readLEB128() {
|
|
813
|
+
let result = 0, shift = 0;
|
|
814
|
+
while (pos < buf.length) {
|
|
815
|
+
const byte = buf[pos++];
|
|
816
|
+
result |= (byte & 0x7F) << shift;
|
|
817
|
+
if (!(byte & 0x80)) break;
|
|
818
|
+
shift += 7;
|
|
819
|
+
}
|
|
820
|
+
return result;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function skipBytes(n) { pos += n; }
|
|
824
|
+
|
|
825
|
+
while (pos < buf.length) {
|
|
826
|
+
const sectionId = buf[pos++];
|
|
827
|
+
const sectionSize = readLEB128();
|
|
828
|
+
const sectionEnd = pos + sectionSize;
|
|
829
|
+
|
|
830
|
+
if (sectionId === 2) {
|
|
831
|
+
const count = readLEB128();
|
|
832
|
+
for (let i = 0; i < count; i++) {
|
|
833
|
+
const modLen = readLEB128();
|
|
834
|
+
skipBytes(modLen);
|
|
835
|
+
const fieldLen = readLEB128();
|
|
836
|
+
skipBytes(fieldLen);
|
|
837
|
+
const kind = buf[pos++];
|
|
838
|
+
|
|
839
|
+
if (kind === 0x02) {
|
|
840
|
+
const flags = buf[pos++];
|
|
841
|
+
const shared = !!(flags & 0x02);
|
|
842
|
+
const hasMax = !!(flags & 0x01);
|
|
843
|
+
const initial = readLEB128();
|
|
844
|
+
const maximum = hasMax ? readLEB128() : undefined;
|
|
845
|
+
return { initial, maximum, shared };
|
|
846
|
+
} else if (kind === 0x00) {
|
|
847
|
+
readLEB128();
|
|
848
|
+
} else if (kind === 0x01) {
|
|
849
|
+
pos++;
|
|
850
|
+
const tFlags = buf[pos++];
|
|
851
|
+
readLEB128();
|
|
852
|
+
if (tFlags & 0x01) readLEB128();
|
|
853
|
+
} else if (kind === 0x03) {
|
|
854
|
+
pos++;
|
|
855
|
+
pos++;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
pos = sectionEnd;
|
|
862
|
+
}
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
getManifest() {
|
|
867
|
+
return this._manifest ? { ...this._manifest } : null;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// --- Networking (ABI v3) ---
|
|
871
|
+
|
|
872
|
+
_withTempWasmData(data, callback) {
|
|
873
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
874
|
+
const len = bytes.length;
|
|
875
|
+
const malloc = this.instance.exports.malloc;
|
|
876
|
+
const free = this.instance.exports.free;
|
|
877
|
+
|
|
878
|
+
if (malloc && free) {
|
|
879
|
+
const ptr = malloc(len);
|
|
880
|
+
if (ptr === 0) return;
|
|
881
|
+
this._updateViews();
|
|
882
|
+
this._u8.set(bytes, ptr);
|
|
883
|
+
try { callback(ptr, len); } finally { free(ptr); }
|
|
884
|
+
} else {
|
|
885
|
+
const memSize = this.memory.buffer.byteLength;
|
|
886
|
+
const scratchStart = memSize - 65536;
|
|
887
|
+
if (len > 65536 || len === 0) return;
|
|
888
|
+
this._updateViews();
|
|
889
|
+
this._u8.set(bytes, scratchStart);
|
|
890
|
+
callback(scratchStart, len);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
_wsOpen(urlPtr, urlLen) {
|
|
895
|
+
const allowlist = this._manifest?.net?.websocket;
|
|
896
|
+
if (!allowlist || !globalThis.WebSocket) return -1;
|
|
897
|
+
|
|
898
|
+
this._updateViews();
|
|
899
|
+
const url = new TextDecoder().decode(this._u8.slice(urlPtr, urlPtr + urlLen));
|
|
900
|
+
|
|
901
|
+
let hostname;
|
|
902
|
+
try {
|
|
903
|
+
hostname = new URL(url).hostname;
|
|
904
|
+
} catch {
|
|
905
|
+
return -1;
|
|
906
|
+
}
|
|
907
|
+
if (!allowlist.includes(hostname)) return -1;
|
|
908
|
+
|
|
909
|
+
const id = this._wsNextId++;
|
|
910
|
+
try {
|
|
911
|
+
const ws = new WebSocket(url);
|
|
912
|
+
ws.binaryType = 'arraybuffer';
|
|
913
|
+
|
|
914
|
+
const conn = { ws, eventQueue: [] };
|
|
915
|
+
ws.onopen = () => conn.eventQueue.push({ type: 'open' });
|
|
916
|
+
ws.onmessage = (e) => {
|
|
917
|
+
if (typeof e.data === 'string') {
|
|
918
|
+
conn.eventQueue.push({ type: 'text', data: e.data });
|
|
919
|
+
} else {
|
|
920
|
+
conn.eventQueue.push({ type: 'binary', data: e.data });
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
ws.onclose = (e) => conn.eventQueue.push({ type: 'close', code: e.code || 1000 });
|
|
924
|
+
ws.onerror = () => conn.eventQueue.push({ type: 'error' });
|
|
925
|
+
|
|
926
|
+
this._wsConnections.set(id, conn);
|
|
927
|
+
return id;
|
|
928
|
+
} catch {
|
|
929
|
+
return -1;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
_wsClose(connId, code) {
|
|
934
|
+
const conn = this._wsConnections.get(connId);
|
|
935
|
+
if (!conn) return;
|
|
936
|
+
try { conn.ws.close(code || 1000); } catch {}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
_wsSend(connId, dataPtr, len, isText) {
|
|
940
|
+
const conn = this._wsConnections.get(connId);
|
|
941
|
+
if (!conn) return -1;
|
|
942
|
+
try {
|
|
943
|
+
if (conn.ws.readyState !== 1) return -1;
|
|
944
|
+
this._updateViews();
|
|
945
|
+
if (isText) {
|
|
946
|
+
const str = new TextDecoder().decode(this._u8.slice(dataPtr, dataPtr + len));
|
|
947
|
+
conn.ws.send(str);
|
|
948
|
+
} else {
|
|
949
|
+
const bytes = this._u8.slice(dataPtr, dataPtr + len);
|
|
950
|
+
conn.ws.send(bytes);
|
|
951
|
+
}
|
|
952
|
+
return len;
|
|
953
|
+
} catch {
|
|
954
|
+
return -1;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
_wsState(connId) {
|
|
959
|
+
const conn = this._wsConnections.get(connId);
|
|
960
|
+
if (!conn) return 3;
|
|
961
|
+
return conn.ws.readyState;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
_dcPeerInfo(index, destPtr, maxLen) {
|
|
965
|
+
const peers = [...this._dcPeers.entries()];
|
|
966
|
+
if (index >= peers.length) return -1;
|
|
967
|
+
const [peerId, peer] = peers[index];
|
|
968
|
+
this._updateViews();
|
|
969
|
+
const labelBytes = new TextEncoder().encode(peer.label + '\0');
|
|
970
|
+
const copyLen = Math.min(labelBytes.length, maxLen);
|
|
971
|
+
this._u8.set(labelBytes.subarray(0, copyLen), destPtr);
|
|
972
|
+
return peerId;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
_dcSend(peerId, dataPtr, len) {
|
|
976
|
+
const peer = this._dcPeers.get(peerId);
|
|
977
|
+
if (!peer || !peer.dc) return -1;
|
|
978
|
+
try {
|
|
979
|
+
this._updateViews();
|
|
980
|
+
const bytes = this._u8.slice(dataPtr, dataPtr + len);
|
|
981
|
+
peer.dc.send(bytes);
|
|
982
|
+
return len;
|
|
983
|
+
} catch {
|
|
984
|
+
return -1;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
_dcBroadcast(dataPtr, len) {
|
|
989
|
+
this._updateViews();
|
|
990
|
+
const bytes = this._u8.slice(dataPtr, dataPtr + len);
|
|
991
|
+
let count = 0;
|
|
992
|
+
for (const [, peer] of this._dcPeers) {
|
|
993
|
+
if (!peer.dc) continue;
|
|
994
|
+
try {
|
|
995
|
+
peer.dc.send(bytes);
|
|
996
|
+
count++;
|
|
997
|
+
} catch {}
|
|
998
|
+
}
|
|
999
|
+
return count || -1;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
_deliverNetEvents() {
|
|
1003
|
+
const exports = this.instance.exports;
|
|
1004
|
+
|
|
1005
|
+
for (const [id, conn] of this._wsConnections) {
|
|
1006
|
+
while (conn.eventQueue.length > 0) {
|
|
1007
|
+
const evt = conn.eventQueue.shift();
|
|
1008
|
+
if (evt.type === 'open' && exports.wc_ws_on_open) {
|
|
1009
|
+
exports.wc_ws_on_open(id);
|
|
1010
|
+
} else if (evt.type === 'binary' && exports.wc_ws_on_message) {
|
|
1011
|
+
const buf = evt.data instanceof ArrayBuffer ? new Uint8Array(evt.data)
|
|
1012
|
+
: evt.data instanceof Uint8Array ? evt.data
|
|
1013
|
+
: new Uint8Array(evt.data);
|
|
1014
|
+
this._withTempWasmData(buf, (ptr, len) => {
|
|
1015
|
+
exports.wc_ws_on_message(id, ptr, len);
|
|
1016
|
+
});
|
|
1017
|
+
} else if (evt.type === 'text' && exports.wc_ws_on_message_text) {
|
|
1018
|
+
const bytes = new TextEncoder().encode(evt.data);
|
|
1019
|
+
this._withTempWasmData(bytes, (ptr, len) => {
|
|
1020
|
+
exports.wc_ws_on_message_text(id, ptr, len);
|
|
1021
|
+
});
|
|
1022
|
+
} else if (evt.type === 'close' && exports.wc_ws_on_close) {
|
|
1023
|
+
exports.wc_ws_on_close(id, evt.code);
|
|
1024
|
+
} else if (evt.type === 'error' && exports.wc_ws_on_error) {
|
|
1025
|
+
exports.wc_ws_on_error(id);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
for (const [peerId, peer] of this._dcPeers) {
|
|
1031
|
+
while (peer.eventQueue.length > 0) {
|
|
1032
|
+
const evt = peer.eventQueue.shift();
|
|
1033
|
+
if (evt.type === 'connect' && exports.wc_dc_on_connect) {
|
|
1034
|
+
const labelBytes = new TextEncoder().encode(peer.label);
|
|
1035
|
+
this._withTempWasmData(labelBytes, (ptr, len) => {
|
|
1036
|
+
exports.wc_dc_on_connect(peerId, ptr, len);
|
|
1037
|
+
});
|
|
1038
|
+
} else if (evt.type === 'message' && exports.wc_dc_on_message) {
|
|
1039
|
+
const buf = evt.data instanceof ArrayBuffer ? new Uint8Array(evt.data)
|
|
1040
|
+
: evt.data instanceof Uint8Array ? evt.data
|
|
1041
|
+
: new Uint8Array(evt.data);
|
|
1042
|
+
this._withTempWasmData(buf, (ptr, len) => {
|
|
1043
|
+
exports.wc_dc_on_message(peerId, ptr, len);
|
|
1044
|
+
});
|
|
1045
|
+
} else if (evt.type === 'disconnect' && exports.wc_dc_on_disconnect) {
|
|
1046
|
+
exports.wc_dc_on_disconnect(peerId);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
addDataChannelPeer(peerId, label, dc) {
|
|
1053
|
+
const peer = { dc, label, eventQueue: [{ type: 'connect' }] };
|
|
1054
|
+
dc.onmessage = (e) => {
|
|
1055
|
+
peer.eventQueue.push({ type: 'message', data: e.data });
|
|
1056
|
+
};
|
|
1057
|
+
dc.onclose = () => {
|
|
1058
|
+
peer.eventQueue.push({ type: 'disconnect' });
|
|
1059
|
+
};
|
|
1060
|
+
this._dcPeers.set(peerId, peer);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
removeDataChannelPeer(peerId) {
|
|
1064
|
+
const peer = this._dcPeers.get(peerId);
|
|
1065
|
+
if (peer) {
|
|
1066
|
+
peer.eventQueue.push({ type: 'disconnect' });
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// --- Pointer Input (ABI v3) ---
|
|
1071
|
+
|
|
1072
|
+
setPointer(id, x, y, buttons, active) {
|
|
1073
|
+
if (id < 0 || id >= MAX_POINTERS) return;
|
|
1074
|
+
const p = this._pointerState[id];
|
|
1075
|
+
p.x = x;
|
|
1076
|
+
p.y = y;
|
|
1077
|
+
p.buttons = buttons;
|
|
1078
|
+
p.active = active ? 1 : 0;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
pointerDown(id, x, y, button) {
|
|
1082
|
+
if (id < 0 || id >= MAX_POINTERS) return;
|
|
1083
|
+
const p = this._pointerState[id];
|
|
1084
|
+
p.x = x;
|
|
1085
|
+
p.y = y;
|
|
1086
|
+
p.buttons |= (1 << (button || 0));
|
|
1087
|
+
p.active = 1;
|
|
1088
|
+
this._pointerEvents.push({ type: 'down', id, x, y, button: button || 0 });
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
pointerMove(id, x, y) {
|
|
1092
|
+
if (id < 0 || id >= MAX_POINTERS) return;
|
|
1093
|
+
const p = this._pointerState[id];
|
|
1094
|
+
p.x = x;
|
|
1095
|
+
p.y = y;
|
|
1096
|
+
this._pointerEvents.push({ type: 'move', id, x, y });
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
pointerUp(id, button) {
|
|
1100
|
+
if (id < 0 || id >= MAX_POINTERS) return;
|
|
1101
|
+
const p = this._pointerState[id];
|
|
1102
|
+
p.buttons &= ~(1 << (button || 0));
|
|
1103
|
+
if (p.buttons === 0 && id > 0) {
|
|
1104
|
+
p.active = 0;
|
|
1105
|
+
}
|
|
1106
|
+
this._pointerEvents.push({ type: 'up', id, button: button || 0 });
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
_writePointerState() {
|
|
1110
|
+
if (!this.info || !this.info.pointerPtr || !this.info.wantsPointer) return;
|
|
1111
|
+
if (!this._manifest?.pointer) return;
|
|
1112
|
+
this._updateViews();
|
|
1113
|
+
const base = this.info.pointerPtr;
|
|
1114
|
+
for (let i = 0; i < MAX_POINTERS; i++) {
|
|
1115
|
+
const p = this._pointerState[i];
|
|
1116
|
+
const off = base + i * POINTER_SIZE;
|
|
1117
|
+
this._i16[off >> 1] = p.x;
|
|
1118
|
+
this._i16[(off + 2) >> 1] = p.y;
|
|
1119
|
+
this._u8[off + 4] = p.buttons;
|
|
1120
|
+
this._u8[off + 5] = p.active;
|
|
1121
|
+
this._u8[off + 6] = 0;
|
|
1122
|
+
this._u8[off + 7] = 0;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
_deliverPointerEvents() {
|
|
1127
|
+
if (!this.info?.wantsPointer || !this._manifest?.pointer) return;
|
|
1128
|
+
const exports = this.instance.exports;
|
|
1129
|
+
while (this._pointerEvents.length > 0) {
|
|
1130
|
+
const evt = this._pointerEvents.shift();
|
|
1131
|
+
if (evt.type === 'down' && exports.wc_ptr_on_down) {
|
|
1132
|
+
exports.wc_ptr_on_down(evt.id, evt.x, evt.y, evt.button);
|
|
1133
|
+
} else if (evt.type === 'move' && exports.wc_ptr_on_move) {
|
|
1134
|
+
exports.wc_ptr_on_move(evt.id, evt.x, evt.y);
|
|
1135
|
+
} else if (evt.type === 'up' && exports.wc_ptr_on_up) {
|
|
1136
|
+
exports.wc_ptr_on_up(evt.id, evt.button);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// --- Keyboard Input (ABI v3) ---
|
|
1142
|
+
|
|
1143
|
+
keyDown(keycode, modifiers) {
|
|
1144
|
+
if (keycode < 0 || keycode > 255) return;
|
|
1145
|
+
this._keyState[keycode >> 3] |= (1 << (keycode & 7));
|
|
1146
|
+
this._keyEvents.push({ type: 'down', keycode, modifiers: modifiers || 0 });
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
keyUp(keycode, modifiers) {
|
|
1150
|
+
if (keycode < 0 || keycode > 255) return;
|
|
1151
|
+
this._keyState[keycode >> 3] &= ~(1 << (keycode & 7));
|
|
1152
|
+
this._keyEvents.push({ type: 'up', keycode, modifiers: modifiers || 0 });
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
_writeKeyState() {
|
|
1156
|
+
if (!this.info || !this.info.keysPtr || !this.info.wantsKeyboard) return;
|
|
1157
|
+
if (!this._manifest?.keyboard) return;
|
|
1158
|
+
this._updateViews();
|
|
1159
|
+
this._u8.set(this._keyState, this.info.keysPtr);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
_deliverKeyEvents() {
|
|
1163
|
+
if (!this.info?.wantsKeyboard || !this._manifest?.keyboard) return;
|
|
1164
|
+
const exports = this.instance.exports;
|
|
1165
|
+
while (this._keyEvents.length > 0) {
|
|
1166
|
+
const evt = this._keyEvents.shift();
|
|
1167
|
+
if (evt.type === 'down' && exports.wc_kb_on_down) {
|
|
1168
|
+
exports.wc_kb_on_down(evt.keycode, evt.modifiers);
|
|
1169
|
+
} else if (evt.type === 'up' && exports.wc_kb_on_up) {
|
|
1170
|
+
exports.wc_kb_on_up(evt.keycode, evt.modifiers);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// --- Private ---
|
|
1176
|
+
|
|
1177
|
+
_validateModule(module) {
|
|
1178
|
+
const moduleImports = WebAssembly.Module.imports(module);
|
|
1179
|
+
const moduleExports = WebAssembly.Module.exports(module);
|
|
1180
|
+
|
|
1181
|
+
for (const imp of moduleImports) {
|
|
1182
|
+
if (imp.module === 'wasi_snapshot_preview1' || imp.module === 'wasi') continue;
|
|
1183
|
+
if (imp.module === 'gl') continue;
|
|
1184
|
+
if (imp.module !== 'env') {
|
|
1185
|
+
throw new Error(`Cart imports unknown module: "${imp.module}"`);
|
|
1186
|
+
}
|
|
1187
|
+
if (imp.kind === 'memory') continue;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const exportNames = moduleExports.map(e => e.name);
|
|
1191
|
+
if (!exportNames.includes('wc_render')) {
|
|
1192
|
+
throw new Error('Cart must export wc_render');
|
|
1193
|
+
}
|
|
1194
|
+
if (!exportNames.includes('wc_get_info')) {
|
|
1195
|
+
throw new Error('Cart must export wc_get_info');
|
|
1196
|
+
}
|
|
1197
|
+
// Threaded carts may import memory instead of exporting it
|
|
1198
|
+
const analysis = this._analyzeModule(module);
|
|
1199
|
+
if (!exportNames.includes('memory') && !analysis.importsMemory) {
|
|
1200
|
+
throw new Error('Cart must export memory');
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
_updateViews() {
|
|
1205
|
+
const buf = this.memory.buffer;
|
|
1206
|
+
if (buf === this._lastBuffer && buf.byteLength === this._lastByteLength) return;
|
|
1207
|
+
this._lastBuffer = buf;
|
|
1208
|
+
this._lastByteLength = buf.byteLength;
|
|
1209
|
+
this._u8 = new Uint8Array(buf);
|
|
1210
|
+
this._u16 = new Uint16Array(buf);
|
|
1211
|
+
this._i16 = new Int16Array(buf);
|
|
1212
|
+
this._u32 = new Uint32Array(buf);
|
|
1213
|
+
this._f32 = new Float32Array(buf);
|
|
1214
|
+
this._f64 = new Float64Array(buf);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
_readInfo(ptr) {
|
|
1218
|
+
const u32 = this._u32;
|
|
1219
|
+
const base = ptr >> 2;
|
|
1220
|
+
|
|
1221
|
+
const info = {
|
|
1222
|
+
version: u32[base + 0],
|
|
1223
|
+
width: u32[base + 1],
|
|
1224
|
+
height: u32[base + 2],
|
|
1225
|
+
fbPtr: u32[base + 3],
|
|
1226
|
+
audioPtr: u32[base + 4],
|
|
1227
|
+
audioCap: u32[base + 5],
|
|
1228
|
+
audioWritePtr: u32[base + 6],
|
|
1229
|
+
inputPtr: u32[base + 7],
|
|
1230
|
+
savePtr: u32[base + 8],
|
|
1231
|
+
saveSize: u32[base + 9],
|
|
1232
|
+
timePtr: u32[base + 10],
|
|
1233
|
+
hostInfoPtr: 0,
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
if (info.version >= 2) {
|
|
1237
|
+
const hip = u32[base + 11];
|
|
1238
|
+
if (hip > 0 && hip < 0x10000000 && (hip & 3) === 0) {
|
|
1239
|
+
info.hostInfoPtr = hip;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
info.flags = u32[base + 12] || 0;
|
|
1244
|
+
info.audioIsF32 = !!(info.flags & 1);
|
|
1245
|
+
info.audioSampleRate = u32[base + 13] || 0;
|
|
1246
|
+
|
|
1247
|
+
// Read v3 fields (offset 56, 60)
|
|
1248
|
+
info.pointerPtr = 0;
|
|
1249
|
+
info.keysPtr = 0;
|
|
1250
|
+
if (info.version >= 3) {
|
|
1251
|
+
const pp = u32[base + 14];
|
|
1252
|
+
if (pp > 0 && pp < 0x10000000 && (pp & 1) === 0) {
|
|
1253
|
+
info.pointerPtr = pp;
|
|
1254
|
+
}
|
|
1255
|
+
const kp = u32[base + 15];
|
|
1256
|
+
if (kp > 0 && kp < 0x10000000) {
|
|
1257
|
+
info.keysPtr = kp;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
info.wantsPointer = !!(info.flags & FLAG_POINTER);
|
|
1261
|
+
info.wantsKeyboard = !!(info.flags & FLAG_KEYBOARD);
|
|
1262
|
+
|
|
1263
|
+
// gpu_api (offset 64, u32 index 16) - 0=2D, 1=WebGL2, 2=WebGPU, 3=Vulkan
|
|
1264
|
+
info.gpuApi = u32[base + 16] || 0;
|
|
1265
|
+
|
|
1266
|
+
return info;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
_writeHostInfo(hostInfoPtr, options) {
|
|
1270
|
+
if (!hostInfoPtr) return;
|
|
1271
|
+
const u32 = this._u32;
|
|
1272
|
+
const base = hostInfoPtr >> 2;
|
|
1273
|
+
u32[base + 0] = options.preferredWidth || 0;
|
|
1274
|
+
u32[base + 1] = options.preferredHeight || 0;
|
|
1275
|
+
u32[base + 2] = 0; // reserved
|
|
1276
|
+
u32[base + 3] = options.audioSampleRate || 48000;
|
|
1277
|
+
u32[base + 4] = options.flags || 0;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
_writeTime(timeMs, deltaMs, frame) {
|
|
1281
|
+
const ptr = this.info.timePtr;
|
|
1282
|
+
const f64Base = ptr >> 3;
|
|
1283
|
+
this._f64[f64Base + 0] = timeMs;
|
|
1284
|
+
this._f64[f64Base + 1] = deltaMs;
|
|
1285
|
+
this._u32[(ptr + 16) >> 2] = frame;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
_writePads(pads) {
|
|
1289
|
+
const basePtr = this.info.inputPtr;
|
|
1290
|
+
|
|
1291
|
+
for (let i = 0; i < MAX_PADS; i++) {
|
|
1292
|
+
const pad = pads[i];
|
|
1293
|
+
const offset = basePtr + (i * PAD_SIZE);
|
|
1294
|
+
|
|
1295
|
+
// Capture pad name (if provided by caller)
|
|
1296
|
+
this._padNames[i] = (pad && pad.name) ? pad.name : '';
|
|
1297
|
+
|
|
1298
|
+
if (!pad || !pad.connected) {
|
|
1299
|
+
this._u8.fill(0, offset, offset + PAD_SIZE);
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
this._u16[offset >> 1] = pad.buttons || 0;
|
|
1304
|
+
this._i16[(offset + 2) >> 1] = pad.leftX || 0;
|
|
1305
|
+
this._i16[(offset + 4) >> 1] = pad.leftY || 0;
|
|
1306
|
+
this._i16[(offset + 6) >> 1] = pad.rightX || 0;
|
|
1307
|
+
this._i16[(offset + 8) >> 1] = pad.rightY || 0;
|
|
1308
|
+
this._u8[offset + 10] = pad.leftTrigger || 0;
|
|
1309
|
+
this._u8[offset + 11] = pad.rightTrigger || 0;
|
|
1310
|
+
this._u8[offset + 12] = 1; // connected
|
|
1311
|
+
this._u8[offset + 13] = 0; // padding
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
_padName(padId, bufPtr, bufLen) {
|
|
1316
|
+
if (padId >= MAX_PADS || !bufLen) return 0;
|
|
1317
|
+
const name = this._padNames[padId] || '';
|
|
1318
|
+
if (!name.length) return 0;
|
|
1319
|
+
this._updateViews();
|
|
1320
|
+
const encoded = new TextEncoder().encode(name);
|
|
1321
|
+
const len = Math.min(encoded.length, bufLen);
|
|
1322
|
+
this._u8.set(encoded.subarray(0, len), bufPtr);
|
|
1323
|
+
return len;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
_drainAudio() {
|
|
1327
|
+
if (!this.info.audioPtr || this.info.audioCap === 0) return null;
|
|
1328
|
+
|
|
1329
|
+
const writeCursor = this._u32[this.info.audioWritePtr >> 2];
|
|
1330
|
+
const readCursor = this.audioReadCursor;
|
|
1331
|
+
|
|
1332
|
+
if (writeCursor === readCursor) return null;
|
|
1333
|
+
|
|
1334
|
+
const cap = this.info.audioCap;
|
|
1335
|
+
const audioBase = this.info.audioPtr;
|
|
1336
|
+
|
|
1337
|
+
let available;
|
|
1338
|
+
if (writeCursor >= readCursor) {
|
|
1339
|
+
available = writeCursor - readCursor;
|
|
1340
|
+
} else {
|
|
1341
|
+
available = cap - readCursor + writeCursor;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (available === 0) return null;
|
|
1345
|
+
|
|
1346
|
+
const needed = available * 2; // stereo
|
|
1347
|
+
|
|
1348
|
+
if (this.info.audioIsF32) {
|
|
1349
|
+
if (!this._audioBufF32 || this._audioBufF32.length < needed) {
|
|
1350
|
+
this._audioBufF32 = new Float32Array(needed);
|
|
1351
|
+
}
|
|
1352
|
+
const samples = this._audioBufF32;
|
|
1353
|
+
const ringF32Base = audioBase >> 2;
|
|
1354
|
+
|
|
1355
|
+
for (let i = 0; i < available; i++) {
|
|
1356
|
+
const ringIdx = ((readCursor + i) % cap) * 2;
|
|
1357
|
+
samples[i * 2] = this._f32[ringF32Base + ringIdx];
|
|
1358
|
+
samples[i * 2 + 1] = this._f32[ringF32Base + ringIdx + 1];
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
this.audioReadCursor = writeCursor;
|
|
1362
|
+
return samples.subarray(0, needed);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// Int16 path
|
|
1366
|
+
if (!this._audioBuf || this._audioBuf.length < needed) {
|
|
1367
|
+
this._audioBuf = new Int16Array(needed);
|
|
1368
|
+
}
|
|
1369
|
+
const samples = this._audioBuf;
|
|
1370
|
+
const ringI16Base = audioBase >> 1;
|
|
1371
|
+
|
|
1372
|
+
for (let i = 0; i < available; i++) {
|
|
1373
|
+
const ringIdx = ((readCursor + i) % cap) * 2;
|
|
1374
|
+
samples[i * 2] = this._i16[ringI16Base + ringIdx];
|
|
1375
|
+
samples[i * 2 + 1] = this._i16[ringI16Base + ringIdx + 1];
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
this.audioReadCursor = writeCursor;
|
|
1379
|
+
return samples.subarray(0, needed);
|
|
1380
|
+
}
|
|
1381
|
+
}
|