Files
2026-10-06 22:25:34 -04:00

168 lines
8.9 KiB
JavaScript

import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { normalizeNativeResourcePath, nativeResourceFile } from "./native-collision-assembly.mjs";
export async function sha256File(file) {
const bytes = await readFile(file);
return createHash("sha256").update(bytes).digest("hex");
}
export function normalizedResourceKey(value) {
return normalizeNativeResourcePath(value).toLowerCase();
}
function resolveInventoryFile(inventoryRoot, relativePath) {
if (typeof relativePath !== "string" || !relativePath.trim()) throw new Error("Inventory file path is missing.");
const resolved = path.resolve(inventoryRoot, relativePath);
const relative = path.relative(path.resolve(inventoryRoot), resolved);
if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) {
throw new Error(`Inventory file escaped its root: ${relativePath}`);
}
return resolved;
}
export async function writeNativeCollisionInventoryManifest({
inventoryRoot,
placementManifestSha256,
sourceWdbSha256,
nativeExtractor,
collisionMode = "complete",
resources,
sourceResourceRoot,
}) {
if (!new Set(["complete", "respect-options"]).has(collisionMode)) throw new Error(`Unsupported native collision mode: ${collisionMode}`);
const rows = [];
for (const resource of [...resources].sort((a, b) => normalizedResourceKey(a.resource).localeCompare(normalizedResourceKey(b.resource)))) {
const sourcePath = nativeResourceFile(sourceResourceRoot, resource.resource);
const sourceBefore = await sha256File(sourcePath);
if (resource.sourceResourceSha256 && resource.sourceResourceSha256 !== sourceBefore) {
throw new Error(`Source ROS changed after native extraction: ${resource.resource}`);
}
const reportSha256 = await sha256File(resource.reportFile);
const objSha256 = await sha256File(resource.objFile);
const flagsSha256 = await sha256File(resource.flagsFile);
const sourceAfter = await sha256File(sourcePath);
if (sourceBefore !== sourceAfter) throw new Error(`Source ROS changed while indexing collision extraction: ${resource.resource}`);
const relative = (file) => {
const rel = path.relative(inventoryRoot, file);
if (path.isAbsolute(rel) || rel === ".." || rel.startsWith(`..${path.sep}`)) {
throw new Error(`Collision extraction output is outside inventory root: ${file}`);
}
return rel.split(path.sep).join("/");
};
rows.push({
resource: resource.resource,
sourceResourceSha256: resource.sourceResourceSha256 ?? sourceBefore,
report: relative(resource.reportFile),
reportSha256,
obj: relative(resource.objFile),
objSha256,
triangleFlags: relative(resource.flagsFile),
triangleFlagsSha256: flagsSha256,
nativeTriangleCount: Number(resource.report.nativeCollisionTriangleCount),
completeNativeTriangleCount: Number(resource.report.completeNativeCollisionTriangleCount ?? resource.report.nativeCollisionTriangleCount),
nativeTrianglesOmittedByCollisionOptions: Number(resource.report.nativeTrianglesOmittedByCollisionOptions ?? 0),
disabledCollisionOptionEntityCount: Number(resource.report.disabledCollisionOptionEntityCount ?? 0),
entityNodesSkippedByCollisionOptionGates: Number(resource.report.entityNodesSkippedByCollisionOptionGates ?? 0),
renderMeshFallbackTriangleCount: Number(resource.report.renderMeshFallbackTriangleCount ?? 0),
});
}
const manifest = {
schemaVersion: 2,
extraction: "Rune Engine serialized IRuCollisionObject / CRuCollisionStructure; render-mesh fallback excluded",
collisionMode,
placementManifestSha256,
sourceWdbSha256,
nativeExtractor,
triangleFlagsEncoding: "one little-endian uint32 per serialized native collision triangle, in OBJ triangle order",
resources: rows,
};
const file = path.join(inventoryRoot, "native-collision-inventory.json");
await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
return { manifest, file, sha256: await sha256File(file) };
}
export async function readAndValidateNativeCollisionInventory({
inventoryRoot,
placementManifestSha256,
sourceWdbSha256,
collisionMode = "complete",
nativeExtractor,
expectedResources,
sourceResourceRoot,
}) {
const manifestPath = path.join(inventoryRoot, "native-collision-inventory.json");
const inventory = JSON.parse(await readFile(manifestPath, "utf8"));
if (![1, 2].includes(inventory.schemaVersion)) throw new Error(`Unsupported native collision inventory schema: ${inventory.schemaVersion}`);
const indexedCollisionMode = inventory.collisionMode ?? "complete";
if (indexedCollisionMode !== collisionMode) {
throw new Error(`Native collision inventory mode ${indexedCollisionMode} does not match requested mode ${collisionMode}.`);
}
if (nativeExtractor) {
for (const field of ["executableSha256", "sourceSha256"]) {
if (inventory.nativeExtractor?.[field] !== nativeExtractor[field]) {
throw new Error(`Native collision inventory ${field} does not match the current extractor.`);
}
}
if ((inventory.nativeExtractor?.collisionMode ?? "complete") !== collisionMode) {
throw new Error("Native collision inventory extractor mode does not match the requested mode.");
}
}
if (inventory.placementManifestSha256 !== placementManifestSha256) {
throw new Error("Native collision inventory is bound to a different placement manifest.");
}
if (inventory.sourceWdbSha256 !== sourceWdbSha256) throw new Error("Native collision inventory is bound to a different source WDB.");
if (!Array.isArray(inventory.resources)) throw new Error("Native collision inventory has no resource rows.");
const expected = new Map(expectedResources.map((resource) => [normalizedResourceKey(resource), resource]));
const validated = new Map();
for (const row of inventory.resources) {
const key = normalizedResourceKey(row.resource);
if (!expected.has(key)) throw new Error(`Unexpected resource in collision inventory: ${row.resource}`);
if (validated.has(key)) throw new Error(`Duplicate resource in collision inventory: ${row.resource}`);
const sourcePath = nativeResourceFile(sourceResourceRoot, row.resource);
const actualSourceHash = await sha256File(sourcePath);
if (actualSourceHash !== row.sourceResourceSha256) {
throw new Error(`Source ROS SHA-256 mismatch for ${row.resource}; regenerate the collision inventory.`);
}
const reportFile = resolveInventoryFile(inventoryRoot, row.report);
const objFile = resolveInventoryFile(inventoryRoot, row.obj);
const flagsFile = resolveInventoryFile(inventoryRoot, row.triangleFlags);
if (await sha256File(reportFile) !== row.reportSha256) throw new Error(`Collision report changed after inventory was indexed: ${row.resource}`);
if (await sha256File(objFile) !== row.objSha256) throw new Error(`Collision OBJ changed after inventory was indexed: ${row.resource}`);
if (await sha256File(flagsFile) !== row.triangleFlagsSha256) throw new Error(`Collision flag sidecar changed after inventory was indexed: ${row.resource}`);
const report = JSON.parse(await readFile(reportFile, "utf8"));
if (normalizedResourceKey(report.sourceModel) !== key) throw new Error(`Collision report resource identity mismatch for ${row.resource}`);
if ((report.collisionMode ?? "complete") !== collisionMode) throw new Error(`Collision report mode mismatch for ${row.resource}`);
if (Number(report.nativeCollisionTriangleCount) !== Number(row.nativeTriangleCount)) {
throw new Error(`Collision report triangle count mismatch for ${row.resource}`);
}
const completeCount = Number(report.completeNativeCollisionTriangleCount ?? report.nativeCollisionTriangleCount);
if (!Number.isSafeInteger(completeCount) || completeCount < Number(row.nativeTriangleCount)) {
throw new Error(`Invalid complete collision triangle count for ${row.resource}`);
}
if (Number(row.completeNativeTriangleCount ?? completeCount) !== completeCount) {
throw new Error(`Complete collision triangle count mismatch for ${row.resource}`);
}
const fallbackCount = Number(report.renderMeshFallbackTriangleCount ?? 0);
if (fallbackCount !== Number(row.renderMeshFallbackTriangleCount ?? 0)) {
throw new Error(`Collision report fallback count mismatch for ${row.resource}`);
}
if (fallbackCount < 0 || !Number.isSafeInteger(fallbackCount)) throw new Error(`Invalid collision fallback count for ${row.resource}`);
validated.set(key, {
resource: expected.get(key),
row,
report,
reportFile,
objFile,
flagsFile,
});
}
const missing = [...expected.keys()].filter((key) => !validated.has(key));
if (missing.length) throw new Error(`Native collision inventory omits ${missing.length} WDB resource(s): ${missing.slice(0, 5).join(", ")}`);
return { inventory, manifestPath, manifestSha256: await sha256File(manifestPath), resources: validated };
}