diff --git a/docs/design/realm-renderer-recovery.md b/docs/design/realm-renderer-recovery.md index 241af468..8754c939 100644 --- a/docs/design/realm-renderer-recovery.md +++ b/docs/design/realm-renderer-recovery.md @@ -17,16 +17,22 @@ wheel, label-click, and camera input are synchronously suspended while the context is lost so a partially disposed scene cannot consume a gesture. The restored event starts a bounded scene rebuild and records loss/restore counts on the canvas for DOM diagnostics. If the browser does not restore the context in -time, the user sees an explicit retry surface. No code intentionally calls -`WEBGL_lose_context` during capability probing; the probe is WebGL2-only. +time, the user sees an explicit retry surface. All renderer surfaces share one +cached, non-destructive WebGL2 capability probe. No capability check calls +`WEBGL_lose_context` or otherwise tears down a context; a probe only reads the +optional texture-size limit. Castle loading is staged: Compact is mandatory and retried once for transient transport failures after a deterministic short yield; Balanced and High are optional upgrades. A missing optional LOD records the active quality in `data-realm-castle-active-lod` and continues with Compact. Pairing, integrity, and Compact failures are reported with stable failure codes for telemetry and -QA. Each controlled load is assigned a monotonic renderer generation, and a -ready renderer can never transition into static compatibility mode. +QA. Each controlled load is assigned a monotonic renderer generation. Scene +callbacks carry that generation and stale callbacks from a disposed scene are +ignored by both the React boundary and the pure lifecycle reducer. The DOM +exposes the active generation and the last generation that rendered a +successful frame, making recovery assertions deterministic. A ready renderer +can never transition into static compatibility mode. The recovery contract is intentionally frontend-only. Durable world state, authorization, and SpacetimeDB subscriptions remain outside the renderer and diff --git a/src/components/realm/RealmMapScreen.tsx b/src/components/realm/RealmMapScreen.tsx index bcff9bf2..13212869 100644 --- a/src/components/realm/RealmMapScreen.tsx +++ b/src/components/realm/RealmMapScreen.tsx @@ -612,6 +612,11 @@ function CanonicalRealmMapScreen({ rendererModeRef.current = rendererMode; const rendererLifecycleRef = useRef(rendererLifecycle); rendererLifecycleRef.current = rendererLifecycle; + // Scene callbacks can arrive after React has already retired their effect. + // Keep the active generation in a synchronous ref so stale scenes cannot + // publish ready/failure state into a newer renderer. + const activeRendererGenerationRef = useRef(0); + const lastSuccessfulRendererGenerationRef = useRef(0); const rendererRecoveryTimerRef = useRef(null); const rendererRecoveryNonceRef = useRef(0); const [rendererRecoveryNonce, setRendererRecoveryNonce] = useState(0); @@ -899,10 +904,12 @@ function CanonicalRealmMapScreen({ : classifyRealmRendererFailure(failureInput, current.state); if (failure.code === 'webgl-unavailable') { rendererModeRef.current = current.everReady ? 'loading' : 'fallback'; - setRendererLifecycle(transitionRealmRendererLifecycle(current, { + const nextLifecycle = transitionRealmRendererLifecycle(current, { type: 'webgl-unsupported', failure - })); + }); + rendererLifecycleRef.current = nextLifecycle; + setRendererLifecycle(nextLifecycle); return; } // Stop accepting pointer/camera mutations synchronously, before React has @@ -911,11 +918,13 @@ function CanonicalRealmMapScreen({ rendererModeRef.current = 'loading'; if (shouldRetryRealmRenderer(current, failure)) { const nextAttempt = current.attempt + 1; - setRendererLifecycle(transitionRealmRendererLifecycle(current, { + const nextLifecycle = transitionRealmRendererLifecycle(current, { type: 'recover', failure, attempt: nextAttempt - })); + }); + rendererLifecycleRef.current = nextLifecycle; + setRendererLifecycle(nextLifecycle); if (failure.code !== 'context-lost') { rendererRecoveryNonceRef.current += 1; setRendererRecoveryNonce(rendererRecoveryNonceRef.current); @@ -931,15 +940,19 @@ function CanonicalRealmMapScreen({ phase: latest.state, message: 'The browser did not restore the Realm graphics context in time.' }; - setRendererLifecycle(transitionRealmRendererLifecycle(latest, { + const failedLifecycle = transitionRealmRendererLifecycle(latest, { type: 'failed', failure: timeoutFailure - })); + }); + rendererLifecycleRef.current = failedLifecycle; + setRendererLifecycle(failedLifecycle); }, REALM_RENDERER_CONTEXT_RESTORE_TIMEOUT_MS); } return; } - setRendererLifecycle(transitionRealmRendererLifecycle(current, { type: 'failed', failure })); + const failedLifecycle = transitionRealmRendererLifecycle(current, { type: 'failed', failure }); + rendererLifecycleRef.current = failedLifecycle; + setRendererLifecycle(failedLifecycle); }, []); const retryRenderer = useCallback(() => { @@ -947,10 +960,12 @@ function CanonicalRealmMapScreen({ window.clearTimeout(rendererRecoveryTimerRef.current); rendererRecoveryTimerRef.current = null; } - setRendererLifecycle(transitionRealmRendererLifecycle(rendererLifecycleRef.current, { + const loadingLifecycle = transitionRealmRendererLifecycle(rendererLifecycleRef.current, { type: 'load-start', attempt: 0 - })); + }); + rendererLifecycleRef.current = loadingLifecycle; + setRendererLifecycle(loadingLifecycle); rendererModeRef.current = 'loading'; rendererRecoveryNonceRef.current += 1; setRendererRecoveryNonce(rendererRecoveryNonceRef.current); @@ -1284,11 +1299,16 @@ function CanonicalRealmMapScreen({ } let scene: RealmSceneHandle | null = null; + const rendererGeneration = rendererLifecycleRef.current.generation + 1; + const loadingLifecycle = transitionRealmRendererLifecycle(rendererLifecycleRef.current, { + type: 'load-start', + attempt: rendererLifecycleRef.current.attempt, + generation: rendererGeneration + }); + activeRendererGenerationRef.current = rendererGeneration; + rendererLifecycleRef.current = loadingLifecycle; try { - setRendererLifecycle(transitionRealmRendererLifecycle(rendererLifecycleRef.current, { - type: 'load-start', - attempt: rendererLifecycleRef.current.attempt - })); + setRendererLifecycle(loadingLifecycle); latestProjectionRef.current = { width: 0, height: 0, castles: [] }; labelMembershipSignatureRef.current = ''; latestVisibleCastleLabelsRef.current = []; @@ -1363,6 +1383,7 @@ function CanonicalRealmMapScreen({ sharedForestTrees: sharedForestProjection.trees, waterCells, realmId: snapshot.realm.realmId, + rendererGeneration, // The retired local planner is exposed only to the synthetic dev // observer. Player scenes wait for the paired shared public tables. allowLegacyForestFallback: observerMode, @@ -1376,6 +1397,7 @@ function CanonicalRealmMapScreen({ onTargetHover: handleSceneTargetHover, onKeepStatusChange: () => undefined, onCastlesReady: (castleCount) => { + if (activeRendererGenerationRef.current !== rendererGeneration) return; if (castleCount !== expectedCastleCountRef.current) { markRendererFailure({ code: 'castle-count-mismatch', @@ -1387,8 +1409,10 @@ function CanonicalRealmMapScreen({ } rendererModeRef.current = 'webgl'; const activeLod = canvas.dataset.realmCastleActiveLod; + lastSuccessfulRendererGenerationRef.current = rendererGeneration; setRendererLifecycle(transitionRealmRendererLifecycle(rendererLifecycleRef.current, { type: 'ready', + generation: rendererGeneration, degradedQuality: activeLod === 'compact' || activeLod === 'balanced' ? activeLod : undefined @@ -1402,8 +1426,13 @@ function CanonicalRealmMapScreen({ onStoneNodePresentationTelemetry: updateStoneNodePresentationTelemetry, onTerrainPresentationTelemetry: updateTerrainPresentationTelemetry, onCastleProjection: updateCastleProjection, - onRendererFailure: markRendererFailure, + onRendererFailure: (failure) => { + if (activeRendererGenerationRef.current === rendererGeneration) { + markRendererFailure(failure); + } + }, onRendererContextRestored: () => { + if (activeRendererGenerationRef.current !== rendererGeneration) return; if (rendererRecoveryTimerRef.current !== null) { window.clearTimeout(rendererRecoveryTimerRef.current); rendererRecoveryTimerRef.current = null; @@ -1459,7 +1488,9 @@ function CanonicalRealmMapScreen({ scene.restoreCameraAttestation?.(attestation); } } catch (error) { - markRendererFailure(classifyRealmRendererFailure(error, 'loading')); + if (activeRendererGenerationRef.current === rendererGeneration) { + markRendererFailure(classifyRealmRendererFailure(error, 'loading')); + } } return () => { @@ -1472,6 +1503,9 @@ function CanonicalRealmMapScreen({ } scene?.dispose(); if (sceneRef.current === scene) sceneRef.current = null; + if (activeRendererGenerationRef.current === rendererGeneration) { + activeRendererGenerationRef.current = 0; + } if (rendererRecoveryTimerRef.current !== null) { window.clearTimeout(rendererRecoveryTimerRef.current); rendererRecoveryTimerRef.current = null; @@ -1620,6 +1654,7 @@ function CanonicalRealmMapScreen({ data-renderer-failure={rendererLifecycle.failure?.code ?? 'none'} data-renderer-failure-code={rendererLifecycle.failure?.code ?? 'none'} data-renderer-generation={String(rendererLifecycle.generation)} + data-renderer-last-successful-generation={String(lastSuccessfulRendererGenerationRef.current)} data-renderer-context-loss-count={canvasTelemetry?.realmRendererContextLossCount ?? '0'} data-renderer-context-restore-count={canvasTelemetry?.realmRendererContextRestoreCount ?? '0'} data-renderer-degraded-quality={rendererLifecycle.degradedQuality ?? 'none'} diff --git a/src/components/realm/createRealmScene.ts b/src/components/realm/createRealmScene.ts index ca10ebf0..728d907b 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -523,6 +523,8 @@ export type CreateRealmSceneOptions = Readonly<{ sharedForestLayout?: unknown; /** Canonical realm id used to bind the shared forest table to this scene. */ realmId?: string; + /** React-owned monotonic generation for stale callback suppression. */ + rendererGeneration?: number; /** Complete, digest-validated public water projection; absent means water is unavailable. */ waterCells?: readonly GenesisWaterCellV1[]; /** @@ -952,6 +954,7 @@ function initializeRealmScene( cleanup: RealmSceneCleanup ): RealmSceneHandle { const sceneBuildSequence = nextRealmSceneBuildSequence++; + const rendererGeneration = options.rendererGeneration ?? sceneBuildSequence; const noLakeRevisionActive = realmNoLakeRevisionActive(options.waterCells); const landRenderMap = realmLandPresentationMap( options.surface.renderMap, @@ -1002,6 +1005,8 @@ function initializeRealmScene( options.canvas.dataset.realmCanvasIdentity = canvasId; options.canvas.dataset.realmSceneBuildSequence = String(sceneBuildSequence); options.canvas.dataset.realmSceneIdentity = scene.uuid; + options.canvas.dataset.realmRendererGeneration = String(rendererGeneration); + options.canvas.dataset.realmLastSuccessfulRenderedGeneration = '0'; scene.background = new THREE.Color(REALM_SKY_FALLBACK_COLOR); const fog = new THREE.Fog( REALM_SKY_FALLBACK_COLOR, @@ -1950,6 +1955,7 @@ function initializeRealmScene( // only the grass layer if its pinned shader chunk contract has changed. renderer.render(scene, cameraController.camera); } + options.canvas.dataset.realmLastSuccessfulRenderedGeneration = String(rendererGeneration); projectCastleLabels(); if (pendingCastlesReadyCount !== null) { const castleCount = pendingCastlesReadyCount; diff --git a/src/components/realm/realmMapPresentationHelpers.ts b/src/components/realm/realmMapPresentationHelpers.ts index b4e412b2..8df5505f 100644 --- a/src/components/realm/realmMapPresentationHelpers.ts +++ b/src/components/realm/realmMapPresentationHelpers.ts @@ -2,6 +2,10 @@ import { useEffect, useState } from 'react'; import { axialToWorld, type HexCoord } from '../../game/map/hexCoordinates'; import { terrainCellByCoord } from '../../game/map/generateTerrainMap'; +import { + probeWebGL2Capability, + resetWebGL2CapabilityForTests +} from '../../settings/graphicsPreference'; import type { RealmTerrainSurface } from '../../game/map/realmTerrainSurface'; import type { TerrainCell } from '../../game/map/terrainTypes'; import { @@ -76,30 +80,12 @@ export function directionForKey(key: string): HexCoord | null { } } -let cachedWebGlCapability: boolean | undefined; - -/** - * Probe once per document without deliberately destroying a context. The old - * probe used WEBGL_lose_context as a feature test, which could leave the next - * real canvas in the exact terminal state we are trying to recover from. - */ export function canUseWebGL() { - if (cachedWebGlCapability !== undefined) return cachedWebGlCapability; - try { - const canvas = document.createElement('canvas'); - // WebGL2 is the renderer's authoritative capability. WebGL1 can report a - // usable context while still lacking the instancing, texture, and shader - // contracts required by the Realm scene, so it must not enter the 3D path. - const context = canvas.getContext('webgl2'); - cachedWebGlCapability = Boolean(context); - } catch { - cachedWebGlCapability = false; - } - return cachedWebGlCapability; + return probeWebGL2Capability().available; } export function resetWebGLCapabilityForTests() { - cachedWebGlCapability = undefined; + resetWebGL2CapabilityForTests(); } export function pointsForSvg(coord: HexCoord) { diff --git a/src/components/realm/realmRendererRecovery.ts b/src/components/realm/realmRendererRecovery.ts index f54c92cd..f8eb83ec 100644 --- a/src/components/realm/realmRendererRecovery.ts +++ b/src/components/realm/realmRendererRecovery.ts @@ -50,9 +50,9 @@ export function transitionRealmRendererLifecycle( | { type: 'probe-start' } | { type: 'webgl-unsupported'; failure?: RealmRendererFailure } | { type: 'load-start'; attempt?: number; generation?: number } - | { type: 'ready'; degradedQuality?: 'compact' | 'balanced' } - | { type: 'recover'; failure: RealmRendererFailure; attempt?: number } - | { type: 'failed'; failure: RealmRendererFailure } + | { type: 'ready'; degradedQuality?: 'compact' | 'balanced'; generation?: number } + | { type: 'recover'; failure: RealmRendererFailure; attempt?: number; generation?: number } + | { type: 'failed'; failure: RealmRendererFailure; generation?: number } ): RealmRendererLifecycle { switch (event.type) { case 'probe-start': @@ -88,6 +88,12 @@ export function transitionRealmRendererLifecycle( failure: undefined }); case 'ready': + // Late callbacks from a disposed scene must never publish readiness for + // a newer scene generation. Omitting generation keeps the reducer + // convenient for pure callers and legacy integrations. + if (event.generation !== undefined && event.generation !== current.generation) { + return current; + } return Object.freeze({ ...current, state: 'ready', @@ -96,6 +102,9 @@ export function transitionRealmRendererLifecycle( degradedQuality: event.degradedQuality }); case 'recover': + if (event.generation !== undefined && event.generation !== current.generation) { + return current; + } return Object.freeze({ ...current, state: 'recovering', @@ -103,6 +112,9 @@ export function transitionRealmRendererLifecycle( failure: event.failure }); case 'failed': + if (event.generation !== undefined && event.generation !== current.generation) { + return current; + } return Object.freeze({ ...current, state: 'failed', failure: event.failure }); default: return current; diff --git a/src/components/title/WarpkeepTitleScreen3D.tsx b/src/components/title/WarpkeepTitleScreen3D.tsx index 66aa0e71..9b14472f 100644 --- a/src/components/title/WarpkeepTitleScreen3D.tsx +++ b/src/components/title/WarpkeepTitleScreen3D.tsx @@ -51,6 +51,7 @@ import { type WarpkeepTitleScreenHandle, type WarpkeepTitleScreenProps } from './titleScreenTypes'; +import { probeWebGL2Capability } from '../../settings/graphicsPreference'; import './WarpkeepTitleScreen.css'; type PointLayer = { @@ -72,14 +73,7 @@ type GalaxyAssembly = { }; function canUseWebGL() { - try { - const canvas = document.createElement('canvas'); - const context = canvas.getContext('webgl2'); - context?.getExtension('WEBGL_lose_context')?.loseContext(); - return Boolean(context); - } catch { - return false; - } + return probeWebGL2Capability().available; } function createRandom(seed: number) { diff --git a/src/settings/graphicsPreference.ts b/src/settings/graphicsPreference.ts index 14877c25..17ae3214 100644 --- a/src/settings/graphicsPreference.ts +++ b/src/settings/graphicsPreference.ts @@ -22,6 +22,50 @@ export type GraphicsCapabilityInput = Readonly<{ maxTextureSize?: number; }>; +export type WebGL2Capability = Readonly<{ + available: boolean; + maxTextureSize?: number; +}>; + +let cachedWebGL2Capability: WebGL2Capability | undefined; + +/** + * Probe the renderer contract once without mutating the context. The Realm + * and title renderer must share this capability result so a feature probe can + * never consume or deliberately lose the context that a real scene needs. + */ +export function probeWebGL2Capability(): WebGL2Capability { + if (cachedWebGL2Capability !== undefined) return cachedWebGL2Capability; + if (typeof document === 'undefined') return Object.freeze({ available: false }); + try { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('webgl2'); + if (!context) { + cachedWebGL2Capability = Object.freeze({ available: false }); + return cachedWebGL2Capability; + } + let maxTextureSize: number | undefined; + try { + const measured = context.getParameter(context.MAX_TEXTURE_SIZE); + if (typeof measured === 'number' && Number.isFinite(measured) && measured > 0) { + maxTextureSize = measured; + } + } catch { + // A context can be usable even when a non-essential capability query is + // blocked by the browser; quality resolution handles an unknown size. + } + cachedWebGL2Capability = Object.freeze({ available: true, maxTextureSize }); + } catch { + cachedWebGL2Capability = Object.freeze({ available: false }); + } + return cachedWebGL2Capability; +} + +/** Test-only reset; production callers should retain the shared probe cache. */ +export function resetWebGL2CapabilityForTests() { + cachedWebGL2Capability = undefined; +} + export type StorageLike = Pick; export function isGraphicsPreference(value: unknown): value is GraphicsPreference { @@ -110,17 +154,7 @@ export function browserGraphicsCapabilities(): GraphicsCapabilityInput { const navigatorWithMemory = typeof navigator === 'undefined' ? undefined : navigator as Navigator & { deviceMemory?: number }; - let maxTextureSize: number | undefined; - if (typeof document !== 'undefined') { - try { - const probe = document.createElement('canvas'); - const context = probe.getContext('webgl2'); - maxTextureSize = context?.getParameter(context.MAX_TEXTURE_SIZE) as number | undefined; - context?.getExtension('WEBGL_lose_context')?.loseContext(); - } catch { - maxTextureSize = undefined; - } - } + const maxTextureSize = probeWebGL2Capability().maxTextureSize; return { width: typeof window === 'undefined' ? 1280 : window.innerWidth, height: typeof window === 'undefined' ? 720 : window.innerHeight, diff --git a/tests/graphicsPreference.test.ts b/tests/graphicsPreference.test.ts index 7c94dae8..ec38300e 100644 --- a/tests/graphicsPreference.test.ts +++ b/tests/graphicsPreference.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_GRAPHICS_PREFERENCE, + probeWebGL2Capability, + resetWebGL2CapabilityForTests, WARPKEEP_GRAPHICS_PREFERENCE_KEY, parseGraphicsPreference, readGraphicsPreference, @@ -23,9 +25,27 @@ function memoryStorage() { afterEach(() => { vi.restoreAllMocks(); + resetWebGL2CapabilityForTests(); }); describe('graphics preference', () => { + it('shares one non-destructive WebGL2 capability probe', () => { + const context = { + MAX_TEXTURE_SIZE: 0x0d33, + getParameter: vi.fn(() => 8_192), + getExtension: vi.fn() + }; + const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockImplementation(((contextId: string) => ( + contextId === 'webgl2' ? context : null + )) as typeof HTMLCanvasElement.prototype.getContext); + + expect(probeWebGL2Capability()).toEqual({ available: true, maxTextureSize: 8_192 }); + expect(probeWebGL2Capability()).toEqual({ available: true, maxTextureSize: 8_192 }); + expect(getContext).toHaveBeenCalledTimes(1); + expect(context.getExtension).not.toHaveBeenCalled(); + }); + it('validates and persists only the versioned visual preference', () => { const storage = memoryStorage(); expect(parseGraphicsPreference('obsolete')).toBe(DEFAULT_GRAPHICS_PREFERENCE); diff --git a/tests/realmRendererRecovery.test.ts b/tests/realmRendererRecovery.test.ts index 93313760..4a35705d 100644 --- a/tests/realmRendererRecovery.test.ts +++ b/tests/realmRendererRecovery.test.ts @@ -70,6 +70,33 @@ describe('Realm renderer recovery lifecycle', () => { expect(second.generation).toBe(9); }); + it('ignores readiness, recovery, and failure callbacks from retired generations', () => { + const first = transitionRealmRendererLifecycle(initialRealmRendererLifecycle(), { + type: 'load-start', + generation: 1 + }); + const current = transitionRealmRendererLifecycle(first, { + type: 'load-start', + generation: 2 + }); + expect(transitionRealmRendererLifecycle(current, { + type: 'ready', + generation: 1 + })).toBe(current); + expect(transitionRealmRendererLifecycle(current, { + type: 'recover', + generation: 1, + failure: { code: 'context-lost', retryable: true, phase: 'loading' } + })).toBe(current); + expect(transitionRealmRendererLifecycle(current, { + type: 'failed', + generation: 1, + failure: { code: 'scene-build-failed', retryable: true, phase: 'loading' } + })).toBe(current); + expect(current.state).toBe('loading'); + expect(current.generation).toBe(2); + }); + it('classifies integrity and pairing failures as explicit non-retryable failures', () => { expect(classifyRealmRendererFailure(new Error('sha256 integrity mismatch'), 'loading').code) .toBe('castle-integrity-failed');