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

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