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 ".' : 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 s"; negative values hide the countdown.' : null, safetyBubble: addon.source.includes('bubbleText:SetText(string.format("Safety Bubble: %.1f yd"') ? { label: "Safety Bubble: 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 ", 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"); }