forked from phenom/healer-man
250 lines
12 KiB
JavaScript
250 lines
12 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { Document, NodeIO } from "@gltf-transform/core";
|
|
import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs";
|
|
import { extractNavigationComponent } from "./extract-navigation-component.mjs";
|
|
|
|
const projectRoot = path.resolve(import.meta.dirname, "../..");
|
|
const sourceRoot = path.resolve(projectRoot, "../LadiksMPQEditor/common-2.mpq");
|
|
const outputRoot = path.join(projectRoot, "public/assets/game/arenas");
|
|
|
|
const ARENAS = Object.freeze([
|
|
{ slug: "nagrand-arena", title: "Nagrand Arena", root: "World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01.wmo", teamSpawns: [[-43.41, 13.4, -42.04], [42.03, 14.5, -44.44]] },
|
|
{ slug: "blades-edge-arena", title: "Blade's Edge Arena", root: "World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium.wmo", teamSpawns: [[29.2, 50.7, -51.49], [31.13, 54.4, 49.51]] },
|
|
{ slug: "ruins-of-lordaeron", title: "Ruins of Lordaeron", root: "World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena.wmo", teamSpawns: [[-8.68, 30.1, -73.96], [-5.08, 27.6, 57.28]] },
|
|
{ slug: "dalaran-sewers", title: "Dalaran Sewers", root: "World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena.wmo", teamSpawns: [[-12.41, 20.1, -63.05], [21.79, 20.2, 25.15]] },
|
|
{ slug: "ring-of-valor", title: "Ring of Valor", root: "World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena.wmo", teamSpawns: [[0, 27, 25], [0, 27, -25]] },
|
|
]);
|
|
|
|
function tag(buffer, offset) {
|
|
return buffer.toString("ascii", offset, offset + 4).split("").reverse().join("");
|
|
}
|
|
|
|
export function wmoChunks(buffer, start = 0, end = buffer.length) {
|
|
const result = new Map();
|
|
let offset = start;
|
|
while (offset + 8 <= end) {
|
|
const id = tag(buffer, offset);
|
|
const size = buffer.readUInt32LE(offset + 4);
|
|
const payloadStart = offset + 8;
|
|
let payloadEnd = payloadStart + size;
|
|
// Some Legion-era custom group files report the original uncompressed MOGP
|
|
// container length even though trailing optional chunks were stripped.
|
|
// MOGP is the terminal top-level container, so the physical EOF is safe.
|
|
if (id === "MOGP" && payloadEnd > end) payloadEnd = end;
|
|
if (payloadEnd > end) throw new Error(`Invalid ${id} chunk at ${offset}: ${size} bytes exceeds the WMO boundary.`);
|
|
result.set(id, buffer.subarray(payloadStart, payloadEnd));
|
|
offset = payloadEnd;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function parseRootGroupCount(buffer) {
|
|
const root = wmoChunks(buffer);
|
|
const header = root.get("MOHD");
|
|
if (!header || header.length < 8) throw new Error("WMO root is missing MOHD.");
|
|
return header.readUInt32LE(4);
|
|
}
|
|
|
|
export function parseWmoGroup(buffer, name) {
|
|
const top = wmoChunks(buffer);
|
|
const group = top.get("MOGP");
|
|
if (!group || group.length <= 68) throw new Error(`${name}: missing MOGP geometry.`);
|
|
const nested = wmoChunks(group, 68, group.length);
|
|
const vertexBytes = nested.get("MOVT");
|
|
const normalBytes = nested.get("MONR");
|
|
const indexBytes = nested.get("MOVI");
|
|
const materialBytes = nested.get("MOPY");
|
|
if (!vertexBytes || !indexBytes) throw new Error(`${name}: missing MOVT or MOVI.`);
|
|
const positions = new Float32Array(vertexBytes.length / 4);
|
|
for (let offset = 0; offset < vertexBytes.length; offset += 4) positions[offset / 4] = vertexBytes.readFloatLE(offset);
|
|
const sourceNormals = normalBytes ? new Float32Array(normalBytes.length / 4) : null;
|
|
if (normalBytes) for (let offset = 0; offset < normalBytes.length; offset += 4) sourceNormals[offset / 4] = normalBytes.readFloatLE(offset);
|
|
const indices = new Uint32Array(indexBytes.length / 2);
|
|
for (let offset = 0; offset < indexBytes.length; offset += 2) indices[offset / 2] = indexBytes.readUInt16LE(offset);
|
|
const transformed = new Float32Array(positions.length);
|
|
const normals = sourceNormals?.length === positions.length ? new Float32Array(positions.length) : null;
|
|
for (let index = 0; index < positions.length; index += 3) {
|
|
transformed[index] = -positions[index];
|
|
transformed[index + 1] = positions[index + 2];
|
|
transformed[index + 2] = positions[index + 1];
|
|
if (normals) {
|
|
normals[index] = -sourceNormals[index];
|
|
normals[index + 1] = sourceNormals[index + 2];
|
|
normals[index + 2] = sourceNormals[index + 1];
|
|
}
|
|
}
|
|
const flagCounts = {};
|
|
const collisionIndexValues = [];
|
|
if (materialBytes) {
|
|
for (let offset = 0; offset < materialBytes.length; offset += 2) {
|
|
const flag = materialBytes[offset];
|
|
flagCounts[flag] = (flagCounts[flag] ?? 0) + 1;
|
|
if (flag & 0x04) {
|
|
const triangleOffset = (offset / 2) * 3;
|
|
collisionIndexValues.push(indices[triangleOffset], indices[triangleOffset + 1], indices[triangleOffset + 2]);
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
name, positions: transformed, normals, indices, flagCounts,
|
|
// Retain every structural WMO triangle for authoritative collision. MOPY
|
|
// bit meanings vary by client generation and several arena floors use flag
|
|
// combinations that would otherwise be omitted; Recast filters slopes.
|
|
collisionIndices: indices,
|
|
flaggedCollisionIndices: materialBytes ? new Uint32Array(collisionIndexValues) : indices,
|
|
};
|
|
}
|
|
|
|
export function geometryBounds(groups) {
|
|
const min = [Infinity, Infinity, Infinity];
|
|
const max = [-Infinity, -Infinity, -Infinity];
|
|
for (const group of groups) {
|
|
for (let offset = 0; offset < group.positions.length; offset += 3) {
|
|
for (let axis = 0; axis < 3; axis += 1) {
|
|
min[axis] = Math.min(min[axis], group.positions[offset + axis]);
|
|
max[axis] = Math.max(max[axis], group.positions[offset + axis]);
|
|
}
|
|
}
|
|
}
|
|
return { min, max };
|
|
}
|
|
|
|
export function rebaseWmoGroups(groups) {
|
|
const sourceBounds = geometryBounds(groups);
|
|
const center = [
|
|
(sourceBounds.min[0] + sourceBounds.max[0]) / 2,
|
|
sourceBounds.min[1],
|
|
(sourceBounds.min[2] + sourceBounds.max[2]) / 2,
|
|
];
|
|
for (const group of groups) {
|
|
for (let offset = 0; offset < group.positions.length; offset += 3) {
|
|
group.positions[offset] -= center[0];
|
|
group.positions[offset + 1] -= center[1];
|
|
group.positions[offset + 2] -= center[2];
|
|
}
|
|
}
|
|
return { sourceBounds, origin: center, bounds: geometryBounds(groups) };
|
|
}
|
|
|
|
export async function writeWmoGeometry(file, groups, visual) {
|
|
const document = new Document();
|
|
const buffer = document.createBuffer("arena-buffer");
|
|
const scene = document.createScene("arena");
|
|
const colors = [
|
|
[0.37, 0.32, 0.22, 1], [0.29, 0.34, 0.3, 1], [0.42, 0.4, 0.34, 1],
|
|
[0.31, 0.27, 0.24, 1], [0.34, 0.37, 0.42, 1],
|
|
];
|
|
for (const [index, group] of groups.entries()) {
|
|
const groupIndices = visual ? group.indices : group.collisionIndices;
|
|
if (!groupIndices.length) continue;
|
|
const position = document.createAccessor(`${group.name}-position`, buffer).setType("VEC3").setArray(group.positions);
|
|
const indices = document.createAccessor(`${group.name}-indices`, buffer).setType("SCALAR").setArray(groupIndices);
|
|
const primitive = document.createPrimitive().setAttribute("POSITION", position).setIndices(indices);
|
|
if (visual && group.normals) {
|
|
primitive.setAttribute("NORMAL", document.createAccessor(`${group.name}-normal`, buffer).setType("VEC3").setArray(group.normals));
|
|
}
|
|
if (visual) {
|
|
primitive.setMaterial(document.createMaterial(`${group.name}-stone`)
|
|
.setBaseColorFactor(colors[index % colors.length])
|
|
.setRoughnessFactor(0.84)
|
|
.setMetallicFactor(0.02)
|
|
.setDoubleSided(true));
|
|
}
|
|
const mesh = document.createMesh(group.name).addPrimitive(primitive);
|
|
scene.addChild(document.createNode(group.name).setMesh(mesh));
|
|
}
|
|
await new NodeIO().write(file, document);
|
|
}
|
|
|
|
async function sha256(file) {
|
|
const buffer = await readFile(file);
|
|
return createHash("sha256").update(buffer).digest("hex");
|
|
}
|
|
|
|
async function buildArena(definition) {
|
|
const rootFile = path.join(sourceRoot, ...definition.root.split("/"));
|
|
const root = await readFile(rootFile);
|
|
const groupCount = parseRootGroupCount(root);
|
|
const extension = path.extname(rootFile);
|
|
const base = rootFile.slice(0, -extension.length);
|
|
const groups = [];
|
|
const sourceFiles = [rootFile];
|
|
for (let index = 0; index < groupCount; index += 1) {
|
|
const groupFile = `${base}_${String(index).padStart(3, "0")}${extension}`;
|
|
const data = await readFile(groupFile);
|
|
sourceFiles.push(groupFile);
|
|
groups.push(parseWmoGroup(data, `group-${String(index).padStart(3, "0")}`));
|
|
}
|
|
const transform = rebaseWmoGroups(groups);
|
|
const outputDirectory = path.join(outputRoot, definition.slug);
|
|
await mkdir(outputDirectory, { recursive: true });
|
|
const visualFile = path.join(outputDirectory, "visual.glb");
|
|
const collisionFile = path.join(outputDirectory, "collision.glb");
|
|
const navigationFile = path.join(outputDirectory, "navigation.glb");
|
|
const rawNavigationFile = path.join(outputDirectory, "navigation-raw.glb");
|
|
const recastReport = path.join(outputDirectory, "recast-report.json");
|
|
await writeWmoGeometry(visualFile, groups, true);
|
|
await writeWmoGeometry(collisionFile, groups, false);
|
|
const triangles = groups.reduce((sum, group) => sum + group.indices.length / 3, 0);
|
|
const collisionTriangles = groups.reduce((sum, group) => sum + group.collisionIndices.length / 3, 0);
|
|
if (collisionTriangles > 100000) throw new Error(`${definition.slug}: ${collisionTriangles} collision triangles exceed the rated limit.`);
|
|
const relativeCollision = path.relative(projectRoot, collisionFile).replace(/\\/g, "/");
|
|
const navigation = await bakeNavigationFromCollisionAssets({
|
|
slug: definition.slug,
|
|
collisionAssets: [{ id: "collision", role: "collision", path: relativeCollision }],
|
|
settings: { cellSize: 0.2, cellHeight: 0.1, agentHeight: 1.8, agentRadius: 0.36, agentMaxClimb: 0.5, agentMaxSlope: 50 },
|
|
output: rawNavigationFile,
|
|
reportFile: recastReport,
|
|
source: { kind: "direct-wmo-parser", files: sourceFiles.map((file) => path.relative(projectRoot, file).replace(/\\/g, "/")) },
|
|
});
|
|
const connectedNavigation = await extractNavigationComponent({
|
|
source: rawNavigationFile,
|
|
output: navigationFile,
|
|
teamSpawns: definition.teamSpawns,
|
|
});
|
|
await unlink(rawNavigationFile);
|
|
const assets = await Promise.all([
|
|
["visual", visualFile], ["collision", collisionFile], ["navigation", navigationFile],
|
|
].map(async ([role, file]) => ({ role, file: path.basename(file), bytes: (await readFile(file)).length, sha256: await sha256(file) })));
|
|
const report = {
|
|
schemaVersion: 1,
|
|
id: definition.slug,
|
|
title: definition.title,
|
|
status: "generated-review-required",
|
|
source: { root: path.relative(projectRoot, rootFile).replace(/\\/g, "/"), groupCount },
|
|
coordinateSystem: { conversion: "three(x,y,z)=(-wow.x,wow.z,wow.y)", rebasedOrigin: transform.origin },
|
|
sourceBounds: transform.sourceBounds,
|
|
bounds: transform.bounds,
|
|
geometry: {
|
|
groups: groups.length,
|
|
vertices: groups.reduce((sum, group) => sum + group.positions.length / 3, 0),
|
|
triangles,
|
|
collisionTriangles,
|
|
triangleFlags: groups.map((group) => ({ group: group.name, counts: group.flagCounts })),
|
|
},
|
|
navigation: {
|
|
triangles: connectedNavigation.triangles,
|
|
vertices: connectedNavigation.vertices,
|
|
sourceComponents: connectedNavigation.sourceComponents,
|
|
spawnDistances: connectedNavigation.spawnDistances,
|
|
rawTriangles: navigation.geometry.triangles,
|
|
report: "recast-report.json",
|
|
},
|
|
assets,
|
|
};
|
|
await writeFile(path.join(outputDirectory, "stage-pack.json"), `${JSON.stringify(report, null, 2)}\n`);
|
|
return report;
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
|
|
const requested = new Set(process.argv.slice(2));
|
|
const selected = requested.size ? ARENAS.filter((arena) => requested.has(arena.slug)) : ARENAS;
|
|
if (!selected.length) throw new Error(`No matching arena. Choose: ${ARENAS.map((arena) => arena.slug).join(", ")}`);
|
|
for (const arena of selected) {
|
|
const report = await buildArena(arena);
|
|
console.log(`${arena.slug}: ${report.geometry.triangles} triangles, ${report.navigation.triangles} navigation triangles`);
|
|
}
|
|
}
|