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,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`).
|