Files
2026-09-13 13:21:27 -04:00

160 lines
11 KiB
JavaScript

#!/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();
}