diff --git a/kicad b/kicad
index c2e0545..d6e3dc1 160000
--- a/kicad
+++ b/kicad
@@ -1 +1 @@
-Subproject commit c2e0545e4745d38eddc98da122dcf7c147fbf610
+Subproject commit d6e3dc1a87a0565fc5cfab55d42420e72fc48f87
diff --git a/tests/3d-regression/wasm/3d_webgl_test.cpp b/tests/3d-regression/wasm/3d_webgl_test.cpp
index 530f5b7..a4f8c2d 100644
--- a/tests/3d-regression/wasm/3d_webgl_test.cpp
+++ b/tests/3d-regression/wasm/3d_webgl_test.cpp
@@ -109,6 +109,172 @@ int runScenario( int aIndex )
return 0;
}
+// ---------------------------------------------------------------------------
+// Engine-toggle regression scaffolding: model a "modern-GL consumer" (the
+// raytracer blit / the 2D GAL) sharing the process with the FFP shim.
+// ---------------------------------------------------------------------------
+
+// A minimal GLSL quad drawer with a PERSISTENT VAO (the victim). Mirrors the
+// raytracer blit: attribute location 0 (colliding with the shim's
+// ATTR_POSITION), own VBO, own program.
+static GLuint g_appVAO = 0, g_appVBO = 0, g_appProg = 0;
+
+static GLuint compileMini( GLenum type, const char* src )
+{
+ GLuint s = glCreateShader( type );
+ glShaderSource( s, 1, &src, nullptr );
+ glCompileShader( s );
+ return s;
+}
+
+extern "C"
+{
+
+// (Re)build the persistent app quad in the CURRENT context. Green full-screen
+// quad on attribute 0. Returns 0 on success.
+EMSCRIPTEN_KEEPALIVE
+int appQuadInit()
+{
+ static const char* VS = "#version 300 es\nlayout(location=0) in vec2 p;"
+ "void main(){ gl_Position = vec4(p,0.,1.); }";
+ static const char* FS = "#version 300 es\nprecision mediump float;"
+ "out vec4 c; void main(){ c = vec4(0.,1.,0.,1.); }";
+
+ GLuint vs = compileMini( GL_VERTEX_SHADER, VS );
+ GLuint fs = compileMini( GL_FRAGMENT_SHADER, FS );
+ GLuint prog = glCreateProgram();
+ glAttachShader( prog, vs );
+ glAttachShader( prog, fs );
+ glLinkProgram( prog );
+ glDeleteShader( vs );
+ glDeleteShader( fs );
+
+ GLint linked = GL_FALSE;
+ glGetProgramiv( prog, GL_LINK_STATUS, &linked );
+ if( !linked )
+ return -1;
+
+ static const float quad[] = { -1.f, -1.f, 1.f, -1.f, -1.f, 1.f,
+ -1.f, 1.f, 1.f, -1.f, 1.f, 1.f };
+ g_appProg = prog;
+ glGenVertexArrays( 1, &g_appVAO );
+ glBindVertexArray( g_appVAO );
+ glGenBuffers( 1, &g_appVBO );
+ glBindBuffer( GL_ARRAY_BUFFER, g_appVBO );
+ glBufferData( GL_ARRAY_BUFFER, sizeof( quad ), quad, GL_STATIC_DRAW );
+ glEnableVertexAttribArray( 0 );
+ glVertexAttribPointer( 0, 2, GL_FLOAT, GL_FALSE, 0, nullptr );
+ glBindVertexArray( 0 );
+ glBindBuffer( GL_ARRAY_BUFFER, 0 );
+ return (int) glGetError();
+}
+
+// Draw the persistent app quad exactly like the blit does: bind ITS VAO, its
+// program, glDrawArrays. aClearFirst=1 clears to black before drawing so the
+// framebuffer afterwards shows only this draw's output. Returns glGetError.
+EMSCRIPTEN_KEEPALIVE
+int appQuadDraw( int aClearFirst )
+{
+ while( glGetError() != GL_NO_ERROR ) {}
+ if( aClearFirst )
+ {
+ glViewport( 0, 0, CAPTURE_WIDTH, CAPTURE_HEIGHT );
+ glClearColor( 0.f, 0.f, 0.f, 1.f );
+ glClear( GL_COLOR_BUFFER_BIT );
+ }
+ glUseProgram( g_appProg );
+ glBindVertexArray( g_appVAO );
+ glDrawArrays( GL_TRIANGLES, 0, 6 );
+ glBindVertexArray( 0 );
+ glUseProgram( 0 );
+ glFinish();
+ return (int) glGetError();
+}
+
+// Leave the FFP mirror in the poisoned shape the engine switch can produce:
+// GL_VERTEX_ARRAY enabled with a (small) VBO captured by glVertexPointer —
+// the MODEL_3D::BeginDrawMulti window state.
+EMSCRIPTEN_KEEPALIVE
+void ffpMakeStale()
+{
+ static GLuint tinyVBO = 0;
+ static const float tri[] = { 0.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 1.f, 0.f };
+ if( !tinyVBO )
+ {
+ glGenBuffers( 1, &tinyVBO );
+ glBindBuffer( GL_ARRAY_BUFFER, tinyVBO );
+ glBufferData( GL_ARRAY_BUFFER, sizeof( tri ), tri, GL_STATIC_DRAW );
+ }
+ else
+ glBindBuffer( GL_ARRAY_BUFFER, tinyVBO );
+ glVertexPointer( 3, GL_FLOAT, 0, nullptr ); // captures tinyVBO in the mirror
+ glEnableClientState( GL_VERTEX_ARRAY );
+ glBindBuffer( GL_ARRAY_BUFFER, 0 );
+}
+
+EMSCRIPTEN_KEEPALIVE
+void ffpClearStale()
+{
+ glDisableClientState( GL_VERTEX_ARRAY );
+}
+
+// A second live WebGL context on its own canvas — the harness model of the 2D
+// editor's GAL context coexisting with the 3D viewer's.
+static EMSCRIPTEN_WEBGL_CONTEXT_HANDLE g_context2 = 0;
+
+EMSCRIPTEN_KEEPALIVE
+int createSecondContext()
+{
+ EM_ASM( {
+ if( !document.getElementById( "canvas2" ) )
+ {
+ var c = document.createElement( "canvas" );
+ c.id = "canvas2";
+ c.width = 320; c.height = 240;
+ document.body.appendChild( c );
+ if( typeof specialHTMLTargets !== 'undefined' )
+ specialHTMLTargets['#canvas2'] = c;
+ }
+ } );
+
+ EmscriptenWebGLContextAttributes attrs;
+ emscripten_webgl_init_context_attributes( &attrs );
+ attrs.majorVersion = 2;
+ attrs.minorVersion = 0;
+ attrs.preserveDrawingBuffer = true;
+ g_context2 = emscripten_webgl_create_context( "#canvas2", &attrs );
+ return g_context2 > 0 ? 0 : -1;
+}
+
+// Switch the current context: 1 = the main harness context, 2 = the second.
+EMSCRIPTEN_KEEPALIVE
+int useContext( int aWhich )
+{
+ EMSCRIPTEN_WEBGL_CONTEXT_HANDLE h = ( aWhich == 2 ) ? g_context2 : g_context;
+ if( h <= 0 )
+ return -1;
+ return emscripten_webgl_make_context_current( h ) == EMSCRIPTEN_RESULT_SUCCESS ? 0 : -2;
+}
+
+// Build + draw + delete a fresh GLSL quad entirely in the CURRENT context (no
+// persistent state — safe under any context). Returns glGetError after draw.
+EMSCRIPTEN_KEEPALIVE
+int quadDrawFresh()
+{
+ GLuint savedVAO = g_appVAO, savedVBO = g_appVBO, savedProg = g_appProg;
+ g_appVAO = g_appVBO = g_appProg = 0;
+ int rc = appQuadInit();
+ if( rc == 0 )
+ rc = appQuadDraw( 1 );
+ glDeleteVertexArrays( 1, &g_appVAO );
+ glDeleteBuffers( 1, &g_appVBO );
+ glDeleteProgram( g_appProg );
+ g_appVAO = savedVAO; g_appVBO = savedVBO; g_appProg = savedProg;
+ return rc;
+}
+
+} // extern "C"
+
// Destroy the current WebGL context and mint a fresh one on a fresh canvas
// element — the harness model of the app's 3D-viewer close/reopen (~wxGLCanvas
// destroys its context AND its DOM canvas; a new frame creates new ones).
diff --git a/tests/3d-regression/wasm/3d_webgl_test.html b/tests/3d-regression/wasm/3d_webgl_test.html
index 63b70d0..7a0e293 100644
--- a/tests/3d-regression/wasm/3d_webgl_test.html
+++ b/tests/3d-regression/wasm/3d_webgl_test.html
@@ -24,6 +24,13 @@
getCanvasWidth: () => wasmModule.ccall('getCanvasWidth', 'number', [], []),
getCanvasHeight: () => wasmModule.ccall('getCanvasHeight', 'number', [], []),
recreateContext: () => wasmModule.ccall('recreateContext', 'number', [], []),
+ appQuadInit: () => wasmModule.ccall('appQuadInit', 'number', [], []),
+ appQuadDraw: (clear) => wasmModule.ccall('appQuadDraw', 'number', ['number'], [clear]),
+ ffpMakeStale: () => wasmModule.ccall('ffpMakeStale', null, [], []),
+ ffpClearStale: () => wasmModule.ccall('ffpClearStale', null, [], []),
+ createSecondContext: () => wasmModule.ccall('createSecondContext', 'number', [], []),
+ useContext: (n) => wasmModule.ccall('useContext', 'number', ['number'], [n]),
+ quadDrawFresh: () => wasmModule.ccall('quadDrawFresh', 'number', [], []),
};
diff --git a/tests/3d-regression/wasm/Makefile b/tests/3d-regression/wasm/Makefile
index 339cfc1..9af2843 100644
--- a/tests/3d-regression/wasm/Makefile
+++ b/tests/3d-regression/wasm/Makefile
@@ -69,7 +69,7 @@ CFLAGS = $(OPT_FLAGS) $(DEPS_EH_FLAGS) -I$(PROJECT_ROOT)/wasm/stubs
BASE_LDFLAGS = $(DEPS_EH_FLAGS) \
-sALLOW_MEMORY_GROWTH=1 \
-sERROR_ON_UNDEFINED_SYMBOLS=0 \
- -sEXPORTED_FUNCTIONS=['_main','_runScenario','_getTotalScenarios','_getScenarioName','_getCanvasWidth','_getCanvasHeight','_recreateContext'] \
+ -sEXPORTED_FUNCTIONS=['_main','_runScenario','_getTotalScenarios','_getScenarioName','_getCanvasWidth','_getCanvasHeight','_recreateContext','_appQuadInit','_appQuadDraw','_ffpMakeStale','_ffpClearStale','_createSecondContext','_useContext','_quadDrawFresh'] \
-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap'] \
-sMODULARIZE=1 \
-sEXPORT_NAME='create3DTest' \
diff --git a/tests/apps/3d-webgl/3d_webgl_test.html b/tests/apps/3d-webgl/3d_webgl_test.html
index 63b70d0..7a0e293 100644
--- a/tests/apps/3d-webgl/3d_webgl_test.html
+++ b/tests/apps/3d-webgl/3d_webgl_test.html
@@ -24,6 +24,13 @@
getCanvasWidth: () => wasmModule.ccall('getCanvasWidth', 'number', [], []),
getCanvasHeight: () => wasmModule.ccall('getCanvasHeight', 'number', [], []),
recreateContext: () => wasmModule.ccall('recreateContext', 'number', [], []),
+ appQuadInit: () => wasmModule.ccall('appQuadInit', 'number', [], []),
+ appQuadDraw: (clear) => wasmModule.ccall('appQuadDraw', 'number', ['number'], [clear]),
+ ffpMakeStale: () => wasmModule.ccall('ffpMakeStale', null, [], []),
+ ffpClearStale: () => wasmModule.ccall('ffpClearStale', null, [], []),
+ createSecondContext: () => wasmModule.ccall('createSecondContext', 'number', [], []),
+ useContext: (n) => wasmModule.ccall('useContext', 'number', ['number'], [n]),
+ quadDrawFresh: () => wasmModule.ccall('quadDrawFresh', 'number', [], []),
};
diff --git a/tests/e2e/3d-webgl.spec.ts b/tests/e2e/3d-webgl.spec.ts
index 3d90e48..6ff14af 100644
--- a/tests/e2e/3d-webgl.spec.ts
+++ b/tests/e2e/3d-webgl.spec.ts
@@ -159,6 +159,136 @@ test.describe('3D WebGL Regression', () => {
).toBeLessThan(0.001);
});
+ /**
+ * Engine-toggle regressions: the raytracing round-trip poisons the shim's global
+ * FFP routing state (a MODEL_3D BeginDrawMulti-style window leaves GL_VERTEX_ARRAY
+ * enabled with a VBO captured), after which modern-GL consumers (the raytracer
+ * blit, the 2D GAL) get their glDrawArrays misrouted through the FFP pipeline.
+ * Three deterministic reproductions of the traced failure modes:
+ * - T1: a misrouted draw must not corrupt the CALLER's VAO (blit collision).
+ * - T2: a draw under a FOREIGN context (the 2D GAL model) must pass through.
+ * - T3: FFP client-array state must die with its context.
+ */
+ test.describe('FFP routing isolation (engine-toggle model)', () => {
+ const glLines = (arr: string[]) => arr.filter((l) => l.includes('[gl1] WebGL context changed'));
+
+ test('T1: misrouted draw does not corrupt the victim VAO', async ({ page }) => {
+ await page.goto('/3d-webgl/3d_webgl_test.html');
+ await page.waitForFunction(() => (window as any).threeDTest?.isReady(), undefined, { timeout: 60000 });
+
+ const rc = await page.evaluate(() => {
+ const t = (window as any).threeDTest;
+ const r: Record = {};
+ r.init = t.appQuadInit();
+ r.clean = t.appQuadDraw(1); // sanity: quad renders before poisoning
+ t.ffpMakeStale(); // the BeginDrawMulti-window leak shape
+ r.routed = t.appQuadDraw(0); // blit-style draw with victim VAO bound → misrouted today
+ t.ffpClearStale(); // later state cleanup (flag off again)
+ r.after = t.appQuadDraw(1); // the victim draws again — must still work
+ return r;
+ });
+ await page.evaluate(() => new Promise(requestAnimationFrame));
+ const green = await page.evaluate(() => {
+ const el = document.getElementById('canvas') as HTMLCanvasElement;
+ const c = document.createElement('canvas');
+ c.width = el.width; c.height = el.height;
+ const x = c.getContext('2d', { willReadFrequently: true })!;
+ x.drawImage(el, 0, 0);
+ const d = x.getImageData(0, 0, el.width, el.height).data;
+ let g = 0, n = 0;
+ for (let i = 0; i < 16; i++)
+ for (let j = 0; j < 16; j++) {
+ const p = (Math.floor((el.height * j) / 16) * el.width + Math.floor((el.width * i) / 16)) * 4;
+ n++;
+ if (d[p] < 40 && d[p + 1] > 200 && d[p + 2] < 40) g++;
+ }
+ return g / n;
+ });
+ console.log(`[TEST] T1 rc=${JSON.stringify(rc)} greenFraction=${green}`);
+ expect(rc.init, 'app quad init').toBe(0);
+ expect(rc.clean, 'app quad renders before poisoning').toBe(0);
+ expect(rc.after,
+ 'the victim VAO must survive a misrouted draw (nonzero = the shim scribbled its attributes)')
+ .toBe(0);
+ expect(green,
+ 'the victim quad must still render green after the misroute (blank = corrupted VAO)')
+ .toBeGreaterThan(0.9);
+ });
+
+ test('T2: stale FFP flag must not route draws under a foreign context', async ({ page }) => {
+ const consoleLines: string[] = [];
+ page.on('console', (m) => consoleLines.push(m.text()));
+
+ await page.goto('/3d-webgl/3d_webgl_test.html');
+ await page.waitForFunction(() => (window as any).threeDTest?.isReady(), undefined, { timeout: 60000 });
+
+ const rc = await page.evaluate((idx) => {
+ const t = (window as any).threeDTest;
+ const r: Record = {};
+ r.scenario = t.runScenario(idx); // adopt ctx1 as the shim owner (real FFP work)
+ t.ffpMakeStale(); // poison the global mirror under ctx1
+ r.ctx2 = t.createSecondContext(); // the "2D GAL" context
+ r.use2 = t.useContext(2);
+ r.draw2 = t.quadDrawFresh(); // GAL-style modern draw → must pass through
+ t.useContext(1);
+ (window as any).threeDTest.ffpClearStale();
+ return r;
+ }, MANIFEST.scenarios.indexOf('redraw-mini-board-navigator'));
+ const thrash = glLines(consoleLines);
+ console.log(`[TEST] T2 rc=${JSON.stringify(rc)} gl1Lines=${thrash.length}`);
+ expect(rc.scenario, 'owner-context scenario render').toBe(0);
+ expect(rc.ctx2, 'second context created').toBe(0);
+ expect(rc.use2, 'second context current').toBe(0);
+ expect(rc.draw2,
+ 'a modern-GL draw under a foreign context must pass through untouched '
+ + '(nonzero = it was routed through the FFP pipeline)')
+ .toBe(0);
+ expect(thrash,
+ 'the context guard must not fire for foreign-context draws (thrash)').toEqual([]);
+ });
+
+ test('T3: FFP client-array state dies with its context', async ({ page }) => {
+ await page.goto('/3d-webgl/3d_webgl_test.html');
+ await page.waitForFunction(() => (window as any).threeDTest?.isReady(), undefined, { timeout: 60000 });
+
+ const rc = await page.evaluate(() => {
+ const t = (window as any).threeDTest;
+ const r: Record = {};
+ t.ffpMakeStale(); // poison under the original context
+ r.recreate = t.recreateContext(); // context (and its VBO) destroyed
+ r.draw = t.quadDrawFresh(); // fresh modern draw in the new context
+ return r;
+ });
+ await page.evaluate(() => new Promise(requestAnimationFrame));
+ const green = await page.evaluate(() => {
+ const el = document.getElementById('canvas') as HTMLCanvasElement;
+ const c = document.createElement('canvas');
+ c.width = el.width; c.height = el.height;
+ const x = c.getContext('2d', { willReadFrequently: true })!;
+ x.drawImage(el, 0, 0);
+ const d = x.getImageData(0, 0, el.width, el.height).data;
+ let g = 0, n = 0;
+ for (let i = 0; i < 16; i++)
+ for (let j = 0; j < 16; j++) {
+ const p = (Math.floor((el.height * j) / 16) * el.width + Math.floor((el.width * i) / 16)) * 4;
+ n++;
+ if (d[p] < 40 && d[p + 1] > 200 && d[p + 2] < 40) g++;
+ }
+ return g / n;
+ });
+ console.log(`[TEST] T3 rc=${JSON.stringify(rc)} greenFraction=${green}`);
+ expect(rc.recreate, 'context recreation').toBe(0);
+ expect(rc.draw,
+ 'client-array state from a dead context must not route draws in the new one '
+ + '(nonzero = the stale enabled flag survived the context change)')
+ .toBe(0);
+ expect(green,
+ 'the fresh-context quad must render green (blank/garbage = draw was routed '
+ + 'through the FFP pipeline with dead-context state)')
+ .toBeGreaterThan(0.9);
+ });
+ });
+
test('render all scenarios', async ({ page }) => {
test.setTimeout(300000);
diff --git a/tests/kicad/3d-viewer-engine-toggle.spec.ts b/tests/kicad/3d-viewer-engine-toggle.spec.ts
new file mode 100644
index 0000000..09e58ee
--- /dev/null
+++ b/tests/kicad/3d-viewer-engine-toggle.spec.ts
@@ -0,0 +1,126 @@
+import { test, expect } from './fixtures';
+import { clickByTooltip, clickToolbarTool, shotPath } from '../e2e/utils/element-tracker';
+import { waitForPcbnew } from './utils/pcbnew-ready';
+import { DEMO, countGlCanvases, loadBoard, logThreeDDiag, openThreeDViewer, waitForThreeDRender }
+ from './utils/threed-viewer';
+
+/**
+ * Engine round-trip smoke: OpenGL → raytracing → OpenGL must render and stay
+ * console-clean.
+ *
+ * The user-visible regression: after the round-trip the viewer showed only the
+ * background gradient, with `[gl1] WebGL context changed` firing mid-session and
+ * INVALID_OPERATION storms on BOTH WebGL contexts. Root cause: the wasm/gl1 shim's
+ * FFP draw routing keyed on a single process-global client-array flag — an
+ * interrupted fixed-function window (MODEL_3D::BeginDrawMulti) left it set, after
+ * which the raytracer blit's and the 2D GAL's glDrawArrays were routed through the
+ * FFP pipeline, scribbling their own VAOs (the persistent blank). Fixed by
+ * owner-context routing gates + VAO isolation in the shim, client-state teardown in
+ * contextSync, and client-array disables in the blit preamble; the deterministic
+ * mechanism-level reproductions live in tests/e2e/3d-webgl.spec.ts (T1-T3).
+ *
+ * This spec drives the REAL viewer round-trip. The exact race needs interaction
+ * timing no harness forces reliably, so this is the happy-path gate: with the fixes
+ * the round-trip must ALWAYS render and never emit a context-change or
+ * INVALID_OPERATION line.
+ *
+ * CI-skip mirrors 3d-viewer-deadlock.spec.ts: the raytrace pass must converge in
+ * seconds, which needs a real GPU-adjacent machine, not CI's contended software GL.
+ * ISOLATED in its own spec file (own worker) like the other heavy 3D specs.
+ */
+test.describe('3D viewer engine toggle', () => {
+ test.skip(!!process.env.CI, 'raytrace-convergence timing needs a real GPU; the '
+ + 'mechanism-level coverage runs everywhere in e2e/3d-webgl.spec.ts');
+
+ test.describe.configure({ mode: 'serial' });
+ test.setTimeout(480000);
+
+ test('raytracing round-trip re-renders and stays console-clean', async ({ page, testLogger }) => {
+ await page.goto('/kicad/pcbnew.html');
+ await waitForPcbnew(page);
+ await loadBoard(page, testLogger);
+
+ const glBefore = await countGlCanvases(page);
+ await openThreeDViewer(page, glBefore);
+ await waitForThreeDRender(page);
+ await logThreeDDiag(page, 'engine-toggle: first OpenGL render');
+
+ // Content snapshot of the newest glcanvas: sampled colour count + hash.
+ const snap = () => page.evaluate(() => {
+ const list = document.querySelectorAll('canvas[id^="glcanvas-"]');
+ const el = list[list.length - 1] as HTMLCanvasElement;
+ const tmp = document.createElement('canvas');
+ tmp.width = el.width; tmp.height = el.height;
+ const ctx = tmp.getContext('2d', { willReadFrequently: true })!;
+ ctx.drawImage(el, 0, 0);
+ const img = ctx.getImageData(0, 0, el.width, el.height).data;
+ const colors = new Set();
+ let hash = 0;
+ for (let i = 0; i < 24; i++) {
+ for (let j = 0; j < 24; j++) {
+ const p = (Math.floor(el.height * j / 24) * el.width
+ + Math.floor(el.width * i / 24)) * 4;
+ colors.add(`${img[p]},${img[p + 1]},${img[p + 2]}`);
+ hash = (hash * 31 + img[p] + img[p + 1] * 7 + img[p + 2] * 13) | 0;
+ }
+ }
+ return { colors: colors.size, hash };
+ });
+
+ const before = await snap();
+ console.log(`[TEST] OpenGL frame: ${JSON.stringify(before)}`);
+
+ // ── Toggle to raytracing. KNOWN ISSUE (3d-viewer-deadlock.spec.ts, 2026-07-04,
+ // re-verified 2026-08-13): the raytraced image never displays on the wasm
+ // build — but the ENGINE does switch and the reload machinery does run, and
+ // that switch-back reload (display-list re-record with the GL context lock
+ // released) is exactly the poisoning path this spec guards. So no
+ // blitted-frame expectation here; the round-trip itself is the test. ──
+ const toggled = (await clickToolbarTool(page, 'Use raytracing'))
+ || (await clickByTooltip(page, 'Render current view using Raytracing'));
+ expect(toggled, 'the raytracer toolbar toggle should be clickable').toBe(true);
+
+ // Give the raytracer engine a bounded window to run repaints: poll the canvas
+ // until two consecutive samples agree (content is allowed to stay identical —
+ // the known-issue inert blit — or to change and settle).
+ let rtSettle = await snap();
+ await expect.poll(async () => {
+ const next = await snap();
+ const stable = next.hash === rtSettle.hash;
+ rtSettle = next;
+ return stable;
+ }, { timeout: 60000, intervals: [2000] }).toBe(true);
+ await logThreeDDiag(page, 'engine-toggle: raytracer engine window elapsed');
+ await page.screenshot({ path: shotPath(page, `3d-engine-toggle-rt-${DEMO.name}.png`),
+ scale: 'css' });
+
+ // ── Toggle back to the OpenGL engine. ──
+ const toggledBack = (await clickToolbarTool(page, 'Use raytracing'))
+ || (await clickByTooltip(page, 'Render current view using Raytracing'));
+ expect(toggledBack, 'the raytracer toggle should toggle back').toBe(true);
+
+ // THE regression assertion: the OpenGL engine must render the board again.
+ await expect.poll(async () => (await snap()).colors, {
+ message: 'the OpenGL engine must re-render the board after the round-trip '
+ + '(a near-uniform canvas means FFP draws were misrouted/corrupted)',
+ timeout: 90000,
+ intervals: [1000],
+ }).toBeGreaterThan(8);
+ await page.screenshot({ path: shotPath(page, `3d-engine-toggle-back-${DEMO.name}.png`),
+ scale: 'css' });
+
+ // ── Console gates. ──
+ const allLines = [...testLogger.consoleLogs, ...testLogger.errors];
+ const ctxChanges = allLines.filter((l) => l.includes('[gl1] WebGL context changed'));
+ expect(ctxChanges,
+ 'the gl1 context guard must never fire during an engine toggle '
+ + '(the context does not change) — firing means draws ran under a foreign context')
+ .toEqual([]);
+ const glErrors = allLines.filter((l) => l.includes('INVALID_OPERATION'));
+ expect(glErrors,
+ `no INVALID_OPERATION storms during the round-trip:\n${glErrors.slice(0, 5).join('\n')}`)
+ .toEqual([]);
+ const aborts = allLines.filter((l) => l.includes('Aborted('));
+ expect(aborts, `WASM aborted during the engine toggle:\n${aborts.join('\n\n')}`).toEqual([]);
+ });
+});
diff --git a/wasm/gl1/README.md b/wasm/gl1/README.md
index af0a211..ef5f974 100644
--- a/wasm/gl1/README.md
+++ b/wasm/gl1/README.md
@@ -34,11 +34,28 @@ Link sites (both read `sources.txt` + `wrapped_symbols.txt`):
## Draw routing
-A `glDrawArrays`/`glDrawElements` call is FFP traffic iff `GL_VERTEX_ARRAY`
-client state is enabled: only GL1 code calls `glEnableClientState`, while the
-raytracer blit (`eda_3d_canvas_wasm.cpp`) and the 2D WebGL GAL drive their own
-GLSL programs and never touch client state — their draws pass through
-untouched.
+A `glDrawArrays`/`glDrawElements` call is FFP traffic iff BOTH hold:
+
+- the CURRENT WebGL context is the shim's **owner context** (`contextIsOwner()`,
+ adopted at the first FFP client-state mutation or `programSync()` in a
+ context — a draw under any other context is a modern-GL consumer by
+ definition and always passes through, even mid-display-list-recording);
+- `GL_VERTEX_ARRAY` client state is enabled (only GL1 code calls
+ `glEnableClientState`; the raytracer blit and the 2D WebGL GAL drive their
+ own GLSL programs).
+
+The owner gate exists because the client-state flag is process-global and an
+interrupted FFP window (e.g. `MODEL_3D::BeginDrawMulti` loops crossing a JSPI
+suspension) can leave it set — without the gate, the 2D GAL's draws got routed
+through the FFP pipeline (the engine-toggle blank-viewer bug). Two further
+hardenings from the same bug:
+
+- **VAO isolation** (`ScopedDefaultVAO`, gl1_draw.cpp): draw executors bind
+ VAO 0 for their attribute setup and restore the caller's binding — a routed
+ draw arriving with the caller's VAO bound (the blit's attribute 0 collides
+ with `ATTR_POSITION`) must never scribble it.
+- `contextSync()` resets the client-array mirror entirely on a context change:
+ enables, pointers and captured VBO names all described the dead context.
## Invariants that are easy to break (learned from the native goldens)
diff --git a/wasm/gl1/include/gl1_shim.h b/wasm/gl1/include/gl1_shim.h
index d1f2220..74370e7 100644
--- a/wasm/gl1/include/gl1_shim.h
+++ b/wasm/gl1/include/gl1_shim.h
@@ -230,6 +230,12 @@ void stateAlphaFunc( GLenum func, GLclampf ref );
// immediate-mode state: the change can be detected mid-scene-rebuild, and
// those modules are context-agnostic CPU state.
void contextSync();
+// True when the CURRENT WebGL context is the shim's owner context (or no owner
+// exists yet). The __wrap_* draw interceptors gate FFP routing on this: only
+// the owner context's draws can be fixed-function traffic — a draw under any
+// other context (the 2D GAL, a recreated canvas) always passes through, no
+// matter what the global client-array mirror says. (gl1_state.cpp)
+bool contextIsOwner();
// Per-TU cache drops invoked by contextSync() on a context change.
void shadersDropContextObjects(); // FFP program + uniform locations + fail latch
void drawDropContextObjects(); // stream/scratch VBOs
diff --git a/wasm/gl1/src/gl1_draw.cpp b/wasm/gl1/src/gl1_draw.cpp
index 7c43774..3849bed 100644
--- a/wasm/gl1/src/gl1_draw.cpp
+++ b/wasm/gl1/src/gl1_draw.cpp
@@ -40,11 +40,40 @@ enum
};
+// The shim's attribute setup assumes the DEFAULT VAO (see the VAO-policy note
+// above) — but a routed draw can arrive with the CALLER's VAO bound (the
+// raytracer blit and the 2D GAL both bind their own VAO right before drawing,
+// and their attribute 0 collides with ATTR_POSITION). Scribbling attribute
+// state into that VAO corrupts the caller PERMANENTLY — the engine-toggle
+// blank-viewer amplifier. Every draw executor therefore isolates itself on
+// VAO 0 and restores the caller's binding afterwards.
+class ScopedDefaultVAO
+{
+public:
+ ScopedDefaultVAO()
+ {
+ glGetIntegerv( GL_VERTEX_ARRAY_BINDING, &m_prev );
+ if( m_prev != 0 )
+ glBindVertexArray( 0 );
+ }
+ ~ScopedDefaultVAO()
+ {
+ if( m_prev != 0 )
+ glBindVertexArray( (GLuint) m_prev );
+ }
+
+private:
+ GLint m_prev = 0;
+};
+
+
void drawImmVertices( GLenum mode, const ImmVertex* verts, GLsizei count )
{
if( !programSync() )
return;
+ ScopedDefaultVAO vaoGuard;
+
if( !s_streamVBO )
glGenBuffers( 1, &s_streamVBO );
@@ -203,6 +232,8 @@ void drawArraysWithSources( GLenum mode, GLsizei count, const AttribSource aSrc[
return;
}
+ ScopedDefaultVAO vaoGuard;
+
GLint prevArrayBuffer = 0;
glGetIntegerv( GL_ARRAY_BUFFER_BINDING, &prevArrayBuffer );
diff --git a/wasm/gl1/src/gl1_entry_ffp.cpp b/wasm/gl1/src/gl1_entry_ffp.cpp
index ec6b874..99178a4 100644
--- a/wasm/gl1/src/gl1_entry_ffp.cpp
+++ b/wasm/gl1/src/gl1_entry_ffp.cpp
@@ -407,6 +407,15 @@ void glColorMaterial( GLenum face, GLenum mode )
static ClientArray* clientArraySlot( GLenum cap )
{
+ // FFP client-state mutation is the earliest signal that fixed-function
+ // code is working in the CURRENT context — adopt/refresh ownership here,
+ // BEFORE the mutation lands in the mirror. This is what moves ownership
+ // to a recreated 3D context (its scene rebuild calls gl*Pointer /
+ // glEnableClientState long before its first draw), and what guarantees a
+ // context change wipes stale client-array state before new state is
+ // recorded. Only FFP callers reach these names; the 2D GAL never does.
+ contextSync();
+
State& s = S();
switch( cap )
@@ -456,6 +465,7 @@ static GLuint currentArrayBufferBinding()
void glVertexPointer( GLint size, GLenum type, GLsizei stride, const GLvoid* ptr )
{
+ contextSync(); // see clientArraySlot()
ClientArray& a = S().clientArrays[CA_VERTEX];
a.size = size;
a.type = type;
diff --git a/wasm/gl1/src/gl1_entry_wrapped.cpp b/wasm/gl1/src/gl1_entry_wrapped.cpp
index 64c8109..4910980 100644
--- a/wasm/gl1/src/gl1_entry_wrapped.cpp
+++ b/wasm/gl1/src/gl1_entry_wrapped.cpp
@@ -78,6 +78,18 @@ void __wrap_glGetFloatv( GLenum pname, GLfloat* params )
void __wrap_glDrawArrays( GLenum mode, GLint first, GLsizei count )
{
+ // Only the shim's owner context can carry FFP traffic. A draw under any
+ // other context (the 2D GAL, the raytracer blit after a canvas swap) is a
+ // modern-GL consumer by definition — pass it through even if the global
+ // client-array mirror was left enabled by an interrupted FFP window, and
+ // even while a display list is recording (a foreign draw must never be
+ // swallowed into the owner's open list).
+ if( !contextIsOwner() )
+ {
+ __real_glDrawArrays( mode, first, count );
+ return;
+ }
+
if( dlistRecording() )
{
dlistRecordDrawArrays( mode, first, count );
@@ -98,6 +110,13 @@ void __wrap_glDrawArrays( GLenum mode, GLint first, GLsizei count )
void __wrap_glDrawElements( GLenum mode, GLsizei count, GLenum type, const GLvoid* indices )
{
+ // Same owner-context gate as __wrap_glDrawArrays.
+ if( !contextIsOwner() )
+ {
+ __real_glDrawElements( mode, count, type, indices );
+ return;
+ }
+
if( dlistRecording() )
{
GL1_WARN_ONCE( "glDrawElements inside glNewList is not supported — dropped" );
diff --git a/wasm/gl1/src/gl1_state.cpp b/wasm/gl1/src/gl1_state.cpp
index 026ab0f..5a612dc 100644
--- a/wasm/gl1/src/gl1_state.cpp
+++ b/wasm/gl1/src/gl1_state.cpp
@@ -41,6 +41,16 @@ static int currentContextId()
// first contextSync() under a live context.
static int s_ownerContext = 0;
+bool contextIsOwner()
+{
+ int cur = currentContextId();
+
+ // No live context: nothing can be drawn anyway. No owner yet: the first
+ // FFP consumer under any context becomes the owner (adopted in
+ // contextSync() on its first programSync()).
+ return cur != 0 && ( s_ownerContext == 0 || cur == s_ownerContext );
+}
+
void contextSync()
{
int cur = currentContextId();
@@ -61,8 +71,13 @@ void contextSync()
State& s = S();
s.boundTexture2D = 0;
+ // Client-array state dies with its context: the enables, the captured
+ // VBO names AND the CPU pointers all described the dead context's
+ // world. Keeping `.enabled` was the engine-toggle bug's tail — a flag
+ // left set by the old context kept routing modern-GL draws through the
+ // FFP pipeline in the new one.
for( int i = 0; i < CA_COUNT; ++i )
- s.clientArrays[i].boundBuffer = 0;
+ s.clientArrays[i] = ClientArray();
// The new program starts with default-initialized uniforms; force a
// full re-upload on its first sync.