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