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); }