import { createHash } from "node:crypto"; import { access, mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs"; import { extractNavigationComponent } from "./extract-navigation-component.mjs"; import { MeshoptDecoder } from "meshoptimizer"; import { Vector3 } from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { geometryBounds, parseRootGroupCount, parseWmoGroup, rebaseWmoGroups, wmoChunks, writeWmoGeometry, } from "./build-wmo-arenas.mjs"; const projectRoot = path.resolve(import.meta.dirname, "../.."); const extractedRoot = path.resolve(projectRoot, "../LadiksMPQEditor"); const outputRoot = path.join(projectRoot, "public/assets/game/arenas"); const sourceLayers = ["patch-WC1", "patch-WB2", "patch-WB1", "patch-2", "patch", "lichking", "common-2.mpq"]; const DEFINITIONS = Object.freeze([ { slug: "black-rook-hold-arena", title: "Black Rook Hold Arena", mapDirectory: "patch-WB1/world/maps/blackrookholdarena", primaryPattern: /7vs_blackrookhold_arena\.wmo$/i, alignmentOffset: [117.542488, -102.29949, -224.463489], legacyCollision: "public/assets/game/manastorm/1784-blackrookholdarena/collision.glb", gameplayBounds: { min: [-37, -32, -286], max: [126, 20, -138] }, teamSpawns: [[-35.043, -28.317, -204.435], [108.031, -28.317, -205.573]], }, { slug: "ashamanes-fall", title: "Ashamane's Fall", mapDirectory: "patch-WB2/world/maps/valsharaharena", primaryPattern: /custom_valsharah_arena\.wmo$/i, alignmentOffset: [34.174934, -32.347685, 20.31486], legacyCollision: "public/assets/game/manastorm/1785-valsharaharena/collision.glb", gameplayBounds: { min: [14, -4, 2], max: [58, 16, 36] }, teamSpawns: [[18.223099, -0.480263, 32.034554], [55.023094, -0.480263, 7.123436]], }, ]); function strings(buffer) { return buffer.toString("utf8").split("\0").filter(Boolean); } export function parseAdtPlacements(buffer) { const top = wmoChunks(buffer); const mwmo = top.get("MWMO"); const mwid = top.get("MWID"); const modf = top.get("MODF"); if (!mwmo || !mwid || !modf) return []; const names = new Map(); for (let offset = 0; offset < mwmo.length;) { const end = mwmo.indexOf(0, offset); if (end < 0) break; if (end > offset) names.set(offset, mwmo.toString("utf8", offset, end)); offset = end + 1; } const offsets = []; for (let offset = 0; offset + 4 <= mwid.length; offset += 4) offsets.push(mwid.readUInt32LE(offset)); const placements = []; for (let offset = 0; offset + 64 <= modf.length; offset += 64) { const nameIndex = modf.readUInt32LE(offset); placements.push({ path: names.get(offsets[nameIndex]) ?? strings(mwmo)[nameIndex] ?? null, uniqueId: modf.readUInt32LE(offset + 4), position: [modf.readFloatLE(offset + 8), modf.readFloatLE(offset + 12), modf.readFloatLE(offset + 16)], rotation: [modf.readFloatLE(offset + 20), modf.readFloatLE(offset + 24), modf.readFloatLE(offset + 28)], scale: modf.readUInt16LE(offset + 62) / 1024 || 1, }); } return placements.filter((placement) => placement.path); } async function exists(file) { try { await access(file); return true; } catch { return false; } } async function resolveWmo(relativePath) { const normalized = relativePath.replace(/\\/g, "/"); for (const layer of sourceLayers) { const file = path.join(extractedRoot, layer, ...normalized.split("/")); if (await exists(file)) return file; } return null; } function rotate([x, y, z], [xDegrees, yDegrees, zDegrees]) { const radians = [xDegrees, yDegrees, zDegrees].map((degrees) => degrees * Math.PI / 180); let [nextX, nextY, nextZ] = [x, y, z]; let cosine = Math.cos(radians[0]); let sine = Math.sin(radians[0]); [nextY, nextZ] = [nextY * cosine - nextZ * sine, nextY * sine + nextZ * cosine]; cosine = Math.cos(radians[1]); sine = Math.sin(radians[1]); [nextX, nextZ] = [nextX * cosine + nextZ * sine, -nextX * sine + nextZ * cosine]; cosine = Math.cos(radians[2]); sine = Math.sin(radians[2]); [nextX, nextY] = [nextX * cosine - nextY * sine, nextX * sine + nextY * cosine]; return [nextX, nextY, nextZ]; } function placementBase(position) { return [17066.6666666667 - position[2], 17066.6666666667 - position[0], position[1]]; } function placeGroup(group, placement, primaryBase) { const base = placementBase(placement.position); const positions = new Float32Array(group.positions.length); const normals = group.normals ? new Float32Array(group.normals.length) : null; for (let offset = 0; offset < group.positions.length; offset += 3) { // Undo the direct parser's Three-axis conversion before applying the MODF transform. const local = [-group.positions[offset], group.positions[offset + 2], group.positions[offset + 1]]; const transformed = rotate(local.map((value) => value * placement.scale), placement.rotation); positions[offset] = -(base[0] + transformed[0] - primaryBase[0]); positions[offset + 1] = base[2] + transformed[2] - primaryBase[2]; positions[offset + 2] = base[1] + transformed[1] - primaryBase[1]; if (normals) { const localNormal = [-group.normals[offset], group.normals[offset + 2], group.normals[offset + 1]]; const transformedNormal = rotate(localNormal, placement.rotation); normals[offset] = -transformedNormal[0]; normals[offset + 1] = transformedNormal[2]; normals[offset + 2] = transformedNormal[1]; } } return { ...group, positions, normals }; } async function loadPlacementGroups(placement, placementIndex, primaryBase) { const rootFile = await resolveWmo(placement.path); if (!rootFile) throw new Error(`Missing placed WMO: ${placement.path}`); const root = await readFile(rootFile); const groupCount = parseRootGroupCount(root); const extension = path.extname(rootFile); const base = rootFile.slice(0, -extension.length); const groups = []; for (let groupIndex = 0; groupIndex < groupCount; groupIndex += 1) { const groupFile = `${base}_${String(groupIndex).padStart(3, "0")}${extension}`; let parsed; try { parsed = parseWmoGroup(await readFile(groupFile), `placement-${placementIndex}-group-${groupIndex}`); } catch (error) { throw new Error(`${placement.path} group ${groupIndex}: ${error.message}`, { cause: error }); } groups.push(placeGroup(parsed, placement, primaryBase)); } return { rootFile, groups }; } function splitCollisionGroups(groups, triangleLimit = 90000) { const chunks = []; let current = []; let currentTriangles = 0; for (const group of groups) { let indexOffset = 0; while (indexOffset < group.collisionIndices.length) { const availableTriangles = triangleLimit - currentTriangles; if (availableTriangles <= 0) { chunks.push(current); current = []; currentTriangles = 0; continue; } const takeIndices = Math.min(group.collisionIndices.length - indexOffset, availableTriangles * 3); current.push({ ...group, collisionIndices: group.collisionIndices.slice(indexOffset, indexOffset + takeIndices) }); currentTriangles += takeIndices / 3; indexOffset += takeIndices; } } if (current.length) chunks.push(current); return chunks; } async function hash(file) { return createHash("sha256").update(await readFile(file)).digest("hex"); } function triangleInsideGameplayBounds(points, bounds) { const centroid = [0, 1, 2].map((axis) => (points[0][axis] + points[1][axis] + points[2][axis]) / 3); return centroid[0] >= bounds.min[0] - 6 && centroid[0] <= bounds.max[0] + 6 && centroid[1] >= bounds.min[1] - 6 && centroid[1] <= bounds.max[1] + 16 && centroid[2] >= bounds.min[2] - 6 && centroid[2] <= bounds.max[2] + 6; } async function croppedNavigationGroup(legacyCollisionFile, groups, bounds) { await MeshoptDecoder.ready; const bytes = await readFile(legacyCollisionFile); const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); const scene = await new Promise((resolve, reject) => new GLTFLoader() .setMeshoptDecoder(MeshoptDecoder) .parse(buffer, "", (gltf) => resolve(gltf.scene), reject)); scene.updateMatrixWorld(true); const output = []; const a = new Vector3(); const b = new Vector3(); const c = new Vector3(); scene.traverse((object) => { if (!object.isMesh || !object.geometry?.getAttribute("position")) return; const positions = object.geometry.getAttribute("position"); const indices = object.geometry.getIndex(); const count = indices?.count ?? positions.count; const vertexIndex = (element) => indices ? indices.getX(element) : element; for (let element = 0; element < count; element += 3) { const points = [a, b, c].map((point, corner) => point.fromBufferAttribute(positions, vertexIndex(element + corner)).applyMatrix4(object.matrixWorld).toArray()); if (triangleInsideGameplayBounds(points, bounds)) output.push(...points.flat()); } }); for (const group of groups) { for (let offset = 0; offset < group.collisionIndices.length; offset += 3) { const points = [0, 1, 2].map((corner) => { const index = group.collisionIndices[offset + corner] * 3; return [group.positions[index], group.positions[index + 1], group.positions[index + 2]]; }); if (triangleInsideGameplayBounds(points, bounds)) output.push(...points.flat()); } } const positions = new Float32Array(output); const indices = Uint32Array.from({ length: positions.length / 3 }, (_, index) => index); return { name: "rated-gameplay-collision", positions, normals: null, indices, collisionIndices: indices, flagCounts: {} }; } async function build(definition) { const mapRoot = path.join(extractedRoot, ...definition.mapDirectory.split("/")); const adtFiles = (await readdir(mapRoot)).filter((name) => name.toLowerCase().endsWith(".adt")).sort(); const placementsById = new Map(); for (const adt of adtFiles) { for (const placement of parseAdtPlacements(await readFile(path.join(mapRoot, adt)))) placementsById.set(placement.uniqueId, placement); } const placements = [...placementsById.values()]; const primary = placements.find((placement) => definition.primaryPattern.test(placement.path)); if (!primary) throw new Error(`${definition.slug}: primary arena WMO placement was not found.`); const primaryBase = placementBase(primary.position); const groups = []; const sources = []; for (const [index, placement] of placements.entries()) { const loaded = await loadPlacementGroups(placement, index, primaryBase); groups.push(...loaded.groups); sources.push(path.relative(projectRoot, loaded.rootFile).replace(/\\/g, "/")); } const transform = rebaseWmoGroups(groups); for (const group of groups) { for (let offset = 0; offset < group.positions.length; offset += 3) { group.positions[offset] += definition.alignmentOffset[0]; group.positions[offset + 1] += definition.alignmentOffset[1]; group.positions[offset + 2] += definition.alignmentOffset[2]; } } const alignedBounds = geometryBounds(groups); const outputDirectory = path.join(outputRoot, definition.slug); await mkdir(outputDirectory, { recursive: true }); const visualFile = path.join(outputDirectory, "visual.glb"); await writeWmoGeometry(visualFile, groups, true); const collisionChunks = splitCollisionGroups(groups); const collisionFiles = []; for (const [index, chunk] of collisionChunks.entries()) { const file = path.join(outputDirectory, index === 0 ? "collision.glb" : `collision-${String(index).padStart(3, "0")}.glb`); await writeWmoGeometry(file, chunk, false); collisionFiles.push(file); } const navigationFile = path.join(outputDirectory, "navigation.glb"); const rawNavigationFile = path.join(outputDirectory, "navigation-raw.glb"); const legacyCollisionFile = path.join(projectRoot, ...definition.legacyCollision.split("/")); const navigationCollisionFile = path.join(outputDirectory, "navigation-collision.glb"); const navigationGroup = await croppedNavigationGroup(legacyCollisionFile, groups, definition.gameplayBounds); await writeWmoGeometry(navigationCollisionFile, [navigationGroup], false); const navigation = await bakeNavigationFromCollisionAssets({ slug: definition.slug, collisionAssets: [{ id: "rated-gameplay-collision", role: "collision", path: path.relative(projectRoot, navigationCollisionFile).replace(/\\/g, "/") }], settings: { cellSize: 0.2, cellHeight: 0.1, agentHeight: 1.8, agentRadius: 0.36, agentMaxClimb: 0.5, agentMaxSlope: 50 }, output: rawNavigationFile, reportFile: path.join(outputDirectory, "recast-report.json"), source: { kind: "adt-placed-wmo-repair", adtFiles, placedWmos: sources, legacyCollision: definition.legacyCollision, gameplayBounds: definition.gameplayBounds }, }); const connectedNavigation = await extractNavigationComponent({ source: rawNavigationFile, output: navigationFile, teamSpawns: definition.teamSpawns, }); await Promise.all([unlink(rawNavigationFile), unlink(navigationCollisionFile)]); const files = [visualFile, ...collisionFiles, navigationFile]; const report = { schemaVersion: 1, id: definition.slug, title: definition.title, status: "generated-review-required", source: { mapDirectory: definition.mapDirectory, adtFiles, placementCount: placements.length, placedWmos: sources }, coordinateSystem: { conversion: "three(x,y,z)=(-wow.x,wow.z,wow.y)", rebasedOrigin: transform.origin, alignmentOffset: definition.alignmentOffset, alignment: "primary WMO matched to the existing HealerMan stage coordinate frame", }, bounds: alignedBounds, geometry: { groups: groups.length, triangles: groups.reduce((sum, group) => sum + group.indices.length / 3, 0), collisionTriangles: groups.reduce((sum, group) => sum + group.collisionIndices.length / 3, 0), collisionChunks: collisionFiles.length, }, navigation: { triangles: connectedNavigation.triangles, vertices: connectedNavigation.vertices, sourceComponents: connectedNavigation.sourceComponents, spawnDistances: connectedNavigation.spawnDistances, rawTriangles: navigation.geometry.triangles, report: "recast-report.json", }, assets: await Promise.all(files.map(async (file) => ({ file: path.basename(file), bytes: (await readFile(file)).length, sha256: await hash(file) }))), }; await writeFile(path.join(outputDirectory, "stage-pack.json"), `${JSON.stringify(report, null, 2)}\n`); console.log(`${definition.slug}: ${placements.length} placed WMOs, ${report.geometry.triangles} triangles, ${report.navigation.triangles} nav triangles`); } if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) { const requested = new Set(process.argv.slice(2)); for (const definition of DEFINITIONS.filter((entry) => !requested.size || requested.has(entry.slug))) await build(definition); }