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