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"; 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 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(projectRoot, "scripts", "asset-pipeline", "compress-ktx2.mjs"); const khronosValidator = path.join(projectRoot, "scripts", "manastorm-assets", "validate-package-glbs.cjs"); 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 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", ); } function sourcePointToPrimaryLocal(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)); return point.toArray(); } async function appendSourceAnchorSupport() { const support = recipe.source.sourceAnchorSupport; if (!support) 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"]; let nextVertex = vertexCount + 1; for (const sourcePoint of support.points) { const [x, y, z] = sourcePoint.map(Number); const worldVertices = [ [x - half, y, z - half], [x + half, y, z - half], [x + half, y, z + half], [x - half, y, z + half], [x - half, y - thickness, z - half], [x + half, y - thickness, z - half], [x + half, y - thickness, z + half], [x - half, y - thickness, z + half], ]; const localVertices = worldVertices.map((value) => sourcePointToPrimaryLocal(value, placement)); lines.push(...localVertices.map((value) => "v " + value.join(" "))); const index = Array.from({ length: 8 }, (_, offset) => nextVertex + offset); const faces = [ [0, 3, 2], [0, 2, 1], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7], ]; lines.push(...faces.map((face) => "f " + face.map((offset) => index[offset]).join(" "))); nextVertex += 8; } 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"), ]); } 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; } const ddsFiles = (await readdir(sourceTextures)) .filter((file) => file.toLowerCase().endsWith(".dds")) .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 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", ); } 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; } async function assembleRepeatedPlacements() { const translations = 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"); await run( process.execPath, [ gltfTransform, "optimize", source, output, "--compress", "meshopt", "--meshopt-level", "high", "--instance", "true", "--instance-min", "3", "--texture-size", "2048", ], "optimize the visual GLB with meshopt", ); await run( process.execPath, [ktx2Compressor, output, "--concurrency", "1", "--jobs", "2"], "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 }, compression: "meshopt+ktx2", }, null, 2) + "\n", "utf8"); } async function bakeNavigation() { const conversion = JSON.parse(await readFile(path.join(convertedDirectory, "conversion-report.json"), "utf8")); 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"); await bakeNavigationFromCollisionAssets({ slug, collisionAssets, settings: recipe.navigation, output: navigationFile, reportFile, source: { kind: "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) return; const { placement } = await primaryPlacement(); const half = Number(support.halfExtentSourceUnits ?? 60); const meters = recipe.coordinateSystem.metersPerSourceUnit; const positions = []; for (const sourcePoint of support.points) { const [x, y, z] = sourcePoint.map(Number); const corners = [ [x - half, y, z - half], [x + half, y, z - half], [x + half, y, z + half], [x - half, y, z + half], ].map((value) => { const local = sourcePointToPrimaryLocal(value, placement); return [local[0] * meters, local[1] * meters, -local[2] * meters]; }); for (const index of [0, 3, 2, 0, 2, 1]) positions.push(...corners[index]); } 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, 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) })), { role: "navigation", source: path.join(convertedDirectory, slug + "-navigation.glb"), target: path.join(shippingDirectory, slug + "-navigation.glb") }, ]; for (const entry of files) 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")); 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 projectedEntrance = await projectNavigationPoint(localEntrance); const yaw = server.playerSpawn.dir / server.playerSpawn.directionUnitsPerTurn * Math.PI * 2; const entrance = { sourcePosition: sourceEntrance, sourceDirection: server.playerSpawn.dir, localPosition: localEntrance.map((value) => Number(value.toFixed(6))), footPosition: projectedEntrance.position, navmeshDistance: projectedEntrance.distance, yaw: Number(yaw.toFixed(9)), forward: [Number(Math.sin(yaw).toFixed(9)), 0, Number(Math.cos(yaw).toFixed(9))], provenance: server.playerSpawn.source, reviewRequired: server.playerSpawn.reviewRequired, }; if (entrance.navmeshDistance > 75) { throw new Error("Authoritative entrance is " + entrance.navmeshDistance + "m from navigation."); } const entranceProjectionReview = entrance.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, } : {}), server, wdbReport: { descriptorCount: report.descriptorCount, file: "wdb-report.json", primaryPlacement: placement }, }, coordinateSystem: recipe.coordinateSystem, entrance, assets: await Promise.all(files.map(async (entry) => ({ role: entry.role, fileName: path.basename(entry.target), size: (await stat(entry.target)).size, sha256: await sha256(entry.target), }))), review: recipe.review, exceptions: [ ...recipe.exceptions, ...(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 assembleRepeatedPlacements(); await optimizeVisual(); await bakeNavigation(); await stageShippingPackage(); console.log("\n[runewaker-environment] package: " + projectPath(shippingDirectory));