@@ -1137,6 +1185,20 @@ export class HelpModal extends BaseModal {
`;
}
+ openTroubleshooting() {
+ const troubleshootingModal = document.querySelector(
+ "troubleshooting-modal",
+ ) as TroubleshootingModal;
+ if (
+ !troubleshootingModal ||
+ !(troubleshootingModal instanceof TroubleshootingModal)
+ ) {
+ console.warn("Troubleshooting modal element not found");
+ return;
+ }
+ troubleshootingModal.open();
+ }
+
protected onOpen(): void {
this.keybinds = this.getKeybinds();
}
diff --git a/src/client/Main.ts b/src/client/Main.ts
index 8858b2f43..b89c7a3cd 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -813,6 +813,7 @@ class Client {
"game-top-bar",
"help-modal",
"user-setting",
+ "troubleshooting-modal",
"territory-patterns-modal",
"language-modal",
"news-modal",
diff --git a/src/client/TroubleshootingModal.ts b/src/client/TroubleshootingModal.ts
new file mode 100644
index 000000000..4a017a4fe
--- /dev/null
+++ b/src/client/TroubleshootingModal.ts
@@ -0,0 +1,254 @@
+import { html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { translateText } from "./Utils";
+import { BaseModal } from "./components/BaseModal";
+import "./components/baseComponents/Modal";
+import { modalHeader } from "./components/ui/ModalHeader";
+import {
+ collectGraphicsDiagnostics,
+ GraphicsDiagnostics,
+} from "./utilities/Diagnostic";
+import infoIcon from "/images/InfoIcon.svg?url";
+
+@customElement("troubleshooting-modal")
+export class TroubleshootingModal extends BaseModal {
+ @property({ type: String }) markdown = "Loading...";
+
+ @property({ type: Object })
+ diagnostics?: GraphicsDiagnostics;
+
+ @property({ type: Boolean }) loading = true;
+
+ private initialized: boolean = false;
+
+ private async loadDiagnostics() {
+ const canvas = document.createElement("canvas");
+ this.diagnostics = await collectGraphicsDiagnostics(canvas);
+ this.loading = false;
+ this.initialized = true;
+ }
+
+ render() {
+ const content = html`
+
+ ${modalHeader({
+ titleContent: html`
`,
+ onBack: this.close,
+ ariaLabel: translateText("common.back"),
+ })}
+ ${this.loading
+ ? ""
+ : html`
+
+ ${this.section(
+ "",
+ html`${this.infoTip(
+ translateText("troubleshooting.hardware_acceleration_tip"),
+ true,
+ )}`,
+ )}
+ ${this.section(
+ translateText("troubleshooting.environment"),
+ html`
+ ${this.row(
+ translateText("troubleshooting.browser"),
+ this.diagnostics!.browser.engine,
+ )}
+ ${this.row(
+ translateText("troubleshooting.platform"),
+ this.diagnostics!.browser.platform,
+ )}
+ ${this.row(
+ translateText("troubleshooting.os"),
+ this.diagnostics!.browser.os,
+ )}
+ ${this.row(
+ translateText("troubleshooting.device_pixel_ratio"),
+ this.diagnostics!.browser.dpr,
+ )}
+ ${this.infoTip(
+ translateText("troubleshooting.chromium_tip"),
+ )}
+ `,
+ )}
+ ${this.section(
+ translateText("troubleshooting.rendering"),
+ html`
+ ${this.row(
+ translateText("troubleshooting.renderer"),
+ this.describeRenderer(this.diagnostics!.rendering),
+ )}
+ ${this.row(
+ translateText("troubleshooting.max_texture_size"),
+ this.diagnostics!.rendering.maxTextureSize ??
+ translateText("troubleshooting.unknown"),
+ )}
+ ${this.row(
+ translateText("troubleshooting.high_precision_shaders"),
+ this.diagnostics!.rendering.shaderHighp === true
+ ? translateText("troubleshooting.yes")
+ : translateText("troubleshooting.no"),
+ )}${this.row(
+ translateText("troubleshooting.gpu"),
+ !this.diagnostics!.rendering.gpu ||
+ this.diagnostics!.rendering.gpu.unavailable
+ ? translateText("troubleshooting.unavailable")
+ : `${this.diagnostics!.rendering.gpu.vendor} — ${this.diagnostics!.rendering.gpu.renderer}`,
+ )}
+ ${this.infoTip(translateText("troubleshooting.gpu_tip"))}
+ `,
+ )}
+ ${this.section(
+ translateText("troubleshooting.power"),
+ html`
+ ${this.diagnostics!.power.unavailable
+ ? this.row(
+ translateText("troubleshooting.battery"),
+ translateText("troubleshooting.unavailable"),
+ )
+ : html`
+ ${this.row(
+ translateText("troubleshooting.charging"),
+ this.diagnostics!.power.charging
+ ? translateText("troubleshooting.yes")
+ : translateText("troubleshooting.no"),
+ )}
+ ${this.row(
+ translateText("troubleshooting.battery_level"),
+ this.diagnostics!.power.level,
+ )}
+ `}
+ ${this.infoTip(
+ translateText("troubleshooting.power_saving_tip"),
+ )}
+ `,
+ )}
+
+ `}
+
+ `;
+
+ if (this.inline) {
+ return content;
+ }
+
+ return html`
+
+ ${content}
+
+ `;
+ }
+
+ private infoTip(text: string, warning?: boolean): unknown {
+ return html`
+
+

+ ${text}
+
+ `;
+ }
+
+ protected onOpen(): void {
+ if (!this.initialized) {
+ this.initialized = true;
+ this.loadDiagnostics();
+ }
+ }
+
+ private section(title: string, content: unknown) {
+ return html`
+
+
+ ${title}
+
+
${content}
+
+ `;
+ }
+
+ private row(label: string, value: unknown) {
+ return html`
+
+ ${label}
+ ${value}
+
+ `;
+ }
+
+ private async copyDiagnostics() {
+ if (!this.diagnostics) return;
+ const formatted =
+ "```json\n" + JSON.stringify(this.diagnostics, null, 2) + "\n```";
+ await navigator.clipboard.writeText(formatted);
+ window.dispatchEvent(
+ new CustomEvent("show-message", {
+ detail: {
+ message: html`${translateText("troubleshooting.copied_to_clipboard")}`,
+ type: "info",
+ duration: 3000,
+ },
+ }),
+ );
+ }
+
+ private describeRenderer(rendering: any): string {
+ if (rendering.gpu?.software) {
+ return translateText("troubleshooting.software_rendering");
+ }
+ if (rendering.type === "Canvas2D") {
+ return translateText("troubleshooting.canvas_2d_no_gpu");
+ }
+ return `${rendering.type}`;
+ }
+
+ public close(): void {
+ this.unregisterEscapeHandler();
+
+ if (this.inline) {
+ this.style.pointerEvents = "none";
+ if (window.showPage) {
+ window.showPage?.("page-help");
+ }
+ } else {
+ this.modalEl?.close();
+ }
+ }
+}
diff --git a/src/client/utilities/Diagnostic.ts b/src/client/utilities/Diagnostic.ts
new file mode 100644
index 000000000..dc6553071
--- /dev/null
+++ b/src/client/utilities/Diagnostic.ts
@@ -0,0 +1,141 @@
+export type RendererType = "Canvas2D" | "WebGL1" | "WebGL2";
+
+export interface BrowserInfo {
+ engine: string;
+ platform: string;
+ os: string;
+ dpr: number;
+}
+
+export interface GraphicsDiagnostics {
+ browser: BrowserInfo;
+ rendering: RenderingInfo;
+ power: PowerInfo;
+}
+
+export interface GPUInfo {
+ vendor?: string;
+ renderer?: string;
+ software?: boolean;
+ unavailable?: boolean;
+}
+
+export interface RenderingInfo {
+ type: RendererType;
+ antialias?: boolean;
+ maxTextureSize?: number;
+ shaderHighp?: boolean;
+ gpu?: GPUInfo;
+}
+
+export interface PerformanceInfo {
+ fps: number;
+ worstFrameMs: number;
+ jankPercent: number;
+ throttlingLikely: boolean;
+}
+
+export interface PowerInfo {
+ charging?: boolean;
+ level?: string;
+ unavailable?: boolean;
+}
+
+export async function collectGraphicsDiagnostics(
+ canvas: HTMLCanvasElement,
+): Promise
{
+ /* ---------- Browser / OS ---------- */
+
+ const uaData = (navigator as any).userAgentData;
+
+ const os = uaData?.platform ?? detectOS(navigator.userAgent);
+
+ const browser: BrowserInfo = {
+ engine: uaData?.brands
+ ? uaData.brands.map((b: any) => b.brand).join(", ")
+ : navigator.userAgent,
+ platform: navigator.platform,
+ os,
+ dpr: window.devicePixelRatio,
+ };
+
+ /* ---------- Rendering ---------- */
+
+ let gl: WebGLRenderingContext | WebGL2RenderingContext | null = null;
+ let type: RendererType = "Canvas2D";
+
+ gl =
+ canvas.getContext("webgl2", { antialias: true }) ??
+ canvas.getContext("webgl", { antialias: true });
+
+ if (gl) {
+ const isWebGL2 =
+ typeof WebGL2RenderingContext !== "undefined" &&
+ gl instanceof WebGL2RenderingContext;
+ type = isWebGL2 ? "WebGL2" : "WebGL1";
+ }
+
+ const rendering: RenderingInfo = { type };
+
+ if (gl) {
+ rendering.antialias = gl.getContextAttributes()?.antialias ?? false;
+ rendering.maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
+
+ const precision = gl.getShaderPrecisionFormat(
+ gl.FRAGMENT_SHADER,
+ gl.HIGH_FLOAT,
+ );
+ rendering.shaderHighp = precision !== null && precision.precision > 0;
+
+ const debugInfo = gl.getExtension("WEBGL_debug_renderer_info");
+
+ if (debugInfo) {
+ const renderer = gl.getParameter(
+ (debugInfo as any).UNMASKED_RENDERER_WEBGL,
+ ) as string;
+
+ const vendor = gl.getParameter(
+ (debugInfo as any).UNMASKED_VENDOR_WEBGL,
+ ) as string;
+ rendering.gpu = {
+ vendor,
+ renderer,
+ software: /swiftshader|llvmpipe|software/i.test(renderer),
+ };
+ } else {
+ rendering.gpu = { unavailable: true };
+ }
+ }
+
+ /* ---------- Power ---------- */
+
+ let power: PowerInfo = {};
+
+ if ("getBattery" in navigator) {
+ try {
+ const battery = await (navigator as any).getBattery();
+ power = {
+ charging: battery.charging,
+ level: Math.round(battery.level * 100) + "%",
+ };
+ } catch {
+ power = { unavailable: true };
+ }
+ } else {
+ power = { unavailable: true };
+ }
+ return {
+ browser,
+ rendering,
+ power,
+ };
+}
+
+function detectOS(ua: string): string {
+ if (/windows nt/i.test(ua)) return "Windows";
+ if (/mac os x/i.test(ua)) return "macOS";
+ if (/android/i.test(ua)) return "Android";
+ if (/iphone|ipad|ipod/i.test(ua)) return "iOS";
+ if (/linux/i.test(ua)) return "Linux";
+ return "Unknown";
+}