Part of #41: the comments of `benilla-formats` (the library, its extract tools, examples and tests) are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 19,654 comment lines become 7,264. Fixes #105: the WMO collision comments now name walking's box-query leaf `0x6bca50` and the camera and line-of-sight segment-query leaf `0x6bc700`, in `models/collision.rs`, `examples/wmo_bsp.rs` and `tests/chain.rs`. - **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal. - **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them: - A placed doodad's material alpha reads file slot 0 on its spawn clock, while the reference reads bone 0's armed sequence, re-rolled every play window. The old text said doodads are armed once at load. - The fog end is stored ×36 and scaled once at load (`0x6d6090`). It is not raw. - The MCSH bit picks a map doodad's sun scale, 1.0 lit or 0.5 shadowed. 2.5 belongs to an entity's light node. - `SpellRec` is the row as stored, column × 4, not a translated copy. - A global sequence's clock is anchored at the instance's attach time (`0x714352`). - Particle twinkle is a gated size multiplier (`0x7b2adc`). - The blob shadow sizes from the Stand box. - Data claims are checked against the install: the weapon-trail kits' parameters and alphas, the beam rows' noise, and the maps' `field_16`. - Dozens more turned up on the way: renamed or deleted items, docs sitting on the wrong item, stale counts and links. - **Deviation labels.** "Deviation:" now marks only a choice with a reason. An unbuilt behaviour says so plainly. - **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Four second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the reference, the code and the shipped data. String literals are untouched; they are #92. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
437 lines
16 KiB
Rust
437 lines
16 KiB
Rust
//! The M2 particle-emitter parser against shipped models.
|
||
|
||
use benilla_formats::{
|
||
open_chain, parse_m2_particle_emitters, CellRamp, OverLife, ParticleBlend, ParticleShape,
|
||
};
|
||
|
||
#[test]
|
||
fn campfire_emitters_match_real_bytes() {
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let mut chain = open_chain(&data).expect("open vanilla patch chain");
|
||
let bytes = chain
|
||
.read_file("World\\Azeroth\\Elwynn\\PassiveDoodads\\Campfire\\ElwynnCampfire.m2")
|
||
.expect("read ElwynnCampfire.m2");
|
||
|
||
let emitters = parse_m2_particle_emitters(&bytes).expect("parse emitters");
|
||
|
||
// Two additive plane emitters, a slow glow plume and a fast 4×4-flipbook flame, off the file.
|
||
assert_eq!(emitters.len(), 2, "campfire has two emitters");
|
||
|
||
for e in &emitters {
|
||
assert_eq!(e.shape, ParticleShape::Plane);
|
||
assert_eq!(e.blend, ParticleBlend::Add);
|
||
let now = e.params.sample(None, 0.0, 0.0);
|
||
assert!(now.lifespan > 0.0 && now.lifespan.is_finite());
|
||
let rate = e
|
||
.timing
|
||
.constant_rate()
|
||
.expect("ambient prop rates are constant tracks");
|
||
assert!(rate > 0.0 && rate.is_finite());
|
||
assert!(now.horizontal_range > 6.0, "campfire emits in a full ring");
|
||
assert!(
|
||
e.texture.as_deref().is_some_and(|t| !t.is_empty()),
|
||
"emitter texture resolves, got {:?}",
|
||
e.texture
|
||
);
|
||
}
|
||
|
||
let glow = &emitters[0];
|
||
let glow_now = glow.params.sample(None, 0.0, 0.0);
|
||
assert!(
|
||
(glow_now.lifespan - 4.0).abs() < 1e-3,
|
||
"glow lifespan ~4.0, got {}",
|
||
glow_now.lifespan
|
||
);
|
||
assert!(
|
||
(glow.timing.constant_rate().unwrap() - 6.0).abs() < 1e-3,
|
||
"glow rate ~6, got {:?}",
|
||
glow.timing.constant_rate()
|
||
);
|
||
assert_eq!((glow.tile_rows, glow.tile_cols), (1, 1));
|
||
assert!(glow_now.vertical_range > 0.3, "glow has a wide cone");
|
||
|
||
let flame = &emitters[1];
|
||
let flame_now = flame.params.sample(None, 0.0, 0.0);
|
||
assert!(
|
||
(flame_now.lifespan - 1.5).abs() < 1e-3,
|
||
"flame lifespan ~1.5, got {}",
|
||
flame_now.lifespan
|
||
);
|
||
assert!(
|
||
(flame.timing.constant_rate().unwrap() - 20.0).abs() < 1e-3,
|
||
"flame rate ~20, got {:?}",
|
||
flame.timing.constant_rate()
|
||
);
|
||
assert_eq!(
|
||
(flame.tile_rows, flame.tile_cols),
|
||
(4, 4),
|
||
"flame has a 4×4 flicker atlas"
|
||
);
|
||
assert!(
|
||
flame_now.vertical_range < 0.2,
|
||
"flame is a tight upward jet"
|
||
);
|
||
|
||
// Drag, file `+0x194`, decays velocity as `vel -= min(dt·drag, 1)·vel`.
|
||
assert!(
|
||
(glow.drag - 0.5).abs() < 1e-3,
|
||
"campfire glow drag ~0.5, got {}",
|
||
glow.drag
|
||
);
|
||
assert!(
|
||
flame.drag == 0.0,
|
||
"campfire flame drag 0, got {}",
|
||
flame.drag
|
||
);
|
||
|
||
eprintln!("campfire emitters OK:");
|
||
for (i, e) in emitters.iter().enumerate() {
|
||
let ol = &e.over_life;
|
||
eprintln!(
|
||
" [{i}] {:?} {:?} tex={:?} life={} rate={:?} cone={:.3} tiles={}x{}",
|
||
e.shape,
|
||
e.blend,
|
||
e.texture,
|
||
e.params.sample(None, 0.0, 0.0).lifespan,
|
||
e.timing.constant_rate(),
|
||
e.params.sample(None, 0.0, 0.0).vertical_range,
|
||
e.tile_rows,
|
||
e.tile_cols
|
||
);
|
||
eprintln!(
|
||
" mid={:.2} color={:?} scale={:?} head{:?} tail{:?} repeat{:?}",
|
||
ol.mid, ol.color, ol.scale, ol.head_cells, ol.tail_cells, ol.repeat
|
||
);
|
||
for u in [0.0_f32, 0.25, 0.5, 0.75, 1.0] {
|
||
let s = ol.sample(u);
|
||
eprintln!(
|
||
" u={u:.2}: rgba={:?} size={:.3} cell head={} tail={}",
|
||
s.color, s.size, s.head_cell, s.tail_cell
|
||
);
|
||
}
|
||
|
||
for s in ol.scale {
|
||
assert!(
|
||
s.is_finite() && (0.0..8.0).contains(&s),
|
||
"emitter {i} scale {s} sane"
|
||
);
|
||
}
|
||
for k in ol.color {
|
||
for ch in k {
|
||
assert!(
|
||
(0.0..=1.0).contains(&ch),
|
||
"emitter {i} color channel {ch} in 0..1"
|
||
);
|
||
}
|
||
}
|
||
assert!(
|
||
(0.0..=1.0).contains(&ol.mid),
|
||
"emitter {i} midPoint in 0..1"
|
||
);
|
||
}
|
||
|
||
let glow_ol = &emitters[0].over_life;
|
||
let a_start = glow_ol.sample(0.0).color[3];
|
||
let a_end = glow_ol.sample(1.0).color[3];
|
||
assert!(
|
||
a_end <= a_start,
|
||
"glow alpha should not rise over life ({a_start} -> {a_end})"
|
||
);
|
||
|
||
let flame_ol = &emitters[1].over_life;
|
||
let cell_start = flame_ol.sample(0.0).head_cell;
|
||
let cell_end = flame_ol.sample(1.0).head_cell;
|
||
assert!(
|
||
cell_end >= cell_start,
|
||
"flame cell advances ({cell_start} -> {cell_end})"
|
||
);
|
||
}
|
||
|
||
/// The flipbook cell ramp against the reference's output (`0x7b9da0` builds it, `0x7b9b10` samples
|
||
/// it): `cell(0) == begin` and `cell(1) == end` exactly, by the build's `±1` and the
|
||
/// `0.99·t + 0.005` inset together, and a decreasing pair plays backwards.
|
||
#[test]
|
||
fn cell_ramp_matches_the_reference_including_backwards() {
|
||
let at = |begin: u16, end: u16| {
|
||
let r = CellRamp::new(begin, end);
|
||
// u = 0, ¼, ½, ¾ and 1, through the evaluator's inset.
|
||
[0.0_f32, 0.25, 0.5, 0.75, 1.0]
|
||
.map(|t| r.sample(t * 0.99 + 0.005))
|
||
.to_vec()
|
||
};
|
||
|
||
// Forward, and the three inverted pairs the shipped corpus authors.
|
||
assert_eq!(at(0, 15), vec![0, 4, 8, 11, 15], "ascending 0..15");
|
||
assert_eq!(at(6, 5), vec![6, 6, 6, 5, 5], "DwarvenBrazier01 (6,5)");
|
||
assert_eq!(
|
||
at(31, 16),
|
||
vec![31, 27, 24, 20, 16],
|
||
"ShadowWordSilence (31,16)"
|
||
);
|
||
assert_eq!(at(15, 0), vec![15, 11, 8, 4, 0], "ShadowWordSilence (15,0)");
|
||
|
||
for (b, e) in [(0, 15), (8, 16), (31, 16), (15, 0), (6, 5), (7, 7), (0, 63)] {
|
||
let r = CellRamp::new(b, e);
|
||
assert_eq!(r.sample(0.005), b, "cell(u=0) == begin for ({b},{e})");
|
||
assert_eq!(r.sample(0.995), e, "cell(u=1) == end for ({b},{e})");
|
||
}
|
||
|
||
// Through `OverLife::sample`, where the inset is applied.
|
||
let ol = OverLife {
|
||
mid: 0.5,
|
||
color: [[1.0; 4]; 3],
|
||
scale: [1.0; 3],
|
||
head_cells: [CellRamp::new(0, 7), CellRamp::new(31, 16)],
|
||
tail_cells: [CellRamp::new(3, 3), CellRamp::new(9, 4)],
|
||
repeat: [1.0; 2],
|
||
};
|
||
assert_eq!(ol.sample(0.0).head_cell, 0, "u=0 sits on segment A's begin");
|
||
assert_eq!(ol.sample(1.0).head_cell, 16, "u=1 sits on segment B's end");
|
||
assert_eq!(ol.sample(0.0).tail_cell, 3, "the tail ramp is sampled too");
|
||
assert_eq!(ol.sample(1.0).tail_cell, 4, "…and backwards, independently");
|
||
// The split is inclusive toward A (`age > lifespan·mid` is the only way into B).
|
||
assert_eq!(
|
||
ol.sample(0.5).head_cell,
|
||
7,
|
||
"u==mid is still segment A, at its end"
|
||
);
|
||
}
|
||
|
||
/// Colour and size ride the cells' inset: `0x7b9b10` stores `t·0.99 + 0.005` once and every
|
||
/// channel reloads it, so a particle starts 0.5% into its ramp and ends 0.5% short.
|
||
#[test]
|
||
fn colour_and_size_ride_the_same_inset() {
|
||
let ol = OverLife {
|
||
mid: 0.5,
|
||
color: [[0.0; 4], [1.0; 4], [1.0; 4]],
|
||
scale: [0.0, 100.0, 100.0],
|
||
head_cells: [CellRamp::new(0, 0); 2],
|
||
tail_cells: [CellRamp::new(0, 0); 2],
|
||
repeat: [1.0; 2],
|
||
};
|
||
// Segment A runs 0 to 100 over u in [0, 0.5].
|
||
let start = ol.sample(0.0);
|
||
assert!(
|
||
(start.size - 0.5).abs() < 1e-4,
|
||
"u=0 is 0.5 % into the ramp, not on the key: got {}",
|
||
start.size
|
||
);
|
||
assert!(
|
||
(start.color[0] - 0.005).abs() < 1e-4,
|
||
"colour takes the same inset: got {}",
|
||
start.color[0]
|
||
);
|
||
let end = ol.sample(0.5);
|
||
assert!(
|
||
(end.size - 99.5).abs() < 1e-3,
|
||
"u=mid is 99.5 % along, not 100 %: got {}",
|
||
end.size
|
||
);
|
||
}
|
||
|
||
/// `DwarvenBrazier01`'s settling flame authors the inverted segment-B pair `(6, 5)`.
|
||
#[test]
|
||
fn inverted_ramp_on_real_data_does_not_panic() {
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let mut chain = open_chain(&data).expect("open vanilla patch chain");
|
||
let bytes = chain
|
||
.read_file("World\\Generic\\Dwarf\\Passive Doodads\\Braziers\\DwarvenBrazier01.m2")
|
||
.expect("read DwarvenBrazier01.m2");
|
||
let emitters = parse_m2_particle_emitters(&bytes).expect("parse emitters");
|
||
let ol = &emitters[1].over_life;
|
||
assert_eq!(
|
||
(ol.head_cells[0].begin, ol.head_cells[0].end),
|
||
(0, 5),
|
||
"segment A runs forward"
|
||
);
|
||
assert_eq!(
|
||
(ol.head_cells[1].begin, ol.head_cells[1].end),
|
||
(6, 5),
|
||
"segment B is the INVERTED pair — the whole point of this test"
|
||
);
|
||
// Every point of the particle's life, at the granularity a 60 Hz frame would visit.
|
||
for i in 0..=1000 {
|
||
let s = ol.sample(i as f32 / 1000.0);
|
||
assert!(s.head_cell <= 6, "cell stays inside the authored pair");
|
||
}
|
||
}
|
||
|
||
/// The per-segment flipbook repeat count (file `+0x16c`, `+0x172`) cycles the cell ramp by
|
||
/// `fmod(t·repeat, 1.0)`; the 18 shipped emitters that author one are the Insect Swarm visuals.
|
||
#[test]
|
||
fn repeat_count_cycles_the_flipbook() {
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let mut chain = open_chain(&data).expect("open vanilla patch chain");
|
||
let bytes = chain
|
||
.read_file("SPELLS\\InsectSwarm_State_Chest.m2")
|
||
.expect("read InsectSwarm_State_Chest.m2");
|
||
let emitters = parse_m2_particle_emitters(&bytes).expect("parse emitters");
|
||
let ol = &emitters[0].over_life;
|
||
assert_eq!(ol.repeat, [5.0, 5.0], "the swarm authors a 5× cycle");
|
||
|
||
let mid = ol.mid;
|
||
let mut drops = 0;
|
||
let mut prev = ol.sample(0.0).head_cell;
|
||
for i in 1..=2000 {
|
||
let c = ol.sample(mid * (i as f32 / 2000.0)).head_cell;
|
||
if c < prev {
|
||
drops += 1;
|
||
}
|
||
prev = c;
|
||
}
|
||
assert_eq!(
|
||
drops, 4,
|
||
"5 passes over the segment == 4 wraps, got {drops}"
|
||
);
|
||
}
|
||
|
||
/// File `+0x188` and `+0x18c` are twinkleScale `{min, max}`, a per-frame size flicker skipped when
|
||
/// the range is degenerate (`0x7b2a50`), not a spawn-time multiplier: the kobold candle authors
|
||
/// `{0, 0}` and burns in the reference.
|
||
#[test]
|
||
fn twinkle_fields_gate_not_scale() {
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let mut chain = open_chain(&data).expect("open vanilla patch chain");
|
||
|
||
let kobold = parse_m2_particle_emitters(
|
||
&chain
|
||
.read_file("Creature\\Kobold\\Kobold.m2")
|
||
.expect("read Kobold.m2"),
|
||
)
|
||
.expect("parse kobold emitters");
|
||
assert_eq!(kobold.len(), 1, "kobold has one candle emitter");
|
||
let candle = &kobold[0];
|
||
assert_eq!((candle.twinkle_min, candle.twinkle_max), (0.0, 0.0));
|
||
assert_eq!(
|
||
candle.twinkle(0.7),
|
||
1.0,
|
||
"degenerate {{0,0}} twinkle is identity — the candle burns at ramp size"
|
||
);
|
||
assert!(
|
||
candle.over_life.sample(0.5).size > 0.0,
|
||
"the candle flame's over-life size ramp is nonzero"
|
||
);
|
||
|
||
let campfire = parse_m2_particle_emitters(
|
||
&chain
|
||
.read_file("World\\Azeroth\\Elwynn\\PassiveDoodads\\Campfire\\ElwynnCampfire.m2")
|
||
.expect("read ElwynnCampfire.m2"),
|
||
)
|
||
.expect("parse campfire emitters");
|
||
let glow = &campfire[0];
|
||
assert_eq!((glow.twinkle_min, glow.twinkle_max), (0.0, 1.0));
|
||
assert_eq!(glow.twinkle(0.25), 0.25, "active range lerps min..max");
|
||
assert!(glow.twinkle_percent.is_finite() && glow.twinkle_speed.is_finite());
|
||
}
|
||
|
||
/// The file-to-runtime flag remap (`0x70faf8`-`0x70fc44`): file bit 0x10 (runtime 0x100) is model
|
||
/// space, file 0x20 (runtime 0x200) scales size by the placement. The chandelier's flames (0x11,
|
||
/// 0x15) ride its swing; the campfire (0x21, 0x29) scales.
|
||
#[test]
|
||
fn flag_remap_reads_the_file_bits() {
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let mut chain = open_chain(&data).expect("open vanilla patch chain");
|
||
|
||
let kobold =
|
||
parse_m2_particle_emitters(&chain.read_file("Creature\\Kobold\\Kobold.m2").unwrap())
|
||
.unwrap();
|
||
assert_eq!(kobold[0].flags, 0x01);
|
||
assert!(!kobold[0].model_space() && !kobold[0].scale_size_by_instance());
|
||
|
||
let chandelier = parse_m2_particle_emitters(
|
||
&chain
|
||
.read_file("World\\Dungeon\\GoldshireInn\\InnChandelier\\InnChandelier.m2")
|
||
.unwrap(),
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
chandelier.iter().take(6).all(|e| e.model_space()),
|
||
"the swinging candle flames are model-space (file bit 0x10)"
|
||
);
|
||
|
||
let campfire = parse_m2_particle_emitters(
|
||
&chain
|
||
.read_file("World\\Azeroth\\Elwynn\\PassiveDoodads\\Campfire\\ElwynnCampfire.m2")
|
||
.unwrap(),
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
campfire
|
||
.iter()
|
||
.all(|e| e.scale_size_by_instance() && !e.model_space()),
|
||
"campfire: size-by-scale (0x20) set, model-space (0x10) clear"
|
||
);
|
||
}
|
||
|
||
/// `G_BarrelExplode.m2` (GameObject 20737): all 7 emitters fire inside the one-shot clips (slot 0,
|
||
/// anim 157; slot 3, anim 150 Destroy) and are off in both idles (slot 1 Stand, slot 2 Closed).
|
||
#[test]
|
||
fn barrel_explode_emitters_are_off_at_rest_and_fire_in_their_clips() {
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let mut chain = open_chain(&data).expect("open vanilla patch chain");
|
||
let emitters = parse_m2_particle_emitters(
|
||
&chain
|
||
.read_file("World\\Goober\\G_BarrelExplode.m2")
|
||
.expect("read G_BarrelExplode.m2"),
|
||
)
|
||
.expect("parse emitters");
|
||
assert_eq!(emitters.len(), 7, "the barrel authors seven emitters");
|
||
for (i, e) in emitters.iter().enumerate() {
|
||
assert!(e.timing.peak_rate() > 0.0, "emitter {i} can emit");
|
||
for slot in [1usize, 2] {
|
||
for t in [0.0f32, 0.1, 0.3, 5.0] {
|
||
assert!(
|
||
!e.timing.emitting(Some(slot), t, 0.0),
|
||
"emitter {i} must be OFF at rest (slot {slot}, t {t})"
|
||
);
|
||
}
|
||
}
|
||
// The explode clip, slot 0, runs 1 s and clamps.
|
||
let fires = (0..100).any(|k| e.timing.emitting(Some(0), k as f32 * 0.01, 0.0));
|
||
assert!(fires, "emitter {i} fires inside the explode clip");
|
||
assert!(
|
||
!e.timing.emitting(Some(0), 1.0, 0.0),
|
||
"emitter {i} must be off at the clip end"
|
||
);
|
||
assert!(
|
||
!e.timing.emitting(Some(0), 30.0, 0.0),
|
||
"emitter {i} must stay off parked past the clip"
|
||
);
|
||
}
|
||
let destroy_fires = emitters
|
||
.iter()
|
||
.any(|e| (0..100).any(|k| e.timing.emitting(Some(3), k as f32 * 0.01, 0.0)));
|
||
assert!(destroy_fires, "the Destroy clip plays the explosion");
|
||
}
|
||
|
||
/// Particle `blendingType` 1 is AlphaKey, not Opaque: blending off under `glAlphaFunc(GEQUAL,
|
||
/// 224/255)`, which cuts `PARTROCK.BLP`'s silhouette. The earth elemental is the one creature that
|
||
/// authors it, and nothing in 1.12.1 authors mode 0.
|
||
#[test]
|
||
fn earth_elemental_debris_is_alphakey_not_opaque() {
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let mut chain = open_chain(&data).expect("open vanilla patch chain");
|
||
let bytes = chain
|
||
.read_file("Creature\\ElementalEarth\\ElementalEarth.m2")
|
||
.expect("read ElementalEarth.m2");
|
||
|
||
let emitters = parse_m2_particle_emitters(&bytes).expect("parse emitters");
|
||
assert_eq!(emitters.len(), 13, "the elemental authors 13 emitters");
|
||
|
||
// Emitters 3 and 12 are the rock chips; 11 is the third PARTROCK emitter, authored mode 2.
|
||
let rock = |e: &benilla_formats::ParticleEmitterDef| {
|
||
e.texture
|
||
.as_deref()
|
||
.is_some_and(|t| t.eq_ignore_ascii_case("CREATURE\\ELEMENTALEARTH\\PARTROCK.BLP"))
|
||
};
|
||
assert!(rock(&emitters[3]) && rock(&emitters[11]) && rock(&emitters[12]));
|
||
assert_eq!(emitters[3].blend, ParticleBlend::AlphaKey);
|
||
assert_eq!(emitters[12].blend, ParticleBlend::AlphaKey);
|
||
assert_eq!(emitters[11].blend, ParticleBlend::Alpha);
|
||
|
||
assert!(
|
||
!emitters.iter().any(|e| e.blend == ParticleBlend::Opaque),
|
||
"no emitter here folds to Opaque"
|
||
);
|
||
}
|