import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { copyFile, mkdir, readFile, readdir, rm, 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"; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(scriptDirectory, "../.."); const recipeFile = path.join(scriptDirectory, "recipes", "forsaken-abbey-population.json"); const recipe = JSON.parse(await readFile(recipeFile, "utf8")); const resolveProject = (value) => path.resolve(projectRoot, value); const sourceRoot = path.resolve( process.env[recipe.source.rootEnvironmentVariable] ?? path.join(projectRoot, recipe.source.relativeDefault), ); const resourceRoot = path.join(sourceRoot, recipe.source.resourceRoot); const workRoot = path.join(projectRoot, "runewaker-export-work", "forsaken-abbey-population"); const actorOutput = resolveProject(recipe.files.actorOutput); 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-actor.py"); const gltfTransform = path.join( projectRoot, "node_modules", "@gltf-transform", "cli", "bin", "cli.js", ); 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-population] " + 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) { try { const info = await stat(file); if (!info.isFile()) throw new Error(); } catch { throw new Error(guidance + "\nMissing: " + file); } } async function sha256(file) { return createHash("sha256").update(await readFile(file)).digest("hex"); } function stableHash(value) { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); } function projectPath(file) { return path.relative(projectRoot, file).split(path.sep).join("/"); } async function prepareActor(actor) { const actorRoot = path.join(workRoot, "actors", actor.id); const raw = path.join(actorRoot, "raw"); const prepared = path.join(actorRoot, "prepared"); const converted = path.join(actorRoot, "converted"); await rm(actorRoot, { recursive: true, force: true }); await mkdir(raw, { recursive: true }); await mkdir(path.join(prepared, "textures"), { recursive: true }); await mkdir(converted, { recursive: true }); await run( nativeExporter, [resourceRoot, path.normalize(actor.sourceModel), raw], "export " + actor.id + " from its preserved ROS", ); const ddsFiles = (await readdir(path.join(raw, "textures"))) .filter((file) => file.toLowerCase().endsWith(".dds")) .sort() .map((file) => path.join(raw, "textures", file)); if (!ddsFiles.length) throw new Error(actor.id + " exported without DDS textures."); await run( texconv, ["-ft", "PNG", "-o", path.join(prepared, "textures"), "-y", "--permissive", ...ddsFiles], "decode " + actor.id + " 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", ); const rawGlb = path.join(converted, actor.id + ".raw.glb"); const reportFile = path.join(converted, actor.id + ".report.json"); await run( blender, [ "--background", "--factory-startup", "--python", blenderScript, "--", path.join(prepared, "scene.obj"), rawGlb, reportFile, actor.id, String(recipe.coordinateSystem.metersPerSourceUnit), ], "convert " + actor.id + " to GLB", ); const optimized = path.join(converted, actor.id + ".glb"); await run( process.execPath, [ gltfTransform, "optimize", rawGlb, optimized, "--compress", "meshopt", "--meshopt-level", "high", "--texture-size", "1024", ], "meshopt-compress " + actor.id, ); await mkdir(actorOutput, { recursive: true }); const shipping = path.join(actorOutput, actor.id + ".glb"); await copyFile(optimized, shipping); const conversion = JSON.parse(await readFile(reportFile, "utf8")); const sourceModel = path.join(resourceRoot, actor.sourceModel); return { id: actor.id, sourceModel: actor.sourceModel, sourceModelSha256: await sha256(sourceModel), fileName: actor.id + ".glb", url: "/assets/creatures/forsaken-abbey/" + actor.id + ".glb", checksum: await sha256(shipping), size: (await stat(shipping)).size, triangles: conversion.triangles, bounds: conversion.bounds, rotationY: actor.runtimeRotationY, ...conversion.runtime, animations: [], }; } async function buildActors() { await Promise.all([ requireFile(nativeExporter, "Build the preserved RuneWaker exporter first."), requireFile(blender, "Set BLENDER_BIN to Blender 5."), requireFile(texconv, "Set TEXCONV_BIN to Microsoft texconv."), ]); const assets = []; for (const actor of recipe.actors) assets.push(await prepareActor(actor)); await run( process.execPath, [khronosValidator, actorOutput], "validate all Forsaken Abbey creature GLBs with Khronos", ); const manifest = { schemaVersion: 1, dungeonId: recipe.dungeonId, status: "green", animationPolicy: "authentic static ROS models with HealerMan procedural movement", assets, }; await writeFile( resolveProject(recipe.files.actorManifest), JSON.stringify(manifest, null, 2) + "\n", "utf8", ); return manifest; } async function navigationProjector() { const navigationFile = resolveProject(recipe.files.navigation); await requireFile(navigationFile, "Build the Forsaken Abbey environment navigation first."); const document = await new NodeIO().read(navigationFile); const positions = []; const vertex = new Vector3(); for (const node of document.getRoot().listNodes()) { const mesh = node.getMesh(); if (!mesh) continue; const world = new Matrix4().fromArray(node.getWorldMatrix()); 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 bvh = new MeshBVH(geometry); return { project(point) { const hit = bvh.closestPointToPoint(new Vector3(...point)); if (!hit) throw new Error("Could not project a Forsaken Abbey spawn to navigation."); return { position: hit.point.toArray().map((value) => Number(value.toFixed(6))), distance: Number(hit.distance.toFixed(6)), }; }, dispose() { geometry.dispose(); }, }; } function sourceToLocal(sourcePosition, placement) { const point = new Vector3(...sourcePosition); point.sub(new Vector3(...placement.translation)); point.applyQuaternion(new Quaternion(...placement.rotation).invert()); const scale = placement.scale; point.set(point.x / scale[0], point.y / scale[1], point.z / scale[2]); const meters = recipe.coordinateSystem.metersPerSourceUnit; return [ point.x * meters, point.y * meters, -point.z * meters, ]; } function yawFromDirection(direction) { const turn = recipe.coordinateSystem.directionUnitsPerTurn; return Number((((direction % turn) + turn) % turn * Math.PI * 2 / turn).toFixed(9)); } function distanceSquared(a, b) { return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2; } function attackFor(template) { if (template.id === 100074 || template.id === 100070) { return { id: "rw-fa-shadow-bolt", name: "Shadow Bolt", delivery: "projectile", target: "primary", school: "shadow", animation: "cast", range: 18, cooldownMs: 4200, damageMultiplier: 1.08, }; } return { id: "rw-fa-basic-attack", name: template.archetype === "ooze" ? "Corrosive Slam" : "Basic Attack", delivery: "melee", target: "primary", school: template.archetype === "ooze" ? "nature" : "physical", animation: "attack", range: 4.5, cooldownMs: 1900, damageMultiplier: 1, }; } function entityFor(template, actor) { const boss = template.classification === "boss"; return { id: "rw-fa-" + template.id, name: template.name, kind: boss ? "boss" : "mob", ...(boss ? { title: template.title } : {}), hasLoot: false, combat: { level: template.level, healthMultiplier: boss ? 7.5 : 1, damageMultiplier: boss ? 1.3 : 1, moveSpeed: boss ? 2.25 : 2.15, leashRange: boss ? 32 : 24, attacks: [attackFor(template)], }, visual: { primaryColor: template.primaryColor, accentColor: template.accentColor, scale: 1, archetype: template.archetype, model: { url: actor.url, rotationY: actor.rotationY, groundOffset: actor.groundOffset, labelHeight: actor.labelHeight, markerRadius: actor.markerRadius, animationMode: actor.animations.length ? "native" : "procedural", }, }, }; } function validateSnapshot(snapshot, templates) { if (snapshot.schemaVersion !== 1 || snapshot.dungeonId !== recipe.dungeonId) { throw new Error("Forsaken Abbey snapshot schema or dungeon id is invalid."); } if (snapshot.zoneId !== recipe.zoneId || snapshot.rows.length !== recipe.expectedActiveRows) { throw new Error( "Expected " + recipe.expectedActiveRows + " active Zone " + recipe.zoneId + " rows, found " + snapshot.rows.length + ".", ); } const ids = new Set(); for (const row of snapshot.rows) { if (ids.has(row.spawnId)) throw new Error("Duplicate RuneWaker spawn id " + row.spawnId + "."); ids.add(row.spawnId); const template = templates.get(row.templateId); if (!template) throw new Error("Unmapped RuneWaker template " + row.templateId + "."); if (row.classification !== template.classification) { throw new Error("Classification drift for RuneWaker template " + row.templateId + "."); } if (row.modelPath !== (template.actor ? recipe.actors.find((actor) => actor.id === template.actor)?.sourceModel : null)) { throw new Error("Model-path drift for RuneWaker template " + row.templateId + "."); } } } async function generatePopulation() { const snapshotFile = resolveProject(recipe.files.snapshot); const manifestFile = resolveProject(recipe.files.actorManifest); await Promise.all([ requireFile( snapshotFile, "Run node scripts/runewaker-pipeline/refresh-forsaken-population.mjs once to create the portable snapshot.", ), requireFile( manifestFile, "Run node scripts/runewaker-pipeline/build-forsaken-population.mjs --actors-only to package the creature models.", ), ]); const [snapshot, manifest, environment] = await Promise.all([ readFile(snapshotFile, "utf8").then(JSON.parse), readFile(manifestFile, "utf8").then(JSON.parse), readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse), ]); const templates = new Map(recipe.templates.map((template) => [template.id, template])); const actors = new Map(manifest.assets.map((actor) => [actor.id, actor])); validateSnapshot(snapshot, templates); for (const actor of recipe.actors) { if (!actors.has(actor.id)) throw new Error("Actor manifest is missing " + actor.id + "."); } const placement = environment.source?.wdb?.primaryPlacement; if (!placement) throw new Error("Forsaken Abbey environment metadata has no WDB anchor."); const projector = await navigationProjector(); const entities = {}; const staticSpawns = []; const records = []; const rawBosses = []; const countsByTemplate = {}; try { for (const row of snapshot.rows) { const template = templates.get(row.templateId); const local = sourceToLocal(row.sourcePosition, placement); const projected = projector.project(local); if ( projected.distance > recipe.coordinateSystem.maximumProjectionDistance && template.classification !== "deferred-object" ) { throw new Error( "Spawn " + row.spawnId + " is " + projected.distance + "m from navigation (maximum " + recipe.coordinateSystem.maximumProjectionDistance + "m).", ); } const record = { sourceSpawnId: row.spawnId, sourceTemplateId: row.templateId, name: row.name, classification: row.classification, sourcePosition: row.sourcePosition, sourceDirection: row.direction, localPosition: local.map((value) => Number(value.toFixed(6))), projectedPosition: projected.position, projectionDistance: projected.distance, modelPath: row.modelPath, }; records.push(record); countsByTemplate[row.templateId] = (countsByTemplate[row.templateId] ?? 0) + 1; if (template.classification === "deferred-object") continue; const entityId = "rw-fa-" + row.templateId; if (!entities[entityId]) { entities[entityId] = entityFor(template, actors.get(template.actor)); } const spawn = { id: "rw-fa-spawn-" + row.spawnId, entityId, position: projected.position, yaw: yawFromDirection(row.direction), spawnMask: 1, }; staticSpawns.push(spawn); if (template.classification === "boss") { rawBosses.push({ id: spawn.id, templateId: template.id, name: template.name, position: spawn.position, }); } } } finally { projector.dispose(); } if (rawBosses.length !== recipe.expectedBossSpawns) { throw new Error( "Expected " + recipe.expectedBossSpawns + " boss spawns, found " + rawBosses.length + ".", ); } const objectiveOrder = [...rawBosses] .sort((a, b) => ( distanceSquared(a.position, environment.entrance.footPosition) - distanceSquared(b.position, environment.entrance.footPosition) || a.id.localeCompare(b.id) )); let keeperIndex = 0; const bosses = objectiveOrder.map((boss) => ({ id: boss.id, name: boss.templateId === 100070 ? "Necromage Keeper " + (++keeperIndex) : boss.name, position: boss.position, })); const excluded = records.filter((record) => record.classification === "deferred-object"); const projectionDistances = records.map((record) => record.projectionDistance); const maximumProjectionDistance = Math.max(...projectionDistances); const combatProjectionDistances = records .filter((record) => record.classification !== "deferred-object") .map((record) => record.projectionDistance); const maximumCombatProjectionDistance = Math.max(...combatProjectionDistances); const deferredOffNavigationCount = excluded.filter( (record) => record.projectionDistance > recipe.coordinateSystem.maximumProjectionDistance, ).length; const provenance = { schemaVersion: 1, dungeonId: recipe.dungeonId, zoneId: recipe.zoneId, sourceRowCount: snapshot.rows.length, importedSpawnCount: staticSpawns.length, excludedRowCount: excluded.length, bossSpawnCount: bosses.length, sourceSnapshot: projectPath(snapshotFile), sourceContentSha256: snapshot.contentSha256, sourceHashes: snapshot.sourceHashes, coordinateTransform: "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)", navmeshProjection: { maximumAllowedDistance: recipe.coordinateSystem.maximumProjectionDistance, maximumObservedDistance: Number(maximumProjectionDistance.toFixed(6)), maximumCombatDistance: Number(maximumCombatProjectionDistance.toFixed(6)), deferredRowsBeyondCombatLimit: deferredOffNavigationCount, }, runeWakerIdsAreNamespaced: true, serverEntryPolicy: "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", }; const report = { ...provenance, status: "green", recipe: projectPath(recipeFile), actorManifest: projectPath(manifestFile), entityCount: Object.keys(entities).length, countsByTemplate, bosses, excluded, records, deferred: recipe.deferred, }; await mkdir(path.dirname(resolveProject(recipe.files.report)), { recursive: true }); await writeFile( resolveProject(recipe.files.report), JSON.stringify(report, null, 2) + "\n", "utf8", ); const generated = [ "/* This file is generated by scripts/runewaker-pipeline/build-forsaken-population.mjs. */", 'import type { DungeonBossObjective } from "../dungeonTypes";', 'import type { PopulationDefinitionMap, StaticMobSpawnDefinition } from "../mobPopulation";', "", "export const FORSAKEN_ABBEY_ENTITIES = " + JSON.stringify(entities, null, 2) + " as const satisfies PopulationDefinitionMap;", "", "export const FORSAKEN_ABBEY_STATIC_SPAWNS = " + JSON.stringify(staticSpawns, null, 2) + " as const satisfies readonly StaticMobSpawnDefinition[];", "", "export const FORSAKEN_ABBEY_BOSSES = " + JSON.stringify(bosses, null, 2) + " as const satisfies readonly DungeonBossObjective[];", "", "export const FORSAKEN_ABBEY_OBJECTIVE_ORDER = FORSAKEN_ABBEY_BOSSES;", "", "export const FORSAKEN_ABBEY_POPULATION_PROVENANCE = " + JSON.stringify(provenance, null, 2) + " as const;", "", ].join("\n"); const generatedFile = resolveProject(recipe.files.generatedSource); await mkdir(path.dirname(generatedFile), { recursive: true }); await writeFile(generatedFile, generated, "utf8"); console.log( "\n[runewaker-population] generated " + staticSpawns.length + " combat spawns, " + bosses.length + " objectives, and " + excluded.length + " deferred object.", ); } const actorsOnly = process.argv.includes("--actors-only"); if (actorsOnly || process.argv.includes("--actors")) await buildActors(); if (!actorsOnly) await generatePopulation();