From 5e045f8165db5df1c29a482f2a968069409e4c87 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Sun, 17 May 2026 12:28:34 +0200 Subject: [PATCH] feat(utils): validate and cap render configuration dimensions before allocation Extract pre-allocation dimension checks into a new RenderLimits module. Adds validateRenderDimensions() for integer and engine size-cap checks across displaySize, canvasDisplaySize, and maxCanvasDisplaySize; adds validateWebGPUTextureDimension() for adapter texture-limit checks; and introduces RenderDimensionLimitError to signal hard-abort cases that must not fall back to the software renderer. Four matching error message helpers are centralised in errorMessages.ts. BTAPI and WebGPUContext now delegate to the new validators instead of carrying the logic inline. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- docs/api-core.md | 22 +++-- src/core/BTAPI.test.ts | 87 +++++++++++++++++ src/core/BTAPI.ts | 14 ++- src/core/IBlitTechDemo.ts | 13 ++- src/core/WebGPUContext.ts | 34 ++++++- src/utils/RenderLimits.test.ts | 106 ++++++++++++++++++++ src/utils/RenderLimits.ts | 170 +++++++++++++++++++++++++++++++++ src/utils/errorMessages.ts | 60 ++++++++++++ 8 files changed, 493 insertions(+), 13 deletions(-) create mode 100644 src/utils/RenderLimits.test.ts create mode 100644 src/utils/RenderLimits.ts diff --git a/docs/api-core.md b/docs/api-core.md index 2b8a5a8..f7b5657 100644 --- a/docs/api-core.md +++ b/docs/api-core.md @@ -62,14 +62,20 @@ set `canvas.tabIndex = 0` and call `canvas.focus()` so keyboard events reach the **`HardwareSettings`** (returned from `configure()`): -| Field | Type | Default | Description | -| --------------------- | ------------------------ | ----------- | --------------------------------------------- | -| `displaySize` | `Vector2i` | `320×240` | Logical render resolution | -| `canvasDisplaySize` | `Vector2i` | `640×480` | CSS/output size; enables display-tier effects | -| `targetFPS` | `number` | `60` | Fixed-update rate | -| `renderer` | `'webgpu' \| 'software'` | `'webgpu'` | Force backend | -| `outputUpscaleFilter` | `'nearest' \| 'linear'` | `'nearest'` | Upscale filter | -| `detectDroppedFrames` | `boolean` | `false` | Log a console warning on missed vsync | +| Field | Type | Default | Description | +| ---------------------- | ------------------------ | ----------- | --------------------------------------------- | +| `displaySize` | `Vector2i` | `320×240` | Logical render resolution | +| `canvasDisplaySize` | `Vector2i` | `640×480` | CSS/output size; enables display-tier effects | +| `maxCanvasDisplaySize` | `Vector2i` | `960×720` | Maximum on-screen canvas CSS size | +| `targetFPS` | `number` | `60` | Fixed-update rate | +| `renderer` | `'webgpu' \| 'software'` | `'webgpu'` | Force backend | +| `outputUpscaleFilter` | `'nearest' \| 'linear'` | `'nearest'` | Upscale filter | +| `detectDroppedFrames` | `boolean` | `false` | Log a console warning on missed vsync | + +`displaySize`, `canvasDisplaySize`, and `maxCanvasDisplaySize` must be positive whole-number pixel dimensions. Each size +is capped at `8192×8192` per axis and `16,777,216` total pixels. Invalid sizes make initialization fail before the +engine applies canvas layout or allocates renderer buffers. In WebGPU mode, the requested logical and output sizes must +also fit the active adapter/device `maxTextureDimension2D` limit. **`BT` getters vs `configure()` fields:** `displaySize`, `canvasDisplaySize`, and `targetFPS` on `BT` mirror the same names on `HardwareSettings`. `outputSize` is the effective drawing-buffer size (`canvasDisplaySize ?? displaySize`). diff --git a/src/core/BTAPI.test.ts b/src/core/BTAPI.test.ts index 71b16d6..e41e4bb 100644 --- a/src/core/BTAPI.test.ts +++ b/src/core/BTAPI.test.ts @@ -20,6 +20,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createMockGPUCanvasContext, + createMockGPUDevice, installMockNavigatorGPU, uninstallMockNavigatorGPU, } from '../__test__/webgpu-mock'; @@ -369,6 +370,92 @@ describe('BTAPI', () => { expect(result).toBe(false); }); + it('rejects invalid displaySize before layout or renderer setup', async () => { + const demo: IBlitTechDemo = { + configure: vi.fn().mockReturnValue({ + displaySize: { x: 0, y: 240 } as Vector2i, + targetFPS: 60, + }), + init: vi.fn().mockResolvedValue(true), + update: vi.fn(), + render: vi.fn(), + }; + const canvas = makeMockCanvas(); + const getContext = vi.fn(canvas.getContext.bind(canvas)); + (canvas as unknown as { getContext: typeof getContext }).getContext = getContext; + + const result = await BTAPI.instance.init(demo, canvas); + + expect(result).toBe(false); + expect(canvas.width).toBe(0); + expect(canvas.height).toBe(0); + expect(canvas.style.setProperty).not.toHaveBeenCalled(); + expect(getContext).not.toHaveBeenCalled(); + expect(demo.init).not.toHaveBeenCalled(); + }); + + it('rejects invalid software canvasDisplaySize before software renderer allocation', async () => { + const demo: IBlitTechDemo = { + configure: vi.fn().mockReturnValue({ + displaySize: new Vector2i(320, 240), + canvasDisplaySize: { x: 8193, y: 480 } as Vector2i, + targetFPS: 60, + renderer: 'software', + }), + init: vi.fn().mockResolvedValue(true), + update: vi.fn(), + render: vi.fn(), + }; + const canvas = makeMock2DCanvas(); + const getContext = vi.fn(canvas.getContext.bind(canvas)); + (canvas as unknown as { getContext: typeof getContext }).getContext = getContext; + + const result = await BTAPI.instance.init(demo, canvas); + + expect(result).toBe(false); + expect(canvas.width).toBe(0); + expect(canvas.height).toBe(0); + expect(getContext).not.toHaveBeenCalled(); + }); + + it('rejects WebGPU dimensions above adapter texture limits before canvas allocation', async () => { + const requestDevice = vi.fn(async () => createMockGPUDevice()); + Object.defineProperty(globalThis, 'navigator', { + value: { + gpu: { + requestAdapter: async () => ({ + requestDevice, + features: new Set(), + limits: { maxTextureDimension2D: 1024 } as GPUSupportedLimits, + }), + getPreferredCanvasFormat: () => 'bgra8unorm' as GPUTextureFormat, + }, + userAgent: 'test', + }, + writable: true, + configurable: true, + }); + const demo: IBlitTechDemo = { + configure: vi.fn().mockReturnValue({ + displaySize: new Vector2i(2048, 1024), + targetFPS: 60, + renderer: 'webgpu', + }), + init: vi.fn().mockResolvedValue(true), + update: vi.fn(), + render: vi.fn(), + }; + const canvas = makeMockCanvas(); + + const result = await BTAPI.instance.init(demo, canvas); + + expect(result).toBe(false); + expect(canvas.width).toBe(0); + expect(canvas.height).toBe(0); + expect(requestDevice).not.toHaveBeenCalled(); + expect(demo.init).not.toHaveBeenCalled(); + }); + it('returns false when both WebGPU and software renderer init fail', async () => { uninstallMockNavigatorGPU(); diff --git a/src/core/BTAPI.ts b/src/core/BTAPI.ts index f7dcad6..4f62eaa 100644 --- a/src/core/BTAPI.ts +++ b/src/core/BTAPI.ts @@ -28,6 +28,7 @@ import { spriteNotIndexizedError, } from '../utils/errorMessages'; import type { Rect2i } from '../utils/Rect2i'; +import { RenderDimensionLimitError, validateRenderDimensions } from '../utils/RenderLimits'; import { Vector2i } from '../utils/Vector2i'; import type { FrameDropCallback, FrameDropEvent } from './GameLoop'; import { GameLoop } from './GameLoop'; @@ -194,6 +195,13 @@ export class BTAPI { this.applyRendererQueryOverride(); const { targetFPS } = this.hwSettings; + const renderDimensionError = validateRenderDimensions(this.hwSettings); + + if (renderDimensionError) { + console.error(`[BT] ${renderDimensionError}`); + + return false; + } if (!Number.isFinite(targetFPS) || targetFPS <= 0) { console.error(`[BT] Invalid targetFPS: ${targetFPS}. Must be a finite number > 0.`); @@ -877,7 +885,11 @@ export class BTAPI { let webGPUResult: Awaited> = null; try { webGPUResult = await initWebGPU(canvas, hw.displaySize, hw.canvasDisplaySize); - } catch { + } catch (error) { + if (error instanceof RenderDimensionLimitError) { + return false; + } + // Adapter/device unavailable; fall through to software. } diff --git a/src/core/IBlitTechDemo.ts b/src/core/IBlitTechDemo.ts index 92b3186..7aa2d1d 100644 --- a/src/core/IBlitTechDemo.ts +++ b/src/core/IBlitTechDemo.ts @@ -26,7 +26,12 @@ export type RendererBackend = 'webgpu' | 'software'; * implements that optional hook, or by {@link defaultConfig} otherwise. */ export interface HardwareSettings { - /** Logical render resolution in pixels (e.g. `320x240`). */ + /** + * Logical render resolution in pixels (e.g. `320x240`). + * + * Must use positive whole-number dimensions no larger than `8192x8192` + * and no more than `16,777,216` total pixels. + */ displaySize: Vector2i; /** @@ -39,6 +44,9 @@ export interface HardwareSettings { * Display-tier effects need this to be larger than `displaySize` to express * curvature/scanlines/etc. cleanly without floor-quantizing onto the * logical pixel grid. + * + * Must use positive whole-number dimensions no larger than `8192x8192` + * and no more than `16,777,216` total pixels. */ canvasDisplaySize?: Vector2i; @@ -46,6 +54,9 @@ export interface HardwareSettings { * Maximum on-screen canvas size in CSS pixels. The demos layout scales the * canvas up to the viewport (preserving aspect ratio) but not beyond this * size. Defaults to `960x720` in {@link defaultConfig}. + * + * Must use positive whole-number dimensions no larger than `8192x8192` + * and no more than `16,777,216` total pixels. */ maxCanvasDisplaySize?: Vector2i; diff --git a/src/core/WebGPUContext.ts b/src/core/WebGPUContext.ts index 9dda80b..616ffcd 100644 --- a/src/core/WebGPUContext.ts +++ b/src/core/WebGPUContext.ts @@ -1,4 +1,5 @@ import { WEBGPU_ADAPTER_MESSAGE, WEBGPU_DEVICE_MESSAGE } from '../utils/errorMessages'; +import { RenderDimensionLimitError, validateWebGPUTextureDimension } from '../utils/RenderLimits'; import type { Vector2i } from '../utils/Vector2i'; // #region Types @@ -68,6 +69,22 @@ export async function initWebGPU( throw new Error(WEBGPU_ADAPTER_MESSAGE); } + // Drawing buffer = canvasDisplaySize when provided, else logical displaySize. + const drawingBufferSize = canvasDisplaySize ?? displaySize; + const adapterLimit = adapter.limits?.maxTextureDimension2D; + const adapterDisplayError = validateWebGPUTextureDimension('displaySize', displaySize, adapterLimit); + const adapterOutputError = + canvasDisplaySize === undefined + ? null + : validateWebGPUTextureDimension('canvasDisplaySize', drawingBufferSize, adapterLimit); + const adapterDimensionError = adapterDisplayError ?? adapterOutputError; + + if (adapterDimensionError) { + console.error(`[BT] ${adapterDimensionError}`); + + throw new RenderDimensionLimitError(adapterDimensionError); + } + // Request device. let device: GPUDevice; @@ -79,11 +96,22 @@ export async function initWebGPU( throw new Error(WEBGPU_DEVICE_MESSAGE, { cause: err }); } - // Drawing buffer = canvasDisplaySize when provided, else logical displaySize. + const deviceLimit = device.limits?.maxTextureDimension2D; + const deviceDisplayError = validateWebGPUTextureDimension('displaySize', displaySize, deviceLimit); + const deviceOutputError = + canvasDisplaySize === undefined + ? null + : validateWebGPUTextureDimension('canvasDisplaySize', drawingBufferSize, deviceLimit); + const deviceDimensionError = deviceDisplayError ?? deviceOutputError; + + if (deviceDimensionError) { + console.error(`[BT] ${deviceDimensionError}`); + + throw new RenderDimensionLimitError(deviceDimensionError); + } + // Set canvas resolution BEFORE getting the WebGPU context so getCurrentTexture() // returns valid textures. - const drawingBufferSize = canvasDisplaySize ?? displaySize; - canvas.width = drawingBufferSize.x; canvas.height = drawingBufferSize.y; diff --git a/src/utils/RenderLimits.test.ts b/src/utils/RenderLimits.test.ts new file mode 100644 index 0000000..543f263 --- /dev/null +++ b/src/utils/RenderLimits.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +import { + MAX_RENDER_DIMENSION, + MAX_RENDER_PIXELS, + type RenderDimensionField, + type RenderDimensionSettings, + validateRenderDimensions, + validateWebGPUTextureDimension, +} from './RenderLimits'; +import { Vector2i } from './Vector2i'; + +// #region Helpers + +function rawSize(x: number, y: number): Vector2i { + return { x, y } as Vector2i; +} + +function makeSettings(field: RenderDimensionField, size: Vector2i): RenderDimensionSettings { + switch (field) { + case 'canvasDisplaySize': + return { + displaySize: new Vector2i(320, 240), + canvasDisplaySize: size, + maxCanvasDisplaySize: new Vector2i(960, 720), + }; + case 'maxCanvasDisplaySize': + return { + displaySize: new Vector2i(320, 240), + canvasDisplaySize: new Vector2i(640, 480), + maxCanvasDisplaySize: size, + }; + case 'displaySize': + return { + displaySize: size, + canvasDisplaySize: new Vector2i(640, 480), + maxCanvasDisplaySize: new Vector2i(960, 720), + }; + } +} + +// #endregion + +describe('RenderLimits', () => { + describe('validateRenderDimensions', () => { + const fields: RenderDimensionField[] = ['displaySize', 'canvasDisplaySize', 'maxCanvasDisplaySize']; + const cases: Array<{ name: string; size: Vector2i }> = [ + { name: 'zero', size: rawSize(0, 240) }, + { name: 'negative', size: rawSize(-1, 240) }, + { name: 'fractional', size: rawSize(320.5, 240) }, + { name: 'NaN', size: rawSize(Number.NaN, 240) }, + { name: 'Infinity', size: rawSize(Number.POSITIVE_INFINITY, 240) }, + { name: 'huge width', size: rawSize(MAX_RENDER_DIMENSION + 1, 240) }, + { name: 'huge height', size: rawSize(320, MAX_RENDER_DIMENSION + 1) }, + { name: 'huge area', size: rawSize(4096, 4097) }, + ]; + + for (const field of fields) { + for (const testCase of cases) { + it(`rejects ${testCase.name} ${field}`, () => { + const error = validateRenderDimensions(makeSettings(field, testCase.size)); + + expect(error).toContain(field); + }); + } + } + + it('accepts the default render dimensions', () => { + expect( + validateRenderDimensions({ + displaySize: new Vector2i(320, 240), + canvasDisplaySize: new Vector2i(640, 480), + maxCanvasDisplaySize: new Vector2i(960, 720), + }), + ).toBeNull(); + }); + + it('accepts omitted optional canvas dimensions', () => { + expect( + validateRenderDimensions({ + displaySize: new Vector2i(320, 240), + }), + ).toBeNull(); + }); + + it('rejects total area separately from per-axis limits', () => { + const error = validateRenderDimensions({ + displaySize: rawSize(4096, 4097), + }); + + expect(error).toContain(MAX_RENDER_PIXELS.toLocaleString('en-US')); + }); + }); + + describe('validateWebGPUTextureDimension', () => { + it('rejects dimensions above the provided WebGPU texture limit', () => { + const error = validateWebGPUTextureDimension('canvasDisplaySize', new Vector2i(2048, 1024), 1024); + + expect(error).toContain('graphics card'); + }); + + it('ignores missing WebGPU texture limits', () => { + expect(validateWebGPUTextureDimension('displaySize', new Vector2i(2048, 1024), undefined)).toBeNull(); + }); + }); +}); diff --git a/src/utils/RenderLimits.ts b/src/utils/RenderLimits.ts new file mode 100644 index 0000000..4cf4196 --- /dev/null +++ b/src/utils/RenderLimits.ts @@ -0,0 +1,170 @@ +import { + renderDimensionAreaTooLargeError, + renderDimensionGpuLimitError, + renderDimensionInvalidError, + renderDimensionTooLargeError, +} from './errorMessages'; +import type { Vector2i } from './Vector2i'; + +// #region Constants + +/** Maximum render dimension accepted on either axis before renderer allocation. */ +export const MAX_RENDER_DIMENSION = 8192; + +/** Maximum total pixels accepted for a render-sized buffer before renderer allocation. */ +export const MAX_RENDER_PIXELS = 4096 * 4096; + +/** Shared render allocation policy for logical, output, and canvas-layout dimensions. */ +export const RENDER_DIMENSION_LIMITS = { + maxWidth: MAX_RENDER_DIMENSION, + maxHeight: MAX_RENDER_DIMENSION, + maxPixels: MAX_RENDER_PIXELS, +} as const; + +// #endregion + +// #region Types + +/** Hardware settings fields that carry render or canvas dimensions. */ +export type RenderDimensionField = 'displaySize' | 'canvasDisplaySize' | 'maxCanvasDisplaySize'; + +/** Error type for render-dimension failures that must abort instead of falling back to another backend. */ +export class RenderDimensionLimitError extends Error { + /** + * Creates a render-dimension limit error. + * + * @param message - User-facing validation message. + */ + constructor(message: string) { + super(message); + this.name = 'RenderDimensionLimitError'; + } +} + +/** Minimal settings shape needed for render-dimension validation. */ +export interface RenderDimensionSettings { + /** Logical render resolution in pixels. */ + displaySize: Vector2i; + /** Optional output drawing-buffer size in pixels. */ + canvasDisplaySize?: Vector2i; + /** Optional maximum on-screen canvas CSS size in pixels. */ + maxCanvasDisplaySize?: Vector2i; +} + +// #endregion + +// #region Helpers + +/** + * Formats a render dimension for error messages. + * + * @param size - Size value to format. + * @returns Size formatted as `WIDTHxHEIGHT`. + */ +function formatSize(size: Vector2i | undefined): string { + if (size === undefined) { + return 'missing'; + } + + return `${size.x}x${size.y}`; +} + +/** + * Validates a single render dimension against integer and engine allocation limits. + * + * @param field - Hardware settings field being checked. + * @param size - Size value to validate. + * @returns A user-facing error message when invalid, otherwise `null`. + */ +export function validateRenderDimension(field: RenderDimensionField, size: Vector2i | undefined): string | null { + const x = size?.x; + const y = size?.y; + + if ( + typeof x !== 'number' || + typeof y !== 'number' || + !Number.isFinite(x) || + !Number.isFinite(y) || + !Number.isInteger(x) || + !Number.isInteger(y) || + x <= 0 || + y <= 0 + ) { + return renderDimensionInvalidError(field, formatSize(size)); + } + + if (x > RENDER_DIMENSION_LIMITS.maxWidth || y > RENDER_DIMENSION_LIMITS.maxHeight) { + return renderDimensionTooLargeError( + field, + formatSize(size), + RENDER_DIMENSION_LIMITS.maxWidth, + RENDER_DIMENSION_LIMITS.maxHeight, + ); + } + + if (x * y > RENDER_DIMENSION_LIMITS.maxPixels) { + return renderDimensionAreaTooLargeError(field, formatSize(size), RENDER_DIMENSION_LIMITS.maxPixels); + } + + return null; +} + +/** + * Validates all render dimensions in hardware settings before renderer allocation. + * + * @param settings - Hardware settings returned by `configure()` or defaults. + * @returns A user-facing error message when invalid, otherwise `null`. + */ +export function validateRenderDimensions(settings: RenderDimensionSettings): string | null { + const displayError = validateRenderDimension('displaySize', settings.displaySize); + if (displayError) { + return displayError; + } + + if (settings.canvasDisplaySize !== undefined) { + const canvasDisplayError = validateRenderDimension('canvasDisplaySize', settings.canvasDisplaySize); + if (canvasDisplayError) { + return canvasDisplayError; + } + } + + if (settings.maxCanvasDisplaySize !== undefined) { + const maxCanvasDisplayError = validateRenderDimension('maxCanvasDisplaySize', settings.maxCanvasDisplaySize); + if (maxCanvasDisplayError) { + return maxCanvasDisplayError; + } + } + + return null; +} + +/** + * Validates a render texture size against the current WebGPU adapter/device limit. + * + * @param field - Hardware settings field being checked. + * @param size - Requested texture or drawing-buffer size. + * @param maxTextureDimension2D - WebGPU `maxTextureDimension2D`, when exposed. + * @returns A user-facing error message when invalid, otherwise `null`. + */ +export function validateWebGPUTextureDimension( + field: RenderDimensionField, + size: Vector2i, + maxTextureDimension2D: number | undefined, +): string | null { + if ( + maxTextureDimension2D === undefined || + !Number.isFinite(maxTextureDimension2D) || + maxTextureDimension2D <= 0 || + !Number.isInteger(maxTextureDimension2D) + ) { + return null; + } + + if (size.x > maxTextureDimension2D || size.y > maxTextureDimension2D) { + return renderDimensionGpuLimitError(field, formatSize(size), maxTextureDimension2D); + } + + return null; +} + +// #endregion diff --git a/src/utils/errorMessages.ts b/src/utils/errorMessages.ts index ccc0d2b..1371a64 100644 --- a/src/utils/errorMessages.ts +++ b/src/utils/errorMessages.ts @@ -44,6 +44,66 @@ export const WEBGPU_ADAPTER_MESSAGE = export const WEBGPU_DEVICE_MESSAGE = "Couldn't connect to the graphics card. Try closing other tabs or restarting the browser."; +// #region Runtime — Render configuration + +/** + * Returns the error message for a render dimension that is not a positive whole-number pixel size. + * + * @param field - Hardware settings field that contains the invalid size. + * @param size - Invalid size formatted as `WIDTHxHEIGHT`. + * @returns User-facing error string. + */ +export function renderDimensionInvalidError(field: string, size: string): string { + return ( + `${field} must use whole-number pixel dimensions greater than 0 (got ${size}). ` + + 'Update configure() to return a positive integer width and height' + ); +} + +/** + * Returns the error message for a render dimension whose width or height exceeds engine limits. + * + * @param field - Hardware settings field that contains the invalid size. + * @param size - Invalid size formatted as `WIDTHxHEIGHT`. + * @param maxWidth - Maximum accepted width in pixels. + * @param maxHeight - Maximum accepted height in pixels. + * @returns User-facing error string. + */ +export function renderDimensionTooLargeError(field: string, size: string, maxWidth: number, maxHeight: number): string { + return ( + `${field} is too large (got ${size}). ` + `Use a size no larger than ${maxWidth}x${maxHeight} in configure()` + ); +} + +/** + * Returns the error message for a render dimension whose total pixel area exceeds engine limits. + * + * @param field - Hardware settings field that contains the invalid size. + * @param size - Invalid size formatted as `WIDTHxHEIGHT`. + * @param maxPixels - Maximum accepted total pixels. + * @returns User-facing error string. + */ +export function renderDimensionAreaTooLargeError(field: string, size: string, maxPixels: number): string { + return `${field} uses too many pixels (got ${size}). Use a size with ${maxPixels.toLocaleString('en-US')} total pixels or fewer`; +} + +/** + * Returns the error message for a render dimension that exceeds the active WebGPU texture limit. + * + * @param field - Hardware settings field that contains the invalid size. + * @param size - Invalid size formatted as `WIDTHxHEIGHT`. + * @param maxTextureDimension2D - WebGPU adapter/device texture dimension limit. + * @returns User-facing error string. + */ +export function renderDimensionGpuLimitError(field: string, size: string, maxTextureDimension2D: number): string { + return ( + `${field} is too large for this graphics card (got ${size}). ` + + `Use a width and height of ${maxTextureDimension2D} pixels or fewer` + ); +} + +// #endregion + // #region Runtime — Palette /** -- 2.51.2