import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { BufferAttribute, BufferGeometry, Matrix4, Quaternion, Vector3 } from "three"; import { MeshBVH } from "three-mesh-bvh"; import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs"; import { loadEnvironmentRecipe, pipelineDirectory, projectPath, projectRoot, sourceRootFor, } from "./lib/recipe.mjs"; import { assembleInstancedGlb, buildWdbPlacementPlan, normalizeWdbResourcePath, transformSourceHeadingToPrimaryThree, } from "./lib/environment-assembly.mjs"; import { environmentOptimizationArguments } from "./lib/environment-optimization.mjs"; import { applyNativeMaterialSemantics } from "./lib/native-material-semantics.mjs"; import { applyRunewakerVisualUvAdaptationsToFile } from "./lib/rom-visual-uv-adaptations-file.mjs"; import { applyRunewakerDerivedSupportStylingToFile } from "./lib/rom-derived-support-styling-file.mjs"; import { listRunewakerDerivedSupportStylingPolicies } from "./lib/rom-derived-support-styling.mjs"; import { sourceAnchorSupportGeometry } from "./lib/source-anchor-support.mjs"; import { writeBoundedCollisionGlbs } from "./lib/bounded-collision-glb.mjs"; import { validateNativeAlphaResourceCache } from "./lib/native-alpha-cache-validation.mjs"; import { sourceNavigationAnchors } from "./lib/source-navigation-anchors.mjs"; const argv = process.argv.slice(2); const fallbackRecipe = path.join(pipelineDirectory, "recipes", "forsaken-abbey.json"); const { file: recipeFile, recipe } = await loadEnvironmentRecipe(argv, fallbackRecipe); const slug = recipe.slug; const workDirectory = path.join(projectRoot, "runewaker-export-work", slug); const rawDirectory = path.join(workDirectory, "raw"); const preparedDirectory = path.join(workDirectory, "prepared"); const convertedDirectory = path.join(workDirectory, "converted"); const resourceCacheDirectory = path.join(projectRoot, "runewaker-export-work", "resource-cache"); const shippingDirectory = path.join(projectRoot, "src", "assets", "game", "dungeons", slug); const sourceRoot = sourceRootFor(recipe); const resourceRoot = path.join(sourceRoot, recipe.source.resourceRoot); const nativeExporter = path.join(pipelineDirectory, "native", "bin", "runewaker_model_exporter.exe"); const blender = path.resolve(process.env.BLENDER_BIN ?? path.join( process.env.USERPROFILE ?? "", "blender-portable", "blender-5.0.1-windows-x64", "blender.exe", )); const texconv = path.resolve(process.env.TEXCONV_BIN ?? path.join( process.env.LOCALAPPDATA ?? "", "Microsoft", "WinGet", "Packages", "Microsoft.DirectXTex.Texconv_Microsoft.Winget.Source_8wekyb3d8bbwe", "texconv.exe", )); const blenderScript = path.join(pipelineDirectory, "blender", "convert-runewaker.py"); const gltfTransform = path.join(projectRoot, "node_modules", "@gltf-transform", "cli", "bin", "cli.js"); const ktx2Compressor = path.join(pipelineDirectory, "compress-environment-textures.mjs"); const khronosValidator = path.join(projectRoot, "scripts", "manastorm-assets", "validate-package-glbs.cjs"); const fullWdbAssembly = !recipe.source.proceduralTerrainFallback && recipe.source.wdbAssembly?.mode !== "primary-only" && !argv.includes("--primary-only"); const collisionChunkTriangleTarget = 90_000; if (recipe.source.nativeCollision && !fullWdbAssembly) { throw new Error("Serialized native collision requires full WDB assembly."); } let nativeTerrain = null; let nativeWater = null; let nativeCollision = null; let visualUvAdaptations = null; let derivedSupportStyling = null; async function buildNativeTerrain() { if (!recipe.source.nativeTerrain) return; if (recipe.source.nativeTerrain.kind !== "runewaker-wdb-terrain") { throw new Error("Unsupported native terrain kind: " + recipe.source.nativeTerrain.kind); } await run(process.execPath, [ path.join(pipelineDirectory, "sand-vortex", "extract-native-terrain.mjs"), "--recipe", recipeFile, "--wdb", path.join(sourceRoot, recipe.source.wdb), "--output", path.join(workDirectory, "native-terrain-source"), ], "recover native WDB terrain chunks and materials"); await run(process.execPath, [ path.join(pipelineDirectory, "sand-vortex", "build-native-terrain.mjs"), "--recipe", recipeFile, ], "build native terrain visual and collision"); nativeTerrain = JSON.parse(await readFile( path.join(convertedDirectory, "terrain", "conversion-report.json"), "utf8", )); } async function buildNativeWater() { if (!recipe.source.nativeWater) return; if (recipe.source.nativeWater.kind !== "runewaker-wdb-water-layer" || !nativeTerrain || !fullWdbAssembly) { throw new Error("Native water requires runewaker-wdb-water-layer, native terrain, and full WDB assembly."); } const outputRoot = path.join(workDirectory, "native-water-source"); await run(process.execPath, [ path.join(pipelineDirectory, "build-native-water.mjs"), "--recipe", recipeFile, "--wdb-report", path.join(rawDirectory, "wdb-report.json"), "--ground-manifest", path.join(workDirectory, "native-terrain-source", "terrain-native-source.json"), "--output-root", outputRoot, "--audit-cache-root", workDirectory, ], "recover native water layers and animated normal frames"); nativeWater = JSON.parse(await readFile(path.join(outputRoot, "conversion-report.json"), "utf8")); } function run(executable, argumentsList, label) { return new Promise((resolve, reject) => { console.log("\n[runewaker-environment] " + label); const child = spawn(executable, argumentsList, { cwd: projectRoot, stdio: "inherit", windowsHide: true, }); child.once("error", reject); child.once("exit", (code) => { if (code === 0) resolve(); else reject(new Error(label + " exited with code " + code + ".")); }); }); } async function requirePath(file, guidance) { try { await stat(file); } catch { throw new Error(guidance + "\nMissing: " + file); } } async function exists(file) { try { await stat(file); return true; } catch { return false; } } async function sha256(file) { return createHash("sha256").update(await readFile(file)).digest("hex"); } async function buildNativeExporterIfNeeded() { try { await stat(nativeExporter); if (!argv.includes("--rebuild-native")) return; } catch { // Build below. } await run( "powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path.join(pipelineDirectory, "native", "build-exporter.ps1")], "build the preserved RuneWaker ROS bridge", ); } async function exportSource() { await mkdir(rawDirectory, { recursive: true }); await run( nativeExporter, ["--wdb-report", path.join(sourceRoot, recipe.source.wdb), path.join(rawDirectory, "wdb-report.json")], "read " + recipe.title + " WDB placements", ); if (recipe.source.proceduralTerrainFallback) { const fallback = recipe.source.proceduralTerrainFallback; const anchor = recipe.source.primaryPlacementTranslation; const minimum = fallback.sourceBounds.min; const maximum = fallback.sourceBounds.max; const padding = Number(fallback.paddingSourceUnits ?? 0); const thickness = Number(fallback.thicknessSourceUnits ?? 4); const x0 = Number(minimum[0]) - padding - Number(anchor[0]); const x1 = Number(maximum[0]) + padding - Number(anchor[0]); const z0 = Number(minimum[2]) - padding - Number(anchor[2]); const z1 = Number(maximum[2]) + padding - Number(anchor[2]); const top = Number(fallback.floorYSource) - Number(anchor[1]); const bottom = top - thickness; const vertices = [ [x0, top, z0], [x1, top, z0], [x1, top, z1], [x0, top, z1], [x0, bottom, z0], [x1, bottom, z0], [x1, bottom, z1], [x0, bottom, z1], ]; const faces = [ [1, 4, 3], [1, 3, 2], [5, 6, 7], [5, 7, 8], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 4, 8], [3, 8, 7], [4, 1, 5], [4, 5, 8], ]; const obj = [ "# Source-evidence procedural terrain fallback", "mtllib scene.mtl", "g runewaker_procedural_terrain", "usemtl runewaker_procedural_terrain", ...vertices.map((value) => "v " + value.join(" ")), ...faces.map((value) => "f " + value.join(" ")), "", ].join("\n"); await Promise.all([ writeFile(path.join(rawDirectory, "scene.obj"), obj, "utf8"), writeFile(path.join(rawDirectory, "scene.mtl"), "newmtl runewaker_procedural_terrain\nKd 0.38 0.27 0.14\n", "utf8"), writeFile(path.join(rawDirectory, "export-report.json"), JSON.stringify({ schemaVersion: 1, sourceModel: recipe.source.model, exportMode: "source-evidence-procedural-terrain-fallback", fallback, }, null, 2) + "\n", "utf8"), ]); return; } await run( nativeExporter, [resourceRoot, recipe.source.model.replaceAll("/", "\\"), rawDirectory], "export the primary " + recipe.title + " ROS", ); } async function appendSourceAnchorSupport() { const support = recipe.source.sourceAnchorSupport; if (!support || fullWdbAssembly) return; const { placement } = await primaryPlacement(); const half = Number(support.halfExtentSourceUnits ?? 60); const thickness = Number(support.thicknessSourceUnits ?? 3); const objFile = path.join(preparedDirectory, "scene.obj"); const mtlFile = path.join(preparedDirectory, "scene.mtl"); let obj = await readFile(objFile, "utf8"); const vertexCount = (obj.match(/^v\s+/gm) ?? []).length; const lines = ["", "# Source-anchor navigation support", "g runewaker_source_anchor_support", "usemtl runewaker_source_anchor_support"]; const nextVertex = vertexCount + 1; const geometry = sourceAnchorSupportGeometry(support.points, placement, { halfExtent: half, thickness, omitPointIndices: support.omitNativeGroundedPointIndices ?? [], }); for (let i = 0; i < geometry.positions.length; i += 3) lines.push("v " + geometry.positions.slice(i, i + 3).join(" ")); for (let i = 0; i < geometry.indices.length; i += 3) lines.push("f " + geometry.indices.slice(i, i + 3).map(index => nextVertex + index).join(" ")); obj += lines.join("\n") + "\n"; const mtl = await readFile(mtlFile, "utf8"); await Promise.all([ writeFile(objFile, obj, "utf8"), writeFile(mtlFile, mtl + "\nnewmtl runewaker_source_anchor_support\nKd 0.22 0.17 0.11\n", "utf8"), ]); } function resourceSourceFile(resource) { return path.join(resourceRoot, ...normalizeWdbResourcePath(resource).split("\\")); } async function prepareCachedResourceTextures(raw, prepared) { const sourceTextures = path.join(raw, "textures"); const preparedTextures = path.join(prepared, "textures"); await mkdir(preparedTextures, { recursive: true }); const ddsFiles = await exists(sourceTextures) ? (await readdir(sourceTextures)) .filter((file) => file.toLowerCase().endsWith(".dds")) .sort() .map((file) => path.join(sourceTextures, file)) : []; if (ddsFiles.length) { await run( texconv, ["-ft", "PNG", "-o", preparedTextures, "-y", "--permissive", ...ddsFiles], "convert " + ddsFiles.length + " cached DDS textures", ); } await copyFile(path.join(raw, "scene.obj"), path.join(prepared, "scene.obj")); await copyFile(path.join(raw, "export-report.json"), path.join(prepared, "export-report.json")); const sourceMtl = await readFile(path.join(raw, "scene.mtl"), "utf8"); await writeFile( path.join(prepared, "scene.mtl"), sourceMtl.replace(/^(map_Kd\s+.+)\.dds\s*$/gim, "$1.png"), "utf8", ); } async function cachedResourceAsset(resourceRecord) { const sourceFile = resourceSourceFile(resourceRecord.resource); if (!await exists(sourceFile)) { throw new Error("source ROS is missing: " + projectPath(sourceFile)); } const sourceSha256 = await sha256(sourceFile); const converterSha256 = await sha256(blenderScript); const exporterSha256 = await sha256(nativeExporter); const nativeAlphaPolicySha256 = await sha256(path.join(pipelineDirectory, "blender", "native_material_alpha.py")); const cacheRoot = path.join(resourceCacheDirectory, resourceRecord.cacheKey); const raw = path.join(cacheRoot, "raw"); const prepared = path.join(cacheRoot, "prepared"); const converted = path.join(cacheRoot, "converted"); const manifestFile = path.join(cacheRoot, "cache-manifest.json"); if (!argv.includes("--force-resource-cache") && await exists(manifestFile)) { const cached = JSON.parse(await readFile(manifestFile, "utf8")); let nativeAlphaCache = { valid: false }; try { const [rawReport, preparedReport] = await Promise.all([ readFile(path.join(raw, "export-report.json"), "utf8").then(JSON.parse), readFile(path.join(prepared, "export-report.json"), "utf8").then(JSON.parse), ]); nativeAlphaCache = validateNativeAlphaResourceCache({ manifest: cached, rawReport, preparedReport, expectedResource: resourceRecord.normalizedResource, expectedSourceSha256: sourceSha256, expectedToolchain: { converterSha256, exporterSha256, nativeAlphaPolicySha256, }, }); } catch { nativeAlphaCache = { valid: false }; } const outputs = [cached.visual?.file, ...(cached.collision ?? []).map((entry) => entry.file)] .filter(Boolean) .map((file) => path.join(cacheRoot, file)); if (cached.source?.resource === resourceRecord.normalizedResource && cached.source?.sha256 === sourceSha256 && nativeAlphaCache.valid && cached.coordinateSystem?.metersPerSourceUnit === recipe.coordinateSystem.metersPerSourceUnit && (await Promise.all(outputs.map(exists))).every(Boolean)) { return { ...cached, cacheHit: true, visualFile: path.join(cacheRoot, cached.visual.file), collision: cached.collision.map((entry) => ({ ...entry, absoluteFile: path.join(cacheRoot, entry.file), })), }; } } await Promise.all([ mkdir(raw, { recursive: true }), mkdir(prepared, { recursive: true }), mkdir(converted, { recursive: true }), ]); await run( nativeExporter, [resourceRoot, resourceRecord.resource.replaceAll("/", "\\"), raw], "export cached WDB resource " + resourceRecord.resource, ); const rawReportFile = path.join(raw, "export-report.json"); const rawReport = JSON.parse(await readFile(rawReportFile, "utf8")); rawReport.sourceResourceSha256 = sourceSha256; await writeFile(rawReportFile, JSON.stringify(rawReport, null, 2) + "\n", "utf8"); await prepareCachedResourceTextures(raw, prepared); const sourceReportFile = path.join(prepared, "export-report.json"); const sourceReport = JSON.parse(await readFile(sourceReportFile, "utf8")); await run( blender, [ "--background", "--factory-startup", "--python", blenderScript, "--", path.join(prepared, "scene.obj"), converted, resourceRecord.cacheKey, String(recipe.coordinateSystem.metersPerSourceUnit), ], "convert cached WDB resource " + resourceRecord.resource, ); const conversion = JSON.parse(await readFile(path.join(converted, "conversion-report.json"), "utf8")); const manifest = { schemaVersion: 1, source: { resource: resourceRecord.normalizedResource, file: projectPath(sourceFile), sha256: sourceSha256, }, coordinateSystem: { nativeBridge: recipe.coordinateSystem.nativeBridge, metersPerSourceUnit: recipe.coordinateSystem.metersPerSourceUnit, }, toolchain: { converterSha256, exporterSha256, nativeAlphaPolicySha256, }, visual: { file: path.relative( cacheRoot, path.join(converted, resourceRecord.cacheKey + "-visual.glb"), ).split(path.sep).join("/"), triangles: conversion.visual.triangles, meshObjects: conversion.visual.meshObjects, bounds: conversion.visual.bounds, }, collision: conversion.collision.map((entry) => ({ ...entry, file: path.relative(cacheRoot, path.join(converted, entry.file)).split(path.sep).join("/"), })), }; await writeFile(manifestFile, JSON.stringify(manifest, null, 2) + "\n", "utf8"); return { ...manifest, cacheHit: false, visualFile: path.join(cacheRoot, manifest.visual.file), collision: manifest.collision.map((entry) => ({ ...entry, absoluteFile: path.join(cacheRoot, entry.file), })), }; } async function prepareTextures() { const sourceTextures = path.join(rawDirectory, "textures"); const preparedTextures = path.join(preparedDirectory, "textures"); await mkdir(preparedTextures, { recursive: true }); if (recipe.source.proceduralTerrainFallback) { await Promise.all([ copyFile(path.join(rawDirectory, "scene.obj"), path.join(preparedDirectory, "scene.obj")), copyFile(path.join(rawDirectory, "scene.mtl"), path.join(preparedDirectory, "scene.mtl")), copyFile(path.join(rawDirectory, "export-report.json"), path.join(preparedDirectory, "export-report.json")), ]); return; } // Native terrain shares these audit caches, but its linear weight PNGs and // opaque tile PNGs are prepared by its own decoder, not the ROS bridge. const exportedDdsBasenames = recipe.source.nativeTerrain ? new Set( JSON.parse(await readFile(path.join(rawDirectory, "export-report.json"), "utf8")) .materials.map((material) => path.basename(material.stagedTexture ?? "").toLowerCase()), ) : null; const ddsFiles = (await readdir(sourceTextures)) .filter((file) => file.toLowerCase().endsWith(".dds")) .filter((file) => !exportedDdsBasenames || exportedDdsBasenames.has(file.toLowerCase())) .sort() .map((file) => path.join(sourceTextures, file)); if (!ddsFiles.length) throw new Error(recipe.title + " ROS exported without DDS textures."); await run( texconv, ["-ft", "PNG", "-o", preparedTextures, "-y", "--permissive", ...ddsFiles], "convert " + ddsFiles.length + " legacy DDS textures to PNG", ); await copyFile(path.join(rawDirectory, "scene.obj"), path.join(preparedDirectory, "scene.obj")); await copyFile(path.join(rawDirectory, "export-report.json"), path.join(preparedDirectory, "export-report.json")); const sourceReportFile = path.join(preparedDirectory, "export-report.json"); const sourceReport = JSON.parse(await readFile(sourceReportFile, "utf8")); sourceReport.sourceResourceSha256 = await sha256(path.join(resourceRoot, recipe.source.model)); await writeFile(sourceReportFile, JSON.stringify(sourceReport, null, 2) + "\n", "utf8"); const sourceMtl = await readFile(path.join(rawDirectory, "scene.mtl"), "utf8"); await writeFile( path.join(preparedDirectory, "scene.mtl"), sourceMtl.replace(/^(map_Kd\s+.+)\.dds\s*$/gim, "$1.png"), "utf8", ); await appendSourceAnchorSupport(); } async function convertEnvironment() { await mkdir(convertedDirectory, { recursive: true }); await run( blender, [ "--background", "--factory-startup", "--python", blenderScript, "--", path.join(preparedDirectory, "scene.obj"), convertedDirectory, slug, String(recipe.coordinateSystem.metersPerSourceUnit), ], "build textured visual and provisional collision GLBs", ); visualUvAdaptations = await applyRunewakerVisualUvAdaptationsToFile( path.join(convertedDirectory, `${slug}-visual.glb`), { dungeonId: slug, policyIds: recipe.visualUvAdaptationPolicies ?? [], surfaceQualityPolicyIds: recipe.visualSurfaceQualityPolicies ?? [], sourceReport: recipe.visualUvAdaptationPolicies?.length || recipe.visualSurfaceQualityPolicies?.length ? JSON.parse(await readFile(path.join(preparedDirectory, "export-report.json"), "utf8")) : undefined, }, ); const supportPolicy = listRunewakerDerivedSupportStylingPolicies().find(p => p.id === recipe.derivedSupportStylingPolicy); derivedSupportStyling = await applyRunewakerDerivedSupportStylingToFile( path.join(convertedDirectory, `${slug}-visual.glb`), { dungeonId: slug, policyId: recipe.derivedSupportStylingPolicy, sourceReport: supportPolicy ? JSON.parse(await readFile(path.join(preparedDirectory, "export-report.json"), "utf8")) : undefined, sourceDdsFile: supportPolicy ? path.join(rawDirectory, "textures", supportPolicy.stagedTexture) : null, }, ); } function assemblyTranslations() { const primary = recipe.source.primaryPlacementTranslation; return (recipe.source.assemblyPlacementTranslations ?? []) .filter((translation) => translation.some((value, index) => Math.abs(Number(value) - Number(primary[index])) > 0.0001)) .map((translation) => translation.map((value, index) => Number(value) - Number(primary[index]))); } function cloneNodeTree(document, node) { const clone = document.createNode(node.getName()) .setMatrix(node.getMatrix()) .setExtras({ ...node.getExtras() }) .setMesh(node.getMesh()) .setCamera(node.getCamera()) .setSkin(node.getSkin()) .setWeights([...node.getWeights()]); for (const child of node.listChildren()) clone.addChild(cloneNodeTree(document, child)); return clone; } function groupCollisionRecords(records) { const groups = []; let current = []; let triangles = 0; for (const record of records) { if (current.length && triangles + record.triangles > collisionChunkTriangleTarget) { groups.push({ records: current, triangles }); current = []; triangles = 0; } current.push(record); triangles += record.triangles; } if (current.length) groups.push({ records: current, triangles }); return groups; } async function assembleAllWdbPlacements() { const wdbReport = JSON.parse(await readFile(path.join(rawDirectory, "wdb-report.json"), "utf8")); const plan = buildWdbPlacementPlan(wdbReport, recipe); const primaryResource = normalizeWdbResourcePath(plan.primaryPlacement.resource); const primaryConversion = JSON.parse( await readFile(path.join(convertedDirectory, "conversion-report.json"), "utf8"), ); const assets = new Map(); assets.set(primaryResource, { cacheHit: true, cacheKind: "primary-conversion", visualFile: path.join(convertedDirectory, slug + "-visual.glb"), visual: primaryConversion.visual, collision: primaryConversion.collision.map((entry) => ({ ...entry, absoluteFile: path.join(convertedDirectory, entry.file), })), }); const failures = []; for (const resource of plan.resources) { if (resource.normalizedResource === primaryResource) continue; try { assets.set(resource.normalizedResource, await cachedResourceAsset(resource)); } catch (error) { failures.push({ resource: resource.resource, normalizedResource: resource.normalizedResource, placementCount: resource.placementCount, reason: error.message, }); } } const unavailable = new Set(failures.map((failure) => failure.normalizedResource)); for (const resource of plan.resources) { const asset = assets.get(resource.normalizedResource); resource.assetStatus = asset ? "available" : "unsupported"; resource.cacheHit = asset?.cacheHit ?? false; resource.cacheKind = asset?.cacheKind ?? "normalized-path-cache-with-source-hash-validation"; if (!asset) resource.blocker = failures.find((entry) => ( entry.normalizedResource === resource.normalizedResource ))?.reason; } for (const placement of [...plan.staticPlacements, ...plan.dynamicPlacements]) { placement.assetStatus = unavailable.has(placement.normalizedResource) ? "unsupported" : "available"; placement.assetId = "wdb-resource:" + placement.resourceCacheKey; } const staticPlacements = plan.staticPlacements.filter((placement) => ( placement.assetStatus === "available" )); if (!staticPlacements.length) throw new Error("WDB assembly produced no available static placements."); const visualRecords = staticPlacements.map((placement) => ({ placement, assetFile: assets.get(placement.normalizedResource).visualFile, })); const terrainPlacement = { id: "native-wdb-terrain", guid: "native-wdb-terrain", resource: recipe.source.wdb, resourceCacheKey: "native-wdb-terrain", transform: { primaryLocalThreeMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] }, }; if (nativeTerrain) visualRecords.push({ placement: terrainPlacement, assetFile: path.join(convertedDirectory, "terrain", nativeTerrain.visual.file), }); if (nativeWater) visualRecords.push({ placement: { ...terrainPlacement, id: "native-wdb-water", guid: "native-wdb-water", resourceCacheKey: "native-wdb-water" }, assetFile: path.resolve(projectRoot, nativeWater.outputs.waterGlb.path), }); const assembledVisualFile = path.join(convertedDirectory, slug + "-visual.wdb-assembled.glb"); const visualAssembly = await assembleInstancedGlb(visualRecords, assembledVisualFile); await copyFile(assembledVisualFile, path.join(convertedDirectory, slug + "-visual.glb")); const collisionRecords = staticPlacements.flatMap((placement) => ( assets.get(placement.normalizedResource).collision.map((collision) => ({ placement, assetFile: collision.absoluteFile, triangles: Number(collision.triangles ?? collision.sourceTriangleEstimate ?? 0), })) )); if (nativeTerrain) collisionRecords.push({ placement: terrainPlacement, assetFile: path.join(convertedDirectory, "terrain", nativeTerrain.collision.file), triangles: nativeTerrain.collision.triangles, }); collisionRecords.sort((left, right) => ( left.placement.id.localeCompare(right.placement.id) || left.assetFile.localeCompare(right.assetFile) )); const collision = []; if (recipe.source.nativeCollision) { if (recipe.source.nativeCollision.kind !== "runewaker-serialized-ros-trekker") { throw new Error("Unsupported native collision kind: " + recipe.source.nativeCollision.kind); } // Keep the input manifest immutable; the final assembly plan also contains // the resulting collision report and cannot be its own hash-pinned input. const collisionManifest = path.join(convertedDirectory, "wdb-native-collision-placement-manifest.json"); await writeFile(collisionManifest, JSON.stringify({ schemaVersion: plan.schemaVersion, dungeonId: slug, resources: plan.resources, sourceDescriptorCount: staticPlacements.length, staticPlacements, dynamicPlacements: plan.dynamicPlacements, }, null, 2) + "\n", "utf8"); const reportFile = path.join(convertedDirectory, "native-collision-report.json"); await run(process.execPath, [ path.join(pipelineDirectory, "assemble-native-collision.mjs"), "--recipe", recipeFile, "--placement-manifest", collisionManifest, "--output-root", path.join(workDirectory, "native-collision-source"), "--collision-mode", "respect-options", "--report-output", reportFile, ], "assemble source serialized collision with the native pathing mask"); nativeCollision = JSON.parse(await readFile(reportFile, "utf8")); if (nativeCollision.nativeExtractorPolicy?.collisionMode !== "respect-options" || nativeCollision.nativeExtractorPolicy?.renderMeshFallbackUsed !== false) { throw new Error("Native gameplay collision must honor source entity collision options without visual fallback."); } for (const [index, asset] of nativeCollision.collisionAssets.trekkerFiltered.entries()) { const file = slug + "-native-collision-" + String(index).padStart(3, "0") + ".glb"; await copyFile(path.resolve(projectRoot, asset.file), path.join(convertedDirectory, file)); collision.push({ file, triangles: asset.triangles, sourceTriangleEstimate: asset.triangles, assembledFromWdb: true, nativeSerializedCollision: true }); } if (nativeTerrain) { const file = slug + "-terrain-collision.glb"; await copyFile(path.join(convertedDirectory, "terrain", nativeTerrain.collision.file), path.join(convertedDirectory, file)); collision.push({ file, triangles: nativeTerrain.collision.triangles, nativeWdbTerrain: true }); } } else for (const [index, group] of groupCollisionRecords(collisionRecords).entries()) { const file = slug + "-wdb-collision-" + String(index).padStart(3, "0") + ".glb"; await assembleInstancedGlb(group.records, path.join(convertedDirectory, file)); collision.push({ file, triangles: group.triangles, sourceTriangleEstimate: group.triangles, placementCount: group.records.length, assembledFromWdb: true, }); } const dynamicDirectory = path.join(convertedDirectory, "dynamic"); const dynamicAssets = []; const dynamicResources = plan.resources.filter((resource) => ( resource.dynamicPlacementCount > 0 && resource.assetStatus === "available" )); if (dynamicResources.length) await mkdir(dynamicDirectory, { recursive: true }); for (const resource of dynamicResources) { const asset = assets.get(resource.normalizedResource); const visualName = resource.cacheKey + "-visual.glb"; await copyFile(asset.visualFile, path.join(dynamicDirectory, visualName)); dynamicAssets.push({ role: "dynamic-visual", file: "dynamic/" + visualName, assetId: "wdb-resource:" + resource.cacheKey, resource: resource.resource, }); for (const [index, sourceCollision] of asset.collision.entries()) { const collisionName = resource.cacheKey + "-collision-" + String(index).padStart(3, "0") + ".glb"; await copyFile(sourceCollision.absoluteFile, path.join(dynamicDirectory, collisionName)); dynamicAssets.push({ role: "dynamic-collision", file: "dynamic/" + collisionName, assetId: "wdb-resource:" + resource.cacheKey, resource: resource.resource, triangles: Number(sourceCollision.triangles ?? 0), }); } } plan.resourceFailures = failures; if (nativeTerrain) plan.nativeTerrain = nativeTerrain; if (nativeWater) plan.nativeWater = nativeWater; if (nativeCollision) plan.nativeCollision = nativeCollision; plan.buildSummary = { staticPlacements: staticPlacements.length, dynamicPlacements: plan.dynamicPlacements.filter((placement) => ( placement.assetStatus === "available" )).length, unavailablePlacements: [...plan.staticPlacements, ...plan.dynamicPlacements] .filter((placement) => placement.assetStatus === "unsupported").length, uniqueResources: plan.resources.length, availableResources: assets.size, unavailableResources: failures.length, resourceCacheHits: [...assets.values()].filter((asset) => ( asset.cacheHit && asset.cacheKind !== "primary-conversion" )).length, }; const placementManifestFile = path.join(convertedDirectory, "wdb-placement-manifest.json"); await writeFile(placementManifestFile, JSON.stringify(plan, null, 2) + "\n", "utf8"); const visualTriangles = staticPlacements.reduce((total, placement) => ( total + Number(assets.get(placement.normalizedResource).visual.triangles ?? 0) ), Number(nativeTerrain?.visual.triangles ?? 0) + (nativeWater?.geometry.layers ?? []).reduce((sum, layer) => sum + layer.emittedTriangles, 0)); const visualMeshObjects = staticPlacements.reduce((total, placement) => ( total + Number(assets.get(placement.normalizedResource).visual.meshObjects ?? 0) ), Number(nativeTerrain?.visual.meshObjects ?? 0) + Number(nativeWater?.payloadDecode.serializedLayerCount ?? 0)); const conversion = { schemaVersion: 2, dungeonId: slug, status: failures.length || plan.reviewRequiredPlacements.length ? "review-required" : "source-packaged", environmentMode: "runewaker-wdb-assembly", source: { wdb: recipe.source.wdb, primaryModel: recipe.source.model, descriptorCount: plan.sourceDescriptorCount, placementManifest: "wdb-placement-manifest.json", ...(nativeTerrain ? { nativeTerrain } : {}), ...(nativeWater ? { nativeWater } : {}), ...(nativeCollision ? { nativeCollision } : {}), }, coordinateTransform: plan.coordinateTransform, visual: { file: slug + "-visual.glb", meshObjects: visualMeshObjects, triangles: visualTriangles, sourceTriangles: visualTriangles, bounds: visualAssembly.bounds, assembledWdbPlacements: staticPlacements.length, uniqueResources: new Set(staticPlacements.map((placement) => placement.normalizedResource)).size, }, collision, collisionPolicy: { kind: nativeCollision ? "runewaker-serialized-ros-trekker-static-placement" : "wdb-static-placement-trimesh", maximumTrianglesPerChunk: 100_000, workingChunkTarget: collisionChunkTriangleTarget, excludesReviewedDynamicPlacements: true, includesUnreviewedDynamicCandidates: true, }, dynamicAssets, preview: slug + "-preview.png", editableSource: slug + ".source.blend", wdbAssembly: plan.buildSummary, warnings: [ "The preview and editable Blender source cover the primary ROS only; the shipping GLB is assembled from the WDB manifest.", "Name-based dynamic candidates remain static until a reviewed dynamic-placement rule supplies source evidence.", ...failures.map((failure) => failure.resource + ": " + failure.reason), ], }; await writeFile( path.join(convertedDirectory, "conversion-report.json"), JSON.stringify(conversion, null, 2) + "\n", "utf8", ); } async function assembleRepeatedPlacements() { const translations = fullWdbAssembly ? [] : assemblyTranslations(); if (!translations.length) return; const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); const visualFile = path.join(convertedDirectory, slug + "-visual.glb"); const visualDocument = await io.read(visualFile); const visualScene = visualDocument.getRoot().getDefaultScene() ?? visualDocument.getRoot().listScenes()[0]; const visualRoots = visualScene.listChildren(); const meters = recipe.coordinateSystem.metersPerSourceUnit; for (const [index, sourceDelta] of translations.entries()) { const placement = visualDocument.createNode("WDB placement " + String(index + 2)); placement.setTranslation([sourceDelta[0] * meters, sourceDelta[1] * meters, -sourceDelta[2] * meters]); placement.setExtras({ sourceDelta, assembledFromWdb: true }); for (const root of visualRoots) placement.addChild(cloneNodeTree(visualDocument, root)); visualScene.addChild(placement); } await io.write(visualFile, visualDocument); const reportFile = path.join(convertedDirectory, "conversion-report.json"); const report = JSON.parse(await readFile(reportFile, "utf8")); const originalCollision = [...report.collision]; const addedCollision = []; for (const [placementIndex, sourceDelta] of translations.entries()) { const translation = [sourceDelta[0] * meters, sourceDelta[1] * meters, -sourceDelta[2] * meters]; for (const [chunkIndex, chunk] of originalCollision.entries()) { const sourceFile = path.join(convertedDirectory, chunk.file); const document = await io.read(sourceFile); const scene = document.getRoot().getDefaultScene() ?? document.getRoot().listScenes()[0]; const container = document.createNode("WDB collision placement " + String(placementIndex + 2)); container.setTranslation(translation); for (const root of scene.listChildren()) container.addChild(root); scene.addChild(container); const file = slug + "-collision-placement-" + String(placementIndex + 2).padStart(3, "0") + "-" + String(chunkIndex).padStart(3, "0") + ".glb"; await io.write(path.join(convertedDirectory, file), document); addedCollision.push({ ...chunk, file, wdbSourceDelta: sourceDelta }); } } const originalBounds = report.visual.bounds; const translatedBounds = [[0, 0, 0], ...translations.map((value) => [value[0] * meters, value[1] * meters, -value[2] * meters])]; report.visual.bounds = { min: originalBounds.min.map((value, axis) => Math.min(...translatedBounds.map((delta) => value + delta[axis]))), max: originalBounds.max.map((value, axis) => Math.max(...translatedBounds.map((delta) => value + delta[axis]))), }; report.visual.meshObjects *= translations.length + 1; report.visual.triangles *= translations.length + 1; report.visual.sourceTriangles *= translations.length + 1; report.visual.assembledWdbPlacements = translations.length + 1; report.collision.push(...addedCollision); report.warnings.push("Repeated identity-transform WDB placements were assembled into this package."); await writeFile(reportFile, JSON.stringify(report, null, 2) + "\n", "utf8"); } async function optimizeVisual() { const source = path.join(convertedDirectory, slug + "-visual.glb"); const output = path.join(convertedDirectory, slug + "-visual.optimized.glb"); // Establish lighting modes before dedup can merge otherwise identical mats. const nativeMaterialSemantics = await applyNativeMaterialSemantics(source); await run( process.execPath, [ gltfTransform, ...environmentOptimizationArguments(source, output, { preserveNativeWaterLayers: Boolean(nativeWater), preserveMaterialVariantCarriers: Boolean(recipe.visualSurfaceQualityPolicies?.length || recipe.visualUvAdaptationPolicies?.length), }), ], "optimize the visual GLB with meshopt", ); await run( process.execPath, [ktx2Compressor, output, "--concurrency", "1", "--jobs", "2", "--report", path.join(convertedDirectory, "texture-quality-report.json")], "compress the visual textures with KTX2", ); await writeFile(path.join(convertedDirectory, "optimization-report.json"), JSON.stringify({ schemaVersion: 1, dungeonId: slug, status: "review-required", source: { file: path.basename(source), size: (await stat(source)).size }, optimized: { file: path.basename(output), size: (await stat(output)).size, checksum: await sha256(output), }, compression: "meshopt+ktx2", commands: [ "node scripts/runewaker-pipeline/lib/native-material-semantics.mjs (before optimization)", "gltf-transform optimize --compress meshopt --meshopt-level high --instance true --instance-min 3 --simplify false --texture-compress false", "node scripts/runewaker-pipeline/compress-environment-textures.mjs --concurrency 1 --jobs 2", ], textureQuality: JSON.parse(await readFile(path.join(convertedDirectory, "texture-quality-report.json"), "utf8")), nativeMaterialSemantics, visualUvAdaptations, derivedSupportStyling, }, null, 2) + "\n", "utf8"); } async function bakeNavigation() { const conversion = JSON.parse(await readFile(path.join(convertedDirectory, "conversion-report.json"), "utf8")); if (recipe.source.primaryNativeCollision) { if (fullWdbAssembly || recipe.source.primaryNativeCollision.kind !== "runewaker-native-core-and-source-supports" || recipe.source.primaryNativeCollision.collisionMode !== "respect-options") { throw new Error("Native primary/support navigation requires primary-only assembly and respect-options collision."); } const outputRoot = path.join(workDirectory, "native-core-candidates", String(Date.now())); await run(process.execPath, [path.join(pipelineDirectory, "build-native-core-support-candidate.mjs"), slug, "--respect-options", "--output-root=" + projectPath(outputRoot)], "build options-enabled native primary collision and source support navigation"); const proof = JSON.parse(await readFile(path.join(outputRoot, "candidate-proof.json"), "utf8")); if (proof.nativeCollision.queryRespectsEntityCollisionOptions !== true || !proof.navCandidate?.entryWithinHalfMeter) { throw new Error("Native primary navigation must verify collision options and the source entry floor."); } const physicalSource = path.resolve(projectRoot, proof.fullCollisionCandidate.file); if (await sha256(physicalSource) !== proof.fullCollisionCandidate.sha256) throw new Error("Native primary collision proof checksum mismatch."); conversion.collision = await writeBoundedCollisionGlbs(physicalSource, convertedDirectory, slug, { sourceSha256: proof.fullCollisionCandidate.sha256 }); conversion.source.primaryNativeCollision = proof; conversion.collisionPolicy = { kind: "runewaker-serialized-primary-ros-and-source-supports", collisionMode: "respect-options", renderMeshFallbackUsed: false, maximumTrianglesPerChunk: 90_000, }; await writeFile(path.join(convertedDirectory, "conversion-report.json"), JSON.stringify(conversion, null, 2) + "\n", "utf8"); await writeFile(path.join(convertedDirectory, "primary-native-support-report.json"), JSON.stringify(proof, null, 2) + "\n", "utf8"); const navigationSource = path.resolve(projectRoot, proof.navCandidate.file); if (await sha256(navigationSource) !== proof.navCandidate.sha256) throw new Error("Native primary navigation proof checksum mismatch."); await copyFile(navigationSource, path.join(convertedDirectory, slug + "-navigation.glb")); await copyFile(path.resolve(projectRoot, proof.navCandidate.recastReport), path.join(convertedDirectory, "recast-report.json")); return; } const baseCollision = conversion.collision.filter((chunk) => !chunk.wdbSourceDelta); const collisionAssets = baseCollision.map((chunk, index) => ({ id: "collision-" + String(index).padStart(3, "0"), role: "collision", path: projectPath(path.join(convertedDirectory, chunk.file)), })); const navigationFile = path.join(convertedDirectory, slug + "-navigation.glb"); const reportFile = path.join(convertedDirectory, "recast-report.json"); const detourAnchorPositions = recipe.navigation.sourceAnchorComponents ? sourceNavigationAnchors(recipe, (await primaryPlacement()).placement) : []; await bakeNavigationFromCollisionAssets({ slug, collisionAssets, settings: recipe.navigation, includeDetourTopology: recipe.navigation.includeDetourTopology === true, detourAnchorPositions, output: navigationFile, reportFile, source: { kind: nativeCollision ? "runewaker-serialized-ros-trekker-and-native-terrain" : fullWdbAssembly ? "runewaker-wdb-static-assembly" : "runewaker-primary-ros", recipe: projectPath(recipeFile), collisionChunkCount: collisionAssets.length, }, }); const translations = assemblyTranslations(); if (translations.length) { const io = new NodeIO(); const document = await io.read(navigationFile); const scene = document.getRoot().getDefaultScene() ?? document.getRoot().listScenes()[0]; const roots = scene.listChildren(); const meters = recipe.coordinateSystem.metersPerSourceUnit; for (const [index, sourceDelta] of translations.entries()) { const placement = document.createNode("WDB navigation placement " + String(index + 2)); placement.setTranslation([sourceDelta[0] * meters, sourceDelta[1] * meters, -sourceDelta[2] * meters]); for (const root of roots) placement.addChild(cloneNodeTree(document, root)); scene.addChild(placement); } await io.write(navigationFile, document); const report = JSON.parse(await readFile(reportFile, "utf8")); const originalBounds = report.geometry.bounds; const translated = [[0, 0, 0], ...translations.map((value) => [value[0] * meters, value[1] * meters, -value[2] * meters])]; report.geometry.bounds = [ originalBounds[0].map((value, axis) => Math.min(...translated.map((delta) => value + delta[axis]))), originalBounds[1].map((value, axis) => Math.max(...translated.map((delta) => value + delta[axis]))), ]; report.geometry.vertices *= translations.length + 1; report.geometry.triangles *= translations.length + 1; report.source.assembledNavigationPlacements = translations.length + 1; await writeFile(reportFile, JSON.stringify(report, null, 2) + "\n", "utf8"); } await appendSourceAnchorNavigation(navigationFile, reportFile); } async function appendSourceAnchorNavigation(navigationFile, reportFile) { const support = recipe.source.sourceAnchorSupport; if (!support || fullWdbAssembly) return; const { placement } = await primaryPlacement(); const half = Number(support.halfExtentSourceUnits ?? 60); const meters = recipe.coordinateSystem.metersPerSourceUnit; const { navigationPositions: positions } = sourceAnchorSupportGeometry(support.points, placement, { halfExtent: half, thickness: Number(support.thicknessSourceUnits ?? 3), metersPerSourceUnit: meters, omitPointIndices: support.omitNativeGroundedPointIndices ?? [], }); const io = new NodeIO(); const document = await io.read(navigationFile); const buffer = document.getRoot().listBuffers()[0] ?? document.createBuffer("source-anchor-support-buffer"); const accessor = document.createAccessor("source-anchor-support-position", buffer) .setType("VEC3") .setArray(new Float32Array(positions)); const primitive = document.createPrimitive().setAttribute("POSITION", accessor); const mesh = document.createMesh("source-anchor-support-navigation").addPrimitive(primitive); const node = document.createNode("source-anchor-support-navigation").setMesh(mesh); const scene = document.getRoot().getDefaultScene() ?? document.getRoot().listScenes()[0]; scene.addChild(node); await io.write(navigationFile, document); const report = JSON.parse(await readFile(reportFile, "utf8")); for (let offset = 0; offset < positions.length; offset += 3) { for (let axis = 0; axis < 3; axis += 1) { report.geometry.bounds[0][axis] = Math.min(report.geometry.bounds[0][axis], positions[offset + axis]); report.geometry.bounds[1][axis] = Math.max(report.geometry.bounds[1][axis], positions[offset + axis]); } } report.geometry.vertices += positions.length / 3; report.geometry.triangles += positions.length / 9; report.source.sourceAnchorSupport = { pointCount: support.points.length - (support.omitNativeGroundedPointIndices?.length ?? 0), omittedNativeGroundedPointIndices: support.omitNativeGroundedPointIndices ?? [], halfExtentSourceUnits: half, provenance: support.evidence, }; await writeFile(reportFile, JSON.stringify(report, null, 2) + "\n", "utf8"); } function normalizedResourcePath(value) { const separator = String.fromCharCode(92); let normalized = value.replaceAll("/", separator).toLowerCase(); while (normalized.startsWith(separator)) normalized = normalized.slice(1); return normalized; /* Superseded by the character-code implementation above. return value.replaceAll("/", "\\").replace(/^\\\\+/, "").toLowerCase(); */ } async function primaryPlacement() { const report = JSON.parse(await readFile(path.join(rawDirectory, "wdb-report.json"), "utf8")); const target = normalizedResourcePath(recipe.source.model); const matches = report.descriptors.filter((descriptor) => ( normalizedResourcePath(descriptor.resource) === target )); if (!matches.length) { throw new Error("Expected at least one WDB placement for " + recipe.source.model + "."); } let placement = matches[0]; if (matches.length > 1) { const expected = recipe.source.primaryPlacementTranslation; if (!Array.isArray(expected) || expected.length !== 3) { throw new Error("Multiple WDB placements require source.primaryPlacementTranslation for " + recipe.source.model + "."); } placement = matches.find((descriptor) => descriptor.translation.every((value, index) => ( Math.abs(Number(value) - Number(expected[index])) <= 0.0001 ))); if (!placement) throw new Error("Configured primary WDB placement was not found for " + recipe.source.model + "."); } return { report, placement }; } function sourcePointToLocalThree(sourcePosition, placement) { const point = new Vector3(...sourcePosition).sub(new Vector3(...placement.translation)); point.applyQuaternion(new Quaternion(...placement.rotation).invert()); point.divide(new Vector3(...placement.scale)); const meters = recipe.coordinateSystem.metersPerSourceUnit; return [point.x * meters, point.y * meters, -point.z * meters]; } async function projectNavigationPoint(sourcePosition) { const document = await new NodeIO().read(path.join(convertedDirectory, slug + "-navigation.glb")); const positions = []; for (const node of document.getRoot().listNodes()) { const mesh = node.getMesh(); if (!mesh) continue; const world = new Matrix4().fromArray(node.getWorldMatrix()); const vertex = new Vector3(); for (const primitive of mesh.listPrimitives()) { const source = primitive.getAttribute("POSITION")?.getArray(); if (!source) continue; const sourceIndices = primitive.getIndices()?.getArray(); const indices = sourceIndices ? Array.from(sourceIndices) : Array.from({ length: source.length / 3 }, (_, index) => index); for (const index of indices) { vertex.fromArray(source, index * 3).applyMatrix4(world); positions.push(vertex.x, vertex.y, vertex.z); } } } if (!positions.length) throw new Error(recipe.title + " navigation contains no triangles."); const geometry = new BufferGeometry(); geometry.setAttribute("position", new BufferAttribute(new Float32Array(positions), 3)); const hit = new MeshBVH(geometry).closestPointToPoint(new Vector3(...sourcePosition)); geometry.dispose(); if (!hit) throw new Error(recipe.title + " entrance could not be projected to navigation."); return { position: hit.point.toArray().map((value) => Number(value.toFixed(6))), distance: Number(hit.distance.toFixed(6)), }; } function luaNumber(source, name) { const match = source.match(new RegExp("^\\s*" + name + "\\s*=\\s*(-?\\d+(?:\\.\\d+)?)", "m")); if (!match) throw new Error("Missing " + name + " in " + recipe.source.entryLua + "."); return Number(match[1]); } async function sourceMetadata() { const dungeonConfig = JSON.parse(await readFile(path.join(sourceRoot, recipe.source.dungeonConfig), "utf8")); const serverDungeon = dungeonConfig.dungeons.find((dungeon) => dungeon.id === recipe.source.dungeonConfigId); if (!serverDungeon) throw new Error("Missing dungeon_config entry " + recipe.source.dungeonConfigId + "."); let playerSpawn; if (recipe.source.entryCoordinates) { const entry = recipe.source.entryCoordinates; if (!Array.isArray(entry.position) || entry.position.length !== 3 || entry.position.some((value) => !Number.isFinite(Number(value)))) { throw new Error("entryCoordinates.position must contain exactly three finite numbers."); } playerSpawn = { x: Number(entry.position[0]), y: Number(entry.position[1]), z: Number(entry.position[2]), dir: Number(entry.direction ?? 0), directionUnitsPerTurn: recipe.coordinateSystem.directionUnitsPerTurn, source: "environment recipe entryCoordinates", reviewRequired: entry.reviewRequired === true, }; } else { const lua = await readFile(path.join(sourceRoot, recipe.source.entryLua), "utf8"); const prefix = recipe.source.entryConstantPrefix; playerSpawn = { x: luaNumber(lua, prefix + "_X"), y: luaNumber(lua, prefix + "_Y"), z: luaNumber(lua, prefix + "_Z"), dir: luaNumber(lua, prefix + "_DIR"), directionUnitsPerTurn: recipe.coordinateSystem.directionUnitsPerTurn, source: recipe.source.entryLua + ":" + prefix + "_*", reviewRequired: false, }; } return { dungeonConfigId: serverDungeon.id, baseZone: serverDungeon.base_zone, variants: serverDungeon.variant_zones, luaFiles: serverDungeon.lua_files, mobGuids: serverDungeon.mob_guids, bossGuids: serverDungeon.boss_guids, bossPositions: serverDungeon.boss_positions, playerSpawn, }; } async function stageShippingPackage() { await mkdir(shippingDirectory, { recursive: true }); const conversion = JSON.parse(await readFile(path.join(convertedDirectory, "conversion-report.json"), "utf8")); const files = [ { role: "visual", source: path.join(convertedDirectory, slug + "-visual.optimized.glb"), target: path.join(shippingDirectory, slug + "-visual.glb") }, ...conversion.collision.map((chunk) => ({ role: "collision", source: path.join(convertedDirectory, chunk.file), target: path.join(shippingDirectory, chunk.file) })), ...(conversion.dynamicAssets ?? []).map((asset) => ({ role: asset.role, source: path.join(convertedDirectory, asset.file), target: path.join(shippingDirectory, asset.file), assetId: asset.assetId, resource: asset.resource, })), { role: "navigation", source: path.join(convertedDirectory, slug + "-navigation.glb"), target: path.join(shippingDirectory, slug + "-navigation.glb") }, ]; for (const entry of files) { await mkdir(path.dirname(entry.target), { recursive: true }); await copyFile(entry.source, entry.target); } for (const report of ["conversion-report.json", "optimization-report.json", "recast-report.json", slug + "-preview.png"]) { await copyFile(path.join(convertedDirectory, report), path.join(shippingDirectory, report)); } await copyFile(path.join(rawDirectory, "wdb-report.json"), path.join(shippingDirectory, "wdb-report.json")); if (fullWdbAssembly) { await copyFile( path.join(convertedDirectory, "wdb-placement-manifest.json"), path.join(shippingDirectory, "wdb-placement-manifest.json"), ); } if (nativeCollision) { for (const report of ["native-collision-report.json", "wdb-native-collision-placement-manifest.json"]) { await copyFile(path.join(convertedDirectory, report), path.join(shippingDirectory, report)); } } if (recipe.source.primaryNativeCollision) { await copyFile(path.join(convertedDirectory, "primary-native-support-report.json"), path.join(shippingDirectory, "primary-native-support-report.json")); } const server = await sourceMetadata(); const { report, placement } = await primaryPlacement(); const sourceEntrance = [server.playerSpawn.x, server.playerSpawn.y, server.playerSpawn.z]; const localEntrance = sourcePointToLocalThree(sourceEntrance, placement); const authoredProjection = await projectNavigationPoint(localEntrance); const sourceYaw = server.playerSpawn.dir / server.playerSpawn.directionUnitsPerTurn * Math.PI * 2; let forward = transformSourceHeadingToPrimaryThree(sourceYaw, placement) .map((value) => Number(value.toFixed(9))); let footPosition = authoredProjection.position; let navmeshDistance = authoredProjection.distance; let yaw = (Math.atan2(forward[0], forward[2]) + Math.PI * 2) % (Math.PI * 2); const entrance = { sourcePosition: sourceEntrance, sourceDirection: server.playerSpawn.dir, localPosition: localEntrance.map((value) => Number(value.toFixed(6))), footPosition, navmeshDistance, yaw: Number(yaw.toFixed(9)), forward, provenance: server.playerSpawn.source, reviewRequired: server.playerSpawn.reviewRequired, }; if (authoredProjection.distance > 75) { throw new Error("Authoritative entrance is " + authoredProjection.distance + "m from navigation."); } const runtimeReview = recipe.source.reviewedRuntimeEntrance; if (runtimeReview) { const runtimeSourcePosition = runtimeReview.sourcePosition.map(Number); const runtimeLocalPosition = sourcePointToLocalThree(runtimeSourcePosition, placement); const runtimeProjection = await projectNavigationPoint(runtimeLocalPosition); const maximumProjection = Number(runtimeReview.maximumNavmeshProjectionMeters ?? 2); if (runtimeProjection.distance > maximumProjection) { throw new Error("Reviewed runtime entrance is " + runtimeProjection.distance + "m from navigation; its recipe limit is " + maximumProjection + "m."); } const sourceForward = runtimeReview.sourceForwardVector.map(Number); if (sourceForward.length !== 3 || sourceForward.some((value) => !Number.isFinite(value)) || Math.hypot(sourceForward[0], sourceForward[2]) < 1e-8) { throw new Error("reviewedRuntimeEntrance.sourceForwardVector must have a finite, nonzero XZ direction."); } const runtimeSourceYaw = Math.atan2(sourceForward[0], -sourceForward[2]); forward = transformSourceHeadingToPrimaryThree(runtimeSourceYaw, placement) .map((value) => Number(value.toFixed(9))); yaw = (Math.atan2(forward[0], forward[2]) + Math.PI * 2) % (Math.PI * 2); footPosition = runtimeProjection.position; navmeshDistance = runtimeProjection.distance; entrance.authoredNavmeshDistance = authoredProjection.distance; entrance.reviewedRuntime = { sourcePosition: runtimeSourcePosition, localPosition: runtimeLocalPosition.map((value) => Number(value.toFixed(6))), sourceForwardVector: sourceForward, sourcePathNode: runtimeReview.sourcePathNode, headingTarget: runtimeReview.headingTarget, navmeshDistance: runtimeProjection.distance, gameplayReviewed: runtimeReview.gameplayReviewed === true, evidence: runtimeReview.evidence, }; entrance.reviewRequired = runtimeReview.gameplayReviewed !== true; } entrance.footPosition = footPosition; entrance.navmeshDistance = navmeshDistance; entrance.yaw = Number(yaw.toFixed(9)); entrance.forward = forward; const entranceProjectionReview = navmeshDistance > 2; entrance.reviewRequired = entrance.reviewRequired || entranceProjectionReview; const sourceWdb = path.join(sourceRoot, recipe.source.wdb); const sourceRos = path.join(resourceRoot, recipe.source.model); const metadata = { schemaVersion: 2, dungeonId: slug, title: recipe.title, status: entrance.reviewRequired ? "review-required" : "source-packaged", generatedAt: new Date().toISOString(), source: { recipe: projectPath(recipeFile), wdb: recipe.source.wdb, wdbSha256: await sha256(sourceWdb), model: recipe.source.model, modelSha256: await sha256(sourceRos), ...(recipe.source.proceduralTerrainFallback ? { proceduralTerrainFallback: recipe.source.proceduralTerrainFallback, } : {}), ...(nativeTerrain ? { nativeTerrain } : {}), ...(nativeWater ? { nativeWater } : {}), ...(nativeCollision ? { nativeCollision } : {}), server, ...(recipe.source.primaryNativeCollision ? { primaryNativeCollision: conversion.source.primaryNativeCollision } : {}), wdbReport: { descriptorCount: report.descriptorCount, file: "wdb-report.json", primaryPlacement: placement }, ...(fullWdbAssembly ? { wdbAssembly: { mode: "all-ros", placementManifest: "wdb-placement-manifest.json", ...conversion.wdbAssembly, }, } : {}), }, coordinateSystem: recipe.coordinateSystem, entrance, assets: await Promise.all(files.map(async (entry) => ({ role: entry.role, fileName: path.basename(entry.target), relativeFile: path.relative(shippingDirectory, entry.target).split(path.sep).join("/"), ...(entry.assetId ? { assetId: entry.assetId, sourceResource: entry.resource } : {}), size: (await stat(entry.target)).size, sha256: await sha256(entry.target), }))), review: recipe.review, exceptions: [ ...recipe.exceptions, ...(runtimeReview ? [ "The Lua-authored entrance remains in provenance; runtime entry uses the source path node recorded in reviewedRuntimeEntrance.", ...(runtimeReview.gameplayReviewed === true ? [] : [ "The reviewed runtime entrance still requires ordinary in-game movement verification.", ]), ] : []), ...(entranceProjectionReview ? [ "The authoritative entrance was projected " + entrance.navmeshDistance + "m to the packaged primary navigation and requires an in-game entrance review.", ] : []), ], }; await writeFile(path.join(shippingDirectory, "import-metadata.json"), JSON.stringify(metadata, null, 2) + "\n", "utf8"); await run(process.execPath, [khronosValidator, shippingDirectory], "run the Khronos glTF gate"); } await requirePath(resourceRoot, "Set RUNEWAKER_ROOT to the preserved RuneWaker backup root."); await requirePath(blender, "Set BLENDER_BIN to a Blender 5 executable."); await requirePath(texconv, "Install Microsoft DirectXTex texconv or set TEXCONV_BIN."); await requirePath(gltfTransform, "Run npm install in HealerMan."); await buildNativeExporterIfNeeded(); await exportSource(); await prepareTextures(); await convertEnvironment(); await buildNativeTerrain(); await buildNativeWater(); if (fullWdbAssembly) await assembleAllWdbPlacements(); else await assembleRepeatedPlacements(); await optimizeVisual(); await bakeNavigation(); await stageShippingPackage(); console.log("\n[runewaker-environment] package: " + projectPath(shippingDirectory));