import type { CombatPresentationEvent, CombatVfxMode, CombatVfxQuality, } from "../game/combatPresentation"; export interface OriginalCombatAuraIdentity { readonly targetId: string; readonly definition: { readonly id: string; readonly name: string; }; } export function originalAuraMatchesPresentationEvent( aura: OriginalCombatAuraIdentity, event: CombatPresentationEvent, ): boolean { if (aura.targetId !== event.targetActorId) return false; if (event.sourceSpellId !== undefined && aura.definition.id.startsWith(`wow335:${event.sourceSpellId}:`)) return true; return aura.definition.id.includes(event.abilityId) || aura.definition.name === event.name || aura.definition.name.startsWith(`${event.name} (`); } export type OriginalCombatEffectVector2 = readonly [number, number]; export type OriginalCombatEffectVector3 = readonly [number, number, number]; export interface OriginalCombatEffectTimeSample { readonly timeMs: number; readonly value: T; } export interface OriginalCombatEffectLifetimeSample { readonly normalizedTime: number; readonly value: T; } export type OriginalCombatEffectBlend = | "opaque" | "alpha-key" | "alpha" | "no-alpha-add" | "add" | "mod" | "mod-2x" | "blend-add"; export type OriginalCombatEffectEmitter = "none" | "plane" | "sphere" | "spline" | "bone"; export interface OriginalCombatEffectAtlas { readonly rows: number; readonly columns: number; readonly rotation: number; } export interface OriginalCombatEffectTexture { readonly type: number; readonly flags: number; readonly filename: string; readonly sourcePath?: string; readonly sourceSha256?: string; readonly url?: string; } export interface OriginalCombatEffectAnimation { readonly index: number; readonly id: number; readonly variationIndex: number; readonly durationMs: number; readonly moveSpeed: number; readonly flags: number; } export interface OriginalCombatEffectParticleSamples { readonly emissionSpeed: readonly OriginalCombatEffectTimeSample[]; readonly speedVariation: readonly OriginalCombatEffectTimeSample[]; readonly verticalRange: readonly OriginalCombatEffectTimeSample[]; readonly horizontalRange: readonly OriginalCombatEffectTimeSample[]; readonly gravity: readonly OriginalCombatEffectTimeSample[]; readonly lifespan: readonly OriginalCombatEffectTimeSample[]; readonly emissionRate: readonly OriginalCombatEffectTimeSample[]; readonly emissionAreaLength: readonly OriginalCombatEffectTimeSample[]; readonly emissionAreaWidth: readonly OriginalCombatEffectTimeSample[]; readonly zSource: readonly OriginalCombatEffectTimeSample[]; readonly color: readonly OriginalCombatEffectLifetimeSample[]; readonly alpha: readonly OriginalCombatEffectLifetimeSample[]; readonly scale: readonly OriginalCombatEffectLifetimeSample[]; readonly headCell: readonly OriginalCombatEffectLifetimeSample[]; readonly tailCell: readonly OriginalCombatEffectLifetimeSample[]; readonly enabled: readonly OriginalCombatEffectTimeSample[]; } export type OriginalCombatEffectInterpolationMode = 0 | 1 | 2 | 3; export type OriginalCombatEffectAnimatedTrackName = | "emissionSpeed" | "speedVariation" | "verticalRange" | "horizontalRange" | "gravity" | "lifespan" | "emissionRate" | "emissionAreaLength" | "emissionAreaWidth" | "zSource" | "enabled"; export interface OriginalCombatEffectParticle { readonly id: number; readonly flags: number; readonly position: OriginalCombatEffectVector3; readonly parentBone: number; readonly textureIndex: number; readonly geometryModel: string; readonly recursionModel: string; readonly blendMode: number; readonly emitterType: number; readonly particleColorIndex: number; readonly particleType: number; readonly headOrTail: number; readonly textureTileRotation: number; readonly textureRows: number; readonly textureColumns: number; readonly lifespanVariation: number; readonly emissionRateVariation: number; readonly scaleVariation: OriginalCombatEffectVector2; readonly tailLength: number; readonly interpolation: Readonly>; readonly globalSequence: Readonly>; readonly samples: OriginalCombatEffectParticleSamples; readonly blend: OriginalCombatEffectBlend; readonly emitter: OriginalCombatEffectEmitter; readonly atlas: OriginalCombatEffectAtlas; } export interface OriginalCombatEffectRuntimeMetadata { readonly renderer: "wow-m2-particles-v1"; readonly readyParticleIndices: readonly number[]; readonly unsupportedRibbonCount: number; readonly unsupportedGeometryVertexCount: number; } export interface OriginalCombatEffectGraph { readonly schemaVersion: 1; readonly kind: "wow-m2-particle-effect"; readonly source: "wow335"; readonly sourceModel: string; readonly normalizedSourceModel: string; readonly sourceSha256: string; readonly durationMs: number; readonly animations: readonly OriginalCombatEffectAnimation[]; readonly textures: readonly OriginalCombatEffectTexture[]; readonly particles: readonly OriginalCombatEffectParticle[]; readonly runtime: OriginalCombatEffectRuntimeMetadata; } const PARTICLE_TIME_SAMPLE_KEYS = [ "emissionSpeed", "speedVariation", "verticalRange", "horizontalRange", "gravity", "lifespan", "emissionRate", "emissionAreaLength", "emissionAreaWidth", "zSource", ] as const; const PARTICLE_LIFETIME_SAMPLE_KEYS = ["color", "alpha", "scale"] as const; const PARTICLE_ANIMATED_TRACK_KEYS = [...PARTICLE_TIME_SAMPLE_KEYS, "enabled"] as const; const BLEND_BY_CODE: readonly OriginalCombatEffectBlend[] = [ "opaque", "alpha-key", "alpha", "no-alpha-add", "add", "mod", "mod-2x", "blend-add", ]; const EMITTER_BY_CODE: readonly OriginalCombatEffectEmitter[] = [ "none", "plane", "sphere", "spline", "bone", ]; type JsonRecord = Record; type InterpolatedValue = number | OriginalCombatEffectVector2 | OriginalCombatEffectVector3; function exactObject( value: unknown, label: string, requiredKeys: readonly string[], optionalKeys: readonly string[] = [], ): JsonRecord { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new TypeError(`${label} must be an object.`); } const prototype = Object.getPrototypeOf(value); if (prototype !== Object.prototype && prototype !== null) { throw new TypeError(`${label} must be a plain JSON object.`); } const record = value as JsonRecord; const allowed = new Set([...requiredKeys, ...optionalKeys]); for (const key of requiredKeys) { if (!Object.prototype.hasOwnProperty.call(record, key)) { throw new TypeError(`${label}.${key} is required.`); } } for (const key of Object.keys(record)) { if (!allowed.has(key)) throw new TypeError(`${label}.${key} is not part of schema version 1.`); } return record; } function finiteNumber(value: unknown, label: string): number { if (typeof value !== "number" || !Number.isFinite(value)) { throw new TypeError(`${label} must be a finite number.`); } return value; } function integerInRange(value: unknown, label: string, minimum: number, maximum: number): number { const parsed = finiteNumber(value, label); if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { throw new TypeError(`${label} must be an integer from ${minimum} through ${maximum}.`); } return parsed; } function literalString(value: unknown, expected: T, label: string): T { if (value !== expected) throw new TypeError(`${label} must be ${JSON.stringify(expected)}.`); return expected; } function stringValue(value: unknown, label: string, allowEmpty = true): string { if (typeof value !== "string" || (!allowEmpty && value.trim().length === 0)) { throw new TypeError(`${label} must be ${allowEmpty ? "a string" : "a non-empty string"}.`); } return value; } function sha256(value: unknown, label: string): string { const digest = stringValue(value, label, false); if (!/^[0-9a-f]{64}$/.test(digest)) { throw new TypeError(`${label} must be a lowercase SHA-256 digest.`); } return digest; } function vector2(value: unknown, label: string): OriginalCombatEffectVector2 { if (!Array.isArray(value) || value.length !== 2) { throw new TypeError(`${label} must contain exactly two numbers.`); } return [finiteNumber(value[0], `${label}[0]`), finiteNumber(value[1], `${label}[1]`)]; } function vector3(value: unknown, label: string): OriginalCombatEffectVector3 { if (!Array.isArray(value) || value.length !== 3) { throw new TypeError(`${label} must contain exactly three numbers.`); } return [ finiteNumber(value[0], `${label}[0]`), finiteNumber(value[1], `${label}[1]`), finiteNumber(value[2], `${label}[2]`), ]; } function parseTimeSamples(value: unknown, label: string): readonly OriginalCombatEffectTimeSample[] { if (!Array.isArray(value)) throw new TypeError(`${label} must be an array.`); let previousTime = -Infinity; return value.map((entry, index) => { const sampleLabel = `${label}[${index}]`; const sample = exactObject(entry, sampleLabel, ["timeMs", "value"]); const timeMs = finiteNumber(sample.timeMs, `${sampleLabel}.timeMs`); if (timeMs < 0 || timeMs < previousTime) { throw new TypeError(`${sampleLabel}.timeMs must be non-negative and ordered.`); } previousTime = timeMs; return { timeMs, value: finiteNumber(sample.value, `${sampleLabel}.value`) }; }); } function parseLifetimeSamples( value: unknown, label: string, parseValue: (raw: unknown, valueLabel: string) => T, ): readonly OriginalCombatEffectLifetimeSample[] { if (!Array.isArray(value)) throw new TypeError(`${label} must be an array.`); let previousTime = -Infinity; return value.map((entry, index) => { const sampleLabel = `${label}[${index}]`; const sample = exactObject(entry, sampleLabel, ["normalizedTime", "value"], ["timestamp"]); const normalizedTime = finiteNumber(sample.normalizedTime, `${sampleLabel}.normalizedTime`); if (normalizedTime < 0 || normalizedTime > 1 || normalizedTime < previousTime) { throw new TypeError(`${sampleLabel}.normalizedTime must be ordered from zero through one.`); } previousTime = normalizedTime; if (sample.timestamp !== undefined) { const timestamp = integerInRange(sample.timestamp, `${sampleLabel}.timestamp`, 0, 32_767); if (Math.abs(normalizedTime - timestamp / 32_767) > 1e-9) { throw new TypeError(`${sampleLabel}.normalizedTime does not match its timestamp.`); } } return { normalizedTime, value: parseValue(sample.value, `${sampleLabel}.value`), }; }); } function parseAnimation(value: unknown, index: number): OriginalCombatEffectAnimation { const label = `effect graph.animations[${index}]`; const record = exactObject(value, label, ["index", "id", "variationIndex", "durationMs", "moveSpeed", "flags"]); return { index: integerInRange(record.index, `${label}.index`, 0, 0xffff_ffff), id: integerInRange(record.id, `${label}.id`, 0, 0xffff), variationIndex: integerInRange(record.variationIndex, `${label}.variationIndex`, 0, 0xffff), durationMs: integerInRange(record.durationMs, `${label}.durationMs`, 0, 0xffff_ffff), moveSpeed: finiteNumber(record.moveSpeed, `${label}.moveSpeed`), flags: integerInRange(record.flags, `${label}.flags`, 0, 0xffff_ffff), }; } function parseTexture(value: unknown, index: number): OriginalCombatEffectTexture { const label = `effect graph.textures[${index}]`; const record = exactObject( value, label, ["type", "flags", "filename"], ["sourcePath", "sourceSha256", "url"], ); const texture: OriginalCombatEffectTexture = { type: integerInRange(record.type, `${label}.type`, 0, 0xffff_ffff), flags: integerInRange(record.flags, `${label}.flags`, 0, 0xffff_ffff), filename: stringValue(record.filename, `${label}.filename`), }; const hasSourcePath = record.sourcePath !== undefined; const hasDigest = record.sourceSha256 !== undefined; const hasUrl = record.url !== undefined; if (hasDigest !== hasUrl || ((hasDigest || hasUrl) && !hasSourcePath)) { throw new TypeError(`${label} packaged texture metadata must be complete.`); } if (!hasSourcePath) return texture; const locatedTexture: OriginalCombatEffectTexture = { ...texture, sourcePath: stringValue(record.sourcePath, `${label}.sourcePath`, false), }; if (!hasDigest) return locatedTexture; return { ...locatedTexture, sourceSha256: sha256(record.sourceSha256, `${label}.sourceSha256`), url: stringValue(record.url, `${label}.url`, false), }; } function parseParticleSamples(value: unknown, label: string): OriginalCombatEffectParticleSamples { const record = exactObject(value, label, [ ...PARTICLE_TIME_SAMPLE_KEYS, ...PARTICLE_LIFETIME_SAMPLE_KEYS, "headCell", "tailCell", "enabled", ]); const timeSamples = Object.fromEntries(PARTICLE_TIME_SAMPLE_KEYS.map((key) => [ key, parseTimeSamples(record[key], `${label}.${key}`), ])) as unknown as Pick; return { ...timeSamples, color: parseLifetimeSamples(record.color, `${label}.color`, vector3), alpha: parseLifetimeSamples(record.alpha, `${label}.alpha`, finiteNumber), scale: parseLifetimeSamples(record.scale, `${label}.scale`, vector2), headCell: parseLifetimeSamples(record.headCell, `${label}.headCell`, finiteNumber), tailCell: parseLifetimeSamples(record.tailCell, `${label}.tailCell`, finiteNumber), enabled: parseTimeSamples(record.enabled, `${label}.enabled`), }; } function parseParticleTrackMetadata( value: unknown, label: string, minimum: number, maximum: number, ): Readonly> { const record = exactObject(value, label, PARTICLE_ANIMATED_TRACK_KEYS); return Object.fromEntries(PARTICLE_ANIMATED_TRACK_KEYS.map((key) => [ key, integerInRange(record[key], `${label}.${key}`, minimum, maximum), ])) as Readonly>; } function parseParticle( value: unknown, index: number, textureCount: number, ): OriginalCombatEffectParticle { const label = `effect graph.particles[${index}]`; const record = exactObject(value, label, [ "id", "flags", "position", "parentBone", "textureIndex", "geometryModel", "recursionModel", "blendMode", "emitterType", "particleColorIndex", "particleType", "headOrTail", "textureTileRotation", "textureRows", "textureColumns", "lifespanVariation", "emissionRateVariation", "scaleVariation", "tailLength", "interpolation", "globalSequence", "samples", ]); const textureIndex = integerInRange(record.textureIndex, `${label}.textureIndex`, 0, 0xffff); if (textureIndex >= textureCount) throw new TypeError(`${label}.textureIndex has no matching texture.`); const blendMode = integerInRange(record.blendMode, `${label}.blendMode`, 0, BLEND_BY_CODE.length - 1); const emitterType = integerInRange(record.emitterType, `${label}.emitterType`, 0, EMITTER_BY_CODE.length - 1); const textureRows = integerInRange(record.textureRows, `${label}.textureRows`, 1, 0xffff); const textureColumns = integerInRange(record.textureColumns, `${label}.textureColumns`, 1, 0xffff); const textureTileRotation = integerInRange(record.textureTileRotation, `${label}.textureTileRotation`, 0, 0xffff); return { id: integerInRange(record.id, `${label}.id`, 0, 0xffff_ffff), flags: integerInRange(record.flags, `${label}.flags`, 0, 0xffff_ffff), position: vector3(record.position, `${label}.position`), parentBone: integerInRange(record.parentBone, `${label}.parentBone`, 0, 0xffff), textureIndex, geometryModel: stringValue(record.geometryModel, `${label}.geometryModel`), recursionModel: stringValue(record.recursionModel, `${label}.recursionModel`), blendMode, emitterType, particleColorIndex: integerInRange(record.particleColorIndex, `${label}.particleColorIndex`, 0, 0xffff), particleType: integerInRange(record.particleType, `${label}.particleType`, 0, 0xff), headOrTail: integerInRange(record.headOrTail, `${label}.headOrTail`, 0, 0xff), textureTileRotation, textureRows, textureColumns, lifespanVariation: finiteNumber(record.lifespanVariation, `${label}.lifespanVariation`), emissionRateVariation: finiteNumber(record.emissionRateVariation, `${label}.emissionRateVariation`), scaleVariation: vector2(record.scaleVariation, `${label}.scaleVariation`), tailLength: finiteNumber(record.tailLength, `${label}.tailLength`), interpolation: parseParticleTrackMetadata( record.interpolation, `${label}.interpolation`, 0, 3, ) as OriginalCombatEffectParticle["interpolation"], globalSequence: parseParticleTrackMetadata( record.globalSequence, `${label}.globalSequence`, -32_768, 32_767, ), samples: parseParticleSamples(record.samples, `${label}.samples`), blend: BLEND_BY_CODE[blendMode], emitter: EMITTER_BY_CODE[emitterType], atlas: { rows: textureRows, columns: textureColumns, rotation: textureTileRotation }, }; } function parseRuntime( value: unknown, particles: readonly OriginalCombatEffectParticle[], textures: readonly OriginalCombatEffectTexture[], ): OriginalCombatEffectRuntimeMetadata { const label = "effect graph.runtime"; const record = exactObject(value, label, [ "renderer", "readyParticleIndices", "unsupportedRibbonCount", "unsupportedGeometryVertexCount", ]); literalString(record.renderer, "wow-m2-particles-v1", `${label}.renderer`); if (!Array.isArray(record.readyParticleIndices)) { throw new TypeError(`${label}.readyParticleIndices must be an array.`); } let previousIndex = -1; const readyParticleIndices = record.readyParticleIndices.map((entry, index) => { const particleIndex = integerInRange(entry, `${label}.readyParticleIndices[${index}]`, 0, 0xffff_ffff); if (particleIndex >= particles.length) { throw new TypeError(`${label}.readyParticleIndices[${index}] has no matching particle.`); } if (particleIndex <= previousIndex) { throw new TypeError(`${label}.readyParticleIndices must be unique and ordered.`); } previousIndex = particleIndex; const texture = textures[particles[particleIndex].textureIndex]; if (!texture?.url) { throw new TypeError(`${label}.readyParticleIndices[${index}] references an unpackaged texture.`); } return particleIndex; }); return { renderer: "wow-m2-particles-v1", readyParticleIndices, unsupportedRibbonCount: integerInRange( record.unsupportedRibbonCount, `${label}.unsupportedRibbonCount`, 0, 0xffff_ffff, ), unsupportedGeometryVertexCount: integerInRange( record.unsupportedGeometryVertexCount, `${label}.unsupportedGeometryVertexCount`, 0, 0xffff_ffff, ), }; } export function parseOriginalCombatEffectGraph(value: unknown): OriginalCombatEffectGraph { const graph = exactObject(value, "effect graph", [ "schemaVersion", "kind", "source", "sourceModel", "normalizedSourceModel", "sourceSha256", "durationMs", "animations", "textures", "particles", "runtime", ]); integerInRange(graph.schemaVersion, "effect graph.schemaVersion", 1, 1); literalString(graph.kind, "wow-m2-particle-effect", "effect graph.kind"); literalString(graph.source, "wow335", "effect graph.source"); if (!Array.isArray(graph.animations)) throw new TypeError("effect graph.animations must be an array."); if (!Array.isArray(graph.textures)) throw new TypeError("effect graph.textures must be an array."); if (!Array.isArray(graph.particles)) throw new TypeError("effect graph.particles must be an array."); const textures = graph.textures.map(parseTexture); const particles = graph.particles.map((particle, index) => parseParticle(particle, index, textures.length)); return { schemaVersion: 1, kind: "wow-m2-particle-effect", source: "wow335", sourceModel: stringValue(graph.sourceModel, "effect graph.sourceModel", false), normalizedSourceModel: stringValue( graph.normalizedSourceModel, "effect graph.normalizedSourceModel", false, ), sourceSha256: sha256(graph.sourceSha256, "effect graph.sourceSha256"), durationMs: integerInRange(graph.durationMs, "effect graph.durationMs", 0, 0xffff_ffff), animations: graph.animations.map(parseAnimation), textures, particles, runtime: parseRuntime(graph.runtime, particles, textures), }; } export function isOriginalCombatEffectGraph(value: unknown): boolean { try { parseOriginalCombatEffectGraph(value); return true; } catch { return false; } } export function interpolateOriginalScalar( samples: readonly OriginalCombatEffectTimeSample[], timeMs: number, interpolation: OriginalCombatEffectInterpolationMode = 1, ): number | null { if (samples.length === 0) return null; const position = Number.isFinite(timeMs) ? Math.max(0, timeMs) : 0; if (position <= samples[0].timeMs) return samples[0].value; for (let index = 1; index < samples.length; index += 1) { const upper = samples[index]; if (position > upper.timeMs) continue; const lower = samples[index - 1]; const span = upper.timeMs - lower.timeMs; if (span <= 0) return upper.value; if (interpolation === 0) return position < upper.timeMs ? lower.value : upper.value; const progress = (position - lower.timeMs) / span; return lower.value + (upper.value - lower.value) * progress; } return samples[samples.length - 1].value; } function interpolateValue(lower: T, upper: T, progress: number): T { if (typeof lower === "number" && typeof upper === "number") { return (lower + (upper - lower) * progress) as T; } if (!Array.isArray(lower) || !Array.isArray(upper) || lower.length !== upper.length) { throw new TypeError("Lifetime sample values must have a consistent shape."); } return lower.map((component, index) => ( component + (upper[index] - component) * progress )) as unknown as T; } export function interpolateOriginalLifetime( samples: readonly OriginalCombatEffectLifetimeSample[], normalizedTime: number, ): number | null; export function interpolateOriginalLifetime( samples: readonly OriginalCombatEffectLifetimeSample[], normalizedTime: number, ): OriginalCombatEffectVector2 | null; export function interpolateOriginalLifetime( samples: readonly OriginalCombatEffectLifetimeSample[], normalizedTime: number, ): OriginalCombatEffectVector3 | null; export function interpolateOriginalLifetime( samples: readonly OriginalCombatEffectLifetimeSample[], normalizedTime: number, ): InterpolatedValue | null { if (samples.length === 0) return null; const position = Number.isFinite(normalizedTime) ? Math.min(1, Math.max(0, normalizedTime)) : 0; if (position <= samples[0].normalizedTime) return samples[0].value; for (let index = 1; index < samples.length; index += 1) { const upper = samples[index]; if (position > upper.normalizedTime) continue; const lower = samples[index - 1]; const span = upper.normalizedTime - lower.normalizedTime; if (span <= 0) return upper.value; return interpolateValue(lower.value, upper.value, (position - lower.normalizedTime) / span); } return samples[samples.length - 1].value; } export const ORIGINAL_PARTICLE_LIMIT_PER_EMITTER: Readonly< Record, number> > = Object.freeze({ low: 48, high: 128 }); function maximumPositiveSample(samples: readonly OriginalCombatEffectTimeSample[]): number { return samples.reduce((maximum, sample) => ( Number.isFinite(sample.value) && sample.value > maximum ? sample.value : maximum ), 0); } export function originalEmitterParticleBudget( particle: Pick, quality: CombatVfxQuality, ): number { if (quality === "off") return 0; // Animated emitters commonly begin at zero, so readiness and capacity must // consider the whole track rather than treating the first key as authoritative. const peakEmissionRate = maximumPositiveSample(particle.samples.emissionRate); const peakLifespanSeconds = maximumPositiveSample(particle.samples.lifespan); if (peakEmissionRate <= 0 || peakLifespanSeconds <= 0) return 0; const requested = Math.max(1, Math.ceil(peakEmissionRate * peakLifespanSeconds)); return Math.min(requested, ORIGINAL_PARTICLE_LIMIT_PER_EMITTER[quality]); } export interface OriginalCombatEffectPhaseAsset { readonly effectGraphUrl?: string; } export type OriginalCombatEffectRenderSelection = | { readonly kind: "generated" } | { readonly kind: "original"; readonly effectGraphUrl: string }; export function selectCombatEffectRender( mode: CombatVfxMode, phaseAssets: readonly OriginalCombatEffectPhaseAsset[] | null | undefined, ): OriginalCombatEffectRenderSelection | null { if (mode === "generated") return { kind: "generated" }; const authentic = phaseAssets?.find((asset) => ( typeof asset.effectGraphUrl === "string" && asset.effectGraphUrl.trim().length > 0 )); return authentic?.effectGraphUrl ? { kind: "original", effectGraphUrl: authentic.effectGraphUrl } : null; }