Back to Supersonic RC Revive
SOURCE / PINNED RELEASE

Made of little things.

Supersonic RC Revive

Release
1ba42f1ca1d6…
Author-recorded commit
baecad10b1cd…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-143146b0d6f

Archive hash verified: 90d22b206672eba4…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/assets.ts
// The game's asset pack (.ssrcpack, see tools/build/asset-pack.ts). Dev and
// original-asset builds embed it; release builds name it by SHA-256 and
// download it from Blossom through NAP-RESOURCE. A player can also side-load a
// pack file through the host's file picker, or paste a Blossom link or hash.
// Compressible files arrive gzip-compressed and are inflated here in memory,
// without network access.
import { FS_DOMAIN, fs, RESOURCE_DOMAIN, resource } from '@napplet/sdk';
import source from 'virtual:ssrc-assets';
import { runtimeHasDomain } from '../domain-availability.js';
import { type CollisionFile, type LevelData, type Markers, parseRayMesh } from './level/data.js';
import { deriveLevel, isEditorLevel, levelSettings } from './level/editorLevel.js';
import { parseGlb } from './render/glb.js';

const MAGIC = [0x89, 0x53, 0x53, 0x52, 0x43, 0x50, 0x4b, 0x0a];
const VERSION = 1;
/** NAP-RESOURCE and fs session limits are 10 MiB per file. */
const MAX_PACK_BYTES = 10 * 1024 * 1024;
const FS_CHUNK = 256 * 1024;

export interface PackMeta {
  title: string;
  /** Built from the 2004 game's files: local use only (flagged in the UI). */
  original: boolean;
}

interface Entry {
  mime: string;
  gz: boolean;
  bytes: Uint8Array<ArrayBuffer>;
}

let entries = new Map<string, Entry>();
let packMeta: PackMeta | null = null;
let packBuffer: ArrayBuffer | null = null;
/** A level .glb played on top of the installed pack (installLevelOverlay). */
let overlay: { buffer: ArrayBuffer; meta: PackMeta } | null = null;
const dataUrls = new Map<string, string>();

export class PackError extends Error {}

/** Whether this build carries its pack, or has to download / side-load one. */
export const PACK_EMBEDDED = source.kind === 'embedded';

/** The installed pack's own metadata, ignoring any level overlay. */
export function installedPack(): PackMeta | null {
  return packMeta;
}

/** The installed pack's metadata, or the level overlay's (null until a pack is installed). */
export function currentPack(): PackMeta | null {
  return overlay?.meta ?? packMeta;
}

/** The installed pack's bytes, to reinstall it if a switch to another pack fails. */
export function currentPackBuffer(): ArrayBuffer | null {
  return packBuffer;
}

/** Whether bytes are a glTF binary (a level .glb) rather than a pack. */
export function isGlb(buffer: ArrayBuffer): boolean {
  return buffer.byteLength >= 4 && new DataView(buffer).getUint32(0, true) === 0x46546c67;
}

/** The level .glb played on top of the pack, if any (to restore it if a switch fails). */
export function currentLevelOverlay(): ArrayBuffer | null {
  return overlay?.buffer ?? null;
}

/**
 * Play a level .glb (one exported from Blender, docs/level-editing.md) with
 * the installed pack's car, props, sounds and screens. The pack's own level
 * files are hidden while it is installed; null removes it. Throws if the
 * bytes are not a readable GLB.
 */
export function installLevelOverlay(buffer: ArrayBuffer | null): PackMeta | null {
  if (!buffer) {
    overlay = null;
    dataUrls.clear();
    return null;
  }
  if (!packMeta) throw new PackError('A level file plays with the game data; load an asset pack first.');
  let settings;
  try {
    settings = levelSettings(parseGlb(buffer).doc);
  } catch (error) {
    throw new PackError(`That level file cannot be read (${errorDetail(error)}).`);
  }
  overlay = { buffer, meta: { title: settings?.title ?? 'Custom level', original: settings?.original === true } };
  dataUrls.clear();
  return overlay.meta;
}

/** Pack files a level overlay replaces (its level.glb) or hides (the legacy level's). */
const OVERLAID = /^(level\.glb|collision\.json|raymesh\.bin|lightmaps\/.*)$/i;

/** Parse and install a pack, replacing any previous one (and any level overlay). */
export function installPack(buffer: ArrayBuffer): PackMeta {
  if (isGlb(buffer)) throw new PackError('That is a level file (.glb); load it from the title screen’s Asset pack… button.');
  const bytes = new Uint8Array(buffer);
  if (bytes.length < 16 || MAGIC.some((b, i) => bytes[i] !== b)) throw new PackError('That file is not a SuperSonic RC asset pack.');
  const view = new DataView(buffer);
  if (view.getUint32(8, true) !== VERSION) throw new PackError('This asset pack was made for a different version of the game.');
  const indexLength = view.getUint32(12, true);
  let index: { meta: PackMeta; files: Array<{ name: string; mime: string; gz: 0 | 1; offset: number; length: number }> };
  try {
    index = JSON.parse(new TextDecoder().decode(bytes.subarray(16, 16 + indexLength)));
  } catch {
    throw new PackError('The asset pack is damaged (unreadable index).');
  }
  const base = 16 + indexLength;
  const next = new Map<string, Entry>();
  for (const f of index.files) {
    if (base + f.offset + f.length > bytes.length) throw new PackError('The asset pack is damaged (truncated).');
    next.set(f.name, { mime: f.mime, gz: f.gz === 1, bytes: bytes.subarray(base + f.offset, base + f.offset + f.length) });
  }
  entries = next;
  overlay = null;
  dataUrls.clear();
  packBuffer = buffer;
  packMeta = { title: String(index.meta?.title ?? 'Asset pack'), original: index.meta?.original === true };
  return packMeta;
}

function decodeBase64(base64: string): Uint8Array<ArrayBuffer> {
  const bin = atob(base64);
  const out = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
  return out;
}

function encodeBase64(bytes: Uint8Array): string {
  let bin = '';
  for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
  return btoa(bin);
}

async function sha256Hex(buffer: ArrayBuffer): Promise<string | null> {
  if (typeof crypto === 'undefined' || !crypto.subtle) return null;
  const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', buffer));
  return [...digest].map((b) => b.toString(16).padStart(2, '0')).join('');
}

/** Whether a release build can download its pack here. */
export function canDownloadPack(): boolean {
  return source.kind === 'external' && runtimeHasDomain(RESOURCE_DOMAIN);
}

/** Whether the host offers a file picker for side-loading a pack. */
export function canSideLoadPack(): boolean {
  return runtimeHasDomain(FS_DOMAIN);
}

/** Whether the host can download a pack the player links by hash. */
export function canLoadPackLink(): boolean {
  return runtimeHasDomain(RESOURCE_DOMAIN);
}

/**
 * A pasted pack link: its SHA-256, the Blossom servers it names (hints for the
 * host), and the direct blob URLs to try if the host cannot find it.
 */
export interface PackLink {
  sha256: string;
  servers: string[];
  urls: string[];
}

const HASH = /(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])/gi;

/** A server hint as an https origin ("cdn.example.com" or "https://cdn.example.com/"), else null. */
function serverOrigin(hint: string): string | null {
  try {
    const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(hint) ? hint : `https://${hint}`);
    return url.protocol === 'https:' ? url.origin : null;
  } catch {
    return null;
  }
}

/**
 * Read a pasted pack link: a bare SHA-256, "blossom:sha256:<hash>", a BUD-10
 * "blossom:<hash>.ssrcpack?xs=<server>&xs=…" URI, or a Blossom blob URL such
 * as "https://server/<hash>.ssrcpack". The hash is the last 64-hex run of the
 * path (where Blossom puts it). A blob URL's own server and every "xs" hint
 * become Blossom servers; the blob URL, and the hash's URL on each "xs"
 * server, are the direct fallbacks. Null when there is no hash.
 */
export function parsePackLink(text: string): PackLink | null {
  const trimmed = text.trim();
  let url: URL | null = null;
  if (/^https?:\/\//i.test(trimmed)) {
    try {
      url = new URL(trimmed);
    } catch {
      return null;
    }
  }
  const [path = '', query = ''] = url ? [url.pathname, url.search.slice(1)] : trimmed.split('?', 2);
  const hashes = path.match(HASH);
  if (!hashes) return null;
  const sha256 = hashes[hashes.length - 1]!.toLowerCase();
  const servers = new Set<string>();
  const urls = new Set<string>();
  if (url?.protocol === 'https:') {
    servers.add(url.origin);
    urls.add(url.href);
  }
  if (url || /^blossom:/i.test(path)) {
    // Keep the file extension from a BUD-10 URI ("<hash>.ssrcpack") on the fallback URLs.
    const ext = url ? '' : (/[0-9a-f]{64}(\.[a-z0-9]+)$/i.exec(path)?.[1] ?? '');
    for (const hint of new URLSearchParams(query).getAll('xs')) {
      const origin = serverOrigin(hint);
      if (!origin) continue;
      servers.add(origin);
      urls.add(`${origin}/${sha256}${ext}`);
    }
  }
  return { sha256, servers: [...servers], urls: [...urls] };
}

/**
 * Download a pack by hash through NAP-RESOURCE: first as a Blossom blob, with
 * any server hints, so the host picks the servers and verifies the hash; then
 * from each direct URL in turn. Checks the hash either way. Not installed.
 */
async function downloadPack(sha256: string, what: string, servers: string[] = [], urls: string[] = []): Promise<ArrayBuffer> {
  if (!runtimeHasDomain(RESOURCE_DOMAIN)) throw new PackError(`This host cannot download ${what}.`);
  const attempts = [
    () => resource.bytes(`blossom:sha256:${sha256}`, servers.length ? { servers } : undefined),
    ...urls.map((url) => () => resource.bytes(url)),
  ];
  let failure: unknown;
  for (const attempt of attempts) {
    try {
      const blob = await attempt();
      if (blob.size > MAX_PACK_BYTES) throw new PackError('That file is too large to be an asset pack.');
      const buffer = await blob.arrayBuffer();
      // The host verifies Blossom hashes but not plain https bytes; check where WebCrypto is available.
      const hash = await sha256Hex(buffer);
      if (hash !== null && hash !== sha256) throw new PackError(`The download of ${what} does not match its hash.`);
      return buffer;
    } catch (error) {
      failure = error;
    }
  }
  if (failure instanceof PackError) throw failure;
  throw new PackError(`Could not download ${what} (${errorDetail(failure)}).`, { cause: failure });
}

/** A NAP-RESOURCE rejection's code: its `code`, or the "code: …" prefix some runtimes put in the message. */
function resourceErrorCode(error: unknown): string | undefined {
  const code = (error as { code?: unknown } | null)?.code;
  if (typeof code === 'string') return code;
  return error instanceof Error ? /^([a-z]+(?:-[a-z]+)*):/.exec(error.message)?.[1] : undefined;
}

function errorDetail(error: unknown): string {
  return error instanceof Error ? error.message : String(error);
}

/** Install the build's own pack: decode the embedded one, or download it from Blossom. */
export async function loadBuildPack(): Promise<PackMeta> {
  if (source.kind === 'embedded') return installPack(decodeBase64(source.pack).buffer);
  return installPack(await downloadPack(source.sha256, 'the game data'));
}

/** An extra level registered as a soyli asset ("level-<name>", tools/assets/register.ts). */
export type RegisteredLevel = (typeof source.levels)[number];

/** The extra levels this build offers (empty without the host's resource domain, which downloads them). */
export function registeredLevels(): RegisteredLevel[] {
  return canLoadPackLink() ? source.levels : [];
}

const levelDownloads = new Map<string, Promise<ArrayBuffer>>();

/** Download a registered level's GLB by its hash (not installed); kept for the session. */
export function fetchRegisteredLevel(level: RegisteredLevel): Promise<ArrayBuffer> {
  let pending = levelDownloads.get(level.sha256);
  if (!pending) {
    pending = downloadPack(level.sha256, `the level “${level.title}”`);
    pending.catch(() => levelDownloads.delete(level.sha256));
    levelDownloads.set(level.sha256, pending);
  }
  // A copy: installing the level keeps (and may transfer) the buffer it is given.
  return pending.then((buffer) => buffer.slice(0));
}

/** Download the pack a pasted link or hash names (not installed). */
export async function fetchPackLink(text: string): Promise<ArrayBuffer> {
  const link = parsePackLink(text);
  if (!link) throw new PackError('Paste a Blossom link or the 64-character SHA-256 hash of an .ssrcpack or level .glb file.');
  try {
    return await downloadPack(link.sha256, 'that asset pack', link.servers, link.urls);
  } catch (error) {
    // A bare hash the host's own Blossom servers do not have (they report not-found or blocked-by-policy).
    const code = error instanceof PackError ? resourceErrorCode(error.cause) : undefined;
    if (!link.servers.length && (code === 'not-found' || code === 'blocked-by-policy'))
      throw new PackError("This host's Blossom servers do not have that asset pack. Paste the full link to it instead.");
    throw error;
  }
}

/**
 * The original-assets pack: a BUD-10 URI naming its SHA-256 (the byte-
 * deterministic output of tools/build-original-assets.sh) and the Blossom
 * servers that carry it. The napplet only ever names those bytes by hash — it
 * never bundles or uploads them — so the download resolves only where a listed
 * server has the blob. See the secret code in src/main.ts.
 */
export const ORIGINAL_PACK_LINK =
  'blossom:9cbb6eed57dc0a276d4e0925f20704c5dd6a4a69aad94e1a7ce6c678cde12518.ssrcpack?xs=blossom.ditto.pub&xs=blossom.napplet.soy';

/** Download the original-assets pack by its built-in link (not installed). */
export async function fetchOriginalPack(): Promise<ArrayBuffer> {
  const link = parsePackLink(ORIGINAL_PACK_LINK);
  if (!link) throw new PackError('The built-in original asset pack link is malformed.');
  try {
    return await downloadPack(link.sha256, 'the original assets', link.servers, link.urls);
  } catch (error) {
    if (error instanceof PackError) throw error;
    const servers = link.servers.map((s) => s.replace(/^https?:\/\//, '')).join(' or ');
    throw new PackError(`The original asset pack is not available${servers ? ` (tried ${servers})` : ''}.`, { cause: error });
  }
}

/** Ask the player for a .ssrcpack or level .glb file and read it (not installed). Resolves null if they cancel. */
export async function pickPackFile(): Promise<ArrayBuffer | null> {
  let picked;
  try {
    picked = await fs.pickFile({
      accept: [{ extension: '.ssrcpack' }, { extension: '.glb' }],
      description: 'Choose a SuperSonic RC asset pack (.ssrcpack) or level (.glb)',
    });
  } catch (error) {
    if (error instanceof Error && /cancel/i.test(error.message)) return null;
    throw new PackError(`Could not open the file (${errorDetail(error)}).`);
  }
  const entry = picked.entries.find((e) => e.kind === 'file');
  if (!entry) return null;
  const chunks: Uint8Array[] = [];
  let offset = 0;
  for (;;) {
    const part = await fs.read(entry.path, { offset, length: FS_CHUNK });
    const bytes = decodeBase64(part.data);
    chunks.push(bytes);
    offset += part.bytesRead;
    if (offset > MAX_PACK_BYTES) throw new PackError('That file is too large to be an asset pack.');
    if (part.eof || part.bytesRead === 0) break;
  }
  const all = new Uint8Array(offset);
  let at = 0;
  for (const c of chunks) {
    all.set(c, at);
    at += c.length;
  }
  return all.buffer;
}

const stem = (name: string) => name.replace(/\.[^./]+$/, '').toLowerCase();

/**
 * The entry for "dir/Name.ext". Names are matched without their extension, so
 * an asset re-encoded to another format (PNG→WebP, WAV→MP3) still resolves.
 */
function find(name: string): Entry | undefined {
  if (overlay && OVERLAID.test(name)) {
    return /^level\.glb$/i.test(name) ? { mime: 'model/gltf-binary', gz: false, bytes: new Uint8Array(overlay.buffer) } : undefined;
  }
  const exact = entries.get(name);
  if (exact) return exact;
  const want = stem(name);
  for (const [key, entry] of entries) if (stem(key) === want) return entry;
  return undefined;
}

async function inflate(bytes: Uint8Array<ArrayBuffer>): Promise<ArrayBuffer> {
  if (typeof DecompressionStream === 'undefined') throw new Error('This browser is too old to unpack the game data (no DecompressionStream).');
  return new Response(new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'))).arrayBuffer();
}

/** Data URL for an image (e.g. "lightmaps/001LightingMap.jpg"), if the pack has it. */
export function assetUrl(name: string): string | undefined {
  const entry = find(name);
  if (!entry) return undefined;
  if (entry.gz) throw new Error(`${name} is stored compressed; use assetBytes`);
  let url = dataUrls.get(name);
  if (!url) dataUrls.set(name, (url = `data:${entry.mime};base64,${encodeBase64(entry.bytes)}`));
  return url;
}

export async function assetBytes(name: string): Promise<ArrayBuffer | undefined> {
  const entry = find(name);
  if (!entry) return undefined;
  return entry.gz ? inflate(entry.bytes) : entry.bytes.slice().buffer;
}

export async function assetJson<T>(name: string): Promise<T | undefined> {
  const bytes = await assetBytes(name);
  return bytes ? (JSON.parse(new TextDecoder().decode(bytes)) as T) : undefined;
}

/**
 * Whether the pack's level.glb is a Blender-authored level (everything derived
 * from the GLB) rather than a legacy one with collision.json and raymesh.bin.
 */
export function levelIsEditor(levelGlb: ArrayBuffer): boolean {
  return isEditorLevel(levelGlb) || !find('collision.json') || !find('raymesh.bin');
}

export async function loadLevelData(): Promise<LevelData | undefined> {
  const glb = await assetBytes('level.glb');
  if (glb && levelIsEditor(glb)) {
    // A legacy markers.json still supplies the car hull when the level has none.
    const legacy = await assetJson<Markers>('markers.json');
    const { level, warnings } = deriveLevel(glb, { carHull: legacy?.car_hull });
    for (const w of warnings) console.warn(`level.glb: ${w}`);
    return level;
  }
  const [markers, collision, rays] = await Promise.all([
    assetJson<Markers>('markers.json'),
    assetJson<CollisionFile>('collision.json'),
    assetBytes('raymesh.bin'),
  ]);
  if (!markers || !collision || !rays) return undefined;
  return { markers, collision, rayMesh: parseRayMesh(rays) };
}