Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions docs/design/realm-renderer-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,21 @@ explicit loading/recovery/failed state instead of silently replacing a real
world with a full-world SVG.

Context loss calls `preventDefault`, pauses ambient work and rendering, and
retains React selection, camera intent, and the scene attestation. The restored
event starts a bounded scene rebuild. 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.
retains React selection, camera intent, and the scene attestation. Pointer,
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.

Castle loading is staged: Compact is mandatory and retried once for transient
transport failures; 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.
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.

The recovery contract is intentionally frontend-only. Durable world state,
authorization, and SpacetimeDB subscriptions remain outside the renderer and
Expand Down
38 changes: 22 additions & 16 deletions src/components/realm/RealmMapScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,6 @@ function CanonicalRealmMapScreen({
const rendererRecoveryTimerRef = useRef<number | null>(null);
const rendererRecoveryNonceRef = useRef(0);
const [rendererRecoveryNonce, setRendererRecoveryNonce] = useState(0);
const rendererEverReadyRef = useRef(false);
const rendererAttestationRef = useRef<ReturnType<RealmSceneHandle['getCameraAttestation']> | null>(null);
const [cameraMode, setCameraMode] = useState<RealmCameraMode>('realm');
const [interaction, dispatchInteraction] = useReducer(
Expand Down Expand Up @@ -899,12 +898,17 @@ function CanonicalRealmMapScreen({
? failureInput as RealmRendererFailure
: classifyRealmRendererFailure(failureInput, current.state);
if (failure.code === 'webgl-unavailable') {
rendererModeRef.current = current.everReady ? 'loading' : 'fallback';
setRendererLifecycle(transitionRealmRendererLifecycle(current, {
type: 'webgl-unsupported',
failure
}));
return;
}
// Stop accepting pointer/camera mutations synchronously, before React has
// committed the loading/recovering state to the DOM. The scene itself
// applies the same guard while a WebGL context is lost.
rendererModeRef.current = 'loading';
if (shouldRetryRealmRenderer(current, failure)) {
const nextAttempt = current.attempt + 1;
setRendererLifecycle(transitionRealmRendererLifecycle(current, {
Expand Down Expand Up @@ -943,12 +947,11 @@ function CanonicalRealmMapScreen({
window.clearTimeout(rendererRecoveryTimerRef.current);
rendererRecoveryTimerRef.current = null;
}
const next: RealmRendererLifecycle = {
state: 'loading',
attempt: 0,
everReady: rendererEverReadyRef.current
};
setRendererLifecycle(next);
setRendererLifecycle(transitionRealmRendererLifecycle(rendererLifecycleRef.current, {
type: 'load-start',
attempt: 0
}));
rendererModeRef.current = 'loading';
rendererRecoveryNonceRef.current += 1;
setRendererRecoveryNonce(rendererRecoveryNonceRef.current);
}, []);
Expand Down Expand Up @@ -1383,7 +1386,6 @@ function CanonicalRealmMapScreen({
return;
}
rendererModeRef.current = 'webgl';
rendererEverReadyRef.current = true;
const activeLod = canvas.dataset.realmCastleActiveLod;
setRendererLifecycle(transitionRealmRendererLifecycle(rendererLifecycleRef.current, {
type: 'ready',
Expand Down Expand Up @@ -1603,6 +1605,9 @@ function CanonicalRealmMapScreen({
if (isPlayableRealmCoord(surface, next)) selectCoord(next);
};

// The scene mirrors these counters onto the canvas so a context event can
// be diagnosed without exposing implementation details in user copy.
const canvasTelemetry = canvasRef.current?.dataset;
return (
<main
ref={rootRef}
Expand All @@ -1613,6 +1618,10 @@ function CanonicalRealmMapScreen({
data-renderer-ever-ready={String(rendererLifecycle.everReady)}
data-renderer-recovery-attempt={String(rendererLifecycle.attempt)}
data-renderer-failure={rendererLifecycle.failure?.code ?? 'none'}
data-renderer-failure-code={rendererLifecycle.failure?.code ?? 'none'}
data-renderer-generation={String(rendererLifecycle.generation)}
data-renderer-context-loss-count={canvasTelemetry?.realmRendererContextLossCount ?? '0'}
data-renderer-context-restore-count={canvasTelemetry?.realmRendererContextRestoreCount ?? '0'}
data-renderer-degraded-quality={rendererLifecycle.degradedQuality ?? 'none'}
data-quality={quality}
tabIndex={0}
Expand Down Expand Up @@ -1869,9 +1878,6 @@ function CanonicalRealmMapScreen({
<p className="realm-map-screen__fallback-copy">
WebGL is unavailable on this device. Showing a bounded, accessible view of the
canonical Genesis 001 region around your keep.
<span hidden>
Detailed terrain is unavailable. Showing the canonical Genesis 001 realm map.
</span>
</p>
</div>
) : null}
Expand All @@ -1883,20 +1889,20 @@ function CanonicalRealmMapScreen({
>
<div>
{rendererLifecycle.state === 'failed' ? (
<strong role="alert">The 3D realm needs another attempt</strong>
<strong role="alert">THE REALM COULD NOT BE RESTORED</strong>
) : rendererLifecycle.state === 'recovering' ? (
<strong role="status">Recovering the 3D realm…</strong>
<strong role="status">RESTORING THE REALM…</strong>
) : (
<strong role="status">Surveying the bright lowlands…</strong>
)}
<span>
{rendererLifecycle.state === 'failed'
? 'The renderer stopped before the world was ready. Your world state is safe.'
? 'Warpkeep could not restart 3D rendering. Your server-owned progress was not changed.'
: rendererLifecycle.state === 'recovering'
? 'Restoring the graphics context without replacing the realm with a flat map.'
? 'The graphics device was interrupted. Your game state is safe while the realm is restored.'
: 'Preparing every canonical castle before the realm is revealed.'}
</span>
{rendererLifecycle.state === 'failed' ? (
{rendererLifecycle.state === 'failed' && rendererLifecycle.failure?.retryable !== false ? (
<button type="button" onClick={retryRenderer}>Retry 3D Realm</button>
) : null}
<button type="button" onClick={onRequestReturn}>
Expand Down
44 changes: 44 additions & 0 deletions src/components/realm/createRealmScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1689,6 +1689,8 @@ function initializeRealmScene(
let renderPendingWhileHidden = false;
let pendingCastlesReadyCount: number | null = null;
let contextLost = false;
let contextLossCount = 0;
let contextRestoreCount = 0;
let ambientScheduler: RealmAmbientScheduler | null = null;
const ambientIsNeeded = () => !options.reducedMotion
&& renderPlan.grass.animationFrameCap > 0
Expand Down Expand Up @@ -2393,6 +2395,10 @@ function initializeRealmScene(
};

const handlePointerDown = (event: PointerEvent) => {
if (contextLost) {
event.preventDefault();
return;
}
const lane = laneForTarget(event.target);
if (!lane || (event.pointerType !== 'touch' && event.button !== 0)) return;
if (pointerGestures.snapshot().pointerCount === 0) clearLabelClickSuppression();
Expand All @@ -2416,6 +2422,10 @@ function initializeRealmScene(
};

const handlePointerMove = (event: PointerEvent) => {
if (contextLost) {
event.preventDefault();
return;
}
if (pointerGestures.snapshot().pointerCount === 0) {
if (event.target === options.canvas) scheduleHover(event.clientX, event.clientY);
return;
Expand Down Expand Up @@ -2461,6 +2471,10 @@ function initializeRealmScene(
};

const handlePointerUp = (event: PointerEvent) => {
if (contextLost) {
event.preventDefault();
return;
}
const labelTarget = labelPointerTargets.get(event.pointerId);
labelPointerTargets.delete(event.pointerId);
const result = pointerGestures.end({
Expand All @@ -2482,6 +2496,10 @@ function initializeRealmScene(
};

const handlePointerCancel = (event: PointerEvent) => {
if (contextLost) {
event.preventDefault();
return;
}
labelPointerTargets.delete(event.pointerId);
const result = pointerGestures.cancel(event.pointerId);
if (!result.accepted) return;
Expand All @@ -2492,6 +2510,7 @@ function initializeRealmScene(
};

const handleLostPointerCapture = (event: PointerEvent) => {
if (contextLost) return;
labelPointerTargets.delete(event.pointerId);
const result = pointerGestures.lostCapture(event.pointerId);
if (!result.accepted) return;
Expand All @@ -2517,6 +2536,11 @@ function initializeRealmScene(
};

const handleLabelClickCapture = (event: MouseEvent) => {
if (contextLost) {
event.preventDefault();
event.stopImmediatePropagation();
return;
}
// Keyboard and assistive-technology activation carries no mouse click
// count and must never be consumed by a prior pointer gesture.
if (event.detail === 0 || !(event.target instanceof Element)) return;
Expand All @@ -2531,12 +2555,17 @@ function initializeRealmScene(
};

const handlePointerLeave = () => {
if (contextLost) return;
if (pointerGestures.snapshot().pointerCount === 0) {
cancelPendingHover();
dispatchHover(null);
}
};
const handleWheel = (event: WheelEvent) => {
if (contextLost) {
event.preventDefault();
return;
}
const lane = laneForTarget(event.target);
if (!lane) return;
event.preventDefault();
Expand Down Expand Up @@ -2578,6 +2607,11 @@ function initializeRealmScene(
event.preventDefault();
if (cleanup.isDisposed()) return;
contextLost = true;
contextLossCount += 1;
options.canvas.dataset.realmRendererContextLost = 'true';
options.canvas.dataset.realmRendererContextLossCount = String(contextLossCount);
options.canvas.dataset.realmRendererContextRestoreCount = String(contextRestoreCount);
cancelAllPointers(pointerGestures.blur());
ambientScheduler?.setActive(false);
options.onRendererFailure?.({
code: 'context-lost',
Expand All @@ -2588,6 +2622,12 @@ function initializeRealmScene(
};
const handleContextRestored = () => {
if (cleanup.isDisposed() || !contextLost) return;
contextLost = false;
contextRestoreCount += 1;
options.canvas.dataset.realmRendererContextLost = 'false';
options.canvas.dataset.realmRendererContextLossCount = String(contextLossCount);
options.canvas.dataset.realmRendererContextRestoreCount = String(contextRestoreCount);
ambientScheduler?.setActive(ambientIsNeeded());
options.onRendererContextRestored?.();
};

Expand Down Expand Up @@ -2712,6 +2752,10 @@ function initializeRealmScene(
} catch (error) {
lastError = error;
if (lod !== 'compact' || attempt !== 0) break;
// Keep the retry bounded and deterministic. The short yield lets a
// transient browser fetch/decode stall settle without overlapping
// two requests for the same content-addressed lease.
await new Promise<void>((resolve) => window.setTimeout(resolve, 40));
}
}
throw lastError ?? new Error(`Unable to load castle ${lod} LOD.`);
Expand Down
5 changes: 4 additions & 1 deletion src/components/realm/realmMapPresentationHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ export function canUseWebGL() {
if (cachedWebGlCapability !== undefined) return cachedWebGlCapability;
try {
const canvas = document.createElement('canvas');
const context = canvas.getContext('webgl2') ?? canvas.getContext('webgl');
// 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;
Expand Down
22 changes: 20 additions & 2 deletions src/components/realm/realmRendererRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export type RealmRendererFailure = Readonly<{
export type RealmRendererLifecycle = Readonly<{
state: RealmRendererLifecycleState;
attempt: number;
/** Monotonic scene-generation identifier used to correlate DOM telemetry. */
generation: number;
failure?: RealmRendererFailure;
everReady: boolean;
degradedQuality?: 'compact' | 'balanced';
Expand All @@ -39,15 +41,15 @@ export const REALM_RENDERER_MAX_RECOVERY_ATTEMPTS = 2;
export const REALM_RENDERER_CONTEXT_RESTORE_TIMEOUT_MS = 8_000;

export function initialRealmRendererLifecycle(): RealmRendererLifecycle {
return Object.freeze({ state: 'probing', attempt: 0, everReady: false });
return Object.freeze({ state: 'probing', attempt: 0, generation: 0, everReady: false });
}

export function transitionRealmRendererLifecycle(
current: RealmRendererLifecycle,
event:
| { type: 'probe-start' }
| { type: 'webgl-unsupported'; failure?: RealmRendererFailure }
| { type: 'load-start'; attempt?: number }
| { type: 'load-start'; attempt?: number; generation?: number }
| { type: 'ready'; degradedQuality?: 'compact' | 'balanced' }
| { type: 'recover'; failure: RealmRendererFailure; attempt?: number }
| { type: 'failed'; failure: RealmRendererFailure }
Expand All @@ -56,6 +58,21 @@ export function transitionRealmRendererLifecycle(
case 'probe-start':
return Object.freeze({ ...current, state: 'probing', failure: undefined });
case 'webgl-unsupported':
// A renderer that has already presented a frame must never be
// reclassified as a static unsupported device. That would silently
// replace a live world with an SVG after a transient runtime failure.
if (current.everReady) {
return Object.freeze({
...current,
state: 'failed',
failure: {
code: 'renderer-construction-failed' as const,
retryable: true,
phase: current.state,
message: 'WebGL became unavailable after the Realm renderer was ready.'
}
});
}
return Object.freeze({
...current,
state: 'static-unsupported',
Expand All @@ -67,6 +84,7 @@ export function transitionRealmRendererLifecycle(
...current,
state: 'loading',
attempt: event.attempt ?? current.attempt,
generation: event.generation ?? current.generation + 1,
failure: undefined
});
case 'ready':
Expand Down
5 changes: 4 additions & 1 deletion tests/realmMapScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,11 @@ describe('RealmMapScreen', () => {
expect(screen.getByRole('region', { name: 'Your resources' })).not.toBeNull();
const fallback = screen.getByTestId('realm-static-fallback');
expect(within(fallback).getByText(
'Detailed terrain is unavailable. Showing the canonical Genesis 001 realm map.'
'WebGL is unavailable on this device. Showing a bounded, accessible view of the canonical Genesis 001 region around your keep.'
)).not.toBeNull();
expect(fallback.textContent).not.toContain(
'Detailed terrain is unavailable. Showing the canonical Genesis 001 realm map.'
);
expect(fallback.textContent).not.toMatch(/\b(?:traversable cells|realm cells|rendered)\b/i);
const polygons = container.querySelectorAll<SVGPolygonElement>(
'.realm-map-screen__fallback-map polygon'
Expand Down
33 changes: 33 additions & 0 deletions tests/realmRendererRecovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,39 @@ describe('Realm renderer recovery lifecycle', () => {
expect(transitionRealmRendererLifecycle(recovering, { type: 'ready' }).state).toBe('ready');
});

it('never downgrades a previously-ready renderer to static unsupported mode', () => {
const ready = transitionRealmRendererLifecycle(initialRealmRendererLifecycle(), {
type: 'ready'
});
const state = transitionRealmRendererLifecycle(ready, {
type: 'webgl-unsupported',
failure: {
code: 'webgl-unavailable',
retryable: false,
phase: 'probing'
}
});
expect(state.state).toBe('failed');
expect(state.state).not.toBe('static-unsupported');
expect(state.everReady).toBe(true);
expect(state.failure?.code).toBe('renderer-construction-failed');
});

it('increments the generation when a scene load begins', () => {
const initial = initialRealmRendererLifecycle();
const first = transitionRealmRendererLifecycle(initial, {
type: 'load-start',
attempt: 0
});
const second = transitionRealmRendererLifecycle(first, {
type: 'load-start',
attempt: 1,
generation: 9
});
expect(first.generation).toBe(1);
expect(second.generation).toBe(9);
});

it('classifies integrity and pairing failures as explicit non-retryable failures', () => {
expect(classifyRealmRendererFailure(new Error('sha256 integrity mismatch'), 'loading').code)
.toBe('castle-integrity-failed');
Expand Down
Loading