933 lines
32 KiB
JavaScript
933 lines
32 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { createHash } from "node:crypto";
|
|
|
|
export const MANASTORM_CATALOG_SCHEMA_VERSION = 1;
|
|
export const MANASTORM_IMPORTER_VERSION = "1.1.0";
|
|
|
|
const WDBC_HEADER_SIZE = 20;
|
|
const SOURCE_SCHEMAS = Object.freeze({
|
|
manastorm: { fieldCount: 9, recordSize: 36 },
|
|
messages: { fieldCount: 39, recordSize: 156 },
|
|
modifiers: { fieldCount: 15, recordSize: 60 },
|
|
groupModifiers: { fieldCount: 5, recordSize: 20 },
|
|
map: { fieldCount: 66, recordSize: 264 },
|
|
dungeonEncounter: { fieldCount: 23, recordSize: 92 },
|
|
dungeonEncounterExtra: { fieldCount: 4, recordSize: 16 },
|
|
spell: { minimumFieldCount: 234 },
|
|
spellIcon: { fieldCount: 2, recordSize: 8 },
|
|
});
|
|
|
|
export class ManastormImportError extends Error {
|
|
constructor(code, message, details = {}) {
|
|
super(message);
|
|
this.name = "ManastormImportError";
|
|
this.code = code;
|
|
this.details = details;
|
|
}
|
|
}
|
|
|
|
function fail(code, message, details = {}) {
|
|
throw new ManastormImportError(code, message, details);
|
|
}
|
|
|
|
function assertInteger(value, label) {
|
|
if (!Number.isInteger(value) || value < 0) {
|
|
fail("INVALID_INTEGER", `${label} must be a non-negative integer.`, { value });
|
|
}
|
|
}
|
|
|
|
function normalizeRelativePath(projectRoot, filePath) {
|
|
return path.relative(projectRoot, filePath).replaceAll("\\", "/");
|
|
}
|
|
|
|
function stableId(kind, sourceId) {
|
|
return `manastorm:${kind}:${sourceId}`;
|
|
}
|
|
|
|
function cleanText(value) {
|
|
return value
|
|
.replace(/\|c[0-9a-f]{8}/gi, "")
|
|
.replace(/\|r/gi, "")
|
|
.replace(/[\r\n]+/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function iconBasename(value) {
|
|
return value
|
|
.replace(/^.*[\\/]/, "")
|
|
.replace(/\.(?:blp|tga)$/i, "")
|
|
.trim();
|
|
}
|
|
|
|
function normalizedIconKey(value) {
|
|
return iconBasename(value).toLowerCase().replace(/\s+/g, "");
|
|
}
|
|
|
|
function indexInstalledIconFiles(iconDirectory, projectRoot) {
|
|
const byKey = new Map();
|
|
if (!iconDirectory || !fs.existsSync(iconDirectory)) return byKey;
|
|
const pending = [iconDirectory];
|
|
while (pending.length) {
|
|
const directory = pending.pop();
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
const absolute = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
pending.push(absolute);
|
|
continue;
|
|
}
|
|
if (!/\.(?:blp|tga|png)$/i.test(entry.name)) continue;
|
|
const basename = iconBasename(entry.name);
|
|
const key = normalizedIconKey(basename);
|
|
if (!byKey.has(key)) {
|
|
byKey.set(key, {
|
|
basename,
|
|
path: normalizeRelativePath(projectRoot, absolute),
|
|
sha256: hashFile(absolute),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return byKey;
|
|
}
|
|
|
|
export function parseWdbcBuffer(buffer, sourceLabel, schema = {}) {
|
|
if (!Buffer.isBuffer(buffer)) {
|
|
fail("INVALID_BUFFER", `${sourceLabel} was not supplied as a Buffer.`);
|
|
}
|
|
if (buffer.length < WDBC_HEADER_SIZE) {
|
|
fail("TRUNCATED_HEADER", `${sourceLabel} is shorter than the 20-byte WDBC header.`, {
|
|
byteLength: buffer.length,
|
|
});
|
|
}
|
|
const magic = buffer.toString("ascii", 0, 4);
|
|
if (magic !== "WDBC") {
|
|
fail("INVALID_MAGIC", `${sourceLabel} has magic ${JSON.stringify(magic)}, expected WDBC.`);
|
|
}
|
|
|
|
const recordCount = buffer.readUInt32LE(4);
|
|
const fieldCount = buffer.readUInt32LE(8);
|
|
const recordSize = buffer.readUInt32LE(12);
|
|
const stringBlockSize = buffer.readUInt32LE(16);
|
|
if (fieldCount === 0 || recordSize === 0 || recordSize !== fieldCount * 4) {
|
|
fail(
|
|
"INVALID_RECORD_LAYOUT",
|
|
`${sourceLabel} must contain fixed-width 32-bit WDBC fields.`,
|
|
{ fieldCount, recordSize },
|
|
);
|
|
}
|
|
if (schema.fieldCount !== undefined && fieldCount !== schema.fieldCount) {
|
|
fail(
|
|
"UNEXPECTED_FIELD_COUNT",
|
|
`${sourceLabel} has ${fieldCount} fields; expected ${schema.fieldCount}.`,
|
|
{ fieldCount, expected: schema.fieldCount },
|
|
);
|
|
}
|
|
if (schema.minimumFieldCount !== undefined && fieldCount < schema.minimumFieldCount) {
|
|
fail(
|
|
"UNEXPECTED_FIELD_COUNT",
|
|
`${sourceLabel} has ${fieldCount} fields; expected at least ${schema.minimumFieldCount}.`,
|
|
{ fieldCount, minimum: schema.minimumFieldCount },
|
|
);
|
|
}
|
|
if (schema.recordSize !== undefined && recordSize !== schema.recordSize) {
|
|
fail(
|
|
"UNEXPECTED_RECORD_SIZE",
|
|
`${sourceLabel} has ${recordSize}-byte records; expected ${schema.recordSize}.`,
|
|
{ recordSize, expected: schema.recordSize },
|
|
);
|
|
}
|
|
|
|
const recordsByteLength = recordCount * recordSize;
|
|
const stringBlockOffset = WDBC_HEADER_SIZE + recordsByteLength;
|
|
const expectedByteLength = stringBlockOffset + stringBlockSize;
|
|
if (!Number.isSafeInteger(expectedByteLength) || expectedByteLength !== buffer.length) {
|
|
fail(
|
|
"INVALID_FILE_LENGTH",
|
|
`${sourceLabel} length does not match its WDBC header.`,
|
|
{ byteLength: buffer.length, expectedByteLength },
|
|
);
|
|
}
|
|
|
|
const fieldOffset = (recordIndex, fieldIndex) => {
|
|
assertInteger(recordIndex, "recordIndex");
|
|
assertInteger(fieldIndex, "fieldIndex");
|
|
if (recordIndex >= recordCount || fieldIndex >= fieldCount) {
|
|
fail("FIELD_OUT_OF_RANGE", `${sourceLabel} field access is out of range.`, {
|
|
recordIndex,
|
|
fieldIndex,
|
|
recordCount,
|
|
fieldCount,
|
|
});
|
|
}
|
|
return WDBC_HEADER_SIZE + recordIndex * recordSize + fieldIndex * 4;
|
|
};
|
|
|
|
const uint = (recordIndex, fieldIndex) => (
|
|
buffer.readUInt32LE(fieldOffset(recordIndex, fieldIndex))
|
|
);
|
|
const float = (recordIndex, fieldIndex) => {
|
|
const value = buffer.readFloatLE(fieldOffset(recordIndex, fieldIndex));
|
|
if (!Number.isFinite(value)) {
|
|
fail("NON_FINITE_FLOAT", `${sourceLabel} contains a non-finite floating-point value.`, {
|
|
recordIndex,
|
|
fieldIndex,
|
|
});
|
|
}
|
|
return value;
|
|
};
|
|
const string = (stringOffset, context = {}) => {
|
|
assertInteger(stringOffset, "stringOffset");
|
|
if (stringOffset >= stringBlockSize) {
|
|
fail("STRING_OFFSET_OUT_OF_RANGE", `${sourceLabel} has an invalid string offset.`, {
|
|
stringOffset,
|
|
stringBlockSize,
|
|
...context,
|
|
});
|
|
}
|
|
const start = stringBlockOffset + stringOffset;
|
|
const end = buffer.indexOf(0, start);
|
|
if (end < 0 || end >= expectedByteLength) {
|
|
fail("UNTERMINATED_STRING", `${sourceLabel} contains an unterminated string.`, {
|
|
stringOffset,
|
|
...context,
|
|
});
|
|
}
|
|
return buffer.toString("utf8", start, end);
|
|
};
|
|
const localizedString = (recordIndex, firstField, localeCount = 16) => {
|
|
for (let locale = 0; locale < localeCount; locale += 1) {
|
|
const stringOffset = uint(recordIndex, firstField + locale);
|
|
const value = string(stringOffset, { recordIndex, fieldIndex: firstField + locale });
|
|
if (value) return cleanText(value);
|
|
}
|
|
return "";
|
|
};
|
|
|
|
return {
|
|
buffer,
|
|
sourceLabel,
|
|
header: {
|
|
recordCount,
|
|
fieldCount,
|
|
recordSize,
|
|
stringBlockSize,
|
|
},
|
|
uint,
|
|
float,
|
|
string,
|
|
localizedString,
|
|
};
|
|
}
|
|
|
|
function readWdbc(filePath, role) {
|
|
if (!fs.existsSync(filePath)) {
|
|
fail("SOURCE_NOT_FOUND", `Required ${role} source was not found: ${filePath}`, {
|
|
role,
|
|
filePath,
|
|
});
|
|
}
|
|
return parseWdbcBuffer(fs.readFileSync(filePath), path.basename(filePath), SOURCE_SCHEMAS[role]);
|
|
}
|
|
|
|
function hashFile(filePath) {
|
|
const hash = createHash("sha256");
|
|
const file = fs.openSync(filePath, "r");
|
|
const chunk = Buffer.allocUnsafe(1024 * 1024);
|
|
try {
|
|
for (;;) {
|
|
const bytesRead = fs.readSync(file, chunk, 0, chunk.length, null);
|
|
if (!bytesRead) break;
|
|
hash.update(chunk.subarray(0, bytesRead));
|
|
}
|
|
} finally {
|
|
fs.closeSync(file);
|
|
}
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
function sourceProvenance(projectRoot, role, filePath, table) {
|
|
return {
|
|
role,
|
|
path: normalizeRelativePath(projectRoot, filePath),
|
|
sha256: hashFile(filePath),
|
|
byteLength: table.buffer.length,
|
|
dbc: table.header,
|
|
};
|
|
}
|
|
|
|
function ensureUniqueSourceIds(rows, source) {
|
|
const seen = new Set();
|
|
for (const row of rows) {
|
|
if (seen.has(row.sourceRecordId)) {
|
|
fail("DUPLICATE_SOURCE_ID", `${source} contains duplicate ID ${row.sourceRecordId}.`, {
|
|
source,
|
|
recordId: row.sourceRecordId,
|
|
});
|
|
}
|
|
seen.add(row.sourceRecordId);
|
|
}
|
|
}
|
|
|
|
function parseManastormRows(table) {
|
|
const rows = Array.from({ length: table.header.recordCount }, (_, recordIndex) => ({
|
|
sourceRecordId: table.uint(recordIndex, 0),
|
|
mapId: table.uint(recordIndex, 1),
|
|
modeId: table.uint(recordIndex, 2),
|
|
encounterId: table.uint(recordIndex, 3),
|
|
scalars: Object.freeze(Array.from(
|
|
{ length: 5 },
|
|
(_, scalarIndex) => table.float(recordIndex, 4 + scalarIndex),
|
|
)),
|
|
}));
|
|
ensureUniqueSourceIds(rows, table.sourceLabel);
|
|
return rows.sort((left, right) => left.sourceRecordId - right.sourceRecordId);
|
|
}
|
|
|
|
function parseMaps(table) {
|
|
const maps = new Map();
|
|
for (let recordIndex = 0; recordIndex < table.header.recordCount; recordIndex += 1) {
|
|
const mapId = table.uint(recordIndex, 0);
|
|
if (maps.has(mapId)) {
|
|
fail("DUPLICATE_MAP_ID", `${table.sourceLabel} contains duplicate map ID ${mapId}.`, {
|
|
mapId,
|
|
});
|
|
}
|
|
const name = table.localizedString(recordIndex, 5);
|
|
const directory = cleanText(table.string(table.uint(recordIndex, 1), {
|
|
recordIndex,
|
|
fieldIndex: 1,
|
|
}));
|
|
maps.set(mapId, {
|
|
mapId,
|
|
name,
|
|
directory,
|
|
instanceType: table.uint(recordIndex, 2),
|
|
flags: table.uint(recordIndex, 3),
|
|
expansionId: table.uint(recordIndex, 63),
|
|
maxPlayers: table.uint(recordIndex, 65),
|
|
});
|
|
}
|
|
return maps;
|
|
}
|
|
|
|
function parseDungeonEncounters(table) {
|
|
const encounters = new Map();
|
|
for (let recordIndex = 0; recordIndex < table.header.recordCount; recordIndex += 1) {
|
|
const encounterId = table.uint(recordIndex, 0);
|
|
if (encounters.has(encounterId)) {
|
|
fail(
|
|
"DUPLICATE_DUNGEON_ENCOUNTER_ID",
|
|
`${table.sourceLabel} contains duplicate encounter ID ${encounterId}.`,
|
|
{ encounterId },
|
|
);
|
|
}
|
|
encounters.set(encounterId, {
|
|
encounterId,
|
|
mapId: table.uint(recordIndex, 1),
|
|
difficultyId: table.uint(recordIndex, 2),
|
|
orderIndex: table.uint(recordIndex, 3),
|
|
bit: table.uint(recordIndex, 4),
|
|
name: table.localizedString(recordIndex, 5),
|
|
});
|
|
}
|
|
return encounters;
|
|
}
|
|
|
|
function parseDungeonEncounterExtras(table) {
|
|
const extras = new Map();
|
|
for (let recordIndex = 0; recordIndex < table.header.recordCount; recordIndex += 1) {
|
|
const encounterId = table.uint(recordIndex, 0);
|
|
const creatureIds = extras.get(encounterId) ?? new Set();
|
|
const creatureId = table.uint(recordIndex, 1);
|
|
if (creatureId) creatureIds.add(creatureId);
|
|
extras.set(encounterId, creatureIds);
|
|
}
|
|
return new Map(
|
|
[...extras.entries()].map(([encounterId, creatureIds]) => [
|
|
encounterId,
|
|
{
|
|
encounterId,
|
|
creatureIds: [...creatureIds].sort((left, right) => left - right),
|
|
},
|
|
]),
|
|
);
|
|
}
|
|
|
|
export function assertResolvedMaps(sourceRows, mapsById) {
|
|
const unresolved = [...new Set(
|
|
sourceRows
|
|
.map((row) => row.mapId)
|
|
.filter((mapId) => !mapsById.has(mapId)),
|
|
)].sort((left, right) => left - right);
|
|
if (unresolved.length) {
|
|
fail(
|
|
"UNRESOLVED_MAP_REFERENCE",
|
|
`Manastorm.dbc references map IDs absent from Map.dbc: ${unresolved.join(", ")}.`,
|
|
{ mapIds: unresolved },
|
|
);
|
|
}
|
|
for (const mapId of new Set(sourceRows.map((row) => row.mapId))) {
|
|
const map = mapsById.get(mapId);
|
|
if (!map?.name || !map.directory) {
|
|
fail(
|
|
"INVALID_MAP_METADATA",
|
|
`Map ${mapId} is missing its authoritative client name or directory.`,
|
|
{ mapId, map },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseSpellIcons(table) {
|
|
const byBasename = new Map();
|
|
const byId = new Map();
|
|
for (let recordIndex = 0; recordIndex < table.header.recordCount; recordIndex += 1) {
|
|
const spellIconId = table.uint(recordIndex, 0);
|
|
const iconPath = cleanText(table.string(table.uint(recordIndex, 1), {
|
|
recordIndex,
|
|
fieldIndex: 1,
|
|
}));
|
|
const basename = iconBasename(iconPath);
|
|
const normalized = basename.toLowerCase();
|
|
const existing = byBasename.get(normalized) ?? {
|
|
basename,
|
|
paths: new Set(),
|
|
spellIconIds: [],
|
|
};
|
|
existing.paths.add(iconPath);
|
|
existing.spellIconIds.push(spellIconId);
|
|
byBasename.set(normalized, existing);
|
|
byId.set(spellIconId, { spellIconId, iconPath, basename });
|
|
}
|
|
for (const entry of byBasename.values()) {
|
|
entry.spellIconIds.sort((left, right) => left - right);
|
|
}
|
|
return { byBasename, byId };
|
|
}
|
|
|
|
function indexSpellIconUsage(table) {
|
|
const spellIdsByIconId = new Map();
|
|
for (let recordIndex = 0; recordIndex < table.header.recordCount; recordIndex += 1) {
|
|
const spellId = table.uint(recordIndex, 0);
|
|
const spellIconId = table.uint(recordIndex, 133);
|
|
if (!spellIconId) continue;
|
|
const spellIds = spellIdsByIconId.get(spellIconId) ?? [];
|
|
spellIds.push(spellId);
|
|
spellIdsByIconId.set(spellIconId, spellIds);
|
|
}
|
|
return spellIdsByIconId;
|
|
}
|
|
|
|
function unresolvedReference(kind, reason, extra = {}) {
|
|
return { kind, status: "unresolved", id: null, reason, ...extra };
|
|
}
|
|
|
|
function parseMessages(
|
|
table,
|
|
spellIcons,
|
|
spellIdsByIconId,
|
|
installedIconFiles,
|
|
validationIssues,
|
|
) {
|
|
const rows = Array.from({ length: table.header.recordCount }, (_, recordIndex) => {
|
|
const sourceRecordId = table.uint(recordIndex, 0);
|
|
const rawIcon = cleanText(table.string(table.uint(recordIndex, 4), {
|
|
recordIndex,
|
|
fieldIndex: 4,
|
|
}));
|
|
const basename = iconBasename(rawIcon);
|
|
const icon = spellIcons.byBasename.get(basename.toLowerCase());
|
|
const installedIcon = installedIconFiles.get(normalizedIconKey(basename));
|
|
const spellIconIds = icon?.spellIconIds ?? [];
|
|
const candidateSpellCount = new Set(
|
|
spellIconIds.flatMap((spellIconId) => spellIdsByIconId.get(spellIconId) ?? []),
|
|
).size;
|
|
const iconReference = icon
|
|
? {
|
|
kind: "icon",
|
|
status: "resolved",
|
|
basename,
|
|
spellIconIds,
|
|
paths: [...icon.paths].sort((left, right) => left.localeCompare(right)),
|
|
resolutionSource: "SpellIcon.dbc",
|
|
}
|
|
: installedIcon
|
|
? {
|
|
kind: "icon",
|
|
status: "resolved",
|
|
basename: installedIcon.basename,
|
|
spellIconIds: [],
|
|
paths: [installedIcon.path],
|
|
sha256: installedIcon.sha256,
|
|
resolutionSource: "installed-icon-file",
|
|
}
|
|
: unresolvedReference(
|
|
"icon",
|
|
"The message icon basename is absent from the installed SpellIcon.dbc.",
|
|
{ basename },
|
|
);
|
|
if (!icon && !installedIcon) {
|
|
validationIssues.push({
|
|
severity: "warning",
|
|
code: "UNRESOLVED_MESSAGE_ICON",
|
|
source: table.sourceLabel,
|
|
recordId: sourceRecordId,
|
|
field: "icon",
|
|
value: basename,
|
|
message: `Message ${sourceRecordId} icon ${JSON.stringify(basename)} is not in SpellIcon.dbc.`,
|
|
});
|
|
}
|
|
return {
|
|
id: stableId("message", sourceRecordId),
|
|
sourceRecordId,
|
|
modeId: table.uint(recordIndex, 1),
|
|
progressionValue: table.uint(recordIndex, 2),
|
|
groupId: table.uint(recordIndex, 3),
|
|
iconReference,
|
|
spellReference: unresolvedReference(
|
|
"spell",
|
|
"ManastormMessages.dbc has no authoritative SpellID field; shared icon usage is not a spell identity.",
|
|
{ candidateSpellCount },
|
|
),
|
|
title: table.localizedString(recordIndex, 5),
|
|
description: table.localizedString(recordIndex, 22),
|
|
titleFlags: table.uint(recordIndex, 21),
|
|
descriptionFlags: table.uint(recordIndex, 38),
|
|
};
|
|
});
|
|
ensureUniqueSourceIds(rows, table.sourceLabel);
|
|
return rows.sort((left, right) => left.sourceRecordId - right.sourceRecordId);
|
|
}
|
|
|
|
function parseModifiers(table) {
|
|
const rows = Array.from({ length: table.header.recordCount }, (_, recordIndex) => {
|
|
const sourceRecordId = table.uint(recordIndex, 0);
|
|
return {
|
|
id: stableId("modifier", sourceRecordId),
|
|
sourceRecordId,
|
|
modeId: table.uint(recordIndex, 1),
|
|
level: table.uint(recordIndex, 2),
|
|
tuning: Array.from(
|
|
{ length: 11 },
|
|
(_, tuningIndex) => table.float(recordIndex, 3 + tuningIndex),
|
|
),
|
|
rawFlag: table.uint(recordIndex, 14),
|
|
};
|
|
});
|
|
ensureUniqueSourceIds(rows, table.sourceLabel);
|
|
return rows.sort((left, right) => left.sourceRecordId - right.sourceRecordId);
|
|
}
|
|
|
|
function parseGroupModifiers(table) {
|
|
const rows = Array.from({ length: table.header.recordCount }, (_, recordIndex) => {
|
|
const sourceRecordId = table.uint(recordIndex, 0);
|
|
return {
|
|
id: stableId("group-modifier", sourceRecordId),
|
|
sourceRecordId,
|
|
playerCount: table.uint(recordIndex, 1),
|
|
modeId: table.uint(recordIndex, 2),
|
|
multipliers: [table.float(recordIndex, 3), table.float(recordIndex, 4)],
|
|
};
|
|
});
|
|
ensureUniqueSourceIds(rows, table.sourceLabel);
|
|
return rows.sort((left, right) => left.sourceRecordId - right.sourceRecordId);
|
|
}
|
|
|
|
function sourcePaths(options) {
|
|
const projectRoot = path.resolve(options.projectRoot);
|
|
const installedClientRoot = path.resolve(
|
|
options.installedClientRoot
|
|
?? path.join(projectRoot, "..", "LadiksMPQEditor", "client-current"),
|
|
);
|
|
const realmDbcRoot = path.resolve(
|
|
options.realmDbcRoot
|
|
?? path.join(installedClientRoot, "area-52", "patch-D", "DBFilesClient"),
|
|
);
|
|
const currentGlobalDbcRoot = path.join(installedClientRoot, "patch-M", "DBFilesClient");
|
|
const legacyGlobalDbcRoot = path.join(
|
|
projectRoot,
|
|
"..",
|
|
"LadiksMPQEditor",
|
|
"patch-M",
|
|
"DBFilesClient",
|
|
);
|
|
const globalDbcPath = (fileName) => {
|
|
const currentPath = path.join(currentGlobalDbcRoot, fileName);
|
|
return fs.existsSync(currentPath) ? currentPath : path.join(legacyGlobalDbcRoot, fileName);
|
|
};
|
|
return {
|
|
projectRoot,
|
|
manastorm: path.resolve(options.manastormPath ?? path.join(realmDbcRoot, "Manastorm.dbc")),
|
|
messages: path.resolve(options.messagesPath ?? path.join(realmDbcRoot, "ManastormMessages.dbc")),
|
|
modifiers: path.resolve(options.modifiersPath ?? path.join(realmDbcRoot, "ManastormModifiers.dbc")),
|
|
groupModifiers: path.resolve(
|
|
options.groupModifiersPath
|
|
?? path.join(realmDbcRoot, "ManastormPlayerGroupModifiers.dbc"),
|
|
),
|
|
map: path.resolve(options.mapPath ?? globalDbcPath("Map.dbc")),
|
|
dungeonEncounter: path.resolve(
|
|
options.dungeonEncounterPath ?? globalDbcPath("DungeonEncounter.dbc"),
|
|
),
|
|
dungeonEncounterExtra: path.resolve(
|
|
options.dungeonEncounterExtraPath ?? globalDbcPath("DungeonEncounterExtra.dbc"),
|
|
),
|
|
spell: path.resolve(options.spellPath ?? path.join(realmDbcRoot, "Spell.dbc")),
|
|
spellIcon: path.resolve(
|
|
options.spellIconPath
|
|
?? path.join(installedClientRoot, "patch-S", "DBFilesClient", "SpellIcon.dbc"),
|
|
),
|
|
iconDirectory: path.resolve(
|
|
options.iconDirectory
|
|
?? path.join(projectRoot, "..", "LadiksMPQEditor", "patch-I", "Interface", "Icons"),
|
|
),
|
|
};
|
|
}
|
|
|
|
export function generateManastormCatalog(options = {}) {
|
|
const paths = sourcePaths({
|
|
...options,
|
|
projectRoot: options.projectRoot ?? process.cwd(),
|
|
});
|
|
const tables = {
|
|
manastorm: readWdbc(paths.manastorm, "manastorm"),
|
|
messages: readWdbc(paths.messages, "messages"),
|
|
modifiers: readWdbc(paths.modifiers, "modifiers"),
|
|
groupModifiers: readWdbc(paths.groupModifiers, "groupModifiers"),
|
|
map: readWdbc(paths.map, "map"),
|
|
dungeonEncounter: readWdbc(paths.dungeonEncounter, "dungeonEncounter"),
|
|
dungeonEncounterExtra: readWdbc(paths.dungeonEncounterExtra, "dungeonEncounterExtra"),
|
|
spell: readWdbc(paths.spell, "spell"),
|
|
spellIcon: readWdbc(paths.spellIcon, "spellIcon"),
|
|
};
|
|
|
|
const sourceRows = parseManastormRows(tables.manastorm);
|
|
const allMaps = parseMaps(tables.map);
|
|
const dungeonEncounters = parseDungeonEncounters(tables.dungeonEncounter);
|
|
const dungeonEncounterExtras = parseDungeonEncounterExtras(tables.dungeonEncounterExtra);
|
|
assertResolvedMaps(sourceRows, allMaps);
|
|
const spellIcons = parseSpellIcons(tables.spellIcon);
|
|
const installedIconFiles = indexInstalledIconFiles(paths.iconDirectory, paths.projectRoot);
|
|
const spellIdsByIconId = indexSpellIconUsage(tables.spell);
|
|
const validationIssues = [];
|
|
const messages = parseMessages(
|
|
tables.messages,
|
|
spellIcons,
|
|
spellIdsByIconId,
|
|
installedIconFiles,
|
|
validationIssues,
|
|
);
|
|
const modifiers = parseModifiers(tables.modifiers);
|
|
const groupModifiers = parseGroupModifiers(tables.groupModifiers);
|
|
|
|
const rowsByMapId = new Map();
|
|
const rowsByEncounterId = new Map();
|
|
for (const row of sourceRows) {
|
|
const mapRows = rowsByMapId.get(row.mapId) ?? [];
|
|
mapRows.push(row);
|
|
rowsByMapId.set(row.mapId, mapRows);
|
|
const encounterRows = rowsByEncounterId.get(row.encounterId) ?? [];
|
|
encounterRows.push(row);
|
|
rowsByEncounterId.set(row.encounterId, encounterRows);
|
|
}
|
|
|
|
const maps = [...rowsByMapId.entries()]
|
|
.sort(([left], [right]) => left - right)
|
|
.map(([mapId, rows]) => {
|
|
const map = allMaps.get(mapId);
|
|
return {
|
|
id: stableId("map", mapId),
|
|
...map,
|
|
sourceRowIds: rows.map((row) => stableId("row", row.sourceRecordId)),
|
|
encounterIds: [...new Set(rows.map((row) => row.encounterId))]
|
|
.sort((left, right) => left - right)
|
|
.map((encounterId) => stableId("encounter", encounterId)),
|
|
};
|
|
});
|
|
|
|
const encounters = [...rowsByEncounterId.entries()]
|
|
.sort(([left], [right]) => left - right)
|
|
.map(([encounterId, rows]) => {
|
|
const dungeonEncounter = dungeonEncounters.get(encounterId);
|
|
const dungeonEncounterExtra = dungeonEncounterExtras.get(encounterId);
|
|
const resolvedCreatureId = dungeonEncounterExtra?.creatureIds.length === 1
|
|
? dungeonEncounterExtra.creatureIds[0]
|
|
: null;
|
|
const mapIds = [...new Set(rows.map((row) => row.mapId))]
|
|
.sort((left, right) => left - right);
|
|
const relationMatchesMap = dungeonEncounter
|
|
? mapIds.includes(dungeonEncounter.mapId)
|
|
: false;
|
|
const creatureReference = encounterId === 0
|
|
? unresolvedReference(
|
|
"creature",
|
|
"Encounter ID 0 is a source sentinel and does not identify a creature.",
|
|
{ sourceEncounterId: encounterId },
|
|
)
|
|
: dungeonEncounter && resolvedCreatureId && relationMatchesMap
|
|
? {
|
|
kind: "creature",
|
|
status: "resolved",
|
|
id: resolvedCreatureId,
|
|
sourceEncounterId: encounterId,
|
|
source: "DungeonEncounterExtra.dbc",
|
|
}
|
|
: unresolvedReference(
|
|
"creature",
|
|
dungeonEncounter && !relationMatchesMap
|
|
? "DungeonEncounter.dbc maps this encounter to a different map than Manastorm.dbc."
|
|
: dungeonEncounterExtra?.creatureIds.length > 1
|
|
? "DungeonEncounterExtra.dbc maps this encounter to multiple creatures; a single runtime boss identity cannot be selected safely."
|
|
: "DungeonEncounter.dbc or DungeonEncounterExtra.dbc does not contain a complete encounter-to-creature relation.",
|
|
{
|
|
sourceEncounterId: encounterId,
|
|
...(dungeonEncounterExtra?.creatureIds.length
|
|
? { candidateCreatureIds: dungeonEncounterExtra.creatureIds }
|
|
: {}),
|
|
},
|
|
);
|
|
return {
|
|
id: stableId("encounter", encounterId),
|
|
encounterId,
|
|
mapIds,
|
|
modeIds: [...new Set(rows.map((row) => row.modeId))].sort((left, right) => left - right),
|
|
sourceRowIds: rows.map((row) => stableId("row", row.sourceRecordId)),
|
|
...(dungeonEncounter
|
|
? {
|
|
name: dungeonEncounter.name,
|
|
difficultyId: dungeonEncounter.difficultyId,
|
|
orderIndex: dungeonEncounter.orderIndex,
|
|
dungeonEncounterBit: dungeonEncounter.bit,
|
|
}
|
|
: {}),
|
|
creatureReference,
|
|
spellReference: unresolvedReference(
|
|
"spell",
|
|
"The installed Manastorm client tables do not provide an authoritative encounter SpellID.",
|
|
),
|
|
iconReference: unresolvedReference(
|
|
"icon",
|
|
"The installed Manastorm client tables do not provide an authoritative encounter icon.",
|
|
),
|
|
};
|
|
});
|
|
|
|
const catalogRows = sourceRows.map((row) => ({
|
|
id: stableId("row", row.sourceRecordId),
|
|
sourceRecordId: row.sourceRecordId,
|
|
mapId: row.mapId,
|
|
mapCatalogId: stableId("map", row.mapId),
|
|
modeId: row.modeId,
|
|
encounterId: row.encounterId,
|
|
encounterCatalogId: stableId("encounter", row.encounterId),
|
|
scalars: row.scalars,
|
|
}));
|
|
|
|
validationIssues.sort((left, right) => (
|
|
left.code.localeCompare(right.code)
|
|
|| left.source.localeCompare(right.source)
|
|
|| left.recordId - right.recordId
|
|
));
|
|
|
|
const sources = [
|
|
["manastorm", paths.manastorm],
|
|
["messages", paths.messages],
|
|
["modifiers", paths.modifiers],
|
|
["groupModifiers", paths.groupModifiers],
|
|
["map", paths.map],
|
|
["dungeonEncounter", paths.dungeonEncounter],
|
|
["dungeonEncounterExtra", paths.dungeonEncounterExtra],
|
|
["spell", paths.spell],
|
|
["spellIcon", paths.spellIcon],
|
|
].map(([role, filePath]) => sourceProvenance(paths.projectRoot, role, filePath, tables[role]));
|
|
|
|
const resolvedMessageIcons = messages.filter(
|
|
(message) => message.iconReference.status === "resolved",
|
|
).length;
|
|
return {
|
|
schemaVersion: MANASTORM_CATALOG_SCHEMA_VERSION,
|
|
catalogId: "ascension-manastorm-installed-client",
|
|
provenance: {
|
|
importerVersion: MANASTORM_IMPORTER_VERSION,
|
|
generatedFrom: sources.map((source) => source.path),
|
|
sources,
|
|
},
|
|
counts: {
|
|
sourceRows: catalogRows.length,
|
|
maps: maps.length,
|
|
encounters: encounters.length,
|
|
modifiers: modifiers.length,
|
|
groupModifiers: groupModifiers.length,
|
|
messages: messages.length,
|
|
spellRecords: tables.spell.header.recordCount,
|
|
spellIconRecords: tables.spellIcon.header.recordCount,
|
|
resolvedMessageIcons,
|
|
unresolvedMessageIcons: messages.length - resolvedMessageIcons,
|
|
resolvedEncounterCreatures: encounters.filter(
|
|
(encounter) => encounter.creatureReference.status === "resolved",
|
|
).length,
|
|
validationIssues: validationIssues.length,
|
|
},
|
|
referencePolicy: {
|
|
map: "Map IDs must resolve to a named Map.dbc row; unresolved map references abort generation.",
|
|
encounter: "Encounter IDs are preserved exactly from Manastorm.dbc, including the zero sentinel.",
|
|
creature: "Creature IDs resolve only through matching DungeonEncounter.dbc and DungeonEncounterExtra.dbc rows on the same Manastorm map.",
|
|
spell: "No spell identity is inferred from progression values, group IDs, or shared icon usage.",
|
|
icon: "Message icons resolve by exact case-insensitive SpellIcon.dbc basename, then by normalized exact basename in the installed icon files; missing references remain unresolved.",
|
|
},
|
|
validationIssues,
|
|
maps,
|
|
encounters,
|
|
sourceRows: catalogRows,
|
|
modifiers,
|
|
groupModifiers,
|
|
messages,
|
|
};
|
|
}
|
|
|
|
export function writeManastormCatalog(catalog, outputPath) {
|
|
const serialized = `${JSON.stringify(catalog, null, 2)}\n`;
|
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
fs.writeFileSync(outputPath, serialized);
|
|
return Buffer.byteLength(serialized);
|
|
}
|
|
|
|
export function writeManastormCatalogSummary(catalog, outputPath) {
|
|
const summary = {
|
|
catalogId: catalog.catalogId,
|
|
sourceRows: catalog.counts.sourceRows,
|
|
maps: catalog.counts.maps,
|
|
encounters: catalog.counts.encounters,
|
|
modifiers: catalog.counts.modifiers,
|
|
groupModifiers: catalog.counts.groupModifiers,
|
|
messages: catalog.counts.messages,
|
|
};
|
|
const serialized = [
|
|
"/** Generated by scripts/manastorm-import; safe to load before the full runtime catalog. */",
|
|
`export const MANASTORM_CATALOG_SUMMARY = Object.freeze(${JSON.stringify(summary, null, 2)} as const);`,
|
|
"",
|
|
].join("\n");
|
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
fs.writeFileSync(outputPath, serialized);
|
|
return Buffer.byteLength(serialized);
|
|
}
|
|
|
|
export function writeManastormRuntimeCatalog(catalog, outputPath, modeId = 0) {
|
|
const modeIds = [...new Set([
|
|
...catalog.sourceRows.map((row) => row.modeId),
|
|
...catalog.modifiers.map((modifier) => modifier.modeId),
|
|
...catalog.messages.map((message) => message.modeId),
|
|
])].sort((left, right) => left - right);
|
|
const runtimeModes = modeIds.map((currentModeId) => {
|
|
const modifiers = catalog.modifiers
|
|
.filter((modifier) => modifier.modeId === currentModeId)
|
|
.sort((left, right) => left.level - right.level)
|
|
.map((modifier) => [modifier.level, ...modifier.tuning]);
|
|
return {
|
|
modeId: currentModeId,
|
|
key: currentModeId === 0
|
|
? "leveling"
|
|
: currentModeId === 2
|
|
? "endgame"
|
|
: `mode-${currentModeId}`,
|
|
maximumLevel: modifiers.reduce(
|
|
(maximum, modifier) => Math.max(maximum, modifier[0]),
|
|
1,
|
|
),
|
|
modifiers,
|
|
messages: catalog.messages
|
|
.filter((message) => message.modeId === currentModeId)
|
|
.map((message) => ({
|
|
progressionValue: message.progressionValue,
|
|
groupId: message.groupId,
|
|
title: message.title,
|
|
description: message.description,
|
|
...(message.iconReference.status === "resolved"
|
|
? { iconReference: message.iconReference.basename }
|
|
: {}),
|
|
})),
|
|
};
|
|
});
|
|
const primaryMode = runtimeModes.find((mode) => mode.modeId === modeId)
|
|
?? runtimeModes[0]
|
|
?? { modeId, maximumLevel: 1, modifiers: [], messages: [] };
|
|
const rowsByEncounterId = new Map();
|
|
for (const row of catalog.sourceRows) {
|
|
const rows = rowsByEncounterId.get(row.encounterId) ?? [];
|
|
rows.push(row);
|
|
rowsByEncounterId.set(row.encounterId, rows);
|
|
}
|
|
const encounters = catalog.encounters
|
|
.filter((encounter) => (
|
|
encounter.creatureReference.status === "resolved"
|
|
&& rowsByEncounterId.has(encounter.encounterId)
|
|
))
|
|
.map((encounter) => {
|
|
const rows = rowsByEncounterId.get(encounter.encounterId);
|
|
return {
|
|
encounterId: encounter.encounterId,
|
|
name: encounter.name,
|
|
creatureId: encounter.creatureReference.id,
|
|
mapIds: [...new Set(rows.map((row) => row.mapId))].sort((left, right) => left - right),
|
|
modeIds: [...new Set(rows.map((row) => row.modeId))].sort((left, right) => left - right),
|
|
sourceRowIds: rows.map((row) => row.id),
|
|
};
|
|
});
|
|
const encounterSources = catalog.provenance.sources
|
|
.filter((source) => (
|
|
source.role === "dungeonEncounter"
|
|
|| source.role === "dungeonEncounterExtra"
|
|
))
|
|
.map((source) => ({
|
|
role: source.role,
|
|
path: source.path,
|
|
sha256: source.sha256,
|
|
dbc: source.dbc,
|
|
}));
|
|
const encounterModesByMap = [...new Set(catalog.sourceRows.map((row) => row.mapId))]
|
|
.sort((left, right) => left - right)
|
|
.map((mapId) => ({
|
|
mapId,
|
|
modeIds: [...new Set(
|
|
catalog.sourceRows
|
|
.filter((row) => row.mapId === mapId)
|
|
.map((row) => row.modeId),
|
|
)].sort((left, right) => left - right),
|
|
}));
|
|
const sourceRows = catalog.sourceRows.map((row) => ({
|
|
sourceRecordId: row.sourceRecordId,
|
|
mapId: row.mapId,
|
|
modeId: row.modeId,
|
|
encounterId: row.encounterId,
|
|
scalars: row.scalars,
|
|
}));
|
|
const runtimeCatalog = {
|
|
schemaVersion: 2,
|
|
catalogId: catalog.catalogId,
|
|
modeId,
|
|
defaultModeId: modeId,
|
|
modeIds,
|
|
maximumLevel: runtimeModes.reduce(
|
|
(maximum, mode) => Math.max(maximum, mode.maximumLevel),
|
|
1,
|
|
),
|
|
modes: runtimeModes,
|
|
encounters,
|
|
sourceRows,
|
|
encounterSources,
|
|
encounterModesByMap,
|
|
// Retained for consumers of the original single-mode snapshot schema.
|
|
modifiers: primaryMode.modifiers,
|
|
groupModifiers: catalog.groupModifiers
|
|
.filter((modifier) => modifier.modeId === modeId)
|
|
.sort((left, right) => left.playerCount - right.playerCount)
|
|
.map((modifier) => [modifier.playerCount, ...modifier.multipliers]),
|
|
messages: primaryMode.messages,
|
|
};
|
|
const serialized = `${JSON.stringify(runtimeCatalog)}\n`;
|
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
fs.writeFileSync(outputPath, serialized);
|
|
return Buffer.byteLength(serialized);
|
|
}
|