#!/usr/bin/env node // Read campaign metadata without booting the full combat runtime. import fs from "node:fs"; import path from "node:path"; import { createHash } from "node:crypto"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { gunzipSync } from "node:zlib"; import { decodeBlp2, encodePng } from "../lib/blp.mjs"; import { stitchMap } from "../lib/wow-map-image.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const require = createRequire(import.meta.url); const compressedSource = fs.readFileSync(path.join(root, "src/game/generated/dungeonCampaignCatalog.compressed.ts"), "utf8"); const compressed = compressedSource.match(/export default "([A-Za-z0-9+/=]+)"/)?.[1]; if (!compressed) throw new Error("Missing compressed campaign metadata"); const campaign = JSON.parse(gunzipSync(Buffer.from(compressed, "base64")).toString("utf8")); const authoredWailing = fs.readFileSync(path.join(root, "src/game/dungeons/wailingCaverns.ts"), "utf8"); if (!authoredWailing.includes('fromWow: "three(x,y,z)=(-wow.x,wow.z,wow.y)"')) throw new Error("Wailing Caverns basis changed; review the map importer"); const definitions = campaign.definitions.map((definition) => definition.id === "wailing-caverns" ? { ...definition, assets: { visual: [{}] }, provenance: { ...definition.provenance, coordinateTransform: "three(x,y,z)=(-wow.x,wow.z,wow.y)" } } : definition); const { MpqArchive } = require("stormlib-js"); const source = path.resolve(process.argv[2] ?? path.join(root, "../downloads/patch-enUS-M.MPQ")); const client = path.resolve(process.env.WOW_CLIENT_ROOT ?? path.join(root, "../wow335a")); const work = path.resolve(process.env.WOW_DUNGEON_WORK_ROOT ?? path.join(root, "../HealerMan-Storage/pipeline-work/dungeons")); const archivePaths = [source, ...[ "patch-enUS-3.MPQ", "patch-enUS-2.MPQ", "patch-enUS.MPQ", "lichking-locale-enUS.MPQ", "expansion-locale-enUS.MPQ", "locale-enUS.MPQ", ].map((name) => path.join(client, "Data/enUS", name))]; const archives = archivePaths.filter(fs.existsSync).map((file) => ({ file, archive: MpqArchive.open(file, { noListfile: true, noAttributes: true }) })); const sourcesUsed = new Set(); const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex"); function extract(name) { for (const entry of archives) { if (!entry.archive.hasFile(name)) continue; sourcesUsed.add(entry.file); return entry.archive.extractFile(name); } return null; } function dbc(name, fields) { const b = extract(`DBFilesClient/${name}.dbc`); if (!b || b.toString("ascii", 0, 4) !== "WDBC" || b.readUInt32LE(8) !== fields) throw new Error(`Invalid ${name} DBC`); const count = b.readUInt32LE(4), size = b.readUInt32LE(12), stringStart = 20 + count * size; if (size !== fields * 4 || stringStart + b.readUInt32LE(16) > b.length) throw new Error(`Truncated ${name} DBC`); return Array.from({ length: count }, (_, i) => { const start = 20 + i * size; return { int: (f) => b.readInt32LE(start + f * 4), float: (f) => b.readFloatLE(start + f * 4), string: (f) => { const offset = b.readUInt32LE(start + f * 4); const end = b.indexOf(0, stringStart + offset); return b.toString("utf8", stringStart + offset, end); }, }; }); } function readJson(file) { return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null; } const add = (a, b) => a.map((v, i) => v + b[i]); const subtract = (a, b) => a.map((v, i) => v - b[i]); function rotate(point, degrees) { const angle = degrees * Math.PI / 180, c = Math.cos(angle), s = Math.sin(angle); return [point[0] * c - point[2] * s, point[1], point[0] * s + point[2] * c]; } /** Inverse of the reviewed campaign mapper, including WMO yaw and anchor offsets. */ function runtimeToWow(definition, recipe, draft) { const authoredWailing = definition.id === "wailing-caverns"; const adt = definition.environmentMode === "adt-hybrid"; const flip = recipe.globalWmoSourceBasis === "flip-x"; const adtBasis = recipe.adtSourceBasis ?? (recipe.clientBuild === "3.3.5a-Epoch" ? "z-y-negative-x" : "negative-z-y-negative-x"); const basisDraft = (p) => adt ? [adtBasis === "z-y-negative-x" ? p[2] : -p[2], p[1], -p[0]] : [flip ? -p[0] : p[0], p[1], p[2]]; const basisWow = (p) => authoredWailing ? [-p[0], p[2], p[1]] : basisDraft([-p[0], p[2], p[1]]); const alignment = definition.provenance.coordinateAlignment ?? "source"; const yaw = !authoredWailing && !adt && alignment === "source-transform" ? recipe.coordinateTransform?.yawDegrees ?? 0 : 0; let offset = [0, 0, 0]; if (!authoredWailing && alignment === "source-transform" && !adt) { if (!recipe.coordinateTransform) throw new Error(`${definition.id}: missing source transform`); offset = rotate(basisDraft(recipe.coordinateTransform.origin).map((v) => -v), yaw); } else if (!authoredWailing && alignment === "player-anchor") { if (!draft?.entrance) throw new Error(`${definition.id}: missing draft entrance for anchor alignment`); offset = subtract(definition.entrance.footPosition, basisDraft(draft.entrance)); } const axes = [[1, 0, 0], [0, 1, 0], [0, 0, 1]].map((p) => rotate(basisWow(p), yaw)); const inverse = axes.map((axis) => [...axis, -axis.reduce((sum, value, i) => sum + value * offset[i], 0)]); // Verify against independent imported spawn positions, not the dungeon bounds. const pairs = (draft?.spawns ?? []).flatMap((spawn) => { const runtime = definition.staticSpawns.find((candidate) => candidate.id === spawn.id); return runtime ? [{ runtime: runtime.position, wow: [-spawn.position[0], spawn.position[2], spawn.position[1]] }] : []; }); const checkedSpawns = pairs.length; // Event-driven arenas can have no static actors; verify their source entrance instead. const checkedEntrances = !pairs.length && draft?.entrance ? 1 : 0; if (checkedEntrances) pairs.push({ runtime: definition.entrance.footPosition, wow: [-draft.entrance[0], draft.entrance[2], draft.entrance[1]] }); if (!pairs.length) throw new Error(`${definition.id}: no source positions available to verify alignment`); const error = Math.max(0, ...pairs.map(({ runtime, wow }) => Math.hypot(...inverse.map((axis, i) => axis[3] + axis.slice(0, 3).reduce((sum, v, j) => sum + v * runtime[j], 0) - wow[i])))); if (error > (authoredWailing ? 0.25 : 0.15)) throw new Error(`${definition.id}: map alignment failed (${error.toFixed(3)} yards)`); return { matrix: inverse.flat().map((v) => Number(v.toFixed(10))), checkedSpawns, checkedEntrances, maxError: error }; } try { const areas = dbc("WorldMapArea", 11).map((r) => ({ id: r.int(0), mapId: r.int(1), name: r.string(3), bounds: [r.float(7), r.float(6), r.float(5), r.float(4)], })); const floors = dbc("DungeonMap", 8).map((r) => ({ dbcId: r.int(0), mapId: r.int(1), level: r.int(2), // DungeonMap: MinX, MaxX, MinY, MaxY (world X is north, Y is west). bounds: [r.float(5), r.float(6), r.float(3), r.float(4)], })); const strings = extract("Interface/FrameXML/GlobalStrings.lua")?.toString("utf8") ?? ""; const names = Object.fromEntries([...strings.matchAll(/DUNGEON_FLOOR_(\w+)\s*=\s*"([^"]+)"/g)].map((m) => [m[1].toLowerCase(), m[2]])); const catalog = { schemaVersion: 1, width: 1002, height: 668, dungeons: {} }; const report = { source: path.basename(source), sourceSha256: sha256(fs.readFileSync(source)), imported: [], skipped: [], coordinateSources: ["WorldMapArea.dbc", "DungeonMap.dbc"], layoutSource: "https://github.com/wowgaming/3.3.5-interface-files/blob/main/WorldMapFrame.xml" }; for (const definition of definitions.filter((d) => d.expansion !== "runewaker")) { console.log(`Importing ${definition.id}`); const area = areas.find((a) => a.mapId === definition.mapId); if (!area) { report.skipped.push({ id: definition.id, reason: "No matching WorldMapArea record" }); continue; } const recipe = readJson(path.join(root, "dungeon-pipeline/recipes", `${definition.id}.json`)) ?? {}; const draft = readJson(path.join(work, definition.id, "runtime-draft.json")); const candidates = floors.filter((floor) => floor.mapId === definition.mapId) .filter((floor, index, entries) => entries.findIndex((entry) => entry.level === floor.level) === index); if (area.bounds[1] > area.bounds[0] && area.bounds[3] > area.bounds[2]) candidates.unshift({ level: 0, bounds: area.bounds }); const importedFloors = []; for (const floor of candidates) { if (!floor.bounds.every(Number.isFinite) || floor.bounds[1] <= floor.bounds[0] || floor.bounds[3] <= floor.bounds[2]) continue; const stem = floor.level ? `${area.name}${floor.level}_` : area.name; const tilePaths = Array.from({ length: 12 }, (_, i) => `Interface/WorldMap/${area.name}/${stem}${i + 1}.blp`); const buffers = tilePaths.map(extract); if (buffers.some((b) => !b)) continue; const png = encodePng(stitchMap(buffers.map((b, i) => decodeBlp2(b, tilePaths[i])))); const logicalPath = `/assets/game/dungeons/${definition.id}/maps/floor-${floor.level}.png`; const output = path.join(root, "public", logicalPath); fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, png); importedFloors.push({ level: floor.level, name: names[`${area.name}${floor.level}`.toLowerCase()] ?? (floor.level ? `Floor ${floor.level}` : "Overview"), bounds: floor.bounds, image: logicalPath, checksum: sha256(png) }); } if (!importedFloors.length) { report.skipped.push({ id: definition.id, reason: "No complete twelve-tile map in supplied/client archives" }); continue; } let alignment; try { alignment = runtimeToWow(definition, recipe, draft); } catch (error) { report.skipped.push({ id: definition.id, reason: error.message }); continue; } catalog.dungeons[definition.id] = { mapId: definition.mapId, coordinateTransform: definition.provenance.coordinateTransform, visualChecksum: definition.assets.visual[0]?.checksum, runtimeToWow: alignment.matrix, floors: importedFloors, }; report.imported.push({ id: definition.id, floors: importedFloors.length, checkedSpawns: alignment.checkedSpawns, checkedEntrances: alignment.checkedEntrances, maxAlignmentError: alignment.maxError }); } report.archives = [...sourcesUsed].map((file) => path.basename(file)); fs.writeFileSync(path.join(root, "src/game/generated/wowDungeonMaps.generated.json"), `${JSON.stringify(catalog, null, 2)}\n`); fs.writeFileSync(path.join(root, "dungeon-pipeline/WOW_DUNGEON_MAP_REPORT.json"), `${JSON.stringify(report, null, 2)}\n`); console.log(`Imported ${report.imported.length} dungeon maps / ${report.imported.reduce((sum, entry) => sum + entry.floors, 0)} floors; skipped ${report.skipped.length}`); for (const skip of report.skipped) console.log(` ${skip.id}: ${skip.reason}`); } finally { for (const { archive } of archives) archive.close(); }