Back to Supersonic RC Revive
SOURCE / PINNED RELEASE

Made of little things.

Supersonic RC Revive

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

Archive hash verified: 76e4eb30c67a6f45…. 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, LevelData, Markers } from './level/data.js';
import { parseRayMesh } from './level/data.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;
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 metadata (null until one is installed). */
export function currentPack(): PackMeta | null {
  return packMeta;
}

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

/** Parse and install a pack, replacing any previous one. */
export function installPack(buffer: ArrayBuffer): PackMeta {
  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;
  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'));
}

/** 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 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;
  }
}

/** Ask the player for a .ssrcpack 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' }], description: 'Choose a SuperSonic RC asset pack (.ssrcpack)' });
  } 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 {
  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;
}

export async function loadLevelData(): Promise<LevelData | undefined> {
  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) };
}