mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
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:
1 parent
db1f30e1c1
commit
56b73e08fc
7 files changed
+580
-86
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user