- 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>
1133 lines
58 KiB
Rust
1133 lines
58 KiB
Rust
//! 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"
|
||
);
|
||
}
|
||
}
|