forked from phenom/healer-man
168 lines
8.9 KiB
JavaScript
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 };
|
|
}
|