feat(hiscores): server-side sprite rendering for KOTH/viewer pages + cache headers

- Hiscores pages emit <img> tags pointing at /sprite/player/... and
  /sprite/item/... URLs; sprites are rendered once in a worker thread hosting
  the webclient's ItemViewer, PNG-encoded, LRU-cached and served with long
  cache headers, instead of every browser pulling the whole model cache.
- KOTH page shows the reigning king of each window (all time / week / today)
  side by side above the table.
- ItemViewer gains a lazy ZipProvider that unpacks models on first use.
- Viewer cache archives get Cache-Control headers (immutable when crc-suffixed).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Fable 5.1 committed 2026-09-02 13:25:34 -04:00
1 parent db1f30e1c1
commit 56b73e08fc
7 files changed
+580 -86

No files matched your search

+18 -4
View File
@@ -4,6 +4,7 @@ import path from 'path';
import Environment from '#/util/Environment.js';
import { handleHiscoresPage, handleHiscoresPlayerPage, handleHiscoresOutfitPage, handleHiscoresBankPage, handleHiscoresKothPage } from './pages/hiscores.js';
import { handlePublicFiles } from './pages/static.js';
import { handleSpriteRequest } from './sprites/SpriteRenderer.js';
const PACK_DIR = path.resolve(import.meta.dir, '../../data/pack');
const VIEWER_OUT_DIR = path.resolve(import.meta.dir, '../../../webclient/out/viewer');
@@ -62,11 +63,19 @@ function buildCrcBuffer(): Uint8Array {
let crcBuffer: Uint8Array | null = null;
// Browser cache policy for the in-browser viewer (bank/outfit pages still render item icons
// client-side). The archives are requested as /config{crc} etc., so the URL changes whenever
// the content does and they can be cached hard; the unversioned ones get a bounded TTL.
const CACHE_ARCHIVE = 'public, max-age=31536000, immutable';
const CACHE_ONDEMAND = 'public, max-age=3600';
const CACHE_VIEWER_JS = 'public, max-age=600';
const CACHE_CRC = 'public, max-age=60';
export function handleViewerAssets(url: URL): Response | null {
// CRC endpoint
if (url.pathname.startsWith('/crc')) {
if (!crcBuffer) crcBuffer = buildCrcBuffer();
return new Response(Buffer.from(crcBuffer));
return new Response(Buffer.from(crcBuffer), { headers: { 'Cache-Control': CACHE_CRC } });
}
// Cache archive endpoints
@@ -75,7 +84,9 @@ export function handleViewerAssets(url: URL): Response | null {
if (url.pathname.startsWith(`/${name}`)) {
const filePath = `${PACK_DIR}/client/${name}`;
if (fs.existsSync(filePath)) {
return new Response(Bun.file(filePath));
// only the crc-suffixed form is content-addressed
const versioned = url.pathname.length > name.length + 1;
return new Response(Bun.file(filePath), { headers: { 'Cache-Control': versioned ? CACHE_ARCHIVE : CACHE_CRC } });
}
return new Response(null, { status: 404 });
}
@@ -86,7 +97,7 @@ export function handleViewerAssets(url: URL): Response | null {
const zipPath = `${PACK_DIR}/ondemand.zip`;
if (fs.existsSync(zipPath)) {
return new Response(Bun.file(zipPath), {
headers: { 'Content-Type': 'application/zip' },
headers: { 'Content-Type': 'application/zip', 'Cache-Control': CACHE_ONDEMAND },
});
}
return new Response(null, { status: 404 });
@@ -99,7 +110,7 @@ export function handleViewerAssets(url: URL): Response | null {
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = path.extname(filePath);
return new Response(Bun.file(filePath), {
headers: { 'Content-Type': VIEWER_MIME[ext] || 'application/octet-stream' },
headers: { 'Content-Type': VIEWER_MIME[ext] || 'application/octet-stream', 'Cache-Control': CACHE_VIEWER_JS },
});
}
// Audio assets (soundfonts, etc.) are not needed for icon rendering — return empty to avoid 404 noise
@@ -131,6 +142,9 @@ export function startHiscoresWeb() {
const hiscoresKothResponse = await handleHiscoresKothPage(url);
if (hiscoresKothResponse) return hiscoresKothResponse;
const spriteResponse = await handleSpriteRequest(url);
if (spriteResponse) return spriteResponse;
// Static files (decoration images)
const publicFilesResponse = handlePublicFiles(url);
if (publicFilesResponse) return publicFilesResponse;
+58 -48
View File
@@ -2,6 +2,7 @@ import { db, toDbDate } from '#/db/query.js';
import Environment from '#/util/Environment.js';
import { tryParseInt } from '#/util/TryParse.js';
import { escapeHtml, SKILL_NAMES, ENABLED_SKILLS } from '../utils.js';
import { playerSpriteUrl } from '#/web/sprites/SpriteRenderer.js';
const hiddenNames = Environment.HISCORES_HIDDEN_NAMES;
@@ -880,14 +881,45 @@ function formatAgo(dbDate: string | Date): string {
return `${Math.floor(mins / 1440)}d ago`;
}
export async function handleHiscoresKothPage(url: URL): Promise<Response | null> {
const match = url.pathname.match(/^\/hi(?:gh)?scores\/koth\/?$/);
if (!match) return null;
// the reigning king of each window, shown side by side above the table
const KING_WINDOWS = [
{ label: 'All time', ms: 0 },
{ label: 'This week', ms: 7 * 24 * 3600_000 },
{ label: 'Today', ms: 24 * 3600_000 }
];
const profile = (url.searchParams.get('profile') || 'main').replace(/[^a-zA-Z0-9_-]/g, '');
const windowParam = url.searchParams.get('window') || 'all';
const windowMs = windowParam === 'day' ? 24 * 3600_000 : windowParam === 'week' ? 7 * 24 * 3600_000 : 0;
type KothRow = { username: string; minutes: number; last_held: string | Date };
type KothKing = { label: string; ms: number; leader: { username: string; minutes: number } | undefined; sprite: KothLoadout | undefined };
type KothPageData = { results: KothRow[]; spriteFor: Map<string, KothLoadout>; kings: KothKing[] };
// Same short TTL + in-flight dedup as getRankedList: these run on the tick thread, and the
// capture table grows by one row per minute so a group-by over it is not free.
const kothCache = new Map<string, { at: number; data: KothPageData | null; pending: Promise<KothPageData> | null }>();
async function getKothPageData(profile: string, windowMs: number): Promise<KothPageData> {
const key = `${profile}:${windowMs}`;
const now = Date.now();
const cached = kothCache.get(key);
if (cached?.data && now - cached.at < RANKED_TTL_MS) {
return cached.data;
}
if (cached?.pending) {
return cached.pending;
}
const load = loadKothPageData(profile, windowMs).then(data => {
kothCache.set(key, { at: Date.now(), data, pending: null });
return data;
});
kothCache.set(key, { at: cached?.at ?? 0, data: cached?.data ?? null, pending: load });
try {
return await load;
} catch (err) {
kothCache.delete(key);
throw err;
}
}
async function loadKothPageData(profile: string, windowMs: number): Promise<KothPageData> {
let query = db
.selectFrom('koth_capture')
.innerJoin('account', 'account.username', 'koth_capture.username')
@@ -939,12 +971,6 @@ export async function handleHiscoresKothPage(url: URL): Promise<Response | null>
windowMs
);
// the reigning king of each window, shown side by side above the table
const KING_WINDOWS = [
{ label: 'All time', ms: 0 },
{ label: 'This week', ms: 7 * 24 * 3600_000 },
{ label: 'Today', ms: 24 * 3600_000 }
];
const kings = await Promise.all(
KING_WINDOWS.map(async w => {
let kingQuery = db
@@ -968,10 +994,25 @@ export async function handleHiscoresKothPage(url: URL): Promise<Response | null>
})
);
const spriteCanvas = (loadout: KothLoadout, size: 'big' | 'small'): string => {
return { results: results as KothRow[], spriteFor, kings };
}
export async function handleHiscoresKothPage(url: URL): Promise<Response | null> {
const match = url.pathname.match(/^\/hi(?:gh)?scores\/koth\/?$/);
if (!match) return null;
const profile = (url.searchParams.get('profile') || 'main').replace(/[^a-zA-Z0-9_-]/g, '');
const windowParam = url.searchParams.get('window') || 'all';
const windowMs = windowParam === 'day' ? 24 * 3600_000 : windowParam === 'week' ? 7 * 24 * 3600_000 : 0;
const { results, spriteFor, kings } = await getKothPageData(profile, windowMs);
// sprites are rendered server-side (see web/sprites) and cached by URL, so the page is
// plain HTML + <img> — no viewer bundle or model archives shipped to the browser
const spriteImg = (loadout: KothLoadout, size: 'big' | 'small'): string => {
const w = size === 'big' ? 78 : 52;
const h = size === 'big' ? 130 : 88;
return `<canvas class="koth-sprite" data-appearance="${escapeHtml(JSON.stringify(loadout))}" width="${w}" height="${h}" style="image-rendering:pixelated;vertical-align:middle"></canvas>`;
return `<img src="${playerSpriteUrl(loadout, w, h)}" width="${w}" height="${h}" alt="" loading="lazy" decoding="async" style="image-rendering:pixelated;vertical-align:middle">`;
};
const rows = results.map((r, i) => {
@@ -979,7 +1020,7 @@ export async function handleHiscoresKothPage(url: URL): Promise<Response | null>
return `
<tr>
<td align="right">${i + 1}</td>
<td>${sprite ? spriteCanvas(sprite, 'small') : ''}</td>
<td>${sprite ? spriteImg(sprite, 'small') : ''}</td>
<td><a href="/hiscores/player/${encodeURIComponent(r.username)}?profile=${profile}" class="c">${escapeHtml(r.username)}</a></td>
<td align="right" class="yellow">${Number(r.minutes).toLocaleString()}</td>
<td align="right" style="font-size:11px">${formatAgo(r.last_held as string | Date)}</td>
@@ -1007,7 +1048,7 @@ export async function handleHiscoresKothPage(url: URL): Promise<Response | null>
.map(
k => `<td class="e" width="33%" align="center" valign="bottom">
<b style="font-size:11px">${k.label}</b><br>
${k.sprite ? spriteCanvas(k.sprite, 'small') + '<br>' : ''}
${k.sprite ? spriteImg(k.sprite, 'small') + '<br>' : ''}
${k.leader ? `<a href="/hiscores/player/${encodeURIComponent(k.leader.username)}?profile=${profile}" class="c text-orange">${escapeHtml(k.leader.username)}</a><br><span class="yellow" style="font-size:11px">${Number(k.leader.minutes).toLocaleString()} min</span>` : '<span style="font-size:11px">unclaimed</span>'}
</td>`
)
@@ -1135,41 +1176,10 @@ export async function handleHiscoresKothPage(url: URL): Promise<Response | null>
</td>
</tr>
</table>
<!-- Hidden canvas required by viewer internals -->
<canvas id="canvas" width="256" height="256" style="display:none"></canvas>
<script type="module">
import { ItemViewer } from '/viewer/viewer.js';
const sprites = document.querySelectorAll('canvas.koth-sprite');
if (sprites.length > 0) {
const viewer = new ItemViewer();
try {
await viewer.init('');
for (const el of sprites) {
try {
const appearance = JSON.parse(el.dataset.appearance);
const img = viewer.renderPlayerSpriteAsImageData(appearance, el.width, el.height);
if (img) {
el.getContext('2d').putImageData(img, 0, 0);
} else {
el.style.display = 'none';
}
} catch (renderErr) {
el.style.display = 'none';
}
}
} catch (err) {
console.error('ItemViewer init failed:', err);
for (const el of sprites) {
el.style.display = 'none';
}
}
}
</script>
</body>
</html>`;
return new Response(html, { headers: { 'Content-Type': 'text/html' } });
return new Response(html, { headers: { 'Content-Type': 'text/html', 'Cache-Control': 'public, max-age=30' } });
}
// Bank value leaderboard handler
@@ -0,0 +1,9 @@
// Messages between SpriteRenderer (main thread) and SpriteWorker (worker thread).
export type PlayerAppearance = { gender: number; colors: number[]; slots: number[] };
export type SpriteJob = { kind: 'player'; appearance: PlayerAppearance; width: number; height: number } | { kind: 'item'; itemId: number; count: number };
export type SpriteRequest = SpriteJob & { id: number };
export type SpriteResponse = { id: number; png: Uint8Array | null; error?: string };
@@ -0,0 +1,204 @@
// Server-side sprite rendering for the hiscores pages.
//
// Pages used to embed <canvas> tags plus a module script that pulled the whole model cache
// (config/textures/versionlist + a 7MB ondemand.zip, no cache headers) into every browser
// and unpacked it all before drawing a dozen sprites. Now the page emits <img> tags that
// point at /sprite/... URLs; the sprite is rendered once in a worker thread (SpriteWorker.ts),
// PNG-encoded, kept in an in-memory LRU, and served with long cache headers.
//
// URL formats (all parameters are part of the path so the PNG is cacheable by URL):
// /sprite/player/{w}x{h}/{gender}/{c0.c1.c2.c3.c4}/{s0.s1.….s11}.png
// /sprite/item/{itemId}.png /sprite/item/{itemId}-{count}.png
import { Worker } from 'worker_threads';
import type { PlayerAppearance, SpriteJob, SpriteRequest, SpriteResponse } from '#/web/sprites/SpriteProtocol.js';
const CACHE_MAX_ENTRIES = Number(process.env.SPRITE_CACHE_MAX ?? 5000);
const RENDER_TIMEOUT_MS = 15_000;
const MAX_DIMENSION = 256;
const MAX_PENDING = 512;
// ---- worker client --------------------------------------------------------------------------
// ok=false means the worker never answered (timeout, crash, backpressure) — don't cache that
type RenderResult = { png: Uint8Array | null; ok: boolean };
type Pending = { resolve: (result: RenderResult) => void; timer: ReturnType<typeof setTimeout> };
let worker: Worker | null = null;
let restarting: ReturnType<typeof setTimeout> | null = null;
let nextId = 1;
const pending = new Map<number, Pending>();
function settle(id: number, result: RenderResult): void {
const p = pending.get(id);
if (!p) return;
pending.delete(id);
clearTimeout(p.timer);
p.resolve(result);
}
function failAll(reason: string): void {
if (pending.size > 0) {
console.error(`[sprites] failing ${pending.size} pending render(s): ${reason}`);
}
for (const id of [...pending.keys()]) {
settle(id, { png: null, ok: false });
}
}
function getWorker(): Worker | null {
if (worker) return worker;
if (restarting) return null;
const w = new Worker(new URL('./SpriteWorker.ts', import.meta.url));
worker = w;
w.on('message', (res: SpriteResponse) => {
if (res.error) {
console.error(`[sprites] render ${res.id} failed: ${res.error}`);
}
settle(res.id, { png: res.png, ok: !res.error });
});
w.on('error', err => {
console.error('[sprites] worker error:', err);
});
w.on('exit', code => {
worker = null;
failAll(`worker exited with code ${code}`);
if (code === 0) return;
console.error(`[sprites] worker exited with code ${code}; restarting shortly.`);
restarting = setTimeout(() => {
restarting = null;
}, 2000);
});
return w;
}
function renderInWorker(req: SpriteJob): Promise<RenderResult> {
const w = getWorker();
if (!w || pending.size >= MAX_PENDING) {
return Promise.resolve({ png: null, ok: false });
}
const id = nextId++;
return new Promise<RenderResult>(resolve => {
const timer = setTimeout(() => {
console.error(`[sprites] render ${id} timed out`);
settle(id, { png: null, ok: false });
}, RENDER_TIMEOUT_MS);
pending.set(id, { resolve, timer });
const msg: SpriteRequest = { ...req, id };
w.postMessage(msg);
});
}
// ---- cache ----------------------------------------------------------------------------------
// keyed by the canonical URL path; null caches a "can't render" so a bad id doesn't hit the
// worker on every request. Insertion-ordered Map as an LRU.
const cache = new Map<string, Uint8Array | null>();
const inflight = new Map<string, Promise<Uint8Array | null>>();
function cacheGet(key: string): Uint8Array | null | undefined {
if (!cache.has(key)) return undefined;
const value = cache.get(key)!;
cache.delete(key);
cache.set(key, value);
return value;
}
function cacheSet(key: string, value: Uint8Array | null): void {
cache.set(key, value);
while (cache.size > CACHE_MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest === undefined) break;
cache.delete(oldest);
}
}
async function renderCached(key: string, req: SpriteJob): Promise<Uint8Array | null> {
const hit = cacheGet(key);
if (hit !== undefined) return hit;
const existing = inflight.get(key);
if (existing) return existing;
const job = renderInWorker(req).then(result => {
inflight.delete(key);
if (result.ok) {
cacheSet(key, result.png);
}
return result.png;
});
inflight.set(key, job);
return job;
}
export function spriteCacheStats(): { entries: number; inflight: number; pending: number; workerAlive: boolean } {
return { entries: cache.size, inflight: inflight.size, pending: pending.size, workerAlive: worker !== null };
}
// ---- URL helpers ----------------------------------------------------------------------------
function clampInt(n: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, n | 0));
}
/** URL for a player sprite; the page embeds this in an <img src>. */
export function playerSpriteUrl(appearance: PlayerAppearance, width: number, height: number): string {
const colors = Array.from({ length: 5 }, (_, i) => clampInt(appearance.colors[i] ?? 0, 0, 255));
const slots = Array.from({ length: 12 }, (_, i) => clampInt(appearance.slots[i] ?? 0, 0, 0xffff));
return `/sprite/player/${clampInt(width, 1, MAX_DIMENSION)}x${clampInt(height, 1, MAX_DIMENSION)}/${appearance.gender & 1}/${colors.join('.')}/${slots.join('.')}.png`;
}
/** URL for a 32x32 item icon. */
export function itemSpriteUrl(itemId: number, count: number = 1): string {
return count > 1 ? `/sprite/item/${itemId | 0}-${count | 0}.png` : `/sprite/item/${itemId | 0}.png`;
}
const PLAYER_RE = /^\/sprite\/player\/(\d{1,3})x(\d{1,3})\/([01])\/((?:\d{1,3}\.){4}\d{1,3})\/((?:\d{1,5}\.){11}\d{1,5})\.png$/;
const ITEM_RE = /^\/sprite\/item\/(\d{1,6})(?:-(\d{1,10}))?\.png$/;
function parseSpriteUrl(pathname: string): { key: string; req: SpriteJob } | null {
const player = pathname.match(PLAYER_RE);
if (player) {
const width = Number(player[1]);
const height = Number(player[2]);
if (width < 1 || height < 1 || width > MAX_DIMENSION || height > MAX_DIMENSION) return null;
const appearance: PlayerAppearance = {
gender: Number(player[3]),
colors: player[4].split('.').map(Number),
slots: player[5].split('.').map(Number)
};
if (appearance.colors.some(c => c > 255) || appearance.slots.some(s => s > 0xffff)) return null;
// canonical form so "007" and "7" share a cache entry
const key = playerSpriteUrl(appearance, width, height);
return { key, req: { kind: 'player', appearance, width, height } };
}
const item = pathname.match(ITEM_RE);
if (item) {
const itemId = Number(item[1]);
const count = item[2] ? Number(item[2]) : 1;
if (count < 1 || count > 0x7fffffff) return null;
return { key: itemSpriteUrl(itemId, count), req: { kind: 'item', itemId, count } };
}
return null;
}
/** Route handler: returns a PNG response for /sprite/... paths, null for anything else. */
export async function handleSpriteRequest(url: URL): Promise<Response | null> {
if (!url.pathname.startsWith('/sprite/')) return null;
const parsed = parseSpriteUrl(url.pathname);
if (!parsed) {
return new Response('Bad sprite request', { status: 400 });
}
const png = await renderCached(parsed.key, parsed.req);
if (!png) {
return new Response(null, { status: 404, headers: { 'Cache-Control': 'public, max-age=300' } });
}
return new Response(png, {
headers: {
'Content-Type': 'image/png',
'Content-Length': String(png.length),
'Cache-Control': 'public, max-age=86400'
}
});
}
@@ -0,0 +1,174 @@
// Sprite rendering worker thread.
//
// Hosts the webclient's standalone ItemViewer (server/webclient/src/viewer) inside a Bun
// worker so hiscores pages can serve pre-rendered PNG sprites instead of shipping the whole
// model cache (ondemand.zip, ~7MB) to every browser and re-unpacking it on every page view.
//
// Runs off the tick thread: viewer init takes ~400ms and ~150MB, and each render is a few
// ms of pure CPU. The main thread side is SpriteRenderer.ts, which owns the cache.
import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
import { parentPort } from 'worker_threads';
import type { SpriteRequest, SpriteResponse } from '#/web/sprites/SpriteProtocol.js';
if (!parentPort) throw new Error('This file must be run as a worker thread.');
const PACK_DIR = path.resolve(import.meta.dir, '../../../data/pack');
const VIEWER_ENTRY = new URL('../../../../webclient/src/viewer/ItemViewer.ts', import.meta.url).href;
// ---- DOM shim -------------------------------------------------------------------------------
// The viewer's dependency tree touches `document` at module load (Canvas.ts grabs
// #canvas, Jpeg.ts creates a scratch canvas) and PixMap asks the 2d context for an
// ImageData. None of that is used for offscreen sprite rendering, which rasterises into a
// plain Int32Array, so a minimal stand-in is enough. Must be installed before the import.
type FakeImageData = { data: Uint8ClampedArray; width: number; height: number };
const fakeContext = {
createImageData: (width: number, height: number): FakeImageData => ({ data: new Uint8ClampedArray(width * height * 4), width, height }),
getImageData: (_x: number, _y: number, width: number, height: number): FakeImageData => ({ data: new Uint8ClampedArray(width * height * 4), width, height }),
putImageData: (): void => {},
drawImage: (): void => {}
};
const fakeCanvas = { width: 256, height: 256, getContext: () => fakeContext };
const g = globalThis as Record<string, unknown>;
if (typeof g.document === 'undefined') {
g.document = { getElementById: () => fakeCanvas, createElement: () => fakeCanvas };
}
if (typeof g.window === 'undefined') {
g.window = { location: { host: 'localhost', protocol: 'http:' } };
}
// ---- viewer ---------------------------------------------------------------------------------
type Appearance = { gender: number; colors: number[]; slots: number[] };
// Pix32: `wi`/`hi` are the sprite's width/height
type Pix32Like = { data: Int32Array; wi: number; hi: number };
type Viewer = {
initFromData(data: { config: Uint8Array; textures: Uint8Array; versionlist: Uint8Array; ondemand: Uint8Array }, options?: { lazyModels?: boolean }): void;
renderPlayerSprite(appearance: Appearance, width: number, height: number, yaw?: number): Pix32Like | null;
renderItemIcon(itemId: number, count?: number, outlineRgb?: number): Pix32Like | null;
};
let viewerPromise: Promise<Viewer> | null = null;
function loadViewer(): Promise<Viewer> {
// memoised: requests queued behind the first one must not each init the viewer
if (!viewerPromise) {
viewerPromise = initViewer().catch(err => {
viewerPromise = null;
throw err;
});
}
return viewerPromise;
}
async function initViewer(): Promise<Viewer> {
// dynamic import: the viewer lives in the webclient package (outside this tsconfig's
// rootDir) and needs the DOM shim above installed first
const started = performance.now();
const mod = (await import(VIEWER_ENTRY)) as { ItemViewer: new () => Viewer };
const v = new mod.ItemViewer();
v.initFromData(
{
config: fs.readFileSync(`${PACK_DIR}/client/config`),
textures: fs.readFileSync(`${PACK_DIR}/client/textures`),
versionlist: fs.readFileSync(`${PACK_DIR}/client/versionlist`),
ondemand: fs.readFileSync(`${PACK_DIR}/ondemand.zip`)
},
{ lazyModels: true }
);
console.log(`[sprites] viewer ready in ${(performance.now() - started).toFixed(0)}ms`);
return v;
}
// ---- PNG encoder ----------------------------------------------------------------------------
// Bun has no canvas, so write the PNG by hand: 8-bit RGBA, filter type 0 on every row,
// one zlib stream. Pixel 0 is the renderer's transparent key.
const CRC_TABLE = new Int32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
CRC_TABLE[i] = c;
}
function crc32(buf: Uint8Array): number {
let c = -1;
for (let i = 0; i < buf.length; i++) {
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
}
return (c ^ -1) >>> 0;
}
function pngChunk(type: string, data: Uint8Array): Uint8Array {
const out = new Uint8Array(12 + data.length);
const view = new DataView(out.buffer);
view.setUint32(0, data.length);
for (let i = 0; i < 4; i++) {
out[4 + i] = type.charCodeAt(i);
}
out.set(data, 8);
view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
return out;
}
function encodePng(pixels: Int32Array, width: number, height: number): Uint8Array {
const stride = width * 4 + 1;
const raw = new Uint8Array(stride * height);
for (let y = 0; y < height; y++) {
const row = y * stride;
raw[row] = 0; // filter: none
for (let x = 0; x < width; x++) {
const p = pixels[y * width + x];
if (p === 0) continue; // transparent key, already zeroed
const o = row + 1 + x * 4;
raw[o] = (p >> 16) & 0xff;
raw[o + 1] = (p >> 8) & 0xff;
raw[o + 2] = p & 0xff;
raw[o + 3] = 0xff;
}
}
const ihdr = new Uint8Array(13);
const view = new DataView(ihdr.buffer);
view.setUint32(0, width);
view.setUint32(4, height);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // colour type: RGBA
const parts = [new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), pngChunk('IHDR', ihdr), pngChunk('IDAT', zlib.deflateSync(raw, { level: 9 })), pngChunk('IEND', new Uint8Array(0))];
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let off = 0;
for (const p of parts) {
out.set(p, off);
off += p.length;
}
return out;
}
// ---- request loop ---------------------------------------------------------------------------
parentPort.on('message', async (req: SpriteRequest) => {
let png: Uint8Array | null = null;
let error: string | undefined;
try {
const v = await loadViewer();
let sprite: Pix32Like | null = null;
if (req.kind === 'player') {
sprite = v.renderPlayerSprite(req.appearance, req.width, req.height);
} else if (req.kind === 'item') {
sprite = v.renderItemIcon(req.itemId, req.count);
}
if (sprite) {
png = encodePng(sprite.data, sprite.wi, sprite.hi);
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
const res: SpriteResponse = { id: req.id, png, error };
if (png) {
parentPort!.postMessage(res, [png.buffer as ArrayBuffer]);
} else {
parentPort!.postMessage(res);
}
});
+9 -5
View File
@@ -227,10 +227,14 @@ export default class Model extends ModelSource {
}
static load(id: number): Model | null {
const meta = Model.meta[id];
let meta = Model.meta[id];
if (!meta) {
Model.provider.requestModel(id);
return null;
// a synchronous provider (viewer lazy-loading from a zip) may have unpacked it
meta = Model.meta[id];
if (!meta) {
return null;
}
}
const model = new Model();
@@ -414,10 +418,10 @@ export default class Model extends ModelSource {
}
static requestDownload(id: number): boolean {
const meta = Model.meta[id];
if (!meta) {
if (!Model.meta[id]) {
Model.provider.requestModel(id);
return false;
// a synchronous provider (viewer lazy-loading from a zip) may have unpacked it
return Model.meta[id] != null;
}
return true;
+108 -29
View File
@@ -64,6 +64,49 @@ class NoopProvider extends OnDemandProvider {
}
}
/**
* Synchronous provider that gunzips + unpacks a model from the ondemand.zip
* entries the first time Model asks for it. Model.load/requestDownload re-check
* the metadata table right after requestModel, so a render that only needs a
* handful of models (a player sprite, an item icon) never touches the other
* ~3000 entries. Missing ids are recorded as empty models so they aren't
* re-requested forever.
*/
class ZipProvider extends OnDemandProvider {
private readonly entries: Map<number, Uint8Array>;
constructor(entries: Map<number, Uint8Array>) {
super();
this.entries = entries;
}
override requestModel(id: number): void {
const entry = this.entries.get(id);
if (!entry) {
Model.unpack(id, null);
return;
}
this.entries.delete(id);
try {
// Strip 2-byte version trailer and decompress
Model.unpack(id, gunzipSync(entry.subarray(0, entry.length - 2)));
} catch {
Model.unpack(id, null);
}
}
}
export type ItemViewerData = { config: Uint8Array; textures: Uint8Array; versionlist: Uint8Array; ondemand: Uint8Array };
export type ItemViewerOptions = {
/**
* Keep model entries compressed and unpack them on first use instead of
* gunzipping the whole archive up front. Cuts init from ~650ms/220MB to a
* few ms for callers that render a handful of things (the engine's sprite
* worker). Animation frames are still unpacked eagerly.
*/
lazyModels?: boolean;
};
/**
* Minimal standalone renderer for items and characters.
*/
@@ -88,15 +131,28 @@ export class ItemViewer {
}
// Fetch required archives
const [configData, texturesData, versionlistData] = await Promise.all([
const [config, textures, versionlist, ondemand] = await Promise.all([
downloadUrl(`${serverBase}/config${crcs[2]}`),
downloadUrl(`${serverBase}/textures${crcs[6]}`),
downloadUrl(`${serverBase}/versionlist${crcs[5]}`),
downloadUrl(`${serverBase}/ondemand.zip`)
]);
const jagConfig = new JagFile(configData);
const jagTextures = new JagFile(texturesData);
const jagVersionlist = new JagFile(versionlistData);
this.initFromData({ config, textures, versionlist, ondemand });
}
/**
* Initialize the rendering pipeline from already-loaded archive bytes.
*
* This is the transport-free half of init(): the engine uses it to run the
* viewer inside a Bun worker (reading the pack from disk) so hiscores pages
* can serve pre-rendered sprites instead of shipping the whole model cache
* to every browser.
*/
initFromData(data: ItemViewerData, options: ItemViewerOptions = {}): void {
const jagConfig = new JagFile(data.config);
const jagTextures = new JagFile(data.textures);
const jagVersionlist = new JagFile(data.versionlist);
// Parse version list to determine model count
const versionlistPacket = new Packet(jagVersionlist.read('model_version'));
@@ -108,7 +164,14 @@ export class ItemViewer {
// Initialize model system
AnimFrame.init(animCount);
Model.init(modelCount, new NoopProvider());
const zip = unzipSync(data.ondemand);
if (options.lazyModels) {
Model.init(modelCount, new ZipProvider(this.indexModels(zip)));
this.preloadAnims(zip);
} else {
Model.init(modelCount, new NoopProvider());
this.preloadModels(zip);
}
// Set up canvas drawing area
this.drawArea = new PixMap(canvas.width, canvas.height);
@@ -131,14 +194,11 @@ export class ItemViewer {
IdkType.init(jagConfig);
SpotType.init(jagConfig);
// Pre-load model data from ondemand.zip
await this.preloadModels(serverBase);
this.initialized = true;
}
/**
* Fetch and unpack all models from ondemand.zip.
* Unpack every model and animation frame from an unzipped ondemand.zip.
*
* The zip contains entries named "{archive+1}.{file}" where:
* - archive 0 (entries "1.*") = model data
@@ -147,32 +207,51 @@ export class ItemViewer {
* Each entry is gzipped with a 2-byte version trailer that must be stripped
* before decompression.
*/
private async preloadModels(serverBase: string): Promise<void> {
const data = await downloadUrl(`${serverBase}/ondemand.zip`);
const zip = unzipSync(data);
private preloadModels(zip: Record<string, Uint8Array>): void {
for (const [name, entry] of Object.entries(zip)) {
const parsed = ItemViewer.parseEntryName(name);
if (!parsed) continue;
for (const [name, entryData] of Object.entries(zip)) {
const parts = name.split('.');
if (parts.length !== 2) continue;
const archivePlusOne = parseInt(parts[0]);
const file = parseInt(parts[1]);
if (isNaN(archivePlusOne) || isNaN(file)) continue;
const archive = archivePlusOne - 1;
// Strip 2-byte version trailer and decompress
const entry = entryData as Uint8Array;
const decompressed = gunzipSync(entry.slice(0, entry.length - 2));
if (archive === 0) {
Model.unpack(file, decompressed);
} else if (archive === 1) {
const decompressed = gunzipSync(entry.subarray(0, entry.length - 2));
if (parsed.archive === 0) {
Model.unpack(parsed.file, decompressed);
} else if (parsed.archive === 1) {
AnimFrame.unpack(decompressed);
}
}
}
/** Unpack only the animation frames (archive 1); models stay compressed for ZipProvider. */
private preloadAnims(zip: Record<string, Uint8Array>): void {
for (const [name, entry] of Object.entries(zip)) {
const parsed = ItemViewer.parseEntryName(name);
if (parsed?.archive === 1) {
AnimFrame.unpack(gunzipSync(entry.subarray(0, entry.length - 2)));
}
}
}
/** Map model id -> still-compressed zip entry (archive 0). */
private indexModels(zip: Record<string, Uint8Array>): Map<number, Uint8Array> {
const entries = new Map<number, Uint8Array>();
for (const [name, entry] of Object.entries(zip)) {
const parsed = ItemViewer.parseEntryName(name);
if (parsed?.archive === 0) {
entries.set(parsed.file, entry);
}
}
return entries;
}
private static parseEntryName(name: string): { archive: number; file: number } | null {
const parts = name.split('.');
if (parts.length !== 2) return null;
const archivePlusOne = parseInt(parts[0]);
const file = parseInt(parts[1]);
if (isNaN(archivePlusOne) || isNaN(file)) return null;
return { archive: archivePlusOne - 1, file };
}
/**
* Render a 32x32 item icon.
*