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/src/abi.js ADDED
@@ -0,0 +1,94 @@
1
+ // wasmcart ABI v3 definitions (backward compatible with v1 and v2)
2
+
3
+ export const ABI_VERSION = 3;
4
+ export const MIN_ABI_VERSION = 1; // oldest version we still support
5
+
6
+ // Button bitmask positions (matches common gamepad layout)
7
+ export const BUTTON = {
8
+ A: 1 << 0,
9
+ B: 1 << 1,
10
+ X: 1 << 2,
11
+ Y: 1 << 3,
12
+ L: 1 << 4,
13
+ R: 1 << 5,
14
+ START: 1 << 6,
15
+ SELECT: 1 << 7,
16
+ UP: 1 << 8,
17
+ DOWN: 1 << 9,
18
+ LEFT: 1 << 10,
19
+ RIGHT: 1 << 11,
20
+ L3: 1 << 12,
21
+ R3: 1 << 13,
22
+ };
23
+
24
+ // WCPad struct layout (16 bytes per pad)
25
+ // u16 buttons
26
+ // i16 left_x, left_y, right_x, right_y
27
+ // u8 left_trigger, right_trigger, connected, _pad
28
+ export const PAD_SIZE = 16;
29
+ export const MAX_PADS = 4;
30
+ export const INPUT_REGION_SIZE = PAD_SIZE * MAX_PADS; // 64 bytes
31
+
32
+ // WCTime struct layout (20 bytes)
33
+ // f64 time_ms (offset 0)
34
+ // f64 delta_ms (offset 8)
35
+ // u32 frame (offset 16)
36
+ export const TIME_SIZE = 20;
37
+
38
+ // WCInfo struct field offsets (returned by wc_get_info)
39
+ // All fields are u32
40
+ export const INFO_FIELDS = {
41
+ VERSION: 0,
42
+ WIDTH: 4,
43
+ HEIGHT: 8,
44
+ FB_PTR: 12,
45
+ AUDIO_PTR: 16,
46
+ AUDIO_CAP: 20, // capacity in stereo frames
47
+ AUDIO_WRITE: 24, // cart's write cursor offset (pointer to u32 in cart memory)
48
+ INPUT_PTR: 28,
49
+ SAVE_PTR: 32,
50
+ SAVE_SIZE: 36,
51
+ TIME_PTR: 40,
52
+ HOST_INFO_PTR: 44, // pointer to wc_host_info_t (host writes before wc_init)
53
+ };
54
+ export const INFO_STRUCT_SIZE = 48;
55
+
56
+ // WCHostInfo struct layout (written by host before wc_init)
57
+ // All fields are u32
58
+ export const HOST_INFO_FIELDS = {
59
+ PREFERRED_WIDTH: 0,
60
+ PREFERRED_HEIGHT: 4,
61
+ HOST_FPS: 8,
62
+ AUDIO_SAMPLE_RATE: 12,
63
+ FLAGS: 16,
64
+ };
65
+ export const HOST_INFO_SIZE = 20;
66
+
67
+ // Cart info flags (wc_info_t.flags)
68
+ export const FLAG_AUDIO_F32 = 1 << 0; // audio ring buffer uses float32
69
+ export const FLAG_NET_WS = 1 << 1; // cart wants WebSocket imports
70
+ export const FLAG_NET_DC = 1 << 2; // cart wants data channel imports
71
+ export const FLAG_POINTER = 1 << 3; // cart wants pointer input
72
+ export const FLAG_KEYBOARD = 1 << 4; // cart wants raw keyboard input
73
+
74
+ // Extended info fields (v3) - byte offsets from wc_info_t start
75
+ export const INFO_FIELDS_V3 = {
76
+ POINTER_PTR: 56, // u32 index 14
77
+ KEYS_PTR: 60, // u32 index 15
78
+ GPU_API: 64, // u32 index 16 - 0=2D, 1=WebGL2/GLES3, 2=WebGPU, 3=Vulkan
79
+ };
80
+
81
+ // GPU API values for wc_info_t.gpu_api
82
+ export const GPU_API_NONE = 0; // 2D framebuffer only
83
+ export const GPU_API_WEBGL2 = 1; // WebGL2 / GLES3
84
+ export const GPU_API_WEBGPU = 2; // reserved
85
+ export const GPU_API_VULKAN = 3; // reserved
86
+
87
+ // Pointer struct layout (8 bytes per pointer)
88
+ // i16 x, i16 y, u8 buttons, u8 active, u8[2] pad
89
+ export const POINTER_SIZE = 8;
90
+ export const MAX_POINTERS = 10;
91
+ export const POINTER_REGION_SIZE = POINTER_SIZE * MAX_POINTERS; // 80 bytes
92
+
93
+ // Keyboard state bitmask size
94
+ export const KEYS_STATE_SIZE = 32; // 256 bits = 32 bytes
@@ -0,0 +1,201 @@
1
+ /**
2
+ * cartWorker.js - WASI threads worker entry point
3
+ *
4
+ * Runs in a Node.js worker_thread. Receives a compiled WebAssembly.Module
5
+ * and shared WebAssembly.Memory, instantiates the module, and calls
6
+ * wasi_thread_start(tid, start_arg).
7
+ *
8
+ * All pthread logic (mutexes, condvars, TLS) lives inside the WASM module
9
+ * via wasi-libc. This worker just provides the host-side imports.
10
+ */
11
+
12
+ import { workerData, parentPort } from 'worker_threads';
13
+ import { openSync, readSync, closeSync, statSync } from 'fs';
14
+ import { inflateRawSync } from 'zlib';
15
+
16
+ const { module: wasmModule, memory, tid, startArg, assetConfig } = workerData;
17
+
18
+ // --- Asset access (worker opens its own fd for the .wasc file) ---
19
+
20
+ let assetIndex = null; // Map<path, entry>
21
+ let assetFd = null;
22
+ let assetBuf = null;
23
+
24
+ if (assetConfig) {
25
+ if (assetConfig.type === 'zip' && assetConfig.filePath) {
26
+ assetFd = openSync(assetConfig.filePath, 'r');
27
+ assetIndex = new Map(assetConfig.index);
28
+ } else if (assetConfig.type === 'buffer' && assetConfig.buffer) {
29
+ assetBuf = assetConfig.buffer;
30
+ assetIndex = new Map(assetConfig.index);
31
+ } else if (assetConfig.type === 'dir' && assetConfig.dir) {
32
+ // Directory-based dev mode - not supported in worker yet
33
+ // (would need readFileSync, rarely used with threaded carts)
34
+ }
35
+ }
36
+
37
+ function readZipEntry(fd, entry) {
38
+ const buf = Buffer.alloc(entry.compressedSize);
39
+ readSync(fd, buf, 0, entry.compressedSize, entry.dataOffset);
40
+ if (entry.compressionMethod === 0) return buf;
41
+ if (entry.compressionMethod === 8) return inflateRawSync(buf);
42
+ return null;
43
+ }
44
+
45
+ function readZipEntryFromBuffer(buf, entry) {
46
+ const data = buf.slice(entry.dataOffset, entry.dataOffset + entry.compressedSize);
47
+ if (entry.compressionMethod === 0) return data;
48
+ if (entry.compressionMethod === 8) return inflateRawSync(data);
49
+ return null;
50
+ }
51
+
52
+ function assetSize(pathPtr, pathLen) {
53
+ if (!assetIndex) return -1;
54
+ const path = new TextDecoder().decode(new Uint8Array(memory.buffer, pathPtr, pathLen));
55
+ const entry = assetIndex.get(path);
56
+ return entry ? entry.uncompressedSize : -1;
57
+ }
58
+
59
+ function loadAsset(pathPtr, pathLen, destPtr, maxSize) {
60
+ if (!assetIndex) return -1;
61
+ const path = new TextDecoder().decode(new Uint8Array(memory.buffer, pathPtr, pathLen));
62
+ const entry = assetIndex.get(path);
63
+ if (!entry) return -1;
64
+ if (entry.uncompressedSize > maxSize) return -1;
65
+
66
+ let data;
67
+ if (assetFd !== null) {
68
+ data = readZipEntry(assetFd, entry);
69
+ } else if (assetBuf) {
70
+ data = readZipEntryFromBuffer(assetBuf, entry);
71
+ } else {
72
+ return -1;
73
+ }
74
+ if (!data) return -1;
75
+
76
+ // Write into shared WASM memory
77
+ const dest = new Uint8Array(memory.buffer, destPtr, data.length);
78
+ dest.set(new Uint8Array(data.buffer || data, data.byteOffset, data.length));
79
+ return data.length;
80
+ }
81
+
82
+ // --- Build import object ---
83
+
84
+ const imports = {
85
+ env: {
86
+ memory,
87
+ wc_log: (ptr, len) => {
88
+ const bytes = new Uint8Array(memory.buffer).slice(ptr, ptr + len);
89
+ const text = new TextDecoder().decode(bytes);
90
+ console.log(`[cart:t${tid}]`, text);
91
+ },
92
+ wc_asset_size: assetSize,
93
+ wc_load_asset: loadAsset,
94
+ emscripten_notify_memory_growth: () => {},
95
+ emscripten_asm_const_int: () => 0,
96
+ emscripten_asm_const_double: () => 0.0,
97
+ emscripten_get_element_css_size: () => 0,
98
+ __syscall_getcwd: () => -1,
99
+ __syscall_getdents64: () => -1,
100
+ },
101
+ wasi_snapshot_preview1: {
102
+ fd_close: () => 0,
103
+ fd_write: (fd, iovs, iovs_len, nwritten_ptr) => {
104
+ try {
105
+ const view = new DataView(memory.buffer);
106
+ const u8 = new Uint8Array(memory.buffer);
107
+ let totalWritten = 0;
108
+ let text = '';
109
+ for (let i = 0; i < iovs_len; i++) {
110
+ const ptr = view.getUint32(iovs + i * 8, true);
111
+ const len = view.getUint32(iovs + i * 8 + 4, true);
112
+ if (len > 0) text += new TextDecoder().decode(u8.slice(ptr, ptr + len));
113
+ totalWritten += len;
114
+ }
115
+ if (text && (fd === 1 || fd === 2)) {
116
+ for (const line of text.split('\n')) {
117
+ if (line.length > 0) process.stderr.write(`[cart:t${tid}] ${line}\n`);
118
+ }
119
+ }
120
+ if (nwritten_ptr) view.setUint32(nwritten_ptr, totalWritten, true);
121
+ } catch {}
122
+ return 0;
123
+ },
124
+ fd_seek: () => 0,
125
+ fd_read: () => 0,
126
+ environ_get: () => 0,
127
+ environ_sizes_get: () => 0,
128
+ proc_exit: () => {},
129
+ clock_time_get: (id, precision, resultPtr) => {
130
+ try {
131
+ const ns = BigInt(Math.round(performance.now() * 1e6));
132
+ new DataView(memory.buffer).setBigUint64(resultPtr, ns, true);
133
+ } catch {}
134
+ return 0;
135
+ },
136
+ sched_yield: () => 0,
137
+ },
138
+ wasi: {
139
+ 'thread-spawn': (arg) => {
140
+ // Nested thread spawning: request main thread to do it
141
+ // For now, use synchronous message exchange
142
+ // TODO: use Atomics.wait/notify for true sync if needed
143
+ parentPort.postMessage({ type: 'spawn', startArg: arg });
144
+ // Return -1 for now (nested spawn needs async handling)
145
+ // Full implementation would use Atomics.wait on a shared buffer
146
+ return -1;
147
+ },
148
+ },
149
+ };
150
+
151
+ // Provide GL stubs if the module imports GL functions (they must not be called from workers)
152
+ const moduleImports = WebAssembly.Module.imports(wasmModule);
153
+ for (const imp of moduleImports) {
154
+ if (imp.kind !== 'function') continue;
155
+ // GL functions in 'gl' module
156
+ if (imp.module === 'gl') {
157
+ if (!imports.gl) imports.gl = {};
158
+ imports.gl[imp.name] = () => {
159
+ throw new Error(`GL call ${imp.name}() not allowed from worker thread ${tid}`);
160
+ };
161
+ }
162
+ // GL functions in 'env' module (gl4es pattern)
163
+ if (imp.module === 'env' && (imp.name.startsWith('gl') || imp.name.startsWith('emscripten_gl'))) {
164
+ if (!(imp.name in imports.env)) {
165
+ imports.env[imp.name] = () => {
166
+ throw new Error(`GL call ${imp.name}() not allowed from worker thread ${tid}`);
167
+ };
168
+ }
169
+ }
170
+ // Other unknown env functions - provide no-op stubs to avoid instantiation failure
171
+ if (imp.module === 'env' && !(imp.name in imports.env)) {
172
+ imports.env[imp.name] = () => 0;
173
+ }
174
+ // Unknown wasi_snapshot_preview1 functions - no-op stubs
175
+ if (imp.module === 'wasi_snapshot_preview1' && !(imp.name in imports.wasi_snapshot_preview1)) {
176
+ imports.wasi_snapshot_preview1[imp.name] = () => 0;
177
+ }
178
+ }
179
+
180
+ // --- Instantiate and run ---
181
+
182
+ async function run() {
183
+ const instance = await WebAssembly.instantiate(wasmModule, imports);
184
+
185
+ // Call the thread entry point
186
+ instance.exports.wasi_thread_start(tid, startArg);
187
+
188
+ // Thread function returned - clean up and notify main thread
189
+ if (assetFd !== null) {
190
+ try { closeSync(assetFd); } catch {}
191
+ }
192
+ parentPort.postMessage({ type: 'exit', tid });
193
+ }
194
+
195
+ run().catch(err => {
196
+ console.error(`[thread ${tid}] fatal:`, err.message);
197
+ if (assetFd !== null) {
198
+ try { closeSync(assetFd); } catch {}
199
+ }
200
+ parentPort.postMessage({ type: 'exit', tid, error: err.message });
201
+ });
@@ -0,0 +1,170 @@
1
+ /**
2
+ * cartWorkerWeb.js - WASI threads worker for browser
3
+ *
4
+ * Receives a compiled WebAssembly.Module and shared WebAssembly.Memory
5
+ * via postMessage, instantiates the module, and calls
6
+ * wasi_thread_start(tid, start_arg).
7
+ */
8
+
9
+ let inflateSync = null;
10
+
11
+ let memory;
12
+ let tid;
13
+ let assetBuf = null;
14
+ let assetIndex = null;
15
+
16
+ function readZipEntryFromBuffer(buf, entry) {
17
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
18
+ const nameLen = view.getUint16(entry.localHeaderOffset + 26, true);
19
+ const extraLen = view.getUint16(entry.localHeaderOffset + 28, true);
20
+ const dataOffset = entry.localHeaderOffset + 30 + nameLen + extraLen;
21
+
22
+ const compressedData = buf.subarray(dataOffset, dataOffset + entry.compressedSize);
23
+
24
+ if (entry.compressionMethod === 0) return compressedData;
25
+ if (entry.compressionMethod === 8) {
26
+ if (!inflateSync) throw new Error('fflate not loaded - cannot decompress asset');
27
+ return inflateSync(compressedData);
28
+ }
29
+ return null;
30
+ }
31
+
32
+ function assetSize(pathPtr, pathLen) {
33
+ if (!assetIndex) return -1;
34
+ const path = new TextDecoder().decode(new Uint8Array(memory.buffer, pathPtr, pathLen));
35
+ const entry = assetIndex.get(path);
36
+ return entry ? entry.uncompressedSize : -1;
37
+ }
38
+
39
+ function loadAsset(pathPtr, pathLen, destPtr, maxSize) {
40
+ if (!assetIndex) return -1;
41
+ const path = new TextDecoder().decode(new Uint8Array(memory.buffer, pathPtr, pathLen));
42
+ const entry = assetIndex.get(path);
43
+ if (!entry) return -1;
44
+ if (entry.uncompressedSize > maxSize) return -1;
45
+
46
+ let data;
47
+ if (assetBuf) {
48
+ data = readZipEntryFromBuffer(assetBuf, entry);
49
+ } else {
50
+ return -1;
51
+ }
52
+ if (!data) return -1;
53
+
54
+ const dest = new Uint8Array(memory.buffer, destPtr, data.length);
55
+ dest.set(data.subarray ? data.subarray(0, data.length) : new Uint8Array(data, 0, data.length));
56
+ return data.length;
57
+ }
58
+
59
+ self.onmessage = async function(e) {
60
+ const { module: wasmModule, memory: sharedMemory, tid: threadId, startArg, assetConfig } = e.data;
61
+ memory = sharedMemory;
62
+ tid = threadId;
63
+
64
+ // Set up asset access
65
+ if (assetConfig && assetConfig.type === 'buffer' && assetConfig.buffer) {
66
+ assetBuf = new Uint8Array(assetConfig.buffer);
67
+ assetIndex = new Map(assetConfig.index);
68
+ // Load fflate dynamically for asset decompression
69
+ try {
70
+ const fflate = await import('fflate');
71
+ inflateSync = fflate.inflateSync;
72
+ } catch (e) {
73
+ console.warn(`[thread] fflate not available - compressed assets will fail`);
74
+ }
75
+ }
76
+
77
+ const imports = {
78
+ env: {
79
+ memory,
80
+ wc_log: (ptr, len) => {
81
+ const bytes = new Uint8Array(memory.buffer).slice(ptr, ptr + len);
82
+ const text = new TextDecoder().decode(bytes);
83
+ console.warn(`[cart:t${tid}]`, text);
84
+ },
85
+ wc_asset_size: assetSize,
86
+ wc_load_asset: loadAsset,
87
+ emscripten_notify_memory_growth: () => {},
88
+ emscripten_asm_const_int: () => 0,
89
+ emscripten_asm_const_double: () => 0.0,
90
+ emscripten_get_element_css_size: () => 0,
91
+ __syscall_getcwd: () => -1,
92
+ __syscall_getdents64: () => -1,
93
+ },
94
+ wasi_snapshot_preview1: {
95
+ fd_close: () => 0,
96
+ fd_write: (fd, iovs, iovs_len, nwritten_ptr) => {
97
+ try {
98
+ const view = new DataView(memory.buffer);
99
+ const u8 = new Uint8Array(memory.buffer);
100
+ let totalWritten = 0;
101
+ let text = '';
102
+ for (let i = 0; i < iovs_len; i++) {
103
+ const ptr = view.getUint32(iovs + i * 8, true);
104
+ const len = view.getUint32(iovs + i * 8 + 4, true);
105
+ if (len > 0) text += new TextDecoder().decode(u8.slice(ptr, ptr + len));
106
+ totalWritten += len;
107
+ }
108
+ if (text && (fd === 1 || fd === 2)) {
109
+ console.warn(`[cart:t${tid}]`, text);
110
+ }
111
+ if (nwritten_ptr) view.setUint32(nwritten_ptr, totalWritten, true);
112
+ } catch {}
113
+ return 0;
114
+ },
115
+ fd_seek: () => 0,
116
+ fd_read: () => 0,
117
+ environ_get: () => 0,
118
+ environ_sizes_get: () => 0,
119
+ proc_exit: () => {},
120
+ clock_time_get: (id, precision, resultPtr) => {
121
+ try {
122
+ const ns = BigInt(Math.round(performance.now() * 1e6));
123
+ new DataView(memory.buffer).setBigUint64(resultPtr, ns, true);
124
+ } catch {}
125
+ return 0;
126
+ },
127
+ sched_yield: () => 0,
128
+ },
129
+ wasi: {
130
+ 'thread-spawn': (arg) => {
131
+ self.postMessage({ type: 'spawn', startArg: arg });
132
+ return -1;
133
+ },
134
+ },
135
+ };
136
+
137
+ // Stub GL and unknown imports
138
+ const moduleImports = WebAssembly.Module.imports(wasmModule);
139
+ for (const imp of moduleImports) {
140
+ if (imp.kind !== 'function') continue;
141
+ if (imp.module === 'gl') {
142
+ if (!imports.gl) imports.gl = {};
143
+ imports.gl[imp.name] = () => {
144
+ throw new Error(`GL call ${imp.name}() not allowed from worker thread ${tid}`);
145
+ };
146
+ }
147
+ if (imp.module === 'env' && (imp.name.startsWith('gl') || imp.name.startsWith('emscripten_gl'))) {
148
+ if (!(imp.name in imports.env)) {
149
+ imports.env[imp.name] = () => {
150
+ throw new Error(`GL call ${imp.name}() not allowed from worker thread ${tid}`);
151
+ };
152
+ }
153
+ }
154
+ if (imp.module === 'env' && !(imp.name in imports.env)) {
155
+ imports.env[imp.name] = () => 0;
156
+ }
157
+ if (imp.module === 'wasi_snapshot_preview1' && !(imp.name in imports.wasi_snapshot_preview1)) {
158
+ imports.wasi_snapshot_preview1[imp.name] = () => 0;
159
+ }
160
+ }
161
+
162
+ try {
163
+ const instance = await WebAssembly.instantiate(wasmModule, imports);
164
+ instance.exports.wasi_thread_start(tid, startArg);
165
+ self.postMessage({ type: 'exit', tid });
166
+ } catch (err) {
167
+ console.error(`[thread ${tid}] fatal:`, err.message);
168
+ self.postMessage({ type: 'exit', tid, error: err.message });
169
+ }
170
+ };