655 lines
32 KiB
JavaScript
655 lines
32 KiB
JavaScript
import { spawn } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { createReadStream } from "node:fs";
|
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import readline from "node:readline";
|
|
import { Document, NodeIO } from "@gltf-transform/core";
|
|
import { projectRoot, readJson, sourceRootFor } from "./lib/recipe.mjs";
|
|
import {
|
|
isTrekkerPathEligible,
|
|
NativeCollisionPartAccumulator,
|
|
normalizeNativeResourcePath,
|
|
nativeResourceFile,
|
|
renderMeshOmissions,
|
|
transformNativeCollisionTriangle,
|
|
triangleTileKey,
|
|
TREKKER_WATER_FOLIAGE_EXCLUSION_MASK,
|
|
validateNativeCollisionCounts,
|
|
} from "./lib/native-collision-assembly.mjs";
|
|
import {
|
|
normalizedResourceKey,
|
|
readAndValidateNativeCollisionInventory,
|
|
sha256File,
|
|
writeNativeCollisionInventoryManifest,
|
|
} from "./lib/native-collision-inventory.mjs";
|
|
|
|
const DEFAULT_RECIPE = "scripts/runewaker-pipeline/recipes/the-origin.json";
|
|
const MAX_TRIANGLES_PER_GLB = 90_000;
|
|
const DEFAULT_TILE_SIZE_METERS = 256;
|
|
const USAGE = `Assemble bounded collision chunks from serialized native Runewaker ROS collision.
|
|
|
|
Usage:
|
|
node scripts/runewaker-pipeline/assemble-native-collision.mjs --recipe <recipe.json> --placement-manifest <wdb-placement-manifest.json> --output-root <directory> [options]
|
|
|
|
Required:
|
|
--recipe <path> Dungeon recipe; supplies source root, WDB, and coordinate scale
|
|
--placement-manifest <path> Exact WDB placement matrices and resource list
|
|
--output-root <path> Output directory for the deterministic run and default inventory
|
|
|
|
Options:
|
|
--source-root <path> Override recipe source root
|
|
--inventory-root <path> Reuse inventory with native-collision-inventory.json SHA index
|
|
--extractor <path> Native Rune Engine collision extractor executable
|
|
--collision-mode <mode> complete (default) or respect-options (honor ruENTITYOPTION_COLLISION)
|
|
--tile-size-meters <number> Spatial tile size (default ${DEFAULT_TILE_SIZE_METERS})
|
|
--report-output <path> Copy the complete run report to a stable workspace file
|
|
--help Show this help
|
|
|
|
Each collision GLB is limited to ${MAX_TRIANGLES_PER_GLB.toLocaleString()} triangles. No render-mesh fallback is used.`;
|
|
|
|
function parseArgs(argv) {
|
|
const options = {};
|
|
const valueOptions = new Set([
|
|
"recipe", "placement-manifest", "output-root", "source-root", "inventory-root", "extractor", "collision-mode", "tile-size-meters", "report-output",
|
|
]);
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const token = argv[index];
|
|
if (token === "--help" || token === "-h") return { help: true };
|
|
if (!token.startsWith("--")) throw new Error(`Unexpected positional argument: ${token}`);
|
|
const name = token.slice(2);
|
|
if (!valueOptions.has(name)) throw new Error(`Unknown option --${name}.`);
|
|
if (options[name] !== undefined) throw new Error(`Option --${name} may only be supplied once.`);
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith("--")) throw new Error(`Option --${name} requires a value.`);
|
|
options[name] = value;
|
|
index += 1;
|
|
}
|
|
if (!options.recipe) options.recipe = DEFAULT_RECIPE;
|
|
if (options["collision-mode"] === undefined) options["collision-mode"] = "complete";
|
|
if (!["complete", "respect-options"].includes(options["collision-mode"])) throw new Error("--collision-mode must be complete or respect-options.");
|
|
for (const required of ["placement-manifest", "output-root"]) {
|
|
if (!options[required]) throw new Error(`Missing required option --${required}.`);
|
|
}
|
|
return { help: false, ...options };
|
|
}
|
|
|
|
function resolveProjectPath(value) {
|
|
return path.resolve(projectRoot, value);
|
|
}
|
|
|
|
function resolveWorkspaceOutput(value) {
|
|
const output = resolveProjectPath(value);
|
|
const relative = path.relative(projectRoot, output);
|
|
if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) {
|
|
throw new Error("--output-root must stay inside the HealerMan workspace.");
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function validatePlacementInputs(recipe, manifest) {
|
|
if (manifest.dungeonId && manifest.dungeonId !== recipe.slug) {
|
|
throw new Error(`Placement manifest dungeonId ${manifest.dungeonId} does not match recipe ${recipe.slug}.`);
|
|
}
|
|
if (!Array.isArray(manifest.staticPlacements) || manifest.staticPlacements.length === 0) {
|
|
throw new Error("Placement manifest must contain staticPlacements.");
|
|
}
|
|
if (!Array.isArray(manifest.resources) || manifest.resources.length === 0) {
|
|
throw new Error("Placement manifest must contain resources.");
|
|
}
|
|
if (Number(manifest.sourceDescriptorCount) !== manifest.staticPlacements.length) {
|
|
throw new Error("Placement manifest static placement count does not match sourceDescriptorCount.");
|
|
}
|
|
if (!Number.isFinite(Number(recipe.coordinateSystem?.metersPerSourceUnit)) || Number(recipe.coordinateSystem.metersPerSourceUnit) <= 0) {
|
|
throw new Error("Recipe must declare coordinateSystem.metersPerSourceUnit as a positive value.");
|
|
}
|
|
const placementResources = new Map();
|
|
for (const placement of manifest.staticPlacements) {
|
|
normalizeNativeResourcePath(placement.resource);
|
|
if (!Array.isArray(placement.transform?.primaryLocalThreeMatrix) || placement.transform.primaryLocalThreeMatrix.length !== 16) {
|
|
throw new Error(`Placement ${placement.guid ?? placement.id} lacks its exact 16-value primaryLocalThreeMatrix.`);
|
|
}
|
|
placementResources.set(normalizedResourceKey(placement.resource), placement.resource);
|
|
}
|
|
const manifestResources = new Set();
|
|
for (const row of manifest.resources) {
|
|
const key = normalizedResourceKey(row.resource);
|
|
if (manifestResources.has(key)) throw new Error(`Duplicate resource row in placement manifest: ${row.resource}`);
|
|
manifestResources.add(key);
|
|
}
|
|
for (const key of placementResources.keys()) {
|
|
if (!manifestResources.has(key)) throw new Error(`Placed ROS resource is absent from placement manifest resource index: ${key}`);
|
|
}
|
|
const staticResources = [...placementResources.keys()].map((key) => manifest.resources.find((row) => normalizedResourceKey(row.resource) === key));
|
|
return { staticPlacements: manifest.staticPlacements, resources: staticResources };
|
|
}
|
|
|
|
function runExtractor(extractor, resourceRoot, resource, reportFile, collisionMode) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(extractor, [resourceRoot, resource, reportFile.replaceAll("\\", "/"), collisionMode], {
|
|
windowsHide: true,
|
|
stdio: ["ignore", "ignore", "pipe"],
|
|
});
|
|
let stderr = "";
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (part) => { stderr += part; });
|
|
child.on("error", reject);
|
|
child.on("close", (code) => {
|
|
if (code !== 0) reject(new Error(`Native collision extractor failed for ${resource} (exit ${code}): ${stderr.trim()}`));
|
|
else resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function extractInventory({ inventoryRoot, extractor, nativeExtractor, collisionMode, resourceRoot, expectedResources, placementManifestSha256, sourceWdbSha256 }) {
|
|
await mkdir(inventoryRoot, { recursive: true });
|
|
const rows = [];
|
|
const ordered = [...expectedResources].sort((a, b) => normalizedResourceKey(a.resource).localeCompare(normalizedResourceKey(b.resource)));
|
|
for (let index = 0; index < ordered.length; index += 1) {
|
|
const resource = ordered[index].resource;
|
|
const sourceFile = nativeResourceFile(resourceRoot, resource);
|
|
const sourceBefore = await sha256File(sourceFile);
|
|
const basename = path.basename(normalizeNativeResourcePath(resource)).replace(/[^a-zd._-]/gi, "_");
|
|
const reportFile = path.join(inventoryRoot, `${String(index).padStart(4, "0")}-${basename}.json`);
|
|
await runExtractor(extractor, resourceRoot, resource, reportFile, collisionMode);
|
|
const sourceAfter = await sha256File(sourceFile);
|
|
if (sourceBefore !== sourceAfter) throw new Error(`Source ROS changed during extraction: ${resource}`);
|
|
const report = JSON.parse(await readFile(reportFile, "utf8"));
|
|
if (normalizedResourceKey(report.sourceModel) !== normalizedResourceKey(resource)) {
|
|
throw new Error(`Extractor report identity mismatch. Requested ${resource}, got ${report.sourceModel}.`);
|
|
}
|
|
if (report.collisionMode !== collisionMode) throw new Error(`Extractor collision mode mismatch for ${resource}.`);
|
|
const triangleCount = Number(report.nativeCollisionTriangleCount);
|
|
if (!Number.isSafeInteger(triangleCount) || triangleCount < 0) throw new Error(`Invalid native triangle count in report for ${resource}.`);
|
|
const objFile = `${reportFile}.obj`;
|
|
const flagsFile = `${reportFile}.flags.bin`;
|
|
rows.push({ resource, report, reportFile, objFile, flagsFile, sourceResourceSha256: sourceBefore });
|
|
if ((index + 1) % 25 === 0 || index + 1 === ordered.length) {
|
|
console.log(`extracted ${index + 1}/${ordered.length} native ROS collision resources`);
|
|
}
|
|
}
|
|
return writeNativeCollisionInventoryManifest({
|
|
inventoryRoot,
|
|
placementManifestSha256,
|
|
sourceWdbSha256,
|
|
nativeExtractor,
|
|
collisionMode,
|
|
resources: rows,
|
|
sourceResourceRoot: resourceRoot,
|
|
});
|
|
}
|
|
|
|
async function readObjTriangleSoup(file, expectedTriangles) {
|
|
const positions = new Float32Array(expectedTriangles * 9);
|
|
const reader = readline.createInterface({ input: createReadStream(file), crlfDelay: Infinity });
|
|
let vertex = 0;
|
|
let face = 0;
|
|
for await (const line of reader) {
|
|
if (line.startsWith("v ")) {
|
|
const parts = line.trim().split(/\s+/);
|
|
if (parts.length !== 4) throw new Error(`Malformed OBJ vertex in ${file}.`);
|
|
const offset = vertex * 3;
|
|
positions[offset] = Number(parts[1]);
|
|
positions[offset + 1] = Number(parts[2]);
|
|
positions[offset + 2] = Number(parts[3]);
|
|
if (![positions[offset], positions[offset + 1], positions[offset + 2]].every(Number.isFinite)) {
|
|
throw new Error(`Non-finite OBJ vertex in ${file}.`);
|
|
}
|
|
vertex += 1;
|
|
} else if (line.startsWith("f ")) {
|
|
const parts = line.trim().split(/\s+/).slice(1).map(Number);
|
|
const expected = [face * 3 + 1, face * 3 + 2, face * 3 + 3];
|
|
if (parts.length !== 3 || parts.some((value, index) => value !== expected[index])) {
|
|
throw new Error(`Native collision OBJ is not a sequential triangle soup at face ${face + 1}: ${file}`);
|
|
}
|
|
face += 1;
|
|
}
|
|
}
|
|
validateNativeCollisionCounts({
|
|
triangleCount: expectedTriangles,
|
|
flagBytes: expectedTriangles * 4,
|
|
positionVertexCount: vertex,
|
|
});
|
|
if (face !== expectedTriangles) throw new Error(`${file} has ${face} faces, expected ${expectedTriangles}.`);
|
|
return positions;
|
|
}
|
|
|
|
class TilePartWriter {
|
|
constructor({ variant, tile, outputDirectory, rootExtras }) {
|
|
this.variant = variant;
|
|
this.tile = tile;
|
|
this.outputDirectory = outputDirectory;
|
|
this.rootExtras = rootExtras;
|
|
this.partIndex = 0;
|
|
this.accumulator = new NativeCollisionPartAccumulator(MAX_TRIANGLES_PER_GLB);
|
|
this.files = [];
|
|
}
|
|
|
|
async add(points, flag) {
|
|
const complete = this.accumulator.add(points, flag);
|
|
if (complete) await this.writePart(complete);
|
|
}
|
|
|
|
async flush() {
|
|
const part = this.accumulator.finish();
|
|
if (part) await this.writePart(part);
|
|
}
|
|
|
|
async writePart(part) {
|
|
const document = new Document();
|
|
const { triangleCount, positions, sourceTriangleFlags: flags, flagCounts } = part;
|
|
document.getRoot().setExtras({
|
|
...this.rootExtras,
|
|
variant: this.variant,
|
|
tile: this.tile,
|
|
partIndex: this.partIndex,
|
|
triangleCount,
|
|
triangleFlagEncoding: "primitive.extras.sourceTriangleFlags maps one value to each consecutive triangle in POSITION order",
|
|
});
|
|
const buffer = document.createBuffer(`${this.variant}-${this.tile}-${this.partIndex}-buffer`);
|
|
const scene = document.createScene(`${this.variant}-${this.tile}-${this.partIndex}-scene`);
|
|
document.getRoot().setDefaultScene(scene);
|
|
const positionAccessor = document.createAccessor(`${this.variant}-${this.tile}-${this.partIndex}-position`, buffer)
|
|
.setType("VEC3")
|
|
.setArray(positions);
|
|
const primitive = document.createPrimitive()
|
|
.setAttribute("POSITION", positionAccessor)
|
|
.setExtras({ sourceTriangleFlags: flags, sourceTriangleFlagCounts: flagCounts });
|
|
const mesh = document.createMesh(`${this.variant}-tile-${this.tile}-part-${this.partIndex}`).addPrimitive(primitive);
|
|
scene.addChild(document.createNode(mesh.getName()).setMesh(mesh));
|
|
const filename = `${this.variant}-tile-${this.tile.replace(":", "_")}-part-${String(this.partIndex).padStart(3, "0")}.glb`;
|
|
const file = path.join(this.outputDirectory, filename);
|
|
await new NodeIO().write(file, document);
|
|
const bytes = await readFile(file);
|
|
this.files.push({
|
|
file,
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
byteLength: bytes.byteLength,
|
|
tile: this.tile,
|
|
partIndex: this.partIndex,
|
|
triangles: triangleCount,
|
|
flagCounts,
|
|
});
|
|
this.partIndex += 1;
|
|
}
|
|
}
|
|
|
|
class VariantTileCollection {
|
|
constructor({ variant, outputDirectory, rootExtras }) {
|
|
this.variant = variant;
|
|
this.outputDirectory = outputDirectory;
|
|
this.rootExtras = rootExtras;
|
|
this.tiles = new Map();
|
|
}
|
|
|
|
async add(points, flag, tileSize) {
|
|
const tile = triangleTileKey(points, tileSize);
|
|
let writer = this.tiles.get(tile);
|
|
if (!writer) {
|
|
writer = new TilePartWriter({ variant: this.variant, tile, outputDirectory: this.outputDirectory, rootExtras: this.rootExtras });
|
|
this.tiles.set(tile, writer);
|
|
}
|
|
await writer.add(points, flag);
|
|
}
|
|
|
|
async finish() {
|
|
for (const [tile, writer] of [...this.tiles.entries()].sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }))) {
|
|
await writer.flush();
|
|
}
|
|
return [...this.tiles.values()].flatMap((writer) => writer.files);
|
|
}
|
|
}
|
|
|
|
function appendMapCount(map, key, amount = 1) {
|
|
map.set(key, (map.get(key) ?? 0) + amount);
|
|
}
|
|
|
|
async function assemble({
|
|
recipe,
|
|
recipeSha256,
|
|
placementManifest,
|
|
placementManifestSha256,
|
|
sourceWdbSha256,
|
|
inventory,
|
|
outputRoot,
|
|
tileSizeMeters,
|
|
collisionMode,
|
|
}) {
|
|
const fingerprint = createHash("sha256")
|
|
.update(`${recipeSha256}\n${placementManifestSha256}\n${sourceWdbSha256}\n${inventory.manifestSha256}\n${TREKKER_WATER_FOLIAGE_EXCLUSION_MASK}\n${tileSizeMeters}\n${collisionMode}`)
|
|
.digest("hex");
|
|
const runRoot = path.join(outputRoot, "runs", fingerprint.slice(0, 24));
|
|
const allDirectory = path.join(runRoot, "collision-all");
|
|
const pathDirectory = path.join(runRoot, "collision-trekker-filtered");
|
|
await mkdir(allDirectory, { recursive: true });
|
|
await mkdir(pathDirectory, { recursive: true });
|
|
const meters = Number(recipe.coordinateSystem.metersPerSourceUnit);
|
|
const rootExtras = {
|
|
sourceManifestSha256: placementManifestSha256,
|
|
sourceWdbSha256,
|
|
nativeCollisionInventorySha256: inventory.manifestSha256,
|
|
nativeCollisionMode: collisionMode,
|
|
sourceCoordinateTransform: `pThree=S*pRunewaker; S=scale(${meters},${meters},-${meters}), then exact WDB primaryLocalThreeMatrix`,
|
|
collisionSource: "serialized native IRuCollisionObject / CRuCollisionStructure only; render-mesh fallback excluded",
|
|
trekkerWaterFoliageExclusionMask: TREKKER_WATER_FOLIAGE_EXCLUSION_MASK,
|
|
maxTrianglesPerGlb: MAX_TRIANGLES_PER_GLB,
|
|
};
|
|
const all = new VariantTileCollection({ variant: "all-serialized-native", outputDirectory: allDirectory, rootExtras });
|
|
const pathEligible = new VariantTileCollection({ variant: "trekker-water-foliage-excluded", outputDirectory: pathDirectory, rootExtras });
|
|
const resources = inventory.resources;
|
|
const perResource = new Map();
|
|
const perPlacement = [];
|
|
const overallFlags = new Map();
|
|
let allTriangles = 0;
|
|
let completeNativeTriangles = 0;
|
|
let collisionOptionOmittedTriangles = 0;
|
|
let eligibleTriangles = 0;
|
|
let excludedTriangles = 0;
|
|
let fallbackTriangles = 0;
|
|
|
|
for (let placementOrdinal = 0; placementOrdinal < placementManifest.staticPlacements.length; placementOrdinal += 1) {
|
|
const placement = placementManifest.staticPlacements[placementOrdinal];
|
|
const key = normalizedResourceKey(placement.resource);
|
|
const native = resources.get(key);
|
|
if (!native) throw new Error(`No hash-validated native collision inventory for ${placement.resource}.`);
|
|
const report = native.report;
|
|
const triangleCount = Number(report.nativeCollisionTriangleCount);
|
|
const completeNativeCount = Number(report.completeNativeCollisionTriangleCount ?? triangleCount);
|
|
const collisionOptionOmittedCount = Number(report.nativeTrianglesOmittedByCollisionOptions ?? Math.max(0, completeNativeCount - triangleCount));
|
|
const fallbackCount = Number(report.renderMeshFallbackTriangleCount ?? 0);
|
|
const flagBuffer = await readFile(native.flagsFile);
|
|
const positions = await readObjTriangleSoup(native.objFile, triangleCount);
|
|
validateNativeCollisionCounts({ triangleCount, flagBytes: flagBuffer.byteLength, positionVertexCount: positions.length / 3 });
|
|
const matrix = placement.transform.primaryLocalThreeMatrix.map(Number);
|
|
if (matrix.some((value) => !Number.isFinite(value))) throw new Error(`Non-finite matrix in placement ${placement.guid ?? placement.id}.`);
|
|
let placementEligible = 0;
|
|
let placementExcluded = 0;
|
|
const placementFlags = new Map();
|
|
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) {
|
|
const flag = flagBuffer.readUInt32LE(triangleIndex * 4);
|
|
appendMapCount(placementFlags, flag);
|
|
appendMapCount(overallFlags, flag);
|
|
const offset = triangleIndex * 9;
|
|
const sourceTriangle = [
|
|
positions.subarray(offset, offset + 3),
|
|
positions.subarray(offset + 3, offset + 6),
|
|
positions.subarray(offset + 6, offset + 9),
|
|
];
|
|
const worldTriangle = transformNativeCollisionTriangle(sourceTriangle, matrix, meters);
|
|
await all.add(worldTriangle, flag, tileSizeMeters);
|
|
if (isTrekkerPathEligible(flag)) {
|
|
await pathEligible.add(worldTriangle, flag, tileSizeMeters);
|
|
placementEligible += 1;
|
|
} else {
|
|
placementExcluded += 1;
|
|
}
|
|
}
|
|
allTriangles += triangleCount;
|
|
completeNativeTriangles += completeNativeCount;
|
|
collisionOptionOmittedTriangles += collisionOptionOmittedCount;
|
|
eligibleTriangles += placementEligible;
|
|
excludedTriangles += placementExcluded;
|
|
fallbackTriangles += fallbackCount;
|
|
let totals = perResource.get(key);
|
|
if (!totals) {
|
|
totals = {
|
|
resource: placement.resource,
|
|
placementCount: 0,
|
|
nativeTriangles: 0,
|
|
completeNativeTriangles: 0,
|
|
collisionOptionOmittedTriangles: 0,
|
|
disabledCollisionOptionEntityCount: Number(report.disabledCollisionOptionEntityCount ?? 0),
|
|
entityNodesSkippedByCollisionOptionGates: Number(report.entityNodesSkippedByCollisionOptionGates ?? 0),
|
|
trekkerFilteredTriangles: 0,
|
|
engineVisualFallbackTriangles: 0,
|
|
sourceReport: native.row.report,
|
|
};
|
|
perResource.set(key, totals);
|
|
}
|
|
totals.placementCount += 1;
|
|
totals.nativeTriangles += triangleCount;
|
|
totals.completeNativeTriangles += completeNativeCount;
|
|
totals.collisionOptionOmittedTriangles += collisionOptionOmittedCount;
|
|
totals.trekkerFilteredTriangles += placementEligible;
|
|
totals.engineVisualFallbackTriangles += fallbackCount;
|
|
perPlacement.push({
|
|
ordinal: placementOrdinal,
|
|
id: placement.id,
|
|
guid: placement.guid,
|
|
resource: placement.resource,
|
|
sourceMatrix: "manifest.staticPlacements[].transform.primaryLocalThreeMatrix (column-major)",
|
|
nativeSerializedTriangles: triangleCount,
|
|
completeNativeSerializedTriangles: completeNativeCount,
|
|
collisionOptionOmittedTriangles: collisionOptionOmittedCount,
|
|
trekkerEligibleTriangles: placementEligible,
|
|
maskExcludedTriangles: placementExcluded,
|
|
engineVisualFallbackTriangles: fallbackCount,
|
|
triangleFlagCounts: Object.fromEntries([...placementFlags.entries()].sort((a, b) => a[0] - b[0]).map(([flag, count]) => [String(flag), count])),
|
|
renderableSubmeshesWithoutSerializedCollision: renderMeshOmissions(report),
|
|
});
|
|
}
|
|
|
|
const allFiles = await all.finish();
|
|
const eligibleFiles = await pathEligible.finish();
|
|
const verifyFileCaps = (files, variant) => {
|
|
for (const file of files) {
|
|
if (file.triangles > MAX_TRIANGLES_PER_GLB) throw new Error(`${variant} output exceeds the ${MAX_TRIANGLES_PER_GLB}-triangle GLB limit.`);
|
|
}
|
|
};
|
|
verifyFileCaps(allFiles, "All-source");
|
|
verifyFileCaps(eligibleFiles, "Trekker-filtered");
|
|
const sumFiles = (files) => files.reduce((sum, row) => sum + row.triangles, 0);
|
|
if (sumFiles(allFiles) !== allTriangles || sumFiles(eligibleFiles) !== eligibleTriangles) {
|
|
throw new Error("Written collision chunk triangle totals do not match source inventory assembly totals.");
|
|
}
|
|
const relativeFiles = (files) => files.map((row) => ({
|
|
...row,
|
|
file: path.relative(projectRoot, row.file).split(path.sep).join("/"),
|
|
}));
|
|
const report = {
|
|
schemaVersion: 1,
|
|
purpose: "Repeatable static WDB source collision extraction and placement assembly; diagnostic assets only until reviewed and integrated.",
|
|
source: {
|
|
recipe: path.relative(projectRoot, path.resolve(projectRoot, recipe.file)).split(path.sep).join("/"),
|
|
recipeSha256,
|
|
placementManifest: placementManifest.file,
|
|
placementManifestSha256,
|
|
sourceWdb: recipe.source.wdb,
|
|
sourceWdbSha256,
|
|
nativeCollisionInventory: path.relative(projectRoot, inventory.manifestPath).split(path.sep).join("/"),
|
|
nativeCollisionInventorySha256: inventory.manifestSha256,
|
|
nativeExtractor: inventory.inventory.nativeExtractor ?? null,
|
|
sourceRosHashPolicy: "Each source ROS SHA-256 is captured before and after native extraction and revalidated against the inventory when assembled.",
|
|
},
|
|
nativeExtractorPolicy: {
|
|
query: collisionMode === "respect-options"
|
|
? "CRuEntity::QueryCollision with ignoreCollisionFlag=false; serialized collision traversal skips any node and subtree where ruENTITYOPTION_COLLISION is false"
|
|
: "Complete serialized collision-option audit with ignoreCollisionFlag=true; disabled entities are included",
|
|
collisionMode,
|
|
renderMeshFallbackUsed: false,
|
|
renderMeshFallbackTrianglesReportedButNotAssembled: fallbackTriangles,
|
|
absentSerializedCollision: "reported and omitted; there is no visual-triangle fallback",
|
|
},
|
|
placement: {
|
|
dungeonId: recipe.slug,
|
|
uniqueResourceCount: inventory.resources.size,
|
|
staticPlacementCount: placementManifest.staticPlacements.length,
|
|
dynamicPlacementCountExcludedFromThisAssembly: placementManifest.dynamicPlacements?.length ?? 0,
|
|
sourceBridge: `(x,y,z) -> (${meters}*x,${meters}*y,-${meters}*z), then primaryLocalThreeMatrix`,
|
|
matrixStorage: "column-major affine matrix from exact WDB placement manifest",
|
|
winding: "source-to-Three Z reflection reverses winding; swap vertices 1 and 2 exactly once after full point transform",
|
|
metersPerSourceUnit: meters,
|
|
},
|
|
trekkerPathPolicy: {
|
|
excludedFlagMask: TREKKER_WATER_FOLIAGE_EXCLUSION_MASK,
|
|
excludedBits: { water: 0x00200000, foliage: 0x00800000 },
|
|
sourceEvidence: "Rune Engine RuFusion_Trekker.cpp::IsPathClear and ::FindRestingHeight use m_exclusionMask=0x00A00000; RuWorld_Base.h defines WATER=0x00200000 and FOLIAGE=0x00800000.",
|
|
flagSidecar: "Little-endian uint32 per serialized source triangle in the native query output order.",
|
|
},
|
|
limits: {
|
|
maxTrianglesPerGlb: MAX_TRIANGLES_PER_GLB,
|
|
spatialTileSizeMeters: tileSizeMeters,
|
|
outputs: "Collision GLBs are non-indexed position-only triangle soups. Primitive extras preserve the exact source triangle flags in triangle order.",
|
|
noFallback: "Resources/submeshes with no serialized collision are listed in per-placement omissions and omitted from output GLBs.",
|
|
},
|
|
totals: {
|
|
nativeSerializedTriangles: allTriangles,
|
|
completeNativeSerializedTriangles: completeNativeTriangles,
|
|
collisionOptionOmittedTriangles,
|
|
trekkerEligibleTriangles: eligibleTriangles,
|
|
excludedByWaterFoliageMask: excludedTriangles,
|
|
engineVisualFallbackTrianglesReported: fallbackTriangles,
|
|
allSourceChunkCount: allFiles.length,
|
|
trekkerFilteredChunkCount: eligibleFiles.length,
|
|
sourceTriangleFlagCounts: Object.fromEntries([...overallFlags.entries()].sort((a, b) => a[0] - b[0]).map(([flag, count]) => [String(flag), count])),
|
|
},
|
|
collisionAssets: {
|
|
allSerializedSource: relativeFiles(allFiles),
|
|
trekkerFiltered: relativeFiles(eligibleFiles),
|
|
},
|
|
perResource: [...perResource.values()].sort((a, b) => a.resource.localeCompare(b.resource)),
|
|
perPlacement,
|
|
};
|
|
const reportPath = path.join(runRoot, "native-collision-assembly-report.json");
|
|
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
return { reportPath, report, runRoot };
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.help) {
|
|
console.log(USAGE);
|
|
return;
|
|
}
|
|
const recipeFile = resolveProjectPath(options.recipe);
|
|
const recipe = await readJson(recipeFile);
|
|
recipe.file = recipeFile;
|
|
const placementManifestFile = resolveProjectPath(options["placement-manifest"]);
|
|
const placementManifestBytes = await readFile(placementManifestFile);
|
|
const placementManifest = JSON.parse(placementManifestBytes.toString("utf8"));
|
|
placementManifest.file = path.relative(projectRoot, placementManifestFile).split(path.sep).join("/");
|
|
const { resources } = validatePlacementInputs(recipe, placementManifest);
|
|
const recipeSha256 = await sha256File(recipeFile);
|
|
const placementManifestSha256 = createHash("sha256").update(placementManifestBytes).digest("hex");
|
|
const sourceRoot = options["source-root"]
|
|
? resolveProjectPath(options["source-root"])
|
|
: sourceRootFor(recipe);
|
|
const resourceRoot = path.resolve(sourceRoot, recipe.source.resourceRoot);
|
|
const sourceWdbFile = path.resolve(sourceRoot, recipe.source.wdb);
|
|
const sourceWdbSha256 = await sha256File(sourceWdbFile);
|
|
const outputRoot = resolveWorkspaceOutput(options["output-root"]);
|
|
const tileSizeMeters = Number(options["tile-size-meters"] ?? DEFAULT_TILE_SIZE_METERS);
|
|
if (!Number.isFinite(tileSizeMeters) || tileSizeMeters <= 0) throw new Error("--tile-size-meters must be a finite positive number.");
|
|
const extractor = options.extractor
|
|
? resolveProjectPath(options.extractor)
|
|
: path.join(path.dirname(fileURLToPath(import.meta.url)), "native", "bin", "native-collision-audit.exe");
|
|
const extractorSource = path.join(path.dirname(fileURLToPath(import.meta.url)), "native", "native-collision-audit.cpp");
|
|
if (!await import("node:fs/promises").then(({ access }) => access(extractor).then(() => true).catch(() => false))) {
|
|
throw new Error(`Native collision extractor not found at ${extractor}; build it with scripts/runewaker-pipeline/build-native-collision-audit.ps1 or pass --extractor.`);
|
|
}
|
|
const extractorPath = path.relative(projectRoot, extractor).split(path.sep).join("/");
|
|
const extractorSourcePath = path.relative(projectRoot, extractorSource).split(path.sep).join("/");
|
|
const nativeExtractor = {
|
|
executable: path.isAbsolute(extractorPath) || extractorPath.startsWith("..") ? extractor : extractorPath,
|
|
executableSha256: await sha256File(extractor),
|
|
source: extractorSourcePath,
|
|
sourceSha256: await sha256File(extractorSource),
|
|
collisionMode: options["collision-mode"],
|
|
};
|
|
|
|
let inventoryRoot;
|
|
let inventory;
|
|
if (options["inventory-root"]) {
|
|
inventoryRoot = resolveProjectPath(options["inventory-root"]);
|
|
} else {
|
|
// Immutable inventory roots keep separate source revisions from overwriting one another.
|
|
const currentSourceHashes = [];
|
|
for (const row of [...resources].sort((a, b) => normalizedResourceKey(a.resource).localeCompare(normalizedResourceKey(b.resource)))) {
|
|
currentSourceHashes.push(`${normalizedResourceKey(row.resource)}:${await sha256File(nativeResourceFile(resourceRoot, row.resource))}`);
|
|
}
|
|
const inventoryKey = createHash("sha256")
|
|
.update(`${placementManifestSha256}\n${sourceWdbSha256}\n${currentSourceHashes.join("\n")}\n${nativeExtractor.executableSha256}\n${nativeExtractor.sourceSha256}\n${options["collision-mode"]}`)
|
|
.digest("hex").slice(0, 24);
|
|
inventoryRoot = path.join(outputRoot, "inventories", inventoryKey);
|
|
await mkdir(path.dirname(inventoryRoot), { recursive: true });
|
|
const existing = await readdir(inventoryRoot).catch(() => []);
|
|
if (existing.includes("native-collision-inventory.json")) {
|
|
inventory = await readAndValidateNativeCollisionInventory({
|
|
inventoryRoot,
|
|
placementManifestSha256,
|
|
sourceWdbSha256,
|
|
collisionMode: options["collision-mode"],
|
|
nativeExtractor,
|
|
expectedResources: resources.map((row) => row.resource),
|
|
sourceResourceRoot: resourceRoot,
|
|
});
|
|
console.log(`reusing SHA-validated collision inventory: ${inventoryRoot}`);
|
|
}
|
|
}
|
|
|
|
if (!inventory) {
|
|
if (options["inventory-root"]) {
|
|
inventory = await readAndValidateNativeCollisionInventory({
|
|
inventoryRoot,
|
|
placementManifestSha256,
|
|
sourceWdbSha256,
|
|
collisionMode: options["collision-mode"],
|
|
nativeExtractor,
|
|
expectedResources: resources.map((row) => row.resource),
|
|
sourceResourceRoot: resourceRoot,
|
|
});
|
|
} else {
|
|
await mkdir(inventoryRoot, { recursive: true });
|
|
const existing = await readdir(inventoryRoot);
|
|
if (existing.length > 0) throw new Error(`Inventory root is non-empty but has no valid manifest: ${inventoryRoot}`);
|
|
await extractInventory({
|
|
inventoryRoot, extractor, nativeExtractor, collisionMode: options["collision-mode"],
|
|
resourceRoot, expectedResources: resources, placementManifestSha256, sourceWdbSha256,
|
|
});
|
|
inventory = await readAndValidateNativeCollisionInventory({
|
|
inventoryRoot,
|
|
placementManifestSha256,
|
|
sourceWdbSha256,
|
|
collisionMode: options["collision-mode"],
|
|
nativeExtractor,
|
|
expectedResources: resources.map((row) => row.resource),
|
|
sourceResourceRoot: resourceRoot,
|
|
});
|
|
}
|
|
}
|
|
|
|
const result = await assemble({
|
|
recipe, recipeSha256, placementManifest, placementManifestSha256, sourceWdbSha256,
|
|
inventory, outputRoot, tileSizeMeters, collisionMode: options["collision-mode"],
|
|
});
|
|
if (options["report-output"]) {
|
|
const reportOutput = resolveWorkspaceOutput(options["report-output"]);
|
|
await mkdir(path.dirname(reportOutput), { recursive: true });
|
|
await writeFile(reportOutput, `${JSON.stringify(result.report, null, 2)}\n`, "utf8");
|
|
}
|
|
console.log(JSON.stringify({
|
|
report: path.relative(projectRoot, result.reportPath).split(path.sep).join("/"),
|
|
inventory: path.relative(projectRoot, inventory.manifestPath).split(path.sep).join("/"),
|
|
nativeResources: inventory.resources.size,
|
|
staticPlacements: placementManifest.staticPlacements.length,
|
|
collisionMode: options["collision-mode"],
|
|
nativeTriangles: result.report.totals.nativeSerializedTriangles,
|
|
completeNativeTriangles: result.report.totals.completeNativeSerializedTriangles,
|
|
disabledSubtreeNativeTriangles: result.report.totals.collisionOptionOmittedTriangles,
|
|
trekkerEligibleTriangles: result.report.totals.trekkerEligibleTriangles,
|
|
waterFoliageExcludedTriangles: result.report.totals.excludedByWaterFoliageMask,
|
|
chunkCounts: {
|
|
all: result.report.totals.allSourceChunkCount,
|
|
trekkerFiltered: result.report.totals.trekkerFilteredChunkCount,
|
|
maximumTrianglesPerChunk: MAX_TRIANGLES_PER_GLB,
|
|
},
|
|
runRoot: path.relative(projectRoot, result.runRoot).split(path.sep).join("/"),
|
|
}, null, 2));
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error.stack ?? error.message);
|
|
process.exitCode = 1;
|
|
});
|