Files
benilla-octo/crates/benilla-formats/src/particles.rs
T
SamandClaude Fable 5 ab235bad6e the target-of-target frame, and talents can be unlearned again
- the target-of-target frame and its option: 1.12's frame with its five display modes, fed one hop off the selected unit's own target field through the same read `/assist` runs, in four feeds (snapshot and transitions, the debuff buttons, the portrait booth, TargetUnit's click arm); ships off, as 1.12 does, on its own pair of saved globals
- talents can be unlearned again: the trainer's `MSG_TALENT_WIPE_CONFIRM` had no handler since the wipe flow was deferred, so the gossip line closed the menu and did nothing — the question is an NPC session (walking away retracts it), the popup shows the cost on the StaticPopup money variant, Accept answers it behind the client's own purse check
- Sprint's sparkles hang in the air behind you along the trail instead of collapsing into your torso: ride-vs-trail is the emitter's own file flag 0x10, not the host class — the finding wow-re returned eighteen days ago and nobody folded; the particle dump names the two flag bits it had been mis-reporting
- `/sleep` takes both movement masks (the strictest posture), correcting the day's sit gate which let you lie down on a lake bottom while `/sit` was refused; a Lua-driven selection (TargetUnit on an attackable token) starts the swing exactly as a click does, through the one select path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 18:40:16 +07:00

1133 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Vanilla (build 5875 / MD20 v256) M2 **particle emitter** parsing — read straight from raw bytes.
//!
//! `benilla-m2` parses only the render path and deliberately skips the cosmetic chunks, so the
//! particle emitters are read here. The post-vanilla `M2ParticleEmitter` (with `texture_file_data_ids`,
//! `enable_encryption`, WoD flag bits) does not match 1.12; the vanilla layout is a fixed
//! **0x1f8-byte** record. The byte layout is from `wow-5875-re` (`FUN_0070ebd0` @ `0x70ebd0`,
//! `m2-decomp.c`) and **difftested** here against `ElwynnCampfire.m2` (see `tests/`):
//!
//! ```text
//! header array : count @ MD20+0x13c, ptr @ MD20+0x140 record stride 0x1f8
//! +0x04 flags(u32) +0x08 position(C3Vector) +0x14 bone(u16) +0x16 texture(u16, index→M2 textures)
//! +0x28 blendingType(u8) +0x2a emitterType(u16:1=Plane,2=Sphere,3=Spline)
//! +0x2c particleType(u16:0=head,1=tail,2=both) +0x30 tileRows(u16) +0x32 tileCols(u16)
//! +0x34.. ten emission M2Tracks (vanilla 28-byte track; the runtime uses value[0] of each):
//! +0x34 emissionSpeed +0x50 speedVariation +0x6c verticalRange +0x88 horizontalRange
//! +0xa4 gravity +0xc0 lifespan +0xdc emissionRate +0xf8 areaLength +0x114 areaWidth
//! +0x130 zSource
//! +0x14c.. baked over-life color/opacity/scale/cell ramps (see `OverLife`).
//! +0x17c tailTime(f32, SECONDS — the tail streak's length is velocity·tailTime)
//! +0x180 twinkleSpeed +0x184 twinklePercent +0x188/+0x18c twinkleScale{min,max}
//! +0x190 inheritScale(f32) +0x194 drag(f32, plain scalar — the velocity decay)
//! +0x198 spin(f32, rad/s quad rotation)
//! +0x1c4..+0x1d0 followSpeed1/followScale1/followSpeed2/followScale2 (the follow-delta line)
//! +0x1dc enabled(M2Track<u8>, step) — the emission ON/OFF gate, closing the record at 0x1f8.
//! ```
//!
//! The two keyed tracks here (emission rate, enabled) bake **one loop per sequence** into
//! [`EmitTiming`] through the same FN1 kernel as the material alpha (`models::key_anim`,
//! decision 0641): the reference samples both per frame through the *playing* sequence's own key
//! window (`0x713d50` — wow-re `part-emission-rate-animated.md` §2/§3), so what an emitter does
//! is a function of which sequence its model instance is playing. A quest GameObject authors its
//! explosion inside one-shot clips and an OFF window in every idle sequence; baking only
//! sequence 0's band parked that choreography at its end value forever (bug B27).
//!
//! The record tail (+0x180..) is the wow-re `part-simspace-fields.md` §5-verified map (their
//! `ac915a7d`): the loader `0x70ebd0` remaps file+0x188/+0x18c → runtime twinkleScale min/max
//! (delta = max − min at rt+0x1c0), NOT a size multiplier — see [`ParticleEmitterDef::twinkle`].
use std::io::Cursor;
use anyhow::Result;
use benilla_m2::{parse_m2, M2ScalarTrack};
use crate::emit_timing::{EmitParams, EmitTiming};
use crate::models::SeqSlot;
/// Emitter spawn shape (file `emitterType` @ +0x2a). Only `Plane` is needed for campfires/torches;
/// `Sphere` (e.g. dust clouds) and `Spline` follow.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParticleShape {
Plane,
Sphere,
Spline,
}
/// How a particle batch blends (file `blendingType` @ +0x28 = the M2/EGxBlend enum). Additive is the
/// flame/glow case (the campfire is `4`); mod/mod2x still fold to alpha (rare for the props we
/// render, and they will get their own path when a model needs them).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ParticleBlend {
/// `3` NoAlphaAdd / `4` Add — `(SRC_ALPHA, ONE)`. Flames, glows, embers. No depth write/sort.
Add,
/// `2` Alpha — `(SRC_ALPHA, ONE_MINUS_SRC_ALPHA)`. Smoke.
Alpha,
/// `1` AlphaKey — blending **OFF**, depth-write ON, and the fixed-function **alpha test**
/// `GEQUAL 224/255` carving the texture's silhouette out of the quad. The chip/debris family:
/// solid little rocks, bone shards, ice splinters, each authored as a shape on a
/// fully-transparent field.
///
/// Every step is byte-verified in wow-re (see [`ParticleBlend::Opaque`] for the shared half):
/// the emitter's own blend field decides its list at `0x7085db` (`cmp word[rec+0x28],1; ja` —
/// mode ≤ 1 **and** instance alpha ≥ 0.99999 lands in the `+0x4c` **opaque** list, i.e. pass 0,
/// `part-flush-emitter-depth.md` §1), pass 0 takes promotion-table row 0
/// `0x811fe0 = {0,1,2,10,3,4,5}` so mode 1 stays **EGxBlend 1**
/// (`m2-blend-promotion-zfill.md` §1), EGxBlend 0/1 → `glDisable(GL_BLEND)`
/// (`0x59d563`, `egxrs-depth-blend-states.md`), and `0x70c256` sets the ref from the RAW mode —
/// `round(instanceAlpha × 224.0)`, `[0x812034] = 224.0f` — never from the pass
/// (`m2-blend-promotion-zfill.md` §2). Depth-write stays ON because the synthetic material only
/// sets its z-write-off bit for `blendMode > 1` (`0x70d8f1`, `part-scene-multipliers.md` §0).
///
/// Folding this into `Opaque` — no discard at all — paints the transparent field solid: the
/// Lesser Rock Elemental's debris (`PARTROCK.BLP`, 31 % of its texels at α = 0) came out as
/// flat pale squares instead of tumbling chips.
AlphaKey,
/// `0` Opaque — blending OFF, depth-write ON, and **no** alpha test (`0x70c237`'s mode-0 arm
/// sets ref 0, which `0x59d5b9` turns into `glDisable(GL_ALPHA_TEST)`). Genuinely rare for
/// particles; the fallback for an out-of-range blend byte too.
Opaque,
}
/// One segment's integer **flipbook cell ramp** — the authored `(begin, end)` pair and the
/// `(base, span)` the reference derives from it at load (`0x7b9da0` head / `0x7b9de0` tail, both
/// pure-integer; wow-re `part-cell-flipbook-ramp.md` §2).
///
/// The two build arms are deliberately **asymmetric**, and that asymmetry is the whole mechanism:
/// the ramp covers `N = |end − begin| + 1` cells, each getting `1/N` of the segment, travelling in
/// the **authored direction**.
///
/// ```text
/// end >= begin : base = begin, span = end − begin + 1 (forward)
/// end < begin : base = begin + 1, span = end − begin − 1 (NEGATIVE — plays backwards)
/// ```
///
/// **A decreasing pair is legal, shipped, and means "run the flipbook in reverse"** — four models
/// author one (`DwarvenBrazier01`'s settling flame, `ShadowWordSilence_Breath`'s 32-frame reverse
/// sweep). There is no swap and no clamp anywhere on the reference's path; an earlier reading here
/// clamped into `[begin, end]`, which both mangled the reverse ramps and panicked outright on them
/// (decision 0685).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CellRamp {
/// The authored pair, verbatim (the reference archives it at `rec+0x3c/+0x40` and never reads
/// it back — kept here because it is what a dump should show).
pub begin: u16,
pub end: u16,
base: i32,
span: i32,
}
impl CellRamp {
/// Build from an authored `(begin, end)` pair — `0x7b9da0`'s two arms, verbatim.
pub fn new(begin: u16, end: u16) -> Self {
let (b, e) = (i32::from(begin), i32::from(end));
let (base, span) = if e >= b {
(b, e - b + 1)
} else {
(b + 1, e - b - 1)
};
Self {
begin,
end,
base,
span,
}
}
/// The cell index at segment fraction `t` — **`t` must already carry the endpoint inset** (and
/// the repeat wrap, if any); see [`OverLife::sample`].
///
/// `floor(base + span·t) & 0xFF`. The mask is the reference's mod-256 wrap (its fast-floor
/// idiom `bits(x + 512.0) >> 14` yields a byte), **not** a clamp into the authored range or the
/// atlas — bounding the index is the atlas walk's business, and it only masks the column.
pub fn sample(&self, t: f32) -> u16 {
// Saturating float→int (NaN → 0) rather than the reference's raw mantissa read: a `mid` of
// 0 or 1 makes the reference's own segment span 1/0 and walks NaN into this floor, and we
// will not reproduce a fault. No shipped emitter authors that mid (corpus-swept).
let v = self.base as f32 + self.span as f32 * t;
((v.floor() as i32) & 0xFF) as u16
}
}
/// The baked **over-life** ramps (record tail, +0x14c..) sampled by a particle's normalized age `u =
/// age/lifespan`. Colour and size are **3-key ramps** evaluated as two linear segments split at
/// [`Self::mid`]: key0→key1 over `u∈[0,mid]`, then key1→key2 over `u∈[mid,1]`. The flipbook cells
/// are a different shape — a `(begin, end)` [`CellRamp`] **per segment**, and there are two
/// independent sets of them (head quad, tail streak). Byte layout + math verified in `wow-5875-re`
/// (`FUN_0070ebd0` builder, `FUN_007b9b10` evaluator, `part-cell-flipbook-ramp.md`) and difftested
/// against `ElwynnCampfire.m2`. These drive the believable flame fade / grow / flicker.
#[derive(Debug, Clone, Copy)]
pub struct OverLife {
/// Normalized age (`+0x14c`) splitting the two ramp segments. The reference's split is
/// **inclusive toward segment A** (`age > lifespan·mid` is the only way into B).
pub mid: f32,
/// RGBA keys k0/k1/k2 (`+0x150/+0x154/+0x158`, packed BGRA u32 → linear 0..1). The **A** channel
/// is the particle's additive weight (its over-life opacity); there is no separate alpha array.
pub color: [[f32; 4]; 3],
/// Uniform particle size keys k0/k1/k2 (`+0x15c/+0x160/+0x164`, yards).
pub scale: [f32; 3],
/// The **head** quad's flipbook ramp, per segment: A (`+0x168/+0x16a`), B (`+0x16e/+0x170`).
pub head_cells: [CellRamp; 2],
/// The **tail** streak's own, independent flipbook ramp: A (`+0x174/+0x176`), B
/// (`+0x178/+0x17a`). The reference emits two cell indices per particle and the tail quad reads
/// this one — a model whose tail ramp differs from its head ramp animates the streak
/// separately (wow-re `part-cell-flipbook-ramp.md` §4, correcting `part-quad-tail-twinkle.md`).
pub tail_cells: [CellRamp; 2],
/// Per-segment flipbook **repeat count** (`+0x16c`/`+0x172`, `fild`-converted at load). `1.0`
/// (the ctor default, and what nearly all content authors) is one pass; anything else cycles
/// the cell ramp `fmod(t·repeat, 1.0)` times across the segment. Colour and size do not cycle.
pub repeat: [f32; 2],
}
/// One evaluation of the over-life ramps — the reference's `0x7b9b10` writes exactly these four
/// outputs (colour, **two** cell indices, size) from one call.
#[derive(Debug, Clone, Copy)]
pub struct OverLifeSample {
/// Linear RGBA; the A channel is the particle's over-life opacity/additive weight.
pub color: [f32; 4],
/// Half-extent in yards.
pub size: f32,
/// Atlas cell for the head quad.
pub head_cell: u16,
/// Atlas cell for the tail streak (independent of [`Self::head_cell`]).
pub tail_cell: u16,
}
impl OverLife {
/// Sample every ramp at normalized age `u` (0..1).
pub fn sample(&self, u: f32) -> OverLifeSample {
let u = u.clamp(0.0, 1.0);
// A `mid` of 0 divides by zero in the reference (§7 of the wow-re note: it walks NaN into
// the sampler); no shipped emitter authors one, and we refuse to reproduce the fault.
let mid = self.mid.clamp(1e-3, 1.0);
let (k0, k1, t, seg) = if u <= mid {
(0, 1, u / mid, 0)
} else {
(1, 2, (u - mid) / (1.0 - mid).max(1e-3), 1)
};
// **The endpoint inset, and it is the only normalized time the reference has.** `0x7b9b10`
// computes `t·0.99 + 0.005` once, over its own `age` argument slot, and every consumer
// reloads that slot — all four colour channels, the size, and both cell ramps (verified
// instruction by instruction, wow-re `part-cell-flipbook-ramp.md` §3a "scope of the
// inset"). So a particle at a segment's start sits **0.5 % along** its colour and size
// ramps, never exactly on the authored key, and 99.5 % at the end.
//
// For the CELLS the inset is not a rounding artefact but load-bearing: co-designed with
// the ramp's ±1, it is the only thing making `cell(0) == begin` and `cell(1) == end` hold
// in BOTH directions (a raw `t` floors one past the end going forward, one past the begin
// going backward). Exact while `0.005·N < 1` — 200 cells; the largest shipped atlas is 8×8.
let t = t.clamp(0.0, 1.0) * 0.99 + 0.005;
let mut color = [0.0; 4];
for (c, slot) in color.iter_mut().enumerate() {
*slot = self.color[k0][c] + (self.color[k1][c] - self.color[k0][c]) * t;
}
let size = self.scale[k0] + (self.scale[k1] - self.scale[k0]) * t;
// The repeat count cycles the FLIPBOOK ONLY — an explicit verified negative, and visible in
// the reference's own ordering: colour and size are computed and stored out before the
// `rec+0x50 != 1.0` branch is even evaluated, and the wrapped coefficient never becomes `t`
// (§3b). So a 5× swarm emitter flaps its wings five times while fading exactly once.
let ct = if self.repeat[seg] != 1.0 {
(t * self.repeat[seg]).fract()
} else {
t
};
OverLifeSample {
color,
size,
head_cell: self.head_cells[seg].sample(ct),
tail_cell: self.tail_cells[seg].sample(ct),
}
}
}
/// A SPLINE (type-3) emitter's authored curve: a flattened **cubic Bézier chain** (K segments,
/// `3K+1` control points: `P, out-tangent, in-tangent, P, …`), **arc-length parameterized** — the
/// reference computes per-segment arc lengths into a knot array at load (`0x7b9a80` →
/// `0x4532e0`; the knots are NOT on disk) and the spawn kernel's `t ∈ [0,1]` walks the chain by
/// normalized arc length (wow-re `part-spline-file-layout.md` + `part-shape-kernels.md` §3). Our
/// per-segment length is a 16-chord subdivision (the reference's `0x453e50` method is untraced —
/// any smooth-curve length approximation lands within a fraction of a percent).
#[derive(Debug, Clone)]
pub struct SplineData {
/// Control points, model-local WoW axes (`3K+1`, from file+0x1d4/+0x1d8).
pub points: Vec<[f32; 3]>,
/// Cumulative normalized arc length at each segment boundary (`K+1` entries, 0 → 1).
knots: Vec<f32>,
}
impl SplineData {
/// Build from the raw control points (must be `3K+1`; returns `None` below one segment).
pub fn new(points: Vec<[f32; 3]>) -> Option<Self> {
let k = points.len().checked_sub(1)? / 3;
if k == 0 || points.len() != 3 * k + 1 {
return None;
}
let mut knots = vec![0.0f32];
for seg in 0..k {
let mut len = 0.0;
let mut prev = Self::bezier(&points[3 * seg..3 * seg + 4], 0.0);
for i in 1..=16 {
let p = Self::bezier(&points[3 * seg..3 * seg + 4], i as f32 / 16.0);
len += ((p[0] - prev[0]).powi(2)
+ (p[1] - prev[1]).powi(2)
+ (p[2] - prev[2]).powi(2))
.sqrt();
prev = p;
}
knots.push(knots[seg] + len);
}
let total = *knots.last().unwrap();
if total > 0.0 {
for kn in &mut knots {
*kn /= total;
}
}
Some(Self { points, knots })
}
fn bezier(p: &[[f32; 3]], u: f32) -> [f32; 3] {
let w = [
(1.0 - u).powi(3),
3.0 * u * (1.0 - u).powi(2),
3.0 * u * u * (1.0 - u),
u.powi(3),
];
std::array::from_fn(|c| (0..4).map(|i| w[i] * p[i][c]).sum())
}
fn bezier_deriv(p: &[[f32; 3]], u: f32) -> [f32; 3] {
let w = [
-3.0 * (1.0 - u).powi(2),
3.0 * (1.0 - u) * (1.0 - 3.0 * u),
3.0 * u * (2.0 - 3.0 * u),
3.0 * u * u,
];
std::array::from_fn(|c| (0..4).map(|i| w[i] * p[i][c]).sum())
}
/// Locate `(segment, local u)` for a normalized arc fraction `t`.
fn locate(&self, t: f32) -> (usize, f32) {
let k = self.knots.len() - 1;
let seg = self.knots[1..k]
.iter()
.position(|&kn| t < kn)
.unwrap_or(k - 1);
let (a, b) = (self.knots[seg], self.knots[seg + 1]);
(seg, ((t - a) / (b - a).max(1e-6)).clamp(0.0, 1.0))
}
/// The curve point at arc fraction `t` — first/last control point outside `[0,1]` (the
/// reference's `0x453390` clamp legs).
pub fn eval(&self, t: f32) -> [f32; 3] {
if t <= 0.0 {
return self.points[0];
}
if t >= 1.0 {
return *self.points.last().unwrap();
}
let (seg, u) = self.locate(t);
Self::bezier(&self.points[3 * seg..3 * seg + 4], u)
}
/// The (unnormalized) curve tangent at arc fraction `t` (the reference's `0x453420` leg —
/// its caller renormalizes).
pub fn tangent(&self, t: f32) -> [f32; 3] {
let (seg, u) = self.locate(t.clamp(0.0, 1.0));
Self::bezier_deriv(&self.points[3 * seg..3 * seg + 4], u)
}
}
/// One parsed vanilla M2 particle emitter, reduced to what the renderer needs. Positions/vectors are in
/// **model-local** space (WoW axes, Z up) — compose with the instance world transform at spawn.
#[derive(Debug, Clone)]
pub struct ParticleEmitterDef {
pub flags: u32,
/// Emitter origin, model-local. (Bone-follow @ +0x14 is deferred — static props sit on the root.)
pub position: [f32; 3],
pub bone: u16,
pub shape: ParticleShape,
pub blend: ParticleBlend,
/// Does the scene's light multiply this emitter's quads? See [`lit_of`] for the byte law — it
/// needs the RAW blend field, which [`ParticleBlend`] collapses (5/6 fold to `Alpha`), so the
/// verdict is baked here at parse rather than re-derived downstream.
pub lit: bool,
/// **Geometry model** (file+0x18 count / +0x1c offset, an `M2Array<char>` path — wow-re
/// `part-model-particles.md`): when it names a resolvable `.m2`, this emitter spawns tiny
/// 3-D MODEL instances instead of billboard quads (Whirlwind's blades, Cone of Cold's
/// shards, the cyclones). `None` when unauthored.
pub geometry_model: Option<String>,
/// **Recursion model** (file+0x20 / +0x24 — wow-re `part-child-recursion.md`): the named
/// model's OWN particle emitters (capped at 4) become CHILD emitters driven once per live
/// parent particle per frame at the particle's position (Fire Blast's impact sparks, bomb
/// explosions). `None` when unauthored.
pub recursion_model: Option<String>,
/// Particle texture (`.blp` path, as embedded in the M2 textures table). `None` if unresolved.
pub texture: Option<String>,
/// Texture atlas size for cell animation (`1×1` = a single static cell). **Both are non-zero
/// powers of two**: the reference's setter (`0x7b4ed0`) demands it — it derives `log2(cols)`
/// and the two reciprocals — and on a bad pair writes *none* of its five fields, leaving the
/// ctor's `1×1`. [`parse_m2_particle_emitters`] mirrors that fallback, so the atlas walk can
/// mask instead of divide. No shipped emitter trips it (corpus-swept, decision 0685).
pub tile_rows: u16,
pub tile_cols: u16,
/// 0 = head (camera quad), 1 = tail (speed-stretched), 2 = both. We render head first.
pub head_tail: u8,
/// The two per-frame-sampled emission tracks — spawn rate (`+0xdc`) and the ON/OFF gate
/// (`+0x1dc`) — baked **one loop per sequence** ([`EmitTiming`]).
pub timing: EmitTiming,
/// The other NINE emission M2Tracks (speed, speedVar, latitude, longitude, gravity, lifespan,
/// areaLength, areaWidth, zSource — bases `+0x34..+0x130`), baked the same way and sampled by
/// the sim **each frame on the emitter's clock** ([`EmitParams`]). These animate for real:
/// Frost Nova rides its emission radius 0.19 → 13.2 yd out with the expanding ring, Arcane
/// Explosion 0 → 7.2 yd with the growing dome — the retired `value[0]` flatten birthed both
/// entirely at the caster's feet (decision 0844).
pub params: EmitParams,
/// Velocity **drag** (file +0x194; a plain scalar, not a track — the builder copies it straight to
/// the runtime emitter's `+0x1e0` at `m2-decomp.c:8950`, *outside* the ten track setters). Each
/// frame the runtime applies `vel −= min(dt·drag, 1)·vel` (verified `particle_integrate` @
/// `0x7b2680`, step 4) — exponential
/// velocity decay that caps a particle's total travel near `speed/drag`. Load-bearing for props
/// that author a *fast, long-lived, zero-gravity* jet and rely on drag to contain it: e.g.
/// `CandelabraTallWall01` (speed 0.56, life 6, gravity 0, **drag 10** → a ~0.06 yd flicker; with
/// no drag the same particle coasts 3.3 yd to the ceiling). 0 = no drag.
pub drag: f32,
/// **Tail time** (file +0x17c, seconds — wow-re `part-quad-tail-twinkle.md`, their `65b8305b`):
/// a tail-mode particle (`head_tail` 1/2) renders a velocity-projected streak of world length
/// `|velocity| · tail_time`, trailing behind the motion; file flag `0x400` additionally clamps
/// the time to the particle's age (the streak grows from zero at birth).
pub tail_time: f32,
/// **Tumble** — the model particles' angular-velocity range (file +0x19c min / +0x1a8 max,
/// C3Vectors, rad/s — wow-re `part-model-particles.md` §b): each birth rolls a body-frame
/// spin vector. The reference's roll carries a VERIFIED original-client asymmetry — only X
/// honors `min + u·range`; Y and Z multiply a raw `[1,2)` mantissa by their range alone
/// (their min is loaded but never read) — which a fidelity consumer must replicate.
pub angular_velocity_min: [f32; 3],
pub angular_velocity_max: [f32; 3],
/// **Emitter-motion inherit scale** (file +0x190 → runtime +0x1c4 — wow-re
/// `part-emitter-motion.md` §1, byte-verified `0x70ff71/0x70ff77`; closes the field the old
/// tail map left "?"): scales the inherited velocity a flag-0x40 emitter feeds its births
/// (see [`Self::inherits_emitter_motion`]).
pub inherit_scale: f32,
/// **Follow pairs** (file +0x1c4..+0x1d0 — wow-re `part-emitter-motion.md` §2, the
/// `0x7b5d30` setter): two authored (emitter speed → follow fraction) samples defining the
/// follow-delta response line; see [`Self::follow_line`].
pub follow_speed1: f32,
pub follow_scale1: f32,
pub follow_speed2: f32,
pub follow_scale2: f32,
/// **Twinkle** — the per-frame flicker modulation (file +0x180/+0x184/+0x188/+0x18c; wow-re
/// `part-simspace-fields.md`, byte-verified in the quad writer `0x7b2a50`). The rendered half-
/// size is the over-life scale ramp × a **gated** twinkle multiplier:
/// `min ≠ max ⇒ noise(speed·age)·(max − min) + min`, **skipped entirely when `min == max`**
/// (a degenerate range — `{0,0}` and `{1,1}` alike burn steady at ramp size; the kobold candle's
/// `{0,0}` is NOT size zero). This corrects the old `scale_base + rand·scale_variation` reading,
/// which inflated every `{1,1}` flame 1–2× and collapsed `{0,0}` to invisible.
pub twinkle_speed: f32,
/// Fraction of frames the particle draws at all while twinkling (the reference's separate
/// draw-gate; exact byte condition thin — consumers may treat `≥ 1` / `0` as always-draw).
pub twinkle_percent: f32,
pub twinkle_min: f32,
pub twinkle_max: f32,
/// SPLINE (type-3) curve data — `Some` only when the record authors a valid Bézier chain
/// (file+0x1d4 count / +0x1d8 offset, `N = 3·⌊count/3⌋+1` C3Vectors — wow-re
/// `part-spline-file-layout.md`, VERIFIED). For splines the generic emission fields are
/// **repurposed** (each setter reads its track's `values[0]`): `area_length`/`area_width`
/// = **tMin/tMax** (the spawned arc-fraction window, clamped [0,1]), `vertical_range` =
/// the **tangent-spin range ψ** (birth velocity = +Z rotated about the local tangent by
/// S11·ψ), `horizontal_range` = the **scatter** (a `U01·scatter` position jitter along the
/// velocity), and the rate track's `values[0]` doubles as the load-time arcScale (a
/// one-frame transient — the per-frame rate sample overwrites it; we always sample the
/// track, so it needs no separate field).
pub spline: Option<SplineData>,
/// Quad **spin** (file +0x198 → runtime +0x18c): the billboard rotates in-plane by
/// `angle = spin · age` (rad; fcos/fsin @ `0x7b2ddc` in the quad writer). 0 = no rotation.
/// A **negative** spin is the author's alternating-direction switch: the writer negates the
/// (negative) angle on half the particles (pointer-bit-5 hash, `0x7b2dda`), counter-rotating
/// the cloud — vanilla's only rotation randomizer (the record has no phase/variance field).
pub spin: f32,
/// Baked over-life ramps (color/opacity/size/cell) — see [`OverLife`].
pub over_life: OverLife,
}
impl ParticleEmitterDef {
/// File flag `0x10` (→ runtime `0x100` — the loader's flag word is a **non-identity remap**,
/// wow-re `part-simspace-fields.md` corrections `1f40db0b`, byte block `0x70faf8–0x70fc44`):
/// **the storage-space choice, and so the ride-vs-trail switch** (wow-re
/// `part-emitter-motion.md` §2c, byte-settled). SET ⇒ the spawn stores raw emitter-LOCAL
/// pos/vel and the draw folds the live emitter matrix back in every frame: the whole cloud
/// rides the emitter rigidly, rotation and all (the chandelier's candle flames ride the swing;
/// a carried torch is flagged for it). CLEAR ⇒ the spawn bakes pos/vel into WORLD and the draw
/// never re-applies the emitter matrix: each particle **hangs where it was born**, so a moving
/// host lays a trail `host speed × particle lifetime` long.
///
/// This doc said the opposite until 1578 — "a moving model carries its flame; there is NO
/// world-frozen trail mode" was the reading two earlier RE rounds built on a null test (a
/// kobold's 0.2 s candle smear, too small to see), and `part-emitter-motion.md` §2c refuted it
/// with the spawn/draw pairing plus a 7860-emitter corpus census.
pub fn model_space(&self) -> bool {
self.flags & 0x10 != 0
}
/// File flag `0x4000` (→ runtime `0x40000` — wow-re `part-emitter-motion.md` §2/§2b,
/// §5-resolved): live particles keep exactly **[`Self::follow_line`]'s fraction (≤ 1) of
/// the emitter's per-frame world motion** — at saturation the trail rides the emitter
/// rigidly, below it lags toward a world-frozen trail; it never leads. (The reference's
/// baseline for this content class is world-frozen — its emitter motion folds into the
/// emitter matrix — and its `+fraction·Δ` add recovers the ride; over an anchor-riding
/// store the same observable is a `(fraction−1)·Δ` move.) The hunter missiles author it
/// (19 emitters / 15 spell models).
pub fn follow_emitter(&self) -> bool {
self.flags & 0x4000 != 0
}
/// The follow-delta response line (the reference's load-time `0x7b5d30`): `(slope,
/// intercept)` of the line through the two authored `(speed, fraction)` samples, so the
/// per-frame fraction is `clamp(slope·|Δpos|/dt + intercept, 0, 1)`. `None` when the two
/// speeds coincide (the reference zeroes both — no follow response).
pub fn follow_line(&self) -> Option<(f32, f32)> {
((self.follow_speed2 - self.follow_speed1).abs() >= 1e-6).then(|| {
let slope = (self.follow_scale2 - self.follow_scale1)
/ (self.follow_speed2 - self.follow_speed1);
(slope, self.follow_scale1 - slope * self.follow_speed1)
})
}
/// File flag `0x40` (→ runtime `0x400` — wow-re `part-emitter-motion.md` §1, VERIFIED):
/// births inherit the emitter's recent motion. The emitter keeps a ~30 Hz inherit-velocity
/// vector — at each trigger (accumulated dt > 1/30 s), `oneFrameΔ · ((1/30)/accum) ·
/// inherit_scale`, zeroed while no particles are live — and each birth adds
/// `(1 + S11·speed_variation) · inherit` to its velocity (the shape kernels' closing
/// block). The enchant hands / Bloodlust / Death Wish family authors it (70 emitters / 33
/// spell models).
pub fn inherits_emitter_motion(&self) -> bool {
self.flags & 0x40 != 0
}
/// File flag `0x20` (→ runtime `0x200`): the rendered particle size is multiplied by the
/// emitter transform's scale magnitude. Torches/campfires author it; without it an
/// instance-scaled prop scales its particle *positions* only.
pub fn scale_size_by_instance(&self) -> bool {
self.flags & 0x20 != 0
}
/// File flag `0x100` on a **sphere** emitter (→ runtime `0x4000`, mapped only when the type
/// word is 2): birth velocity is straight `+Z` instead of radial through the shell point.
pub fn sphere_up(&self) -> bool {
self.flags & 0x100 != 0
}
/// File flag `0x80` on a **sphere** emitter (→ runtime `0x800` — like [`Self::sphere_up`]
/// the loader maps it only when the type word is 2, so a plane's authored `0x80` is dead
/// data): kill the particle the frame its motion turns AWAY from the emitter origin — the
/// integrator's tail tests `dot(stepVelocity, updatedPos) > 0` and returns dead (`0x7b2680`,
/// the `rt 0x800` branch). The suction containment: every corpus author pairs it with a
/// NEGATIVE emission speed (Blink/Conjure/Detect Magic converge inward), and the kill is
/// what stops the stream at the centre instead of letting it spray out the far side.
pub fn kill_outbound(&self) -> bool {
self.shape == ParticleShape::Sphere && self.flags & 0x80 != 0
}
/// File flag `0x400` (→ runtime `0x10000`): a tail streak's `tail_time` is clamped to the
/// particle's age — the streak grows from zero at birth instead of popping in full-length.
pub fn tail_clamps_to_age(&self) -> bool {
self.flags & 0x400 != 0
}
/// File flag `0x200` (→ runtime `0x8000` — wow-re `part-model-particles.md` §b): each
/// tumble axis's rolled angular velocity is independently sign-flipped with probability ½
/// (three extra draws) — the model particles' spin-direction randomizer.
pub fn tumble_random_sign(&self) -> bool {
self.flags & 0x200 != 0
}
/// File flag `0x2000` (→ runtime `0x20000` — wow-re `part-groundsnap-zhook.md`, VERIFIED):
/// the **at-spawn ground snap**. Once, at each birth (anchored mode only — the shape
/// kernels call the hook `0x7b2140` inside their bit-0x100-CLEAR branch), the client
/// probes **20 yd straight down** for terrain OR WMO/doodad geometry (`0x672b60` →
/// `0x6aa160`, flags 0x100111); on a hit the particle's up-coordinate becomes `surfaceZ +
/// its birth over-life SIZE` (the sampler's float leg is the scale ramp — `0x7b9e20`
/// writes bank+0x24/+0x28 from file+0x15c/0x160/0x164 — so the quad stands ON the surface
/// by its half-extent). No surface within 20 yd leaves the spawn position untouched. NOT a
/// per-frame clamp, NOT terrain-only. Fire/Frost Nova, the ground fogs (37 emitters / 17
/// spell models).
pub fn ground_snap(&self) -> bool {
self.flags & 0x2000 != 0
}
/// File flag `0x8000` (bit 15): the emission model is a **one-shot BURST**, not continuous.
/// The reference's per-frame emitter pass branches on this bit (`0x718ec8`): a burst emitter
/// gets a rising-edge trigger — the frame `(enabled && rate > 0)` first turns true it requests
/// ONE spawn of `ftol(rate · density · LOD)` particles (`0x7b5c50` → the spawn driver's
/// self-clearing burst branch `0x7b55ae`/`0x7b563d`) — and the continuous-enable bit is
/// skipped, so the `rate·dt` pour can never run for it. Re-arms only when the gate falls
/// (a looping clip's next pass). This flag is why the Feint/Eviscerate impact's plume and
/// crescents are over ~0.5 s while the same-shaped `Eviscerate_Cast_Hands` flame (bit clear)
/// pours for its whole 1.6 s clip (wow-re `part-emission-burst-flag.md`, byte-arbitrated).
pub fn burst(&self) -> bool {
self.flags & 0x8000 != 0
}
/// File flag `0x1000` (→ runtime `0x2000`): the head quad does NOT billboard — it lies flat
/// in the emitter's model-space XY plane, carried by the live model→world matrix (the
/// community "XYQuad"; the impact crescents, state rings, fish-school splash rings).
/// Byte-pinned end to end in wow-re: corners = the ±1 XY unit square (z = 0) × the draw
/// matrix — camera-independent, unit half-extent exactly like the billboard corner table —
/// and quad spin is Rodrigues about the quad-plane normal (consumption
/// `part-quad-tail-twinkle.md` §3, builder semantics `part-tiled-corner-builder.md`, both
/// VERIFIED).
pub fn xy_quad(&self) -> bool {
self.flags & 0x1000 != 0
}
/// The gated twinkle size multiplier, given a noise sample `noise ∈ [0,1)` from the caller's
/// LUT (indexed off `twinkle_speed · age`): identity when the range is degenerate.
pub fn twinkle(&self, noise: f32) -> f32 {
if (self.twinkle_max - self.twinkle_min).abs() < 1e-6 {
1.0
} else {
noise * (self.twinkle_max - self.twinkle_min) + self.twinkle_min
}
}
}
const STRIDE: usize = 0x1f8;
const HDR_COUNT: usize = 0x13c;
const HDR_PTR: usize = 0x140;
fn le_u16(b: &[u8], o: usize) -> u16 {
u16::from_le_bytes([b[o], b[o + 1]])
}
fn le_u32(b: &[u8], o: usize) -> u32 {
u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
fn le_f32(b: &[u8], o: usize) -> f32 {
f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
fn le_vec3(b: &[u8], o: usize) -> [f32; 3] {
[le_f32(b, o), le_f32(b, o + 4), le_f32(b, o + 8)]
}
/// Read one raw vanilla M2Track (28 bytes at `track`) into the shared typed shape — **absolute**
/// key timestamps plus the per-sequence `ranges` windows the FN1 bake indexes. `benilla-m2` skips
/// the cosmetic chunks, so the two emitter timing tracks are lifted here; `elem`/`read` decode one
/// value (`f32`, or the enabled gate's `u8` projected to 0/1).
fn read_raw_track(
b: &[u8],
track: usize,
elem: usize,
read: impl Fn(&[u8], usize) -> f32,
) -> M2ScalarTrack {
let mut out = M2ScalarTrack {
gseq: 0xffff,
..M2ScalarTrack::default()
};
if track + 0x1c > b.len() {
return out;
}
out.interp = le_u16(b, track);
out.gseq = le_u16(b, track + 2);
let (rn, ro) = (le_u32(b, track + 4) as usize, le_u32(b, track + 8) as usize);
let (tn, to) = (
le_u32(b, track + 0x0c) as usize,
le_u32(b, track + 0x10) as usize,
);
let (vn, vo) = (
le_u32(b, track + 0x14) as usize,
le_u32(b, track + 0x18) as usize,
);
if ro + rn * 8 <= b.len() {
out.ranges = (0..rn)
.map(|i| (le_u32(b, ro + i * 8), le_u32(b, ro + i * 8 + 4)))
.collect();
}
let n = tn.min(vn);
if n > 0 && to + n * 4 <= b.len() && vo + n * elem <= b.len() {
out.keys = (0..n)
.map(|i| (le_u32(b, to + i * 4), read(b, vo + i * elem)))
.collect();
}
out
}
fn blend_of(v: u8) -> ParticleBlend {
match v {
3 | 4 => ParticleBlend::Add,
2 | 5 | 6 => ParticleBlend::Alpha, // 5/6 (mod/mod2x) fold to alpha until a model needs them
1 => ParticleBlend::AlphaKey,
_ => ParticleBlend::Opaque,
}
}
/// `DAT_00811fa8` — the reference's per-blend-mode lighting table, indexed by the emitter's RAW
/// blend field (file+0x28): the multiply/modulate modes light nothing, every other mode does
/// (wow-re `part-scene-multipliers.md` §1, VERIFIED at `0x70bb0a`).
const LIGHTING_BY_BLEND: [bool; 7] = [true, true, true, true, true, false, false];
/// Does the reference draw this emitter's quads **LIT** by the scene?
///
/// A particle emitter has no material of its own: the reference *synthesizes* an `M2Material` from
/// the file record every draw (`0x70d8b0`) and runs it through the SAME batch state producer as an
/// ordinary mesh submesh, so `GL_LIGHTING` (EGxRs id `0x0e`) is decided exactly as it is for a
/// mesh. Byte-verified at `0x70baf0` @`0x70bb00` (wow-re `part-scene-multipliers.md` §1):
///
/// ```text
/// lit ⇔ (file+0x04 bit 0x1 CLEAR) AND DAT_00811fa8[file+0x28] ≠ 0
/// ```
///
/// **File bit `0x1` is the UNLIT flag** — the *inverse* of the wowdev-wiki lore ("0x1 = affected by
/// lighting"), and matching the M2 render-flag `0x01 = unlit` convention everywhere else in the
/// format. That polarity is the whole point: the fire/spell corpus authors `0x1` and stays bright at
/// night, while the ambient environment sheets that CLEAR it — waterfall spray, chimney smoke,
/// blown dust and snow — are shaded by the world's own light like the geometry they sit against.
/// Rendering those unlit puts a full-white foam sheet in a shaded jungle (the Zul'Gurub waterfall).
fn lit_of(flags: u32, blend_byte: u8) -> bool {
flags & 0x1 == 0
&& LIGHTING_BY_BLEND
.get(usize::from(blend_byte))
.copied()
.unwrap_or(true)
}
fn shape_of(v: u16) -> ParticleShape {
match v {
2 => ParticleShape::Sphere,
3 => ParticleShape::Spline,
_ => ParticleShape::Plane,
}
}
/// Parse the vanilla M2's particle emitters from its file bytes. Texture indices are resolved against
/// the M2 textures table (parsed by `benilla-m2`; the particle *records* are read here). Returns an
/// empty vec if the model has no emitters or isn't a parseable M2.
pub fn parse_m2_particle_emitters(bytes: &[u8]) -> Result<Vec<ParticleEmitterDef>> {
// Texture names come from `benilla-m2`'s textures table.
let textures: Vec<Option<String>> = match parse_m2(&mut Cursor::new(bytes)) {
Ok(fmt) => fmt
.model()
.textures
.iter()
.map(|t| {
let f = t
.filename
.string
.to_string_lossy()
.trim_end_matches('\0')
.to_string();
(!f.is_empty()).then_some(f)
})
.collect(),
Err(_) => Vec::new(),
};
if bytes.len() < HDR_PTR + 4 || &bytes[..4] != b"MD20" {
return Ok(Vec::new());
}
let count = le_u32(bytes, HDR_COUNT) as usize;
let base = le_u32(bytes, HDR_PTR) as usize;
if count == 0 || count > 256 || base + count * STRIDE > bytes.len() {
return Ok(Vec::new());
}
// Every FILE sequence slot's band + loop flag (entry stride 0x44: band start/end @ +0x04/+0x08,
// flags @ +0x10, bit 0 CLEAR = loops) — the addressing unit the per-sequence timing bake needs,
// the same walk as `m2_batches`. A sequence-less model gets one synthetic whole-timeline slot
// so an authored track still bakes (raw timestamps, the pre-sequence degenerate).
let mut seq_slots: Vec<SeqSlot> = {
let (n, o) = (le_u32(bytes, 0x1c) as usize, le_u32(bytes, 0x20) as usize);
(0..n)
.map_while(|i| {
let e = o + i * 0x44;
(e + 0x44 <= bytes.len()).then(|| SeqSlot {
index: i,
band: (le_u32(bytes, e + 0x04), le_u32(bytes, e + 0x08)),
looping: le_u32(bytes, e + 0x10) & 1 == 0,
})
})
.collect()
};
if seq_slots.is_empty() {
seq_slots.push(SeqSlot {
index: 0,
band: (0, u32::MAX),
looping: true,
});
}
// The global-sequence duration table (header +0x14/+0x18) — a gseq-tagged timing track loops
// on its own free clock, same law as every other channel.
let gseq: Vec<u32> = {
let (n, o) = (le_u32(bytes, 0x14) as usize, le_u32(bytes, 0x18) as usize);
(0..n)
.map_while(|i| (o + i * 4 + 4 <= bytes.len()).then(|| le_u32(bytes, o + i * 4)))
.collect()
};
// Decode a packed BGRA `CImVector` color key → linear RGBA 0..1 (A = over-life opacity).
let color_key = |o: usize| -> [f32; 4] {
let v = le_u32(bytes, o);
[
((v >> 16) & 0xff) as f32 / 255.0, // R
((v >> 8) & 0xff) as f32 / 255.0, // G
(v & 0xff) as f32 / 255.0, // B
((v >> 24) & 0xff) as f32 / 255.0, // A
]
};
let mut out = Vec::with_capacity(count);
for i in 0..count {
let e = base + i * STRIDE;
let tex_index = le_u16(bytes, e + 0x16) as usize;
let texture = textures.get(tex_index).cloned().flatten();
let shape = shape_of(le_u16(bytes, e + 0x2a));
// The two per-emitter model paths (`M2Array<char>`, offset pre-fixup = file-relative):
// geometry (model particles) and recursion (child emitters).
let model_path = |cnt_off: usize| -> Option<String> {
let n = le_u32(bytes, e + cnt_off) as usize;
let ofs = le_u32(bytes, e + cnt_off + 4) as usize;
if n < 2 || ofs + n > bytes.len() {
return None;
}
let s = String::from_utf8_lossy(&bytes[ofs..ofs + n])
.trim_end_matches('\0')
.to_string();
(!s.is_empty()).then_some(s)
};
let geometry_model = model_path(0x18);
let recursion_model = model_path(0x20);
// SPLINE control points (file+0x1d4/+0x1d8, raw M2Array dwords): the reference copies
// N = 3·⌊count/3⌋+1 C3Vectors — the flattened cubic Bézier chain.
let spline = (shape == ParticleShape::Spline)
.then(|| {
let q = le_u32(bytes, e + 0x1d4) as usize;
let ofs = le_u32(bytes, e + 0x1d8) as usize;
let n = 3 * (q / 3) + 1;
if q < 3 || ofs + n * 12 > bytes.len() {
return None;
}
SplineData::new((0..n).map(|p| le_vec3(bytes, ofs + p * 12)).collect())
})
.flatten();
// ROWS @+0x30 / COLUMNS @+0x32, both required non-zero powers of two — the reference's
// `0x7b4ed0` bails on a bad pair having written nothing, leaving its ctor's 1×1.
let tiles = match (le_u16(bytes, e + 0x30), le_u16(bytes, e + 0x32)) {
(r, c) if r.is_power_of_two() && c.is_power_of_two() => (r, c),
_ => (1, 1),
};
let over_life = OverLife {
mid: le_f32(bytes, e + 0x14c),
color: [
color_key(e + 0x150),
color_key(e + 0x154),
color_key(e + 0x158),
],
scale: [
le_f32(bytes, e + 0x15c),
le_f32(bytes, e + 0x160),
le_f32(bytes, e + 0x164),
],
// The +0x168..+0x17b block is TEN u16s, read `{head A, head B, tail A, tail B}` with a
// repeat count wedged after each head pair — not the eight that wowdev's
// `lifespanUVAnim[3]/decayUVAnim[3]/tailUVAnim[2]` naming implies (wow-re
// `part-cell-flipbook-ramp.md` §1; +0x17c is already the tail LENGTH dword).
head_cells: [
CellRamp::new(le_u16(bytes, e + 0x168), le_u16(bytes, e + 0x16a)),
CellRamp::new(le_u16(bytes, e + 0x16e), le_u16(bytes, e + 0x170)),
],
tail_cells: [
CellRamp::new(le_u16(bytes, e + 0x174), le_u16(bytes, e + 0x176)),
CellRamp::new(le_u16(bytes, e + 0x178), le_u16(bytes, e + 0x17a)),
],
repeat: [
f32::from(le_u16(bytes, e + 0x16c)),
f32::from(le_u16(bytes, e + 0x172)),
],
};
out.push(ParticleEmitterDef {
flags: le_u32(bytes, e + 0x04),
position: le_vec3(bytes, e + 0x08),
bone: le_u16(bytes, e + 0x14),
shape,
spline,
geometry_model,
recursion_model,
blend: blend_of(bytes[e + 0x28]),
lit: lit_of(le_u32(bytes, e + 0x04), bytes[e + 0x28]),
texture,
tile_rows: tiles.0,
tile_cols: tiles.1,
head_tail: bytes[e + 0x2c],
timing: EmitTiming::bake(
&read_raw_track(bytes, e + 0xdc, 4, le_f32),
&read_raw_track(bytes, e + 0x1dc, 1, |b, o| f32::from(b[o] != 0)),
&seq_slots,
&gseq,
),
// The nine parameter tracks, full-key ([`EmitParams`] field order — bases 0x1c apart,
// the emitter phase's own walk). NOT `value[0]`: several effects animate them.
params: EmitParams::bake(
[
&read_raw_track(bytes, e + 0x34, 4, le_f32), // speed
&read_raw_track(bytes, e + 0x50, 4, le_f32), // speedVar
&read_raw_track(bytes, e + 0x6c, 4, le_f32), // latitude
&read_raw_track(bytes, e + 0x88, 4, le_f32), // longitude
&read_raw_track(bytes, e + 0xa4, 4, le_f32), // gravity
&read_raw_track(bytes, e + 0xc0, 4, le_f32), // lifespan
&read_raw_track(bytes, e + 0xf8, 4, le_f32), // areaLength
&read_raw_track(bytes, e + 0x114, 4, le_f32), // areaWidth
&read_raw_track(bytes, e + 0x130, 4, le_f32), // zSource
],
&seq_slots,
&gseq,
),
drag: le_f32(bytes, e + 0x194),
tail_time: le_f32(bytes, e + 0x17c),
angular_velocity_min: le_vec3(bytes, e + 0x19c),
angular_velocity_max: le_vec3(bytes, e + 0x1a8),
inherit_scale: le_f32(bytes, e + 0x190),
follow_speed1: le_f32(bytes, e + 0x1c4),
follow_scale1: le_f32(bytes, e + 0x1c8),
follow_speed2: le_f32(bytes, e + 0x1cc),
follow_scale2: le_f32(bytes, e + 0x1d0),
twinkle_speed: le_f32(bytes, e + 0x180),
twinkle_percent: le_f32(bytes, e + 0x184),
twinkle_min: le_f32(bytes, e + 0x188),
twinkle_max: le_f32(bytes, e + 0x18c),
spin: le_f32(bytes, e + 0x198),
over_life,
});
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// **File bit 0x1 is UNLIT, not "lit"** — the polarity this whole mechanism turns on, and the
/// one the community table gets backwards (wowdev: "0x1 = affected by lighting"; the binary
/// forces `GL_LIGHTING` OFF on that bit, wow-re `part-scene-multipliers.md` §1 @`0x70bb00`).
/// Inverting it would light exactly the wrong 95% of the corpus: every fire and spell effect
/// would go dim at night while the environment sheets stayed full-white cutouts — a change
/// that "works" on any single model you happen to check first.
#[test]
fn bit_one_is_the_unlit_flag_and_mod_blends_never_light() {
// The Zul'Gurub / Elwynn waterfall: bit 0x1 CLEAR, blend 2 (alpha) ⇒ the scene lights it.
assert!(
lit_of(0x0002, 2),
"an emitter that CLEARS 0x1 takes the light"
);
// The Orgrimmar bonfire's flame (0x29) and smoke (0x21): both SET 0x1 ⇒ both unlit, which
// is what keeps a campfire bright at night.
assert!(!lit_of(0x0029, 4), "0x1 set: unlit, whatever the blend");
assert!(!lit_of(0x0021, 2));
// `DAT_00811fa8` = {1,1,1,1,1,0,0}: the multiply modes light nothing even with 0x1 clear —
// a lit modulate would darken twice, once through the light and once through the blend.
for blend in 0u8..=4 {
assert!(lit_of(0x0000, blend), "blend {blend} lights");
}
assert!(!lit_of(0x0000, 5), "Mod never lights");
assert!(!lit_of(0x0000, 6), "Mod2x never lights");
// A blend byte past the table is not a panic and not a silent "unlit" — the reference
// indexes a 7-entry table it never overruns, and every such value already folds to
// `Opaque`, which lights.
assert!(lit_of(0x0000, 7));
}
/// The real corpus, at the emitter the director reported: the waterfall's ONE emitter clears
/// bit 0x1 and blends alpha, so it must come back LIT — the asset-side half of the foam fix.
#[test]
fn the_waterfall_emitter_is_lit() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let bytes = chain
.read_file(
"World\\Azeroth\\Elwynn\\PassiveDoodads\\Waterfall\\ElwynnTallWaterfall01.m2",
)
.expect("read ElwynnTallWaterfall01.m2");
let defs = parse_m2_particle_emitters(&bytes).expect("parse emitters");
assert_eq!(defs.len(), 1);
assert_eq!(defs[0].flags, 0x0002, "authored flag word");
assert_eq!(defs[0].blend, ParticleBlend::Alpha);
assert!(defs[0].lit, "the spray sheet is shaded by the world");
}
/// The spline chain is **arc-length** parameterized: two straight segments of length 1 and
/// 3 (control points at exact thirds, so the cubic degenerates to linear) put the segment
/// boundary at t = 0.25, not 0.5 — plus the reference's outside-[0,1] clamp legs.
#[test]
fn spline_chain_is_arc_length_parameterized() {
let x = |v: f32| [v, 0.0, 0.0];
let s = SplineData::new(vec![
x(0.0),
x(1.0 / 3.0),
x(2.0 / 3.0),
x(1.0), // segment 0: 1 yd
x(2.0),
x(3.0),
x(4.0), // segment 1: 3 yd
])
.expect("3K+1 chain");
assert!((s.eval(0.25)[0] - 1.0).abs() < 1e-4, "boundary at arc 1/4");
assert!((s.eval(0.625)[0] - 2.5).abs() < 1e-4, "mid of segment 1");
assert_eq!(s.eval(-0.5), [0.0, 0.0, 0.0], "clamp to first point");
assert_eq!(s.eval(1.5), [4.0, 0.0, 0.0], "clamp to last point");
let tan = s.tangent(0.1);
assert!(tan[0] > 0.0 && tan[1] == 0.0 && tan[2] == 0.0, "+X tangent");
assert!(SplineData::new(vec![x(0.0)]).is_none(), "below one segment");
}
/// The real `BloodSpurt.m2` (decision 0140 fold-back — the melee impact flash): 4 emitters,
/// and the three the old `value[0]` bake silently killed are **keyed bursts** — the starflash
/// (STARFLASH_GREY, additive) rates 0→20→0 over the first 133 ms. Pins the burst keys and
/// that the spray (emitter 0) still reads its constant-equivalent first key of 100/s.
#[test]
fn real_blood_spurt_emitters_are_keyed_bursts() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let bytes = chain
.read_file("Particles\\BloodSpurts\\BloodSpurt.m2")
.expect("read BloodSpurt.m2");
let defs = parse_m2_particle_emitters(&bytes).expect("parse emitters");
assert_eq!(defs.len(), 4);
// Emitter 0 — the red spray: starts hot (100/s at t=0), the old bake's one working case.
assert_eq!(defs[0].timing.rate(None, 0.0, 0.0), 100.0);
// Emitter 3 — the starflash: additive, STARFLASH texture, burst-keyed (first key 0,
// peak 20/s inside the 67–100 ms window, dead again by 133 ms).
let flash = &defs[3];
assert!(flash
.texture
.as_deref()
.is_some_and(|t| t.to_ascii_uppercase().contains("STARFLASH")));
assert_eq!(flash.blend, ParticleBlend::Add);
assert_eq!(flash.timing.rate(None, 0.0, 0.0), 0.0, "silent at t=0");
assert_eq!(flash.timing.peak_rate(), 20.0);
assert_eq!(flash.timing.rate(None, 0.080, 0.0), 20.0);
assert_eq!(flash.timing.rate(None, 0.200, 0.0), 0.0);
// Emitters 1/2 — the glowball droplets, same burst shape, peaks 200.
assert_eq!(defs[1].timing.peak_rate(), 200.0);
assert_eq!(defs[2].timing.peak_rate(), 200.0);
// The spray's enabled track cuts emission at 500 ms — exactly where its rate track goes
// negative (the authored tail the old always-on read let the floor-at-0 hide).
assert!(defs[0].timing.emitting(None, 0.4, 0.0));
assert!(!defs[0].timing.emitting(None, 0.6, 0.0));
}
/// The real `Feint_Impact_Chest.m2` (the Eviscerate/Feint impact) — the BURST-flag split
/// that bounds its visible burst at ~0.5 s on the reference: plume (e0) and crescents (e3)
/// author file flag 0x8000 (one-shot burst — a single `ftol(rate)` puff at their 67 ms key),
/// lava (e1) and dust (e2) are continuous, choreographed by their enabled windows. The
/// cast-side `Eviscerate_Cast_Hands.m2` is the counter-case: the same 1.6 s asset shape with
/// bit 15 clear — a continuous pour for the whole clip.
#[test]
fn real_feint_impact_authors_burst_emitters() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let impact = parse_m2_particle_emitters(
&chain
.read_file("Spells\\Feint_Impact_Chest.m2")
.expect("read Feint_Impact_Chest.m2"),
)
.expect("parse emitters");
assert_eq!(impact.len(), 4);
assert!(impact[0].burst(), "plume is a one-shot burst");
assert!(!impact[1].burst(), "lava pours (enabled window 0–333 ms)");
assert!(!impact[2].burst(), "dust pours (enabled window 67–200 ms)");
assert!(impact[3].burst(), "crescents are a one-shot burst");
// The STEP rate law that arms the burst count: silent before the 67 ms key, the full
// value at it, held past it (where the burst latch keeps it from ever mattering).
assert_eq!(impact[0].timing.rate(None, 0.050, 0.0), 0.0);
assert_eq!(impact[0].timing.rate(None, 0.067, 0.0), 30.0);
assert_eq!(impact[0].timing.rate(None, 1.500, 0.0), 30.0);
let cast = parse_m2_particle_emitters(
&chain
.read_file("Spells\\Eviscerate_Cast_Hands.m2")
.expect("read Eviscerate_Cast_Hands.m2"),
)
.expect("parse emitters");
assert!(
cast.iter().all(|e| !e.burst()),
"the cast-hands flame is continuous — same asset shape, opposite flag"
);
}
/// The real `FlameStrike_Area.m2` — the spline-record law on real content (the
/// `part-spline-file-layout.md` corpus caveat, resolved): e2..e5 are type-3 emitters whose
/// control-point counts land exactly on the `3K+1` flattened-Bézier law (16 and 19), with
/// the repurposed fields reading sanely (t window [0,1], no spin/scatter — the fire
/// columns sit ON their authored descent curves with zero birth velocity).
#[test]
fn real_flamestrike_authors_spline_chains() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let defs = parse_m2_particle_emitters(
&chain
.read_file("Spells\\FlameStrike_Area.m2")
.expect("read FlameStrike_Area.m2"),
)
.expect("parse emitters");
let splines: Vec<_> = defs
.iter()
.filter(|d| d.shape == ParticleShape::Spline)
.collect();
assert_eq!(splines.len(), 4, "the four fire-column emitters");
for d in &splines {
let s = d.spline.as_ref().expect("chain parses");
assert_eq!(s.points.len() % 3, 1, "3K+1 control points");
assert!(s.points.len() >= 16);
let now = d.params.sample(None, 0.0, 0.0);
assert_eq!(now.area_length, 0.0, "tMin");
assert_eq!(now.area_width, 1.0, "tMax");
assert_eq!(now.vertical_range, 0.0, "no tangent spin");
assert!(d.burst(), "one puff of standing flames");
// The chain starts high (the authored descent) — a sane, in-model coordinate.
assert!(
(5.0..15.0).contains(&s.points[0][2]),
"z {}",
s.points[0][2]
);
assert_eq!(s.eval(0.0), s.points[0], "t=0 is the first point");
}
}
/// The real `Fire_Cast_Hand.m2` (spell 133's "go" flash) and `MoltenBlast_Impact_Chest.m2`
/// (its impact): the authored choreography this parse exists to carry. Cast hand: constant
/// rates, enabled for exactly the first 200 ms of the clip. Impact emitter 0 (the plume
/// burst): rate ramps 0→60 across the first 133 ms — a full second late under the old raw
/// global-timeline read (seq 0 spans [1000, 2600]).
#[test]
fn real_fireball_effects_rebase_to_clip_time() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cast = parse_m2_particle_emitters(
&chain
.read_file("Spells\\Fire_Cast_Hand.m2")
.expect("read Fire_Cast_Hand.m2"),
)
.expect("parse emitters");
assert_eq!(cast.len(), 3);
for em in &cast {
assert!(
em.timing.emitting(None, 0.1, 0.0),
"the flash is ON at its start"
);
assert!(
!em.timing.emitting(None, 0.25, 0.0),
"and OFF from 200 ms — the 1.0 s clip does not burn through"
);
}
let impact = parse_m2_particle_emitters(
&chain
.read_file("Spells\\MoltenBlast_Impact_Chest.m2")
.expect("read MoltenBlast_Impact_Chest.m2"),
)
.expect("parse emitters");
assert_eq!(impact.len(), 6);
assert_eq!(impact[0].timing.rate(None, 0.0, 0.0), 0.0);
assert_eq!(
impact[0].timing.rate(None, 0.133, 0.0),
60.0,
"the plume bursts at impact"
);
assert!(impact[1].timing.emitting(None, 0.1, 0.0));
assert!(
!impact[1].timing.emitting(None, 0.4, 0.0),
"shockwave window is 300 ms"
);
assert!(
!impact[4].timing.emitting(None, 0.05, 0.0),
"smoke starts staggered…"
);
assert!(impact[4].timing.emitting(None, 0.2, 0.0));
assert!(
!impact[4].timing.emitting(None, 0.7, 0.0),
"…and ends by 567 ms"
);
}
}