Files
healer-man/scripts/runewaker-pipeline/build-forsaken-abbey.mjs
T
2026-08-14 15:56:39 -04:00

486 lines
16 KiB
JavaScript

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 <optimized.glb> --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}`);