209 lines
8.4 KiB
JavaScript
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();
|
|
}
|