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 /**