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