mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-13 01:10:50 +00:00
175 lines
5.3 KiB
TypeScript
175 lines
5.3 KiB
TypeScript
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<string, ResourceEntry>;
|
|
}
|
|
|
|
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<Manifest> {
|
|
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<string | null> {
|
|
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/<shapeName>"), 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;
|
|
|
|
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/<name>.png and textures/<name>.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;
|
|
|
|
console.log(
|
|
`[crc] RESULT: ${filesFound} files CRC'd, ${filesMissing} missing, ` +
|
|
`crc=0x${crc.toString(16)}, totalSize=${totalSize}`,
|
|
);
|
|
|
|
return { crc, totalSize };
|
|
}
|