wasmcart 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * wasmcart-pack - Package a .wasm cart + assets into a .wasc archive
5
+ *
6
+ * Usage:
7
+ * wasmcart-pack --wasm build/cart.wasm --assets assets/ --output game.wasc
8
+ * wasmcart-pack --wasm build/cart.wasm --output game.wasc (no assets)
9
+ * wasmcart-pack --wasm build/cart.wasm --assets assets/ --name "My Game" --version "1.0.0" --output game.wasc
10
+ */
11
+
12
+ import { createWriteStream, readFileSync, statSync, readdirSync } from 'fs';
13
+ import { resolve, relative, join, basename, extname } from 'path';
14
+ import { ZipFile } from 'yazl';
15
+
16
+ // Parse arguments
17
+ const args = process.argv.slice(2);
18
+ let wasmPath = null;
19
+ let assetsDir = null;
20
+ let outputPath = null;
21
+ let gameName = null;
22
+ let gameVersion = '1.0.0';
23
+ let players = null;
24
+ let netWebsocket = null; // array of domain strings
25
+ let netDataChannel = false;
26
+ let usePointer = false;
27
+ let useKeyboard = false;
28
+
29
+ for (let i = 0; i < args.length; i++) {
30
+ switch (args[i]) {
31
+ case '--wasm':
32
+ wasmPath = resolve(args[++i]);
33
+ break;
34
+ case '--assets':
35
+ assetsDir = resolve(args[++i]);
36
+ break;
37
+ case '--output':
38
+ case '-o':
39
+ outputPath = resolve(args[++i]);
40
+ break;
41
+ case '--name':
42
+ gameName = args[++i];
43
+ break;
44
+ case '--version':
45
+ gameVersion = args[++i];
46
+ break;
47
+ case '--players':
48
+ players = parseInt(args[++i], 10);
49
+ break;
50
+ case '--ws':
51
+ case '--websocket':
52
+ if (!netWebsocket) netWebsocket = [];
53
+ netWebsocket.push(args[++i]);
54
+ break;
55
+ case '--data-channel':
56
+ netDataChannel = true;
57
+ break;
58
+ case '--pointer':
59
+ usePointer = true;
60
+ break;
61
+ case '--keyboard':
62
+ useKeyboard = true;
63
+ break;
64
+ case '--help':
65
+ case '-h':
66
+ printUsage();
67
+ process.exit(0);
68
+ break;
69
+ default:
70
+ if (!args[i].startsWith('-')) {
71
+ // Positional: treat as wasm path if not set, else output
72
+ if (!wasmPath) wasmPath = resolve(args[i]);
73
+ else if (!outputPath) outputPath = resolve(args[i]);
74
+ }
75
+ break;
76
+ }
77
+ }
78
+
79
+ if (!wasmPath) {
80
+ console.error('Error: --wasm <path> is required');
81
+ printUsage();
82
+ process.exit(1);
83
+ }
84
+
85
+ if (!outputPath) {
86
+ // Default: same name as wasm but with .wasc extension
87
+ outputPath = resolve(basename(wasmPath, extname(wasmPath)) + '.wasc');
88
+ }
89
+
90
+ if (!gameName) {
91
+ gameName = basename(wasmPath, extname(wasmPath));
92
+ }
93
+
94
+ // Validate wasm file exists
95
+ try {
96
+ statSync(wasmPath);
97
+ } catch {
98
+ console.error(`Error: WASM file not found: ${wasmPath}`);
99
+ process.exit(1);
100
+ }
101
+
102
+ // Validate new fields
103
+ if (players !== null) {
104
+ if (!Number.isInteger(players) || players < 1 || players > 4) {
105
+ console.error('Error: --players must be an integer between 1 and 4');
106
+ process.exit(1);
107
+ }
108
+ }
109
+
110
+ if (netWebsocket) {
111
+ for (const domain of netWebsocket) {
112
+ if (!domain || domain.includes('/') || domain.includes(':') || domain.startsWith('.')) {
113
+ console.error(`Error: invalid WebSocket domain: "${domain}" (must be a bare domain name)`);
114
+ process.exit(1);
115
+ }
116
+ }
117
+ }
118
+
119
+ // Build manifest
120
+ const manifest = {
121
+ name: gameName,
122
+ version: gameVersion,
123
+ abi: 3,
124
+ entry: 'cart.wasm',
125
+ };
126
+
127
+ if (players !== null && players > 1) {
128
+ manifest.players = players;
129
+ }
130
+
131
+ if (usePointer) {
132
+ manifest.pointer = true;
133
+ }
134
+
135
+ if (useKeyboard) {
136
+ manifest.keyboard = true;
137
+ }
138
+
139
+ if (netWebsocket || netDataChannel) {
140
+ manifest.net = {};
141
+ if (netWebsocket) manifest.net.websocket = netWebsocket;
142
+ if (netDataChannel) manifest.net['data-channel'] = true;
143
+ }
144
+
145
+ if (assetsDir) {
146
+ manifest.assets = 'assets/';
147
+ }
148
+
149
+ // Collect asset files
150
+ function walkDir(dir, base) {
151
+ const files = [];
152
+ const entries = readdirSync(dir);
153
+
154
+ for (const entry of entries) {
155
+ if (entry.startsWith('.')) continue;
156
+ const fullPath = join(dir, entry);
157
+ const relPath = join(base, entry);
158
+
159
+ try {
160
+ const s = statSync(fullPath);
161
+ if (s.isDirectory()) {
162
+ files.push(...walkDir(fullPath, relPath));
163
+ } else if (s.isFile()) {
164
+ files.push({ fullPath, relPath: relPath.replace(/\\/g, '/') });
165
+ }
166
+ } catch {
167
+ // Skip inaccessible files
168
+ }
169
+ }
170
+
171
+ return files;
172
+ }
173
+
174
+ // Create ZIP
175
+ const zipfile = new ZipFile();
176
+
177
+ // Add manifest.json
178
+ const manifestJson = JSON.stringify(manifest, null, 2);
179
+ zipfile.addBuffer(Buffer.from(manifestJson), 'manifest.json');
180
+
181
+ // Add cart.wasm (store without compression - wasm is already compact)
182
+ zipfile.addFile(wasmPath, 'cart.wasm', { compress: false });
183
+
184
+ // Add asset files
185
+ let assetCount = 0;
186
+ let assetBytes = 0;
187
+
188
+ if (assetsDir) {
189
+ try {
190
+ statSync(assetsDir);
191
+ } catch {
192
+ console.error(`Error: Assets directory not found: ${assetsDir}`);
193
+ process.exit(1);
194
+ }
195
+
196
+ const assetFiles = walkDir(assetsDir, '');
197
+ for (const { fullPath, relPath } of assetFiles) {
198
+ const zipPath = 'assets/' + relPath;
199
+ zipfile.addFile(fullPath, zipPath);
200
+ const s = statSync(fullPath);
201
+ assetCount++;
202
+ assetBytes += s.size;
203
+ }
204
+ }
205
+
206
+ // Write output
207
+ const outputStream = createWriteStream(outputPath);
208
+ zipfile.outputStream.pipe(outputStream);
209
+
210
+ outputStream.on('close', () => {
211
+ const outSize = statSync(outputPath).size;
212
+ const wasmSize = statSync(wasmPath).size;
213
+
214
+ console.log(`Created: ${outputPath}`);
215
+ console.log(` WASM: ${formatSize(wasmSize)}`);
216
+ if (assetCount > 0) {
217
+ console.log(` Assets: ${assetCount} files, ${formatSize(assetBytes)} (uncompressed)`);
218
+ }
219
+ console.log(` Total: ${formatSize(outSize)}`);
220
+ });
221
+
222
+ zipfile.end();
223
+
224
+ function formatSize(bytes) {
225
+ if (bytes < 1024) return `${bytes} B`;
226
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
227
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
228
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
229
+ }
230
+
231
+ function printUsage() {
232
+ console.log(`wasmcart-pack - Package a .wasm cart + assets into a .wasc archive`);
233
+ console.log(``);
234
+ console.log(`Usage: wasmcart-pack --wasm <cart.wasm> [--assets <dir>] --output <game.wasc>`);
235
+ console.log(``);
236
+ console.log(`Options:`);
237
+ console.log(` --wasm <path> Path to the compiled cart.wasm file (required)`);
238
+ console.log(` --assets <dir> Directory of assets to include`);
239
+ console.log(` --output, -o Output .wasc file path (default: <name>.wasc)`);
240
+ console.log(` --name <name> Game name for manifest (default: wasm filename)`);
241
+ console.log(` --version <ver> Game version for manifest (default: 1.0.0)`);
242
+ console.log(` --players <n> Number of local players (1-4, default: 1)`);
243
+ console.log(` --ws <domain> Allow WebSocket to domain (repeatable)`);
244
+ console.log(` --data-channel Enable data channel (peer-to-peer)`);
245
+ console.log(` --pointer Enable pointer input (mouse/touch)`);
246
+ console.log(` --keyboard Enable raw keyboard input`);
247
+ console.log(` -h, --help Show this help`);
248
+ console.log(``);
249
+ console.log(`The .wasc file is a ZIP archive containing:`);
250
+ console.log(` manifest.json Metadata (name, version, ABI, entry point)`);
251
+ console.log(` cart.wasm The compiled cart (code only)`);
252
+ console.log(` assets/ Game assets (textures, levels, audio, etc.)`);
253
+ console.log(``);
254
+ console.log(`Examples:`);
255
+ console.log(` wasmcart-pack --wasm build/cart.wasm --assets assets/ -o game.wasc`);
256
+ console.log(` wasmcart-pack --wasm hello.wasm -o hello.wasc`);
257
+ }
@@ -0,0 +1,275 @@
1
+ # glBindFramebuffer(target, 0) - Host FBO Redirect
2
+
3
+ ## The Rule
4
+
5
+ When a cart calls `glBindFramebuffer(GL_FRAMEBUFFER, 0)`, the host MUST redirect this to its display FBO. The cart uses FBO 0 to mean "the screen." The host decides what "the screen" actually is.
6
+
7
+ ## Why This Matters
8
+
9
+ Carts that use Skia Ganesh GL (wasmcart-jsgame's Canvas 2D) render to an offscreen FBO, then blit to FBO 0:
10
+
11
+ ```
12
+ Game draws → Ganesh offscreen FBO (e.g. FBO 2)
13
+
14
+ glBlitFramebuffer(FBO 2 → FBO 0)
15
+
16
+ Host reads display FBO → screen
17
+ ```
18
+
19
+ If the host doesn't intercept `glBindFramebuffer(target, 0)`, the blit goes to the real default framebuffer (FBO 0), which may not be what the host reads.
20
+
21
+ ## How Each Host Handles It
22
+
23
+ ### Browser (CartHostWeb) - Works
24
+
25
+ WebGL2's `gl.bindFramebuffer(target, null)` binds the canvas backbuffer. The cart calls `glBindFramebuffer(target, 0)`, the host maps ID 0 → `null`, which IS the canvas. The browser composites the canvas to the page. No redirect needed.
26
+
27
+ ### Node.js (retroemu cli.js) - Works
28
+
29
+ The Node host creates an offscreen FBO (`glFBO`) with a color texture + depth/stencil renderbuffer. It intercepts `glBindFramebuffer`:
30
+
31
+ ```javascript
32
+ gl.glBindFramebuffer = (target, fb) => {
33
+ const actual = fb === 0 ? glFBO : fb;
34
+ _origBindFB.call(gl, target, actual);
35
+ };
36
+ ```
37
+
38
+ When the cart binds FBO 0, the host redirects to `glFBO`. After `wc_render()`, the host blits from `glFBO` to the window surface via `glBlitFramebuffer`, then calls `swapBuffers`.
39
+
40
+ ### wasmcart-native (standalone) - Works (redirect FBO)
41
+
42
+ The native host creates a redirect FBO via `wc_gl_setup_redirect()`. `gl_imports.cpp` intercepts `glBindFramebuffer(target, 0)` → redirect FBO. After `wc_render()`, `wc_gl_blit_to_screen()` blits redirect FBO → real FBO 0 (EGL window surface) with letterboxing, then calls `eglSwapBuffers`.
43
+
44
+ ### RetroArch (libretro) - WORKS (redirect FBO)
45
+
46
+ RetroArch uses `hw_render` which gives the core a specific FBO via `get_current_framebuffer()`. The wasmcart libretro core uses the shared `gl_imports.cpp` FBO redirect (same code as wasmcart-native via git submodule). When the cart binds FBO 0, the redirect intercepts it to our capture FBO. After `wc_render()`, `libretro.c` blits from the capture FBO → RetroArch's hw_render FBO.
47
+
48
+ **NOTE (2026-03-31): The FBO redirect works correctly. Verified by readPixels:**
49
+
50
+ ```
51
+ Ganesh FBO center pixel: R=252 G=216 B=168 A=255 ← game content in Ganesh's offscreen FBO
52
+ Blit TARGET center pixel: R=252 G=216 B=168 A=255 ← same content in redirect FBO after blit
53
+ ```
54
+
55
+ The cart's blit delivers full game content to FBO 0 (redirect). The content is verified
56
+ present in the redirect FBO. But RetroArch displays only the background.
57
+
58
+ This means the issue is in the HOST's final blit from redirect FBO → RetroArch's
59
+ hw_render FBO (or RetroArch's compositing of the hw_render FBO to screen).
60
+
61
+ **Debugging done on cart side:**
62
+ - Shaders: `#version 300 es` accepted by Mesa Core 3.3 (GL_ARB_ES3_compatibility) - no patching needed
63
+ - Shader compile: all pass
64
+ - Program link: all pass
65
+ - GL errors: none
66
+ - Ganesh FBO readPixels: full game content present
67
+ - Redirect FBO readPixels AFTER blit: full game content present
68
+ - `glBlitFramebuffer` from Ganesh FBO → FBO 0: works (content verified in target)
69
+
70
+ **What to check on host side (wasmcart-native gl_imports.cpp / libretro.c):**
71
+ 1. Does `wc_gl_blit_to_screen()` correctly blit redirect FBO → hw_render FBO?
72
+ 2. Is the blit Y-flipped? The redirect FBO content is kTopLeft (Y=0 at top, Skia convention).
73
+ The hw_render FBO might expect kBottomLeft (Y=0 at bottom, GL convention). If so, the
74
+ blit needs to flip Y: `glBlitFramebuffer(0, H, W, 0, 0, 0, W, H, ...)`.
75
+ 3. Is the blit happening AFTER wc_render returns? The redirect FBO is populated during
76
+ wc_render. If the host blits before wc_render completes, it reads stale content.
77
+ 4. Does RetroArch's hw_render FBO have the correct format (RGBA8, same as redirect)?
78
+ 5. Is the redirect FBO being cleared BEFORE the blit reads from it? Check for glClear
79
+ calls between wc_render return and the redirect→hw_render blit.
80
+
81
+ **UPDATE 2 (2026-03-31): CONFIRMED HOST-SIDE ISSUE**
82
+
83
+ Tested with BOTH GPU (Ganesh) AND CPU (Skia raster + GL blit) rendering.
84
+ Both produce correct game content in the redirect FBO. Host-side logging confirms:
85
+
86
+ ```
87
+ Canvas 2D: CPU (Skia raster + GL blit) ← CPU fallback, no Ganesh
88
+ [pre-blit] rfbo=2 ra_fbo=1 blit=1920x1080
89
+ center=(252,216,168,255) ← game tile color, correct
90
+ quarter=(252,216,168,255) ← also correct
91
+ ```
92
+
93
+ The redirect FBO (rfbo=2) has full game content. RetroArch's FBO (ra_fbo=1) is the
94
+ blit target. The host's blit from rfbo→ra_fbo is not producing visible output.
95
+
96
+ This is NOT a cart-side issue. NOT a Ganesh issue. NOT a shader issue. The cart
97
+ delivers correct pixels to FBO 0 (redirect) via both GPU and CPU paths. The host's
98
+ final blit from redirect FBO → RetroArch's hw_render FBO is broken.
99
+
100
+ **UPDATE 3 (2026-03-31): GL CALL TRACES PROVE HOST ISSUE**
101
+
102
+ Captured full GL call traces on Node (working) and RetroArch (failing). The call
103
+ sequences are FUNCTIONALLY IDENTICAL:
104
+
105
+ ```
106
+ Node frame 5: RetroArch frame 5:
107
+ glBindFramebuffer(0x8d40, 1) glBindFramebuffer(0x8d40, 3)
108
+ glViewport(0, 0, 800, 600) glViewport(0, 0, 1920, 1080)
109
+ glClearColor(0.99, 0.85, 0.66, 1.0) glClearColor(0.99, 0.85, 0.66, 1.0)
110
+ glClear(0x4000) glClear(0x4000)
111
+ glUseProgram(1) glUseProgram(28)
112
+ glDrawArrays(0x5, 0, 4) glDrawArrays(0x5, 0, 4)
113
+ glUseProgram(6) ← textured glUseProgram(43) ← textured
114
+ glActiveTexture(0x84c0) glActiveTexture(0x84c0)
115
+ glBindTexture(0xde1, 13) glBindTexture(0xde1, 192)
116
+ glDrawArrays(0x5, 0, 4) glDrawArrays(0x5, 0, 4)
117
+ ... identical pattern continues ... ... identical pattern continues ...
118
+ ```
119
+
120
+ Same GL calls, same order, same patterns. Only resource IDs differ (expected).
121
+ The cart is doing everything correctly. The HOST's gl_imports.cpp virtual ID
122
+ mapping must be breaking texture or FBO mapping on the RetroArch path.
123
+
124
+ **UPDATE 4: FULL GL CALL TRACE COMPARISON (2026-03-31)**
125
+
126
+ Captured 694 GL calls (Node, working) vs 762 calls (RetroArch, failing).
127
+ The call sequences are FUNCTIONALLY IDENTICAL:
128
+ - Same FBO binds (Node: FBO 1, RetroArch: FBO 3)
129
+ - Same texture uploads (glTexSubImage2D with real data)
130
+ - Same program binds (textured program + solid program)
131
+ - Same draw calls (glDrawArrays with same vertex counts/offsets)
132
+ - Same sampler binds (glBindSampler unit 0)
133
+ - Same blend/stencil/scissor state
134
+ - Same clear color (0.99, 0.85, 0.66 = game background)
135
+
136
+ BUT: RetroArch Ganesh FBO reads as ALL background color at frame 7.
137
+ The 67 textured glDrawArrays calls produce ZERO visible fragments.
138
+
139
+ **This is proven to be a HOST issue, not a cart issue.** The cart sends
140
+ identical GL commands. The host's gl_imports.cpp must be:
141
+ 1. Mapping texture virtual IDs to wrong real GL textures, OR
142
+ 2. Mapping FBO virtual ID to wrong real GL FBO, OR
143
+ 3. RetroArch is overwriting our GL objects between frames
144
+
145
+ The other agent needs to add real GL-level inspection (apitrace/renderdoc)
146
+ to see what ACTUAL GL calls reach the driver after the host's ID translation.
147
+
148
+ **UPDATE 5 (2026-03-31): GANESH WORKS ON RETROARCH!**
149
+
150
+ The other agent's gl_imports.cpp fix resolved the Ganesh rendering issue.
151
+ Adventure-ai renders fully with GPU-accelerated Canvas 2D on RetroArch.
152
+
153
+ **BUT: Three.js (direct WebGL, no Ganesh) is now broken on RetroArch.**
154
+
155
+ The gl_imports.cpp change that fixed Ganesh broke the direct WebGL path.
156
+ The fix MUST work for ALL carts - the host cannot know whether a cart uses
157
+ Ganesh or direct WebGL. The host is a transparent GL passthrough.
158
+
159
+ **The fix must be unconditionally correct.** Whatever GL behavior was changed
160
+ to fix Ganesh must ALSO be correct for direct WebGL carts. If the change
161
+ altered how a specific GL function works (e.g., internal format translation,
162
+ FBO binding, texture storage), find the version that works for BOTH:
163
+
164
+ 1. Identify exactly which gl_imports.cpp function was changed
165
+ 2. Test that function's behavior against both adventure-ai (Ganesh) AND
166
+ threejs (direct WebGL) AND Godot (direct GL)
167
+ 3. The correct behavior is whatever real GLES 3.0 / GL 3.3 does - the host
168
+ should be a transparent passthrough, not cart-specific
169
+
170
+ If the fix involved working around a Ganesh quirk by changing GL behavior,
171
+ that's wrong. The host should pass GL calls through unchanged. The cart
172
+ is responsible for generating correct GL calls - and we proved the cart's
173
+ GL calls are identical between working (Node) and failing (RetroArch) hosts.
174
+
175
+ **Most likely cause:** Y-flip mismatch. The cart's blit to redirect flips Y (Skia
176
+ top-down → GL bottom-up). If the host's blit from redirect→hw_render ALSO flips Y,
177
+ the image is double-flipped. The host should do a 1:1 copy (no flip) since the cart
178
+ already handled the flip. OR: check glBlitFramebuffer src/dst coords in libretro.c.
179
+
180
+ ## The FBO ID Collision Problem
181
+
182
+ On RetroArch, the hw_render FBO might have the same GL ID as Ganesh's offscreen FBO. This happens because:
183
+
184
+ 1. RetroArch binds its hw_render FBO (e.g. FBO 2) before calling `retro_run()`
185
+ 2. During `wc_render()`, Ganesh creates its offscreen render target via `SkSurfaces::RenderTarget`
186
+ 3. The wasmcart host's `glGenFramebuffers` goes through the host's GL import table
187
+ 4. If the host uses virtual ID mapping (like webgl_imports.js), Ganesh might get a virtual ID that collides with the host's FBO virtual ID
188
+ 5. If the host passes through raw GL IDs, Ganesh gets a NEW real FBO (e.g. FBO 3) - no collision
189
+
190
+ On RetroArch, the wasmcart libretro core appears to use raw GL IDs (no virtual mapping). Ganesh calls `glGenFramebuffers` and gets a new FBO. But from the cart's perspective (via the host's GL imports), both the host's FBO and Ganesh's FBO report as ID 2. This suggests the host IS using virtual ID mapping, and both got virtual ID 2.
191
+
192
+ Either way: the cart blits to FBO 0. If the host redirects 0 → its display FBO, it works. If it doesn't redirect, the content goes to the wrong place.
193
+
194
+ ## Fix for RetroArch (wasmcart_libretro.so)
195
+
196
+ The libretro core needs to intercept `glBindFramebuffer` and redirect FBO 0 to the hw_render FBO:
197
+
198
+ ```c
199
+ // In gl_imports.cpp or wherever GL imports are provided:
200
+
201
+ static GLuint _hw_render_fbo = 0;
202
+
203
+ // Called each frame before wc_render():
204
+ void set_hw_render_fbo(GLuint fbo) {
205
+ _hw_render_fbo = fbo;
206
+ }
207
+
208
+ // GL import provided to the cart:
209
+ void gl_glBindFramebuffer(GLenum target, GLuint framebuffer) {
210
+ GLuint actual = (framebuffer == 0 && _hw_render_fbo != 0) ? _hw_render_fbo : framebuffer;
211
+ glBindFramebuffer(target, actual);
212
+ }
213
+ ```
214
+
215
+ And in `retro_run()`:
216
+ ```c
217
+ void retro_run(void) {
218
+ // Get RetroArch's hw_render FBO for this frame
219
+ GLuint fbo = hw_render.get_current_framebuffer();
220
+ set_hw_render_fbo(fbo);
221
+
222
+ // Run the cart frame
223
+ wc_render();
224
+
225
+ // RetroArch reads from fbo after we return
226
+ }
227
+ ```
228
+
229
+ This is the same pattern as the Node host's FBO redirect. The cart always blits to FBO 0 meaning "the screen." The host decides where "the screen" is.
230
+
231
+ ## Why This Affects Ganesh Specifically
232
+
233
+ Other GL carts (Godot, OpenArena) render directly to whatever FBO the host has bound. They don't create their own offscreen FBOs and blit. They call `glClear` + `glDraw*` and the host sees the results in its FBO.
234
+
235
+ Ganesh is different because it creates its OWN offscreen FBO (for stencil support needed by Canvas 2D path rendering). It renders there, then needs to copy the result to the display. The copy goes to FBO 0, which must be the display.
236
+
237
+ ## Cart-Side Principle
238
+
239
+ The cart MUST blit to FBO 0 to mean "the display." The cart MUST NOT try to detect or target a specific host FBO by ID. The host is responsible for making FBO 0 the correct target.
240
+
241
+ This matches the WebGL2 convention: `gl.bindFramebuffer(target, null)` always means the canvas. And the wasmcart spec: the host is a transparent ES 3.0 surface.
242
+
243
+ ## UPDATE 6 (2026-03-31): Direct FBO redirect REVERTED - depth/stencil required
244
+
245
+ Setting RetroArch's hw_render FBO as the redirect target directly
246
+ (`wc_gl_set_redirect_fbo`) broke ALL GL carts - not just Ganesh. Three.js
247
+ and OpenArena also stopped rendering.
248
+
249
+ **Root cause:** RetroArch's hw_render FBO does not have depth+stencil
250
+ attachments. Our `wc_gl_setup_redirect` creates an FBO with:
251
+ - Color texture (RGBA8)
252
+ - Depth24+Stencil8 renderbuffer
253
+
254
+ Three.js needs depth testing. Ganesh needs stencil for path rendering.
255
+ OpenArena needs both. Without these attachments, 3D rendering fails.
256
+
257
+ **Current architecture (restored):**
258
+
259
+ ```
260
+ Cart draws → redirect FBO (ours, with depth+stencil)
261
+ ↓ wc_gl_blit_to_fbo
262
+ RetroArch hw_render FBO (color only)
263
+ ↓ RetroArch composites
264
+ Screen
265
+ ```
266
+
267
+ The intermediate blit is required because RetroArch's FBO lacks depth/stencil.
268
+
269
+ **Ganesh on RetroArch remains broken** (shows only background). The fix is
270
+ cart-side: Ganesh binds VAO 0 which is undefined on Core 3.3 Profile. The
271
+ cart's `wc_gl_get_proc` must redirect `glBindVertexArray(0)` to a real VAO.
272
+ See `wasmcart-jsgame/ganesh.md` UPDATE 3.
273
+
274
+ **Working on RetroArch:** Three.js, OpenArena, Snake, Warlords, Roboblast
275
+ **Broken on RetroArch:** Adventure-ai (Ganesh VAO 0 - cart-side fix needed)
package/docs/fetch.md ADDED
@@ -0,0 +1,105 @@
1
+ # wc_fetch - Network Fetch ABI
2
+
3
+ ## Motivation
4
+
5
+ Carts currently load local assets via `wc_load_asset` (synchronous, from .wasc bundle). There is no mechanism for carts to make HTTP requests to external servers. Games need this for:
6
+
7
+ - Loading level data or assets from a CDN
8
+ - Posting high scores / leaderboards
9
+ - User authentication
10
+ - Dynamic content updates
11
+ - Analytics
12
+
13
+ The WebSocket ABI (`wc_ws_*`) exists but is wrong for request/response patterns. A proper fetch ABI gives carts standard HTTP semantics.
14
+
15
+ ## Design
16
+
17
+ ### Manifest Allowlist
18
+
19
+ The .wasc `manifest.json` declares which domains the cart may fetch from:
20
+
21
+ ```json
22
+ {
23
+ "net": {
24
+ "websocket": ["wss://game-server.example.com"],
25
+ "fetch": ["https://api.example.com", "https://cdn.example.com"]
26
+ }
27
+ }
28
+ ```
29
+
30
+ The host validates every fetch URL against this allowlist before proxying. Domains not in the list get a 403 response. This is the security boundary - carts cannot make arbitrary network requests.
31
+
32
+ ### URL Routing (Host Logic)
33
+
34
+ The host handles ALL fetch requests - both local and network:
35
+
36
+ 1. **Relative path** (e.g. `sounds/laser.mp3`) → load from .wasc bundle. Same as current `wc_load_asset` but through the fetch ABI.
37
+ 2. **Absolute URL on allowlist** (e.g. `https://api.example.com/scores`) → host proxies the request using native fetch (Node.js `fetch`, browser `fetch`).
38
+ 3. **Absolute URL NOT on allowlist** → return 403 Forbidden.
39
+ 4. **Absolute URL, no `net.fetch` in manifest** → return 403.
40
+
41
+ The cart never knows or cares whether the response came from the bundle or the network. This is transparent.
42
+
43
+ ### Browser Hosts and CORS
44
+
45
+ In browser-based wasmcart hosts (e.g. a web page running a .wasc), the host's `fetch` call is subject to standard CORS rules. The API server must return `Access-Control-Allow-Origin` headers. This is the server's responsibility, not the cart's or the host's.
46
+
47
+ In Node.js hosts, there are no CORS restrictions - the host fetches directly.
48
+
49
+ ### Proposed ABI
50
+
51
+ Fetch is inherently async. The wasmcart ABI is synchronous (wc_render is called per frame). Two approaches:
52
+
53
+ #### Option A: Callback-based (like WebSocket)
54
+
55
+ ```c
56
+ // Cart imports (host provides):
57
+ int wc_fetch_start(const char* url, uint32_t url_len,
58
+ const char* method, uint32_t method_len,
59
+ const char* headers, uint32_t headers_len,
60
+ const void* body, uint32_t body_len);
61
+ // Returns request_id, or -1 if URL not allowed.
62
+
63
+ int wc_fetch_state(int request_id);
64
+ // Returns: 0=pending, 1=complete, 2=error
65
+
66
+ int wc_fetch_response_status(int request_id);
67
+ // Returns HTTP status code (200, 404, etc.)
68
+
69
+ int wc_fetch_response_size(int request_id);
70
+ // Returns response body size in bytes, or -1 if not complete.
71
+
72
+ int wc_fetch_response_read(int request_id, void* buf, uint32_t buf_len);
73
+ // Copies response body into buf. Returns bytes copied.
74
+
75
+ void wc_fetch_response_free(int request_id);
76
+ // Frees the response. Cart must call this when done.
77
+
78
+ // Cart exports (host calls when response arrives):
79
+ void wc_fetch_on_complete(int request_id);
80
+ void wc_fetch_on_error(int request_id);
81
+ ```
82
+
83
+ The host calls `wc_fetch_on_complete` during the next `wc_render` frame after the response arrives. The cart then reads the response synchronously.
84
+
85
+ #### Option B: Polling (simpler)
86
+
87
+ Same imports as above but without the callback exports. Cart polls `wc_fetch_state` each frame. Simpler but adds one frame of latency.
88
+
89
+ ### Recommendation
90
+
91
+ Option A (callback-based) matches the WebSocket pattern and is lower latency. The host already calls into the cart during `wc_render` (for WebSocket messages), so the infrastructure exists.
92
+
93
+ ### Impact on wasmcart-jsgame
94
+
95
+ The jsgame cart currently has its own C-level fetch implementation that calls `wc_load_asset` for relative paths and fails on absolute URLs. With `wc_fetch`:
96
+
97
+ 1. **Remove C-level URL routing** - pass ALL fetch URLs to the host via `wc_fetch_start`
98
+ 2. **Host handles routing** - relative paths → .wasc bundle, absolute → network proxy
99
+ 3. **Simpler cart code** - no URL parsing, no asset loading logic in the cart
100
+ 4. **Games get network access** - `fetch('https://api.example.com/scores')` just works (if manifest allows it)
101
+ 5. **Same security model** - host enforces allowlist, cart can't bypass it
102
+
103
+ ### Impact on existing carts
104
+
105
+ Existing carts that only use `wc_load_asset` continue working. `wc_fetch` is an additional import - carts that don't import it aren't affected. Hosts that don't implement it provide stubs (return -1 from `wc_fetch_start`).