Release Healer Man 0.1.12

This commit is contained in:
phenom committed 2026-09-01 14:35:56 -04:00
1 parent 13970d03c8
commit 65e24f644e
201 files changed
+5061 -123

No files matched your search

+20 -8
View File
@@ -31,6 +31,7 @@ import {
updateCombatEffectQueue,
type ActiveCombatEffect,
} from "./combatEffectRuntime";
import { OriginalCombatEffect } from "./OriginalCombatEffect";
export interface CombatEffectsProps {
readonly active: boolean;
@@ -257,12 +258,13 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
useEffect(() => {
if (!import.meta.env.DEV) return;
document.documentElement.dataset[COMBAT_EFFECT_DATASET_KEY] = JSON.stringify({
mode: settings.vfxMode,
quality: effectiveQuality,
systems: effects.length,
activeLights: 0,
allocatedLights: 0,
});
}, [effectiveQuality, effects.length]);
}, [effectiveQuality, effects.length, settings.vfxMode]);
useEffect(() => () => {
if (import.meta.env.DEV) delete document.documentElement.dataset[COMBAT_EFFECT_DATASET_KEY];
@@ -331,13 +333,23 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
return (
<group name="combat-effects">
{effects.map((effect) => (
<CombatEffectPrimitive
key={effect.key}
event={effect.event}
quality={effectiveQuality}
active={active}
onDone={() => setEffects((current) => current.filter((candidate) => candidate.key !== effect.key))}
/>
settings.vfxMode === "original" ? (
<OriginalCombatEffect
key={`original:${effect.key}`}
event={effect.event}
quality={effectiveQuality}
active={active}
onDone={() => setEffects((current) => current.filter((candidate) => candidate.key !== effect.key))}
/>
) : (
<CombatEffectPrimitive
key={`generated:${effect.key}`}
event={effect.event}
quality={effectiveQuality}
active={active}
onDone={() => setEffects((current) => current.filter((candidate) => candidate.key !== effect.key))}
/>
)
))}
</group>
);
+678
View File
@@ -0,0 +1,678 @@
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { useFrame } from "@react-three/fiber";
import {
AdditiveBlending,
BufferAttribute,
BufferGeometry,
DoubleSide,
DynamicDrawUsage,
Group,
MeshBasicMaterial,
MultiplyBlending,
NormalBlending,
ShaderMaterial,
SRGBColorSpace,
Texture,
TextureLoader,
Vector2,
} from "three";
import {
combatOriginalPhaseAssets,
preloadCombatEffectManifest,
type CombatEffectPhaseAsset,
type CombatPresentationEvent,
type CombatVfxQuality,
} from "../game/combatPresentation";
import { abilityById } from "../game/abilityCatalog";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "../game/combatStore";
import { getMobPosition } from "../game/mobRuntimeRegistry";
import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
import {
interpolateOriginalLifetime,
interpolateOriginalScalar,
originalAuraMatchesPresentationEvent,
originalEmitterParticleBudget,
parseOriginalCombatEffectGraph,
type OriginalCombatEffectAnimatedTrackName,
type OriginalCombatEffectGraph,
type OriginalCombatEffectParticle,
} from "./originalCombatEffectRuntime";
import {
parseOriginalCombatMeshGraph,
type OriginalCombatMeshGraph,
} from "./originalCombatMeshRuntime";
type OriginalSourceEffectGraph = OriginalCombatEffectGraph | OriginalCombatMeshGraph;
interface LoadedOriginalGraph {
readonly asset: CombatEffectPhaseAsset;
readonly graph: OriginalSourceEffectGraph;
readonly textures: ReadonlyMap<number, Texture>;
}
interface OriginalCombatEffectProps {
readonly event: CombatPresentationEvent;
readonly quality: Exclude<CombatVfxQuality, "off">;
readonly active: boolean;
readonly onDone: () => void;
}
const textureLoader = new TextureLoader();
const graphRequestCache = new Map<string, Promise<OriginalSourceEffectGraph>>();
const textureRequestCache = new Map<string, Promise<Texture>>();
const ORIGINAL_EFFECT_SLOT_LIMIT = { low: 384, high: 1_024 } as const;
const vertexShader = /* glsl */ `
attribute vec3 particleColor;
attribute float particleAlpha;
attribute float particleSize;
attribute float particleFrame;
varying vec3 vParticleColor;
varying float vParticleAlpha;
varying float vParticleFrame;
uniform float viewportHeight;
void main() {
vParticleColor = particleColor;
vParticleAlpha = particleAlpha;
vParticleFrame = particleFrame;
vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * viewPosition;
gl_PointSize = clamp(
particleSize * viewportHeight / max(1.0, -viewPosition.z),
1.0,
256.0
);
}
`;
const fragmentShader = /* glsl */ `
varying vec3 vParticleColor;
varying float vParticleAlpha;
varying float vParticleFrame;
uniform sampler2D sourceTexture;
uniform vec2 atlasSize;
uniform float alphaKey;
void main() {
float frameCount = max(1.0, atlasSize.x * atlasSize.y);
float frame = mod(max(0.0, floor(vParticleFrame)), frameCount);
float column = mod(frame, atlasSize.x);
float row = floor(frame / atlasSize.x);
vec2 atlasUv = (
vec2(column, atlasSize.y - row - 1.0) + gl_PointCoord
) / atlasSize;
vec4 source = texture2D(sourceTexture, atlasUv);
float resolvedAlpha = source.a * vParticleAlpha;
if ((alphaKey > 0.5 && source.a < 0.5) || resolvedAlpha <= 0.002) discard;
gl_FragColor = vec4(source.rgb * vParticleColor, resolvedAlpha);
#include <colorspace_fragment>
}
`;
function sourceRandom(seed: number): number {
const value = Math.sin(seed * 12.9898 + 78.233) * 43_758.5453;
return value - Math.floor(value);
}
function maximumSampleValue(samples: readonly { readonly value: number }[]): number {
return samples.reduce((maximum, sample) => Math.max(maximum, sample.value), 0);
}
function graphDurationMs(graph: OriginalCombatEffectGraph): number {
return Math.max(1, graph.durationMs, ...graph.animations.map((animation) => animation.durationMs));
}
function graphTailMs(graph: OriginalCombatEffectGraph): number {
return graph.particles.reduce((maximum, particle) => (
Math.max(maximum, maximumSampleValue(particle.samples.lifespan) * 1_000)
), 0);
}
function placementHeight(model: string | undefined, event: CombatPresentationEvent): number {
const normalized = model?.toLowerCase() ?? "";
if (/(?:_|\/)head(?:\.|_|$)/.test(normalized)) return 1.72;
if (/(?:_|\/)chest(?:\.|_|$)/.test(normalized)) return 1.12;
if (/(?:_|\/)base(?:\.|_|$)/.test(normalized)) return 0.08;
if (event.phase === "cast-start" || /(?:_|\/)hand(?:\.|_|$)/.test(normalized)) return 1.15;
return event.delivery === "area" ? 0.08 : 1.05;
}
function liveActorPosition(actorId: string | undefined) {
if (!actorId) return null;
if (actorId === PLAYER_AURA_ENTITY_ID) return useCombatStore.getState().playerPosition;
return getMobPosition(actorId) ?? getPartyRuntimePosition(actorId);
}
function auraDurationMs(event: CombatPresentationEvent): number {
const ability = abilityById(event.abilityId);
if (!ability) return 6_000;
return ability.effects.reduce((maximum, effect) => {
if ("durationMs" in effect && typeof effect.durationMs === "number") {
return Math.max(maximum, effect.durationMs);
}
if ("aura" in effect && effect.aura && typeof effect.aura.durationMs === "number") {
return Math.max(maximum, effect.aura.durationMs);
}
return maximum;
}, 6_000);
}
function auraStillActive(event: CombatPresentationEvent): boolean {
const state = useCombatStore.getState();
const targetId = event.targetActorId ?? PLAYER_AURA_ENTITY_ID;
const legacyPlayerShield = targetId === PLAYER_AURA_ENTITY_ID
&& event.abilityId.includes("power-word-shield")
&& state.shield > 0;
const eventWithTarget = event.targetActorId === targetId
? event
: { ...event, targetActorId: targetId };
return legacyPlayerShield || state.auras.some((aura) => (
originalAuraMatchesPresentationEvent(aura, eventWithTarget)
)) || state.effects.some((effect) => (
effect.targetId === targetId && effect.sourceAbilityId === event.abilityId
));
}
function effectScale(asset: CombatEffectPhaseAsset): number {
return typeof asset.scale === "number" && Number.isFinite(asset.scale)
? Math.max(0.05, Math.min(5, asset.scale))
: 1;
}
async function loadGraph(url: string): Promise<OriginalSourceEffectGraph> {
const existing = graphRequestCache.get(url);
if (existing) return existing;
// Graph filenames are keyed to the immutable source M2, while the decoded
// browser schema can improve between releases. Revalidate the small JSON so
// an older cached decoder payload cannot fail the current strict parser.
const request = fetch(url, { cache: "no-cache" })
.then((response) => response.ok
? response.json()
: Promise.reject(new Error(`Original effect graph request failed (${response.status}): ${url}`)))
.then((value) => (
value && typeof value === "object" && (value as { kind?: unknown }).kind === "wow-m2-static-mesh"
? parseOriginalCombatMeshGraph(value)
: parseOriginalCombatEffectGraph(value)
));
graphRequestCache.set(url, request);
request.catch(() => graphRequestCache.delete(url));
return request;
}
async function loadTexture(url: string): Promise<Texture> {
const existing = textureRequestCache.get(url);
if (existing) return existing;
const request = textureLoader.loadAsync(url).then((texture) => {
texture.colorSpace = SRGBColorSpace;
texture.name = `original-combat-effect:${url}`;
texture.needsUpdate = true;
return texture;
});
textureRequestCache.set(url, request);
request.catch(() => textureRequestCache.delete(url));
return request;
}
async function loadOriginalGraphAsset(asset: CombatEffectPhaseAsset): Promise<LoadedOriginalGraph> {
if (!asset.effectGraphUrl) throw new Error("Original effect phase has no graph URL.");
const graph = await loadGraph(asset.effectGraphUrl);
const textureIndices = graph.kind === "wow-m2-static-mesh"
? [...new Set(graph.layers.map((layer) => layer.textureIndex))]
: [...new Set(graph.runtime.readyParticleIndices.map((particleIndex) => (
graph.particles[particleIndex]?.textureIndex
)).filter((textureIndex): textureIndex is number => textureIndex !== undefined))];
const loadedTextures = await Promise.all(textureIndices.map(async (textureIndex) => {
const url = graph.textures[textureIndex]?.url;
if (!url) throw new Error(`Original effect texture ${textureIndex} has no packaged URL.`);
return [textureIndex, await loadTexture(url)] as const;
}));
return { asset, graph, textures: new Map(loadedTextures) };
}
function materialForParticle(particle: OriginalCombatEffectParticle, texture: Texture): ShaderMaterial {
const additive = particle.blend === "no-alpha-add"
|| particle.blend === "add"
|| particle.blend === "blend-add";
const multiply = particle.blend === "mod" || particle.blend === "mod-2x";
return new ShaderMaterial({
name: `wow-m2-particle-${particle.id}`,
vertexShader,
fragmentShader,
uniforms: {
sourceTexture: { value: texture },
atlasSize: { value: new Vector2(particle.atlas.columns, particle.atlas.rows) },
viewportHeight: { value: 720 },
alphaKey: { value: particle.blend === "alpha-key" ? 1 : 0 },
},
blending: additive ? AdditiveBlending : multiply ? MultiplyBlending : NormalBlending,
transparent: particle.blend !== "opaque",
depthWrite: false,
toneMapped: false,
});
}
const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
graph,
particle,
texture,
budget,
active,
eventId,
emitterIndex,
loopTimeline,
}: {
readonly graph: OriginalCombatEffectGraph;
readonly particle: OriginalCombatEffectParticle;
readonly texture: Texture;
readonly budget: number;
readonly active: boolean;
readonly eventId: number;
readonly emitterIndex: number;
readonly loopTimeline: boolean;
}) {
const elapsedRef = useRef(0);
const geometry = useMemo(() => {
const next = new BufferGeometry();
const position = new BufferAttribute(new Float32Array(budget * 3), 3);
const color = new BufferAttribute(new Float32Array(budget * 3), 3);
const alpha = new BufferAttribute(new Float32Array(budget), 1);
const size = new BufferAttribute(new Float32Array(budget), 1);
const frame = new BufferAttribute(new Float32Array(budget), 1);
position.setUsage(DynamicDrawUsage);
color.setUsage(DynamicDrawUsage);
alpha.setUsage(DynamicDrawUsage);
size.setUsage(DynamicDrawUsage);
frame.setUsage(DynamicDrawUsage);
next.setAttribute("position", position);
next.setAttribute("particleColor", color);
next.setAttribute("particleAlpha", alpha);
next.setAttribute("particleSize", size);
next.setAttribute("particleFrame", frame);
next.setDrawRange(0, budget);
return next;
}, [budget]);
const material = useMemo(() => materialForParticle(particle, texture), [particle, texture]);
const durationMs = graphDurationMs(graph);
const peakRate = maximumSampleValue(particle.samples.emissionRate);
const peakLifespan = maximumSampleValue(particle.samples.lifespan);
useEffect(() => () => {
geometry.dispose();
material.dispose();
}, [geometry, material]);
useFrame((state, delta) => {
if (!active || budget === 0) return;
elapsedRef.current += Math.min(0.1, Math.max(0, delta));
material.uniforms.viewportHeight.value = state.size.height * state.viewport.dpr;
const elapsed = elapsedRef.current;
const positions = geometry.getAttribute("position") as BufferAttribute;
const colors = geometry.getAttribute("particleColor") as BufferAttribute;
const alphas = geometry.getAttribute("particleAlpha") as BufferAttribute;
const sizes = geometry.getAttribute("particleSize") as BufferAttribute;
const frames = geometry.getAttribute("particleFrame") as BufferAttribute;
const cycleLength = Math.max(0.02, peakLifespan);
const emitterSeed = eventId * 1_009
+ particle.id * 17
+ particle.textureIndex * 131
+ emitterIndex * 7_919;
for (let index = 0; index < budget; index += 1) {
const slotOffset = peakRate > 0 ? index / peakRate : Number.POSITIVE_INFINITY;
const completedCycles = Math.floor((elapsed - slotOffset) / cycleLength);
const birthTime = slotOffset + Math.max(0, completedCycles) * cycleLength;
const age = elapsed - birthTime;
const cycleSeed = emitterSeed + index * 97 + Math.max(0, completedCycles) * 271;
const rawTimelineMs = birthTime * 1_000;
const timelineMs = loopTimeline ? rawTimelineMs % durationMs : rawTimelineMs;
const scalarAt = (track: OriginalCombatEffectAnimatedTrackName) => (
interpolateOriginalScalar(
particle.samples[track],
timelineMs,
particle.interpolation[track],
) ?? 0
);
const emissionRate = rawTimelineMs <= durationMs || loopTimeline
? Math.max(0, scalarAt("emissionRate"))
: 0;
const lifespanBase = Math.max(0.02, scalarAt("lifespan"));
const lifespan = lifespanBase * Math.max(
0.05,
1 + (sourceRandom(cycleSeed + 1) * 2 - 1) * particle.lifespanVariation,
);
const accepted = elapsed >= slotOffset
&& age >= 0
&& age < lifespan
&& scalarAt("enabled") > 0.5
&& sourceRandom(cycleSeed + 2) <= Math.min(1, emissionRate / Math.max(peakRate, 0.001));
if (!accepted) {
alphas.setX(index, 0);
sizes.setX(index, 0);
continue;
}
const lifeProgress = Math.min(1, Math.max(0, age / lifespan));
const speedBase = scalarAt("emissionSpeed");
const speedVariation = scalarAt("speedVariation");
const speed = speedBase * Math.max(0, 1 + (sourceRandom(cycleSeed + 3) * 2 - 1) * speedVariation);
const horizontalRange = particle.samples.horizontalRange.length
? scalarAt("horizontalRange")
: Math.PI * 2;
const verticalRange = scalarAt("verticalRange");
const gravity = scalarAt("gravity");
const areaLength = scalarAt("emissionAreaLength");
const areaWidth = scalarAt("emissionAreaWidth");
const zSource = scalarAt("zSource");
const azimuth = (sourceRandom(cycleSeed + 4) - 0.5) * horizontalRange;
const elevation = (sourceRandom(cycleSeed + 5) - 0.5) * verticalRange;
const radial = Math.cos(elevation);
const velocityX = Math.sin(azimuth) * radial * speed;
const velocityY = Math.cos(azimuth) * radial * speed;
const velocityZ = Math.sin(elevation) * speed;
let localX = (sourceRandom(cycleSeed + 6) - 0.5) * areaWidth;
let localY = (sourceRandom(cycleSeed + 7) - 0.5) * areaLength;
let localZ = zSource;
if (particle.emitter === "sphere") {
const longitude = sourceRandom(cycleSeed + 6) * Math.PI * 2;
const sphereZ = sourceRandom(cycleSeed + 7) * 2 - 1;
const sphereRadius = Math.sqrt(Math.max(0, 1 - sphereZ * sphereZ));
localX = Math.cos(longitude) * sphereRadius * areaWidth;
localY = Math.sin(longitude) * sphereRadius * areaLength;
localZ += sphereZ * Math.max(areaWidth, areaLength);
}
// WoW M2 is Z-up; the game scene is Y-up.
positions.setXYZ(
index,
particle.position[0] + localX + velocityX * age,
particle.position[2] + localZ + velocityZ * age - gravity * age * age * 0.5,
-particle.position[1] - localY - velocityY * age,
);
const color = interpolateOriginalLifetime(particle.samples.color, lifeProgress) ?? [255, 255, 255];
const alpha = interpolateOriginalLifetime(particle.samples.alpha, lifeProgress) ?? 0;
const scale = interpolateOriginalLifetime(particle.samples.scale, lifeProgress) ?? [0, 0];
const scaleVariation = 1 + (sourceRandom(cycleSeed + 8) * 2 - 1)
* (particle.scaleVariation[0] + particle.scaleVariation[1]) * 0.5;
colors.setXYZ(index, color[0] / 255, color[1] / 255, color[2] / 255);
alphas.setX(index, Math.min(1, Math.max(0, alpha / 32_767)));
sizes.setX(index, Math.max(0.002, (scale[0] + scale[1]) * 0.5 * Math.max(0.05, scaleVariation)));
const frameSamples = particle.samples.headCell.length
? particle.samples.headCell
: particle.samples.tailCell;
const sourceFrame = interpolateOriginalLifetime(frameSamples, lifeProgress) ?? 0;
frames.setX(index, Math.max(0, Math.floor(sourceFrame)));
}
positions.needsUpdate = true;
colors.needsUpdate = true;
alphas.needsUpdate = true;
sizes.needsUpdate = true;
frames.needsUpdate = true;
});
return budget > 0 ? (
<points geometry={geometry} material={material} frustumCulled={false} />
) : null;
});
function meshBlendMode(blendMode: number) {
if (blendMode === 3 || blendMode === 4 || blendMode === 7) return AdditiveBlending;
if (blendMode === 5 || blendMode === 6) return MultiplyBlending;
return NormalBlending;
}
const OriginalStaticMesh = memo(function OriginalStaticMesh({
graph,
textures,
active,
}: {
readonly graph: OriginalCombatMeshGraph;
readonly textures: ReadonlyMap<number, Texture>;
readonly active: boolean;
}) {
const billboardRef = useRef<Group>(null);
const geometry = useMemo(() => {
const next = new BufferGeometry();
const sourcePositions = graph.geometry.positions;
const sourceNormals = graph.geometry.normals;
const xCenter = Array.from({ length: sourcePositions.length / 3 }, (_, index) => (
sourcePositions[index * 3]
)).reduce((total, value) => total + value, 0) / (sourcePositions.length / 3);
const positions = new Float32Array(sourcePositions.length);
const normals = new Float32Array(sourceNormals.length);
for (let index = 0; index < sourcePositions.length; index += 3) {
// Turn WoW's Z-up source plane into an upright, camera-facing local XY plane.
positions[index] = -sourcePositions[index + 1];
positions[index + 1] = sourcePositions[index + 2];
positions[index + 2] = sourcePositions[index] - xCenter;
normals[index] = -sourceNormals[index + 1];
normals[index + 1] = sourceNormals[index + 2];
normals[index + 2] = sourceNormals[index];
}
next.setAttribute("position", new BufferAttribute(positions, 3));
next.setAttribute("normal", new BufferAttribute(normals, 3));
next.setAttribute("uv", new BufferAttribute(new Float32Array(graph.geometry.uvs), 2));
next.setIndex([...graph.geometry.indices]);
next.clearGroups();
for (const [index, layer] of graph.layers.entries()) {
next.addGroup(layer.indexStart, layer.indexCount, index);
}
next.computeBoundingSphere();
return next;
}, [graph]);
const materials = useMemo(() => graph.layers.map((layer) => new MeshBasicMaterial({
name: `wow-m2-static-layer-${layer.submeshIndex}`,
map: textures.get(layer.textureIndex) ?? null,
blending: meshBlendMode(layer.blendMode),
transparent: layer.blendMode !== 0,
alphaTest: layer.blendMode === 1 ? 0.5 : 0,
depthWrite: layer.blendMode === 0,
side: DoubleSide,
toneMapped: false,
})), [graph.layers, textures]);
useEffect(() => () => {
geometry.dispose();
for (const material of materials) material.dispose();
}, [geometry, materials]);
useFrame(({ camera }) => {
if (active && billboardRef.current) billboardRef.current.lookAt(camera.position);
});
return (
<group ref={billboardRef}>
<mesh geometry={geometry} material={materials} renderOrder={20} frustumCulled={false} />
</group>
);
});
function LoadedOriginalCombatEffect({
event,
quality,
active,
loaded,
onDone,
}: OriginalCombatEffectProps & { readonly loaded: readonly LoadedOriginalGraph[] }) {
const elapsedRef = useRef(0);
const doneRef = useRef(false);
const groupRef = useRef<Group>(null);
const particleBudgets = useMemo(() => {
const emitters = loaded.flatMap((item, loadedIndex) => {
const graph = item.graph;
return graph.kind === "wow-m2-static-mesh"
? []
: graph.runtime.readyParticleIndices.map((particleIndex) => ({
key: `${loadedIndex}:${particleIndex}`,
requested: originalEmitterParticleBudget(graph.particles[particleIndex], quality),
}));
});
const requestedTotal = emitters.reduce((total, emitter) => total + emitter.requested, 0);
const limit = ORIGINAL_EFFECT_SLOT_LIMIT[quality];
if (requestedTotal <= limit) return new Map(emitters.map((emitter) => [emitter.key, emitter.requested]));
const scale = limit / requestedTotal;
let remaining = limit;
return new Map(emitters.map((emitter) => {
const budget = Math.min(
remaining,
emitter.requested > 0 ? Math.max(1, Math.floor(emitter.requested * scale)) : 0,
);
remaining -= budget;
return [emitter.key, budget];
}));
}, [loaded, quality]);
const durationMs = useMemo(() => {
const longestGraph = loaded.reduce((maximum, item) => (
Math.max(
maximum,
item.graph.kind === "wow-m2-static-mesh"
? 6_000
: graphDurationMs(item.graph) + graphTailMs(item.graph),
)
), 0);
if (event.phase === "cast-start") return Math.max(120, event.durationMs ?? longestGraph);
return Math.max(120, Math.min(6_000, longestGraph));
}, [event.durationMs, event.phase, loaded]);
const projectile = event.phase === "release"
&& event.delivery === "projectile"
&& event.targetPosition !== undefined;
const projectileDurationMs = projectile && event.targetPosition
? Math.max(180, Math.min(550, Math.hypot(
event.targetPosition[0] - event.origin[0],
event.targetPosition[1] - event.origin[1],
event.targetPosition[2] - event.origin[2],
) / 28 * 1_000))
: 0;
const anchor = event.phase === "cast-start" || projectile
? event.origin
: event.targetPosition ?? event.origin;
const loopTimeline = event.phase === "cast-start" || event.phase === "aura-start";
useFrame((_state, delta) => {
if (!active || doneRef.current) return;
elapsedRef.current += Math.min(100, Math.max(0, delta * 1_000));
if (groupRef.current) {
if (projectile && event.targetPosition) {
const progress = Math.min(1, elapsedRef.current / projectileDurationMs);
groupRef.current.position.set(
event.origin[0] + (event.targetPosition[0] - event.origin[0]) * progress,
event.origin[1] + (event.targetPosition[1] - event.origin[1]) * progress,
event.origin[2] + (event.targetPosition[2] - event.origin[2]) * progress,
);
} else {
const actorId = event.phase === "cast-start" ? event.sourceActorId : event.targetActorId;
const live = liveActorPosition(actorId);
if (live) groupRef.current.position.set(live[0], live[1], live[2]);
}
}
const terminalDuration = projectile
? projectileDurationMs
: event.phase === "aura-start"
? auraDurationMs(event)
: durationMs;
if (event.phase === "aura-start"
&& elapsedRef.current > 350
&& !auraStillActive(event)) {
doneRef.current = true;
onDone();
return;
}
if (elapsedRef.current < terminalDuration) return;
doneRef.current = true;
onDone();
});
return (
<group ref={groupRef} position={anchor}>
{loaded.map((item, loadedIndex) => {
const graph = item.graph;
return (
<group
key={`${item.asset.effectGraphUrl}:${item.asset.model ?? "model"}`}
position={[0, placementHeight(item.asset.model, event), 0]}
scale={effectScale(item.asset)}
>
{graph.kind === "wow-m2-static-mesh" ? (
<OriginalStaticMesh graph={graph} textures={item.textures} active={active} />
) : graph.runtime.readyParticleIndices.map((particleIndex) => {
const particle = graph.particles[particleIndex];
const texture = item.textures.get(particle?.textureIndex ?? -1);
return particle && texture ? (
<OriginalParticleEmitter
key={`${particleIndex}:${particle.id}:${particle.textureIndex}`}
graph={graph}
particle={particle}
texture={texture}
budget={particleBudgets.get(`${loadedIndex}:${particleIndex}`) ?? 0}
active={active}
eventId={event.id}
emitterIndex={loadedIndex * 4_096 + particleIndex}
loopTimeline={loopTimeline}
/>
) : null;
})}
</group>
);
})}
</group>
);
}
export const OriginalCombatEffect = memo(function OriginalCombatEffect({
event,
quality,
active,
onDone,
}: OriginalCombatEffectProps) {
const [loaded, setLoaded] = useState<readonly LoadedOriginalGraph[] | null>(null);
const onDoneRef = useRef(onDone);
const terminalRef = useRef(false);
onDoneRef.current = onDone;
useEffect(() => {
let cancelled = false;
terminalRef.current = false;
setLoaded(null);
void preloadCombatEffectManifest().then(async () => {
const assets = combatOriginalPhaseAssets(event);
const settled = await Promise.allSettled(assets.map(loadOriginalGraphAsset));
if (cancelled) return;
const ready = settled.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
if (import.meta.env.DEV && assets.length > ready.length) {
const failures = settled.flatMap((result) => (
result.status === "rejected"
? [result.reason instanceof Error ? result.reason.message : String(result.reason)]
: []
));
console.warn(
`Original spell effect failed to load for ${event.name} (${event.phase}).`,
failures,
);
}
if (ready.length) {
setLoaded(ready);
return;
}
if (!terminalRef.current) {
terminalRef.current = true;
onDoneRef.current();
}
});
return () => {
cancelled = true;
};
}, [event]);
return loaded ? (
<LoadedOriginalCombatEffect
event={event}
quality={quality}
active={active}
loaded={loaded}
onDone={onDone}
/>
) : null;
}, (previous, next) => (
previous.event === next.event
&& previous.quality === next.quality
&& previous.active === next.active
));
@@ -0,0 +1,287 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
ORIGINAL_PARTICLE_LIMIT_PER_EMITTER,
interpolateOriginalLifetime,
interpolateOriginalScalar,
isOriginalCombatEffectGraph,
originalAuraMatchesPresentationEvent,
originalEmitterParticleBudget,
parseOriginalCombatEffectGraph,
selectCombatEffectRender,
} from "./originalCombatEffectRuntime";
const shieldPresentationEvent = {
id: 1,
sessionId: 1,
occurredAt: 0,
phase: "aura-start" as const,
actorGroup: "player" as const,
sourceActorId: "player",
targetActorId: "party-tank",
abilityId: "wow335-priest-power-word-shield",
sourceSpellId: 17,
name: "Power Word: Shield",
source: "wow335" as const,
school: "holy" as const,
delivery: "aura" as const,
origin: [0, 0, 0] as const,
};
const zeroTimeSamples = [{ timeMs: 0, value: 0 }];
const particleSamples = {
emissionSpeed: zeroTimeSamples,
speedVariation: zeroTimeSamples,
verticalRange: zeroTimeSamples,
horizontalRange: zeroTimeSamples,
gravity: zeroTimeSamples,
lifespan: [{ timeMs: 0, value: 1 }],
emissionRate: [{ timeMs: 0, value: 0 }, { timeMs: 500, value: 150 }],
emissionAreaLength: zeroTimeSamples,
emissionAreaWidth: zeroTimeSamples,
zSource: zeroTimeSamples,
color: [
{ timestamp: 0, normalizedTime: 0, value: [1, 0, 0] },
{ timestamp: 32_767, normalizedTime: 1, value: [0, 0, 1] },
],
alpha: [
{ timestamp: 0, normalizedTime: 0, value: 32_767 },
{ timestamp: 32_767, normalizedTime: 1, value: 0 },
],
scale: [
{ timestamp: 0, normalizedTime: 0, value: [1, 1] },
{ timestamp: 32_767, normalizedTime: 1, value: [2, 2] },
],
headCell: [{ timestamp: 0, normalizedTime: 0, value: 3 }],
tailCell: [],
enabled: [{ timeMs: 0, value: 1 }],
};
const trackMetadata = {
emissionSpeed: 0,
speedVariation: 0,
verticalRange: 0,
horizontalRange: 0,
gravity: 0,
lifespan: 0,
emissionRate: 1,
emissionAreaLength: 0,
emissionAreaWidth: 0,
zSource: 0,
enabled: 0,
};
function graphFixture(): Record<string, unknown> {
const particle = {
id: 7,
flags: 0,
position: [0, 1, 2],
parentBone: 0,
textureIndex: 0,
geometryModel: "",
recursionModel: "",
blendMode: 4,
emitterType: 1,
particleColorIndex: 0,
particleType: 0,
headOrTail: 0,
textureTileRotation: 2,
textureRows: 4,
textureColumns: 8,
lifespanVariation: 0,
emissionRateVariation: 0,
scaleVariation: [0, 0],
tailLength: 0,
interpolation: trackMetadata,
globalSequence: Object.fromEntries(Object.keys(trackMetadata).map((key) => [key, -1])),
samples: particleSamples,
};
return {
schemaVersion: 1,
kind: "wow-m2-particle-effect",
source: "wow335",
sourceModel: "Spells/HolySmiteImpact.mdx",
normalizedSourceModel: "Spells/HolySmiteImpact.m2",
sourceSha256: "a".repeat(64),
durationMs: 1_000,
animations: [{ index: 0, id: 0, variationIndex: 0, durationMs: 1_000, moveSpeed: 0, flags: 0 }],
textures: [
{
type: 0,
flags: 0,
filename: "Spells\\HolySmite.blp",
sourcePath: "Spells/HolySmite.BLP",
sourceSha256: "b".repeat(64),
url: "/assets/game/combat-effects/original/wow335/priest/textures/holy-smite.png",
},
{
type: 0,
flags: 0,
filename: "Spells\\GLOWSTAR.blp",
sourcePath: "Spells/GLOWSTAR.BLP",
},
],
particles: [particle, { ...particle, id: 8, textureIndex: 1 }],
runtime: {
renderer: "wow-m2-particles-v1",
readyParticleIndices: [0],
unsupportedRibbonCount: 1,
unsupportedGeometryVertexCount: 12,
},
};
}
function cloneFixture(): Record<string, unknown> {
return JSON.parse(JSON.stringify(graphFixture())) as Record<string, unknown>;
}
describe("original combat-effect graph contract", () => {
it("keeps an original aura visual attached to its active ranked WoW aura", () => {
const activeShield = {
targetId: "party-tank",
definition: {
id: "wow335:17:effect:0:aura:69",
name: "Power Word: Shield (DBC 17, Aura 69)",
},
};
expect(originalAuraMatchesPresentationEvent(activeShield, shieldPresentationEvent)).toBe(true);
expect(originalAuraMatchesPresentationEvent(
{ ...activeShield, targetId: "other-party-member" },
shieldPresentationEvent,
)).toBe(false);
});
it("parses the shipped Holy Smite graph without contract drift", () => {
const graphUrl = new URL(
"../../public/assets/game/combat-effects/original/wow335/priest/graphs/holysmite-low-chest-5e638a90a1be.json",
import.meta.url,
);
const graph = parseOriginalCombatEffectGraph(JSON.parse(readFileSync(graphUrl, "utf8")));
expect(graph.normalizedSourceModel).toMatch(/\.m2$/i);
expect(graph.runtime.renderer).toBe("wow-m2-particles-v1");
expect(graph.runtime.readyParticleIndices.length).toBeGreaterThan(0);
});
it("parses the shipped Lesser Heal cast and impact graphs", () => {
const graphNames = [
"holy-precast-med-hand-3001a24bb669.json",
"lesserheal-base-a2863103c7d8.json",
];
const graphs = graphNames.map((name) => parseOriginalCombatEffectGraph(JSON.parse(readFileSync(
new URL(`../../public/assets/game/combat-effects/original/wow335/priest/graphs/${name}`, import.meta.url),
"utf8",
))));
expect(graphs.map((graph) => graph.runtime.readyParticleIndices.length)).toEqual([1, 5]);
expect(graphs.flatMap((graph) => graph.textures).filter((texture) => texture.url).length)
.toBeGreaterThanOrEqual(4);
});
it("strictly validates the packaged decoder output and derives renderer conveniences", () => {
const graph = parseOriginalCombatEffectGraph(graphFixture());
const particle = graph.particles[0];
expect(graph).toMatchObject({
schemaVersion: 1,
kind: "wow-m2-particle-effect",
source: "wow335",
normalizedSourceModel: "Spells/HolySmiteImpact.m2",
runtime: {
renderer: "wow-m2-particles-v1",
readyParticleIndices: [0],
},
});
expect(particle).toMatchObject({
textureIndex: 0,
blend: "add",
emitter: "plane",
atlas: { rows: 4, columns: 8, rotation: 2 },
});
expect(particle.samples.emissionRate).toEqual([
{ timeMs: 0, value: 0 },
{ timeMs: 500, value: 150 },
]);
expect(graph.textures[1]).toEqual({
type: 0,
flags: 0,
filename: "Spells\\GLOWSTAR.blp",
sourcePath: "Spells/GLOWSTAR.BLP",
});
expect(isOriginalCombatEffectGraph(graphFixture())).toBe(true);
});
it("rejects wrong schemas, unknown fields, invalid references, and malformed samples", () => {
expect(() => parseOriginalCombatEffectGraph({ ...graphFixture(), source: "runewaker" }))
.toThrow(/source/);
expect(() => parseOriginalCombatEffectGraph({ ...graphFixture(), fallback: "generated" }))
.toThrow(/not part of schema/);
const badReference = cloneFixture();
(badReference.particles as Array<Record<string, unknown>>)[0].textureIndex = 2;
expect(() => parseOriginalCombatEffectGraph(badReference)).toThrow(/matching texture/);
const badSample = cloneFixture();
const samples = (badSample.particles as Array<Record<string, unknown>>)[0].samples as Record<string, unknown>;
(samples.emissionRate as Array<Record<string, unknown>>)[1].value = "150";
expect(() => parseOriginalCombatEffectGraph(badSample)).toThrow(/emissionRate\[1\]\.value/);
expect(isOriginalCombatEffectGraph(badSample)).toBe(false);
const badRuntime = cloneFixture();
(badRuntime.runtime as Record<string, unknown>).readyParticleIndices = [1];
expect(() => parseOriginalCombatEffectGraph(badRuntime)).toThrow(/unpackaged texture/);
});
it("linearly samples animated scalars and scalar or vector lifetime keys", () => {
expect(interpolateOriginalScalar([
{ timeMs: 100, value: 4 },
{ timeMs: 300, value: 12 },
], 200)).toBe(8);
expect(interpolateOriginalScalar([{ timeMs: 100, value: 4 }], 1_000)).toBe(4);
expect(interpolateOriginalScalar([], 100)).toBeNull();
expect(interpolateOriginalScalar([
{ timeMs: 0, value: 0 },
{ timeMs: 500, value: 10 },
], 250, 0)).toBe(0);
expect(interpolateOriginalLifetime([
{ normalizedTime: 0, value: 1 },
{ normalizedTime: 1, value: 3 },
], 0.25)).toBe(1.5);
expect(interpolateOriginalLifetime([
{ normalizedTime: 0, value: [1, 2, 3] as const },
{ normalizedTime: 1, value: [3, 4, 5] as const },
], 0.5)).toEqual([2, 3, 4]);
});
it("uses the peak animated emission rate for deterministic per-emitter budgets", () => {
const particle = parseOriginalCombatEffectGraph(graphFixture()).particles[0];
expect(particle.samples.emissionRate[0].value).toBe(0);
expect(originalEmitterParticleBudget(particle, "low"))
.toBe(ORIGINAL_PARTICLE_LIMIT_PER_EMITTER.low);
expect(originalEmitterParticleBudget(particle, "high"))
.toBe(ORIGINAL_PARTICLE_LIMIT_PER_EMITTER.high);
expect(originalEmitterParticleBudget(particle, "off")).toBe(0);
expect(originalEmitterParticleBudget(particle, "high"))
.toBe(originalEmitterParticleBudget(particle, "high"));
const inactive = {
samples: { ...particle.samples, emissionRate: [{ timeMs: 0, value: 0 }] },
};
expect(originalEmitterParticleBudget(inactive, "high")).toBe(0);
});
it("never substitutes generated rendering when Original has no graph asset", () => {
expect(selectCombatEffectRender("generated", undefined)).toEqual({ kind: "generated" });
expect(selectCombatEffectRender("original", [
{},
{ effectGraphUrl: "/assets/game/combat-effects/original/wow335/priest/graphs/smite.json" },
])).toEqual({
kind: "original",
effectGraphUrl: "/assets/game/combat-effects/original/wow335/priest/graphs/smite.json",
});
expect(selectCombatEffectRender("original", undefined)).toBeNull();
expect(selectCombatEffectRender("original", [{ effectGraphUrl: " " }])).toBeNull();
});
});
+684
View File
@@ -0,0 +1,684 @@
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<T> {
readonly timeMs: number;
readonly value: T;
}
export interface OriginalCombatEffectLifetimeSample<T> {
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<number>[];
readonly speedVariation: readonly OriginalCombatEffectTimeSample<number>[];
readonly verticalRange: readonly OriginalCombatEffectTimeSample<number>[];
readonly horizontalRange: readonly OriginalCombatEffectTimeSample<number>[];
readonly gravity: readonly OriginalCombatEffectTimeSample<number>[];
readonly lifespan: readonly OriginalCombatEffectTimeSample<number>[];
readonly emissionRate: readonly OriginalCombatEffectTimeSample<number>[];
readonly emissionAreaLength: readonly OriginalCombatEffectTimeSample<number>[];
readonly emissionAreaWidth: readonly OriginalCombatEffectTimeSample<number>[];
readonly zSource: readonly OriginalCombatEffectTimeSample<number>[];
readonly color: readonly OriginalCombatEffectLifetimeSample<OriginalCombatEffectVector3>[];
readonly alpha: readonly OriginalCombatEffectLifetimeSample<number>[];
readonly scale: readonly OriginalCombatEffectLifetimeSample<OriginalCombatEffectVector2>[];
readonly headCell: readonly OriginalCombatEffectLifetimeSample<number>[];
readonly tailCell: readonly OriginalCombatEffectLifetimeSample<number>[];
readonly enabled: readonly OriginalCombatEffectTimeSample<number>[];
}
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<Record<
OriginalCombatEffectAnimatedTrackName,
OriginalCombatEffectInterpolationMode
>>;
readonly globalSequence: Readonly<Record<OriginalCombatEffectAnimatedTrackName, number>>;
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<string, unknown>;
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<T extends string>(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<number>[] {
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<T extends InterpolatedValue>(
value: unknown,
label: string,
parseValue: (raw: unknown, valueLabel: string) => T,
): readonly OriginalCombatEffectLifetimeSample<T>[] {
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<OriginalCombatEffectParticleSamples, typeof PARTICLE_TIME_SAMPLE_KEYS[number]>;
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<Record<OriginalCombatEffectAnimatedTrackName, number>> {
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<Record<OriginalCombatEffectAnimatedTrackName, number>>;
}
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<number>[],
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<T extends InterpolatedValue>(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<number>[],
normalizedTime: number,
): number | null;
export function interpolateOriginalLifetime(
samples: readonly OriginalCombatEffectLifetimeSample<OriginalCombatEffectVector2>[],
normalizedTime: number,
): OriginalCombatEffectVector2 | null;
export function interpolateOriginalLifetime(
samples: readonly OriginalCombatEffectLifetimeSample<OriginalCombatEffectVector3>[],
normalizedTime: number,
): OriginalCombatEffectVector3 | null;
export function interpolateOriginalLifetime(
samples: readonly OriginalCombatEffectLifetimeSample<InterpolatedValue>[],
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<Exclude<CombatVfxQuality, "off">, number>
> = Object.freeze({ low: 48, high: 128 });
function maximumPositiveSample(samples: readonly OriginalCombatEffectTimeSample<number>[]): number {
return samples.reduce((maximum, sample) => (
Number.isFinite(sample.value) && sample.value > maximum ? sample.value : maximum
), 0);
}
export function originalEmitterParticleBudget(
particle: Pick<OriginalCombatEffectParticle, "samples">,
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;
}
@@ -0,0 +1,30 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { parseOriginalCombatMeshGraph } from "./originalCombatMeshRuntime";
const graphUrl = new URL(
"../../public/assets/game/combat-effects/original/wow335/priest/graphs/holydivineshield-state-base-83b519d78c12.json",
import.meta.url,
);
describe("original combat static-mesh contract", () => {
it("parses the shipped Power Word: Shield mesh and its three original layers", () => {
const graph = parseOriginalCombatMeshGraph(JSON.parse(readFileSync(graphUrl, "utf8")));
expect(graph.geometry.positions).toHaveLength(36);
expect(graph.geometry.indices).toHaveLength(18);
expect(graph.textures).toHaveLength(3);
expect(graph.layers.map((layer) => layer.blendMode)).toEqual([4, 4, 4]);
expect(graph.runtime.staticFrame).toBe("source-bind-pose");
});
it("rejects geometry and draw references that leave the packaged source", () => {
const invalidIndex = JSON.parse(readFileSync(graphUrl, "utf8")) as Record<string, unknown>;
((invalidIndex.geometry as Record<string, unknown>).indices as number[])[0] = 12;
expect(() => parseOriginalCombatMeshGraph(invalidIndex)).toThrow(/exceeds its vertices/);
const invalidLayer = JSON.parse(readFileSync(graphUrl, "utf8")) as Record<string, unknown>;
(((invalidLayer.layers as Record<string, unknown>[])[0])).indexCount = 19;
expect(() => parseOriginalCombatMeshGraph(invalidLayer)).toThrow(/draw range/);
});
});
+215
View File
@@ -0,0 +1,215 @@
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 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 textures: readonly OriginalCombatMeshTexture[];
readonly layers: readonly OriginalCombatMeshLayer[];
readonly runtime: {
readonly renderer: "wow-m2-static-mesh-v1";
readonly coordinateSpace: "wow-m2-source";
readonly staticFrame: "source-bind-pose";
};
}
type JsonRecord = Record<string, unknown>;
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 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<T extends string | number>(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]];
}
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,
): 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.`);
return {
submeshIndex: integerValue(record.submeshIndex, `${label}.submeshIndex`),
indexStart,
indexCount,
priorityPlane: integerValue(record.priorityPlane, `${label}.priorityPlane`, -128),
shaderId: integerValue(record.shaderId, `${label}.shaderId`),
colorIndex: integerValue(record.colorIndex, `${label}.colorIndex`, -32_768),
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`, -32_768),
textureWeightIndex: integerValue(record.textureWeightIndex, `${label}.textureWeightIndex`, -32_768),
textureTransformIndex: integerValue(record.textureTransformIndex, `${label}.textureTransformIndex`, -32_768),
};
}
export function parseOriginalCombatMeshGraph(value: unknown): OriginalCombatMeshGraph {
const graph = exactObject(value, "static mesh", [
"schemaVersion", "kind", "source", "sourceModel", "normalizedSourceModel", "sourceSha256",
"sourceSkin", "sourceSkinSha256", "bounds", "geometry", "textures", "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.textures) || !graph.textures.length) throw new TypeError("static mesh textures must be populated.");
const textures = graph.textures.map(parseTexture);
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));
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"),
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 },
textures,
layers,
runtime: {
renderer: "wow-m2-static-mesh-v1",
coordinateSpace: "wow-m2-source",
staticFrame: "source-bind-pose",
},
};
}