import fs from "node:fs/promises"; import path from "node:path"; // CRC-32 lookup table (reflected polynomial 0xEDB88320) const crcTable = new Uint32Array(256); for (let i = 0; i < 256; i++) { let crc = i; for (let j = 0; j < 8; j++) { crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; } crcTable[i] = crc; } /** * Raw CRC-32 over a buffer, continuing from an initial value. * Tribes 2 uses raw CRC (no XOR-in/XOR-out) — verified against * decompiled FUN_004411b0 in Tribes2.exe. */ export function crc32(data: Uint8Array, initial = 0): number { let crc = initial; for (let i = 0; i < data.length; i++) { crc = (crc >>> 8) ^ crcTable[(crc ^ data[i]) & 0xff]; } return crc >>> 0; } /** * Classes that derive from ShapeBaseData in Tribes 2. * Determined by which DataBlockParser unpack functions call shapeBaseDataUnpack. */ const SHAPE_BASE_DATA_CLASSES = new Set([ "ShapeBaseData", "PlayerData", "VehicleData", "FlyingVehicleData", "HoverVehicleData", "WheeledVehicleData", "StaticShapeData", "TurretData", "ItemData", "CameraData", "MissionMarkerData", ]); export interface CRCDataBlock { objectId: number; className: string; shapeName: string; } // Manifest types (mirrored from src/manifest.ts) type SourceTuple = | [sourcePath: string] | [sourcePath: string, actualPath: string]; type ResourceEntry = [firstSeenPath: string, ...SourceTuple[]]; interface Manifest { resources: Record; } let cachedManifest: Manifest | null = null; /** * Path to manifest.json. Defaults to `public/manifest.json` relative to the * project root, but can be overridden via `MANIFEST_PATH` env var for * deployment outside the monorepo layout. */ async function loadManifest(basePath: string): Promise { if (cachedManifest) return cachedManifest; const manifestPath = process.env.MANIFEST_PATH || path.join(basePath, "..", "..", "public", "manifest.json"); const raw = await fs.readFile(manifestPath, "utf-8"); cachedManifest = JSON.parse(raw) as Manifest; return cachedManifest; } /** Resolve a game resource path to a local file path using the manifest. */ async function resolveGameFile( resourcePath: string, basePath: string, ): Promise { const manifest = await loadManifest(basePath); const key = resourcePath.toLowerCase().replace(/\\/g, "/"); const entry = manifest.resources[key]; if (!entry) return null; const [firstSeenPath, ...sources] = entry; const [sourcePath, actualPath] = sources[sources.length - 1]; if (sourcePath) { return path.join(basePath, "@vl2", sourcePath, actualPath ?? firstSeenPath); } return path.join(basePath, actualPath ?? firstSeenPath); } /** * Compute the game CRC matching Tribes 2's FUN_00440580. * * Algorithm: * 1. Start with seed as initial CRC value * 2. For each ShapeBaseData datablock (sorted by objectId): * - CRC-32 the shape file ("shapes/"), using running CRC * - Accumulate file size * - If includeTextures and not PlayerData: also CRC texture files * 3. Final: crc += totalSize */ export async function computeGameCRC( seed: number, datablocks: CRCDataBlock[], basePath: string, includeTextures = false, ): Promise<{ crc: number; totalSize: number }> { // Sort by objectId to match the binary's iteration order (0-2047) const sorted = [...datablocks] .filter((db) => SHAPE_BASE_DATA_CLASSES.has(db.className) && db.shapeName) .sort((a, b) => a.objectId - b.objectId); let crc = seed; let totalSize = 0; let filesFound = 0; let filesMissing = 0; const startTime = performance.now(); console.log( `[crc] starting computation: seed=0x${(seed >>> 0).toString(16)}, ` + `${sorted.length} ShapeBaseData datablocks (of ${datablocks.length} total), ` + `includeTextures=${includeTextures}`, ); for (const db of sorted) { const shapePath = `shapes/${db.shapeName}`; const localPath = await resolveGameFile(shapePath, basePath); if (!localPath) { console.log( `[crc] SKIP id=${db.objectId} ${db.className} "${db.shapeName}" — not found in manifest`, ); filesMissing++; continue; } let data: Uint8Array; try { data = new Uint8Array(await fs.readFile(localPath)); } catch { console.log( `[crc] SKIP id=${db.objectId} ${db.className} "${db.shapeName}" — file read failed`, ); filesMissing++; continue; } const prevCrc = crc; crc = crc32(data, crc); totalSize += data.length; filesFound++; console.log( `[crc] #${filesFound} id=${db.objectId} ${db.className} "${db.shapeName}" ` + `size=${data.length} crc=0x${prevCrc.toString(16)}→0x${crc.toString(16)}`, ); // TODO: If includeTextures && db.className !== "PlayerData", // parse the DTS to enumerate textures and CRC each // textures/.png and textures/.bm8 file. // Most servers don't enable $Host::CRCTextures, so this is deferred. if (includeTextures && db.className !== "PlayerData") { // Texture CRC not yet implemented — would need DTS parsing // to enumerate material textures for each shape. } } crc = (crc + totalSize) >>> 0; const elapsed = performance.now() - startTime; console.log( `[crc] RESULT: ${filesFound} files CRC'd, ${filesMissing} missing, ` + `crc=0x${crc.toString(16)}, totalSize=${totalSize}, elapsed=${elapsed.toFixed(0)}ms`, ); return { crc, totalSize }; }