import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Document, NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { Matrix4, Vector3 } from "three"; import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs"; import { sourceAnchorSupportGeometry } from "./lib/source-anchor-support.mjs"; import { sourceTerrainToThreeMatrix } from "./sand-vortex/terrain-geometry.mjs"; import { buildWdbPlacementPlan } from "./lib/environment-assembly.mjs"; import { buildNativeCollisionAuditArguments, validateNativeCoreCandidateProvenance, } from "./lib/native-core-candidate-provenance.mjs"; import { sourceRootFor, validateEnvironmentRecipe } from "./lib/recipe.mjs"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const workRoot = path.join(projectRoot, "runewaker-export-work"); const argv = process.argv.slice(2); const slug = argv.find((argument) => !argument.startsWith("--")) ?? ""; const stageOnly = argv.includes("--stage-only"); const respectOptions = argv.includes("--respect-options"); const outputRootArgument = argv.find((argument) => argument.startsWith("--output-root="))?.slice("--output-root=".length); const outputRoot = outputRootArgument ? path.resolve(projectRoot, outputRootArgument) : path.join(workRoot, slug, "native-core-candidate-v1"); const nativeReportArgument = argv.find((argument) => argument.startsWith("--native-report="))?.slice("--native-report=".length); const supportName = "runewaker_source_anchor_support"; const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); const relative = (file) => path.relative(projectRoot, file).replaceAll("\\", "/"); if (!["aeternal-circle", "demon-stronghold", "mystic-altar"].includes(slug)) { throw new Error(`Unsupported native-core candidate target ${slug}.`); } if (!respectOptions) { throw new Error("Native-core candidates require --respect-options so collision-disabled ROS subtrees cannot enter the bake."); } const outputRelativeToSlugWork = path.relative(path.join(workRoot, slug), outputRoot); if (!outputRelativeToSlugWork || outputRelativeToSlugWork === ".." || outputRelativeToSlugWork.startsWith(`..${path.sep}`) || path.isAbsolute(outputRelativeToSlugWork)) { throw new Error(`--output-root must be a new directory under runewaker-export-work/${slug}.`); } function emptyBounds() { return { minimum: [Infinity, Infinity, Infinity], maximum: [-Infinity, -Infinity, -Infinity] }; } function include(bounds, point) { for (let axis = 0; axis < 3; axis += 1) { bounds.minimum[axis] = Math.min(bounds.minimum[axis], point[axis]); bounds.maximum[axis] = Math.max(bounds.maximum[axis], point[axis]); } } function measureTriangleList(positions) { if (positions.length % 9 !== 0) throw new Error("Triangle-list POSITION data is not divisible into triangles."); const bounds = emptyBounds(); for (let offset = 0; offset < positions.length; offset += 3) { const point = [positions[offset], positions[offset + 1], positions[offset + 2]]; if (!point.every(Number.isFinite)) throw new Error("Triangle-list POSITION contains a non-finite coordinate."); include(bounds, point); } return bounds; } function directBridgeBounds(nativePositions, meters) { const bounds = emptyBounds(); for (let offset = 0; offset < nativePositions.length; offset += 3) { include(bounds, [nativePositions[offset] * meters, nativePositions[offset + 1] * meters, -nativePositions[offset + 2] * meters]); } return bounds; } function upwardFloorHitsAtXZ(positions, point) { const hits = []; for (let offset = 0; offset < positions.length; offset += 9) { const ax = positions[offset]; const ay = positions[offset + 1]; const az = positions[offset + 2]; const bx = positions[offset + 3]; const by = positions[offset + 4]; const bz = positions[offset + 5]; const cx = positions[offset + 6]; const cy = positions[offset + 7]; const cz = positions[offset + 8]; const denominator = (bz - cz) * (ax - cx) + (cx - bx) * (az - cz); if (Math.abs(denominator) < 1e-12) continue; const weightA = ((bz - cz) * (point[0] - cx) + (cx - bx) * (point[2] - cz)) / denominator; const weightB = ((cz - az) * (point[0] - cx) + (ax - cx) * (point[2] - cz)) / denominator; const weightC = 1 - weightA - weightB; if (Math.min(weightA, weightB, weightC) < -1e-6) continue; const normalY = (bz - az) * (cx - ax) - (bx - ax) * (cz - az); if (normalY <= 1e-9) continue; hits.push({ y: weightA * ay + weightB * by + weightC * cy, normalY: normalY / Math.hypot( (by - ay) * (cz - az) - (bz - az) * (cy - ay), normalY, (bx - ax) * (cy - ay) - (by - ay) * (cx - ax), ), triangle: offset / 9, }); } return hits.sort((left, right) => right.y - left.y || left.triangle - right.triangle); } function boundsDifference(left, right) { let maximum = 0; for (let axis = 0; axis < 3; axis += 1) { maximum = Math.max(maximum, Math.abs(left.minimum[axis] - right.minimum[axis]), Math.abs(left.maximum[axis] - right.maximum[axis])); } return maximum; } function triangleListFromIndexedGeometry(geometry) { const positions = []; for (const index of geometry.indices) { const offset = index * 3; positions.push(geometry.positions[offset], geometry.positions[offset + 1], geometry.positions[offset + 2]); } return positions; } function runNativeCollisionExtractor(executable, args) { return new Promise((resolve, reject) => { const child = spawn(executable, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; }); child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; }); child.once("error", reject); child.once("close", (code) => { if (code !== 0) reject(new Error(`Native collision extraction failed (${code}): ${stderr || stdout}`)); else resolve({ stdout, stderr }); }); }); } function assertStrictNativeQueryReport(report, slug, modelResource) { if (String(report?.sourceModel ?? "").replaceAll("\\", "/").toLowerCase() !== modelResource.toLowerCase() || report.collisionMode !== "respect-options" || report.queryMasks?.ignoreEntityCollisionOption !== false || report.renderMeshFallbackTriangleCount !== 0 || !Number.isInteger(report.nativeCollisionTriangleCount) || report.nativeCollisionTriangleCount <= 0 || report.nativeCollisionTriangleCount !== report.enabledNativeCollisionTriangleCount) { throw new Error(`${slug}: native extractor output is not a complete strict respect-options collision query.`); } } function assertNativeQueryOutputPaths(report, reportPath, objPath, flagsPath) { if (path.resolve(String(report?.geometryOutput ?? "")) !== path.resolve(objPath) || path.resolve(String(report?.triangleFlagOutput ?? "")) !== path.resolve(flagsPath)) { throw new Error(`Native collision report outputs do not match its cache paths: ${reportPath}`); } } async function obtainNativeCollisionReport({ recipe, recipeBytes, sourceModelSha256, sourceWdbSha256 }) { const sourceRoot = sourceRootFor(recipe); const resourceRoot = path.resolve(sourceRoot, recipe.source.resourceRoot); const executable = path.join(projectRoot, "scripts/runewaker-pipeline/native/bin/native-collision-audit.exe"); const extractorSource = path.join(projectRoot, "scripts/runewaker-pipeline/native/native-collision-audit.cpp"); const [executableBytes, extractorSourceBytes] = await Promise.all([readFile(executable), readFile(extractorSource)]); const provenanceInputs = { schemaVersion: 1, slug: recipe.slug, sourceModel: recipe.source.model.replaceAll("\\", "/"), sourceModelSha256, sourceWdb: recipe.source.wdb.replaceAll("\\", "/"), sourceWdbSha256, recipeSha256: sha256(recipeBytes), extractorSha256: sha256(executableBytes), extractorSourceSha256: sha256(extractorSourceBytes), extractorPathArgumentFormat: "forward-slashes-v2", collisionMode: "respect-options", }; const cacheKey = sha256(Buffer.from(JSON.stringify(provenanceInputs))); const cacheRoot = path.join(workRoot, "resource-cache/native-core-collision", recipe.slug, cacheKey); const reportPath = nativeReportArgument ? path.resolve(projectRoot, nativeReportArgument) : path.join(cacheRoot, "native-collision-report.json"); const manifestPath = `${reportPath}.provenance.json`; const objPath = `${reportPath}.obj`; const flagsPath = `${reportPath}.flags.bin`; const validateCached = async () => { const manifest = JSON.parse(await readFile(manifestPath, "utf8")); if (JSON.stringify(manifest.inputs) !== JSON.stringify(provenanceInputs)) { throw new Error(`${recipe.slug}: native collision cache manifest is not bound to current ROS/WDB/recipe/tool hashes.`); } const reportBytes = await readFile(reportPath); const objBytes = await readFile(objPath); const flagsBytes = await readFile(flagsPath); if (sha256(reportBytes) !== manifest.outputs?.reportSha256 || sha256(objBytes) !== manifest.outputs?.geometrySha256 || sha256(flagsBytes) !== manifest.outputs?.flagsSha256) { throw new Error(`${recipe.slug}: native collision cache output hash validation failed.`); } const report = JSON.parse(reportBytes.toString("utf8")); assertStrictNativeQueryReport(report, recipe.slug, provenanceInputs.sourceModel); assertNativeQueryOutputPaths(report, reportPath, objPath, flagsPath); return { report, reportPath, objPath, flagsPath, reportSha256: manifest.outputs.reportSha256, geometrySha256: manifest.outputs.geometrySha256, flagsSha256: manifest.outputs.flagsSha256, manifestPath, manifestSha256: sha256(await readFile(manifestPath)), cacheKey, inputs: provenanceInputs, reused: true, }; }; try { return await validateCached(); } catch (error) { if (error?.code !== "ENOENT") throw error; } if (nativeReportArgument) { throw new Error(`${recipe.slug}: --native-report reuse requires a SHA-validated .provenance.json sidecar.`); } let existingEntries = []; try { existingEntries = await import("node:fs/promises").then(({ readdir }) => readdir(cacheRoot)); } catch (error) { if (error?.code !== "ENOENT") throw error; } if (existingEntries.length) { throw new Error(`${recipe.slug}: refusing to overwrite an incomplete native collision cache at ${cacheRoot}.`); } await mkdir(cacheRoot, { recursive: true }); await runNativeCollisionExtractor(executable, buildNativeCollisionAuditArguments({ resourceRoot, sourceModel: recipe.source.model, reportPath, })); const reportBytes = await readFile(reportPath); const objBytes = await readFile(objPath); const flagsBytes = await readFile(flagsPath); const report = JSON.parse(reportBytes.toString("utf8")); assertStrictNativeQueryReport(report, recipe.slug, provenanceInputs.sourceModel); assertNativeQueryOutputPaths(report, reportPath, objPath, flagsPath); const outputs = { reportSha256: sha256(reportBytes), geometrySha256: sha256(objBytes), flagsSha256: sha256(flagsBytes), reportByteLength: reportBytes.byteLength, geometryByteLength: objBytes.byteLength, flagsByteLength: flagsBytes.byteLength, }; const manifest = { schemaVersion: 1, inputs: provenanceInputs, outputs }; await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); return { report, reportPath, objPath, flagsPath, ...outputs, manifestPath, manifestSha256: sha256(await readFile(manifestPath)), cacheKey, inputs: provenanceInputs, reused: false, }; } function createTriangleMesh(document, scene, name, positions, extras) { if (positions.length % 9 !== 0) throw new Error(`${name}: positions are not triangle-list data.`); const buffer = document.getRoot().listBuffers()[0] ?? document.createBuffer("candidate-geometry-buffer"); const accessor = document.createAccessor(`${name}-position`) .setType("VEC3") .setArray(new Float32Array(positions)) .setBuffer(buffer); const primitive = document.createPrimitive().setAttribute("POSITION", accessor); const mesh = document.createMesh(name).addPrimitive(primitive); const node = document.createNode(name).setMesh(mesh).setExtras(extras); scene.addChild(node); return node; } async function writeScene(file, components) { const document = new Document(); const scene = document.createScene("native-core-support-candidate"); document.getRoot().setDefaultScene(scene); document.createBuffer("candidate-geometry-buffer"); for (const component of components) { createTriangleMesh(document, scene, component.name, component.positions, component.extras); } await io.write(file, document); } function parseNativeCollisionObj(text) { const positions = []; for (const line of text.split(/\r?\n/)) { if (!line.startsWith("v ")) continue; const values = line.trim().split(/\s+/).slice(1, 4).map(Number); if (values.length !== 3 || !values.every(Number.isFinite)) throw new Error("Native collision OBJ contains an invalid vertex."); positions.push(...values); } if (positions.length % 9 !== 0) throw new Error("Native collision OBJ vertex count is not triangle-aligned."); return positions; } function bridgeAndFilterNativeCollision(sourcePositions, flagsBuffer, exclusionMask, meters) { const sourceTriangleCount = sourcePositions.length / 9; if (flagsBuffer.byteLength !== sourceTriangleCount * 4) { throw new Error(`Native collision flags contain ${flagsBuffer.byteLength / 4} entries for ${sourceTriangleCount} source triangles.`); } const flags = new DataView(flagsBuffer.buffer, flagsBuffer.byteOffset, flagsBuffer.byteLength); const acceptedPositions = []; let excludedTriangleCount = 0; let nonzeroSourceFlagCount = 0; for (let triangle = 0; triangle < sourceTriangleCount; triangle += 1) { const flag = flags.getUint32(triangle * 4, true); if (flag !== 0) nonzeroSourceFlagCount += 1; if ((flag & exclusionMask) !== 0) { excludedTriangleCount += 1; continue; } const base = triangle * 9; // The reflected-Z bridge reverses handedness; swap B/C exactly once. for (const sourceVertex of [0, 2, 1]) { const offset = base + sourceVertex * 3; acceptedPositions.push( sourcePositions[offset] * meters, sourcePositions[offset + 1] * meters, -sourcePositions[offset + 2] * meters, ); } } return { positions: acceptedPositions, sourceTriangleCount, excludedTriangleCount, nonzeroSourceFlagCount }; } async function appendRemoteSupportNavigation(navFile, remoteNavPositions, remotePointIndices, insetM) { if (!remotePointIndices.length) return { triangleCount: 0, vertexCount: 0, mergedIntoPrimaryMesh: false }; const document = await io.read(navFile); if (remoteNavPositions.length % 9 !== 0) throw new Error("Remote support navigation positions are not triangle aligned."); const node = document.getRoot().listNodes().find((candidate) => candidate.getName() === "navigation" && candidate.getMesh()); const primitive = node?.getMesh()?.listPrimitives()[0]; const position = primitive?.getAttribute("POSITION"); if (!node || !primitive || !position) throw new Error("Recast output has no primary navigation mesh to receive support islands."); if (document.getRoot().getExtras()?.runewakerNavigationTopology) { throw new Error("Cannot merge disconnected support islands into source-pinned Detour detail geometry."); } const previousPositions = position.getArray(); const baseVertexCount = position.getCount(); const oldIndex = primitive.getIndices()?.getArray(); const oldIndexCount = oldIndex?.length ?? baseVertexCount; if (oldIndexCount % 3 !== 0) throw new Error("Recast navigation index data is not triangle aligned."); const mergedPositions = new Float32Array(previousPositions.length + remoteNavPositions.length); mergedPositions.set(previousPositions); mergedPositions.set(remoteNavPositions, previousPositions.length); const mergedIndices = new Uint32Array(oldIndexCount + remoteNavPositions.length / 3); if (oldIndex) mergedIndices.set(oldIndex); else for (let index = 0; index < baseVertexCount; index += 1) mergedIndices[index] = index; for (let index = 0; index < remoteNavPositions.length / 3; index += 1) { mergedIndices[oldIndexCount + index] = baseVertexCount + index; } const buffer = document.getRoot().listBuffers()[0] ?? document.createBuffer("navigation-support-buffer"); const mergedPosition = document.createAccessor("navigation-position-with-isolated-supports") .setType("VEC3") .setArray(mergedPositions) .setBuffer(buffer); const mergedIndex = document.createAccessor("navigation-indices-with-isolated-supports") .setType("SCALAR") .setArray(mergedIndices) .setBuffer(buffer); primitive.setAttribute("POSITION", mergedPosition).setIndices(mergedIndex); node.setExtras({ ...node.getExtras(), navigationComponentPolicy: "all-components", isolatedDerivedSupportNavigation: { source: supportName, sourcePointIndices: remotePointIndices, triangleCount: remoteNavPositions.length / 9, clearanceInsetM: insetM, inferredConnectivity: false, }, }); await io.write(navFile, document); const roundTrip = await io.read(navFile); const retained = roundTrip.getRoot().listNodes().find((candidate) => candidate.getName() === node.getName()); const retainedPrimitive = retained?.getMesh()?.listPrimitives()[0]; const retainedPosition = retainedPrimitive?.getAttribute("POSITION"); const retainedIndex = retainedPrimitive?.getIndices(); if (!retainedPosition || !retainedIndex || retainedIndex.getCount() / 3 !== (oldIndexCount / 3) + remoteNavPositions.length / 9 || retainedPosition.getCount() !== baseVertexCount + remoteNavPositions.length / 3) { throw new Error("Merged remote support nav triangles failed GLB round-trip validation."); } return { meshName: node.getName(), triangleCount: remoteNavPositions.length / 9, vertexCount: remoteNavPositions.length / 3, remotePadCount: remotePointIndices.length, sourcePointIndices: remotePointIndices, clearanceInsetM: insetM, mergedIntoPrimaryMesh: true, mergedFinalTriangleCount: retainedIndex.getCount() / 3, mergedFinalVertexCount: retainedPosition.getCount(), navigationComponentPolicy: "all-components", inferredConnectivity: false, }; } const recipeFile = path.join(projectRoot, "scripts/runewaker-pipeline/recipes", `${slug}.json`); const recipeBytes = await readFile(recipeFile); const recipe = validateEnvironmentRecipe(JSON.parse(recipeBytes.toString("utf8")), relative(recipeFile)); const recipeSha256 = sha256(recipeBytes); const importMetadataPath = path.join(projectRoot, "src/assets/game/dungeons", slug, "import-metadata.json"); const importMetadata = JSON.parse(await readFile(importMetadataPath, "utf8")); const sourceRoot = sourceRootFor(recipe); const resourceRoot = path.join(sourceRoot, recipe.source.resourceRoot); const sourceModelPath = path.join(resourceRoot, recipe.source.model); const sourceWdbPath = path.join(sourceRoot, recipe.source.wdb); const sourceModelSha256 = sha256(await readFile(sourceModelPath)); const sourceWdbSha256 = sha256(await readFile(sourceWdbPath)); const queryCache = await obtainNativeCollisionReport({ recipe, recipeBytes, sourceModelSha256, sourceWdbSha256 }); const sourceReportPath = queryCache.reportPath; const sourceReport = queryCache.report; const nativeObjPath = queryCache.objPath; const nativeFlagsPath = queryCache.flagsPath; const nativeObjText = await readFile(nativeObjPath, "utf8"); const nativeSourcePositions = parseNativeCollisionObj(nativeObjText); const meters = Number(recipe.coordinateSystem.metersPerSourceUnit); const nativeData = bridgeAndFilterNativeCollision( nativeSourcePositions, await readFile(nativeFlagsPath), Number(sourceReport.pathCollisionPolicy.exclusionMask), meters, ); if (nativeData.sourceTriangleCount !== sourceReport.nativeCollisionTriangleCount) { throw new Error(`${slug}: native query OBJ triangle count does not match its options-aware report.`); } const nativeBounds = measureTriangleList(nativeData.positions); const reportedBounds = directBridgeBounds(nativeSourcePositions, meters); const nativeBoundsProofErrorM = boundsDifference(nativeBounds, reportedBounds); if (nativeBoundsProofErrorM > 1e-4) throw new Error(`${slug}: bridge bounds do not agree with native source report (${nativeBoundsProofErrorM}m).`); const wdbReportPath = path.join(workRoot, slug, "raw/wdb-report.json"); const exportReportPath = path.join(workRoot, slug, "prepared/export-report.json"); const conversionReportPath = path.join(workRoot, slug, "converted/conversion-report.json"); const wdbReportBytes = await readFile(wdbReportPath); const exportReportBytes = await readFile(exportReportPath); const conversionReportBytes = await readFile(conversionReportPath); const wdbReport = JSON.parse(wdbReportBytes.toString("utf8")); const exportReport = JSON.parse(exportReportBytes.toString("utf8")); const collisionConversion = JSON.parse(conversionReportBytes.toString("utf8")); const placement = buildWdbPlacementPlan(wdbReport, recipe).primaryPlacement; const provenance = validateNativeCoreCandidateProvenance({ slug, recipe, recipeSha256, importMetadata, actualSourceModelSha256: sourceModelSha256, actualSourceWdbSha256: sourceWdbSha256, wdbReport, wdbReportSha256: sha256(wdbReportBytes), exportReport, exportReportSha256: sha256(exportReportBytes), conversionReportSha256: sha256(conversionReportBytes), nativeCollisionReport: sourceReport, nativeCollisionReportSha256: queryCache.reportSha256, nativeCollisionObjSha256: queryCache.geometrySha256, nativeCollisionFlagsSha256: queryCache.flagsSha256, primaryPlacement: placement, }); if (sourceReport.nativeCollisionTriangleCount !== sourceReport.enabledNativeCollisionTriangleCount) { throw new Error(`${slug}: respecting-options geometry count does not match enabled serialized native collision triangles.`); } const points = recipe.source.sourceAnchorSupport.points.map((point) => point.map(Number)); const supportPolicy = recipe.source.sourceAnchorSupport; const omittedPointIndices = new Set(supportPolicy.omitNativeGroundedPointIndices ?? []); const retained = points.map((point, sourcePointIndex) => ({ point, sourcePointIndex })).filter(({ sourcePointIndex }) => !omittedPointIndices.has(sourcePointIndex)); const halfExtent = Number(supportPolicy.halfExtentSourceUnits ?? 60); const thickness = Number(supportPolicy.thicknessSourceUnits ?? 3); const expectedSupportGeometry = sourceAnchorSupportGeometry(retained.map(({ point }) => point), placement, { halfExtent, thickness, metersPerSourceUnit: meters, }); const expectedSupportWorldPositionHash = sha256(Buffer.from(new Float32Array(expectedSupportGeometry.positions).buffer)); let proofOmissionProbeRows = []; if (omittedPointIndices.size) { const omissionEvidence = supportPolicy.nativeGroundOmissionEvidence; if (!omissionEvidence || omissionEvidence.primaryRosSha256 !== provenance.sourceModelSha256 || !omittedPointIndices.has(omissionEvidence.sourcePointIndex)) { throw new Error(`${slug}: omitted native-grounded anchors lack matching recipe evidence tied to the current ROS hash.`); } const omissionProbeRows = []; for (const pointIndex of omittedPointIndices) { const point = points[pointIndex]; const bridgedPoint = new Vector3(...point).applyMatrix4(sourceTerrainToThreeMatrix(placement, meters)).toArray(); const floorHits = upwardFloorHitsAtXZ(nativeData.positions, bridgedPoint); const nearestHit = floorHits.find((hit) => Math.abs(hit.y - bridgedPoint[1]) <= 0.5); if (!nearestHit) { throw new Error(`${slug}: omitted source support ${pointIndex} no longer has a matching native upward floor hit within 0.5m.`); } omissionProbeRows.push({ sourcePointIndex: pointIndex, sourcePoint: point, bridgedPoint, nearestUpwardNativeHit: nearestHit }); } proofOmissionProbeRows = omissionProbeRows; } const agentRadius = Number(recipe.navigation.agentRadius ?? 0.36); const insetSourceUnits = agentRadius / meters; const insetHalfExtent = halfExtent - insetSourceUnits; if (insetHalfExtent <= 0) throw new Error(`${slug}: remote navigation inset consumes the full support pad.`); const coreSupportTrianglePositions = []; const allSupportTrianglePositions = []; const remoteSupportNavPositions = []; const corePointIndices = []; const remotePointIndices = []; const supportRows = []; for (const { point, sourcePointIndex } of retained) { const box = sourceAnchorSupportGeometry([point], placement, { halfExtent, thickness, metersPerSourceUnit: meters }); const boxBounds = measureTriangleList(triangleListFromIndexedGeometry(box)); const overlapsNativeCoreXZ = boxBounds.maximum[0] >= nativeBounds.minimum[0] && boxBounds.minimum[0] <= nativeBounds.maximum[0] && boxBounds.maximum[2] >= nativeBounds.minimum[2] && boxBounds.minimum[2] <= nativeBounds.maximum[2]; const boxTrianglePositions = triangleListFromIndexedGeometry(box); allSupportTrianglePositions.push(...boxTrianglePositions); if (overlapsNativeCoreXZ) { corePointIndices.push(sourcePointIndex); coreSupportTrianglePositions.push(...boxTrianglePositions); } else { remotePointIndices.push(sourcePointIndex); const inset = sourceAnchorSupportGeometry([point], placement, { halfExtent: insetHalfExtent, thickness, metersPerSourceUnit: meters, }); remoteSupportNavPositions.push(...inset.navigationPositions); } supportRows.push({ sourcePointIndex, bounds: boxBounds, overlapsNativeCoreXZ }); } const coreBounds = emptyBounds(); for (let offset = 0; offset < nativeData.positions.length; offset += 3) { include(coreBounds, nativeData.positions.slice(offset, offset + 3)); } if (coreSupportTrianglePositions.length) { const extraBounds = measureTriangleList(coreSupportTrianglePositions); include(coreBounds, extraBounds.minimum); include(coreBounds, extraBounds.maximum); } const cellSize = Number(recipe.navigation.cellSize); const predictedGrid = { cellSize, widthCells: Math.ceil((coreBounds.maximum[0] - coreBounds.minimum[0]) / cellSize), depthCells: Math.ceil((coreBounds.maximum[2] - coreBounds.minimum[2]) / cellSize), }; predictedGrid.cellCount = predictedGrid.widthCells * predictedGrid.depthCells; await mkdir(outputRoot, { recursive: true }); const nativeCoreInput = path.join(outputRoot, `${slug}-native-core-input.glb`); const fullCollisionCandidate = path.join(outputRoot, `${slug}-native-collision-and-all-supports-candidate.glb`); const navFile = path.join(outputRoot, `${slug}-native-core-navigation.collision-candidate.glb`); const recastReportFile = path.join(outputRoot, "recast-report.json"); const proofFile = path.join(outputRoot, "candidate-proof.json"); let stagedProof = null; if (stageOnly) { for (const file of [nativeCoreInput, fullCollisionCandidate, proofFile]) { try { await access(file); throw new Error(`Refusing to overwrite staged candidate artifact ${file}.`); } catch (error) { if (error?.code !== "ENOENT") throw error; } } } else { try { stagedProof = JSON.parse(await readFile(proofFile, "utf8")); if (stagedProof.status !== "staged-inputs-no-nav-bake") throw new Error(`${slug}: existing candidate proof is already baked or invalid.`); for (const file of [nativeCoreInput, fullCollisionCandidate]) await access(file); const stagedSource = stagedProof.source ?? {}; for (const [field, expected] of Object.entries({ recipeSha256, sourceRosSha256: provenance.sourceModelSha256, sourceWdbSha256: provenance.sourceWdbSha256, wdbPlacementReportSha256: provenance.wdbReportSha256, nativeExportReportSha256: provenance.exportReportSha256, conversionReportSha256: provenance.conversionReportSha256, nativeCollisionReportSha256: queryCache.reportSha256, nativeCollisionObjSha256: queryCache.geometrySha256, nativeCollisionFlagsSha256: queryCache.flagsSha256, nativeCollisionCacheManifestSha256: queryCache.manifestSha256, })) { if (stagedSource[field] !== expected) throw new Error(`${slug}: staged source hash changed for ${field}.`); } if (stagedProof.nativeCoreInput.sha256 !== sha256(await readFile(nativeCoreInput)) || stagedProof.fullCollisionCandidate.sha256 !== sha256(await readFile(fullCollisionCandidate))) { throw new Error(`${slug}: staged candidate GLB hash disagrees with its proof.`); } } catch (error) { if (error?.code !== "ENOENT") throw error; stagedProof = null; for (const file of [nativeCoreInput, fullCollisionCandidate, proofFile]) { try { await access(file); throw new Error(`Refusing to overwrite incomplete candidate artifact ${file}.`); } catch (checkError) { if (checkError?.code !== "ENOENT") throw checkError; } } } for (const file of [navFile, recastReportFile]) { try { await access(file); throw new Error(`Refusing to overwrite candidate output ${file}.`); } catch (error) { if (error?.code !== "ENOENT") throw error; } } } const commonSourceExtras = { candidateOnly: true, exactNativeQuerySource: relative(sourceReportPath), exactNativeQuerySourceSha256: queryCache.reportSha256, exactNativeCollisionObjSha256: queryCache.geometrySha256, exactNativeCollisionFlagsSha256: queryCache.flagsSha256, sourceModelSha256: provenance.sourceModelSha256, recipeSha256, nativeQueryMode: "respect-options", nativeQueryCacheKey: queryCache.cacheKey, sourceCollisionTriangleCount: sourceReport.nativeCollisionTriangleCount, sourcePathExclusionMask: Number(sourceReport.pathCollisionPolicy.exclusionMask), excludedByPathFlagsTriangleCount: nativeData.excludedTriangleCount, reflectedZBridgeAndWindingSwapAppliedOnce: true, }; if (!stagedProof) { await writeScene(nativeCoreInput, [ { name: "native-query-collision-core", positions: nativeData.positions, extras: { ...commonSourceExtras, role: "native-core-collision" } }, { name: "core-overlapping-derived-support-boxes", positions: coreSupportTrianglePositions, extras: { role: "derived-collision-included-in-native-core-recast", source: supportName, sourcePointIndices: corePointIndices, reason: "XZ footprint overlaps native collision bounds; full boxes are included for Recast surface/obstacle/clearance handling.", } }, ]); await writeScene(fullCollisionCandidate, [ { name: "native-query-collision-core", positions: nativeData.positions, extras: { ...commonSourceExtras, role: "native-core-collision" } }, { name: "all-recipe-derived-support-boxes", positions: allSupportTrianglePositions, extras: { role: "derived-collision-candidate", source: supportName, sourcePointIndices: retained.map(({ sourcePointIndex }) => sourcePointIndex), sourceWorldPositionHash: expectedSupportWorldPositionHash, generatedDirectlyFromRecipeSourceCoordinates: true, canonicalGeneratedSupportWasNotReadOrTransformed: true, } }, ]); } const proof = { schemaVersion: 1, slug, status: "staged-inputs-no-nav-bake", source: { recipe: relative(recipeFile), recipeSha256, importMetadata: relative(importMetadataPath), importMetadataSha256: sha256(await readFile(importMetadataPath)), sourceRosResource: recipe.source.model, sourceRosSha256: provenance.sourceModelSha256, sourceWdbResource: recipe.source.wdb, sourceWdbSha256: provenance.sourceWdbSha256, wdbPlacementReport: relative(wdbReportPath), wdbPlacementReportSha256: provenance.wdbReportSha256, nativeExportReport: relative(exportReportPath), nativeExportReportSha256: provenance.exportReportSha256, conversionReport: relative(conversionReportPath), conversionReportSha256: provenance.conversionReportSha256, sourceModel: sourceReport.sourceModel, primaryPlacement: placement, nativeCollisionCacheKey: queryCache.cacheKey, nativeCollisionCacheInputs: queryCache.inputs, nativeCollisionCacheManifest: relative(queryCache.manifestPath), nativeCollisionCacheManifestSha256: queryCache.manifestSha256, nativeCollisionCacheReused: queryCache.reused, nativeCollisionReport: relative(sourceReportPath), nativeCollisionReportSha256: queryCache.reportSha256, nativeCollisionObj: relative(nativeObjPath), nativeCollisionObjSha256: queryCache.geometrySha256, nativeCollisionFlags: relative(nativeFlagsPath), nativeCollisionFlagsSha256: queryCache.flagsSha256, sourceSupportGeometry: "sourceAnchorSupportGeometry(recipe points, primary WDB placement, source-unit dimensions)", sourceSupportWasGeneratedDirectly: true, transformedCanonicalSupportWasNotConsumed: true, nativeGroundedOmissionProbeRows: proofOmissionProbeRows, collisionConversionReport: relative(conversionReportPath), collisionChunksRetainedAsComparisonOnly: collisionConversion.collision.map((row) => row.file), }, nativeCollision: { queryTriangles: sourceReport.nativeCollisionTriangleCount, completeSerializedTriangles: sourceReport.completeNativeCollisionTriangleCount ?? sourceReport.nativeCollisionTriangleCount, collisionOptionEnabledTriangles: sourceReport.enabledNativeCollisionTriangleCount ?? sourceReport.nativeCollisionTriangleCount, trianglesRemovedByCollisionOptionGates: sourceReport.disabledSubtreeNativeTriangles ?? 0, queryRespectsEntityCollisionOptions: sourceReport.queryMasks?.ignoreEntityCollisionOption === false, sourceNonzeroTriangleFlags: nativeData.nonzeroSourceFlagCount, exclusionMask: Number(sourceReport.pathCollisionPolicy.exclusionMask), excludedTriangleCount: nativeData.excludedTriangleCount, acceptedTriangleCount: nativeData.positions.length / 9, sourceDirectBridgeBounds: nativeBounds, boundsErrorAgainstReportM: nativeBoundsProofErrorM, convertedCollisionComparisonOnly: true, }, supportSelection: { originalCount: points.length, omittedNativeGroundedPointIndices: [...omittedPointIndices], retainedCount: retained.length, sourceWorldPositionHash: expectedSupportWorldPositionHash, generatedFromRawRecipeCoordinatesWithoutCanonicalSupportInput: true, coreOverlappingCount: corePointIndices.length, coreSourcePointIndices: corePointIndices, remoteSeparateCount: remotePointIndices.length, remoteSourcePointIndices: remotePointIndices, remoteNavClearanceInsetM: agentRadius, remoteNavTopInsetHalfExtentM: halfExtent * meters - agentRadius, inferredRemoteConnectivity: false, rows: supportRows, }, nativeCoreInput: { file: relative(nativeCoreInput), sha256: sha256(await readFile(nativeCoreInput)), triangleCount: (nativeData.positions.length + coreSupportTrianglePositions.length) / 9, nativeCorePlusXZOverlappingSupportBounds: coreBounds, predictedCoreGrid: predictedGrid, settings: recipe.navigation, }, fullCollisionCandidate: { file: relative(fullCollisionCandidate), sha256: sha256(await readFile(fullCollisionCandidate)), triangleCount: (nativeData.positions.length + allSupportTrianglePositions.length) / 9, sourceGeometryOnly: true, existingShippingUntouched: true, }, sourceGroundEntryAnchor: null, navCandidate: null, }; const entrySourcePoint = points[0]; proof.sourceGroundEntryAnchor = new Vector3(...entrySourcePoint) .applyMatrix4(sourceTerrainToThreeMatrix(placement, meters)) .toArray(); if (!stageOnly) await writeFile(proofFile, `${JSON.stringify(proof, null, 2)}\n`, "utf8"); if (!stageOnly) { console.log(`[native-core-nav-candidate] ${slug}: baking native core + ${corePointIndices.length} footprint-overlapping supports, ${predictedGrid.cellCount} predicted cells`); const baked = await bakeNavigationFromCollisionAssets({ slug, collisionAssets: [{ id: "native-core-and-overlapping-supports", role: "collision", path: relative(nativeCoreInput) }], settings: { ...recipe.navigation }, output: navFile, reportFile: recastReportFile, includeDetourTopology: remotePointIndices.length === 0, source: { kind: "native-core-plus-overlapping-recipe-support-candidate", candidateOnly: true, collisionQueryMode: "respect-options", nativeQueryReportSha256: commonSourceExtras.exactNativeQuerySourceSha256, nativeQueryObjSha256: commonSourceExtras.exactNativeCollisionObjSha256, nativeQueryFlagsSha256: commonSourceExtras.exactNativeCollisionFlagsSha256, recipeSha256, sourceModelSha256: provenance.sourceModelSha256, sourceWdbSha256: provenance.sourceWdbSha256, directRecipeSupportWorldPositionHash: expectedSupportWorldPositionHash, sourceTransformAppliedOnce: true, allRemoteSupportsRemainExcludedFromRecastInput: true, }, detourRouteProbes: [{ id: "original-entry-anchor-projection", start: proof.sourceGroundEntryAnchor, end: proof.sourceGroundEntryAnchor, maxStartSnapDistance: 0.5, maxEndSnapDistance: 0.5, }], }); const remoteNode = await appendRemoteSupportNavigation(navFile, remoteSupportNavPositions, remotePointIndices, agentRadius); const recastReport = JSON.parse(await readFile(recastReportFile, "utf8")); const probe = recastReport.detourRouteProbes?.find((row) => row.id === "original-entry-anchor-projection"); if (remoteNode.triangleCount > 0) { const recastGeometry = recastReport.geometry; const baseTriangleCount = recastGeometry.triangles; const baseVertexCount = recastGeometry.vertices; const minimum = recastGeometry.bounds[0].slice(); const maximum = recastGeometry.bounds[1].slice(); for (let offset = 0; offset < remoteSupportNavPositions.length; offset += 3) { for (let axis = 0; axis < 3; axis += 1) { minimum[axis] = Math.min(minimum[axis], remoteSupportNavPositions[offset + axis]); maximum[axis] = Math.max(maximum[axis], remoteSupportNavPositions[offset + axis]); } } recastReport.navigationComponentPolicy = "all-components"; recastReport.geometry = { ...recastGeometry, bounds: [minimum, maximum], sourceRecastTriangles: baseTriangleCount, sourceRecastVertices: baseVertexCount, triangles: baseTriangleCount + remoteNode.triangleCount, vertices: baseVertexCount + remoteNode.vertexCount, }; recastReport.isolatedSupportNavigation = { ...remoteNode, appendedWithoutLinks: true, componentPolicy: "all-components", }; } proof.navCandidate = { file: relative(navFile), sha256: sha256(await readFile(navFile)), recastReport: relative(recastReportFile), recastReportSha256: sha256(await readFile(recastReportFile)), buildMode: baked.build.mode, partition: baked.build.partition, tileCount: baked.build.tileCount, populatedTileCount: baked.build.populatedTileCount, inputTriangleCount: baked.input.triangleCount, bakedNavigationTriangleCount: baked.geometry.triangles, finalNavigationTriangleCount: remoteNode.mergedFinalTriangleCount ?? baked.geometry.triangles, navigationComponentPolicy: remoteNode.triangleCount > 0 ? "all-components" : null, entryProjection: probe?.start ?? null, entryWithinHalfMeter: probe?.start?.found === true && probe.start.distance <= 0.5, separateRemoteSupportNavigation: remoteNode, }; recastReport.artifactOnlyNativeCoreCandidate = proof.navCandidate; recastReport.outputSha256 = proof.navCandidate.sha256; await writeFile(recastReportFile, `${JSON.stringify(recastReport, null, 2)}\n`, "utf8"); proof.navCandidate.recastReportSha256 = sha256(await readFile(recastReportFile)); proof.status = "candidate-nav-baked"; } await writeFile(proofFile, `${JSON.stringify(proof, null, 2)}\n`, "utf8"); console.log(JSON.stringify({ status: proof.status, proof: relative(proofFile), nativeTriangles: nativeData.positions.length / 9, coreSupportCount: corePointIndices.length, remoteSupportCount: remotePointIndices.length, coreInputTriangles: proof.nativeCoreInput.triangleCount, coreGrid: predictedGrid, navCandidate: proof.navCandidate, }, null, 2));