#!/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"; 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 }; } function decodeM2TrackHeader(buffer, offset) { if (offset < 0 || offset + 20 > buffer.length) return null; return { interpolation: buffer.readUInt16LE(offset), globalSequence: buffer.readInt16LE(offset + 2), timeCount: buffer.readUInt32LE(offset + 4), timeOffset: buffer.readUInt32LE(offset + 8), keyCount: buffer.readUInt32LE(offset + 12), keyOffset: buffer.readUInt32LE(offset + 16), }; } /** Decode the stable WotLK 3.x particle/ribbon fields used by the browser fallback converter. */ export function decodeWotlkM2Effects(buffer, name = "M2") { if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer); const magic = buffer.toString("ascii", 0, 4); if ((magic !== "MD20" && magic !== "MD21") || buffer.length < 304) { throw new Error(`${name} is not a supported WotLK M2.`); } const ribbonCount = buffer.readUInt32LE(288); const ribbonOffset = buffer.readUInt32LE(292); const particleCount = buffer.readUInt32LE(296); const particleOffset = buffer.readUInt32LE(300); const particleRecordSize = 476; const ribbonRecordSize = 176; if (particleCount && particleOffset + particleCount * particleRecordSize > buffer.length) { throw new Error(`${name} has truncated particle records.`); } if (ribbonCount && ribbonOffset + ribbonCount * ribbonRecordSize > buffer.length) { throw new Error(`${name} has truncated ribbon records.`); } const trackOffsets = { emissionSpeed: 56, speedVariation: 76, verticalRange: 96, horizontalRange: 116, gravity: 136, lifespan: 156, emissionRate: 180, emissionAreaLength: 200, emissionAreaWidth: 220, zSource: 240, color: 260, alpha: 280, scale: 300, }; const particles = Array.from({ length: particleCount }, (_, index) => { const offset = particleOffset + index * particleRecordSize; return { id: buffer.readUInt32LE(offset), flags: buffer.readUInt32LE(offset + 4), position: [buffer.readFloatLE(offset + 8), buffer.readFloatLE(offset + 12), buffer.readFloatLE(offset + 16)], parentBone: buffer.readUInt16LE(offset + 20), textureIndex: buffer.readUInt16LE(offset + 22), blendMode: buffer.readUInt8(offset + 40), emitterType: buffer.readUInt8(offset + 41), particleType: buffer.readUInt8(offset + 44), headOrTail: buffer.readUInt8(offset + 45), textureRows: buffer.readUInt16LE(offset + 48), textureColumns: buffer.readUInt16LE(offset + 50), tracks: Object.fromEntries(Object.entries(trackOffsets) .map(([track, relativeOffset]) => [track, decodeM2TrackHeader(buffer, offset + relativeOffset)])), }; }); const ribbons = Array.from({ length: ribbonCount }, (_, index) => { const offset = ribbonOffset + index * ribbonRecordSize; return { id: buffer.readUInt32LE(offset), parentBone: buffer.readUInt32LE(offset + 4), position: [buffer.readFloatLE(offset + 8), buffer.readFloatLE(offset + 12), buffer.readFloatLE(offset + 16)], textureCount: buffer.readUInt32LE(offset + 20), textureOffset: buffer.readUInt32LE(offset + 24), materialCount: buffer.readUInt32LE(offset + 28), materialOffset: buffer.readUInt32LE(offset + 32), colorTrack: decodeM2TrackHeader(buffer, offset + 36), }; }); return { magic, 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); } } 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, (row) => ({ effects: Array.from({ length: 13 }, (_, offset) => row.uint(3 + offset)).filter(Boolean), soundId: row.uint(16), })); 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; } 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; } function buildRunewakerEntries(spellIds, imageBySpellId, dataRoot) { const magicCollect = fs.readFileSync(path.join(dataRoot, "magiccollectobject.db")); 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))]; const images = parseRunewakerImageRecords(fs.readFileSync(path.join(dataRoot, "imageobject.db")), requestedImages); const entries = []; for (const spellId of spellIds) { const imageId = imageBySpellId.get(spellId); if (!imageId) continue; const paths = images.get(imageId) ?? []; 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 phase = /(?:^|_)t(?:_|\.)|explode|impact|hit/.test(lower) ? "impact" : /(?:^|_)f(?:_|\.)|fly|missile/.test(lower) ? "release" : /buff|aura|extend/.test(lower) ? "aura-start" : "cast-start"; (phases[phase] ??= []).push({ model: sourcePath }); } if (!Object.keys(phases).length && !sounds.length) continue; entries.push({ key: `runewaker:${spellId}`, source: "runewaker", spellId, imageId, phases, ...(sounds.length ? { sounds } : {}), }); } 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 = [ "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 = 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("/", "\\"); for (const { archive } of archives) { try { buffer = archive.extractFile(archiveName); if (buffer?.length) break; } catch { // The source archive stack is sparse by design; continue to the base archives. } } } 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, }; } 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 === "--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 = 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}`); }