1014 lines
44 KiB
JavaScript
1014 lines
44 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 { NodeIO } from "@gltf-transform/core";
|
|
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
|
import { BufferAttribute, BufferGeometry, Matrix4, Quaternion, Vector3 } from "three";
|
|
import { MeshBVH } from "three-mesh-bvh";
|
|
import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs";
|
|
import {
|
|
loadEnvironmentRecipe,
|
|
pipelineDirectory,
|
|
projectPath,
|
|
projectRoot,
|
|
sourceRootFor,
|
|
} from "./lib/recipe.mjs";
|
|
import {
|
|
assembleInstancedGlb,
|
|
buildWdbPlacementPlan,
|
|
normalizeWdbResourcePath,
|
|
} from "./lib/environment-assembly.mjs";
|
|
|
|
const argv = process.argv.slice(2);
|
|
const fallbackRecipe = path.join(pipelineDirectory, "recipes", "forsaken-abbey.json");
|
|
const { file: recipeFile, recipe } = await loadEnvironmentRecipe(argv, fallbackRecipe);
|
|
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 resourceCacheDirectory = path.join(projectRoot, "runewaker-export-work", "resource-cache");
|
|
const shippingDirectory = path.join(projectRoot, "src", "assets", "game", "dungeons", slug);
|
|
const sourceRoot = sourceRootFor(recipe);
|
|
const resourceRoot = path.join(sourceRoot, recipe.source.resourceRoot);
|
|
const nativeExporter = path.join(pipelineDirectory, "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(pipelineDirectory, "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");
|
|
const fullWdbAssembly = !recipe.source.proceduralTerrainFallback
|
|
&& recipe.source.wdbAssembly?.mode !== "primary-only"
|
|
&& !argv.includes("--primary-only");
|
|
const collisionChunkTriangleTarget = 90_000;
|
|
|
|
function run(executable, argumentsList, label) {
|
|
return new Promise((resolve, reject) => {
|
|
console.log("\n[runewaker-environment] " + 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 requirePath(file, guidance) {
|
|
try { await stat(file); }
|
|
catch { throw new Error(guidance + "\nMissing: " + file); }
|
|
}
|
|
|
|
async function exists(file) {
|
|
try {
|
|
await stat(file);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function sha256(file) {
|
|
return createHash("sha256").update(await readFile(file)).digest("hex");
|
|
}
|
|
|
|
async function buildNativeExporterIfNeeded() {
|
|
try {
|
|
await stat(nativeExporter);
|
|
if (!argv.includes("--rebuild-native")) return;
|
|
} catch {
|
|
// Build below.
|
|
}
|
|
await run(
|
|
"powershell.exe",
|
|
["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path.join(pipelineDirectory, "native", "build-exporter.ps1")],
|
|
"build the preserved RuneWaker ROS bridge",
|
|
);
|
|
}
|
|
|
|
async function exportSource() {
|
|
await mkdir(rawDirectory, { recursive: true });
|
|
await run(
|
|
nativeExporter,
|
|
["--wdb-report", path.join(sourceRoot, recipe.source.wdb), path.join(rawDirectory, "wdb-report.json")],
|
|
"read " + recipe.title + " WDB placements",
|
|
);
|
|
if (recipe.source.proceduralTerrainFallback) {
|
|
const fallback = recipe.source.proceduralTerrainFallback;
|
|
const anchor = recipe.source.primaryPlacementTranslation;
|
|
const minimum = fallback.sourceBounds.min;
|
|
const maximum = fallback.sourceBounds.max;
|
|
const padding = Number(fallback.paddingSourceUnits ?? 0);
|
|
const thickness = Number(fallback.thicknessSourceUnits ?? 4);
|
|
const x0 = Number(minimum[0]) - padding - Number(anchor[0]);
|
|
const x1 = Number(maximum[0]) + padding - Number(anchor[0]);
|
|
const z0 = Number(minimum[2]) - padding - Number(anchor[2]);
|
|
const z1 = Number(maximum[2]) + padding - Number(anchor[2]);
|
|
const top = Number(fallback.floorYSource) - Number(anchor[1]);
|
|
const bottom = top - thickness;
|
|
const vertices = [
|
|
[x0, top, z0], [x1, top, z0], [x1, top, z1], [x0, top, z1],
|
|
[x0, bottom, z0], [x1, bottom, z0], [x1, bottom, z1], [x0, bottom, z1],
|
|
];
|
|
const faces = [
|
|
[1, 4, 3], [1, 3, 2], [5, 6, 7], [5, 7, 8],
|
|
[1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6],
|
|
[3, 4, 8], [3, 8, 7], [4, 1, 5], [4, 5, 8],
|
|
];
|
|
const obj = [
|
|
"# Source-evidence procedural terrain fallback",
|
|
"mtllib scene.mtl", "g runewaker_procedural_terrain", "usemtl runewaker_procedural_terrain",
|
|
...vertices.map((value) => "v " + value.join(" ")),
|
|
...faces.map((value) => "f " + value.join(" ")), "",
|
|
].join("\n");
|
|
await Promise.all([
|
|
writeFile(path.join(rawDirectory, "scene.obj"), obj, "utf8"),
|
|
writeFile(path.join(rawDirectory, "scene.mtl"), "newmtl runewaker_procedural_terrain\nKd 0.38 0.27 0.14\n", "utf8"),
|
|
writeFile(path.join(rawDirectory, "export-report.json"), JSON.stringify({
|
|
schemaVersion: 1,
|
|
sourceModel: recipe.source.model,
|
|
exportMode: "source-evidence-procedural-terrain-fallback",
|
|
fallback,
|
|
}, null, 2) + "\n", "utf8"),
|
|
]);
|
|
return;
|
|
}
|
|
await run(
|
|
nativeExporter,
|
|
[resourceRoot, recipe.source.model.replaceAll("/", "\\"), rawDirectory],
|
|
"export the primary " + recipe.title + " ROS",
|
|
);
|
|
}
|
|
|
|
function sourcePointToPrimaryLocal(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));
|
|
return point.toArray();
|
|
}
|
|
|
|
async function appendSourceAnchorSupport() {
|
|
const support = recipe.source.sourceAnchorSupport;
|
|
if (!support || fullWdbAssembly) return;
|
|
const { placement } = await primaryPlacement();
|
|
const half = Number(support.halfExtentSourceUnits ?? 60);
|
|
const thickness = Number(support.thicknessSourceUnits ?? 3);
|
|
const objFile = path.join(preparedDirectory, "scene.obj");
|
|
const mtlFile = path.join(preparedDirectory, "scene.mtl");
|
|
let obj = await readFile(objFile, "utf8");
|
|
const vertexCount = (obj.match(/^v\s+/gm) ?? []).length;
|
|
const lines = ["", "# Source-anchor navigation support", "g runewaker_source_anchor_support", "usemtl runewaker_source_anchor_support"];
|
|
let nextVertex = vertexCount + 1;
|
|
for (const sourcePoint of support.points) {
|
|
const [x, y, z] = sourcePoint.map(Number);
|
|
const worldVertices = [
|
|
[x - half, y, z - half], [x + half, y, z - half],
|
|
[x + half, y, z + half], [x - half, y, z + half],
|
|
[x - half, y - thickness, z - half], [x + half, y - thickness, z - half],
|
|
[x + half, y - thickness, z + half], [x - half, y - thickness, z + half],
|
|
];
|
|
const localVertices = worldVertices.map((value) => sourcePointToPrimaryLocal(value, placement));
|
|
lines.push(...localVertices.map((value) => "v " + value.join(" ")));
|
|
const index = Array.from({ length: 8 }, (_, offset) => nextVertex + offset);
|
|
const faces = [
|
|
[0, 3, 2], [0, 2, 1], [4, 5, 6], [4, 6, 7],
|
|
[0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5],
|
|
[2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7],
|
|
];
|
|
lines.push(...faces.map((face) => "f " + face.map((offset) => index[offset]).join(" ")));
|
|
nextVertex += 8;
|
|
}
|
|
obj += lines.join("\n") + "\n";
|
|
const mtl = await readFile(mtlFile, "utf8");
|
|
await Promise.all([
|
|
writeFile(objFile, obj, "utf8"),
|
|
writeFile(mtlFile, mtl + "\nnewmtl runewaker_source_anchor_support\nKd 0.22 0.17 0.11\n", "utf8"),
|
|
]);
|
|
}
|
|
|
|
function resourceSourceFile(resource) {
|
|
return path.join(resourceRoot, ...normalizeWdbResourcePath(resource).split("\\"));
|
|
}
|
|
|
|
async function prepareCachedResourceTextures(raw, prepared) {
|
|
const sourceTextures = path.join(raw, "textures");
|
|
const preparedTextures = path.join(prepared, "textures");
|
|
await mkdir(preparedTextures, { recursive: true });
|
|
const ddsFiles = await exists(sourceTextures)
|
|
? (await readdir(sourceTextures))
|
|
.filter((file) => file.toLowerCase().endsWith(".dds"))
|
|
.sort()
|
|
.map((file) => path.join(sourceTextures, file))
|
|
: [];
|
|
if (ddsFiles.length) {
|
|
await run(
|
|
texconv,
|
|
["-ft", "PNG", "-o", preparedTextures, "-y", "--permissive", ...ddsFiles],
|
|
"convert " + ddsFiles.length + " cached DDS 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",
|
|
);
|
|
}
|
|
|
|
async function cachedResourceAsset(resourceRecord) {
|
|
const sourceFile = resourceSourceFile(resourceRecord.resource);
|
|
if (!await exists(sourceFile)) {
|
|
throw new Error("source ROS is missing: " + projectPath(sourceFile));
|
|
}
|
|
const sourceSha256 = await sha256(sourceFile);
|
|
const converterSha256 = await sha256(blenderScript);
|
|
const cacheRoot = path.join(resourceCacheDirectory, resourceRecord.cacheKey);
|
|
const raw = path.join(cacheRoot, "raw");
|
|
const prepared = path.join(cacheRoot, "prepared");
|
|
const converted = path.join(cacheRoot, "converted");
|
|
const manifestFile = path.join(cacheRoot, "cache-manifest.json");
|
|
if (!argv.includes("--force-resource-cache") && await exists(manifestFile)) {
|
|
const cached = JSON.parse(await readFile(manifestFile, "utf8"));
|
|
const outputs = [cached.visual?.file, ...(cached.collision ?? []).map((entry) => entry.file)]
|
|
.filter(Boolean)
|
|
.map((file) => path.join(cacheRoot, file));
|
|
if (cached.source?.resource === resourceRecord.normalizedResource
|
|
&& cached.source?.sha256 === sourceSha256
|
|
&& cached.toolchain?.converterSha256 === converterSha256
|
|
&& cached.coordinateSystem?.metersPerSourceUnit === recipe.coordinateSystem.metersPerSourceUnit
|
|
&& (await Promise.all(outputs.map(exists))).every(Boolean)) {
|
|
return {
|
|
...cached,
|
|
cacheHit: true,
|
|
visualFile: path.join(cacheRoot, cached.visual.file),
|
|
collision: cached.collision.map((entry) => ({
|
|
...entry,
|
|
absoluteFile: path.join(cacheRoot, entry.file),
|
|
})),
|
|
};
|
|
}
|
|
}
|
|
await Promise.all([
|
|
mkdir(raw, { recursive: true }),
|
|
mkdir(prepared, { recursive: true }),
|
|
mkdir(converted, { recursive: true }),
|
|
]);
|
|
await run(
|
|
nativeExporter,
|
|
[resourceRoot, resourceRecord.resource.replaceAll("/", "\\"), raw],
|
|
"export cached WDB resource " + resourceRecord.resource,
|
|
);
|
|
await prepareCachedResourceTextures(raw, prepared);
|
|
await run(
|
|
blender,
|
|
[
|
|
"--background", "--factory-startup", "--python", blenderScript, "--",
|
|
path.join(prepared, "scene.obj"),
|
|
converted,
|
|
resourceRecord.cacheKey,
|
|
String(recipe.coordinateSystem.metersPerSourceUnit),
|
|
],
|
|
"convert cached WDB resource " + resourceRecord.resource,
|
|
);
|
|
const conversion = JSON.parse(await readFile(path.join(converted, "conversion-report.json"), "utf8"));
|
|
const manifest = {
|
|
schemaVersion: 1,
|
|
source: {
|
|
resource: resourceRecord.normalizedResource,
|
|
file: projectPath(sourceFile),
|
|
sha256: sourceSha256,
|
|
},
|
|
coordinateSystem: {
|
|
nativeBridge: recipe.coordinateSystem.nativeBridge,
|
|
metersPerSourceUnit: recipe.coordinateSystem.metersPerSourceUnit,
|
|
},
|
|
toolchain: {
|
|
converterSha256,
|
|
},
|
|
visual: {
|
|
file: path.relative(
|
|
cacheRoot,
|
|
path.join(converted, resourceRecord.cacheKey + "-visual.glb"),
|
|
).split(path.sep).join("/"),
|
|
triangles: conversion.visual.triangles,
|
|
meshObjects: conversion.visual.meshObjects,
|
|
bounds: conversion.visual.bounds,
|
|
},
|
|
collision: conversion.collision.map((entry) => ({
|
|
...entry,
|
|
file: path.relative(cacheRoot, path.join(converted, entry.file)).split(path.sep).join("/"),
|
|
})),
|
|
};
|
|
await writeFile(manifestFile, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
return {
|
|
...manifest,
|
|
cacheHit: false,
|
|
visualFile: path.join(cacheRoot, manifest.visual.file),
|
|
collision: manifest.collision.map((entry) => ({
|
|
...entry,
|
|
absoluteFile: path.join(cacheRoot, entry.file),
|
|
})),
|
|
};
|
|
}
|
|
|
|
async function prepareTextures() {
|
|
const sourceTextures = path.join(rawDirectory, "textures");
|
|
const preparedTextures = path.join(preparedDirectory, "textures");
|
|
await mkdir(preparedTextures, { recursive: true });
|
|
if (recipe.source.proceduralTerrainFallback) {
|
|
await Promise.all([
|
|
copyFile(path.join(rawDirectory, "scene.obj"), path.join(preparedDirectory, "scene.obj")),
|
|
copyFile(path.join(rawDirectory, "scene.mtl"), path.join(preparedDirectory, "scene.mtl")),
|
|
copyFile(path.join(rawDirectory, "export-report.json"), path.join(preparedDirectory, "export-report.json")),
|
|
]);
|
|
return;
|
|
}
|
|
const ddsFiles = (await readdir(sourceTextures))
|
|
.filter((file) => file.toLowerCase().endsWith(".dds"))
|
|
.sort()
|
|
.map((file) => path.join(sourceTextures, file));
|
|
if (!ddsFiles.length) throw new Error(recipe.title + " ROS exported without 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");
|
|
await writeFile(
|
|
path.join(preparedDirectory, "scene.mtl"),
|
|
sourceMtl.replace(/^(map_Kd\s+.+)\.dds\s*$/gim, "$1.png"),
|
|
"utf8",
|
|
);
|
|
await appendSourceAnchorSupport();
|
|
}
|
|
|
|
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",
|
|
);
|
|
}
|
|
|
|
function assemblyTranslations() {
|
|
const primary = recipe.source.primaryPlacementTranslation;
|
|
return (recipe.source.assemblyPlacementTranslations ?? [])
|
|
.filter((translation) => translation.some((value, index) => Math.abs(Number(value) - Number(primary[index])) > 0.0001))
|
|
.map((translation) => translation.map((value, index) => Number(value) - Number(primary[index])));
|
|
}
|
|
|
|
function cloneNodeTree(document, node) {
|
|
const clone = document.createNode(node.getName())
|
|
.setMatrix(node.getMatrix())
|
|
.setExtras({ ...node.getExtras() })
|
|
.setMesh(node.getMesh())
|
|
.setCamera(node.getCamera())
|
|
.setSkin(node.getSkin())
|
|
.setWeights([...node.getWeights()]);
|
|
for (const child of node.listChildren()) clone.addChild(cloneNodeTree(document, child));
|
|
return clone;
|
|
}
|
|
|
|
function groupCollisionRecords(records) {
|
|
const groups = [];
|
|
let current = [];
|
|
let triangles = 0;
|
|
for (const record of records) {
|
|
if (current.length && triangles + record.triangles > collisionChunkTriangleTarget) {
|
|
groups.push({ records: current, triangles });
|
|
current = [];
|
|
triangles = 0;
|
|
}
|
|
current.push(record);
|
|
triangles += record.triangles;
|
|
}
|
|
if (current.length) groups.push({ records: current, triangles });
|
|
return groups;
|
|
}
|
|
|
|
async function assembleAllWdbPlacements() {
|
|
const wdbReport = JSON.parse(await readFile(path.join(rawDirectory, "wdb-report.json"), "utf8"));
|
|
const plan = buildWdbPlacementPlan(wdbReport, recipe);
|
|
const primaryResource = normalizeWdbResourcePath(plan.primaryPlacement.resource);
|
|
const primaryConversion = JSON.parse(
|
|
await readFile(path.join(convertedDirectory, "conversion-report.json"), "utf8"),
|
|
);
|
|
const assets = new Map();
|
|
assets.set(primaryResource, {
|
|
cacheHit: true,
|
|
cacheKind: "primary-conversion",
|
|
visualFile: path.join(convertedDirectory, slug + "-visual.glb"),
|
|
visual: primaryConversion.visual,
|
|
collision: primaryConversion.collision.map((entry) => ({
|
|
...entry,
|
|
absoluteFile: path.join(convertedDirectory, entry.file),
|
|
})),
|
|
});
|
|
const failures = [];
|
|
for (const resource of plan.resources) {
|
|
if (resource.normalizedResource === primaryResource) continue;
|
|
try {
|
|
assets.set(resource.normalizedResource, await cachedResourceAsset(resource));
|
|
} catch (error) {
|
|
failures.push({
|
|
resource: resource.resource,
|
|
normalizedResource: resource.normalizedResource,
|
|
placementCount: resource.placementCount,
|
|
reason: error.message,
|
|
});
|
|
}
|
|
}
|
|
const unavailable = new Set(failures.map((failure) => failure.normalizedResource));
|
|
for (const resource of plan.resources) {
|
|
const asset = assets.get(resource.normalizedResource);
|
|
resource.assetStatus = asset ? "available" : "unsupported";
|
|
resource.cacheHit = asset?.cacheHit ?? false;
|
|
resource.cacheKind = asset?.cacheKind ?? "normalized-path-cache-with-source-hash-validation";
|
|
if (!asset) resource.blocker = failures.find((entry) => (
|
|
entry.normalizedResource === resource.normalizedResource
|
|
))?.reason;
|
|
}
|
|
for (const placement of [...plan.staticPlacements, ...plan.dynamicPlacements]) {
|
|
placement.assetStatus = unavailable.has(placement.normalizedResource) ? "unsupported" : "available";
|
|
placement.assetId = "wdb-resource:" + placement.resourceCacheKey;
|
|
}
|
|
|
|
const staticPlacements = plan.staticPlacements.filter((placement) => (
|
|
placement.assetStatus === "available"
|
|
));
|
|
if (!staticPlacements.length) throw new Error("WDB assembly produced no available static placements.");
|
|
const visualRecords = staticPlacements.map((placement) => ({
|
|
placement,
|
|
assetFile: assets.get(placement.normalizedResource).visualFile,
|
|
}));
|
|
const assembledVisualFile = path.join(convertedDirectory, slug + "-visual.wdb-assembled.glb");
|
|
const visualAssembly = await assembleInstancedGlb(visualRecords, assembledVisualFile);
|
|
await copyFile(assembledVisualFile, path.join(convertedDirectory, slug + "-visual.glb"));
|
|
|
|
const collisionRecords = staticPlacements.flatMap((placement) => (
|
|
assets.get(placement.normalizedResource).collision.map((collision) => ({
|
|
placement,
|
|
assetFile: collision.absoluteFile,
|
|
triangles: Number(collision.triangles ?? collision.sourceTriangleEstimate ?? 0),
|
|
}))
|
|
)).sort((left, right) => (
|
|
left.placement.id.localeCompare(right.placement.id)
|
|
|| left.assetFile.localeCompare(right.assetFile)
|
|
));
|
|
const collision = [];
|
|
for (const [index, group] of groupCollisionRecords(collisionRecords).entries()) {
|
|
const file = slug + "-wdb-collision-" + String(index).padStart(3, "0") + ".glb";
|
|
await assembleInstancedGlb(group.records, path.join(convertedDirectory, file));
|
|
collision.push({
|
|
file,
|
|
triangles: group.triangles,
|
|
sourceTriangleEstimate: group.triangles,
|
|
placementCount: group.records.length,
|
|
assembledFromWdb: true,
|
|
});
|
|
}
|
|
|
|
const dynamicDirectory = path.join(convertedDirectory, "dynamic");
|
|
const dynamicAssets = [];
|
|
const dynamicResources = plan.resources.filter((resource) => (
|
|
resource.dynamicPlacementCount > 0 && resource.assetStatus === "available"
|
|
));
|
|
if (dynamicResources.length) await mkdir(dynamicDirectory, { recursive: true });
|
|
for (const resource of dynamicResources) {
|
|
const asset = assets.get(resource.normalizedResource);
|
|
const visualName = resource.cacheKey + "-visual.glb";
|
|
await copyFile(asset.visualFile, path.join(dynamicDirectory, visualName));
|
|
dynamicAssets.push({
|
|
role: "dynamic-visual",
|
|
file: "dynamic/" + visualName,
|
|
assetId: "wdb-resource:" + resource.cacheKey,
|
|
resource: resource.resource,
|
|
});
|
|
for (const [index, sourceCollision] of asset.collision.entries()) {
|
|
const collisionName = resource.cacheKey + "-collision-" + String(index).padStart(3, "0") + ".glb";
|
|
await copyFile(sourceCollision.absoluteFile, path.join(dynamicDirectory, collisionName));
|
|
dynamicAssets.push({
|
|
role: "dynamic-collision",
|
|
file: "dynamic/" + collisionName,
|
|
assetId: "wdb-resource:" + resource.cacheKey,
|
|
resource: resource.resource,
|
|
triangles: Number(sourceCollision.triangles ?? 0),
|
|
});
|
|
}
|
|
}
|
|
|
|
plan.resourceFailures = failures;
|
|
plan.buildSummary = {
|
|
staticPlacements: staticPlacements.length,
|
|
dynamicPlacements: plan.dynamicPlacements.filter((placement) => (
|
|
placement.assetStatus === "available"
|
|
)).length,
|
|
unavailablePlacements: [...plan.staticPlacements, ...plan.dynamicPlacements]
|
|
.filter((placement) => placement.assetStatus === "unsupported").length,
|
|
uniqueResources: plan.resources.length,
|
|
availableResources: assets.size,
|
|
unavailableResources: failures.length,
|
|
resourceCacheHits: [...assets.values()].filter((asset) => (
|
|
asset.cacheHit && asset.cacheKind !== "primary-conversion"
|
|
)).length,
|
|
};
|
|
const placementManifestFile = path.join(convertedDirectory, "wdb-placement-manifest.json");
|
|
await writeFile(placementManifestFile, JSON.stringify(plan, null, 2) + "\n", "utf8");
|
|
|
|
const visualTriangles = staticPlacements.reduce((total, placement) => (
|
|
total + Number(assets.get(placement.normalizedResource).visual.triangles ?? 0)
|
|
), 0);
|
|
const visualMeshObjects = staticPlacements.reduce((total, placement) => (
|
|
total + Number(assets.get(placement.normalizedResource).visual.meshObjects ?? 0)
|
|
), 0);
|
|
const conversion = {
|
|
schemaVersion: 2,
|
|
dungeonId: slug,
|
|
status: failures.length || plan.reviewRequiredPlacements.length
|
|
? "review-required"
|
|
: "source-packaged",
|
|
environmentMode: "runewaker-wdb-assembly",
|
|
source: {
|
|
wdb: recipe.source.wdb,
|
|
primaryModel: recipe.source.model,
|
|
descriptorCount: plan.sourceDescriptorCount,
|
|
placementManifest: "wdb-placement-manifest.json",
|
|
},
|
|
coordinateTransform: plan.coordinateTransform,
|
|
visual: {
|
|
file: slug + "-visual.glb",
|
|
meshObjects: visualMeshObjects,
|
|
triangles: visualTriangles,
|
|
sourceTriangles: visualTriangles,
|
|
bounds: visualAssembly.bounds,
|
|
assembledWdbPlacements: staticPlacements.length,
|
|
uniqueResources: new Set(staticPlacements.map((placement) => placement.normalizedResource)).size,
|
|
},
|
|
collision,
|
|
collisionPolicy: {
|
|
kind: "wdb-static-placement-trimesh",
|
|
maximumTrianglesPerChunk: 100_000,
|
|
workingChunkTarget: collisionChunkTriangleTarget,
|
|
excludesReviewedDynamicPlacements: true,
|
|
includesUnreviewedDynamicCandidates: true,
|
|
},
|
|
dynamicAssets,
|
|
preview: slug + "-preview.png",
|
|
editableSource: slug + ".source.blend",
|
|
wdbAssembly: plan.buildSummary,
|
|
warnings: [
|
|
"The preview and editable Blender source cover the primary ROS only; the shipping GLB is assembled from the WDB manifest.",
|
|
"Name-based dynamic candidates remain static until a reviewed dynamic-placement rule supplies source evidence.",
|
|
...failures.map((failure) => failure.resource + ": " + failure.reason),
|
|
],
|
|
};
|
|
await writeFile(
|
|
path.join(convertedDirectory, "conversion-report.json"),
|
|
JSON.stringify(conversion, null, 2) + "\n",
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
async function assembleRepeatedPlacements() {
|
|
const translations = fullWdbAssembly ? [] : assemblyTranslations();
|
|
if (!translations.length) return;
|
|
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
|
const visualFile = path.join(convertedDirectory, slug + "-visual.glb");
|
|
const visualDocument = await io.read(visualFile);
|
|
const visualScene = visualDocument.getRoot().getDefaultScene() ?? visualDocument.getRoot().listScenes()[0];
|
|
const visualRoots = visualScene.listChildren();
|
|
const meters = recipe.coordinateSystem.metersPerSourceUnit;
|
|
for (const [index, sourceDelta] of translations.entries()) {
|
|
const placement = visualDocument.createNode("WDB placement " + String(index + 2));
|
|
placement.setTranslation([sourceDelta[0] * meters, sourceDelta[1] * meters, -sourceDelta[2] * meters]);
|
|
placement.setExtras({ sourceDelta, assembledFromWdb: true });
|
|
for (const root of visualRoots) placement.addChild(cloneNodeTree(visualDocument, root));
|
|
visualScene.addChild(placement);
|
|
}
|
|
await io.write(visualFile, visualDocument);
|
|
|
|
const reportFile = path.join(convertedDirectory, "conversion-report.json");
|
|
const report = JSON.parse(await readFile(reportFile, "utf8"));
|
|
const originalCollision = [...report.collision];
|
|
const addedCollision = [];
|
|
for (const [placementIndex, sourceDelta] of translations.entries()) {
|
|
const translation = [sourceDelta[0] * meters, sourceDelta[1] * meters, -sourceDelta[2] * meters];
|
|
for (const [chunkIndex, chunk] of originalCollision.entries()) {
|
|
const sourceFile = path.join(convertedDirectory, chunk.file);
|
|
const document = await io.read(sourceFile);
|
|
const scene = document.getRoot().getDefaultScene() ?? document.getRoot().listScenes()[0];
|
|
const container = document.createNode("WDB collision placement " + String(placementIndex + 2));
|
|
container.setTranslation(translation);
|
|
for (const root of scene.listChildren()) container.addChild(root);
|
|
scene.addChild(container);
|
|
const file = slug + "-collision-placement-" + String(placementIndex + 2).padStart(3, "0")
|
|
+ "-" + String(chunkIndex).padStart(3, "0") + ".glb";
|
|
await io.write(path.join(convertedDirectory, file), document);
|
|
addedCollision.push({ ...chunk, file, wdbSourceDelta: sourceDelta });
|
|
}
|
|
}
|
|
const originalBounds = report.visual.bounds;
|
|
const translatedBounds = [[0, 0, 0], ...translations.map((value) => [value[0] * meters, value[1] * meters, -value[2] * meters])];
|
|
report.visual.bounds = {
|
|
min: originalBounds.min.map((value, axis) => Math.min(...translatedBounds.map((delta) => value + delta[axis]))),
|
|
max: originalBounds.max.map((value, axis) => Math.max(...translatedBounds.map((delta) => value + delta[axis]))),
|
|
};
|
|
report.visual.meshObjects *= translations.length + 1;
|
|
report.visual.triangles *= translations.length + 1;
|
|
report.visual.sourceTriangles *= translations.length + 1;
|
|
report.visual.assembledWdbPlacements = translations.length + 1;
|
|
report.collision.push(...addedCollision);
|
|
report.warnings.push("Repeated identity-transform WDB placements were assembled into this package.");
|
|
await writeFile(reportFile, JSON.stringify(report, null, 2) + "\n", "utf8");
|
|
}
|
|
|
|
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 meshopt",
|
|
);
|
|
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",
|
|
}, null, 2) + "\n", "utf8");
|
|
}
|
|
|
|
async function bakeNavigation() {
|
|
const conversion = JSON.parse(await readFile(path.join(convertedDirectory, "conversion-report.json"), "utf8"));
|
|
const baseCollision = conversion.collision.filter((chunk) => !chunk.wdbSourceDelta);
|
|
const collisionAssets = baseCollision.map((chunk, index) => ({
|
|
id: "collision-" + String(index).padStart(3, "0"),
|
|
role: "collision",
|
|
path: projectPath(path.join(convertedDirectory, chunk.file)),
|
|
}));
|
|
const navigationFile = path.join(convertedDirectory, slug + "-navigation.glb");
|
|
const reportFile = path.join(convertedDirectory, "recast-report.json");
|
|
await bakeNavigationFromCollisionAssets({
|
|
slug,
|
|
collisionAssets,
|
|
settings: recipe.navigation,
|
|
output: navigationFile,
|
|
reportFile,
|
|
source: {
|
|
kind: "runewaker-primary-ros",
|
|
recipe: projectPath(recipeFile),
|
|
collisionChunkCount: collisionAssets.length,
|
|
},
|
|
});
|
|
const translations = assemblyTranslations();
|
|
if (translations.length) {
|
|
const io = new NodeIO();
|
|
const document = await io.read(navigationFile);
|
|
const scene = document.getRoot().getDefaultScene() ?? document.getRoot().listScenes()[0];
|
|
const roots = scene.listChildren();
|
|
const meters = recipe.coordinateSystem.metersPerSourceUnit;
|
|
for (const [index, sourceDelta] of translations.entries()) {
|
|
const placement = document.createNode("WDB navigation placement " + String(index + 2));
|
|
placement.setTranslation([sourceDelta[0] * meters, sourceDelta[1] * meters, -sourceDelta[2] * meters]);
|
|
for (const root of roots) placement.addChild(cloneNodeTree(document, root));
|
|
scene.addChild(placement);
|
|
}
|
|
await io.write(navigationFile, document);
|
|
const report = JSON.parse(await readFile(reportFile, "utf8"));
|
|
const originalBounds = report.geometry.bounds;
|
|
const translated = [[0, 0, 0], ...translations.map((value) => [value[0] * meters, value[1] * meters, -value[2] * meters])];
|
|
report.geometry.bounds = [
|
|
originalBounds[0].map((value, axis) => Math.min(...translated.map((delta) => value + delta[axis]))),
|
|
originalBounds[1].map((value, axis) => Math.max(...translated.map((delta) => value + delta[axis]))),
|
|
];
|
|
report.geometry.vertices *= translations.length + 1;
|
|
report.geometry.triangles *= translations.length + 1;
|
|
report.source.assembledNavigationPlacements = translations.length + 1;
|
|
await writeFile(reportFile, JSON.stringify(report, null, 2) + "\n", "utf8");
|
|
}
|
|
await appendSourceAnchorNavigation(navigationFile, reportFile);
|
|
}
|
|
|
|
async function appendSourceAnchorNavigation(navigationFile, reportFile) {
|
|
const support = recipe.source.sourceAnchorSupport;
|
|
if (!support || fullWdbAssembly) return;
|
|
const { placement } = await primaryPlacement();
|
|
const half = Number(support.halfExtentSourceUnits ?? 60);
|
|
const meters = recipe.coordinateSystem.metersPerSourceUnit;
|
|
const positions = [];
|
|
for (const sourcePoint of support.points) {
|
|
const [x, y, z] = sourcePoint.map(Number);
|
|
const corners = [
|
|
[x - half, y, z - half], [x + half, y, z - half],
|
|
[x + half, y, z + half], [x - half, y, z + half],
|
|
].map((value) => {
|
|
const local = sourcePointToPrimaryLocal(value, placement);
|
|
return [local[0] * meters, local[1] * meters, -local[2] * meters];
|
|
});
|
|
for (const index of [0, 3, 2, 0, 2, 1]) positions.push(...corners[index]);
|
|
}
|
|
const io = new NodeIO();
|
|
const document = await io.read(navigationFile);
|
|
const buffer = document.getRoot().listBuffers()[0] ?? document.createBuffer("source-anchor-support-buffer");
|
|
const accessor = document.createAccessor("source-anchor-support-position", buffer)
|
|
.setType("VEC3")
|
|
.setArray(new Float32Array(positions));
|
|
const primitive = document.createPrimitive().setAttribute("POSITION", accessor);
|
|
const mesh = document.createMesh("source-anchor-support-navigation").addPrimitive(primitive);
|
|
const node = document.createNode("source-anchor-support-navigation").setMesh(mesh);
|
|
const scene = document.getRoot().getDefaultScene() ?? document.getRoot().listScenes()[0];
|
|
scene.addChild(node);
|
|
await io.write(navigationFile, document);
|
|
const report = JSON.parse(await readFile(reportFile, "utf8"));
|
|
for (let offset = 0; offset < positions.length; offset += 3) {
|
|
for (let axis = 0; axis < 3; axis += 1) {
|
|
report.geometry.bounds[0][axis] = Math.min(report.geometry.bounds[0][axis], positions[offset + axis]);
|
|
report.geometry.bounds[1][axis] = Math.max(report.geometry.bounds[1][axis], positions[offset + axis]);
|
|
}
|
|
}
|
|
report.geometry.vertices += positions.length / 3;
|
|
report.geometry.triangles += positions.length / 9;
|
|
report.source.sourceAnchorSupport = {
|
|
pointCount: support.points.length,
|
|
halfExtentSourceUnits: half,
|
|
provenance: support.evidence,
|
|
};
|
|
await writeFile(reportFile, JSON.stringify(report, null, 2) + "\n", "utf8");
|
|
}
|
|
|
|
function normalizedResourcePath(value) {
|
|
const separator = String.fromCharCode(92);
|
|
let normalized = value.replaceAll("/", separator).toLowerCase();
|
|
while (normalized.startsWith(separator)) normalized = normalized.slice(1);
|
|
return normalized;
|
|
/* Superseded by the character-code implementation above.
|
|
return value.replaceAll("/", "\\").replace(/^\\\\+/, "").toLowerCase();
|
|
*/
|
|
}
|
|
|
|
async function primaryPlacement() {
|
|
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) {
|
|
throw new Error("Expected at least one WDB placement for " + recipe.source.model + ".");
|
|
}
|
|
let placement = matches[0];
|
|
if (matches.length > 1) {
|
|
const expected = recipe.source.primaryPlacementTranslation;
|
|
if (!Array.isArray(expected) || expected.length !== 3) {
|
|
throw new Error("Multiple WDB placements require source.primaryPlacementTranslation for " + recipe.source.model + ".");
|
|
}
|
|
placement = matches.find((descriptor) => descriptor.translation.every((value, index) => (
|
|
Math.abs(Number(value) - Number(expected[index])) <= 0.0001
|
|
)));
|
|
if (!placement) throw new Error("Configured primary WDB placement was not found for " + recipe.source.model + ".");
|
|
}
|
|
return { report, placement };
|
|
}
|
|
|
|
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(recipe.title + " 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(recipe.title + " 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 dungeon_config entry " + recipe.source.dungeonConfigId + ".");
|
|
let playerSpawn;
|
|
if (recipe.source.entryCoordinates) {
|
|
const entry = recipe.source.entryCoordinates;
|
|
if (!Array.isArray(entry.position) || entry.position.length !== 3 || entry.position.some((value) => !Number.isFinite(Number(value)))) {
|
|
throw new Error("entryCoordinates.position must contain exactly three finite numbers.");
|
|
}
|
|
playerSpawn = {
|
|
x: Number(entry.position[0]), y: Number(entry.position[1]), z: Number(entry.position[2]),
|
|
dir: Number(entry.direction ?? 0),
|
|
directionUnitsPerTurn: recipe.coordinateSystem.directionUnitsPerTurn,
|
|
source: "environment recipe entryCoordinates",
|
|
reviewRequired: entry.reviewRequired === true,
|
|
};
|
|
} else {
|
|
const lua = await readFile(path.join(sourceRoot, recipe.source.entryLua), "utf8");
|
|
const prefix = recipe.source.entryConstantPrefix;
|
|
playerSpawn = {
|
|
x: luaNumber(lua, prefix + "_X"), y: luaNumber(lua, prefix + "_Y"),
|
|
z: luaNumber(lua, prefix + "_Z"), dir: luaNumber(lua, prefix + "_DIR"),
|
|
directionUnitsPerTurn: recipe.coordinateSystem.directionUnitsPerTurn,
|
|
source: recipe.source.entryLua + ":" + prefix + "_*",
|
|
reviewRequired: false,
|
|
};
|
|
}
|
|
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) })),
|
|
...(conversion.dynamicAssets ?? []).map((asset) => ({
|
|
role: asset.role,
|
|
source: path.join(convertedDirectory, asset.file),
|
|
target: path.join(shippingDirectory, asset.file),
|
|
assetId: asset.assetId,
|
|
resource: asset.resource,
|
|
})),
|
|
{ role: "navigation", source: path.join(convertedDirectory, slug + "-navigation.glb"), target: path.join(shippingDirectory, slug + "-navigation.glb") },
|
|
];
|
|
for (const entry of files) {
|
|
await mkdir(path.dirname(entry.target), { recursive: true });
|
|
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"));
|
|
if (fullWdbAssembly) {
|
|
await copyFile(
|
|
path.join(convertedDirectory, "wdb-placement-manifest.json"),
|
|
path.join(shippingDirectory, "wdb-placement-manifest.json"),
|
|
);
|
|
}
|
|
const server = await sourceMetadata();
|
|
const { report, placement } = await primaryPlacement();
|
|
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: server.playerSpawn.source,
|
|
reviewRequired: server.playerSpawn.reviewRequired,
|
|
};
|
|
if (entrance.navmeshDistance > 75) {
|
|
throw new Error("Authoritative entrance is " + entrance.navmeshDistance + "m from navigation.");
|
|
}
|
|
const entranceProjectionReview = entrance.navmeshDistance > 2;
|
|
entrance.reviewRequired = entrance.reviewRequired || entranceProjectionReview;
|
|
const sourceWdb = path.join(sourceRoot, recipe.source.wdb);
|
|
const sourceRos = path.join(resourceRoot, recipe.source.model);
|
|
const metadata = {
|
|
schemaVersion: 2,
|
|
dungeonId: slug,
|
|
title: recipe.title,
|
|
status: entrance.reviewRequired ? "review-required" : "source-packaged",
|
|
generatedAt: new Date().toISOString(),
|
|
source: {
|
|
recipe: projectPath(recipeFile),
|
|
wdb: recipe.source.wdb,
|
|
wdbSha256: await sha256(sourceWdb),
|
|
model: recipe.source.model,
|
|
modelSha256: await sha256(sourceRos),
|
|
...(recipe.source.proceduralTerrainFallback ? {
|
|
proceduralTerrainFallback: recipe.source.proceduralTerrainFallback,
|
|
} : {}),
|
|
server,
|
|
wdbReport: { descriptorCount: report.descriptorCount, file: "wdb-report.json", primaryPlacement: placement },
|
|
...(fullWdbAssembly ? {
|
|
wdbAssembly: {
|
|
mode: "all-ros",
|
|
placementManifest: "wdb-placement-manifest.json",
|
|
...conversion.wdbAssembly,
|
|
},
|
|
} : {}),
|
|
},
|
|
coordinateSystem: recipe.coordinateSystem,
|
|
entrance,
|
|
assets: await Promise.all(files.map(async (entry) => ({
|
|
role: entry.role,
|
|
fileName: path.basename(entry.target),
|
|
relativeFile: path.relative(shippingDirectory, entry.target).split(path.sep).join("/"),
|
|
...(entry.assetId ? { assetId: entry.assetId, sourceResource: entry.resource } : {}),
|
|
size: (await stat(entry.target)).size,
|
|
sha256: await sha256(entry.target),
|
|
}))),
|
|
review: recipe.review,
|
|
exceptions: [
|
|
...recipe.exceptions,
|
|
...(entranceProjectionReview ? [
|
|
"The authoritative entrance was projected " + entrance.navmeshDistance
|
|
+ "m to the packaged primary navigation and requires an in-game entrance review.",
|
|
] : []),
|
|
],
|
|
};
|
|
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");
|
|
}
|
|
|
|
await requirePath(resourceRoot, "Set RUNEWAKER_ROOT to the preserved RuneWaker backup root.");
|
|
await requirePath(blender, "Set BLENDER_BIN to a Blender 5 executable.");
|
|
await requirePath(texconv, "Install Microsoft DirectXTex texconv or set TEXCONV_BIN.");
|
|
await requirePath(gltfTransform, "Run npm install in HealerMan.");
|
|
await buildNativeExporterIfNeeded();
|
|
await exportSource();
|
|
await prepareTextures();
|
|
await convertEnvironment();
|
|
if (fullWdbAssembly) await assembleAllWdbPlacements();
|
|
else await assembleRepeatedPlacements();
|
|
await optimizeVisual();
|
|
await bakeNavigation();
|
|
await stageShippingPackage();
|
|
console.log("\n[runewaker-environment] package: " + projectPath(shippingDirectory));
|