#!/usr/bin/env node import { createHash } from "node:crypto"; import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { verifiedRuneWakerParticlePhases } from "./rom-particle-readiness.mjs"; const require = createRequire(import.meta.url); const { MpqArchive } = require("stormlib-js"); const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); function sha256(buffer) { return createHash("sha256").update(buffer).digest("hex"); } export function parseDbc(buffer, name = "DBC") { if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer); if (buffer.toString("ascii", 0, 4) !== "WDBC") throw new Error(`${name} is not WDBC data.`); const recordCount = buffer.readUInt32LE(4); const fieldCount = buffer.readUInt32LE(8); const recordSize = buffer.readUInt32LE(12); const stringSize = buffer.readUInt32LE(16); const recordsEnd = 20 + recordCount * recordSize; if (recordSize < fieldCount * 4 || recordsEnd + stringSize > buffer.length) { throw new Error(`${name} has an unsupported or truncated schema.`); } const stringAt = (offset) => { if (!offset || offset >= stringSize) return ""; const start = recordsEnd + offset; const end = buffer.indexOf(0, start); return buffer.toString("utf8", start, end < 0 ? buffer.length : end); }; const row = (index) => { const base = 20 + index * recordSize; return { uint: (field) => field < fieldCount ? buffer.readUInt32LE(base + field * 4) : 0, int: (field) => field < fieldCount ? buffer.readInt32LE(base + field * 4) : 0, float: (field) => field < fieldCount ? buffer.readFloatLE(base + field * 4) : 0, string: (field) => stringAt(field < fieldCount ? buffer.readUInt32LE(base + field * 4) : 0), }; }; return { name, buffer, recordCount, fieldCount, recordSize, stringSize, row }; } /** * Decode the WotLK SpellVisualKit field boundary. Fields 3..14 are visual * effect-name IDs; field 15 is SoundID and must never be resolved as a model. */ export function decodeWotlkSpellVisualKitRow(row) { return { effects: Array.from({ length: 12 }, (_, offset) => row.uint(3 + offset)).filter(Boolean), soundId: row.uint(15), }; } const M2_VALUE_TYPES = { float32: { byteLength: 4, read: (buffer, offset) => buffer.readFloatLE(offset) }, uint32: { byteLength: 4, read: (buffer, offset) => buffer.readUInt32LE(offset) }, uint16: { byteLength: 2, read: (buffer, offset) => buffer.readUInt16LE(offset) }, uint8: { byteLength: 1, read: (buffer, offset) => buffer.readUInt8(offset) }, vector2: { byteLength: 8, read: (buffer, offset) => [buffer.readFloatLE(offset), buffer.readFloatLE(offset + 4)], }, vector3: { byteLength: 12, read: (buffer, offset) => [ buffer.readFloatLE(offset), buffer.readFloatLE(offset + 4), buffer.readFloatLE(offset + 8), ], }, }; function assertM2Range(buffer, offset, byteLength, label) { if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(byteLength) || offset < 0 || byteLength < 0 || offset > buffer.length - byteLength) { throw new Error(`${label} points outside the M2 buffer.`); } } function readM2ArrayHeader(buffer, offset, label) { assertM2Range(buffer, offset, 8, `${label} descriptor`); return { count: buffer.readUInt32LE(offset), offset: buffer.readUInt32LE(offset + 4) }; } function decodeM2Array(buffer, offset, valueType, label) { const descriptor = readM2ArrayHeader(buffer, offset, label); const byteLength = descriptor.count * valueType.byteLength; if (!Number.isSafeInteger(byteLength)) throw new Error(`${label} is too large to decode.`); if (descriptor.count) assertM2Range(buffer, descriptor.offset, byteLength, label); return { ...descriptor, values: Array.from({ length: descriptor.count }, (_, index) => ( valueType.read(buffer, descriptor.offset + index * valueType.byteLength) )), }; } function decodeM2NestedArray(buffer, offset, valueType, label) { const descriptor = readM2ArrayHeader(buffer, offset, label); const byteLength = descriptor.count * 8; if (!Number.isSafeInteger(byteLength)) throw new Error(`${label} is too large to decode.`); if (descriptor.count) assertM2Range(buffer, descriptor.offset, byteLength, `${label} sequence descriptors`); return { ...descriptor, sequences: Array.from({ length: descriptor.count }, (_, index) => ( decodeM2Array(buffer, descriptor.offset + index * 8, valueType, `${label} sequence ${index}`) )), }; } function decodeM2StringArray(buffer, offset, label) { const descriptor = readM2ArrayHeader(buffer, offset, label); if (!descriptor.count) return ""; assertM2Range(buffer, descriptor.offset, descriptor.count, label); const bytes = buffer.subarray(descriptor.offset, descriptor.offset + descriptor.count); const terminator = bytes.indexOf(0); return bytes.toString("utf8", 0, terminator < 0 ? bytes.length : terminator); } function decodeM2Track(buffer, offset, valueType, label) { assertM2Range(buffer, offset, 20, `${label} track`); const interpolation = buffer.readUInt16LE(offset); if (interpolation > 3) throw new Error(`${label} uses unsupported interpolation ${interpolation}.`); const spline = interpolation === 2 || interpolation === 3; const trackValueType = spline ? { byteLength: valueType.byteLength * 3, read: (source, valueOffset) => ({ value: valueType.read(source, valueOffset), inTangent: valueType.read(source, valueOffset + valueType.byteLength), outTangent: valueType.read(source, valueOffset + valueType.byteLength * 2), }), } : valueType; const timestamps = decodeM2NestedArray(buffer, offset + 4, M2_VALUE_TYPES.uint32, `${label} timestamps`); const values = decodeM2NestedArray(buffer, offset + 12, trackValueType, `${label} values`); const firstTimestamps = timestamps.sequences[0]?.values ?? []; const firstValues = values.sequences[0]?.values ?? []; return { kind: "animated", interpolation, globalSequence: buffer.readInt16LE(offset + 2), timestamps, values, firstSequence: { timestamps: firstTimestamps, values: firstValues, samples: Array.from({ length: Math.min(firstTimestamps.length, firstValues.length) }, (_, index) => ({ timeMs: firstTimestamps[index], value: firstValues[index], })), }, }; } function decodeM2ParticleLifetimeTrack(buffer, offset, valueType, label) { const timestamps = decodeM2Array(buffer, offset, M2_VALUE_TYPES.uint16, `${label} timestamps`); const values = decodeM2Array(buffer, offset + 8, valueType, `${label} values`); return { kind: "particle-lifetime", timestamps, values, samples: Array.from({ length: Math.min(timestamps.values.length, values.values.length) }, (_, index) => ({ timestamp: timestamps.values[index], normalizedTime: timestamps.values[index] / 32_767, value: values.values[index], })), }; } /** Decode render-neutral descriptors from the WotLK 3.3.5 (version 264) MD20 layout. */ export function decodeWotlkM2Effects(buffer, name = "M2") { if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer); const magic = buffer.toString("ascii", 0, 4); if (magic !== "MD20" || buffer.length < 304 || buffer.readUInt32LE(4) !== 264) { throw new Error(`${name} is not a supported WotLK 3.3.5 MD20.`); } const version = buffer.readUInt32LE(4); const animationCount = buffer.readUInt32LE(28); const animationOffset = buffer.readUInt32LE(32); const textureCount = buffer.readUInt32LE(80); const textureOffset = buffer.readUInt32LE(84); const ribbonCount = buffer.readUInt32LE(288); const ribbonOffset = buffer.readUInt32LE(292); const particleCount = buffer.readUInt32LE(296); const particleOffset = buffer.readUInt32LE(300); const animationRecordSize = 64; const textureRecordSize = 16; const ribbonRecordSize = 176; const particleRecordSize = 476; if (animationCount) assertM2Range(buffer, animationOffset, animationCount * animationRecordSize, `${name} animation records`); if (textureCount) assertM2Range(buffer, textureOffset, textureCount * textureRecordSize, `${name} texture records`); if (ribbonCount) assertM2Range(buffer, ribbonOffset, ribbonCount * ribbonRecordSize, `${name} ribbon records`); if (particleCount) assertM2Range(buffer, particleOffset, particleCount * particleRecordSize, `${name} particle records`); const animations = Array.from({ length: animationCount }, (_, index) => { const offset = animationOffset + index * animationRecordSize; return { index, id: buffer.readUInt16LE(offset), variationIndex: buffer.readUInt16LE(offset + 2), durationMs: buffer.readUInt32LE(offset + 4), moveSpeed: buffer.readFloatLE(offset + 8), flags: buffer.readUInt32LE(offset + 12), }; }); const durationMs = animations.reduce((maximum, animation) => Math.max(maximum, animation.durationMs), 0); const textures = Array.from({ length: textureCount }, (_, index) => { const offset = textureOffset + index * textureRecordSize; return { type: buffer.readUInt32LE(offset), flags: buffer.readUInt32LE(offset + 4), filename: decodeM2StringArray(buffer, offset + 8, `${name} texture ${index} filename`), }; }); const animatedTrackOffsets = { emissionSpeed: 52, speedVariation: 72, verticalRange: 92, horizontalRange: 112, gravity: 132, lifespan: 152, emissionRate: 176, emissionAreaLength: 200, emissionAreaWidth: 220, zSource: 240, }; const particles = Array.from({ length: particleCount }, (_, index) => { const offset = particleOffset + index * particleRecordSize; const tracks = Object.fromEntries(Object.entries(animatedTrackOffsets).map(([track, relativeOffset]) => [ track, decodeM2Track(buffer, offset + relativeOffset, M2_VALUE_TYPES.float32, `${name} particle ${index} ${track}`), ])); tracks.color = decodeM2ParticleLifetimeTrack( buffer, offset + 260, M2_VALUE_TYPES.vector3, `${name} particle ${index} color`, ); tracks.alpha = decodeM2ParticleLifetimeTrack( buffer, offset + 276, M2_VALUE_TYPES.uint16, `${name} particle ${index} alpha`, ); tracks.scale = decodeM2ParticleLifetimeTrack( buffer, offset + 292, M2_VALUE_TYPES.vector2, `${name} particle ${index} scale`, ); tracks.headCell = decodeM2ParticleLifetimeTrack( buffer, offset + 316, M2_VALUE_TYPES.uint16, `${name} particle ${index} head cell`, ); tracks.tailCell = decodeM2ParticleLifetimeTrack( buffer, offset + 332, M2_VALUE_TYPES.uint16, `${name} particle ${index} tail cell`, ); tracks.enabled = decodeM2Track( buffer, offset + 456, M2_VALUE_TYPES.uint8, `${name} particle ${index} enabled`, ); return { id: buffer.readUInt32LE(offset), flags: buffer.readUInt32LE(offset + 4), position: M2_VALUE_TYPES.vector3.read(buffer, offset + 8), parentBone: buffer.readUInt16LE(offset + 20), textureIndex: buffer.readUInt16LE(offset + 22), geometryModel: decodeM2StringArray(buffer, offset + 24, `${name} particle ${index} geometry model`), recursionModel: decodeM2StringArray(buffer, offset + 32, `${name} particle ${index} recursion model`), blendMode: buffer.readUInt8(offset + 40), emitterType: buffer.readUInt8(offset + 41), particleColorIndex: buffer.readUInt16LE(offset + 42), particleType: buffer.readUInt8(offset + 44), headOrTail: buffer.readUInt8(offset + 45), textureTileRotation: buffer.readUInt16LE(offset + 46), textureRows: buffer.readUInt16LE(offset + 48), textureColumns: buffer.readUInt16LE(offset + 50), lifespanVariation: buffer.readFloatLE(offset + 172), emissionRateVariation: buffer.readFloatLE(offset + 196), scaleVariation: M2_VALUE_TYPES.vector2.read(buffer, offset + 308), tailLength: buffer.readFloatLE(offset + 348), tracks, samples: Object.fromEntries([ ...Object.keys(animatedTrackOffsets).map((track) => [track, tracks[track].firstSequence.samples]), ...["color", "alpha", "scale"].map((track) => [track, tracks[track].samples]), ]), }; }); const ribbons = Array.from({ length: ribbonCount }, (_, index) => { const offset = ribbonOffset + index * ribbonRecordSize; return { id: buffer.readUInt32LE(offset), parentBone: buffer.readUInt32LE(offset + 4), position: M2_VALUE_TYPES.vector3.read(buffer, offset + 8), textureIndices: decodeM2Array(buffer, offset + 20, M2_VALUE_TYPES.uint16, `${name} ribbon ${index} textures`), materialIndices: decodeM2Array(buffer, offset + 28, M2_VALUE_TYPES.uint16, `${name} ribbon ${index} materials`), edgesPerSecond: buffer.readFloatLE(offset + 116), edgeLifetime: buffer.readFloatLE(offset + 120), gravity: buffer.readFloatLE(offset + 124), textureRows: buffer.readUInt16LE(offset + 128), textureColumns: buffer.readUInt16LE(offset + 130), priorityPlane: buffer.readInt16LE(offset + 172), tracks: { color: decodeM2Track(buffer, offset + 36, M2_VALUE_TYPES.vector3, `${name} ribbon ${index} color`), alpha: decodeM2Track(buffer, offset + 56, M2_VALUE_TYPES.uint16, `${name} ribbon ${index} alpha`), heightAbove: decodeM2Track(buffer, offset + 76, M2_VALUE_TYPES.float32, `${name} ribbon ${index} height above`), heightBelow: decodeM2Track(buffer, offset + 96, M2_VALUE_TYPES.float32, `${name} ribbon ${index} height below`), textureSlot: decodeM2Track(buffer, offset + 132, M2_VALUE_TYPES.uint16, `${name} ribbon ${index} texture slot`), visibility: decodeM2Track(buffer, offset + 152, M2_VALUE_TYPES.uint8, `${name} ribbon ${index} visibility`), }, }; }); return { magic, version, durationMs, animations, textures, particles, ribbons }; } function tableById(table, decode) { const result = new Map(); for (let index = 0; index < table.recordCount; index += 1) { const row = table.row(index); result.set(row.uint(0), decode(row)); } return result; } function collectAbilitySpellIds() { const requested = new Map(); const add = (source, id) => { if (!Number.isInteger(id) || id <= 0) return; requested.set(`${source}:${id}`, { source, spellId: id }); }; const abilityCatalog = fs.readFileSync(path.join(projectRoot, "src", "game", "abilityCatalog.ts"), "utf8"); for (const match of abilityCatalog.matchAll(/dbcSpellId:\s*(\d+)/g)) add("wow335", Number(match[1])); const wow = JSON.parse(fs.readFileSync(path.join(projectRoot, "src", "game", "wow335AbilityData.generated.json"), "utf8")); for (const chains of Object.values(wow.classes ?? {})) { for (const chain of chains) for (const rank of chain.ranks ?? []) add("wow335", Number(rank.spellId)); } const coa = JSON.parse(fs.readFileSync(path.join(projectRoot, "src", "game", "generated", "coaAbilityRuntimeCatalog.json"), "utf8")); for (const abilities of Object.values(coa.abilitiesByClass ?? {})) { for (const ability of abilities) { add("ascension", Number(ability.dbcSpellId)); for (const rank of ability.ranks ?? []) add("ascension", Number(rank.spellId)); } } const rom = JSON.parse(fs.readFileSync(path.join(projectRoot, "src", "game", "romPlayerCatalog.generated.json"), "utf8")); const runewakerImageBySpellId = new Map(); for (const skill of rom.skills ?? []) { add("runewaker", Number(skill.sourceSkillId)); if (Number(skill.source?.imageId) > 0) runewakerImageBySpellId.set(Number(skill.sourceSkillId), Number(skill.source.imageId)); for (const rank of skill.ranks ?? []) { add("runewaker", Number(rank.sourceSkillId)); const imageId = Number(rank.source?.imageId ?? skill.source?.imageId); if (imageId > 0) runewakerImageBySpellId.set(Number(rank.sourceSkillId), imageId); } } const generatedRoot = path.join(projectRoot, "src", "game", "generated"); for (const name of fs.readdirSync(generatedRoot).filter((entry) => entry.endsWith("Population.generated.ts"))) { const source = fs.readFileSync(path.join(generatedRoot, name), "utf8"); for (const match of source.matchAll(/"spellId":\s*(\d+)/g)) { const spellId = Number(match[1]); add(spellId >= 490_000 && spellId < 600_000 ? "runewaker" : "ascension", spellId); } } const dungeonCatalog = JSON.parse(fs.readFileSync( path.join(generatedRoot, "dungeonCampaignCatalog.json"), "utf8", )); for (const dungeon of dungeonCatalog.definitions ?? []) { for (const entity of Object.values(dungeon.entities ?? {})) { if (entity.kind !== "boss") continue; for (const attack of entity.combat?.attacks ?? []) { if (!attack.effect) continue; const spellId = Number(attack.spellId); add(spellId >= 490_000 && spellId < 600_000 ? "runewaker" : "wow335", spellId); } } } return { requested: [...requested.values()], runewakerImageBySpellId }; } function soundEntry(table, id) { if (!id) return []; for (let index = 0; index < table.recordCount; index += 1) { const row = table.row(index); if (row.uint(0) !== id) continue; const directory = row.string(23).replaceAll("\\", "/").replace(/\/$/, ""); const volume = Number(row.float(24).toFixed(4)); const minimumDistance = Number(row.float(26).toFixed(3)); const maximumDistance = Number(row.float(27).toFixed(3)); return Array.from({ length: 10 }, (_, fileIndex) => { const file = row.string(3 + fileIndex).replaceAll("\\", "/"); if (!file) return null; return { sourcePath: directory ? `${directory}/${file}` : file, weight: Math.max(1, row.uint(13 + fileIndex)), volume: volume > 0 ? volume : 1, ...(minimumDistance > 0 ? { minimumDistance } : {}), ...(maximumDistance > 0 ? { maximumDistance } : {}), }; }).filter(Boolean); } return []; } function buildWowEntries(source, requestedIds, tables) { const spell = parseDbc(tables.Spell, `${source} Spell.dbc`); const visual = tableById(parseDbc(tables.SpellVisual, `${source} SpellVisual.dbc`), (row) => ({ precast: row.uint(1), cast: row.uint(2), impact: row.uint(3), state: row.uint(4), stateDone: row.uint(5), channel: row.uint(6), missileModel: row.uint(8), missileSound: row.uint(11), animationSound: row.uint(12), casterImpact: row.uint(14), targetImpact: row.uint(15), instantArea: row.uint(23), impactArea: row.uint(24), persistentArea: row.uint(25), })); const kitsTable = parseDbc(tables.SpellVisualKit, `${source} SpellVisualKit.dbc`); const kits = tableById(kitsTable, decodeWotlkSpellVisualKitRow); const effectNames = tableById(parseDbc(tables.SpellVisualEffectName, `${source} SpellVisualEffectName.dbc`), (row) => ({ name: row.string(1), model: row.string(2).replaceAll("\\", "/"), scale: row.float(4) || 1, })); const attachments = new Map(); const attachTable = parseDbc(tables.SpellVisualKitModelAttach, `${source} SpellVisualKitModelAttach.dbc`); for (let index = 0; index < attachTable.recordCount; index += 1) { const row = attachTable.row(index); const records = attachments.get(row.uint(1)) ?? []; records.push({ effectId: row.uint(2), attachment: `attachment:${row.uint(3)}` }); attachments.set(row.uint(1), records); } const soundTable = parseDbc(tables.SoundEntries, `${source} SoundEntries.dbc`); const kitAssets = (kitId) => { const kit = kits.get(kitId); if (!kit) return []; const attached = new Map((attachments.get(kitId) ?? []).map((entry) => [entry.effectId, entry.attachment])); return [...new Set([...kit.effects, ...(attachments.get(kitId) ?? []).map((entry) => entry.effectId)])] .map((effectId) => { const effect = effectNames.get(effectId); if (!effect?.model) return null; return { model: effect.model, ...(attached.get(effectId) ? { attachment: attached.get(effectId) } : {}), ...(effect.scale !== 1 ? { scale: Number(effect.scale.toFixed(4)) } : {}), }; }).filter(Boolean); }; const soundsForKit = (kitId) => soundEntry(soundTable, kits.get(kitId)?.soundId ?? 0); const spellRows = new Map(); const shift = spell.fieldCount >= 239 ? 6 : 0; const shifted = (field) => field >= 77 ? field + shift : field; const wanted = new Set(requestedIds); for (let index = 0; index < spell.recordCount; index += 1) { const row = spell.row(index); const id = row.uint(0); if (wanted.has(id)) spellRows.set(id, [row.uint(shifted(131)), row.uint(shifted(132))].filter(Boolean)); } const entries = []; for (const spellId of requestedIds) { const visualIds = spellRows.get(spellId) ?? []; const phases = {}; const sounds = []; const addAssets = (phase, assets) => { const usable = assets.filter(Boolean); if (usable.length) phases[phase] = [...(phases[phase] ?? []), ...usable]; }; for (const visualId of visualIds) { const row = visual.get(visualId); if (!row) continue; for (const kitId of [row.precast, row.cast]) addAssets("cast-start", kitAssets(kitId)); if (row.missileModel) { const missile = effectNames.get(row.missileModel); if (missile?.model) addAssets("release", [{ model: missile.model, scale: missile.scale }]); } for (const kitId of [row.impact, row.casterImpact, row.targetImpact]) addAssets("impact", kitAssets(kitId)); addAssets("aura-start", kitAssets(row.state)); addAssets("aura-end", kitAssets(row.stateDone)); addAssets("tick", kitAssets(row.channel)); for (const kitId of [row.instantArea, row.impactArea, row.persistentArea]) addAssets("impact", kitAssets(kitId)); for (const kitId of [row.precast, row.cast, row.impact, row.state, row.channel, row.instantArea, row.impactArea, row.persistentArea]) { sounds.push(...soundsForKit(kitId)); } sounds.push(...soundEntry(soundTable, row.missileSound), ...soundEntry(soundTable, row.animationSound)); } for (const [phase, assets] of Object.entries(phases)) { phases[phase] = [...new Map(assets.map((asset) => [`${asset.model}:${asset.attachment ?? ""}`, asset])).values()]; } const uniqueSounds = [...new Map(sounds.map((sound) => [sound.sourcePath, sound])).values()]; if (!visualIds.length && !uniqueSounds.length) continue; entries.push({ key: `${source}:${spellId}`, source, spellId, visualIds, ...(Object.keys(phases).length ? { phases } : {}), ...(uniqueSounds.length ? { sounds: uniqueSounds } : {}), }); } return entries; } function readNullSeparatedPaths(record) { const values = []; let start = 0; for (let offset = 0; offset <= record.length; offset += 1) { if (offset < record.length && record[offset] !== 0) continue; if (offset - start >= 4) { const value = record.subarray(start, offset).toString("latin1").trim(); if (/^[\x20-\x7e]+$/.test(value) && /[\\/]/.test(value)) values.push(value.replaceAll("\\", "/")); } start = offset + 1; } return values; } export function parseRunewakerImageRecords(buffer, requestedImageIds) { const count = buffer.readInt32LE(132); const recordSize = 3712; const dataStart = 140; if (count <= 0 || dataStart + count * recordSize > buffer.length) throw new Error("ImageObject.DB has an incompatible layout."); const wanted = new Set(requestedImageIds); const result = new Map(); for (let index = 0; index < count; index += 1) { const offset = dataStart + index * recordSize; const id = buffer.readInt32LE(offset); if (!wanted.has(id)) continue; result.set(id, readNullSeparatedPaths(buffer.subarray(offset, offset + recordSize))); } return result; } export function runewakerSoundPathsForModel(modelPath, resourceRoot) { if (!/^model\/fx\/.+\.(?:ros|ras)$/i.test(modelPath) || modelPath.includes("..")) return []; const soundPath = modelPath.replace(/^model\//i, "sound/"); const stem = soundPath.replace(/\.(?:ros|ras)$/i, ""); for (const candidate of [stem, soundPath]) { for (const extension of ["mp3", "wav", "ogg"]) { const sourcePath = `${candidate}.${extension}`; if (fs.existsSync(path.join(resourceRoot, ...sourcePath.split("/")))) return [sourcePath]; } } return []; } // These one-shot cues are direct 3D audio events (ID 43) in the named ROS // motions. The client places the impact models through the skill's MagicObject // component image, while the projectile model comes from ImageObject.DB. // The projectile's embedded cue differs from the parallel sound/fx filename. const verifiedRuneWakerEventSounds = [ { spellId: 491147, phase: "impact", model: "model/fx/skill/holy/act_benedictory_heal_t_down.ros", sourcePath: "sound/fx/skill/holy/act_benedictory_heal_t_down.mp3", }, { spellId: 490256, phase: "release", model: "model/fx/skill/ice/act_icecone01_f.ros", sourcePath: "sound/fx/skill/ice/icenew/act_icecone01_f.ros.mp3", }, { spellId: 490256, phase: "impact", model: "model/fx/skill/ice/act_riptide_t_freeze_hit.ros", sourcePath: "sound/fx/skill/ice/act_riptide_t_freeze_hit.mp3", }, ]; function attachVerifiedRuneWakerEventSounds(entries) { const bySpellId = new Map(entries.map((entry) => [entry.spellId, entry])); for (const { spellId, phase, model, sourcePath } of verifiedRuneWakerEventSounds) { const entry = bySpellId.get(spellId); if (!entry?.phases?.[phase]?.some((asset) => asset.model?.toLowerCase() === model)) continue; if (entry.sounds?.some((sound) => sound.phase === phase && sound.sourcePath?.toLowerCase() === sourcePath)) continue; (entry.sounds ??= []).push({ sourcePath, phase, weight: 1, volume: 1 }); } } function runewakerImageIdsFromMagicCollect(buffer, spellIds) { const count = buffer.readInt32LE(132); const recordSize = 1032; const dataStart = 140; const wanted = new Set(spellIds); const result = new Map(); for (let index = 0; index < count; index += 1) { const offset = dataStart + index * recordSize; const spellId = buffer.readInt32LE(offset); if (wanted.has(spellId)) result.set(spellId, buffer.readInt32LE(offset + 32)); } return result; } // The cast's MagicCollect ImageID omits the target-side ExtendACT. These two // component links are verified against MagicObject.ImageID and ImageObject.DB // before the target model is added to the combat presentation manifest. const verifiedRuneWakerTargetEffects = [ { spellId: 491147, componentId: 501543, imageId: 573048, model: "model/fx/skill/holy/act_benedictory_heal_t_down.ros", point: "p_down" }, { spellId: 490256, componentId: 620408, imageId: 572062, model: "model/fx/skill/ice/act_riptide_t_freeze_hit.ros", point: "freeze_hit" }, ]; function verifiedRunewakerTargetModels(magicCollect, magicObject, imageObject, spellIds) { const wanted = verifiedRuneWakerTargetEffects.filter((effect) => spellIds.includes(effect.spellId)); if (!wanted.length) return new Map(); const collectOffsets = new Map(); for (let offset = 140; offset + 1032 <= magicCollect.length; offset += 1032) { const spellId = magicCollect.readInt32LE(offset); if (wanted.some((effect) => effect.spellId === spellId)) collectOffsets.set(spellId, offset); } const componentImages = new Map(); for (let offset = 140; offset + 992 <= magicObject.length; offset += 992) { const componentId = magicObject.readInt32LE(offset); if (wanted.some((effect) => effect.componentId === componentId)) { componentImages.set(componentId, magicObject.readInt32LE(offset + 0x20)); } } const componentImageFields = new Map(); for (let offset = 140; offset + 3712 <= imageObject.length; offset += 3712) { const imageId = imageObject.readInt32LE(offset); if (!wanted.some((effect) => effect.imageId === imageId)) continue; const textField = (fieldOffset, length) => imageObject.subarray( offset + fieldOffset, offset + fieldOffset + length, ).toString("latin1").split("\0", 1)[0].replaceAll("\\", "/").toLowerCase(); componentImageFields.set(imageId, { extendAct: textField(2132, 128), extendPoint: textField(2260, 32), }); } for (const effect of wanted) { const collectOffset = collectOffsets.get(effect.spellId); const componentIds = collectOffset === undefined ? [] : Array.from( { length: (0x2c0 - 0x180) / 4 + 1 }, (_, index) => magicCollect.readInt32LE(collectOffset + 0x180 + index * 4), ); if (!componentIds.includes(effect.componentId) || componentImages.get(effect.componentId) !== effect.imageId || componentImageFields.get(effect.imageId)?.extendAct !== effect.model || componentImageFields.get(effect.imageId)?.extendPoint !== effect.point) { throw new Error(`RuneWaker target media source link changed for spell ${effect.spellId}`); } } return new Map(wanted.map((effect) => [effect.spellId, effect])); } function attachVerifiedRuneWakerParticles(entries) { const archivePath = path.join(projectRoot, "public/assets/game/combat-effects/original/runewaker/manifest.json"); if (!fs.existsSync(archivePath)) return; const archive = JSON.parse(fs.readFileSync(archivePath, "utf8")); const decoded = new Map((archive.results ?? []) .filter((result) => (result.rendererReady || result.partialRendererReady) && result.graphUrl) .map((result) => [result.model.toLowerCase(), result])); const bySpellId = new Map(entries.map((entry) => [entry.spellId, entry])); for (const { spellId, phase, model, scale, loopCastSound, partialVisual } of verifiedRuneWakerParticlePhases) { const entry = bySpellId.get(spellId); const asset = entry?.phases?.[phase]?.find((candidate) => ( candidate.model.toLowerCase() === model )); const result = decoded.get(model); if (!asset || !result || (partialVisual ? !result.partialRendererReady : !result.rendererReady)) continue; const graphPath = path.join(projectRoot, "public", result.graphUrl.slice(1)); if (!fs.existsSync(graphPath)) continue; const graph = JSON.parse(fs.readFileSync(graphPath, "utf8")); if (graph.sourceModel !== model || (partialVisual ? !graph.partialRendererReady : !graph.rendererReady)) continue; asset.effectKind = "runewaker-particles"; asset.effectGraphUrl = result.graphUrl; if (partialVisual) asset.visualCompleteness = "partial"; if (scale) asset.scale = scale; if (loopCastSound) for (const sound of entry.sounds ?? []) { if (sound.phase === "cast-start" && sound.sourcePath === "sound/fx/skill/holy/act_casting_c_hand.wav") { sound.loopDuringCast = true; } } } } function buildRunewakerEntries(spellIds, imageBySpellId, dataRoot) { const resourceRoot = path.dirname(dataRoot); const magicCollect = fs.readFileSync(path.join(dataRoot, "magiccollectobject.db")); const magicObject = fs.readFileSync(path.join(dataRoot, "magicobject.db")); const imageObject = fs.readFileSync(path.join(dataRoot, "imageobject.db")); const targetEffects = verifiedRunewakerTargetModels(magicCollect, magicObject, imageObject, spellIds); const collectedImages = runewakerImageIdsFromMagicCollect(magicCollect, spellIds); for (const [spellId, imageId] of collectedImages) if (!imageBySpellId.has(spellId)) imageBySpellId.set(spellId, imageId); const requestedImages = [...new Set([ ...spellIds.map((id) => imageBySpellId.get(id)).filter(Boolean), ...[...targetEffects.values()].map((effect) => effect.imageId), ])]; const images = parseRunewakerImageRecords(imageObject, requestedImages); const entries = []; for (const spellId of spellIds) { const imageId = imageBySpellId.get(spellId); if (!imageId) continue; const targetEffect = targetEffects.get(spellId); const targetImagePaths = targetEffect ? images.get(targetEffect.imageId) ?? [] : []; const targetModel = targetEffect && targetImagePaths.find((candidate) => ( candidate.toLowerCase() === targetEffect.model )); if (targetEffect && !targetModel) { throw new Error(`RuneWaker target ImageObject changed for spell ${spellId}`); } const paths = [...(images.get(imageId) ?? []), ...(targetModel ? [targetModel] : [])]; const phases = {}; const sounds = []; for (const sourcePath of paths) { if (/\.(?:wav|ogg|mp3)$/i.test(sourcePath)) { sounds.push({ sourcePath, weight: 1, volume: 1 }); continue; } if (!/\.(?:ros|ras)$/i.test(sourcePath)) continue; const lower = sourcePath.toLowerCase(); const filename = path.posix.basename(lower); const phase = /(?:^|_)t(?:_|\.)|explode|impact/.test(filename) || (/hit/.test(filename) && !/(?:^|_)f(?:_|\.)/.test(filename)) ? "impact" : /(?:^|_)f(?:_|\.)|fly|missile/.test(filename) ? "release" : /buff|aura|extend/.test(filename) ? "aura-start" : "cast-start"; (phases[phase] ??= []).push({ model: sourcePath }); for (const soundPath of runewakerSoundPathsForModel(sourcePath, resourceRoot)) { sounds.push({ sourcePath: soundPath, phase, weight: 1, volume: 1 }); } } for (const [phase, assets] of Object.entries(phases)) { phases[phase] = [...new Map(assets.map((asset) => [asset.model.toLowerCase(), asset])).values()]; } const uniqueSounds = [...new Map(sounds.map((sound) => [`${sound.phase ?? "any"}:${sound.sourcePath.toLowerCase()}`, sound])).values()]; if (!Object.keys(phases).length && !uniqueSounds.length) continue; entries.push({ key: `runewaker:${spellId}`, source: "runewaker", spellId, imageId, phases, ...(uniqueSounds.length ? { sounds: uniqueSounds } : {}), }); } attachVerifiedRuneWakerEventSounds(entries); attachVerifiedRuneWakerParticles(entries); return entries; } function extractWowTables(archivePath) { const archive = MpqArchive.open(archivePath); try { const extract = (name) => archive.extractFile(`DBFilesClient\\${name}.dbc`); return Object.fromEntries([ "Spell", "SpellVisual", "SpellVisualKit", "SpellVisualEffectName", "SpellVisualKitModelAttach", "SoundEntries", ].map((name) => [name, extract(name)])); } finally { archive.close(); } } function extractedAscensionTables(spellPath, supportRoot, baseSoundEntries) { const read = (name) => fs.readFileSync(path.join(supportRoot, `${name}.dbc`)); return { Spell: fs.readFileSync(spellPath), SpellVisual: read("SpellVisual"), SpellVisualKit: read("SpellVisualKit"), SpellVisualEffectName: read("SpellVisualEffectName"), SpellVisualKitModelAttach: read("SpellVisualKitModelAttach"), SoundEntries: fs.existsSync(path.join(supportRoot, "SoundEntries.dbc")) ? read("SoundEntries") : baseSoundEntries, }; } function openedWowAudioArchives(dataRoot) { const names = [ "enUS/patch-enUS-3.MPQ", "enUS/patch-enUS-2.MPQ", "enUS/patch-enUS-N.MPQ", "enUS/patch-enUS-M.MPQ", "enUS/patch-enUS.MPQ", "enUS/lichking-speech-enUS.MPQ", "enUS/expansion-speech-enUS.MPQ", "enUS/speech-enUS.MPQ", "enUS/locale-enUS.MPQ", "patch-3.MPQ", "patch-2.MPQ", "patch.MPQ", "Patch-B.MPQ", "patch-A.MPQ", "lichking.MPQ", "expansion.MPQ", "common-2.MPQ", "common.MPQ", ]; return names .map((name) => path.join(dataRoot, name)) .filter((archivePath) => fs.existsSync(archivePath) && fs.statSync(archivePath).isFile()) .map((archivePath) => ({ archivePath, archive: MpqArchive.open(archivePath) })); } function packageAudioAssets(entries, options) { const outputRoot = path.resolve(options.audioOutput ?? path.join(projectRoot, "public", "assets", "game", "combat-effects", "audio")); const publicPrefix = "/assets/game/combat-effects/audio"; const wowDataRoot = path.resolve(options.wowDataRoot ?? path.join(projectRoot, "..", "wow335a", "Data")); const runewakerResourceRoot = path.resolve(options.runewakerResourceRoot ?? path.join(projectRoot, "..", "Runewaker", "Resource")); const archives = entries.some((entry) => entry.source !== "runewaker") ? openedWowAudioArchives(wowDataRoot) : []; const sourceCache = new Map(); const missing = []; const packagedFiles = new Set(); fs.mkdirSync(outputRoot, { recursive: true }); const extractSource = (sourcePath, source) => { const cacheKey = `${source}:${sourcePath.toLowerCase()}`; if (sourceCache.has(cacheKey)) return sourceCache.get(cacheKey); let buffer = null; if (source === "runewaker") { const candidate = path.join(runewakerResourceRoot, ...sourcePath.split("/")); if (fs.existsSync(candidate)) buffer = fs.readFileSync(candidate); } else { const archiveName = sourcePath.replaceAll("/", "\\"); // Some WotLK SoundEntries use a historical Creature/*VO directory while // the matching non-voice line remained in Creature/* in common.MPQ. const archiveNames = [...new Set([ archiveName, archiveName.replace(/^(Sound\\Creature\\[^\\]+)VO\\/i, "$1\\"), ])]; for (const candidateName of archiveNames) { for (const { archive } of archives) { try { buffer = archive.extractFile(candidateName); if (buffer?.length) break; } catch { // The source archive stack is sparse by design; continue to the base archives. } } if (buffer?.length) break; } } sourceCache.set(cacheKey, buffer); return buffer; }; try { for (const entry of entries) { for (const sound of entry.sounds ?? []) { const buffer = extractSource(sound.sourcePath, entry.source); if (!buffer?.length) { missing.push(`${entry.source}:${sound.sourcePath}`); continue; } const extension = path.extname(sound.sourcePath).toLowerCase() || ".wav"; const fileName = `${sha256(buffer).slice(0, 20)}${extension}`; const outputPath = path.join(outputRoot, fileName); if (!fs.existsSync(outputPath)) fs.writeFileSync(outputPath, buffer); packagedFiles.add(fileName); sound.url = `${publicPrefix}/${fileName}`; } } } finally { for (const { archive } of archives) archive.close(); } return { referenced: sourceCache.size, packaged: packagedFiles.size, missing: [...new Set(missing)].sort(), }; } export function generateCombatEffectManifest(options = {}) { const wowArchive = path.resolve(options.wowArchive ?? path.join(projectRoot, "..", "wow335a", "Data", "enUS", "patch-enUS.MPQ")); const ascensionRoot = path.resolve(options.ascensionRoot ?? path.join(projectRoot, "..", "LadiksMPQEditor", "client-current")); const ascensionSpell = path.resolve(options.ascensionSpell ?? path.join(ascensionRoot, "area-52", "patch-D", "DBFilesClient", "Spell.dbc")); const ascensionSupport = path.resolve(options.ascensionSupport ?? path.join(ascensionRoot, "patch-S", "DBFilesClient")); const runewakerData = path.resolve(options.runewakerData ?? path.join(projectRoot, "..", "Runewaker", "Resource", "data")); const { requested, runewakerImageBySpellId } = collectAbilitySpellIds(); const requestedBySource = requested.reduce((groups, entry) => { (groups[entry.source] ??= []).push(entry); return groups; }, {}); const wowTables = extractWowTables(wowArchive); const wowEntries = buildWowEntries("wow335", (requestedBySource.wow335 ?? []).map((entry) => entry.spellId), wowTables); const ascensionTables = extractedAscensionTables(ascensionSpell, ascensionSupport, wowTables.SoundEntries); const ascensionEntries = buildWowEntries("ascension", (requestedBySource.ascension ?? []).map((entry) => entry.spellId), ascensionTables); const runewakerEntries = buildRunewakerEntries( (requestedBySource.runewaker ?? []).map((entry) => entry.spellId), runewakerImageBySpellId, runewakerData, ); const entries = [...wowEntries, ...ascensionEntries, ...runewakerEntries] .sort((left, right) => left.source.localeCompare(right.source) || left.spellId - right.spellId); const audio = options.packageAudio ? packageAudioAssets(entries, { ...options, runewakerResourceRoot: path.dirname(runewakerData) }) : { referenced: new Set(entries.flatMap((entry) => (entry.sounds ?? []).map((sound) => `${entry.source}:${sound.sourcePath}`))).size, packaged: 0, missing: [] }; const mapped = new Set(entries.map((entry) => entry.key)); const fallbackKeys = requested.map((entry) => `${entry.source}:${entry.spellId}`).filter((key) => !mapped.has(key)); return { schemaVersion: 1, generatedAt: null, sources: [ { kind: "wow335", path: path.relative(projectRoot, wowArchive).replaceAll("\\", "/"), sha256: sha256(wowTables.Spell) }, { kind: "ascension", path: path.relative(projectRoot, ascensionSpell).replaceAll("\\", "/"), sha256: sha256(ascensionTables.Spell) }, { kind: "runewaker", path: path.relative(projectRoot, runewakerData).replaceAll("\\", "/"), sha256: sha256(fs.readFileSync(path.join(runewakerData, "imageobject.db"))) }, ], coverage: { requested: requested.length, authentic: entries.length, fallback: fallbackKeys.length, fallbackKeys, audio, unsupportedConstructs: [ "WotLK event-driven child emitters use source-styled pooled fallbacks", "RuneWaker native shader programs outside decoded blend/billboard descriptors use source-styled pooled fallbacks", ], }, entries, }; } export function updateRunewakerCombatEffectManifest(options = {}) { const output = path.resolve(options.output ?? path.join(projectRoot, "public", "assets", "game", "combat-effects", "manifest.json")); const previous = JSON.parse(fs.readFileSync(output, "utf8")); const runewakerData = path.resolve(options.runewakerData ?? path.join(projectRoot, "..", "Runewaker", "Resource", "data")); const { requested, runewakerImageBySpellId } = collectAbilitySpellIds(); const runewakerEntries = buildRunewakerEntries( requested.filter((entry) => entry.source === "runewaker").map((entry) => entry.spellId), runewakerImageBySpellId, runewakerData, ); const audio = options.packageAudio ? packageAudioAssets(runewakerEntries, { ...options, runewakerResourceRoot: path.dirname(runewakerData) }) : { missing: [] }; const entries = [...previous.entries.filter((entry) => entry.source !== "runewaker"), ...runewakerEntries] .sort((left, right) => left.source.localeCompare(right.source) || left.spellId - right.spellId); const mapped = new Set(entries.map((entry) => entry.key)); const oldRunewakerRequested = previous.entries.filter((entry) => entry.source === "runewaker").length + previous.coverage.fallbackKeys.filter((key) => key.startsWith("runewaker:")).length; const runewakerRequested = requested.filter((entry) => entry.source === "runewaker"); const fallbackKeys = [ ...previous.coverage.fallbackKeys.filter((key) => !key.startsWith("runewaker:")), ...runewakerRequested.map((entry) => `runewaker:${entry.spellId}`).filter((key) => !mapped.has(key)), ]; const soundPaths = new Set(entries.flatMap((entry) => (entry.sounds ?? []).map((sound) => `${entry.source}:${sound.sourcePath}`))); const audioUrls = new Set(entries.flatMap((entry) => (entry.sounds ?? []).map((sound) => sound.url).filter(Boolean))); return { ...previous, sources: previous.sources.map((source) => source.kind === "runewaker" ? { kind: "runewaker", path: path.relative(projectRoot, runewakerData).replaceAll("\\", "/"), sha256: sha256(fs.readFileSync(path.join(runewakerData, "imageobject.db"))) } : source), coverage: { ...previous.coverage, requested: previous.coverage.requested - oldRunewakerRequested + runewakerRequested.length, authentic: entries.length, fallback: fallbackKeys.length, fallbackKeys, audio: { referenced: soundPaths.size, packaged: audioUrls.size, missing: audio.missing }, }, entries, }; } function parseArguments(arguments_) { const options = {}; for (let index = 0; index < arguments_.length; index += 1) { const argument = arguments_[index]; if (argument === "--audit") options.audit = true; else if (argument === "--package-audio") options.packageAudio = true; else if (argument === "--runewaker-only") options.runewakerOnly = true; else if (argument === "--output") options.output = arguments_[++index]; else if (argument === "--wow-archive") options.wowArchive = arguments_[++index]; else if (argument === "--ascension-root") options.ascensionRoot = arguments_[++index]; else if (argument === "--runewaker-data") options.runewakerData = arguments_[++index]; else throw new Error(`Unknown argument: ${argument}`); } return options; } if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { const options = parseArguments(process.argv.slice(2)); const output = path.resolve(options.output ?? path.join(projectRoot, "public", "assets", "game", "combat-effects", "manifest.json")); const manifest = options.runewakerOnly ? updateRunewakerCombatEffectManifest(options) : generateCombatEffectManifest(options); if (options.audit && manifest.coverage.audio.missing.length) { throw new Error(`Combat effects audit failed: ${manifest.coverage.audio.missing.length} referenced audio files could not be packaged.`); } if (!options.audit) { fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, `${JSON.stringify(manifest)}\n`); } console.log(`Combat effects: ${manifest.coverage.authentic}/${manifest.coverage.requested} source mappings; ${manifest.coverage.fallback} intentional fallbacks.`); if (!options.audit) console.log(`Wrote ${output}`); }