486 lines
16 KiB
JavaScript
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}`);
|