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, } from "./lib/environment-assembly.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(projectRoot, "scripts", "asset-pipeline", "compress-ktx2.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; 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", ); } 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 || 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"]; 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"), ]); } 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 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")); 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 && cached.toolchain?.converterSha256 === converterSha256 && 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, ); await prepareCachedResourceTextures(raw, prepared); 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, }, 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; } 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; } 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 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), })) )).sort((left, right) => ( left.placement.id.localeCompare(right.placement.id) || left.assetFile.localeCompare(right.assetFile) )); const collision = []; 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; 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) ), 0); const visualMeshObjects = staticPlacements.reduce((total, placement) => ( total + Number(assets.get(placement.normalizedResource).visual.meshObjects ?? 0) ), 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", }, 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: "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"); 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 || fullWdbAssembly) 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) })), ...(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"), ); } 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 }, ...(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, ...(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(); if (fullWdbAssembly) await assembleAllWdbPlacements(); else await assembleRepeatedPlacements(); await optimizeVisual(); await bakeNavigation(); await stageShippingPackage(); console.log("\n[runewaker-environment] package: " + projectPath(shippingDirectory));