export type OriginalCombatMeshInterpolation = 0 | 1 | 2 | 3; export interface OriginalCombatMeshSplineValue { readonly value: T; readonly inTangent: T; readonly outTangent: T; } export type OriginalCombatMeshTrackValue = T | OriginalCombatMeshSplineValue; export interface OriginalCombatMeshTrackSequence { readonly timestamps: readonly number[]; readonly values: readonly OriginalCombatMeshTrackValue[]; } export interface OriginalCombatMeshTrack { readonly interpolation: OriginalCombatMeshInterpolation; readonly globalSequence: number; readonly sequences: readonly OriginalCombatMeshTrackSequence[]; } export interface OriginalCombatMeshAnimation { readonly index: number; readonly id: number; readonly variationIndex: number; readonly durationMs: number; readonly moveSpeed: number; readonly flags: number; } export interface OriginalCombatMeshColor { readonly color: OriginalCombatMeshTrack; readonly alpha: OriginalCombatMeshTrack; } export interface OriginalCombatMeshTexture { readonly type: number; readonly flags: number; readonly filename: string; readonly sourcePath: string; readonly sourceSha256: string; readonly url: string; } export interface OriginalCombatMeshLayer { readonly submeshIndex: number; readonly indexStart: number; readonly indexCount: number; readonly priorityPlane: number; readonly shaderId: number; readonly colorIndex: number; readonly materialLayer: number; readonly materialFlags: number; readonly blendMode: number; readonly textureIndex: number; readonly textureCoordinateIndex: number; readonly textureWeightIndex: number; readonly textureTransformIndex: number; } export interface OriginalCombatMeshGraph { readonly schemaVersion: 1; readonly kind: "wow-m2-static-mesh"; readonly source: "wow335"; readonly sourceModel: string; readonly normalizedSourceModel: string; readonly sourceSha256: string; readonly sourceSkin: string; readonly sourceSkinSha256: string; readonly globalSequences: readonly number[]; readonly animations: readonly OriginalCombatMeshAnimation[]; readonly bounds: { readonly minimum: readonly [number, number, number]; readonly maximum: readonly [number, number, number]; readonly radius: number; }; readonly geometry: { readonly positions: readonly number[]; readonly normals: readonly number[]; readonly uvs: readonly number[]; readonly indices: readonly number[]; }; readonly colors: readonly OriginalCombatMeshColor[]; readonly textures: readonly OriginalCombatMeshTexture[]; readonly textureWeights: readonly OriginalCombatMeshTrack[]; readonly layers: readonly OriginalCombatMeshLayer[]; readonly runtime: { readonly renderer: "wow-m2-static-mesh-v1"; readonly coordinateSpace: "wow-m2-source"; readonly staticFrame: "source-bind-pose"; }; } export interface OriginalCombatMeshPlaybackFrame { readonly animationIndex: number; readonly timeMs: number; readonly elapsedMs: number; readonly decaying: boolean; readonly complete: boolean; } export interface OriginalCombatMeshLayerAppearance { readonly color: readonly [number, number, number]; readonly opacity: number; } type JsonRecord = Record; function exactObject(value: unknown, label: string, keys: readonly string[]): JsonRecord { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new TypeError(`${label} must be an object.`); } const record = value as JsonRecord; const expected = new Set(keys); for (const key of keys) { if (!Object.prototype.hasOwnProperty.call(record, key)) throw new TypeError(`${label}.${key} is required.`); } for (const key of Object.keys(record)) { if (!expected.has(key)) throw new TypeError(`${label}.${key} is not part of the static-mesh schema.`); } return record; } function numberValue(value: unknown, label: string): number { if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${label} must be finite.`); return value; } function integerValue(value: unknown, label: string, minimum = 0): number { const resolved = numberValue(value, label); if (!Number.isSafeInteger(resolved) || resolved < minimum) throw new TypeError(`${label} must be an integer >= ${minimum}.`); return resolved; } function integerInRange(value: unknown, label: string, minimum: number, maximum: number): number { const resolved = integerValue(value, label, minimum); if (resolved > maximum) throw new TypeError(`${label} must be an integer <= ${maximum}.`); return resolved; } function stringValue(value: unknown, label: string): string { if (typeof value !== "string" || value.trim().length === 0) throw new TypeError(`${label} must be a non-empty string.`); return value; } function digest(value: unknown, label: string): string { const resolved = stringValue(value, label); if (!/^[0-9a-f]{64}$/.test(resolved)) throw new TypeError(`${label} must be a lowercase SHA-256 digest.`); return resolved; } function literal(value: unknown, expected: T, label: string): T { if (value !== expected) throw new TypeError(`${label} must be ${JSON.stringify(expected)}.`); return expected; } function numberArray(value: unknown, label: string): readonly number[] { if (!Array.isArray(value)) throw new TypeError(`${label} must be an array.`); return value.map((entry, index) => numberValue(entry, `${label}[${index}]`)); } function vector3(value: unknown, label: string): readonly [number, number, number] { const resolved = numberArray(value, label); if (resolved.length !== 3) throw new TypeError(`${label} must contain three numbers.`); return [resolved[0], resolved[1], resolved[2]]; } type TrackValueParser = (value: unknown, label: string) => T; function parseTrackValue( value: unknown, label: string, parseValue: TrackValueParser, spline: boolean, ): OriginalCombatMeshTrackValue { if (!spline) return parseValue(value, label); const record = exactObject(value, label, ["value", "inTangent", "outTangent"]); return { value: parseValue(record.value, `${label}.value`), inTangent: parseValue(record.inTangent, `${label}.inTangent`), outTangent: parseValue(record.outTangent, `${label}.outTangent`), }; } function parseTrack( value: unknown, label: string, parseValue: TrackValueParser, globalSequenceCount: number, ): OriginalCombatMeshTrack { const record = exactObject(value, label, ["interpolation", "globalSequence", "sequences"]); const interpolation = integerInRange(record.interpolation, `${label}.interpolation`, 0, 3) as OriginalCombatMeshInterpolation; const globalSequence = integerValue(record.globalSequence, `${label}.globalSequence`, -1); if (globalSequence >= globalSequenceCount) { throw new TypeError(`${label}.globalSequence has no matching global sequence.`); } if (!Array.isArray(record.sequences)) throw new TypeError(`${label}.sequences must be an array.`); const spline = interpolation === 2 || interpolation === 3; const sequences = record.sequences.map((sequenceValue, sequenceIndex) => { const sequenceLabel = `${label}.sequences[${sequenceIndex}]`; const sequence = exactObject(sequenceValue, sequenceLabel, ["timestamps", "values"]); if (!Array.isArray(sequence.timestamps)) throw new TypeError(`${sequenceLabel}.timestamps must be an array.`); if (!Array.isArray(sequence.values)) throw new TypeError(`${sequenceLabel}.values must be an array.`); const timestamps = sequence.timestamps.map((timestamp, timestampIndex) => ( integerValue(timestamp, `${sequenceLabel}.timestamps[${timestampIndex}]`) )); for (let index = 1; index < timestamps.length; index += 1) { if (timestamps[index] < timestamps[index - 1]) { throw new TypeError(`${sequenceLabel}.timestamps must be ordered.`); } } const values = sequence.values.map((trackValue, trackValueIndex) => parseTrackValue( trackValue, `${sequenceLabel}.values[${trackValueIndex}]`, parseValue, spline, )); if (timestamps.length !== values.length) { throw new TypeError(`${sequenceLabel} timestamp and value counts must match.`); } return { timestamps, values }; }); return { interpolation, globalSequence, sequences }; } function parseAnimation(value: unknown, index: number): OriginalCombatMeshAnimation { const label = `static mesh.animations[${index}]`; const record = exactObject(value, label, [ "index", "id", "variationIndex", "durationMs", "moveSpeed", "flags", ]); return { index: integerValue(record.index, `${label}.index`), id: integerInRange(record.id, `${label}.id`, 0, 65_535), variationIndex: integerInRange(record.variationIndex, `${label}.variationIndex`, 0, 65_535), durationMs: integerValue(record.durationMs, `${label}.durationMs`), moveSpeed: numberValue(record.moveSpeed, `${label}.moveSpeed`), flags: integerInRange(record.flags, `${label}.flags`, 0, 4_294_967_295), }; } function parseColor( value: unknown, index: number, globalSequenceCount: number, ): OriginalCombatMeshColor { const label = `static mesh.colors[${index}]`; const record = exactObject(value, label, ["color", "alpha"]); return { color: parseTrack(record.color, `${label}.color`, vector3, globalSequenceCount), alpha: parseTrack( record.alpha, `${label}.alpha`, (entry, entryLabel) => integerInRange(entry, entryLabel, -32_768, 32_767), globalSequenceCount, ), }; } function parseTexture(value: unknown, index: number): OriginalCombatMeshTexture { const label = `static mesh.textures[${index}]`; const record = exactObject(value, label, [ "type", "flags", "filename", "sourcePath", "sourceSha256", "url", ]); return { type: integerValue(record.type, `${label}.type`), flags: integerValue(record.flags, `${label}.flags`), filename: stringValue(record.filename, `${label}.filename`), sourcePath: stringValue(record.sourcePath, `${label}.sourcePath`), sourceSha256: digest(record.sourceSha256, `${label}.sourceSha256`), url: stringValue(record.url, `${label}.url`), }; } function parseLayer( value: unknown, index: number, indices: readonly number[], textureCount: number, colorCount: number, textureWeightCount: number, ): OriginalCombatMeshLayer { const label = `static mesh.layers[${index}]`; const record = exactObject(value, label, [ "submeshIndex", "indexStart", "indexCount", "priorityPlane", "shaderId", "colorIndex", "materialLayer", "materialFlags", "blendMode", "textureIndex", "textureCoordinateIndex", "textureWeightIndex", "textureTransformIndex", ]); const indexStart = integerValue(record.indexStart, `${label}.indexStart`); const indexCount = integerValue(record.indexCount, `${label}.indexCount`); if (indexStart + indexCount > indices.length) throw new TypeError(`${label} draw range exceeds geometry indices.`); const textureIndex = integerValue(record.textureIndex, `${label}.textureIndex`); if (textureIndex >= textureCount) throw new TypeError(`${label}.textureIndex has no matching texture.`); const colorIndex = integerValue(record.colorIndex, `${label}.colorIndex`, -1); if (colorIndex >= colorCount) throw new TypeError(`${label}.colorIndex has no matching color.`); const textureWeightIndex = integerValue(record.textureWeightIndex, `${label}.textureWeightIndex`, -1); if (textureWeightIndex >= textureWeightCount) { throw new TypeError(`${label}.textureWeightIndex has no matching texture weight.`); } return { submeshIndex: integerValue(record.submeshIndex, `${label}.submeshIndex`), indexStart, indexCount, priorityPlane: integerValue(record.priorityPlane, `${label}.priorityPlane`, -128), shaderId: integerValue(record.shaderId, `${label}.shaderId`), colorIndex, materialLayer: integerValue(record.materialLayer, `${label}.materialLayer`), materialFlags: integerValue(record.materialFlags, `${label}.materialFlags`), blendMode: integerValue(record.blendMode, `${label}.blendMode`), textureIndex, textureCoordinateIndex: integerValue(record.textureCoordinateIndex, `${label}.textureCoordinateIndex`, -1), textureWeightIndex, textureTransformIndex: integerValue(record.textureTransformIndex, `${label}.textureTransformIndex`, -1), }; } export function parseOriginalCombatMeshGraph(value: unknown): OriginalCombatMeshGraph { const graph = exactObject(value, "static mesh", [ "schemaVersion", "kind", "source", "sourceModel", "normalizedSourceModel", "sourceSha256", "sourceSkin", "sourceSkinSha256", "globalSequences", "animations", "bounds", "geometry", "colors", "textures", "textureWeights", "layers", "runtime", ]); literal(graph.schemaVersion, 1, "static mesh.schemaVersion"); literal(graph.kind, "wow-m2-static-mesh", "static mesh.kind"); literal(graph.source, "wow335", "static mesh.source"); const bounds = exactObject(graph.bounds, "static mesh.bounds", ["minimum", "maximum", "radius"]); const geometry = exactObject(graph.geometry, "static mesh.geometry", ["positions", "normals", "uvs", "indices"]); const positions = numberArray(geometry.positions, "static mesh.geometry.positions"); const normals = numberArray(geometry.normals, "static mesh.geometry.normals"); const uvs = numberArray(geometry.uvs, "static mesh.geometry.uvs"); if (!positions.length || positions.length % 3 !== 0) throw new TypeError("static mesh positions must contain vertices."); if (normals.length !== positions.length) throw new TypeError("static mesh normals must match positions."); if (uvs.length !== positions.length / 3 * 2) throw new TypeError("static mesh UVs must match positions."); if (!Array.isArray(geometry.indices) || !geometry.indices.length) throw new TypeError("static mesh indices must be populated."); const vertexCount = positions.length / 3; const indices = geometry.indices.map((entry, index) => { const resolved = integerValue(entry, `static mesh.geometry.indices[${index}]`); if (resolved >= vertexCount) throw new TypeError(`static mesh.geometry.indices[${index}] exceeds its vertices.`); return resolved; }); if (!Array.isArray(graph.globalSequences)) throw new TypeError("static mesh.globalSequences must be an array."); const globalSequences = graph.globalSequences.map((entry, index) => ( integerInRange(entry, `static mesh.globalSequences[${index}]`, 0, 4_294_967_295) )); if (!Array.isArray(graph.animations)) throw new TypeError("static mesh.animations must be an array."); const animations = graph.animations.map(parseAnimation); animations.forEach((animation, index) => { if (animation.index !== index) throw new TypeError(`static mesh.animations[${index}].index must equal ${index}.`); }); if (!Array.isArray(graph.colors)) throw new TypeError("static mesh.colors must be an array."); const colors = graph.colors.map((color, index) => parseColor(color, index, globalSequences.length)); if (!Array.isArray(graph.textures) || !graph.textures.length) throw new TypeError("static mesh textures must be populated."); const textures = graph.textures.map(parseTexture); if (!Array.isArray(graph.textureWeights)) throw new TypeError("static mesh.textureWeights must be an array."); const textureWeights = graph.textureWeights.map((track, index) => parseTrack( track, `static mesh.textureWeights[${index}]`, (entry, entryLabel) => integerInRange(entry, entryLabel, -32_768, 32_767), globalSequences.length, )); if (!Array.isArray(graph.layers) || !graph.layers.length) throw new TypeError("static mesh layers must be populated."); const layers = graph.layers.map((layer, index) => parseLayer( layer, index, indices, textures.length, colors.length, textureWeights.length, )); const runtime = exactObject(graph.runtime, "static mesh.runtime", [ "renderer", "coordinateSpace", "staticFrame", ]); literal(runtime.renderer, "wow-m2-static-mesh-v1", "static mesh.runtime.renderer"); literal(runtime.coordinateSpace, "wow-m2-source", "static mesh.runtime.coordinateSpace"); literal(runtime.staticFrame, "source-bind-pose", "static mesh.runtime.staticFrame"); return { schemaVersion: 1, kind: "wow-m2-static-mesh", source: "wow335", sourceModel: stringValue(graph.sourceModel, "static mesh.sourceModel"), normalizedSourceModel: stringValue(graph.normalizedSourceModel, "static mesh.normalizedSourceModel"), sourceSha256: digest(graph.sourceSha256, "static mesh.sourceSha256"), sourceSkin: stringValue(graph.sourceSkin, "static mesh.sourceSkin"), sourceSkinSha256: digest(graph.sourceSkinSha256, "static mesh.sourceSkinSha256"), globalSequences, animations, bounds: { minimum: vector3(bounds.minimum, "static mesh.bounds.minimum"), maximum: vector3(bounds.maximum, "static mesh.bounds.maximum"), radius: numberValue(bounds.radius, "static mesh.bounds.radius"), }, geometry: { positions, normals, uvs, indices }, colors, textures, textureWeights, layers, runtime: { renderer: "wow-m2-static-mesh-v1", coordinateSpace: "wow-m2-source", staticFrame: "source-bind-pose", }, }; } type OriginalCombatMeshSampleValue = number | readonly [number, number, number] | readonly [number, number, number, number]; function baseTrackValue( value: OriginalCombatMeshTrackValue, ): T { return typeof value === "object" && !Array.isArray(value) && "value" in value ? value.value : value as T; } function splineTrackValue( value: OriginalCombatMeshTrackValue, ): OriginalCombatMeshSplineValue | null { return typeof value === "object" && !Array.isArray(value) && "value" in value ? value : null; } function combineTrackValues( values: readonly T[], coefficients: readonly number[], ): T { if (typeof values[0] === "number") { return values.reduce((total, value, index) => ( total + (value as number) * coefficients[index] ), 0) as T; } return (values[0] as readonly number[]).map((_, component) => values.reduce((total, value, index) => ( total + (value as readonly number[])[component] * coefficients[index] ), 0)) as unknown as T; } /** Sample one source M2 track for a selected animation sequence. */ export function sampleOriginalCombatMeshTrack( graph: Pick, track: OriginalCombatMeshTrack, frame: OriginalCombatMeshPlaybackFrame, ): T | undefined { const globalDuration = track.globalSequence >= 0 ? graph.globalSequences[track.globalSequence] ?? 0 : 0; const preferredIndex = globalDuration > 0 ? 0 : frame.animationIndex; const sequence = track.sequences[preferredIndex]?.values.length ? track.sequences[preferredIndex] : track.sequences.find((candidate) => candidate.values.length > 0); if (!sequence?.values.length) return undefined; const timeMs = globalDuration > 0 ? frame.elapsedMs % globalDuration : frame.timeMs; if (sequence.values.length === 1 || timeMs <= sequence.timestamps[0]) { return baseTrackValue(sequence.values[0]); } const lastIndex = sequence.values.length - 1; if (timeMs >= sequence.timestamps[lastIndex]) return baseTrackValue(sequence.values[lastIndex]); let upperIndex = 1; while (upperIndex < sequence.timestamps.length && timeMs >= sequence.timestamps[upperIndex]) { upperIndex += 1; } const lowerIndex = upperIndex - 1; const lowerTime = sequence.timestamps[lowerIndex]; const upperTime = sequence.timestamps[upperIndex]; const progress = upperTime > lowerTime ? (timeMs - lowerTime) / (upperTime - lowerTime) : 0; const lower = sequence.values[lowerIndex]; const upper = sequence.values[upperIndex]; const lowerValue = baseTrackValue(lower); const upperValue = baseTrackValue(upper); if (track.interpolation === 0) return lowerValue; if (track.interpolation === 1) { if (Array.isArray(lowerValue) && lowerValue.length === 4 && Array.isArray(upperValue)) { const dot = lowerValue.reduce((sum, value, index) => sum + value * upperValue[index], 0); return combineTrackValues([lowerValue as T, upperValue as T], [1 - progress, dot < 0 ? -progress : progress]); } return combineTrackValues([lowerValue, upperValue], [1 - progress, progress]); } const lowerSpline = splineTrackValue(lower); const upperSpline = splineTrackValue(upper); if (!lowerSpline || !upperSpline) return lowerValue; if (track.interpolation === 2) { const p2 = progress * progress; const p3 = p2 * progress; return combineTrackValues( [lowerSpline.value, lowerSpline.outTangent, upperSpline.value, upperSpline.inTangent], [2 * p3 - 3 * p2 + 1, p3 - 2 * p2 + progress, -2 * p3 + 3 * p2, p3 - p2], ); } const inverse = 1 - progress; return combineTrackValues( [lowerSpline.value, lowerSpline.outTangent, upperSpline.inTangent, upperSpline.value], [inverse ** 3, 3 * inverse * inverse * progress, 3 * inverse * progress * progress, progress ** 3], ); } /** Select Stand once, Hold in a loop, or Decay once from the source M2. */ export function originalCombatMeshPlaybackFrame( graph: Pick, elapsedMs: number, decaying = false, ): OriginalCombatMeshPlaybackFrame { const safeElapsed = Math.max(0, Number.isFinite(elapsedMs) ? elapsedMs : 0); const stand = graph.animations.find((animation) => animation.id === 0) ?? graph.animations[0]; const hold = graph.animations.find((animation) => animation.id === 158); const decay = graph.animations.find((animation) => animation.id === 159); if (decaying && decay) { return { animationIndex: decay.index, timeMs: Math.min(safeElapsed, decay.durationMs), elapsedMs: safeElapsed, decaying: true, complete: safeElapsed >= decay.durationMs, }; } if (stand && safeElapsed < stand.durationMs) { return { animationIndex: stand.index, timeMs: safeElapsed, elapsedMs: safeElapsed, decaying: false, complete: false, }; } if (hold) { const holdElapsed = Math.max(0, safeElapsed - (stand?.durationMs ?? 0)); return { animationIndex: hold.index, timeMs: hold.durationMs > 0 ? holdElapsed % hold.durationMs : 0, elapsedMs: safeElapsed, decaying: false, complete: false, }; } return { animationIndex: stand?.index ?? 0, timeMs: stand ? Math.min(safeElapsed, stand.durationMs) : 0, elapsedMs: safeElapsed, decaying: false, complete: false, }; } /** Resolve the source-authentic material color and animated layer weight. */ export function originalCombatMeshLayerAppearance( graph: OriginalCombatMeshGraph, layer: OriginalCombatMeshLayer, frame: OriginalCombatMeshPlaybackFrame, ): OriginalCombatMeshLayerAppearance { const colorTrack = layer.colorIndex >= 0 ? graph.colors[layer.colorIndex] : undefined; const color = colorTrack ? sampleOriginalCombatMeshTrack(graph, colorTrack.color, frame) ?? [1, 1, 1] : [1, 1, 1] as const; const alpha = colorTrack ? sampleOriginalCombatMeshTrack(graph, colorTrack.alpha, frame) ?? 32_767 : 32_767; const weightTrack = layer.textureWeightIndex >= 0 ? graph.textureWeights[layer.textureWeightIndex] : undefined; const weight = weightTrack ? sampleOriginalCombatMeshTrack(graph, weightTrack, frame) ?? 32_767 : 32_767; const normalized = (value: number) => Math.max(0, Math.min(1, value / 32_767)); return { color, opacity: normalized(alpha) * normalized(weight), }; }