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 { fileURLToPath } from "node:url"; import { NodeIO } from "@gltf-transform/core"; import { BufferAttribute, BufferGeometry, Matrix4, Quaternion, Vector3 } from "three"; import { MeshBVH } from "three-mesh-bvh"; import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs"; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(scriptDirectory, "../.."); const recipeFile = path.join(scriptDirectory, "recipes", "forsaken-abbey.json"); const recipe = JSON.parse(await readFile(recipeFile, "utf8")); 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 = path.resolve( process.env[recipe.source.rootEnvironmentVariable] ?? path.join(projectRoot, recipe.source.relativeDefault), ); const resourceRoot = path.join(sourceRoot, recipe.source.resourceRoot); const nativeExporter = path.join( scriptDirectory, "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(scriptDirectory, "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", ); async function exists(file) { try { await stat(file); return true; } catch { return false; } } function run(executable, argumentsList, label) { return new Promise((resolve, reject) => { console.log(`\n[runewaker] ${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 requireFile(file, guidance) { if (!await exists(file)) { throw new Error(`${guidance}\nMissing: ${file}`); } } async function sha256(file) { return createHash("sha256").update(await readFile(file)).digest("hex"); } function relativeProjectPath(file) { return path.relative(projectRoot, file).replace(/\\/g, "/"); } async function buildNativeExporterIfNeeded() { if (await exists(nativeExporter) && !process.argv.includes("--rebuild-native")) return; await run( "powershell.exe", [ "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path.join(scriptDirectory, "native", "build-exporter.ps1"), ], "build the preserved RuneWaker ROS bridge", ); } async function exportRos() { await mkdir(rawDirectory, { recursive: true }); await run( nativeExporter, [resourceRoot, recipe.source.model.replaceAll("/", "\\"), rawDirectory], "export the primary Forsaken Abbey ROS", ); } async function reportWdbPlacements() { await mkdir(rawDirectory, { recursive: true }); await run( nativeExporter, [ "--wdb-report", path.join(sourceRoot, recipe.source.wdb), path.join(rawDirectory, "wdb-report.json"), ], "read Forsaken Abbey WDB placements with the preserved Rune object factory", ); } async function prepareTextures() { const sourceTextures = path.join(rawDirectory, "textures"); const preparedTextures = path.join(preparedDirectory, "textures"); await mkdir(preparedTextures, { recursive: true }); const ddsFiles = (await readdir(sourceTextures)) .filter((file) => file.toLowerCase().endsWith(".dds")) .sort() .map((file) => path.join(sourceTextures, file)); if (!ddsFiles.length) throw new Error("The ROS exporter produced no 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"); const preparedMtl = sourceMtl.replace(/^(map_Kd\s+.+)\.dds\s*$/gim, "$1.png"); await writeFile(path.join(preparedDirectory, "scene.mtl"), preparedMtl, "utf8"); const pngCount = (await readdir(preparedTextures)) .filter((file) => file.toLowerCase().endsWith(".png")) .length; if (pngCount !== ddsFiles.length) { throw new Error(`Expected ${ddsFiles.length} prepared PNG textures, found ${pngCount}.`); } } 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", ); } 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 HealerMan's standard settings", ); 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", commands: [ "gltf-transform optimize --compress meshopt --meshopt-level high --instance true --instance-min 3 --texture-size 2048", "node scripts/asset-pipeline/compress-ktx2.mjs --concurrency 1 --jobs 2", ], }, null, 2)}\n`, "utf8", ); } async function bakeNavigation() { const conversion = JSON.parse( await readFile(path.join(convertedDirectory, "conversion-report.json"), "utf8"), ); const collisionAssets = conversion.collision.map((chunk, index) => ({ id: `collision-${String(index).padStart(3, "0")}`, role: "collision", path: relativeProjectPath(path.join(convertedDirectory, chunk.file)), })); await bakeNavigationFromCollisionAssets({ slug, collisionAssets, settings: recipe.navigation, output: path.join(convertedDirectory, `${slug}-navigation.glb`), reportFile: path.join(convertedDirectory, "recast-report.json"), source: { kind: "runewaker-primary-ros-pilot", recipe: relativeProjectPath(recipeFile), collisionChunkCount: collisionAssets.length, }, }); } function normalizedResourcePath(value) { return value.replaceAll("/", "\\").replace(/^\\+/, "").toLowerCase(); } async function primaryWdbPlacement() { 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 !== 1) { throw new Error(`Expected one WDB placement for ${recipe.source.model}, found ${matches.length}.`); } return { report, placement: matches[0] }; } 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("Forsaken Abbey 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("Forsaken Abbey 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 ${recipe.source.dungeonConfigId} in the preserved dungeon config.`); } const entryLua = await readFile(path.join(sourceRoot, recipe.source.entryLua), "utf8"); const prefix = recipe.source.entryConstantPrefix; const playerSpawn = { x: luaNumber(entryLua, `${prefix}_X`), y: luaNumber(entryLua, `${prefix}_Y`), z: luaNumber(entryLua, `${prefix}_Z`), dir: luaNumber(entryLua, `${prefix}_DIR`), directionUnitsPerTurn: recipe.coordinateSystem.directionUnitsPerTurn, source: recipe.source.entryLua, }; 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 sourceWdb = path.join(sourceRoot, recipe.source.wdb); const sourceRos = path.join(resourceRoot, recipe.source.model); const server = await sourceMetadata(); const { report: wdbReport, placement } = await primaryWdbPlacement(); 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: `${recipe.source.entryLua}:${recipe.source.entryConstantPrefix}_*`, }; if (entrance.navmeshDistance > 2) { throw new Error(`Authoritative entrance is ${entrance.navmeshDistance}m from navigation.`); } const metadata = { schemaVersion: 1, dungeonId: slug, title: recipe.title, status: "review-required", generatedAt: new Date().toISOString(), source: { recipe: relativeProjectPath(recipeFile), wdb: recipe.source.wdb, wdbSha256: await sha256(sourceWdb), model: recipe.source.model, modelSha256: await sha256(sourceRos), server, wdb: { descriptorCount: wdbReport.descriptorCount, report: "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, entrance: "source-and-navmesh-approved", wdbPlacements: "primary-model-approved", }, exceptions: recipe.exceptions, }; 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 on the shipping package", ); } await requireFile(resourceRoot, "Set RUNEWAKER_ROOT to the preserved RuneWaker backup root."); await requireFile(blender, "Set BLENDER_BIN to a Blender 5 portable executable."); await requireFile(texconv, "Install Microsoft.DirectXTex.Texconv or set TEXCONV_BIN."); await requireFile(gltfTransform, "Run npm install in HealerMan."); await buildNativeExporterIfNeeded(); await reportWdbPlacements(); await exportRos(); await prepareTextures(); await convertEnvironment(); await optimizeVisual(); await bakeNavigation(); await stageShippingPackage(); console.log(`\n[runewaker] Forsaken Abbey pilot package: ${shippingDirectory}`);