Files
2026-08-14 15:56:39 -04:00

565 lines
19 KiB
JavaScript

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();