diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts index 5d564870fe..2b15a299e2 100644 --- a/src/client/WebGLFrameBuilder.ts +++ b/src/client/WebGLFrameBuilder.ts @@ -12,7 +12,7 @@ import { TRAIL_EFFECT_TYPES, type TrailEffectAttributes, } from "../core/CosmeticSchemas"; -import { decodePatternData } from "../core/PatternDecoder"; +import { decodePatternData, PATTERN_ROW_BYTES } from "../core/PatternDecoder"; import { PlayerType } from "../core/game/Game"; import { getCachedCosmetics } from "./Cosmetics"; import { uploadFrameData } from "./render/frame/Upload"; @@ -164,7 +164,7 @@ export class WebGLFrameBuilder { PALETTE_SIZE * MAX_TRAIL_COLORS * EFFECT_PALETTE_BLOCKS * 4, ); this.patternMeta = new Float32Array(PALETTE_SIZE * 4); - this.patternData = new Uint8Array(PALETTE_SIZE * 1024); + this.patternData = new Uint8Array(PALETTE_SIZE * PATTERN_ROW_BYTES); } /** Drop internal caches to force a full re-upload of state on the next update(). */ @@ -459,7 +459,16 @@ export class WebGLFrameBuilder { this.patternMeta[metaOff + 2] = decoded.height; this.patternMeta[metaOff + 3] = decoded.scale; - this.patternData.set(decoded.bytes.slice(3), smallID * 1024); + const patternBytes = decoded.bytes.slice(decoded.headerBytes); + if (patternBytes.length > PATTERN_ROW_BYTES) { + console.warn( + "Pattern too large for row buffer", + decoded.width, + decoded.height, + ); + } else { + this.patternData.set(patternBytes, smallID * PATTERN_ROW_BYTES); + } } catch (e) { console.warn("Failed to decode territory pattern", e); } diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index db83d24219..675ea98b3a 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -11,6 +11,7 @@ import type { Config } from "../../../core/configuration/Config"; import type { MapLayer } from "../../../core/game/TerrainMapLoader"; +import { PATTERN_ROW_BYTES } from "../../../core/PatternDecoder"; import { translateText } from "../../Utils"; import type { SpiralRibbon } from "../frame/SpiralTrails"; import type { @@ -334,12 +335,12 @@ export class GPURenderer { }); this.patternDataTex = createTexture2D(gl, { - width: 1024, + width: PATTERN_ROW_BYTES, height: palW, internalFormat: gl.R8UI, format: gl.RED_INTEGER, type: gl.UNSIGNED_BYTE, - data: new Uint8Array(palW * 1024), + data: new Uint8Array(palW * PATTERN_ROW_BYTES), filter: gl.NEAREST, }); @@ -788,7 +789,7 @@ export class GPURenderer { 0, 0, 0, - 1024, + PATTERN_ROW_BYTES, palW, gl.RED_INTEGER, gl.UNSIGNED_BYTE, diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index b51723ab19..cf32bbb51a 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -1,6 +1,6 @@ import { base64url } from "jose"; import { z } from "zod/v4"; -import { decodePatternData } from "./PatternDecoder"; +import { decodePatternData, MAX_PATTERN_DATA_LENGTH } from "./PatternDecoder"; import { PlayerPattern } from "./Schemas"; export type Cosmetics = z.infer; @@ -47,7 +47,7 @@ export const CosmeticNameSchema = z export const PatternDataSchema = z .string() - .max(1403) + .max(MAX_PATTERN_DATA_LENGTH) .base64url() .refine( (val) => { diff --git a/src/core/PatternDecoder.ts b/src/core/PatternDecoder.ts index 8908d7f0a4..8fa980d087 100644 --- a/src/core/PatternDecoder.ts +++ b/src/core/PatternDecoder.ts @@ -1,20 +1,67 @@ -import { PlayerPattern } from "./Schemas"; +/** + * Header sizes in bytes, per pattern format version. + * + * v0: [version][scale:3 | width_lo:5][width_hi:2 | height:6] + * Width is allocated 7 bits (max 129) and height only 6 (max 65). + * Both metadata bytes are fully packed, so the format cannot be + * extended in place. + * + * v1: [version][scale:3 | width_hi:1 | height_hi:1 | reserved:3] + * [width_lo:8][height_lo:8] + * Width and height each get 9 bits (max 513). Three bits remain + * reserved for future use and must be zero. + */ +const HEADER_BYTES_V0 = 3; +const HEADER_BYTES_V1 = 4; + +/** + * Maximum canvas dimension accepted for v1 patterns. + * + * This is a policy limit, deliberately set below what the v1 header can + * express (513). Raising it later is a one-line change that needs no + * format version bump. It bounds the size of user-submitted pattern data + * that reaches server-side validation. + */ +export const MAX_PATTERN_DIMENSION = 128; + +/** + * Maximum base64url length of a valid pattern string, derived from the v1 + * header size and MAX_PATTERN_DIMENSION. Used to bound user-submitted data + * before it reaches the decoder. + */ +const MAX_PATTERN_BYTES = + HEADER_BYTES_V1 + ((MAX_PATTERN_DIMENSION * MAX_PATTERN_DIMENSION + 7) >> 3); +export const MAX_PATTERN_DATA_LENGTH = ((MAX_PATTERN_BYTES * 4 + 2) / 3) | 0; + +/** + * Bytes reserved per player in the renderer's pattern-data texture (one row + * each). Sized for the largest pattern the decoder accepts, so the CPU side + * that fills the buffer and the GPU side that allocates the texture can't + * drift from the format's limit. + */ +export const PATTERN_ROW_BYTES = + (MAX_PATTERN_DIMENSION * MAX_PATTERN_DIMENSION + 7) >> 3; export class PatternDecoder { private bytes: Uint8Array; + private readonly headerBytes: number; readonly height: number; readonly width: number; readonly scale: number; + // Structural rather than importing PlayerPattern from Schemas — that import + // closes a Schemas -> CosmeticSchemas -> PatternDecoder cycle, which matters + // now that CosmeticSchemas reads MAX_PATTERN_DATA_LENGTH at module scope. constructor( - pattern: PlayerPattern, + pattern: { patternData: string; [key: string]: unknown }, base64urlDecode: (input: string) => Uint8Array, ) { ({ height: this.height, width: this.width, scale: this.scale, + headerBytes: this.headerBytes, bytes: this.bytes, } = decodePatternData(pattern.patternData, base64urlDecode)); } @@ -25,7 +72,7 @@ export class PatternDecoder { const idx = py * this.width + px; const byteIndex = idx >> 3; const bitIndex = idx & 7; - const byte = this.bytes[3 + byteIndex]; + const byte = this.bytes[this.headerBytes + byteIndex]; if (byte === undefined) throw new Error("Invalid pattern"); return (byte & (1 << bitIndex)) === 0; @@ -43,30 +90,98 @@ export class PatternDecoder { export function decodePatternData( b64: string, base64urlDecode: (input: string) => Uint8Array, -): { height: number; width: number; scale: number; bytes: Uint8Array } { +): { + height: number; + width: number; + scale: number; + headerBytes: number; + bytes: Uint8Array; +} { const bytes = base64urlDecode(b64); - if (bytes.length < 3) { + if (bytes.length < 1) { throw new Error("Pattern data is too short to contain required metadata."); } const version = bytes[0]; - if (version !== 0) { - throw new Error(`Unrecognized pattern version ${version}.`); - } - const byte1 = bytes[1]; - const byte2 = bytes[2]; - const scale = byte1 & 0x07; + let width: number; + let height: number; + let scale: number; + let headerBytes: number; + + switch (version) { + case 0: { + headerBytes = HEADER_BYTES_V0; + if (bytes.length < headerBytes) { + throw new Error( + "Pattern data is too short to contain required metadata.", + ); + } + + const byte1 = bytes[1]; + const byte2 = bytes[2]; + + scale = byte1 & 0x07; + width = (((byte2 & 0x03) << 5) | ((byte1 >> 3) & 0x1f)) + 2; + height = ((byte2 >> 2) & 0x3f) + 2; + + // v0 cannot express dimensions above 129x65, so a longer payload is + // malformed. Previously bounded by the schema's max length, which v1 + // raises - keep v0 bounded here instead. + if (bytes.length - headerBytes > 1049) { + throw new Error("Pattern data is too long for the v0 format."); + } + break; + } + + case 1: { + headerBytes = HEADER_BYTES_V1; + if (bytes.length < headerBytes) { + throw new Error( + "Pattern data is too short to contain required metadata.", + ); + } - const width = (((byte2 & 0x03) << 5) | ((byte1 >> 3) & 0x1f)) + 2; - const height = ((byte2 >> 2) & 0x3f) + 2; + const byte1 = bytes[1]; + + // Bits 5-7 of byte1 are reserved. Reject when set so that they + // remain available for future format changes. + if (byte1 >> 5 !== 0) { + throw new Error("Reserved bits set in pattern header."); + } + + scale = byte1 & 0x07; + width = ((((byte1 >> 3) & 0x01) << 8) | bytes[2]) + 2; + height = ((((byte1 >> 4) & 0x01) << 8) | bytes[3]) + 2; + + if (width > MAX_PATTERN_DIMENSION || height > MAX_PATTERN_DIMENSION) { + throw new Error( + "Pattern dimensions exceed the maximum supported size.", + ); + } + break; + } + + default: + throw new Error(`Unrecognized pattern version ${version}.`); + } const expectedBits = width * height; const expectedBytes = (expectedBits + 7) >> 3; // Equivalent to: ceil(expectedBits / 8); - if (bytes.length - 3 < expectedBytes) { + const payloadBytes = bytes.length - headerBytes; + + if (payloadBytes < expectedBytes) { throw new Error("Pattern data is too short for the specified dimensions."); } - return { height, width, scale, bytes }; + // v1 requires an exact payload length. v0 remains lenient so that + // existing patterns continue to decode unchanged. + if (version === 1 && payloadBytes !== expectedBytes) { + throw new Error( + "Pattern data length does not match the specified dimensions.", + ); + } + + return { height, width, scale, headerBytes, bytes }; } diff --git a/tests/PatternDecoder.test.ts b/tests/PatternDecoder.test.ts new file mode 100644 index 0000000000..352e412eff --- /dev/null +++ b/tests/PatternDecoder.test.ts @@ -0,0 +1,255 @@ +import { base64url } from "jose"; +import { DefaultPattern } from "../src/core/CosmeticSchemas"; +import { + decodePatternData, + MAX_PATTERN_DIMENSION, + PatternDecoder, +} from "../src/core/PatternDecoder"; +import { PlayerPattern } from "../src/core/Schemas"; + +/** Bytes of pixel data a width x height pattern needs (1 bit per pixel). */ +function payloadBytes(width: number, height: number): number { + return (width * height + 7) >> 3; +} + +/** + * Build a v0 pattern string. + * byte0: version (0) + * byte1: scale(3) | width_lo(5) + * byte2: width_hi(2) | height(6) + * Dimensions are stored as value - 2. + */ +function encodeV0( + scale: number, + width: number, + height: number, + payload?: Uint8Array, +): string { + const w = width - 2; + const h = height - 2; + const bytes = new Uint8Array([ + 0, + ((w & 0x1f) << 3) | (scale & 0x07), + ((h & 0x3f) << 2) | ((w >> 5) & 0x03), + ...(payload ?? new Uint8Array(payloadBytes(width, height))), + ]); + return base64url.encode(bytes); +} + +/** + * Build a v1 pattern string. + * byte0: version (1) + * byte1: scale(3) | width_hi(1) | height_hi(1) | reserved(3) + * byte2: width_lo(8) + * byte3: height_lo(8) + * Dimensions are stored as value - 2. + */ +function encodeV1( + scale: number, + width: number, + height: number, + opts: { reserved?: number; payload?: Uint8Array } = {}, +): string { + const w = width - 2; + const h = height - 2; + const bytes = new Uint8Array([ + 1, + (scale & 0x07) | + (((w >> 8) & 0x01) << 3) | + (((h >> 8) & 0x01) << 4) | + ((opts.reserved ?? 0) << 5), + w & 0xff, + h & 0xff, + ...(opts.payload ?? new Uint8Array(payloadBytes(width, height))), + ]); + return base64url.encode(bytes); +} + +const decode = (b64: string) => decodePatternData(b64, base64url.decode); + +const asPattern = (patternData: string): PlayerPattern => ({ + name: "test", + patternData, + colorPalette: undefined, +}); + +describe("decodePatternData v0", () => { + test("decodes the default pattern as 2x2", () => { + const result = decode(DefaultPattern.patternData); + expect(result.width).toBe(2); + expect(result.height).toBe(2); + expect(result.scale).toBe(0); + expect(result.headerBytes).toBe(3); + }); + + test("round-trips dimensions and scale", () => { + for (const [scale, width, height] of [ + [0, 2, 2], + [0, 16, 16], + [3, 32, 8], + [7, 100, 40], + ] as const) { + const result = decode(encodeV0(scale, width, height)); + expect([result.scale, result.width, result.height]).toEqual([ + scale, + width, + height, + ]); + } + }); + + test("decodes at the v0 maximum of 129x65", () => { + const result = decode(encodeV0(0, 129, 65)); + expect(result.width).toBe(129); + expect(result.height).toBe(65); + }); + + test("accepts a payload longer than the dimensions require", () => { + // v0 length checking is deliberately lenient — patterns already in the + // store rely on this, so the behavior must not change. + const padded = new Uint8Array(payloadBytes(16, 16) + 8); + expect(() => decode(encodeV0(0, 16, 16, padded))).not.toThrow(); + }); + + test("rejects a payload longer than the v0 format can produce", () => { + // 129x65 needs 1049 bytes; nothing valid can exceed that. + const oversized = new Uint8Array(1050); + expect(() => decode(encodeV0(0, 129, 65, oversized))).toThrow( + /too long for the v0 format/, + ); + }); + + test("rejects a payload shorter than the dimensions require", () => { + const short = new Uint8Array(payloadBytes(16, 16) - 1); + expect(() => decode(encodeV0(0, 16, 16, short))).toThrow( + /too short for the specified dimensions/, + ); + }); +}); + +describe("decodePatternData v1", () => { + test("round-trips dimensions and scale", () => { + for (const [scale, width, height] of [ + [0, 2, 2], + [0, 128, 128], + [2, 128, 96], + [5, 65, 128], + ] as const) { + const result = decode(encodeV1(scale, width, height)); + expect([result.scale, result.width, result.height]).toEqual([ + scale, + width, + height, + ]); + expect(result.headerBytes).toBe(4); + } + }); + + test("rejects dimensions above the maximum", () => { + const over = MAX_PATTERN_DIMENSION + 1; + expect(() => decode(encodeV1(0, over, 2))).toThrow( + /exceed the maximum supported size/, + ); + expect(() => decode(encodeV1(0, 2, over))).toThrow( + /exceed the maximum supported size/, + ); + }); + + test("rejects reserved bits being set", () => { + expect(() => decode(encodeV1(0, 4, 4, { reserved: 1 }))).toThrow( + /Reserved bits set/, + ); + }); + + test("requires an exact payload length", () => { + const long = new Uint8Array(payloadBytes(16, 16) + 1); + expect(() => decode(encodeV1(0, 16, 16, { payload: long }))).toThrow( + /does not match the specified dimensions/, + ); + + const short = new Uint8Array(payloadBytes(16, 16) - 1); + expect(() => decode(encodeV1(0, 16, 16, { payload: short }))).toThrow( + /too short for the specified dimensions/, + ); + }); +}); + +describe("decodePatternData version handling", () => { + test("rejects an unrecognized version", () => { + const bytes = new Uint8Array([2, 0, 0, 0]); + expect(() => decode(base64url.encode(bytes))).toThrow( + /Unrecognized pattern version 2/, + ); + }); + + test("rejects data too short to hold a header", () => { + expect(() => decode(base64url.encode(new Uint8Array([0, 0])))).toThrow( + /too short to contain required metadata/, + ); + expect(() => decode(base64url.encode(new Uint8Array([1, 0, 0])))).toThrow( + /too short to contain required metadata/, + ); + }); +}); + +describe("PatternDecoder.isPrimary", () => { + // Pixel lookups are offset by the header size. A v1 pattern read with the + // v0 offset would return the wrong bit for every pixel, so this covers the + // header-size handling as much as the bit arithmetic. + function fourByFour(): Uint8Array { + const payload = new Uint8Array(payloadBytes(4, 4)); + payload[0] = 0b0000_0011; // pixels (0,0) and (1,0) are secondary + return payload; + } + + function expectFourByFour(decoder: PatternDecoder): void { + expect(decoder.isPrimary(0, 0)).toBe(false); + expect(decoder.isPrimary(1, 0)).toBe(false); + expect(decoder.isPrimary(2, 0)).toBe(true); + expect(decoder.isPrimary(0, 1)).toBe(true); + } + + test("reads pixel data at the correct offset for v0", () => { + const pattern = asPattern(encodeV0(0, 4, 4, fourByFour())); + expectFourByFour(new PatternDecoder(pattern, base64url.decode)); + }); + + test("reads pixel data at the correct offset for v1", () => { + const pattern = asPattern(encodeV1(0, 4, 4, { payload: fourByFour() })); + expectFourByFour(new PatternDecoder(pattern, base64url.decode)); + }); + + test("tiles by wrapping on both axes", () => { + const payload = new Uint8Array(payloadBytes(4, 4)); + payload[0] = 0b0000_0001; // only pixel (0,0) + const decoder = new PatternDecoder( + asPattern(encodeV1(0, 4, 4, { payload })), + base64url.decode, + ); + expect(decoder.isPrimary(4, 0)).toBe(false); + expect(decoder.isPrimary(0, 4)).toBe(false); + expect(decoder.isPrimary(4, 4)).toBe(false); + }); + + test("scale shifts the sampled coordinate", () => { + const payload = new Uint8Array(payloadBytes(4, 4)); + payload[0] = 0b0000_0001; + const decoder = new PatternDecoder( + asPattern(encodeV1(1, 4, 4, { payload })), + base64url.decode, + ); + // At scale 1 each pattern pixel covers a 2x2 block of tiles. + expect(decoder.isPrimary(0, 0)).toBe(false); + expect(decoder.isPrimary(1, 1)).toBe(false); + expect(decoder.isPrimary(2, 0)).toBe(true); + }); + + test("reports scaled dimensions", () => { + const decoder = new PatternDecoder( + asPattern(encodeV1(2, 16, 8)), + base64url.decode, + ); + expect(decoder.scaledWidth()).toBe(64); + expect(decoder.scaledHeight()).toBe(32); + }); +});