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 --placement-manifest --output-root [options] Required: --recipe Dungeon recipe; supplies source root, WDB, and coordinate scale --placement-manifest Exact WDB placement matrices and resource list --output-root Output directory for the deterministic run and default inventory Options: --source-root Override recipe source root --inventory-root Reuse inventory with native-collision-inventory.json SHA index --extractor Native Rune Engine collision extractor executable --collision-mode complete (default) or respect-options (honor ruENTITYOPTION_COLLISION) --tile-size-meters Spatial tile size (default ${DEFAULT_TILE_SIZE_METERS}) --report-output 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; });