Skip to content
15 changes: 12 additions & 3 deletions src/client/WebGLFrameBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(). */
Expand Down Expand Up @@ -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);
}
Expand Down
7 changes: 4 additions & 3 deletions src/client/render/gl/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
});

Expand Down Expand Up @@ -788,7 +789,7 @@ export class GPURenderer {
0,
0,
0,
1024,
PATTERN_ROW_BYTES,
palW,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
Expand Down
4 changes: 2 additions & 2 deletions src/core/CosmeticSchemas.ts
Original file line number Diff line number Diff line change
@@ -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<typeof CosmeticsSchema>;
Expand Down Expand Up @@ -47,7 +47,7 @@ export const CosmeticNameSchema = z

export const PatternDataSchema = z
.string()
.max(1403)
.max(MAX_PATTERN_DATA_LENGTH)
.base64url()
.refine(
(val) => {
Expand Down
145 changes: 130 additions & 15 deletions src/core/PatternDecoder.ts
Original file line number Diff line number Diff line change
@@ -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));
}
Expand All @@ -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;
Expand All @@ -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 };
}
Loading
Loading