Files
2026-08-14 15:56:39 -04:00

555 lines
19 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
import { parseWdbcBuffer } from "./importer.mjs";
const TABLE_NAMES = Object.freeze([
"Manastorm.dbc",
"ManastormMessages.dbc",
"ManastormModifiers.dbc",
"ManastormPlayerGroupModifiers.dbc",
]);
function sha256(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
function relativePath(projectRoot, filePath) {
return path.relative(projectRoot, filePath).replaceAll("\\", "/");
}
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 readTable(filePath) {
const buffer = fs.readFileSync(filePath);
const table = parseWdbcBuffer(buffer, path.basename(filePath));
return {
table,
provenance: {
path: filePath,
sha256: sha256(buffer),
byteLength: buffer.length,
dbc: table.header,
},
};
}
function readLuaQuotedBytes(buffer, start) {
const bytes = [];
const simpleEscapes = new Map([
[97, 7],
[98, 8],
[102, 12],
[110, 10],
[114, 13],
[116, 9],
[118, 11],
[34, 34],
[39, 39],
[92, 92],
]);
for (let cursor = start; cursor < buffer.length; cursor += 1) {
const byte = buffer[cursor];
if (byte === 34) {
return { value: Buffer.from(bytes), end: cursor + 1 };
}
if (byte !== 92) {
bytes.push(byte);
continue;
}
cursor += 1;
if (cursor >= buffer.length) throw new Error("Unterminated Lua escape sequence.");
const escaped = buffer[cursor];
if (escaped >= 48 && escaped <= 57) {
let value = escaped - 48;
let digits = 1;
while (
digits < 3
&& cursor + 1 < buffer.length
&& buffer[cursor + 1] >= 48
&& buffer[cursor + 1] <= 57
) {
cursor += 1;
value = value * 10 + buffer[cursor] - 48;
digits += 1;
}
if (value > 255) throw new Error(`Lua byte escape ${value} is out of range.`);
bytes.push(value);
} else if (escaped === 10) {
bytes.push(10);
} else if (escaped === 13) {
if (buffer[cursor + 1] === 10) cursor += 1;
bytes.push(10);
} else if (simpleEscapes.has(escaped)) {
bytes.push(simpleEscapes.get(escaped));
} else {
throw new Error(`Unsupported Lua escape byte ${escaped}.`);
}
}
throw new Error("Unterminated Lua quoted string.");
}
function lzwDictionaryKey(low, high) {
return low | (high << 8);
}
export function decompressLuaLzw(input) {
if (!Buffer.isBuffer(input) || input.length < 1) {
throw new Error("Expected a non-empty LZW Buffer.");
}
if (input[0] === 117) return input.subarray(1);
if (input[0] !== 99 || input.length < 3 || (input.length - 1) % 2 !== 0) {
throw new Error("Invalid lualzw payload.");
}
const codes = input.subarray(1);
const dictionary = new Map();
let dictionaryLow = 1;
let dictionaryHigh = 2;
const lookup = (offset) => (
codes[offset + 1] === 1
? Buffer.from([codes[offset]])
: dictionary.get(lzwDictionaryKey(codes[offset], codes[offset + 1]))
);
let previous = lookup(0);
if (!previous) throw new Error("Invalid first lualzw code.");
const chunks = [previous];
for (let offset = 2; offset < codes.length; offset += 2) {
let decoded = lookup(offset);
if (!decoded) decoded = Buffer.concat([previous, previous.subarray(0, 1)]);
chunks.push(decoded);
if (dictionaryLow >= 256) {
dictionaryLow = 1;
dictionaryHigh += 1;
if (dictionaryHigh >= 256) {
dictionary.clear();
dictionaryHigh = 2;
}
}
dictionary.set(
lzwDictionaryKey(dictionaryLow, dictionaryHigh),
Buffer.concat([previous, decoded.subarray(0, 1)]),
);
dictionaryLow += 1;
previous = decoded;
}
return Buffer.concat(chunks);
}
export function extractAioAddon(cacheBuffer, addonName) {
const marker = Buffer.from(`["${addonName}"]`);
const markerOffset = cacheBuffer.indexOf(marker);
if (markerOffset < 0) throw new Error(`AIO cache does not contain ${addonName}.`);
const codeMarker = Buffer.from('["code"] = "');
const codeMarkerOffset = cacheBuffer.indexOf(codeMarker, markerOffset);
if (codeMarkerOffset < 0) throw new Error(`AIO cache entry ${addonName} has no code string.`);
const prefix = cacheBuffer
.subarray(markerOffset, codeMarkerOffset)
.toString("latin1");
const crcMatch = prefix.match(/\["crc"\]\s*=\s*(\d+)/);
const encoded = readLuaQuotedBytes(cacheBuffer, codeMarkerOffset + codeMarker.length).value;
let source;
let encoding;
if (encoded[0] === 67) {
source = decompressLuaLzw(encoded.subarray(1));
encoding = "AIO_Compressed+lualzw";
} else if (encoded[0] === 85) {
source = encoded.subarray(1);
encoding = "AIO_Uncompressed";
} else {
throw new Error(`Unknown AIO code prefix ${encoded[0]}.`);
}
return {
addonName,
crc: crcMatch ? Number(crcMatch[1]) : null,
encoding,
encodedByteLength: encoded.length,
decodedByteLength: source.length,
decodedSha256: sha256(source),
source: source.toString("utf8"),
};
}
function inspectAddonContract(addon) {
const handlers = [...addon.source.matchAll(
/function\s+MyHandlers\.(\w+)\s*\(([^)]*)\)/g,
)].map((match) => ({
name: match[1],
parameters: match[2].split(",").map((value) => value.trim()).filter(Boolean),
}));
return {
addonName: addon.addonName,
crc: addon.crc,
encoding: addon.encoding,
encodedByteLength: addon.encodedByteLength,
decodedByteLength: addon.decodedByteLength,
decodedSha256: addon.decodedSha256,
handlerNamespace: addon.source.match(/AIO\.AddHandlers\("([^"]+)"/)?.[1] ?? null,
handlers,
displaySemantics: {
level: addon.source.includes('levelText:SetText("Wave " .. level)')
? 'The level argument is rendered verbatim as "Wave <level>".'
: null,
boss: addon.source.includes("bossText:SetText(bossName)")
? "The bossName argument is rendered verbatim."
: null,
elapsedTime: addon.source.includes("timerValue = timerValue + elapsed")
? "While isRunning is true, the client increments timeSecs every frame."
: null,
countdown: addon.source.includes('"Next wave in " .. seconds .. "s"')
? 'Non-negative seconds render as "Next wave in <seconds>s"; negative values hide the countdown.'
: null,
safetyBubble: addon.source.includes('bubbleText:SetText(string.format("Safety Bubble: %.1f yd"')
? {
label: "Safety Bubble: <distanceRemaining> yd",
defaultRadiusYards: 9,
dangerThresholdYards: 1.5,
warningThresholdYards: 3,
}
: null,
},
};
}
function parseMessages(table) {
return Array.from({ length: table.header.recordCount }, (_, recordIndex) => ({
sourceRecordId: table.uint(recordIndex, 0),
modeId: table.uint(recordIndex, 1),
progressionValue: table.uint(recordIndex, 2),
groupId: table.uint(recordIndex, 3),
icon: iconBasename(cleanText(table.string(table.uint(recordIndex, 4)))),
title: table.localizedString(recordIndex, 5),
description: table.localizedString(recordIndex, 22),
}));
}
function normalizedAffixName(value) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function parseAffixDefinitions(spellTable, affixUnlocks) {
const unlocksByName = new Map();
for (const unlock of affixUnlocks) {
const key = normalizedAffixName(unlock.name);
const group = unlocksByName.get(key) ?? [];
group.push(unlock);
unlocksByName.set(key, group);
}
const spellsByName = new Map();
for (let recordIndex = 0; recordIndex < spellTable.header.recordCount; recordIndex += 1) {
const spellId = spellTable.uint(recordIndex, 0);
if (spellId < 90_000 || spellId >= 100_000) continue;
const name = cleanText(spellTable.localizedString(recordIndex, 136));
const descriptions = [
spellTable.localizedString(recordIndex, 187),
spellTable.localizedString(recordIndex, 170),
].map(cleanText).filter(Boolean);
const description = descriptions.find((value) => !/^UPDATE YOUR CLIENT!?$/i.test(value))
?? descriptions[0]
?? "";
if (!name || !description) continue;
const key = normalizedAffixName(name);
if (!unlocksByName.has(key)) continue;
const candidates = spellsByName.get(key) ?? [];
candidates.push({ spellId, name, description });
spellsByName.set(key, candidates);
}
return [...unlocksByName.entries()].map(([key, unlocks]) => {
const spell = (spellsByName.get(key) ?? []).sort(
(left, right) => left.spellId - right.spellId,
)[0];
if (!spell) {
throw new Error(`No authoritative Spell.dbc definition resolved for affix ${unlocks[0].name}.`);
}
const modes = [...new Set(unlocks.map((unlock) => unlock.modeId))].sort(
(left, right) => left - right,
);
const modeZero = unlocks.find((unlock) => unlock.modeId === 0) ?? unlocks[0];
return {
id: `manastorm-affix-${key}`,
name: modeZero.name,
trigger: modeZero.trigger,
unlockLevel: modeZero.progressionValue,
modeIds: modes,
iconReference: modeZero.icon,
spellId: spell.spellId,
description: spell.description.replace(/^.*?(?=Wild Magic spawns)/, ""),
sourceRecordIds: unlocks.map((unlock) => unlock.sourceRecordId).sort(
(left, right) => left - right,
),
};
}).sort((left, right) => left.unlockLevel - right.unlockLevel || left.id.localeCompare(right.id));
}
function classifyMessages(messages) {
const affixUnlocks = [];
const equipmentUnlocks = [];
const portalMentions = [];
const nextRunUnlocks = [];
const defeatedUnlocks = [];
for (const message of messages) {
const affix = message.title.match(/^(On Death|Passive) Affix:\s*(.+?)\s+Unlocked!?$/i);
if (affix) {
affixUnlocks.push({
...message,
trigger: affix[1].toLowerCase() === "on death" ? "on-death" : "passive",
name: affix[2],
});
}
if (/^Unlocked .+ Items!$/i.test(message.title)) equipmentUnlocks.push(message);
if (/\bportal\b/i.test(`${message.title} ${message.description}`)) {
portalMentions.push(message);
}
if (/in your next Manastorm/i.test(message.description)) nextRunUnlocks.push(message);
if (/\bDefeated!?$/i.test(message.title)) defeatedUnlocks.push(message);
}
return {
affixUnlocks,
equipmentUnlocks,
portalMentions,
nextRunUnlocks,
defeatedUnlocks,
};
}
function walkFiles(root) {
if (!fs.existsSync(root)) return [];
const files = [];
const visit = (directory) => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) visit(entryPath);
else if (entry.isFile()) files.push(entryPath);
}
};
visit(root);
return files.sort((left, right) => left.localeCompare(right));
}
export function generateManastormForensics(options = {}) {
const projectRoot = path.resolve(options.projectRoot ?? process.cwd());
const extractedRoot = path.resolve(
options.extractedRoot ?? path.join(projectRoot, "..", "LadiksMPQEditor"),
);
const realmDbcRoot = path.resolve(
options.realmDbcRoot
?? path.join(extractedRoot, "client-current", "area-52", "patch-D", "DBFilesClient"),
);
const legacyDbcRoot = path.resolve(
options.legacyDbcRoot ?? path.join(extractedRoot, "patch-M", "DBFilesClient"),
);
const aioCachePath = path.resolve(
options.aioCachePath
?? path.join(
projectRoot,
"..",
"wow335a",
"WTF",
"Account",
"PHENOM",
"SavedVariables",
"AIO_Client.lua",
),
);
const audioRoot = path.resolve(
options.audioRoot ?? path.join(extractedRoot, "patch-O", "sound", "Manastorm"),
);
const currentTables = new Map();
const tableComparisons = TABLE_NAMES.map((name) => {
const current = readTable(path.join(realmDbcRoot, name));
const legacy = readTable(path.join(legacyDbcRoot, name));
currentTables.set(name, current.table);
return {
table: name,
effectiveRealmOverride: {
...current.provenance,
path: relativePath(projectRoot, current.provenance.path),
},
legacyExtract: {
...legacy.provenance,
path: relativePath(projectRoot, legacy.provenance.path),
},
changed: current.provenance.sha256 !== legacy.provenance.sha256,
deltas: {
recordCount: current.table.header.recordCount - legacy.table.header.recordCount,
fieldCount: current.table.header.fieldCount - legacy.table.header.fieldCount,
recordSize: current.table.header.recordSize - legacy.table.header.recordSize,
stringBlockSize:
current.table.header.stringBlockSize - legacy.table.header.stringBlockSize,
},
};
});
const cacheBuffer = fs.readFileSync(aioCachePath);
const addon = extractAioAddon(cacheBuffer, "manastorms_client.lua");
const messages = parseMessages(currentTables.get("ManastormMessages.dbc"));
const classifiedMessages = classifyMessages(messages);
const spell = readTable(path.join(realmDbcRoot, "Spell.dbc"));
const affixDefinitions = parseAffixDefinitions(spell.table, classifiedMessages.affixUnlocks);
const audioFiles = walkFiles(audioRoot);
const portalAudio = audioFiles
.filter((filePath) => /portal/i.test(path.basename(filePath)))
.map((filePath) => relativePath(projectRoot, filePath));
return {
schemaVersion: 1,
reportId: "ascension-manastorm-client-forensics",
sourceBoundary: {
effectiveClientLayer:
"The Area-52 patch-D extract is the realm override and is treated as the effective DBC source.",
legacyComparison:
"The older extracted patch-M tables are used only to identify structural/data changes.",
aioCache:
"The decoded AIO cache is authoritative for the client handler contract, not server-side cadence or encounter rules.",
},
sources: {
aioCache: {
path: relativePath(projectRoot, aioCachePath),
sha256: sha256(cacheBuffer),
byteLength: cacheBuffer.length,
},
audioRoot: relativePath(projectRoot, audioRoot),
spellDbc: {
...spell.provenance,
path: relativePath(projectRoot, spell.provenance.path),
},
},
tableComparisons,
aioContract: inspectAddonContract(addon),
messages: {
total: messages.length,
counts: {
affixUnlocks: classifiedMessages.affixUnlocks.length,
uniqueAffixes: new Set(
classifiedMessages.affixUnlocks.map((message) => message.name.toLowerCase()),
).size,
equipmentUnlocks: classifiedMessages.equipmentUnlocks.length,
portalMentions: classifiedMessages.portalMentions.length,
nextRunUnlocks: classifiedMessages.nextRunUnlocks.length,
defeatedUnlocks: classifiedMessages.defeatedUnlocks.length,
},
affixUnlocks: classifiedMessages.affixUnlocks,
affixDefinitions,
equipmentUnlocks: classifiedMessages.equipmentUnlocks,
portalMentions: classifiedMessages.portalMentions,
},
audioEvidence: {
fileCount: audioFiles.length,
portalCueCount: portalAudio.length,
portalCues: portalAudio,
},
evidenceAssessment: {
authoritative: [
"Manastorm.dbc supplies map, mode, encounter, and five scalar fields for 1,025 effective realm rows.",
"ManastormMessages.dbc supplies localized progression/unlock text, including affix names and equipment-tier unlock messages.",
"manastorms_client.lua supplies three client handlers: UpdateOverlay, UpdateCountdown, and UpdateBubble.",
"Portal-named audio cues establish that portal-ready and portal-coming-up announcements exist.",
],
inferred: [
"The paired mode 0 and mode 2 affix messages appear to describe the same unlock catalog in two modes.",
"ProgressionValue is an unlock trigger/index because it accompanies unlock messages; its server-side evaluation is not present client-side.",
"The equipment messages identify reward tiers, but not concrete item IDs or loot tables.",
],
notFound: [
"No authoritative x/y/z encounter placement records were found.",
"No encounter-to-creature-template relation was found; encounter IDs are not treated as creature IDs.",
"No encounter spell loadouts or spell-casting cadence were found.",
"No portal destination, spawn cadence, or checkpoint rule was found.",
"No concrete reward item IDs, currency IDs, cache IDs, or loot tables were found.",
],
},
};
}
export function renderManastormForensicsMarkdown(report) {
const lines = [
"# Manastorm client forensic report",
"",
"This report separates data directly present in the installed/extracted client from interpretation.",
"",
"## Effective DBC tables",
"",
"| Table | Effective rows/fields | Legacy rows/fields | Changed |",
"| --- | ---: | ---: | --- |",
...report.tableComparisons.map((entry) => (
`| ${entry.table} | ${entry.effectiveRealmOverride.dbc.recordCount}/${entry.effectiveRealmOverride.dbc.fieldCount}`
+ ` | ${entry.legacyExtract.dbc.recordCount}/${entry.legacyExtract.dbc.fieldCount}`
+ ` | ${entry.changed ? "yes" : "no"} |`
)),
"",
"## Cached client contract",
"",
`AIO namespace: \`${report.aioContract.handlerNamespace}\`.`,
"",
"| Handler | Parameters |",
"| --- | --- |",
...report.aioContract.handlers.map((handler) => (
`| ${handler.name} | ${handler.parameters.join(", ")} |`
)),
"",
'The addon renders `level` as "Wave <level>", renders `bossName` verbatim, increments '
+ "`timeSecs` while `isRunning` is true, displays a next-wave countdown, and displays "
+ "a safety-bubble distance meter (9 yd default; warning at 3 yd; danger at 1.5 yd).",
"",
"## Affix unlock definitions",
"",
"| Affix | Trigger | Progression value | Modes | Icon |",
"| --- | --- | ---: | --- | --- |",
...report.messages.affixDefinitions
.map((affix) => (
`| ${affix.name} | ${affix.trigger} | ${affix.unlockLevel}`
+ ` | ${affix.modeIds.join(", ")} | ${affix.iconReference} (spell ${affix.spellId}) |`
)),
"",
"## Reward-tier evidence",
"",
`${report.messages.counts.equipmentUnlocks} localized messages unlock named equipment tiers. `
+ "They contain no concrete item IDs or loot-table bindings.",
"",
"| Message | Progression value | Group |",
"| --- | ---: | ---: |",
...report.messages.equipmentUnlocks
.sort((left, right) => left.progressionValue - right.progressionValue)
.map((message) => (
`| ${message.title} | ${message.progressionValue} | ${message.groupId} |`
)),
"",
"## Evidence boundary",
"",
"Authoritative:",
"",
...report.evidenceAssessment.authoritative.map((value) => `- ${value}`),
"",
"Inference:",
"",
...report.evidenceAssessment.inferred.map((value) => `- ${value}`),
"",
"Not found in the searched client sources:",
"",
...report.evidenceAssessment.notFound.map((value) => `- ${value}`),
"",
];
return lines.join("\n");
}