Files
healer-man/scripts/dungeon-pipeline/import-epoch-client.mjs
T
2026-08-14 15:56:39 -04:00

209 lines
8.4 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const { MpqArchive } = require("stormlib-js");
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const sourceFile = path.join(projectRoot, "dungeon-pipeline", "epoch-five-player-instances.json");
const outputFile = path.join(projectRoot, "src", "game", "generated", "epochDungeonClientCatalog.json");
const sharedDbcRoot = path.join(
projectRoot,
"dungeon-pipeline",
"work",
"epoch-client",
"DBFilesClient",
);
const dbcNames = Object.freeze([
"Map",
"LFGDungeons",
"MapDifficulty",
"DungeonEncounter",
"CreatureDisplayInfo",
"CreatureDisplayInfoExtra",
"CreatureModelData",
"ItemDisplayInfo",
"GameObjectDisplayInfo",
"AnimationData",
"Spell",
]);
const readJson = async (file) => JSON.parse(await readFile(file, "utf8"));
const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex");
function parseDbc(buffer, name) {
if (buffer.subarray(0, 4).toString("ascii") !== "WDBC") {
throw new Error(`${name}.dbc is not WDBC data.`);
}
const recordCount = buffer.readUInt32LE(4);
const fieldCount = buffer.readUInt32LE(8);
const recordSize = buffer.readUInt32LE(12);
const stringBlockSize = buffer.readUInt32LE(16);
if (recordSize !== fieldCount * 4) throw new Error(`${name}.dbc has unsupported packing.`);
const stringBlockOffset = 20 + recordCount * recordSize;
const uint = (record, field) => buffer.readUInt32LE(20 + record * recordSize + field * 4);
const string = (offset) => {
if (!offset || offset >= stringBlockSize) return "";
const start = stringBlockOffset + offset;
const end = buffer.indexOf(0, start);
return buffer.subarray(start, end < 0 ? buffer.length : end).toString("utf8");
};
const localizedString = (record, firstField) => {
for (let locale = 0; locale < 16; locale += 1) {
const value = string(uint(record, firstField + locale));
if (value) return value.trim();
}
return "";
};
return { buffer, recordCount, fieldCount, recordSize, stringBlockSize, uint, string, localizedString };
}
function rows(table, mapper) {
return Array.from({ length: table.recordCount }, (_, record) => mapper(record));
}
const source = await readJson(sourceFile);
const clientRoot = path.resolve(
process.env[source.source.rootEnvironmentVariable]
?? process.env.EPOCH_CLIENT_ROOT
?? path.join(projectRoot, "../epoch_live"),
);
const mapArchiveFile = path.join(clientRoot, ...source.source.mapArchive.split("/"));
const environmentArchiveFile = path.join(clientRoot, ...source.source.environmentArchive.split("/"));
const mapArchive = MpqArchive.open(mapArchiveFile);
const environmentArchive = MpqArchive.open(environmentArchiveFile);
try {
const dbcBuffers = Object.fromEntries(
dbcNames.map((name) => [
name,
mapArchive.extractFile(`DBFilesClient\\${name}.dbc`),
]),
);
await mkdir(sharedDbcRoot, { recursive: true });
await Promise.all(Object.entries(dbcBuffers).map(([name, buffer]) => (
writeFile(path.join(sharedDbcRoot, `${name}.dbc`), buffer)
)));
const mapTable = parseDbc(dbcBuffers.Map, "Map");
const lfgTable = parseDbc(dbcBuffers.LFGDungeons, "LFGDungeons");
const difficultyTable = parseDbc(dbcBuffers.MapDifficulty, "MapDifficulty");
const encounterTable = parseDbc(dbcBuffers.DungeonEncounter, "DungeonEncounter");
const allMaps = new Map(rows(mapTable, (record) => [mapTable.uint(record, 0), {
mapId: mapTable.uint(record, 0),
directory: mapTable.string(mapTable.uint(record, 1)),
instanceType: mapTable.uint(record, 2),
flags: mapTable.uint(record, 3),
name: mapTable.localizedString(record, 5),
expansionId: mapTable.uint(record, 63),
maxPlayers: mapTable.uint(record, 65),
}]));
const lfgRows = rows(lfgTable, (record) => ({
id: lfgTable.uint(record, 0),
name: lfgTable.localizedString(record, 1),
minimumLevel: lfgTable.uint(record, 18),
maximumLevel: lfgTable.uint(record, 19),
targetLevel: lfgTable.uint(record, 20),
mapId: lfgTable.uint(record, 23),
difficultyId: lfgTable.uint(record, 24),
typeId: lfgTable.uint(record, 25),
}));
const difficultyRows = rows(difficultyTable, (record) => ({
id: difficultyTable.uint(record, 0),
mapId: difficultyTable.uint(record, 1),
difficultyId: difficultyTable.uint(record, 2),
}));
const encounterRows = rows(encounterTable, (record) => ({
encounterId: encounterTable.uint(record, 0),
mapId: encounterTable.uint(record, 1),
difficultyId: encounterTable.uint(record, 2),
orderIndex: encounterTable.uint(record, 3),
bit: encounterTable.uint(record, 4),
name: encounterTable.localizedString(record, 5),
}));
const archiveList = environmentArchive.getFileList();
const maps = [];
const encounters = [];
for (const expected of source.dungeons) {
const map = allMaps.get(expected.mapId);
if (!map) throw new Error(`${expected.slug}: Map.dbc has no map ${expected.mapId}.`);
if (map.instanceType !== 1) throw new Error(`${expected.slug}: map ${expected.mapId} is not a party instance.`);
if (map.directory.toLowerCase() !== expected.mapDirectory.toLowerCase()) {
throw new Error(`${expected.slug}: expected ${expected.mapDirectory}, found ${map.directory}.`);
}
const lfg = lfgRows.filter((row) => row.mapId === expected.mapId);
const foundLfgIds = new Set(lfg.map((row) => row.id));
for (const id of expected.lfgDungeonIds) {
if (!foundLfgIds.has(id)) throw new Error(`${expected.slug}: missing LFG dungeon ${id}.`);
}
const difficulties = difficultyRows.filter((row) => row.mapId === expected.mapId);
for (const id of [0, 1]) {
if (!difficulties.some((row) => row.difficultyId === id)) {
throw new Error(`${expected.slug}: missing map difficulty ${id}.`);
}
}
const mapEncounters = encounterRows.filter((row) => row.mapId === expected.mapId);
if (!mapEncounters.length) throw new Error(`${expected.slug}: no DungeonEncounter rows.`);
const prefix = `world\\maps\\${map.directory}\\`.toLowerCase();
const mapFiles = archiveList.filter((file) => file.toLowerCase().startsWith(prefix));
const wdt = `World\\Maps\\${map.directory}\\${map.directory}.wdt`;
if (!environmentArchive.hasFile(wdt)) throw new Error(`${expected.slug}: ${wdt} is missing.`);
maps.push({
...map,
slug: expected.slug,
title: expected.title,
lfgDungeons: lfg,
difficulties,
environment: {
archive: source.source.environmentArchive,
wdt,
files: mapFiles.length,
adtTiles: mapFiles.filter((file) => /[.]adt$/i.test(file)).length,
},
});
encounters.push(...mapEncounters);
}
const deduplicatedEncounters = [...new Map(
encounters.map((encounter) => [encounter.encounterId, encounter]),
).values()].sort((left, right) => left.encounterId - right.encounterId);
const mapArchiveInfo = await stat(mapArchiveFile);
const environmentArchiveInfo = await stat(environmentArchiveFile);
const output = {
schemaVersion: 1,
source: {
client: source.source.client,
clientBuild: source.source.clientBuild,
rootEnvironmentVariable: source.source.rootEnvironmentVariable,
mapArchive: {
path: source.source.mapArchive,
byteLength: mapArchiveInfo.size,
},
environmentArchive: {
path: source.source.environmentArchive,
byteLength: environmentArchiveInfo.size,
},
dbcHashes: Object.fromEntries(
Object.entries(dbcBuffers).map(([name, buffer]) => [`DBFilesClient/${name}.dbc`, sha256(buffer)]),
),
},
maps,
encounters: deduplicatedEncounters,
};
await writeFile(outputFile, `${JSON.stringify(output, null, 2)}\n`, "utf8");
console.log(JSON.stringify({
status: "complete",
output: path.relative(projectRoot, outputFile).replace(/\\/g, "/"),
sharedDbcRoot: path.relative(projectRoot, sharedDbcRoot).replace(/\\/g, "/"),
dbcFiles: dbcNames.length,
maps: maps.length,
encounters: deduplicatedEncounters.length,
adtTiles: maps.reduce((total, map) => total + map.environment.adtTiles, 0),
}, null, 2));
} finally {
environmentArchive.close();
mapArchive.close();
}