Files
healer-man/scripts/coa-reference/local-source.mjs
T

1019 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { fileURLToPath } from "node:url";
import { decodeJsonBytes, resolveBaselinePath } from "./baseline.mjs";
import { archiveRowsToDbc } from "./archive-dbc.mjs";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const defaultProjectRoot = path.resolve(scriptDirectory, "..", "..");
const baseSpellRelativePath = "client-current/patch-T/DBFilesClient/Spell.dbc";
const overlaySpellRelativePath = "client-current/area-52/patch-D/DBFilesClient/Spell.dbc";
const spellSourceId = "global-spell-base";
const classAliases = new Map([
[14, ["DemonHunter"]],
[19, ["Monk"]],
[20, ["SonOfArugal"]],
]);
function normalizeClassName(value) {
return String(value ?? "").toLocaleLowerCase("en-US").replace(/[^a-z0-9]/g, "");
}
function gameIdForClassName(value) {
return String(value ?? "").toLocaleLowerCase("en-US")
.replace(/['’]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
function sha256(buffer) {
return crypto.createHash("sha256").update(buffer).digest("hex");
}
function pathExists(directory) {
try {
return fs.statSync(directory).isDirectory();
} catch {
return false;
}
}
function rootsForDrive(projectRoot, drive) {
const parent = path.resolve(projectRoot, "..");
if (drive === "H") {
return {
clientRoot: "H:\\resources\\client",
extractedRoot: "H:\\LadiksMPQEditor",
};
}
return {
clientRoot: path.join(parent, "resources", "client"),
extractedRoot: path.join(parent, "LadiksMPQEditor"),
};
}
/** Resolve a client and extraction directory as one source set. */
export function resolveSourceRoots({
projectRoot = defaultProjectRoot,
clientRoot,
extractedRoot,
} = {}) {
const resolvedProjectRoot = path.resolve(projectRoot);
const hasClient = clientRoot !== undefined;
const hasExtracted = extractedRoot !== undefined;
if (hasClient !== hasExtracted) {
throw new Error("Pass clientRoot and extractedRoot together so the source directories stay paired");
}
if (clientRoot !== undefined && extractedRoot !== undefined) {
const roots = { clientRoot: path.resolve(clientRoot), extractedRoot: path.resolve(extractedRoot) };
if (!pathExists(roots.clientRoot)) throw new Error(`Client source root does not exist: ${roots.clientRoot}`);
if (!pathExists(roots.extractedRoot)) throw new Error(`Extracted source root does not exist: ${roots.extractedRoot}`);
return { projectRoot: resolvedProjectRoot, ...roots, rootSet: "supplied" };
}
const hRoots = rootsForDrive(resolvedProjectRoot, "H");
if (pathExists(hRoots.clientRoot) && pathExists(hRoots.extractedRoot)) {
return { projectRoot: resolvedProjectRoot, ...hRoots, rootSet: "H" };
}
const siblingRoots = rootsForDrive(resolvedProjectRoot, "F");
if (pathExists(siblingRoots.clientRoot) && pathExists(siblingRoots.extractedRoot)) {
return { projectRoot: resolvedProjectRoot, ...siblingRoots, rootSet: "F" };
}
throw new Error("Could not find a complete local client/extraction root pair; pass both clientRoot and extractedRoot");
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function readJson(buffer, displayPath) {
let parsed;
try {
parsed = JSON.parse(decodeJsonBytes(buffer, displayPath).toString("utf8"));
} catch (error) {
throw new Error(`${displayPath} is malformed JSON: ${error.message}`);
}
return parsed;
}
function inspectDbc(buffer, displayPath, minimumFields = 1) {
assert(buffer.length >= 20, `${displayPath} has a truncated WDBC header`);
assert(buffer.toString("ascii", 0, 4) === "WDBC", `${displayPath} is not a WDBC file`);
const recordCount = buffer.readUInt32LE(4);
const fieldCount = buffer.readUInt32LE(8);
const recordSize = buffer.readUInt32LE(12);
const stringSize = buffer.readUInt32LE(16);
assert(fieldCount >= minimumFields, `${displayPath} has ${fieldCount} fields; expected at least ${minimumFields}`);
assert(recordSize >= fieldCount * 4, `${displayPath} record size is smaller than its field count`);
const expectedLength = 20 + recordCount * recordSize + stringSize;
assert(Number.isSafeInteger(expectedLength) && expectedLength === buffer.length, `${displayPath} has inconsistent WDBC record/string lengths`);
return { recordCount, fieldCount, recordSize, stringSize, stringsOffset: 20 + recordCount * recordSize };
}
function field(buffer, header, rowBase, index) {
assert(index >= 0 && index < header.fieldCount, `DBC field ${index} is outside the declared schema`);
return buffer.readUInt32LE(rowBase + index * 4);
}
function signedField(buffer, header, rowBase, index) {
assert(index >= 0 && index < header.fieldCount, `DBC field ${index} is outside the declared schema`);
return buffer.readInt32LE(rowBase + index * 4);
}
function floatField(buffer, header, rowBase, index) {
assert(index >= 0 && index < header.fieldCount, `DBC field ${index} is outside the declared schema`);
return buffer.readFloatLE(rowBase + index * 4);
}
function stringAt(buffer, header, offset) {
if (!offset || offset >= header.stringSize) return "";
const start = header.stringsOffset + offset;
const end = buffer.indexOf(0, start);
return buffer.toString("utf8", start, end < 0 ? buffer.length : end);
}
function localizedString(buffer, header, rowBase, firstField, localeCount = 16) {
for (let locale = 0; locale < localeCount; locale += 1) {
const value = stringAt(buffer, header, field(buffer, header, rowBase, firstField + locale));
if (value) return value;
}
return "";
}
function cleanClientText(value) {
return String(value ?? "")
.replace(/\|c[0-9a-f]{8}/gi, "")
.replace(/\|r/gi, "")
.replace(/[\r\n]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function tableMap(buffer, header, valueField, valueKind) {
const values = new Map();
for (let row = 0; row < header.recordCount; row += 1) {
const base = 20 + row * header.recordSize;
const id = field(buffer, header, base, 0);
const value = valueKind === "float"
? floatField(buffer, header, base, valueField)
: signedField(buffer, header, base, valueField);
if (!values.has(id)) values.set(id, value);
}
return values;
}
function rangeTable(buffer, header) {
const values = new Map();
for (let row = 0; row < header.recordCount; row += 1) {
const base = 20 + row * header.recordSize;
const id = field(buffer, header, base, 0);
values.set(id, {
min: Math.max(0, floatField(buffer, header, base, 1), floatField(buffer, header, base, 2)),
max: Math.max(0, floatField(buffer, header, base, 3), floatField(buffer, header, base, 4)),
});
}
return values;
}
function collectClassMaps(buffer, header, classes) {
const namesById = new Map();
const idsByName = new Map();
const rows = [];
for (let index = 0; index < header.recordCount; index += 1) {
const base = 20 + index * header.recordSize;
const id = field(buffer, header, base, 0);
const name = localizedString(buffer, header, base, 4);
if (!name) continue;
namesById.set(id, name);
const normalized = normalizeClassName(name);
const matches = idsByName.get(normalized) ?? [];
matches.push(id);
idsByName.set(normalized, matches);
if (classes.some((definition) => normalizeClassName(definition.className) === normalized)) {
rows.push({ id, name, fields: Array.from({ length: header.fieldCount }, (_, fieldIndex) => field(buffer, header, base, fieldIndex)) });
}
}
return { namesById, idsByName, rows };
}
function allStringsInRow(buffer, header, rowBase) {
const strings = {};
for (let index = 0; index < header.fieldCount; index += 1) {
const offset = field(buffer, header, rowBase, index);
const value = stringAt(buffer, header, offset);
if (value) strings[index] = value;
}
return strings;
}
function collectSpecEvidence(buffer, header, wantedNames) {
const rows = [];
for (let index = 0; index < header.recordCount; index += 1) {
const base = 20 + index * header.recordSize;
const strings = allStringsInRow(buffer, header, base);
const matches = [...wantedNames].filter((name) => Object.values(strings).some((value) => normalizeClassName(value) === normalizeClassName(name)));
if (matches.length === 0) continue;
rows.push({
id: field(buffer, header, base, 0),
classNamesMatched: matches,
fields: Array.from({ length: header.fieldCount }, (_, fieldIndex) => field(buffer, header, base, fieldIndex)),
strings,
});
}
return rows;
}
function buildSkillLineIndex(buffer, header) {
const bySpellId = new Map();
for (let row = 0; row < header.recordCount; row += 1) {
const base = 20 + row * header.recordSize;
const skillLineId = field(buffer, header, base, 1);
const spellId = field(buffer, header, base, 2);
const classMask = field(buffer, header, base, 5);
const entries = bySpellId.get(spellId) ?? [];
if (!entries.some((entry) => entry.skillLineId === skillLineId && entry.classMask === classMask)) {
entries.push({ skillLineId, classMask });
}
bySpellId.set(spellId, entries);
}
return bySpellId;
}
function classIdsFromMask(mask) {
const ids = [];
if (mask === 0 || mask === 0xffffffff) return ids;
for (let classId = 1; classId <= 32; classId += 1) {
if ((mask & (1 << (classId - 1))) !== 0) ids.push(classId);
}
return ids;
}
function makeSpellDbc(buffer, header, id, supports) {
const base = header.idToBase.get(id);
if (base === undefined) return null;
const unsigned = (index) => field(buffer, header, base, index);
const signed = (index) => signedField(buffer, header, base, index);
const floating = (index) => floatField(buffer, header, base, index);
const localized = (index) => cleanClientText(localizedString(buffer, header, base, index));
const durationIndex = unsigned(40);
const castTimeIndex = unsigned(28);
const rangeIndex = unsigned(46);
const effects = Array.from({ length: 3 }, (_, effectIndex) => ({
index: effectIndex,
effectType: unsigned(71 + effectIndex),
auraType: unsigned(95 + effectIndex),
basePoints: signed(80 + effectIndex) + 1,
dieSides: signed(74 + effectIndex),
pointsPerLevel: floating(77 + effectIndex),
mechanic: unsigned(83 + effectIndex),
implicitTargetA: unsigned(86 + effectIndex),
implicitTargetB: unsigned(89 + effectIndex),
radiusIndex: unsigned(92 + effectIndex),
radius: lookupSupport(supports.radii, unsigned(92 + effectIndex)),
periodMs: unsigned(98 + effectIndex),
valueMultiplier: floating(101 + effectIndex),
chainTargets: unsigned(104 + effectIndex),
itemType: unsigned(107 + effectIndex),
miscValue: signed(110 + effectIndex),
miscValueB: signed(113 + effectIndex),
triggerSpellId: unsigned(116 + effectIndex),
pointsPerCombo: floating(119 + effectIndex),
coefficient: floating(229 + effectIndex),
})).filter((effect) => (
effect.effectType || effect.auraType || effect.basePoints !== 1 || effect.triggerSpellId
));
return {
id,
name: localized(136),
rankText: localized(153),
description: localized(170) || localized(187),
categoryId: unsigned(1),
dispelType: unsigned(2),
mechanic: unsigned(3),
attributes: Array.from({ length: 8 }, (_, attribute) => unsigned(4 + attribute)),
castTimeIndex,
castTimeMs: lookupSupport(supports.castTimes, castTimeIndex),
recoveryTimeMs: unsigned(29),
categoryRecoveryTimeMs: unsigned(30),
interruptFlags: unsigned(31),
auraInterruptFlags: unsigned(32),
channelInterruptFlags: unsigned(33),
procFlags: unsigned(34),
procChance: unsigned(35),
procCharges: unsigned(36),
maxLevel: unsigned(37),
baseLevel: unsigned(38),
spellLevel: unsigned(39),
durationIndex,
durationMs: lookupSupport(supports.durations, durationIndex),
powerType: signed(41),
powerCost: unsigned(42),
powerCostPerLevel: unsigned(43),
powerCostPerSecond: unsigned(44),
powerCostPerSecondPerLevel: unsigned(45),
rangeIndex,
rangeMin: supports.ranges ? supports.ranges.get(rangeIndex)?.min ?? (rangeIndex === 0 ? 0 : null) : (rangeIndex === 0 ? 0 : null),
rangeMax: supports.ranges ? supports.ranges.get(rangeIndex)?.max ?? (rangeIndex === 0 ? 0 : null) : (rangeIndex === 0 ? 0 : null),
projectileSpeed: floating(47),
stackAmount: unsigned(49),
equippedItemClass: signed(67),
equippedItemSubclassMask: signed(68),
equippedItemInventoryMask: signed(69),
iconId: unsigned(133),
powerCostPercentage: unsigned(204),
startRecoveryCategory: unsigned(205),
startRecoveryTimeMs: unsigned(206),
maximumTargets: unsigned(212),
damageClass: unsigned(213),
preventionType: unsigned(214),
schoolMask: unsigned(225),
runeCostId: unsigned(226),
powerDisplayId: unsigned(228),
effects,
};
}
function lookupSupport(table, id) {
if (id === 0) return 0;
if (!table) return null;
return table.get(id) ?? null;
}
function addDiagnostic(diagnostics, diagnostic, keys) {
const key = keys ? keys(diagnostic) : `${diagnostic.code}:${diagnostic.message}`;
if (diagnostics._keys.has(key)) return;
diagnostics._keys.add(key);
diagnostics.push(diagnostic);
}
function evidenceKey(classId, row) {
return `coa-${classId}-cad-${Number(row.ID)}`;
}
function parseSpellRows(buffer, header, ids, supports, diagnostics, archive = null) {
const idToBase = new Map();
const duplicateIds = new Set();
for (let index = 0; index < header.recordCount; index += 1) {
const base = 20 + index * header.recordSize;
const id = field(buffer, header, base, 0);
if (idToBase.has(id)) duplicateIds.add(id);
else idToBase.set(id, base);
}
header.idToBase = idToBase;
for (const id of duplicateIds) {
addDiagnostic(diagnostics, {
code: "duplicate-spell-id",
severity: "warning",
message: `Base Spell.dbc contains duplicate spell ID ${id}; the first row was retained.`,
spellId: id,
}, (item) => `${item.code}:${item.spellId}`);
}
const records = new Map();
const archivedIds = new Set();
let archivedBuffer;
let archivedHeader;
if (archive) {
archivedBuffer = archiveRowsToDbc(archive.records, archive.fieldTypes);
archivedHeader = inspectDbc(archivedBuffer, "pinned August global Spell rows", 234);
archivedHeader.idToBase = new Map(archive.records.map((record, index) => [record.f0, 20 + index * archivedHeader.recordSize]));
}
const triggerEvidenceBySpellId = new Map();
const pending = [...ids].sort((left, right) => left - right);
const queued = new Set(pending);
while (pending.length) {
const id = pending.shift();
if (records.has(id)) continue;
let dbc = makeSpellDbc(buffer, header, id, supports);
if (!dbc && archivedHeader) {
dbc = makeSpellDbc(archivedBuffer, archivedHeader, id, supports);
if (dbc) archivedIds.add(id);
}
if (!dbc) {
records.set(id, null);
continue;
}
records.set(id, dbc);
for (const effect of dbc.effects) {
const triggerSpellId = Number(effect.triggerSpellId) || (effect.effectType === 168 ? Number(effect.miscValue) : 0);
if (!triggerSpellId) continue;
const references = triggerEvidenceBySpellId.get(triggerSpellId) ?? [];
references.push({ kind: "dbc-effect-trigger", sourceSpellId: id, effectIndex: effect.index });
triggerEvidenceBySpellId.set(triggerSpellId, references);
if (queued.has(triggerSpellId)) continue;
queued.add(triggerSpellId);
pending.push(triggerSpellId);
}
}
return { records, triggerEvidenceBySpellId, archivedIds };
}
function makeSourceRecord({ id, portablePath, kind, role, buffer, header }) {
return {
id,
path: portablePath,
sha256: sha256(buffer),
bytes: buffer.length,
kind,
role,
...(header ? {
recordCount: header.recordCount,
fieldCount: header.fieldCount,
recordSize: header.recordSize,
stringSize: header.stringSize,
} : {}),
};
}
function addFileSource(sources, id, absolutePath, portablePath, kind, role, required = false, minFields = 1) {
if (!fs.existsSync(absolutePath)) {
if (required) throw new Error(`Required source file is missing: ${portablePath}`);
return null;
}
const buffer = fs.readFileSync(absolutePath);
let header = null;
if (kind === "dbc") header = inspectDbc(buffer, portablePath, minFields);
const source = makeSourceRecord({ id, portablePath, kind, role, buffer, header });
sources.push(source);
return { source, buffer, header };
}
function buildClassCatalogs(classes, cadRows, classDbc, specEvidence, diagnostics) {
const byName = new Map();
for (const row of classDbc.rows) {
const normalized = normalizeClassName(row.name);
const matches = byName.get(normalized) ?? [];
matches.push(row);
byName.set(normalized, matches);
}
const catalogs = [];
const classIdByNormalizedName = new Map();
for (const definition of classes) {
const normalized = normalizeClassName(definition.className);
const candidates = byName.get(normalized) ?? [];
definition.gameId = gameIdForClassName(definition.className);
const aliases = classAliases.get(Number(definition.classId)) ?? [];
if (candidates.length === 1) {
definition.clientClassId = candidates[0].id;
classIdByNormalizedName.set(normalized, Number(definition.classId));
} else {
definition.clientClassId = null;
addDiagnostic(diagnostics, {
code: candidates.length ? "ambiguous-game-class-id" : "missing-game-class-id",
severity: "warning",
message: candidates.length
? `Class ${definition.className} matches multiple ChrClasses rows.`
: `Class ${definition.className} has no matching ChrClasses row.`,
classId: Number(definition.classId),
candidateGameIds: candidates.map((candidate) => candidate.id),
}, (item) => `${item.code}:${item.classId}`);
}
if (aliases.length) definition.aliases = aliases;
catalogs.push({
classId: Number(definition.classId),
className: definition.className,
gameId: definition.gameId,
clientClassId: definition.clientClassId,
...(aliases.length ? { aliases } : {}),
advancementRows: [],
chrClassEvidence: candidates.length === 1 ? candidates[0] : null,
chrSpecEvidence: specEvidence.filter((row) => row.classNamesMatched.some((name) => (
normalizeClassName(name) === normalized || aliases.some((alias) => normalizeClassName(alias) === normalizeClassName(name))
))),
});
for (const alias of aliases) classIdByNormalizedName.set(normalizeClassName(alias), Number(definition.classId));
}
const catalogByClassId = new Map(catalogs.map((catalog) => [catalog.classId, catalog]));
const filteredCadRows = [];
for (const row of cadRows) {
const classId = classIdByNormalizedName.get(normalizeClassName(row.Class));
if (classId === undefined) continue;
const catalog = catalogByClassId.get(classId);
catalog.advancementRows.push(row);
filteredCadRows.push({ classId, row });
}
return { catalogs, filteredCadRows, classIdByNormalizedName };
}
function buildAbilities({ filteredCadRows, nodes, spellRankData, skillLineBySpellId, diagnostics }) {
const ownersBySpellId = new Map();
for (const { classId, row } of filteredCadRows) {
for (const rawSpellId of Array.isArray(row.Spells) ? row.Spells : []) {
const spellId = Number(rawSpellId);
if (!Number.isInteger(spellId) || spellId <= 0) continue;
const owners = ownersBySpellId.get(spellId) ?? new Set();
owners.add(classId);
ownersBySpellId.set(spellId, owners);
}
}
const rankRowsByFirstSpell = new Map();
const rankRowsBySpell = new Map();
for (const rankRow of spellRankData) {
const spellId = Number(rankRow.spellId);
const firstSpellId = Number(rankRow.firstSpellId);
if (!Number.isInteger(spellId) || spellId <= 0 || !Number.isInteger(firstSpellId) || firstSpellId <= 0) continue;
const family = rankRowsByFirstSpell.get(firstSpellId) ?? [];
family.push(rankRow);
rankRowsByFirstSpell.set(firstSpellId, family);
rankRowsBySpell.set(spellId, rankRow);
}
const nodeByClassAndId = new Map(nodes.map((node) => [`${Number(node.classId)}:${Number(node.id)}`, node]));
const abilities = [];
for (const { classId, row } of filteredCadRows) {
const key = evidenceKey(classId, row);
const spellIds = [...new Set((Array.isArray(row.Spells) ? row.Spells : []).map(Number).filter((id) => Number.isInteger(id) && id > 0))].sort((a, b) => a - b);
const node = nodeByClassAndId.get(`${classId}:${Number(row.ID)}`);
const selectedRankSpellIds = new Set();
for (const spellId of spellIds) {
const family = rankRowsByFirstSpell.get(spellId)
?? (rankRowsBySpell.has(spellId) ? [rankRowsBySpell.get(spellId)] : []);
const candidates = [...new Map(family.map((candidate) => [Number(candidate.spellId), candidate])).values()];
const sameClass = candidates.filter((candidate) => {
const candidateId = Number(candidate.spellId);
const cadOwner = ownersBySpellId.get(candidateId)?.has(classId) ?? false;
const maskOwner = (skillLineBySpellId.get(candidateId) ?? []).some((entry) => classIdsFromMask(entry.classMask).includes(classId));
return cadOwner || maskOwner || candidateId === spellId;
});
const chosen = sameClass.length ? sameClass : candidates;
for (const candidate of chosen) selectedRankSpellIds.add(Number(candidate.spellId));
if (candidates.length > 0 && sameClass.length === 0) {
addDiagnostic(diagnostics, {
code: "rank-identity-no-same-class-match",
severity: "warning",
message: `No same-class local rank identity was found for ${row.Name} in ${row.Class}; unscoped candidates are retained as evidence.`,
classId,
abilityKey: key,
firstSpellId: spellId,
candidateSpellIds: candidates.map((candidate) => Number(candidate.spellId)).sort((a, b) => a - b),
}, (item) => `${item.code}:${item.abilityKey}:${item.firstSpellId}`);
}
const byRank = new Map();
for (const candidate of sameClass) {
const rank = Number(candidate.rank) || null;
if (rank === null) continue;
const rankIds = byRank.get(rank) ?? [];
rankIds.push(Number(candidate.spellId));
byRank.set(rank, rankIds);
}
for (const [rank, candidateSpellIds] of byRank) {
if (candidateSpellIds.length > 1) {
addDiagnostic(diagnostics, {
code: "ambiguous-rank-join",
severity: "warning",
message: `Multiple same-class spell IDs match rank ${rank} of ${row.Name}.`,
classId,
abilityKey: key,
firstSpellId: spellId,
rank,
candidateSpellIds: candidateSpellIds.sort((a, b) => a - b),
}, (item) => `${item.code}:${item.abilityKey}:${item.firstSpellId}:${item.rank}`);
}
}
}
const evidence = [{
kind: "local-character-advancement-row",
sourceId: "client-cad",
rowId: Number(row.ID),
rowClass: row.Class,
}];
if (node) evidence.push({ kind: "july-node-baseline-id-match", sourceId: "july-coa-node-baseline", nodeId: Number(node.id), nodeName: node.name });
if (String(row.Realms ?? "")) evidence.push({ kind: "uninterpreted-realm-mask", value: String(row.Realms) });
abilities.push({
key,
classId,
name: String(row.Name ?? ""),
spellIds,
nodeIds: node ? [Number(node.id)] : [],
rankSpellIds: [...selectedRankSpellIds].sort((a, b) => {
const rankDiff = (Number(rankRowsBySpell.get(a)?.rank) || 0) - (Number(rankRowsBySpell.get(b)?.rank) || 0);
return rankDiff || a - b;
}),
live: null,
evidence,
});
}
return { abilities, ownersBySpellId, rankRowsBySpell, rankRowsByFirstSpell };
}
function buildSpellEvidence({ nodes, extraNodes = [], baselineSpells = [], progressionChains, abilities, spellRankData, ownersBySpellId }) {
const evidenceBySpell = new Map();
const classIdsBySpell = new Map();
const add = (spellId, evidence, classId) => {
if (!Number.isInteger(spellId) || spellId <= 0) return;
const evidenceItems = evidenceBySpell.get(spellId) ?? [];
evidenceItems.push(evidence);
evidenceBySpell.set(spellId, evidenceItems);
if (Number.isInteger(classId)) {
const classes = classIdsBySpell.get(spellId) ?? new Set();
classes.add(classId);
classIdsBySpell.set(spellId, classes);
}
};
const nodeNameBySpell = new Map();
for (const node of nodes) {
for (const rank of Array.isArray(node.rankDescriptions) ? node.rankDescriptions : []) {
const spellId = Number(rank.spellId);
add(spellId, { kind: "july-node-rank", sourceId: "july-coa-node-baseline", nodeId: Number(node.id), classId: Number(node.classId), rank: Number(rank.rank) }, Number(node.classId));
const names = nodeNameBySpell.get(spellId) ?? [];
names.push({ name: node.name, description: rank.description, rank: rank.rank });
nodeNameBySpell.set(spellId, names);
}
}
for (const node of extraNodes) {
for (const rank of node.rankDescriptions ?? []) {
add(Number(rank.spellId), { kind: "august-node-rank", sourceId: "august-voljin-supplement", nodeId: Number(node.id), classId: Number(node.classId), rank: Number(rank.rank) }, Number(node.classId));
}
}
for (const spell of baselineSpells) {
const spellId = Number(spell?.id);
const classIds = [...new Set((spell?.classIds ?? []).map(Number).filter((classId) => Number.isInteger(classId) && classId > 11))];
for (const classId of classIds) {
add(spellId, {
kind: "july-spell-identity",
sourceId: "july-coa-node-baseline",
classId,
name: String(spell?.name ?? ""),
rank: String(spell?.rank ?? ""),
}, classId);
}
}
for (const ability of abilities) {
for (const spellId of ability.spellIds) {
add(spellId, { kind: "local-cad-ability", sourceId: "client-cad", abilityKey: ability.key, classId: ability.classId }, ability.classId);
}
for (const spellId of ability.rankSpellIds) {
add(spellId, { kind: "local-cad-rank-family", sourceId: "client-spell-ranks", abilityKey: ability.key, classId: ability.classId }, ability.classId);
}
}
for (const chain of progressionChains) {
for (const rank of Array.isArray(chain.ranks) ? chain.ranks : []) {
add(Number(rank.spellId), {
kind: "july-progression-chain-rank",
sourceId: "july-coa-node-baseline",
chainId: chain.id,
classId: Number(chain.classId),
rank: Number(rank.rank),
}, Number(chain.classId));
}
}
const sourceById = new Map();
for (const rankRow of spellRankData) {
const spellId = Number(rankRow.spellId);
if (!Number.isInteger(spellId) || spellId <= 0) continue;
const rows = sourceById.get(spellId) ?? [];
rows.push(rankRow);
sourceById.set(spellId, rows);
}
for (const [spellId, rows] of sourceById) {
if (!evidenceBySpell.has(spellId)) continue;
for (const row of rows) add(spellId, {
kind: "local-spell-rank-data",
sourceId: "client-spell-ranks",
firstSpellId: Number(row.firstSpellId),
rank: Number(row.rank),
level: Number(row.level),
});
}
for (const [spellId, owners] of ownersBySpellId) {
if (!classIdsBySpell.has(spellId)) classIdsBySpell.set(spellId, new Set());
for (const owner of owners) classIdsBySpell.get(spellId).add(owner);
}
return { evidenceBySpell, classIdsBySpell, nodeNameBySpell };
}
function propagateTriggerClassOwnership(classIdsBySpell, evidenceBySpell, triggerEvidenceBySpellId) {
const targetsBySource = new Map();
for (const [targetSpellId, references] of triggerEvidenceBySpellId) {
for (const reference of references) {
const sourceSpellId = Number(reference.sourceSpellId);
const targets = targetsBySource.get(sourceSpellId) ?? new Set();
targets.add(targetSpellId);
targetsBySource.set(sourceSpellId, targets);
}
}
const queue = [];
const queued = new Set();
const enqueue = (spellId, classId) => {
const key = `${spellId}:${classId}`;
if (queued.has(key)) return;
queued.add(key);
queue.push({ spellId, classId });
};
for (const [spellId, classIds] of classIdsBySpell) {
for (const classId of classIds) enqueue(spellId, classId);
}
for (let cursor = 0; cursor < queue.length; cursor += 1) {
const { spellId: sourceSpellId, classId } = queue[cursor];
for (const targetSpellId of targetsBySource.get(sourceSpellId) ?? []) {
const targetClasses = classIdsBySpell.get(targetSpellId) ?? new Set();
if (!targetClasses.has(classId)) {
targetClasses.add(classId);
const evidenceItems = evidenceBySpell.get(targetSpellId) ?? [];
evidenceItems.push({
kind: "trigger-payload-owner",
sourceId: "dbc-effect-trigger",
sourceSpellId,
classId,
});
evidenceBySpell.set(targetSpellId, evidenceItems);
}
classIdsBySpell.set(targetSpellId, targetClasses);
enqueue(targetSpellId, classId);
}
}
}
function makeSpellRecords({ spellRecords, evidenceBySpell, classIdsBySpell, nodeNameBySpell, rankRowsBySpell, diagnostics, overlayContains, archivedIds, archiveSource }) {
const spellIds = new Set([...evidenceBySpell.keys(), ...spellRecords.keys()]);
const spells = [];
for (const id of [...spellIds].sort((left, right) => left - right)) {
const dbc = spellRecords.get(id) ?? null;
const evidence = evidenceBySpell.get(id) ?? [];
const nodeName = nodeNameBySpell.get(id)?.find((item) => item.name)?.name ?? "";
const classRow = evidence.find((item) => item.kind === "local-cad-ability");
const rankRow = rankRowsBySpell.get(id);
const classCatalogName = classRow ? classRow.abilityKey : "";
const cadAbility = classCatalogName ? classCatalogName : null;
const name = dbc?.name || nodeName || "";
const rank = dbc?.rankText || (rankRow?.rank ? `Rank ${rankRow.rank}` : "");
const nodeDescription = nodeNameBySpell.get(id)?.find((item) => item.description)?.description ?? "";
if (!dbc) {
addDiagnostic(diagnostics, {
code: "missing-spell-reference",
severity: "warning",
message: overlayContains.has(id)
? `Spell ID ${id} is referenced by the selected local baseline but absent from BASE patch-T Spell.dbc; the comparative patch-D row was not used.`
: `Spell ID ${id} is referenced by the selected local baseline but absent from BASE patch-T Spell.dbc.`,
spellId: id,
overlayOnly: overlayContains.has(id),
references: evidence,
}, (item) => `${item.code}:${item.spellId}`);
}
spells.push({
id,
classIds: [...(classIdsBySpell.get(id) ?? [])].sort((a, b) => a - b),
name,
rank,
description: nodeDescription || dbc?.description || "",
source: archivedIds.has(id) ? archiveSource.path : "client-current/patch-T/DBFilesClient/Spell.dbc",
sourceEvidence: [
...evidence,
...(rankRow ? [{ kind: "local-spell-rank-data", firstSpellId: Number(rankRow.firstSpellId), rank: Number(rankRow.rank), level: Number(rankRow.level) }] : []),
...(dbc ? [{ kind: "base-spell-dbc-row", sourceId: archivedIds.has(id) ? archiveSource.id : spellSourceId }] : []),
...(!dbc && overlayContains.has(id) ? [{ kind: "comparative-overlay-presence-only", sourceId: "area52-spell-overlay" }] : []),
...(cadAbility ? [{ kind: "cad-ability-key", abilityKey: cadAbility }] : []),
],
...(dbc ? { dbc } : {}),
});
}
return spells;
}
function requiredJsonSource(sources, id, absolutePath, portablePath, role, shape) {
const source = addFileSource(sources, id, absolutePath, portablePath, "json", role, true);
const value = readJson(source.buffer, portablePath);
if (shape === "array") assert(Array.isArray(value), `${portablePath} must contain a JSON array`);
if (shape === "object") assert(value && typeof value === "object" && !Array.isArray(value), `${portablePath} must contain a JSON object`);
return { source: source.source, value };
}
/** Build an offline, source-labelled reference object from the July baseline and local client files. */
export function buildLocalReference(options = {}) {
const roots = resolveSourceRoots(options);
const sources = [];
const diagnostics = [];
diagnostics._keys = new Set();
const baselineRelativePath = resolveBaselinePath(roots.projectRoot);
const baselinePath = path.join(roots.projectRoot, baselineRelativePath);
const baselineSource = requiredJsonSource(sources, "july-coa-node-baseline", baselinePath, baselineRelativePath, "baseline", "object");
const baseline = baselineSource.value;
assert(Array.isArray(baseline.classes), `${baselineRelativePath} has no classes array`);
assert(Array.isArray(baseline.entries), `${baselineRelativePath} has no entries array`);
assert(Array.isArray(baseline.progressionChains), `${baselineRelativePath} has no progressionChains array`);
assert(baseline.spells === undefined || Array.isArray(baseline.spells), `${baselineRelativePath} spells must be an array when present`);
assert(baseline.source && typeof baseline.source.synchronizedAt === "string", `${baselineRelativePath} has no fixed snapshot timestamp`);
assert(Number.isFinite(Date.parse(baseline.source.synchronizedAt)), `${baselineRelativePath} has an invalid snapshot timestamp`);
const cadPath = path.join(roots.clientRoot, "Data", "Content", "CharacterAdvancementData.json");
const rankPath = path.join(roots.clientRoot, "Data", "Content", "SpellRankData.json");
const cad = requiredJsonSource(sources, "client-cad", cadPath, "client/Data/Content/CharacterAdvancementData.json", "content", "array");
const rankData = requiredJsonSource(sources, "client-spell-ranks", rankPath, "client/Data/Content/SpellRankData.json", "content", "array");
for (const [index, row] of cad.value.entries()) {
assert(row && typeof row === "object" && !Array.isArray(row), `CharacterAdvancementData.json row ${index} must be an object`);
}
for (const [index, row] of rankData.value.entries()) {
assert(row && typeof row === "object" && !Array.isArray(row), `SpellRankData.json row ${index} must be an object`);
}
const baseSpellPath = path.join(roots.extractedRoot, ...baseSpellRelativePath.split("/"));
const baseSpellSource = addFileSource(sources, spellSourceId, baseSpellPath, `extracts/${baseSpellRelativePath}`, "dbc", "base", true, 234);
assert(baseSpellSource.header.fieldCount >= 234, "BASE patch-T Spell.dbc does not expose the expected 234-field schema");
const spellHeader = baseSpellSource.header;
const classRoot = path.join(roots.extractedRoot, "client-current", "patch-M", "DBFilesClient");
const classDbcSource = addFileSource(sources, "global-chr-classes", path.join(classRoot, "ChrClasses.dbc"), "extracts/client-current/patch-M/DBFilesClient/ChrClasses.dbc", "dbc", "class-metadata", true, 20);
const specDbcSource = addFileSource(sources, "global-chr-specs", path.join(classRoot, "ChrSpecs.dbc"), "extracts/client-current/patch-M/DBFilesClient/ChrSpecs.dbc", "dbc", "class-metadata", true, 10);
const skillDbcSource = addFileSource(sources, "global-skill-line-ability", path.join(classRoot, "SkillLineAbility.dbc"), "extracts/client-current/patch-M/DBFilesClient/SkillLineAbility.dbc", "dbc", "class-ability-metadata", true, 6);
const classDbc = collectClassMaps(classDbcSource.buffer, classDbcSource.header, baseline.classes);
const specNames = new Set(baseline.classes.flatMap((definition) => [
definition.className,
...(classAliases.get(Number(definition.classId)) ?? []),
]));
const specEvidence = collectSpecEvidence(specDbcSource.buffer, specDbcSource.header, specNames);
const classes = baseline.classes.map((definition) => ({ ...definition }));
const classCatalogResult = buildClassCatalogs(classes, cad.value, classDbc, specEvidence, diagnostics);
for (const { classId, row } of classCatalogResult.filteredCadRows) {
const numericId = Number(row.ID);
if (!Number.isInteger(numericId)) {
addDiagnostic(diagnostics, {
code: "invalid-cad-row-id",
severity: "warning",
message: `Local CAD row for ${row.Class} has no numeric ID.`,
classId,
name: row.Name ?? "",
});
}
}
const classIds = new Set(classes.map((definition) => Number(definition.classId)));
const nodes = baseline.entries.filter((node) => classIds.has(Number(node.classId))).map((node) => ({ ...node }));
const progressionChains = baseline.progressionChains
.filter((chain) => classIds.has(Number(chain.classId)))
.map((chain) => ({ ...chain, ranks: (chain.ranks ?? []).map((rank) => ({ ...rank })) }));
const skillLineBySpellId = buildSkillLineIndex(skillDbcSource.buffer, skillDbcSource.header);
const supportRoot = path.join(roots.extractedRoot, "client-current", "patch-S", "DBFilesClient");
const supportDefinitions = [
["spell-cast-times", "SpellCastTimes.dbc", 2, "number"],
["spell-durations", "SpellDuration.dbc", 4, "number"],
["spell-radii", "SpellRadius.dbc", 4, "float"],
["spell-ranges", "SpellRange.dbc", 5, "range"],
];
const supports = { castTimes: null, durations: null, radii: null, ranges: null };
for (const [id, fileName, minimumFields, valueKind] of supportDefinitions) {
const displayPath = `extracts/client-current/patch-S/DBFilesClient/${fileName}`;
const source = addFileSource(sources, id, path.join(supportRoot, fileName), displayPath, "dbc", "support", false, minimumFields);
if (!source) {
addDiagnostic(diagnostics, {
code: "missing-optional-support-dbc",
severity: "warning",
message: `${displayPath} is missing; dependent support values remain null when an index is nonzero.`,
sourceId: id,
}, (item) => `${item.code}:${item.sourceId}`);
continue;
}
if (valueKind === "range") supports.ranges = rangeTable(source.buffer, source.header);
else if (valueKind === "float") supports.radii = tableMap(source.buffer, source.header, 3, "float");
else if (fileName === "SpellCastTimes.dbc") supports.castTimes = tableMap(source.buffer, source.header, 1, "number");
else supports.durations = tableMap(source.buffer, source.header, 3, "number");
}
const overlayPath = path.join(roots.extractedRoot, ...overlaySpellRelativePath.split("/"));
const overlaySource = addFileSource(sources, "area52-spell-overlay", overlayPath, `extracts/${overlaySpellRelativePath}`, "dbc", "comparative-overlay", false, 234);
const overlayContains = new Set();
if (overlaySource) {
for (let row = 0; row < overlaySource.header.recordCount; row += 1) {
overlayContains.add(field(overlaySource.buffer, overlaySource.header, 20 + row * overlaySource.header.recordSize, 0));
}
} else {
addDiagnostic(diagnostics, {
code: "missing-comparative-spell-overlay",
severity: "info",
message: `Comparative overlay ${overlaySpellRelativePath} was not available; BASE patch-T remains the only Spell.dbc source.`,
sourceId: "area52-spell-overlay",
});
}
const area52ListArchivePath = path.join(roots.clientRoot, "Data", "area-52", "listarchive");
const area52ListArchive = addFileSource(sources, "area52-listarchive", area52ListArchivePath, "client/Data/area-52/listarchive", "text", "source-order-evidence");
const globalListArchivePath = path.join(roots.clientRoot, "Data", "listarchive");
const globalListArchive = addFileSource(sources, "global-listarchive", globalListArchivePath, "client/Data/listarchive", "text", "source-order-evidence");
const extractionReadmePath = path.join(roots.extractedRoot, "client-current", "README.md");
const extractionReadme = addFileSource(sources, "extraction-readme", extractionReadmePath, "extracts/client-current/README.md", "text", "source-order-evidence");
const areaArchiveText = area52ListArchive?.buffer.toString("utf8").trim() ?? "";
const READMEText = extractionReadme?.buffer.toString("utf8") ?? "";
const hasPatchDArchive = /(?:^|\s)patch-D\.MPQ(?:\s|$)/im.test(areaArchiveText);
const readmeClaimsRealmOverride = /area-52\/patch-D[\s\S]{0,180}realm override/i.test(READMEText);
const readmeClaimsPatchTFallback = /patch-T[\s\S]{0,140}fallback/i.test(READMEText);
if (!globalListArchive || hasPatchDArchive || (readmeClaimsRealmOverride && readmeClaimsPatchTFallback)) {
addDiagnostic(diagnostics, {
code: "source-order-conflict",
severity: "warning",
message: "Local source notes point to area-52 patch-D while this reference export selects global patch-T as BASE. The archive list and extraction README do not independently establish the runtime merge order; patch-D is recorded as a comparative overlay only.",
evidence: {
globalListArchivePresent: Boolean(globalListArchive),
area52ListArchiveSelectsPatchD: hasPatchDArchive,
extractionReadmeClaimsRealmOverride: readmeClaimsRealmOverride,
extractionReadmeClaimsPatchTFallback: readmeClaimsPatchTFallback,
},
}, (item) => item.code);
}
if (!globalListArchive) {
addDiagnostic(diagnostics, {
code: "global-listarchive-missing",
severity: "info",
message: "client/Data/listarchive is absent; only the area-52 listarchive is present in the local client tree.",
sourceId: "global-listarchive",
}, (item) => item.code);
}
const classFilteredRows = classCatalogResult.filteredCadRows;
const { abilities, ownersBySpellId, rankRowsBySpell } = buildAbilities({
filteredCadRows: classFilteredRows,
nodes,
spellRankData: rankData.value,
skillLineBySpellId,
diagnostics,
});
const spellEvidence = buildSpellEvidence({
nodes,
extraNodes: options.extraNodes ?? [],
baselineSpells: baseline.spells ?? [],
progressionChains,
abilities,
spellRankData: rankData.value,
ownersBySpellId,
});
const seedSpellIds = new Set(spellEvidence.evidenceBySpell.keys());
const { records: spellRecords, triggerEvidenceBySpellId, archivedIds } = parseSpellRows(baseSpellSource.buffer, spellHeader, seedSpellIds, supports, diagnostics, options.archivedSpells);
if (options.archivedSpells) sources.push({ ...options.archivedSpells.source, role: "missing-global-spell-supplement", kind: "raw-archive-dbc" });
for (const [spellId, triggerEvidence] of triggerEvidenceBySpellId) {
const existing = spellEvidence.evidenceBySpell.get(spellId) ?? [];
existing.push(...triggerEvidence);
spellEvidence.evidenceBySpell.set(spellId, existing);
}
propagateTriggerClassOwnership(
spellEvidence.classIdsBySpell,
spellEvidence.evidenceBySpell,
triggerEvidenceBySpellId,
);
const spells = makeSpellRecords({
spellRecords,
evidenceBySpell: spellEvidence.evidenceBySpell,
classIdsBySpell: spellEvidence.classIdsBySpell,
nodeNameBySpell: spellEvidence.nodeNameBySpell,
rankRowsBySpell,
diagnostics,
overlayContains,
archivedIds,
archiveSource: options.archivedSpells?.source,
});
const spellById = new Map(spells.map((spell) => [Number(spell.id), spell]));
for (const chain of progressionChains) {
for (const rank of chain.ranks ?? []) {
if (spellById.get(Number(rank.spellId))?.dbc) continue;
addDiagnostic(diagnostics, {
code: "missing-progression-spell",
severity: "warning",
message: `Progression chain ${chain.id} references spell ${rank.spellId}, which has no local BASE Spell.dbc row.`,
sourceId: "july-coa-node-baseline",
chainId: chain.id,
classId: Number(chain.classId),
spellId: Number(rank.spellId),
}, (item) => `${item.code}:${item.chainId}:${item.spellId}`);
}
}
const inventory = {
rootSet: roots.rootSet,
spellTables: {
base: {
sourceId: spellSourceId,
path: `extracts/${baseSpellRelativePath}`,
sha256: baseSpellSource.source.sha256,
bytes: baseSpellSource.source.bytes,
recordCount: spellHeader.recordCount,
fieldCount: spellHeader.fieldCount,
},
overlay: overlaySource ? {
sourceId: "area52-spell-overlay",
path: `extracts/${overlaySpellRelativePath}`,
sha256: overlaySource.source.sha256,
bytes: overlaySource.source.bytes,
recordCount: overlaySource.header.recordCount,
fieldCount: overlaySource.header.fieldCount,
} : null,
},
};
delete diagnostics._keys;
return {
schemaVersion: 1,
classes,
nodes,
spells,
progressionChains,
abilities,
sources,
diagnostics,
baseline: {
kind: "local-july",
capturedAt: baseline.source.synchronizedAt,
sourceId: "july-coa-node-baseline",
},
classCatalogs: classCatalogResult.catalogs,
inventory,
...(options.archivedSpells ? { archivedSpellSupplement: { source: options.archivedSpells.source, usedSpellIds: [...archivedIds].sort((a, b) => a - b), files: options.archivedSpells.files } } : {}),
};
}