Gate fingerprint-capped WebGL contexts (black map on LibreWolf/Firefox RFP)

privacy.resistFingerprinting (default-on in LibreWolf and Mullvad Browser,
opt-in in Firefox) caps MAX_TEXTURE_SIZE at 2048 on an otherwise
hardware-accelerated context. The renderer unconditionally allocates a
4096-wide palette texture, so the oversized texImage2D calls fail silently,
framebuffers never complete, and the whole map renders black (#4357).

Extend initGL with a third failure class: after the software check, read
MAX_TEXTURE_SIZE and classify the context as "limited" when it's below the
palette size. The gate shows fingerprinting-specific instructions for this
case (add the site to privacy.resistFingerprinting.exemptedDomains) instead
of the hardware-acceleration steps, and gl_init reports the capped size.

Fixes #4357

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Evan Pelle
2026-07-02 20:48:20 +00:00
co-authored by Claude Fable 5
parent 31833346ec
commit 906831d803
5 changed files with 143 additions and 26 deletions
+4 -3
View File
@@ -191,8 +191,9 @@ export function joinLobby(
if (startingModal) {
startingModal.classList.add("hidden");
}
// No GPU-accelerated WebGL2: gate with an actionable message rather
// than the generic crash modal (the game would crawl at ~1fps).
// No usable WebGL2 (software-rendered, fingerprint-capped, or
// missing): gate with an actionable message rather than the generic
// crash modal (the game would crawl at ~1fps or render black).
if (e instanceof GLUnavailableError) {
showGLGate(e.glStatus);
return;
@@ -333,7 +334,7 @@ function createWebGLView(
);
} catch (e) {
if (e instanceof GLUnavailableError) {
trackGLInit(e.glStatus, e.renderer);
trackGLInit(e.glStatus, e.renderer, e.maxTextureSize);
}
// The renderer never took ownership of the canvas, so remove it here —
// otherwise it lingers in the DOM holding a (possibly software) GL context.
+45 -14
View File
@@ -1,7 +1,7 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
export type WebGLGateStatus = "software" | "unsupported";
export type WebGLGateStatus = "software" | "unsupported" | "limited";
// Hard-block troubleshooting screen seen by a tiny fraction of sessions (no
// GPU-accelerated WebGL2). The content is intentionally NOT translated — it's
@@ -43,16 +43,39 @@ const STEP_SECTIONS: ReadonlyArray<{ title: string; steps: string[] }> = [
},
];
// Shown for the "limited" status: WebGL works but texture sizes are capped
// below what the game needs, so the map renders black (#4357). The only known
// cause is fingerprinting protection (privacy.resistFingerprinting — on by
// default in LibreWolf and Mullvad Browser, opt-in in Firefox).
const LIMITED_SECTIONS: ReadonlyArray<{ title: string; steps: string[] }> = [
{
title: "Firefox / LibreWolf / Mullvad Browser",
steps: [
"Type about:config in the address bar and press Enter (accept any warning prompts).",
"Search for privacy.resistFingerprinting.exemptedDomains.",
`Add ${window.location.hostname} to the value (comma-separated if other domains are already listed).`,
"Restart your browser.",
],
},
];
const LIMITED_NOTES: string[] = [
"This keeps fingerprinting protection active everywhere else — only this site is exempted.",
"Alternatively, set privacy.resistFingerprinting to false to turn the protection off entirely.",
];
const SAFARI_NOTES: string[] = [
"Mac: WebGL is on by default. If it has been restricted, open Safari > Settings (or Preferences) > Websites > WebGL and set WebGL to Allow or On for this site or globally.",
"iPhone/iPad: WebGL is natively supported and always on for iOS 8 and later.",
];
/**
* Full-screen blocking gate shown when a GPU-accelerated WebGL2 context can't
* be obtained — software rendering (~1fps) or no WebGL2 at all. Shows how to
* turn hardware acceleration / WebGL back on across the most popular browsers.
* Shown imperatively from the game-start path.
* Full-screen blocking gate shown when a usable WebGL2 context can't be
* obtained — software rendering (~1fps), no WebGL2 at all, or texture sizes
* capped by fingerprinting protection. Shows how to turn hardware
* acceleration / WebGL back on (or exempt the site from fingerprinting
* protection) across the most popular browsers. Shown imperatively from the
* game-start path.
*/
@customElement("webgl-gate")
export class WebGLGate extends LitElement {
@@ -64,13 +87,21 @@ export class WebGLGate extends LitElement {
}
render() {
const limited = this.status === "limited";
const software = this.status === "software";
const title = software
? "Hardware acceleration is off"
: "WebGL2 not supported";
const intro = software
? "Your browser is rendering without GPU acceleration, so the game can't run smoothly. Here is how to activate it across the most popular web browsers:"
: "Your browser doesn't support WebGL2, which this game requires. Here is how to enable it across the most popular web browsers:";
const title = limited
? "Your browser is limiting WebGL"
: software
? "Hardware acceleration is off"
: "WebGL2 not supported";
const intro = limited
? 'A privacy setting is capping WebGL texture sizes below what the game needs, so the map would render black. This is usually "resist fingerprinting" protection, which is on by default in some Firefox-based browsers. Here is how to exempt this site:'
: software
? "Your browser is rendering without GPU acceleration, so the game can't run smoothly. Here is how to activate it across the most popular web browsers:"
: "Your browser doesn't support WebGL2, which this game requires. Here is how to enable it across the most popular web browsers:";
const sections = limited ? LIMITED_SECTIONS : STEP_SECTIONS;
const notesTitle = limited ? "Notes" : "Safari";
const notes = limited ? LIMITED_NOTES : SAFARI_NOTES;
return html`
<div
@@ -81,7 +112,7 @@ export class WebGLGate extends LitElement {
>
<h2 class="text-xl font-bold mb-3">${title}</h2>
<p class="text-sm leading-relaxed text-white/85 mb-5">${intro}</p>
${STEP_SECTIONS.map(
${sections.map(
(section) => html`
<section class="mb-5">
<h3 class="text-sm font-bold text-white mb-1.5">
@@ -96,11 +127,11 @@ export class WebGLGate extends LitElement {
`,
)}
<section class="mb-0">
<h3 class="text-sm font-bold text-white mb-1.5">Safari</h3>
<h3 class="text-sm font-bold text-white mb-1.5">${notesTitle}</h3>
<ul
class="pl-5 list-disc text-sm leading-relaxed text-white/85 space-y-1.5"
>
${SAFARI_NOTES.map((note) => html`<li>${note}</li>`)}
${notes.map((note) => html`<li>${note}</li>`)}
</ul>
</section>
</div>
+9 -4
View File
@@ -205,16 +205,21 @@ export class GPURenderer {
this.raf = raf;
this.caf = caf;
// Demand a GPU-accelerated context. A software (SwiftShader) or missing
// WebGL2 context throws GLUnavailableError, which the game-start path turns
// into an actionable gate instead of letting the game crawl at ~1fps.
// Demand a GPU-accelerated context with usable texture limits. A software
// (SwiftShader), fingerprinting-capped, or missing WebGL2 context throws
// GLUnavailableError, which the game-start path turns into an actionable
// gate instead of letting the game crawl at ~1fps or render black.
const res = initGL(canvas, {
alpha: false,
antialias: false,
powerPreference: "high-performance",
});
if (res.status !== "ok") {
throw new GLUnavailableError(res.status, res.renderer);
throw new GLUnavailableError(
res.status,
res.renderer,
res.status === "limited" ? res.maxTextureSize : undefined,
);
}
const gl = res.gl;
this.gl = gl;
+24 -3
View File
@@ -10,10 +10,20 @@
*/
import type { WebGLGateStatus } from "../../components/WebGLGate";
import { getPaletteSize } from "./utils/ColorUtils";
export type GLResult =
| { gl: WebGL2RenderingContext; status: "ok" }
| { gl: null; status: "software" | "unsupported"; renderer: string };
| { gl: null; status: "software" | "unsupported"; renderer: string }
| { gl: null; status: "limited"; renderer: string; maxTextureSize: number };
// The renderer unconditionally allocates a PALETTE_SIZE-wide (4096) palette
// texture, so a context whose MAX_TEXTURE_SIZE is below that can't run the
// game: the oversized texImage2D calls fail silently and everything renders
// black (#4357). In practice this means fingerprinting protection —
// privacy.resistFingerprinting (on by default in LibreWolf, opt-in in
// Firefox) caps MAX_TEXTURE_SIZE at 2048.
const REQUIRED_TEXTURE_SIZE = getPaletteSize();
// Renderer strings reported by software WebGL backends. Mirrors the detection
// in utilities/Diagnostic.ts.
@@ -52,6 +62,12 @@ export function initGL(
if (SOFTWARE_RENDERER.test(renderer)) {
return { gl: null, status: "software", renderer };
}
// Fingerprinting protection caps texture sizes on an otherwise
// hardware-accelerated context; the game would render black (#4357).
const maxTextureSize = Number(accel.getParameter(accel.MAX_TEXTURE_SIZE));
if (maxTextureSize < REQUIRED_TEXTURE_SIZE) {
return { gl: null, status: "limited", renderer, maxTextureSize };
}
return { gl: accel, status: "ok" };
}
@@ -72,8 +88,9 @@ export function initGL(
*/
export class GLUnavailableError extends Error {
constructor(
readonly glStatus: "software" | "unsupported",
readonly glStatus: "software" | "unsupported" | "limited",
readonly renderer: string,
readonly maxTextureSize?: number,
) {
super(`WebGL2 unavailable: ${glStatus}`);
this.name = "GLUnavailableError";
@@ -87,12 +104,16 @@ export class GLUnavailableError extends Error {
* otherwise.
*/
export function trackGLInit(
status: "ok" | "software" | "unsupported",
status: "ok" | "software" | "unsupported" | "limited",
renderer: string,
maxTextureSize?: number,
): void {
window.gtag?.("event", "gl_init", {
status,
renderer: status === "ok" ? "" : renderer,
...(maxTextureSize !== undefined && {
max_texture_size: maxTextureSize,
}),
});
}
+61 -2
View File
@@ -2,17 +2,28 @@ import { GLUnavailableError, initGL } from "../../src/client/render/gl/initGL";
// WEBGL_debug_renderer_info.UNMASKED_RENDERER_WEBGL
const UNMASKED_RENDERER_WEBGL = 0x9246;
// GL_MAX_TEXTURE_SIZE
const MAX_TEXTURE_SIZE = 0x0d33;
// jsdom has no WebGL, so stand in a minimal fake context. When `renderer` is
// provided the fake exposes WEBGL_debug_renderer_info reporting it.
function fakeContext(renderer?: string): WebGL2RenderingContext {
// `maxTextureSize` defaults to a typical desktop-GPU value.
function fakeContext(
renderer?: string,
maxTextureSize = 16384,
): WebGL2RenderingContext {
return {
MAX_TEXTURE_SIZE,
getExtension: (name: string) =>
name === "WEBGL_debug_renderer_info" && renderer !== undefined
? { UNMASKED_RENDERER_WEBGL }
: null,
getParameter: (param: number) =>
param === UNMASKED_RENDERER_WEBGL ? renderer : null,
param === UNMASKED_RENDERER_WEBGL
? renderer
: param === MAX_TEXTURE_SIZE
? maxTextureSize
: null,
} as unknown as WebGL2RenderingContext;
}
@@ -100,6 +111,47 @@ describe("initGL", () => {
}
});
it("reports limited when MAX_TEXTURE_SIZE is below the palette size (fingerprinting cap)", () => {
// privacy.resistFingerprinting (LibreWolf default, Firefox opt-in) caps
// MAX_TEXTURE_SIZE at 2048 on an otherwise hardware-accelerated context;
// the 4096-wide palette texture then fails silently and the map renders
// black (#4357).
stubGetContext({
accelerated: fakeContext("AMD Radeon", 2048),
probe: null,
});
const res = initGL(document.createElement("canvas"));
expect(res.status).toBe("limited");
expect(res.gl).toBeNull();
if (res.status === "limited") {
expect(res.renderer).toBe("AMD Radeon");
expect(res.maxTextureSize).toBe(2048);
}
});
it("returns ok when MAX_TEXTURE_SIZE is exactly the palette size", () => {
const accel = fakeContext("Adreno 640", 4096);
stubGetContext({ accelerated: accel, probe: null });
const res = initGL(document.createElement("canvas"));
expect(res.status).toBe("ok");
expect(res.gl).toBe(accel);
});
it("reports software (not limited) when a software renderer also has capped textures", () => {
stubGetContext({
accelerated: fakeContext("Google SwiftShader", 2048),
probe: null,
});
const res = initGL(document.createElement("canvas"));
expect(res.status).toBe("software");
});
it("reports unsupported when no WebGL2 context can be created at all", () => {
stubGetContext({ accelerated: null, probe: null });
@@ -119,4 +171,11 @@ describe("GLUnavailableError", () => {
expect(err.glStatus).toBe("software");
expect(err.renderer).toBe("SwiftShader");
});
it("carries the capped texture size for the limited status", () => {
const err = new GLUnavailableError("limited", "AMD Radeon", 2048);
expect(err.glStatus).toBe("limited");
expect(err.maxTextureSize).toBe(2048);
});
});