diff --git a/crates/benilla-assets/src/wdt.rs b/crates/benilla-assets/src/wdt.rs index fd628ee5..d54eddb2 100644 --- a/crates/benilla-assets/src/wdt.rs +++ b/crates/benilla-assets/src/wdt.rs @@ -3,10 +3,12 @@ //! Decodes a map's tiny `.wdt` into a [`WdtIndex`] — the 64×64 `MAIN` tile-existence grid the //! terrain streamer consults before requesting any `.adt` (decision 0476): open ocean authors no //! tiles, and probing them wholesale spammed asset-server NotFound errors on every boat crossing. +//! It also carries the map's **global WMO** where there is one (decision 0688): on the 20 shipped +//! maps that author no terrain at all, that single placement is the entire world. //! The parse itself is `benilla-wdt`'s [`WdtReader`] (oracle-tested in the 0021 migration); this //! module only wraps it in the Bevy asset machinery. -use benilla_formats::{WdtFile, WdtReader, WowVersion}; +use benilla_formats::{GlobalWmo, WdtFile, WdtReader, WowVersion}; use bevy::asset::io::Reader; use bevy::asset::{Asset, AssetLoader, LoadContext}; use bevy::reflect::TypePath; @@ -24,6 +26,12 @@ impl WdtIndex { .get_tile(tile_x as usize, tile_y as usize) .is_some_and(|t| t.has_adt) } + + /// The map's single global building, on a map with no terrain (`MPHD` bit 0) — see + /// [`GlobalWmo`]. `None` on an ADT map. + pub fn global_wmo(&self) -> Option<&GlobalWmo> { + self.0.global_wmo() + } } /// Bevy [`AssetLoader`] decoding a vanilla `*.wdt` → [`WdtIndex`]. diff --git a/crates/benilla-assets/src/wmo.rs b/crates/benilla-assets/src/wmo.rs index f2170337..d5258624 100644 --- a/crates/benilla-assets/src/wmo.rs +++ b/crates/benilla-assets/src/wmo.rs @@ -113,6 +113,16 @@ pub struct WmoModel { /// attach path — a WMO MODD doodad never reaches it (create never calls SetMatrix; wow-re /// `trace-forensics-abbey-interior-d3d` §1.1). pub doodad_base: Vec, + /// Per-MODD **owning group** (the first group whose MODR refs name it), parallel to + /// [`Self::doodads`]; `None` for a MODD no group references. + /// + /// This is the prop's portal-cull key. The reference commits a WMO's doodads **per visible + /// group** — `0x695aa0` loops the group's own MODR refs at `group+0xe8`/count `+0x144`, called + /// from the visible-group walk `0x698720` (wow-re `m2-interior-doodad-base-light.md` §453, + /// `trace-forensics-abbey-interior-d3d.md` §90) — so a group the portal flood culled draws none + /// of its furniture. Without this the props outlive their own building: cull every group of a + /// dungeon and its lanterns, crates and cobwebs hang in the void (decision 0689). + pub doodad_owner: Vec>, /// Per-group FOOTPRINT face set (render tris + baked MOCV + MOPY flags), indexed by absolute /// group index; `None` for exterior groups / groups without MOCV. The **GameObject M2** light /// lane samples it: the entity node's env-update attach down-rays the render mesh under the @@ -278,22 +288,11 @@ pub fn floor168(c: [u8; 3]) -> [f32; 3] { floor_raise(c, 168) } -/// Resolve every MODD placement's lighting base once, at load. Ownership and interior class follow -/// the faithful create path: a doodad belongs to the group(s) whose **MODR** references it (the -/// instantiate loop is per-group over MODR — never a spatial test), the owning group's -/// interior/exterior class (`MOGI flags & 0x48`) picks the lane, and the owning group's **MOLR** -/// list is its complete point-light candidate set. A MODD referenced by no group is never -/// instantiated by the reference at all — Exterior here (the harmless default if our spawner shows -/// it anyway). -fn resolve_doodad_bases( - doodads: &[WmoDoodad], - groups: &[WmoGroupInfo], - group_doodad_refs: &[Vec], - group_light_refs: &[Vec], -) -> Vec { - // Invert MODR: MODD index → owning group (first referencing group wins; a doodad shared by two - // groups gets one def per group in the reference — one instance and its first owner here). - let mut owner: Vec> = vec![None; doodads.len()]; +/// Invert MODR: MODD index → its owning group. First referencing group wins — a doodad named by two +/// groups gets one def *per group* in the reference; we instantiate it once, under its first owner. +/// `None` = referenced by no group at all (the reference never instantiates such a MODD). +fn modr_owners(doodad_count: usize, group_doodad_refs: &[Vec]) -> Vec> { + let mut owner: Vec> = vec![None; doodad_count]; for (gi, refs) in group_doodad_refs.iter().enumerate() { for &di in refs { if let Some(slot) = owner.get_mut(di as usize) { @@ -301,9 +300,28 @@ fn resolve_doodad_bases( } } } + owner +} + +/// Resolve every MODD placement's lighting base once, at load. Ownership and interior class follow +/// the faithful create path: a doodad belongs to the group(s) whose **MODR** references it (the +/// instantiate loop is per-group over MODR — never a spatial test), the owning group's +/// interior/exterior class (`MOGI flags & 0x48`) picks the lane, and the owning group's **MOLR** +/// list is its complete point-light candidate set. A MODD referenced by no group is never +/// instantiated by the reference at all — Exterior here (the harmless default if our spawner shows +/// it anyway). +/// +/// `owner` is [`modr_owners`]' inversion, shared with the portal-cull key ([`WmoModel::doodad_owner`]) +/// so the lighting lane and the cull can never disagree about which group a prop belongs to. +fn resolve_doodad_bases( + doodads: &[WmoDoodad], + groups: &[WmoGroupInfo], + owner: &[Option], + group_light_refs: &[Vec], +) -> Vec { doodads .iter() - .zip(&owner) + .zip(owner) .map(|(d, owner)| { let Some(gi) = owner else { return DoodadBase::Exterior; @@ -495,11 +513,13 @@ impl AssetLoader for WmoModelLoader { } } // Resolve every MODD placement's base once — placements, colours, MODR ownership, and MOLR - // light lists are all fixed in the root/groups, so nothing here is ever re-derived. + // light lists are all fixed in the root/groups, so nothing here is ever re-derived. The MODR + // inversion is shared with the portal-cull key below, so the two can't disagree. + let doodad_owner = modr_owners(root.doodads().len(), &group_doodad_refs); let doodad_base = resolve_doodad_bases( root.doodads(), root.group_infos(), - &group_doodad_refs, + &doodad_owner, &group_light_refs, ); @@ -534,6 +554,7 @@ impl AssetLoader for WmoModelLoader { lights: parse_wmo_lights(&bytes), group_bounds: root.group_infos().to_vec(), doodad_base, + doodad_owner, group_footprints, group_footprint_bounds, group_light_refs, @@ -615,7 +636,11 @@ mod doodad_base_tests { let groups = [grp(true), grp(false)]; let modr = vec![vec![0u16], vec![1u16]]; let molr = vec![vec![7u16, 9u16], vec![3u16]]; - let bases = resolve_doodad_bases(&doodads, &groups, &modr, &molr); + let owner = modr_owners(doodads.len(), &modr); + // The same inversion the portal cull keys props on (decision 0689): owned props name their + // group, the unreferenced one names none — so it is the one prop the cull can't hide. + assert_eq!(owner, vec![Some(0), Some(1), None]); + let bases = resolve_doodad_bases(&doodads, &groups, &owner, &molr); match &bases[0] { DoodadBase::Interior(b) => { assert_eq!(b.light_refs, vec![7, 9]); @@ -636,7 +661,7 @@ mod doodad_base_tests { let bases = resolve_doodad_bases( &[prop([120, 110, 150, 255])], &[grp(true)], - &[vec![0u16]], + &[Some(0)], &[Vec::new()], ); match &bases[0] { @@ -644,4 +669,21 @@ mod doodad_base_tests { other => panic!("expected interior base, got {other:?}"), } } + + /// A MODD named by several groups resolves to its **first** referencing group, and the mapping is + /// dense over the doodad list — the portal cull keys every prop off this (decision 0689), so a + /// shared prop is culled with one real room rather than never culled at all. Out-of-range MODR + /// entries (a malformed group) are ignored rather than panicking. + #[test] + fn a_shared_modd_belongs_to_its_first_referencing_group() { + // g0 names doodad 2; g1 names 0 and 2; g2 names 1 — plus a ref past the end of the list. + let modr = vec![vec![2u16], vec![0u16, 2u16], vec![1u16, 99u16]]; + assert_eq!( + modr_owners(3, &modr), + vec![Some(1), Some(2), Some(0)], + "doodad 2 is named by g0 and g1 — g0 wins" + ); + // No groups at all ⇒ nothing is owned, and nothing is cullable. + assert_eq!(modr_owners(2, &[]), vec![None, None]); + } } diff --git a/crates/benilla-formats/examples/dump_emitters.rs b/crates/benilla-formats/examples/dump_emitters.rs index a82788c9..75e54f33 100644 --- a/crates/benilla-formats/examples/dump_emitters.rs +++ b/crates/benilla-formats/examples/dump_emitters.rs @@ -37,7 +37,19 @@ fn main() -> anyhow::Result<()> { e.lifespan, e.drag ); - println!(" rate {:?}", e.emission_rate); + match e.timing.constant_rate() { + Some(r) => println!(" rate {r}/s (constant, every sequence)"), + None => { + for (slot, (looping, rate, enabled)) in e.timing.slot_views().iter().enumerate() { + println!( + " seq {slot}{}: rate {:?} enabled {:?}", + if *looping { "" } else { " (clamped)" }, + rate, + enabled + ); + } + } + } println!( " area {}x{} zsrc {} tail {} spin {}", e.area_length, e.area_width, e.z_source, e.tail_time, e.spin diff --git a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs index 771c59de..9ddca211 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs @@ -211,24 +211,52 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { // and never pours — reading its keys as a continuous rate is the exact misdiagnosis // behind the Eviscerate 0.5s-vs-2s gap (wow-re part-emission-burst-flag.md). let burst = if d.burst() { "BURST " } else { "" }; - let rate = if d.emission_rate.keys.len() == 1 { - format!("{burst}rate {:.1}/s", d.emission_rate.first()) - } else { - format!( - "{burst}rate keys {:?}", - d.emission_rate - .keys + // PER-SEQUENCE timing (the runtime's actual sampling unit — the old print showed the two + // tracks rebased onto sequence 0's band, which read as authoritative and was exactly the + // B27 misparse). The quiet case stays quiet: one constant rate, no gate anywhere. + let views = d.timing.slot_views(); + let rate = match d.timing.constant_rate() { + Some(r) => format!("{burst}rate {r:.1}/s"), + None => { + let per: Vec = views .iter() - .map(|&(t, v)| (t, v as i32)) - .collect::>() - ) + .enumerate() + .map(|(s, (_, rate, _))| match rate { + Some(keys) => format!( + "s{s} {:?}", + keys.iter().map(|&(t, v)| (t, v as i32)).collect::>() + ), + None => format!("s{s} -"), + }) + .collect(); + format!("{burst}rate/seq [{}]", per.join(" ")) + } }; - // The enabled gate (clip-relative ms, like the rate keys): a one-shot effect's - // choreography — "why does this emitter only flash for 200 ms" reads right here. - let rate = if d.enabled.keys.len() == 1 { - rate // always-on (the overwhelmingly common shape) — no noise + // The enabled gate, per sequence slot (seconds from the slot's band start) — a one-shot + // effect's choreography, and a state GameObject's "which clips actually fire this". + let rate = if views.iter().all(|(_, _, e)| e.is_none()) { + rate // no gate authored anywhere (the overwhelmingly common shape) — no noise } else { - format!("{rate} enabled {:?}", d.enabled.keys) + let per: Vec = views + .iter() + .enumerate() + .map(|(s, (looping, _, enabled))| { + let clock = if *looping { "" } else { "!" }; + match enabled { + Some(keys) => { + let w: Vec = keys + .iter() + .map(|&(t, v)| { + format!("{t:.2}:{}", if v > 0.5 { "on" } else { "off" }) + }) + .collect(); + format!("s{s}{clock} {}", w.join(" ")) + } + None => format!("s{s}{clock} on"), + } + }) + .collect(); + format!("{rate} enabled/seq [{}]", per.join(" ")) }; // A tail's streak length is |velocity|·tail_time — without it "how long is this // streak" needs a hand-parse of the raw record (the Eviscerate diagnosis gap). diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index 4e2c0be6..81e96afb 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -132,6 +132,12 @@ enum Command { /// `0x134`) — the population instrument for the ribbon subsystem (weapon trails, streamers): /// which content actually authors one, before we build for it. Ribbonscan, + /// Sweep every `.m2` and census its particle emitters' over-life **flipbook** fields: cell + /// ramps that play BACKWARDS (`begin > end` — legal, shipped, and fatal to a reader that + /// clamps into the pair), tail streaks whose ramp differs from the head's, indices past the + /// atlas (the reference wraps the column and lets the row run off), per-segment repeat counts, + /// and the two degenerate shapes the reference itself falls back on. Decision 0685. + Cellscan, /// Sweep every `.m2` (optionally under a path prefix) and list the models whose MATERIAL /// table authors the MULTIPLY blend modes 5 (Mod) / 6 (Mod2x) — the ARMORREFLECT sheen /// family (decision 0528): the population instrument for the multiply-blend mechanism. @@ -440,6 +446,7 @@ fn main() -> Result<()> { Command::M2batch { internal_path } => m2dump::m2batch(&mut chain, &internal_path)?, Command::M2alpha { internal_path } => m2dump::m2alpha(&mut chain, &internal_path)?, Command::Ribbonscan => scan::ribbonscan(&mut chain)?, + Command::Cellscan => scan::cellscan(&mut chain)?, Command::Blendscan { prefix } => scan::blendscan(&mut chain, prefix.as_deref())?, Command::Bbscan { prefix } => scan::bbscan(&mut chain, prefix.as_deref())?, Command::Bbfacescan { prefix } => scan::bbfacescan(&mut chain, prefix.as_deref())?, diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs index 0d646241..7ef3d110 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -2124,3 +2124,114 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> { println!(" hitting a rate-0 freeze leg (a motion clip standing in for a missing rest pose): {rate0}"); Ok(()) } + +/// Sweep every `.m2` and census its particle emitters' over-life **flipbook** fields — the +/// population instrument behind decision 0685 (the reverse-playing cell ramp). +/// +/// One line per emitter that is interesting on any axis, then the totals. The axes are exactly the +/// ways a flipbook reader can be wrong, each of which shipped data does exercise: +/// +/// - `INVERTED` — a `begin > end` pair. Legal, and it means *play the sheet backwards*; a reader +/// that clamps into `[begin, end]` mangles it (and in Rust panics outright). +/// - `TAIL-RAMP` — the tail streak's own ramp differs from the head's, on an emitter that draws a +/// tail. The two are independently authored (file +0x168.. vs +0x174..); handing the head's cell +/// to the streak animates it through a sheet the author pinned to one cell. +/// - `PAST-ATLAS` — a cell index beyond `rows·cols`. The reference masks the COLUMN and leaves the +/// ROW unbounded, so the index wraps to row 0 rather than holding the last cell. +/// - `REPEAT` — a per-segment repeat count ≠ 1, i.e. the sheet cycles more than once per segment. +/// - `NON-POW2` / `MID` — the two shapes the reference itself degrades on (a 1×1 fallback, and a +/// `mid` of 0/1 that walks its own sampler into a NaN). Both are empty in 1.12.1 and are here so +/// that stays checkable. +pub fn cellscan(chain: &mut Chain) -> Result<()> { + let names: Vec = chain + .list() + .context("listing chain contents")? + .into_iter() + .map(|e| e.name) + .filter(|n| n.to_ascii_lowercase().ends_with(".m2")) + .collect(); + + let (mut models, mut emitters) = (0u32, 0u32); + let (mut inverted, mut tail_ramp, mut repeat_ne1) = (0u32, 0u32, 0u32); + let (mut past_atlas, mut past_atlas_real, mut bad_tiles, mut bad_mid) = + (0u32, 0u32, 0u32, 0u32); + + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + models += 1; + let Ok(defs) = benilla_formats::parse_m2_particle_emitters(&bytes) else { + continue; + }; + for (i, e) in defs.iter().enumerate() { + emitters += 1; + let ol = &e.over_life; + let at = format!("{name} [{i}] {}x{}", e.tile_rows, e.tile_cols); + let ramps = [ + ol.head_cells[0], + ol.head_cells[1], + ol.tail_cells[0], + ol.tail_cells[1], + ]; + let pair = |r: &benilla_formats::CellRamp| (r.begin, r.end); + + if ramps.iter().any(|r| r.begin > r.end) { + inverted += 1; + println!( + "INVERTED {at} head {:?}/{:?}", + pair(&ol.head_cells[0]), + pair(&ol.head_cells[1]) + ); + } + // Only a tail-drawing emitter (particleType 1/2) can show a tail-ramp difference. + if e.head_tail >= 1 && ol.tail_cells != ol.head_cells { + tail_ramp += 1; + println!( + "TAIL-RAMP {at} head {:?}/{:?} tail {:?}/{:?}", + pair(&ol.head_cells[0]), + pair(&ol.head_cells[1]), + pair(&ol.tail_cells[0]), + pair(&ol.tail_cells[1]) + ); + } + if ol.repeat.iter().any(|&r| r != 1.0) { + repeat_ne1 += 1; + println!("REPEAT {at} {:?}", ol.repeat); + } + let atlas = e.tile_rows * e.tile_cols; + if ramps.iter().any(|r| r.begin >= atlas || r.end >= atlas) { + past_atlas += 1; + // On a 1×1 sheet every index resolves to the same texture, so only a real atlas + // can show the wrap. + if atlas > 1 { + past_atlas_real += 1; + println!( + "PAST-ATLAS {at} head {:?}/{:?}", + pair(&ol.head_cells[0]), + pair(&ol.head_cells[1]) + ); + } + } + if !e.tile_rows.is_power_of_two() || !e.tile_cols.is_power_of_two() { + bad_tiles += 1; + println!("NON-POW2 {at}"); + } + if !(ol.mid > 0.0 && ol.mid < 1.0) { + bad_mid += 1; + println!("MID {at} mid {}", ol.mid); + } + } + } + + eprintln!( + "{models} models / {emitters} emitters scanned\n \ + INVERTED {inverted}\n \ + TAIL-RAMP {tail_ramp}\n \ + REPEAT {repeat_ne1}\n \ + PAST-ATLAS {past_atlas} ({past_atlas_real} on a real >1x1 atlas)\n \ + NON-POW2 {bad_tiles}\n \ + MID 0 or 1 {bad_mid}" + ); + Ok(()) +} diff --git a/crates/benilla-formats/src/emit_timing.rs b/crates/benilla-formats/src/emit_timing.rs new file mode 100644 index 00000000..41b51571 --- /dev/null +++ b/crates/benilla-formats/src/emit_timing.rs @@ -0,0 +1,257 @@ +//! Per-sequence **particle emission timing** — the FN1 bake of the emitter's two +//! per-frame-sampled M2Tracks (spawn rate `+0xdc`, enabled gate `+0x1dc`), one loop per FILE +//! sequence slot. Split from [`crate::particles`] (the raw record parse) because it is the +//! *runtime sampling* face: decision 0641's material-alpha structure, one channel over. + +use benilla_m2::M2ScalarTrack; + +use crate::models::{bake_track, ScalarAnim, SeqSlot}; + +/// Per-sequence **emission timing**: the emitter's two per-frame-sampled M2Tracks — spawn rate +/// (`+0xdc`) and the enabled gate (`+0x1dc`) — baked one loop per FILE sequence slot through the +/// FN1 kernel ([`crate::models::bake_track`]), exactly the material-alpha structure of decision +/// 0641 one channel over. The reference's emitter phase of `m2_animate` samples both through the +/// **playing** sequence's key window every frame and forces the spawn rate to 0 while the gate is +/// off (wow-re `part-emission-rate-animated.md` §2/§3, byte-verified `0x717d90`/`0x718f32`). +/// +/// The clock law rides the slot: a **looping** sequence wraps its band (`t mod period` — a +/// windowed gate re-fires every pass), a **clamped** one parks at the band end and holds the +/// tail value (never aliasing back to the band start). Consumers resolve `seq` from whatever +/// clock they run: a placed doodad passes `None` (slot 0 — its one-time arm), an effect its +/// armed slot, a unit/GameObject its live playing sequence. +#[derive(Debug, Clone, Default)] +pub struct EmitTiming { + /// Baked rate loop per file slot; `None` = the track keys nothing there (spawn rate 0). + rate: Vec>, + /// Baked gate per file slot (step, 0/1); `None` = no gate authored — the loader default is + /// ON (`0x710092`: `block+0x14c = 1` for every emitter). + enabled: Vec>, + /// Per file slot: sequence flags bit 0 CLEAR = the band loops (the kernel's modulo wrap). + looping: Vec, +} + +impl EmitTiming { + /// Bake both tracks against every file sequence slot. `slots`/`looping` are parallel + /// (one per file sequence); `gseq` is the global-sequence duration table. + pub(crate) fn bake( + rate: &M2ScalarTrack, + enabled: &M2ScalarTrack, + slots: &[SeqSlot], + looping: Vec, + gseq: &[u32], + ) -> Self { + // Keep every baked shape, constants included — this channel has no static fallback path, + // so a held value must survive the bake (`|_| false` on both predicates). + let per_slot = |t: &M2ScalarTrack| -> Vec> { + slots + .iter() + .map(|&s| bake_track(t, gseq, Some(s), |v| v, |_| false, |_| false)) + .collect() + }; + Self { + rate: per_slot(rate), + enabled: per_slot(enabled), + looping, + } + } + + /// Resolve a consumer's sequence to a file slot: out-of-range / unknown degrades to slot 0 + /// (the doodad lane's one-time arm, and the old single-band behaviour). + fn idx(&self, seq: Option) -> usize { + match seq { + Some(i) if i < self.looping.len() => i, + _ => 0, + } + } + + /// Is the gate ON, `elapsed` seconds into sequence slot `seq`? A slot with no baked gate is + /// ON (the loader default). + pub fn emitting(&self, seq: Option, elapsed: f32) -> bool { + let i = self.idx(seq); + let wrap = self.looping.get(i).copied().unwrap_or(true); + self.enabled + .get(i) + .and_then(|o| o.as_ref()) + .is_none_or(|a| a.sample_clocked(elapsed, wrap, 1.0) > 0.5) + } + + /// The spawn rate (particles/sec), `elapsed` seconds into sequence slot `seq`. A slot with no + /// baked rate spawns nothing. Floored at 0 (a track tail may legitimately go negative). + pub fn rate(&self, seq: Option, elapsed: f32) -> f32 { + let i = self.idx(seq); + let wrap = self.looping.get(i).copied().unwrap_or(true); + self.rate + .get(i) + .and_then(|o| o.as_ref()) + .map_or(0.0, |a| a.sample_clocked(elapsed, wrap, 0.0)) + .max(0.0) + } + + /// The rate track's peak over every slot — the "can this emitter ever contribute" spawn gate + /// (a burst emitter keys `0 → peak → 0`, so its first key is 0 but it absolutely emits). + pub fn peak_rate(&self) -> f32 { + self.rate + .iter() + .flatten() + .flat_map(|a| a.keys.iter().map(|&(_, v)| v)) + .fold(0.0, f32::max) + } + + /// `Some(rate)` when every slot bakes the same single-key rate — the overwhelmingly common + /// shape, and the dump instruments' quiet case. + pub fn constant_rate(&self) -> Option { + let mut it = self.rate.iter(); + let first = it.next()?.as_ref()?; + let &(_, v) = (first.keys.len() == 1).then(|| first.keys.first())??; + it.all(|a| a.as_ref().is_some_and(|a| a.keys == first.keys)) + .then_some(v) + } + + /// Per-slot read view for the dump instruments: `(looping, rate keys, enabled keys)`, one per + /// file sequence slot (`None` = the track keys nothing in that slot). Times are seconds from + /// the slot's band start — the values the runtime actually samples, not the raw file keys. + #[allow(clippy::type_complexity)] // a read-only tuple view for the dumps + pub fn slot_views(&self) -> Vec<(bool, Option<&[(f32, f32)]>, Option<&[(f32, f32)]>)> { + fn keys(list: &[Option], i: usize) -> Option<&[(f32, f32)]> { + list.get(i) + .and_then(|o| o.as_ref()) + .map(|a| a.keys.as_slice()) + } + (0..self.looping.len()) + .map(|i| (self.looping[i], keys(&self.rate, i), keys(&self.enabled, i))) + .collect() + } + + /// Test/tool constructor: a single always-on slot at a constant `rate`, looping. + pub fn constant(rate: f32) -> Self { + Self { + rate: vec![Some(ScalarAnim { + period: 0.0, + step: true, + keys: vec![(0.0, rate)], + })], + enabled: vec![None], + looping: vec![true], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn track(interp: u16, keys: &[(u32, f32)], ranges: &[(u32, u32)]) -> M2ScalarTrack { + M2ScalarTrack { + interp, + gseq: 0xffff, + ranges: ranges.to_vec(), + keys: keys.to_vec(), + } + } + + fn slots(bands: &[(u32, u32)]) -> Vec { + bands + .iter() + .enumerate() + .map(|(index, &band)| SeqSlot { index, band }) + .collect() + } + + /// The STEP rate law: a `{0:0, 67:30}` track is silent before its 67 ms key — the burst + /// fires AT the key, full-count — and holds 30 after (moved here from the runtime's + /// `accumulate_emission` tests when the sampling moved into the bake). + #[test] + fn step_rate_is_silent_before_its_key_and_holds_after() { + let t = EmitTiming::bake( + &track(0, &[(0, 0.0), (67, 30.0)], &[]), + &M2ScalarTrack::default(), + &slots(&[(0, 1000)]), + vec![true], + &[], + ); + assert_eq!(t.rate(None, 0.050), 0.0, "before the key: step holds 0"); + assert_eq!(t.rate(None, 0.070), 30.0, "at/after the key: 30"); + assert_eq!(t.rate(None, 0.500), 30.0, "held to the band end"); + assert!(t.emitting(None, 0.5), "no gate track = always on"); + } + + /// The LINEAR ramp law (the BloodSpurt shape `0 → 100 → 0`, interp 1): mid-ramp pours the + /// interpolated rate, and the self-closing tail really falls back to 0. + #[test] + fn lerp_ramp_interpolates_and_self_closes() { + let t = EmitTiming::bake( + &track(1, &[(0, 0.0), (100, 100.0), (200, 0.0)], &[]), + &M2ScalarTrack::default(), + &slots(&[(0, 1000)]), + vec![true], + &[], + ); + assert!((t.rate(None, 0.050) - 50.0).abs() < 1e-4, "rising mid-ramp"); + assert!( + (t.rate(None, 0.150) - 50.0).abs() < 1e-4, + "falling mid-ramp" + ); + assert_eq!(t.rate(None, 0.500), 0.0, "the ramp self-closes"); + } + + /// The per-sequence window law — the B27 shape, synthesized: an enabled gate authored ON + /// inside a clamped one-shot clip (slot 0) whose window in the idle loop (slot 1) resolves + /// to OFF. The old seq-0-only rebase parked the clamped clock at its end value; the bake + /// must read OFF at idle, run slot 0's window, and HOLD (not wrap) slot 0's tail. + #[test] + fn idle_window_is_off_and_a_clamped_clip_holds_its_tail() { + // Absolute timeline: clip A (one-shot) band 1000..2000, idle band 2333..2667. + // Gate keys: on@1000, off@1333, on@3800 (a later clip's window, outside both bands). + let gate = track( + 0, + &[(1000, 1.0), (1333, 0.0), (3800, 1.0)], + &[(0, 1), (1, 1), (2, 2)], + ); + let t = EmitTiming::bake( + &track(0, &[(0, 20.0)], &[]), + &gate, + &slots(&[(1000, 2000), (2333, 2667), (3800, 4100)]), + vec![false, true, false], + &[], + ); + // Idle (slot 1): the collapsed window (1,1) resolves to keys[1] = OFF — at every time, + // wrap included. + assert!(!t.emitting(Some(1), 0.0)); + assert!(!t.emitting(Some(1), 0.25)); + assert!(!t.emitting(Some(1), 400.0)); + // The one-shot clip (slot 0): ON at its start, OFF from 333 ms — and the clamped clock + // HOLDS that tail at/after the band end instead of aliasing back to the ON start. + assert!(t.emitting(Some(0), 0.1)); + assert!(!t.emitting(Some(0), 0.5)); + assert!(!t.emitting(Some(0), 1.0), "t == period must not alias to 0"); + assert!( + !t.emitting(Some(0), 5.0), + "parked long past the end: still off" + ); + // The later clip (slot 2): its degenerate window is the ON key. + assert!(t.emitting(Some(2), 0.05)); + // Unknown/out-of-range degrades to slot 0 (the doodad law). + assert!(t.emitting(None, 0.1)); + assert!(!t.emitting(Some(9), 0.5)); + // The rate is a whole-track constant: same in every slot. + assert_eq!(t.constant_rate(), Some(20.0)); + assert_eq!(t.peak_rate(), 20.0); + } + + /// A LOOPING slot wraps its band — a windowed gate re-fires every pass (the precast hold's + /// pulsing hand flash), where a clamped slot would have parked. + #[test] + fn a_looping_band_wraps_its_gate_window() { + let gate = track(0, &[(0, 1.0), (200, 0.0)], &[]); + let t = EmitTiming::bake( + &track(0, &[(0, 40.0)], &[]), + &gate, + &slots(&[(0, 1000)]), + vec![true], + &[], + ); + assert!(t.emitting(None, 0.1), "first pass: inside the window"); + assert!(!t.emitting(None, 0.8), "first pass: past it"); + assert!(t.emitting(None, 1.1), "second pass: the window re-fires"); + } +} diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index b6bf19ac..12173cc5 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -129,15 +129,17 @@ pub use spell_visual::{ load_spell_visual_catalog, SpellVisualCatalog, VisualKit, VisualStages, KIT_SLOT_TAGS, MISSILE_ATTACH_TABLE, }; +mod emit_timing; +pub use emit_timing::EmitTiming; mod particles; pub use particles::{ - parse_m2_particle_emitters, OverLife, ParticleBlend, ParticleEmitterDef, ParticleShape, - SplineData, + parse_m2_particle_emitters, CellRamp, OverLife, OverLifeSample, ParticleBlend, + ParticleEmitterDef, ParticleShape, SplineData, }; mod ribbons; pub use ribbons::{parse_m2_ribbon_emitters, RibbonEmitterDef}; mod value_track; -pub use value_track::{OnOffTrack, TrackValue, ValueTrack}; +pub use value_track::{TrackValue, ValueTrack}; mod models; pub use models::{ accumulate_wmo_group_camera_collision, accumulate_wmo_group_collision, hand_grip_finger_poses, @@ -168,7 +170,7 @@ mod wdl; /// World (x, y) ↔ ADT tile `(col, row)` — the same mapping the streamer uses to pick tiles /// (and the minimap uses to place its tile art, decision 0203: minimap `map_.blp` names /// share the ADT index order, chain-verified — `AhnQiraj_27_46.adt` exists, the swap doesn't). -pub use benilla_wdt::{tile_to_world, world_to_tile, WdtFile, WdtReader, WowVersion}; +pub use benilla_wdt::{tile_to_world, world_to_tile, GlobalWmo, WdtFile, WdtReader, WowVersion}; pub use wdl::{WdlFile, WdlTileMesh}; // The map arc (decision 0203), phase 0: the minimap tile hash catalog + the world-map DBCs. diff --git a/crates/benilla-formats/src/models/key_anim.rs b/crates/benilla-formats/src/models/key_anim.rs index 2dfc6a77..572170c6 100644 --- a/crates/benilla-formats/src/models/key_anim.rs +++ b/crates/benilla-formats/src/models/key_anim.rs @@ -55,7 +55,7 @@ impl Lerp for [f32; 4] { /// is what indexes the track's [`M2Track::ranges`] — NOT an index into a filtered animation list) /// and that sequence's absolute `(start_ms, end_ms)` band. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) struct SeqSlot { +pub(crate) struct SeqSlot { pub index: usize, pub band: (u32, u32), } @@ -76,14 +76,27 @@ pub struct KeyAnim { impl KeyAnim { /// Sample at `elapsed` seconds on the loop clock; `empty` is the channel's identity for the /// keyless case (the bake never emits that — each channel's `sample` supplies it). - pub(super) fn sample_or(&self, elapsed: f32, empty: V) -> V { + pub(crate) fn sample_or(&self, elapsed: f32, empty: V) -> V { + self.sample_clocked(elapsed, true, empty) + } + + /// [`Self::sample_or`] with the clock law explicit: `wrap` = the playing sequence LOOPS + /// (`t mod period` — the kernel's modulo wrap re-fires windowed tracks every pass); `!wrap` = + /// it CLAMPS (`min(t, period)` — the clock parks at the band end and the track holds its tail + /// value there, never wrapping back to the band-start value). The distinction is what keeps a + /// clamped one-shot clip's end state honest: at `t == period` a modulo would alias to `0`. + pub(crate) fn sample_clocked(&self, elapsed: f32, wrap: bool, empty: V) -> V { let Some(&(t0, v0)) = self.keys.first() else { return empty; }; if self.period <= 0.0 || self.keys.len() == 1 { return v0; } - let t = elapsed.rem_euclid(self.period); + let t = if wrap { + elapsed.rem_euclid(self.period) + } else { + elapsed.clamp(0.0, self.period) + }; if t <= t0 { return v0; } @@ -169,7 +182,7 @@ pub(super) fn sample_window( /// `gseq_durations` is the model's global-sequence table (ms); `seq` the sequence to bake for /// (see [`SeqSlot`]). A `gseq`-tagged track ignores `seq` entirely — it runs on the global /// sequence's own free clock, the same loop in every animation. -pub(super) fn bake_track( +pub(crate) fn bake_track( track: &M2Track, gseq_durations: &[u32], seq: Option, diff --git a/crates/benilla-formats/src/models/mod.rs b/crates/benilla-formats/src/models/mod.rs index 6591b83d..4994ba9d 100644 --- a/crates/benilla-formats/src/models/mod.rs +++ b/crates/benilla-formats/src/models/mod.rs @@ -33,6 +33,7 @@ pub use anim::*; pub use anim_summary::*; pub use bounds::*; pub use collision::*; +pub(crate) use key_anim::{bake_track, SeqSlot}; pub use m2_batches::*; pub use mat_anim::{AlphaAnim, AlphaSeq, RgbAnim, ScalarAnim}; pub use records::*; diff --git a/crates/benilla-formats/src/particles.rs b/crates/benilla-formats/src/particles.rs index a5fd5650..2d09e520 100644 --- a/crates/benilla-formats/src/particles.rs +++ b/crates/benilla-formats/src/particles.rs @@ -24,10 +24,13 @@ //! +0x1dc enabled(M2Track, step) — the emission ON/OFF gate, closing the record at 0x1f8. //! ``` //! -//! The keyed tracks here (emission rate, enabled) parse through the shared [`crate::value_track`] -//! kernel: **rebased to the first sequence's band** at parse (see its module doc for the -//! global-timeline law), so the runtime emitter clock (seconds since the effect spawned) samples -//! them directly. +//! 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 @@ -36,9 +39,10 @@ use std::io::Cursor; use anyhow::Result; -use benilla_m2::parse_m2; +use benilla_m2::{parse_m2, M2ScalarTrack}; -use crate::value_track::{seq0_band, track_enabled, track_keys_with, OnOffTrack, ValueTrack}; +use crate::emit_timing::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. @@ -62,47 +66,154 @@ pub enum ParticleBlend { 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`. Each of color, size and cell is a **3-key ramp** evaluated as two linear segments -/// split at [`Self::mid`]: key0→key1 over `u∈[0,mid]`, then key1→key2 over `u∈[mid,1]`. Byte layout + -/// math verified in `wow-5875-re` (`FUN_0070ebd0` builder, `FUN_007b9b10` evaluator) and difftested +/// 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. + /// 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], - /// Head texture-cell index range `[begin, end]` for segment A (`u≤mid`, `+0x168/+0x16a`) and - /// segment B (`u>mid`, `+0x16e/+0x170`) — the cell animated across the `rows×cols` atlas. - pub cell_a: [u16; 2], - pub cell_b: [u16; 2], + /// 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 the ramps at normalized age `u` (0..1): `(rgba, size_yards, cell_index)`. - pub fn sample(&self, u: f32) -> ([f32; 4], f32, u16) { + /// 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 (a, b, t, cell) = if u <= mid { - (0, 1, u / mid, self.cell_a) + let (k0, k1, t, seg) = if u <= mid { + (0, 1, u / mid, 0) } else { - (1, 2, (u - mid) / (1.0 - mid).max(1e-3), self.cell_b) + (1, 2, (u - mid) / (1.0 - mid).max(1e-3), 1) }; - let t = t.clamp(0.0, 1.0); + // **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[a][c] + (self.color[b][c] - self.color[a][c]) * t; + *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), } - let size = self.scale[a] + (self.scale[b] - self.scale[a]) * t; - // cell = floor(begin + (end-begin+1)·t), clamped to [begin, end]. - let span = (i32::from(cell[1]) - i32::from(cell[0]) + 1).max(1) as f32; - let idx = (f32::from(cell[0]) + span * t).floor() as i32; - let cell_index = idx.clamp(i32::from(cell[0]), i32::from(cell[1])) as u16; - (color, size, cell_index) } } @@ -225,7 +336,11 @@ pub struct ParticleEmitterDef { pub recursion_model: Option, /// Particle texture (`.blp` path, as embedded in the M2 textures table). `None` if unresolved. pub texture: Option, - /// Texture atlas size for cell animation (`1×1` = a single static cell). + /// 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. @@ -243,21 +358,11 @@ pub struct ParticleEmitterDef { pub gravity: f32, /// Particle lifetime (seconds) — killed at this age. pub lifespan: f32, - /// Particles spawned per second — the one emission parameter parsed as its **full keyed track** - /// ([`ValueTrack`]), because the one-shot combat effects author their bursts on it (see - /// [`ValueTrack`]'s doc). The other nine stay `value[0]`-baked: constant in every model this - /// renderer draws (verified across the spurt family + the ambient props). - pub emission_rate: ValueTrack, - /// The emission ON/OFF gate ([`OnOffTrack`], file `+0x1dc`) — the one-shot effects' - /// choreography track. Sampled on the same clip clock as `emission_rate`. - pub enabled: OnOffTrack, - /// The clip clock's WRAP: `Some(first sequence's span ms)` when that sequence LOOPS — the - /// client samples the two tracks above on `m2_animate`'s sequence time, which wraps there, - /// re-firing windowed tracks every pass (a precast hold's pulsing hand flash). A CLAMPED - /// sequence holds its end instead — and a discrete kit instance is reaped at exactly one - /// pass (`spell_fx`), so its boundary never renders; `None` keeps the end-clamp hold. - /// (wow-re `part-emission-rate-animated.md`: the per-frame `m2_animate` sampler.) - pub clip_wrap_ms: Option, + /// The two per-frame-sampled emission tracks — spawn rate (`+0xdc`) and the ON/OFF gate + /// (`+0x1dc`) — baked **one loop per sequence** ([`EmitTiming`]). The other nine emission + /// M2Tracks stay `value[0]`-baked: constant in every model this renderer draws (verified + /// across the spurt family + the ambient props; a named residual if a model ever authors one). + pub timing: EmitTiming, /// Plane emitter full extents (yards); spawn points are drawn from the `±½·extent` rectangle. pub area_length: f32, pub area_width: f32, @@ -484,10 +589,46 @@ fn track_first(b: &[u8], track: usize, default: f32) -> f32 { le_f32(b, ofs) } -/// Read a vanilla M2Track's full `(timestamp ms, f32)` key list, band-rebased — the shared -/// kernel's reader with the scalar decode ([`track_keys_with`]). -fn track_keys(b: &[u8], track: usize, default: f32, band: (u32, u32)) -> ValueTrack { - track_keys_with(b, track, default, band, 4, le_f32) +/// 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 { @@ -538,10 +679,42 @@ pub fn parse_m2_particle_emitters(bytes: &[u8]) -> Result, Vec<_>>() + }; + if seq_slots.is_empty() { + seq_slots.push(SeqSlot { + index: 0, + band: (0, u32::MAX), + }); + looping.push(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 = { + 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] { @@ -588,6 +761,12 @@ pub fn parse_m2_particle_emitters(bytes: &[u8]) -> Result (r, c), + _ => (1, 1), + }; let over_life = OverLife { mid: le_f32(bytes, e + 0x14c), color: [ @@ -600,8 +779,22 @@ pub fn parse_m2_particle_emitters(bytes: &[u8]) -> Result Result Result { } } -/// An **ON/OFF gate** M2Track (`M2Track`, step-interpolated): the emission gate on particle -/// (`+0x1dc`) and ribbon (`+0xc0` visibility) records. This is how one-shot effect models -/// choreograph their emitters inside the clip — `Fire_Cast_Hand.m2` enables its hand-flash -/// emitters for exactly the first 200 ms of its 1.0 s clip; `MoltenBlast_Impact_Chest.m2` -/// staggers six emitters across 200–570 ms windows. A cleared gate stops NEW emission only; live -/// particles finish their lifespan (the same law as the ribbon `block+0xbc` gate, wow-re -/// `ribbon-emitter-spec.md` §6.3). -#[derive(Debug, Clone)] -pub struct OnOffTrack { - /// `(timestamp ms, on)` keys, ascending — rebased to the first sequence's band like - /// [`ValueTrack`]. Empty never occurs from the parsers (the fallback is a single always-on - /// key). - pub keys: Vec<(u32, bool)>, -} - -impl Default for OnOffTrack { - fn default() -> Self { - Self::always_on() - } -} - -impl OnOffTrack { - /// The parse-fallback shape: a single key at t=0, on. - pub(crate) fn always_on() -> Self { - Self { - keys: vec![(0, true)], - } - } - - /// Step-sample at `ms` since clip start: the last key at or before `ms` (M2 step - /// interpolation — nearest-previous), the first key's value before it, `true` for a keyless - /// track (never emitted by the parse). - pub fn on_at(&self, ms: f32) -> bool { - let mut on = self.keys.first().is_none_or(|&(_, v)| v); - for &(t, v) in &self.keys { - if (t as f32) <= ms { - on = v; - } else { - break; - } - } - on - } -} - /// Rebase one keyed track from the vanilla global timeline onto a sequence's `[start, end]` band /// (clip-relative ms): in-band keys shift by `−start`; of the keys at/before `start` only the /// last survives, collapsed to `t = 0` (it IS the value the band starts on, per the @@ -244,20 +199,6 @@ pub(crate) fn seq0_band(bytes: &[u8]) -> (u32, u32) { } } -/// The emitter clip clock's wrap width: `Some(first sequence's span ms)` when that sequence -/// LOOPS (flags bit0 clear — the verified polarity, `models/anim.rs`), else `None`. The client -/// samples emitter tracks on `m2_animate`'s sequence time, which wraps at a looping sequence's -/// end — windowed rate/enabled tracks re-fire every pass; a clamped sequence holds instead. -pub(crate) fn seq0_wrap_ms(bytes: &[u8]) -> Option { - let (n_seq, o_seq) = (le_u32(bytes, 0x1c) as usize, le_u32(bytes, 0x20) as usize); - if n_seq == 0 || o_seq + 0x44 > bytes.len() { - return None; - } - let span = le_u32(bytes, o_seq + 8).saturating_sub(le_u32(bytes, o_seq + 4)); - let looping = le_u32(bytes, o_seq + 0x10) & 1 == 0; - (looping && span > 0).then_some(span as f32) -} - /// A vanilla M2Track's key sub-arrays: `(gseq, n, timestamps offset, values offset)` — `None` if /// the track is out of range or keyless. fn track_arrays(b: &[u8], track: usize) -> Option<(u16, usize, usize, usize)> { @@ -304,25 +245,6 @@ pub(crate) fn track_keys_with( } } -/// Read an ON/OFF gate M2Track (u8 values) — see [`OnOffTrack`]. Same band rebase as -/// [`track_keys_with`]; a keyless/out-of-range track is always-on (the loader's default, -/// `0x70f80e: mov byte[...+0xbc], 1`). -pub(crate) fn track_enabled(b: &[u8], track: usize, band: (u32, u32)) -> OnOffTrack { - let Some((gseq, n, tofs, vofs)) = track_arrays(b, track) else { - return OnOffTrack::always_on(); - }; - if vofs + n > b.len() { - return OnOffTrack::always_on(); - } - let mut keys: Vec<(u32, bool)> = (0..n) - .map(|i| (le_u32(b, tofs + i * 4), b[vofs + i] != 0)) - .collect(); - if gseq == 0xffff { - rebase_keys_to_band(&mut keys, band.0, band.1); - } - OnOffTrack { keys } -} - #[cfg(test)] mod tests { use super::*; @@ -437,18 +359,4 @@ mod tests { rebase_keys_to_band(&mut keys, 0, 667); assert_eq!(keys, vec![(0, 100.0), (500, -100.0), (667, -100.0)]); } - - /// Step sampling on the enabled gate: nearest-previous key, first value before the first key. - #[test] - fn enabled_track_steps() { - let t = OnOffTrack { - keys: vec![(0, false), (133, true), (567, false)], - }; - assert!(!t.on_at(0.0)); - assert!(!t.on_at(100.0), "step, not lerp — off until the 133 ms key"); - assert!(t.on_at(133.0)); - assert!(t.on_at(500.0)); - assert!(!t.on_at(600.0)); - assert!(OnOffTrack::always_on().on_at(0.0)); - } } diff --git a/crates/benilla-formats/tests/chain.rs b/crates/benilla-formats/tests/chain.rs index 6742c4aa..1c062394 100644 --- a/crates/benilla-formats/tests/chain.rs +++ b/crates/benilla-formats/tests/chain.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; -use benilla_formats::open_chain; +use benilla_formats::{open_chain, Chain}; fn vanilla_data_dir() -> PathBuf { // crates/benilla-formats -> repo root -> WoW/Data @@ -1136,3 +1136,69 @@ fn the_wdl_window_contains_the_cameras_own_tile() { .count(); assert_eq!(window.len(), expected, "every present tile in the window"); } + +/// A **tombstoned path must never fall through to the live copy a base archive still holds.** +/// +/// `patch.MPQ` deletes 26 paths that `model.MPQ` and friends still carry in full — cut content the +/// 1.12.1 client does not load (decision 0246). The failure this pins is silent and points the +/// wrong way: a chain that resolved *past* the delete-marker would serve `OgreMage.m2`'s 386 KB as +/// if it were current, looking entirely healthy while rendering a model the reference never draws. +/// Until now the property was held by a comment in `Chain::read` and nothing else. +/// +/// Found by comparing our corpus census against wow-re's: their chain over-enumerated by exactly +/// these 26, because `list` took the winning listfile entry whatever its flags. Ours already +/// filtered — the point of this test is that it stays that way. +/// +/// Non-vacuous by construction: each name is first read straight out of the base archive that still +/// holds it, so the test fails loudly if the pairing ever stops being a real tombstone-over-content +/// case rather than passing on an absent file. +#[test] +fn tombstoned_paths_never_fall_through_to_the_base_copy() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + + // Tombstoned by `patch.MPQ`; still present, in full, in `model.MPQ` (measured: 386416 / 437456). + const DELETED: [&str; 2] = [ + "Creature\\OgreMage\\OgreMage.m2", + "Creature\\OgreWarlord\\OgreWarlord.m2", + ]; + + let base = Chain::open(&data.join("model.MPQ")).expect("open model.MPQ alone"); + let chain = Chain::open(&data).expect("open the vanilla patch chain"); + let listed: std::collections::HashSet = chain + .list() + .expect("list the chain") + .into_iter() + .map(|e| e.name.replace('/', "\\").to_ascii_lowercase()) + .collect(); + + for name in DELETED { + // The positive control: the content really is still there to be served by mistake. + let live = base + .read(name) + .unwrap_or_else(|e| panic!("{name} must still exist in model.MPQ: {e}")); + assert!( + live.len() > 100_000, + "{name} is a real model in the base archive, got {} bytes", + live.len() + ); + + // And the composite refuses it on all three paths. + assert!(!chain.contains(name), "{name} is deleted from the chain"); + let err = chain + .read(name) + .expect_err("reading a tombstoned path must fail, not return the base copy") + .to_string(); + assert!( + err.contains("deleted from patch chain"), + "the error must name the tombstone, not a generic miss: {err}" + ); + assert!( + !listed.contains(&name.to_ascii_lowercase()), + "{name} must not be enumerated" + ); + } +} diff --git a/crates/benilla-formats/tests/particles.rs b/crates/benilla-formats/tests/particles.rs index 8b87107f..3866eab1 100644 --- a/crates/benilla-formats/tests/particles.rs +++ b/crates/benilla-formats/tests/particles.rs @@ -3,7 +3,9 @@ use std::path::PathBuf; -use benilla_formats::{open_chain, parse_m2_particle_emitters, ParticleBlend, ParticleShape}; +use benilla_formats::{ + open_chain, parse_m2_particle_emitters, CellRamp, OverLife, ParticleBlend, ParticleShape, +}; fn vanilla_data_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") @@ -31,12 +33,11 @@ fn campfire_emitters_match_real_bytes() { assert_eq!(e.shape, ParticleShape::Plane); assert_eq!(e.blend, ParticleBlend::Add); assert!(e.lifespan > 0.0 && e.lifespan.is_finite()); - assert!(e.emission_rate.first() > 0.0 && e.emission_rate.first().is_finite()); - assert_eq!( - e.emission_rate.keys.len(), - 1, - "ambient prop rates are constant tracks" - ); + let rate = e + .timing + .constant_rate() + .expect("ambient prop rates are constant tracks"); + assert!(rate > 0.0 && rate.is_finite()); assert!(e.horizontal_range > 6.0, "campfire emits in a full ring"); // Texture resolves to a real .blp via the M2 textures table. assert!( @@ -54,9 +55,9 @@ fn campfire_emitters_match_real_bytes() { glow.lifespan ); assert!( - (glow.emission_rate.first() - 6.0).abs() < 1e-3, - "glow rate ~6, got {}", - glow.emission_rate.first() + (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.vertical_range > 0.3, "glow has a wide cone"); @@ -69,9 +70,9 @@ fn campfire_emitters_match_real_bytes() { flame.lifespan ); assert!( - (flame.emission_rate.first() - 20.0).abs() < 1e-3, - "flame rate ~20, got {}", - flame.emission_rate.first() + (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), @@ -107,18 +108,21 @@ fn campfire_emitters_match_real_bytes() { e.blend, e.texture, e.lifespan, - e.emission_rate.keys, + e.timing.constant_rate(), e.vertical_range, e.tile_rows, e.tile_cols ); eprintln!( - " mid={:.2} color={:?} scale={:?} cells A{:?} B{:?}", - ol.mid, ol.color, ol.scale, ol.cell_a, ol.cell_b + " 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 (c, s, cell) = ol.sample(u); - eprintln!(" u={u:.2}: rgba={c:?} size={s:.3} cell={cell}"); + 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 + ); } // Sizes are finite, non-negative, and small (campfire props are ~sub-yard to a couple yards). @@ -145,8 +149,8 @@ fn campfire_emitters_match_real_bytes() { // The glow/smoke plume fades out: its additive weight (alpha) at end-of-life is below its peak. let glow_ol = &emitters[0].over_life; - let a_start = glow_ol.sample(0.0).0[3]; - let a_end = glow_ol.sample(1.0).0[3]; + 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})" @@ -154,14 +158,181 @@ fn campfire_emitters_match_real_bytes() { // The flame's cell index advances across its 4×4 atlas over life (the flicker animation). let flame_ol = &emitters[1].over_life; - let cell_start = flame_ol.sample(0.0).2; - let cell_end = flame_ol.sample(1.0).2; + 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 own emulated output (wow-re +/// `part-cell-flipbook-ramp.md` §5 — `emu.call(0x7b9da0)` to build the record, `emu.call(0x7b9b10)` +/// to sample it). Two properties, and the second is the one that crashed us (decision 0685): +/// +/// 1. **The endpoint law** — `cell(0) == begin` and `cell(1) == end`, EXACTLY, in both directions. +/// That is what the `±1` in the build arms and the evaluator's `0.99·t + 0.005` inset exist to +/// buy; either one alone is off by one at an endpoint. +/// 2. **A DECREASING pair plays the flipbook backwards** — it is not swapped, not clamped, not +/// sign-normalized. Shipped data authors it, so a `clamp(begin, end)` here is both wrong and +/// (in Rust) a panic. +#[test] +fn cell_ramp_matches_the_reference_including_backwards() { + let at = |begin: u16, end: u16| { + let r = CellRamp::new(begin, end); + // The sample points the wow-re oracle tabulates, through the same inset the evaluator + // applies (u = 0, ¼, ½, ¾, 1 of the segment). + [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 actually 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)"); + + // The endpoint law over every direction and the degenerate equal pair. + 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})"); + } + + // …and END-TO-END through `OverLife::sample`, which is where the inset is actually applied. + // Asserting the law on `CellRamp` alone passes even with the inset deleted (it feeds one in + // itself) — a mutation check caught exactly that hole. The reference has ONE normalized time + // and every consumer reloads it, so the endpoints must land on a whole-life sample too. + 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 **same inset** as the cells — `0x7b9b10` computes `t·0.99 + 0.005` +/// once, into its own `age` slot, and all four colour channels and the size reload that slot +/// (wow-re `part-cell-flipbook-ramp.md` §3a). So a particle never sits exactly on an authored key: +/// it starts 0.5 % into the ramp and ends 0.5 % short. Lerping on a raw `t` is wrong at both ends +/// of both segments — a small error, but a systematic one, and free to get right. +#[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 → 100 over u ∈ [0, 0.5]; at u=0 the inset puts us 0.5 % along it. + 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] + ); + // …and 99.5 % of the way at the segment's end, not 100 %. + 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 + ); +} + +/// The crash reported from Winterspring (B88): `DwarvenBrazier01`'s settling flame authors the +/// inverted segment-B pair `(6,5)`, which the old `idx.clamp(begin, end)` met with a panic — +/// `clamp` requires `min <= max`. Sampling the real record across life must simply work. +#[test] +fn inverted_ramp_on_real_data_does_not_panic() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + 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): `fmod(t·repeat, 1.0)` cycles the +/// cell ramp that many times across the segment. 18 shipped emitters author one — all of them the +/// druid Insect Swarm visuals, whose insects flap by cycling a 5- or 8-pass flipbook. A reader that +/// ignores the field runs the sheet exactly once and the swarm never flaps. +#[test] +fn repeat_count_cycles_the_flipbook() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + 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"); + + // Five passes across segment A means the cell index resets five times: count the drops. + 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}" + ); +} + /// The record-tail **twinkle** fields (wow-re `part-simspace-fields.md`, their `ac915a7d`): /// file +0x188/+0x18c are twinkleScale **{min, max}** — a GATED per-frame size flicker, skipped /// when the range is degenerate — NOT a spawn-time size multiplier. The discriminating real-data @@ -193,7 +364,7 @@ fn twinkle_fields_gate_not_scale() { ); // Its base size is the over-life ramp alone — nonzero at mid-life. assert!( - candle.over_life.sample(0.5).1 > 0.0, + candle.over_life.sample(0.5).size > 0.0, "the candle flame's over-life size ramp is nonzero" ); @@ -256,3 +427,57 @@ fn flag_remap_reads_the_file_bits() { "campfire: size-by-scale (0x20) set, model-space (0x10) clear" ); } + +/// **The B27 pin, at the real bytes** — `G_BarrelExplode.m2` (GameObject 20737, "Keenly +/// Disguised Barrel"): all 7 emitters author their explosion inside the one-shot clips +/// (slot 0 = anim 157, slot 3 = anim 150 Destroy) and an OFF window in both idle sequences +/// (slot 1 = Stand, slot 2 = anim 147 Closed). The reference shows a quiet barrel at rest; the +/// old seq-0-only rebase parked the clamped explode clock at its end value and the barrel +/// burned permanently at 748 live particles. +#[test] +fn barrel_explode_emitters_are_off_at_rest_and_fire_in_their_clips() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + 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"); + // OFF through both idle windows — Stand (slot 1) and Closed (slot 2), at any time. + for slot in [1usize, 2] { + for t in [0.0f32, 0.1, 0.3, 5.0] { + assert!( + !e.timing.emitting(Some(slot), t), + "emitter {i} must be OFF at rest (slot {slot}, t {t})" + ); + } + } + // The one-shot explode clip (slot 0, anim 157, 1 s clamped): the choreography fires + // somewhere inside it… + let fires = (0..100).any(|k| e.timing.emitting(Some(0), k as f32 * 0.01)); + assert!(fires, "emitter {i} fires inside the explode clip"); + // …and the clamped clock PARKS OFF at/after the clip end — the exact end state the old + // rebase got wrong (it clamped a later clip's ON key onto the band end). + assert!( + !e.timing.emitting(Some(0), 1.0), + "emitter {i} must be off at the clip end" + ); + assert!( + !e.timing.emitting(Some(0), 30.0), + "emitter {i} must stay off parked past the clip" + ); + } + // The Destroy clip (slot 3, anim 150) fires the explosion too — the quest's actual payoff. + let destroy_fires = emitters + .iter() + .any(|e| (0..100).any(|k| e.timing.emitting(Some(3), k as f32 * 0.01))); + assert!(destroy_fires, "the Destroy clip plays the explosion"); +} diff --git a/crates/benilla-wdt/src/lib.rs b/crates/benilla-wdt/src/lib.rs index 066a47ca..efe274af 100644 --- a/crates/benilla-wdt/src/lib.rs +++ b/crates/benilla-wdt/src/lib.rs @@ -1,10 +1,19 @@ //! WDT (map tile table) reader + tile↔world coords for **WoW 1.12.1 (build 5875)** — in-repo, //! replacing `wow-wdt` (decision 0021). //! -//! A vanilla `.wdt` is a tiny chunked file (`MVER`, `MPHD`, then `MAIN`); the only part the client -//! streamer needs is **`MAIN`** — a 64×64 table of 8-byte entries whose flag bit 0 (`0x1`) marks -//! "this tile has an `.adt`". We read that grid and ignore the rest. The coord helpers map a world -//! `(x, y)` to its `(tile_x, tile_y)` and back, on the 64-tile, 533⅓-yd grid. +//! A vanilla `.wdt` is a tiny chunked file (`MVER`, `MPHD`, `MAIN`, and — on a **WMO-only map** — +//! `MWMO` + one `MODF`). Two things in it matter to the streamer: +//! +//! - **`MAIN`** — a 64×64 table of 8-byte entries whose flag bit 0 (`0x1`) marks "this tile has an +//! `.adt`". +//! - **The global WMO** — `MPHD` dword0 bit 0 marks a map with **no terrain at all**, whose whole +//! world is one building placed by the single `MODF` entry after `MWMO`. 20 of the 43 shipped +//! maps are built this way (every WMO dungeon: the Blackrock pair, Gnomeregan, Dire Maul, the +//! Stockade…); on those maps `MAIN` is entirely empty and this placement *is* the map +//! (decision 0688). +//! +//! The coord helpers map a world `(x, y)` to its `(tile_x, tile_y)` and back, on the 64-tile, +//! 533⅓-yd grid. //! //! Proven against `wow-wdt` over a real map's WDT during the decision-0021 migration (oracle test in //! git history); the `benilla-formats` terrain tests pin tile selection + world coords end-to-end. @@ -56,11 +65,37 @@ pub struct TileInfo { pub has_adt: bool, } -/// A parsed WDT: the 64×64 tile-existence grid. +/// The single building that **is** a WMO-only map — the `MWMO` path plus the one `MODF` entry that +/// follows it (decision 0688). Position/rotation are already in the same convention as an ADT's own +/// MODF placements (`benilla_formats::WmoInstance`), so the streamer places it through the exact +/// path it places any other building with. +#[derive(Debug, Clone)] +pub struct GlobalWmo { + /// `MWMO` root path (e.g. `world\wmo\dungeon\az_stormwindprisons\stormwindjail.wmo`). + pub model: String, + /// Position in WoW world coords (X north, Y west, Z up). + /// + /// Taken **raw** from the MODF, without the `32·533⅓ − v` corner-origin remap an ADT's + /// placements get: this entry is authored in world coords already. Every shipped WMO-only map + /// stores `(0, 0, 0)` here, and the remap would put the whole dungeon 24 km from its own + /// entrance — falsified against all 26 of the server's real entry points (decision 0688). + pub position: [f32; 3], + /// Euler rotation in **degrees** (X, Y, Z); Y is the heading about the up axis. Live data, not + /// always identity: Dire Maul is authored at `ry = 180°` while the other 19 maps sit at 0. + pub rotation: [f32; 3], + /// MODF doodad-set index — which *additional* prop set to show beyond the always-on set 0. + pub doodad_set: u16, + /// MODF name-set index — the placement's `WMOAreaTable.NameSetID` naming/audio variant. + pub name_set: u16, +} + +/// A parsed WDT: the 64×64 tile-existence grid, plus the global WMO on a WMO-only map. #[derive(Debug)] pub struct WdtFile { /// `has_adt` per tile, indexed `y * 64 + x` (the on-disk MAIN order). has_adt: Vec, + /// The whole map, on a map with no terrain; `None` on an ADT map. + global_wmo: Option, } impl WdtFile { @@ -75,6 +110,12 @@ impl WdtFile { has_adt: self.has_adt[y * MAP_SIZE + x], }) } + + /// The map's single global building, if this is a WMO-only map (`MPHD` bit 0) — the entire + /// world on such a map. `None` on an ADT map, where the ground comes from the tiles instead. + pub fn global_wmo(&self) -> Option<&GlobalWmo> { + self.global_wmo.as_ref() + } } /// Reads a [`WdtFile`] from a chunked WDT stream. @@ -91,9 +132,13 @@ impl WdtReader { } } - /// Parse the WDT, returning the tile grid. Walks chunks until EOF, reading only `MAIN`. + /// Parse the WDT, returning the tile grid and (on a WMO-only map) the global WMO placement. + /// Walks chunks until EOF; everything but `MPHD`/`MAIN`/`MWMO`/`MODF` is skipped. pub fn read(&mut self) -> std::io::Result { let mut has_adt: Option> = None; + let mut wmo_only = false; + let mut wmo_path: Option = None; + let mut modf: Option> = None; loop { let mut hdr = [0u8; 8]; if !read_full(&mut self.reader, &mut hdr)? { @@ -101,34 +146,92 @@ impl WdtReader { } // Chunk magics are stored reversed on disk; MAIN ⇒ "NIAM". let size = u32::from_le_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]) as u64; - if &hdr[0..4] == b"NIAM" { - // 64×64 entries × 8 bytes (flags u32, area_id u32); bit 0 of flags = has_adt. - let count = MAP_SIZE * MAP_SIZE; - let mut buf = vec![0u8; count * 8]; - self.reader.read_exact(&mut buf)?; - let grid = (0..count) - .map(|i| { - let flags = u32::from_le_bytes([ - buf[i * 8], - buf[i * 8 + 1], - buf[i * 8 + 2], - buf[i * 8 + 3], - ]); - flags & 0x1 != 0 - }) - .collect(); - has_adt = Some(grid); - } else { - self.reader.seek(SeekFrom::Current(size as i64))?; + match &hdr[0..4] { + // MPHD — only dword0 bit 0 is ever read: "this map has no terrain, its world is + // the global WMO below" (the reference reads exactly this one bit, @0x694810). + b"DHPM" if size >= 4 => { + let mut buf = vec![0u8; size as usize]; + self.reader.read_exact(&mut buf)?; + wmo_only = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) & 0x1 != 0; + } + b"NIAM" => { + // 64×64 entries × 8 bytes (flags u32, area_id u32); bit 0 of flags = has_adt. + let count = MAP_SIZE * MAP_SIZE; + let mut buf = vec![0u8; count * 8]; + self.reader.read_exact(&mut buf)?; + let grid = (0..count) + .map(|i| { + let flags = u32::from_le_bytes([ + buf[i * 8], + buf[i * 8 + 1], + buf[i * 8 + 2], + buf[i * 8 + 3], + ]); + flags & 0x1 != 0 + }) + .collect(); + has_adt = Some(grid); + } + // MWMO — one NUL-terminated path on a WMO-only map, and a zero-length stub on an + // ADT map (which is why the `wmo_only` flag, not this chunk, is the gate). + b"OMWM" if size > 0 => { + let mut buf = vec![0u8; size as usize]; + self.reader.read_exact(&mut buf)?; + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + let path = String::from_utf8_lossy(&buf[..end]).into_owned(); + if !path.is_empty() { + wmo_path = Some(path); + } + } + // MODF — exactly one 64-byte placement here (the ADT variant carries many). + b"FDOM" if size >= 64 => { + let mut buf = vec![0u8; size as usize]; + self.reader.read_exact(&mut buf)?; + modf = Some(buf); + } + _ => { + self.reader.seek(SeekFrom::Current(size as i64))?; + } } } let has_adt = has_adt.ok_or_else(|| { std::io::Error::new(std::io::ErrorKind::InvalidData, "WDT missing MAIN chunk") })?; - Ok(WdtFile { has_adt }) + // A WMO-only map with no placement to show would be an empty void, so demand both parts + // before claiming one — a malformed header bit alone must not suppress the tile grid. + let global_wmo = match (wmo_only, wmo_path, modf) { + (true, Some(model), Some(e)) => Some(GlobalWmo { + model, + position: [f32_at(&e, 8), f32_at(&e, 12), f32_at(&e, 16)], + rotation: [f32_at(&e, 20), f32_at(&e, 24), f32_at(&e, 28)], + // 0x20..0x38 is the bounding box; the placement words follow it. (`uniqueId` at + // +4 is dead here — the reference overwrites it from its own counter @0xc9a320.) + doodad_set: u16_at(&e, 58), + name_set: u16_at(&e, 60), + }), + _ => None, + }; + Ok(WdtFile { + has_adt, + global_wmo, + }) } } +/// Little-endian `f32` at `off` (0.0 past the end — callers have already length-checked the entry). +fn f32_at(b: &[u8], off: usize) -> f32 { + b.get(off..off + 4) + .and_then(|s| s.try_into().ok()) + .map_or(0.0, f32::from_le_bytes) +} + +/// Little-endian `u16` at `off` (0 past the end). +fn u16_at(b: &[u8], off: usize) -> u16 { + b.get(off..off + 2) + .and_then(|s| s.try_into().ok()) + .map_or(0, u16::from_le_bytes) +} + /// Read exactly `buf.len()` bytes; `Ok(false)` on a clean EOF before any byte, `Ok(true)` on success. fn read_full(reader: &mut R, buf: &mut [u8]) -> std::io::Result { let mut filled = 0; @@ -194,6 +297,69 @@ mod tests { WdtReader::new(Cursor::new(bytes), WowVersion::Classic).read() } + /// A WMO-only map's on-disk shape: `MPHD` bit 0, an all-empty `MAIN`, then `MWMO` + one 64-byte + /// `MODF`. Field values are distinct primes-ish so a column slip can't pass. + fn synth_wmo_only_wdt(mphd_bit0: bool, with_modf: bool) -> Vec { + let mut out = Vec::new(); + let chunk = |magic: &[u8; 4], payload: &[u8], out: &mut Vec| { + out.extend_from_slice(magic); + out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + out.extend_from_slice(payload); + }; + chunk(b"REVM", &18u32.to_le_bytes(), &mut out); + let mut mphd = [0u8; 32]; + mphd[0] = u8::from(mphd_bit0); + chunk(b"DHPM", &mphd, &mut out); + chunk(b"NIAM", &vec![0u8; MAP_SIZE * MAP_SIZE * 8], &mut out); // no tiles at all + chunk(b"OMWM", b"world\\wmo\\dungeon\\test\\t.wmo\0", &mut out); + if with_modf { + let mut e = [0u8; 64]; + e[0..4].copy_from_slice(&0u32.to_le_bytes()); // nameId + e[4..8].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); // uniqueId (dead) + for (i, v) in [11.0f32, 22.0, 33.0].iter().enumerate() { + e[8 + i * 4..12 + i * 4].copy_from_slice(&v.to_le_bytes()); // position + } + for (i, v) in [1.0f32, 180.0, 2.0].iter().enumerate() { + e[20 + i * 4..24 + i * 4].copy_from_slice(&v.to_le_bytes()); // rotation + } + // 0x20..0x38 bounding box left zero — we don't read it. + e[56..58].copy_from_slice(&7u16.to_le_bytes()); // flags (dead) + e[58..60].copy_from_slice(&3u16.to_le_bytes()); // doodadSet + e[60..62].copy_from_slice(&5u16.to_le_bytes()); // nameSet + chunk(b"FDOM", &e, &mut out); + } + out + } + + /// The WMO-only branch end to end: the `MPHD` gate, the `MWMO` path, and every `MODF` column + /// the streamer places the building with. Position is read **raw** — no corner-origin remap. + #[test] + fn reads_the_global_wmo_of_a_wmo_only_map() { + let wdt = parse(synth_wmo_only_wdt(true, true)).expect("parses"); + let g = wdt.global_wmo().expect("MPHD bit 0 ⇒ a global WMO"); + assert_eq!(g.model, "world\\wmo\\dungeon\\test\\t.wmo"); + assert_eq!(g.position, [11.0, 22.0, 33.0]); + assert_eq!(g.rotation, [1.0, 180.0, 2.0]); + assert_eq!((g.doodad_set, g.name_set), (3, 5)); + // Such a map authors no tiles — the grid still parses, it's just empty. + assert!(!wdt.get_tile(32, 32).expect("in range").has_adt); + } + + /// An ADT map carries a zero-length `MWMO` stub and no `MODF`; it must not claim a global WMO + /// (which would spawn a phantom building over real terrain). + #[test] + fn an_adt_map_has_no_global_wmo() { + let wdt = parse(synth_wdt(&[(30, 30)], false)).expect("parses"); + assert!(wdt.global_wmo().is_none()); + // …and neither does a malformed file that sets the bit but ships no placement: a stray + // header bit must degrade to "no global WMO", never to a half-built one. + let half = parse(synth_wmo_only_wdt(true, false)).expect("parses"); + assert!(half.global_wmo().is_none()); + // …nor one that ships the placement without the flag. + let unflagged = parse(synth_wmo_only_wdt(false, true)).expect("parses"); + assert!(unflagged.global_wmo().is_none()); + } + #[test] fn world_origin_is_map_center_tile() { // World (0, 0) sits at the 32,32 tile boundary — the documented map center. diff --git a/crates/benilla/src/capture/probes.rs b/crates/benilla/src/capture/probes.rs index c540af39..f536f6b1 100644 --- a/crates/benilla/src/capture/probes.rs +++ b/crates/benilla/src/capture/probes.rs @@ -771,12 +771,18 @@ fn fire_particle_census( }) .unwrap_or_default(); let d = e.def(); - let rate_keys: Vec = d - .emission_rate - .keys - .iter() - .map(|(t, v)| format!("{t}:{v:.1}")) - .collect(); + // The rate summary: the constant (the common shape), else each slot's key count — the + // full per-sequence choreography lives in `benilla-extract m2anim`, not a census line. + let rate_keys: Vec = match d.timing.constant_rate() { + Some(r) => vec![format!("{r:.1}")], + None => d + .timing + .slot_views() + .iter() + .enumerate() + .map(|(s, (_, r, _))| format!("s{s}:{}k", r.map_or(0, <[(f32, f32)]>::len))) + .collect(), + }; // The orientation fingerprint (world plane normal + thickness/radius) is the numeric // "which way does this cloud face" — the flat-vs-standing question a screenshot can // only suggest (the InstancePortal swirl-plane investigation). diff --git a/crates/benilla/src/debug_panel/visibility.rs b/crates/benilla/src/debug_panel/visibility.rs index b0671388..a43c3274 100644 --- a/crates/benilla/src/debug_panel/visibility.rs +++ b/crates/benilla/src/debug_panel/visibility.rs @@ -44,6 +44,12 @@ pub(super) fn apply_model_visibility( Option<&WmoGroupVis>, Option<&crate::doodad_anim::MatAnim>, )>, + // The building's own MLIQ surfaces (canals, dungeon pools, Ragefire's lava). They carry a + // `WmoGroupVis` like every other piece of their group but no `ModelPart` — they are not model + // submeshes and take none of the toggle/far-clip/fade rules above. A second QUERY, deliberately + // not a second SYSTEM: decision 0025 wants one `Visibility` authority, and this keeps it + // (decision 0689 — a culled room's water must go with the room, same as its furniture). + mut group_only: Query<(&WmoGroupVis, &mut Visibility), Without>, ) { let m = &debug.models; // The (single) world Camera3d; the egui overlay is a Camera2d. None before it spawns → no distance @@ -174,4 +180,23 @@ pub(super) fn apply_model_visibility( } }, ); + + // The group-only audience: portal cull and nothing else. Serial — a building has a handful of + // liquid surfaces, not the ~100k submeshes above — and change-gated like every write here. + for (gv, mut vis) in &mut group_only { + let want = if !m.portal_cull + || instances + .get(gv.instance) + .ok() + .and_then(|inst| inst.visible.get(gv.group as usize).copied()) + .unwrap_or(true) + { + Visibility::Inherited + } else { + Visibility::Hidden + }; + if *vis != want { + *vis = want; + } + } } diff --git a/crates/benilla/src/doodad_anim.rs b/crates/benilla/src/doodad_anim.rs index 93828c5a..7e3f5077 100644 --- a/crates/benilla/src/doodad_anim.rs +++ b/crates/benilla/src/doodad_anim.rs @@ -360,7 +360,10 @@ impl MatAnim { /// base clips are live and the heavier one wins; the reference instead blends the two sampled /// scalars by λ (wow-re `eval.md` FN 0x71af20's blend leg), a sub-blend-time difference on tracks /// the corpus authors as 0/1 steps — recorded, not modelled. -fn playing_seq(player: &AnimationPlayer, anims: &ModelAnimations) -> Option<(usize, f32)> { +pub(crate) fn playing_seq( + player: &AnimationPlayer, + anims: &ModelAnimations, +) -> Option<(usize, f32)> { player .playing_animations() .filter_map(|(node, active)| { diff --git a/crates/benilla/src/entities/attach/mod.rs b/crates/benilla/src/entities/attach/mod.rs index 7b6eeaf9..69978e3b 100644 --- a/crates/benilla/src/entities/attach/mod.rs +++ b/crates/benilla/src/entities/attach/mod.rs @@ -909,6 +909,11 @@ pub(super) fn attach_entity_visuals( Some(owner), None, // a unit's OWN model is not an attached model (`[model+0x17c]` = 0) Some(entity), // the cloud anchors at the unit; bones compose births only + // The emitters' rate/enabled read this instance's PLAYING sequence — a + // unit's or GameObject's `AnimationPlayer` on the root. A quest object's + // explosion is authored inside its one-shot clips with an OFF window at + // idle (B27); a creature's death-only smoke is the same shape. + particles::EmitClock::Host(entity), ); } } diff --git a/crates/benilla/src/entities/display.rs b/crates/benilla/src/entities/display.rs index 7aa7bff8..c2b891d4 100644 --- a/crates/benilla/src/entities/display.rs +++ b/crates/benilla/src/entities/display.rs @@ -83,9 +83,10 @@ pub(super) struct EntityPart { /// 0153: the brazier/torch "glow on the ground" family). `None` for ordinary geometry. pub(super) billboard: Option, /// The part's animated material-alpha loops (colour-alpha × transparency-weight, decision 0130 - /// phase 2). The **effect lane** ([`super::spell_fx`]) samples them per instance (a spell - /// effect's staggered fade pulses); the unit/GameObject lane doesn't yet (its alpha channel is - /// owned by the appear-fade/interior writers — a named deferral). `None` for static batches. + /// phase 2, per-sequence since 0641). The **effect lane** ([`super::spell_fx`]) samples them + /// per instance on its armed slot; the unit/GameObject lane follows the host's live playing + /// sequence (`MatAnim::following` — 0641 collected the old deferral here). `None` for static + /// batches. pub(super) alpha_anim: Option>, /// The part's animated RGB tint (the M2Color colour track, time-varying only). The static /// vertex tint was skipped at parse for these parts: `material`/its variants are built with diff --git a/crates/benilla/src/entities/equipment.rs b/crates/benilla/src/entities/equipment.rs index 6bd767c4..289bf236 100644 --- a/crates/benilla/src/entities/equipment.rs +++ b/crates/benilla/src/entities/equipment.rs @@ -923,6 +923,9 @@ pub(super) fn attach_held_items( Some((root, [0.0; 3])), Some(root), // a held item is an attached model — the flame fans with the swing Some(root), // whole-model owner: anchor == owner, the plain carry + // A held item spawns no rig; its emitters run the item model's own slot-0 + // loop on the spawn clock (the torch burns always — the doodad law). + crate::particles::EmitClock::Pinned, ); } // The item's own M2 point light — **the held torch's glow** (decision 0016's law on the diff --git a/crates/benilla/src/entities/spell_fx.rs b/crates/benilla/src/entities/spell_fx.rs index b04e563a..1577eed6 100644 --- a/crates/benilla/src/entities/spell_fx.rs +++ b/crates/benilla/src/entities/spell_fx.rs @@ -461,6 +461,12 @@ pub(super) fn attach_effect_visuals( // The cloud anchors at the instance root — the emitter's bone only composes births // (the food sparkle's bone orbits; the risen stars must not swirl with it). Some(root), + // The effect's rig arms ONE clip; the emitters' rate/enabled windows ride that slot + // (a missile's InFlight is not file-order-first), on the instance's own spawn clock. + played_seq.map_or( + particles::EmitClock::Pinned, + particles::EmitClock::PinnedSeq, + ), ); } // The same pick, as an `AnimationData` id — the ribbons' per-sequence visibility keys off it, diff --git a/crates/benilla/src/entities/wmo_props.rs b/crates/benilla/src/entities/wmo_props.rs index 5acde471..501f2bb5 100644 --- a/crates/benilla/src/entities/wmo_props.rs +++ b/crates/benilla/src/entities/wmo_props.rs @@ -321,6 +321,7 @@ pub(super) fn spawn_wmo_gameobject_props( // reference re-anchors every cloud to the emitter's live position), while // an animated bone still never drags it. Some(root), + particles::EmitClock::Pinned, // a placed prop: the doodad law ) { // Parented: the off-map hide reaches the flame, and despawn cascades // (the sim's direct GlobalTransform write runs post-propagation, so the diff --git a/crates/benilla/src/minimap/interior.rs b/crates/benilla/src/minimap/interior.rs index f37f8f29..5b91ab54 100644 --- a/crates/benilla/src/minimap/interior.rs +++ b/crates/benilla/src/minimap/interior.rs @@ -236,6 +236,7 @@ mod tests { group_light_refs: Vec::new(), group_liquids: Vec::new(), doodad_base: Default::default(), + doodad_owner: Default::default(), }; let drawable = interior_group_selection(&model, &Affine3A::IDENTITY, Vec3::ZERO, 25.0, 0); @@ -315,6 +316,7 @@ mod tests { group_light_refs: Vec::new(), group_liquids: Vec::new(), doodad_base: Default::default(), + doodad_owner: Default::default(), }; let drawable = interior_group_selection(&model, &Affine3A::IDENTITY, Vec3::ZERO, 25.0, 0); diff --git a/crates/benilla/src/particles.rs b/crates/benilla/src/particles.rs index a77a093b..2e139159 100644 --- a/crates/benilla/src/particles.rs +++ b/crates/benilla/src/particles.rs @@ -160,6 +160,16 @@ pub struct ParticleEmitter { /// against (an effect model's emitters spawn at its clip start, so age == clip time). Ambient /// props' constant tracks are age-invariant. age: f32, + /// The emission clock's SEQUENCE source (`EmitTiming`, decision 0641's host resolution one + /// channel over): the entity whose live `AnimationPlayer` names the playing sequence + clip + /// time — a unit's / GameObject's emitters read the *playing* sequence's key window, so a + /// quest object's explosion fires in its one-shot clips and stays OFF at idle. `None` = a + /// pinned lane (placed doodad, effect rig, booth) on the spawn-age clock. + host: Option, + /// The sequence FILE slot the timing samples: the pinned slot an effect's rig armed, or the + /// host's last resolved slot (kept across frames where the host has no live player — the + /// pre-arm frame reads that slot's opening pose). `None` ⇒ slot 0 (the doodad law). + seq: Option, rng: u32, mesh: Handle, /// The particle texture — also held here (not just on the material) so the sim can withhold drawing @@ -517,6 +527,7 @@ pub fn spawn_emitter( owner: Option<(Entity, [f32; 3])>, attach: Option, anchor: Option, + clock: EmitClock, ) -> Option { // Perf-bisect kill-switch: $WOW_NO_PARTICLES spawns no emitters at all. if std::env::var_os("WOW_NO_PARTICLES").is_some() { @@ -542,12 +553,17 @@ pub fn spawn_emitter( ]; entity }); - // Gate on the rate track's PEAK, not its first key: a one-shot burst emitter (the blood - // spurt's starflash/glowball, 0140 fold-back) keys `0 → 200 → 0` — value[0] is 0 but it - // absolutely emits. - if def.lifespan <= 0.0 || def.emission_rate.peak() <= 0.0 { + // Gate on the rate track's PEAK over every sequence, not its first key: a one-shot burst + // emitter (the blood spurt's starflash/glowball, 0140 fold-back) keys `0 → 200 → 0` — value[0] + // is 0 but it absolutely emits. + if def.lifespan <= 0.0 || def.timing.peak_rate() <= 0.0 { return None; // emits nothing } + let (host, seq) = match clock { + EmitClock::Pinned => (None, None), + EmitClock::PinnedSeq(s) => (None, Some(s)), + EmitClock::Host(h) => (Some(h), None), + }; let material = particle_material(&def, &texture, materials, light); let mesh = meshes.add(blank_particle_mesh()); // Seed the RNG from the placement position so two campfires don't flicker in lockstep. @@ -580,6 +596,8 @@ pub fn spawn_emitter( inherit_vel: Vec3::ZERO, gate_prev: false, age: 0.0, + host, + seq, rng, mesh, texture, @@ -660,7 +678,7 @@ pub(crate) fn wire_child_emitters( .take(4) .filter_map(|em| { let texture = em.texture.clone()?; - if em.def.lifespan <= 0.0 || em.def.emission_rate.peak() <= 0.0 { + if em.def.lifespan <= 0.0 || em.def.timing.peak_rate() <= 0.0 { return None; } let material = particle_material(&em.def, &texture, &mut materials, &light); @@ -694,15 +712,31 @@ pub(crate) fn wire_child_emitters( } } +/// Which sequence clock an emitter's rate/enabled tracks sample against +/// ([`benilla_formats::EmitTiming`] — one baked loop per sequence, so the consumer names the +/// sequence). Callers pick per lane at [`spawn_emitter`]. +#[derive(Clone, Copy, Default)] +pub enum EmitClock { + /// Slot 0 on the spawn-age clock — a placed doodad's one-time arm (the pre-per-sequence law, + /// still correct there), the portrait booths, and any lane with no rig to ask. + #[default] + Pinned, + /// A fixed armed slot on the spawn-age clock — a spell effect's rig arms one clip at spawn + /// and the emitters ride it (a missile's InFlight is not file-order-first). + PinnedSeq(usize), + /// A live host's `AnimationPlayer` decides the slot **and the clip time** each frame — units + /// and GameObjects, whose playing sequence changes; the reference's `m2_animate` emitter + /// phase samples the CURRENT sequence record (wow-re `part-emission-rate-animated.md` §2). + Host(Entity), +} + /// One frame of the emission front end: how many births the pool is owed, loaded into /// `accumulator` (fractional; the caller's birth loop drains whole particles). The reference's /// per-frame emitter pass + spawn driver (`0x718960` / `0x7b5550`, wow-re /// `part-emission-burst-flag.md` + `part-emission-rate-animated.md`): /// -/// - The rate is sampled at the clip clock by the reference's own track law -/// ([`ValueTrack::sampled_ms`] — STEP for interp-0 tracks, linear for the rest, held past the -/// last key; the corpus authors bursts as step and pour ramps as linear) and the gate is -/// `(enabled && rate > 0)`. +/// - `rate`/`emitting` arrive already sampled from the playing sequence's key window +/// ([`benilla_formats::EmitTiming`]) and the gate is `(enabled && rate > 0)`. /// - A **continuous** emitter (file flag 0x8000 clear) pours `rate · density·LOD · dt`. /// - A **BURST** emitter (flag set) loads `ftol(rate · density·LOD)` ONCE on the gate's rising /// edge and re-arms when it falls (a looping clip's next pass re-fires it); the pour never @@ -711,8 +745,8 @@ pub(crate) fn wire_child_emitters( /// /// Returns the burst count loaded this frame (0.0 otherwise) — the `fx` trace's measurement hook. fn accumulate_emission( - def: &ParticleEmitterDef, - clip_ms: f32, + is_burst: bool, + rate: f32, emitting: bool, scale: f32, dt: f32, @@ -722,14 +756,10 @@ fn accumulate_emission( if !emitting { *accumulator = 0.0; } - let rate = if emitting { - def.emission_rate.sampled_ms(clip_ms).max(0.0) - } else { - 0.0 - }; + let rate = if emitting { rate.max(0.0) } else { 0.0 }; let gate = rate > 0.0; let mut burst = 0.0; - if def.burst() { + if is_burst { if gate && !*gate_prev { burst = (rate * scale).trunc(); *accumulator = burst; @@ -772,7 +802,7 @@ impl Plugin for ParticlePlugin { #[cfg(test)] pub(crate) mod tests { use super::*; - use benilla_formats::{OverLife, ParticleShape, ValueTrack}; + use benilla_formats::{CellRamp, OverLife, ParticleShape}; /// The minimal def, exposed for sibling-module tests (the sim's child-drive test). pub(crate) fn plain_def() -> ParticleEmitterDef { @@ -875,18 +905,13 @@ pub(crate) mod tests { tile_rows: 1, tile_cols: 1, head_tail: 0, - clip_wrap_ms: None, emission_speed: 1.0, speed_variation: 0.0, vertical_range: 0.5, horizontal_range: std::f32::consts::PI, gravity: 0.0, lifespan: 1.0, - emission_rate: ValueTrack { - keys: vec![(0, 10.0)], - interp: 0, - }, - enabled: benilla_formats::OnOffTrack::default(), + timing: benilla_formats::EmitTiming::constant(10.0), area_length: 2.0, area_width: 4.0, z_source: 0.0, @@ -911,90 +936,59 @@ pub(crate) mod tests { mid: 0.5, color: [[1.0; 4]; 3], scale: [1.0; 3], - cell_a: [0, 0], - cell_b: [0, 0], + head_cells: [CellRamp::new(0, 0); 2], + tail_cells: [CellRamp::new(0, 0); 2], + repeat: [1.0; 2], }, } } /// The BURST emission model (file flag 0x8000 — Feint/Eviscerate impact's plume+crescents): - /// one `ftol(rate·scale)` puff on the rising edge of `(enabled && step-sampled rate > 0)`, - /// latched while the gate holds, re-armed when it falls. The STEP rate law means a - /// `{0:0, 67:30}` track is silent before 67 ms — the puff fires AT the key, full-count. + /// one `ftol(rate·scale)` puff on the rising edge of `(enabled && rate > 0)`, latched while + /// the gate holds, re-armed when it falls. (The rate/enabled *sampling* laws — step vs lerp + /// vs held tail, per sequence window — are pinned in `benilla-formats`' `emit_timing` tests, + /// where the bake lives.) #[test] fn burst_emitter_fires_once_on_the_rising_edge() { - let mut d = def(ParticleShape::Plane); - d.flags = 0x8000; - d.emission_rate = ValueTrack { - keys: vec![(0, 0.0), (67, 30.0)], - interp: 0, // every corpus burst authors a STEP rate track (census-verified) - }; let (mut acc, mut prev) = (0.0, false); assert_eq!( - accumulate_emission(&d, 50.0, true, 1.0, 0.016, &mut acc, &mut prev), + accumulate_emission(true, 0.0, true, 1.0, 0.016, &mut acc, &mut prev), 0.0, - "step-sampled rate is 0 before the 67 ms key — no gate, no burst" + "rate 0 — no gate, no burst" ); assert_eq!(acc, 0.0); assert_eq!( - accumulate_emission(&d, 70.0, true, 1.0, 0.016, &mut acc, &mut prev), + accumulate_emission(true, 30.0, true, 1.0, 0.016, &mut acc, &mut prev), 30.0, - "the frame the sample steps to 30: one full-count burst" + "the frame the rate rises: one full-count burst" ); assert_eq!(acc, 30.0); acc = 0.0; // the birth loop drains it - accumulate_emission(&d, 500.0, true, 1.0, 0.016, &mut acc, &mut prev); - accumulate_emission(&d, 1500.0, true, 1.0, 0.016, &mut acc, &mut prev); + accumulate_emission(true, 30.0, true, 1.0, 0.016, &mut acc, &mut prev); + accumulate_emission(true, 30.0, true, 1.0, 0.016, &mut acc, &mut prev); assert_eq!(acc, 0.0, "held-high gate stays latched — never a pour"); // The gate falls (drain/disable) → re-arms → the next rise bursts again (a looping // clip's next pass), the count ftol-truncated through density·LOD. - accumulate_emission(&d, 1600.0, false, 1.0, 0.016, &mut acc, &mut prev); + accumulate_emission(true, 30.0, false, 1.0, 0.016, &mut acc, &mut prev); assert_eq!( - accumulate_emission(&d, 70.0, true, 0.55, 0.016, &mut acc, &mut prev), + accumulate_emission(true, 30.0, true, 0.55, 0.016, &mut acc, &mut prev), 16.0, "ftol(30 · 0.55) = 16" ); } - /// The continuous emission model (flag clear) still pours `rate·dt` — and drops its owed + /// The continuous emission model (flag clear) pours `rate·dt` — and drops its owed /// fraction while disabled (the pre-existing drain hygiene). #[test] fn continuous_emitter_pours_rate_dt() { - let mut d = def(ParticleShape::Plane); - d.emission_rate = ValueTrack { - keys: vec![(0, 0.0), (67, 30.0)], - interp: 0, - }; let (mut acc, mut prev) = (0.0, false); - accumulate_emission(&d, 100.0, true, 1.0, 0.1, &mut acc, &mut prev); - accumulate_emission(&d, 200.0, true, 1.0, 0.1, &mut acc, &mut prev); + accumulate_emission(false, 30.0, true, 1.0, 0.1, &mut acc, &mut prev); + accumulate_emission(false, 30.0, true, 1.0, 0.1, &mut acc, &mut prev); assert!((acc - 6.0).abs() < 1e-4, "30/s × 0.2 s, no burst latch"); - accumulate_emission(&d, 300.0, false, 1.0, 0.1, &mut acc, &mut prev); + accumulate_emission(false, 30.0, false, 1.0, 0.1, &mut acc, &mut prev); assert_eq!(acc, 0.0, "disabled zeroes the owed fraction"); } - /// A LINEAR (interp 1) rate ramp pours the lerped envelope — the BloodSpurt shape (`0 → 100 - /// → … → 0` authored interp 1 corpus-wide): mid-ramp the pour runs at the interpolated rate, - /// not the previous key's step (which over-poured every falling tail and muted every rise). - #[test] - fn continuous_lerp_ramp_pours_the_interpolated_rate() { - let mut d = def(ParticleShape::Plane); - d.emission_rate = ValueTrack { - keys: vec![(0, 0.0), (100, 100.0), (200, 0.0)], - interp: 1, - }; - let (mut acc, mut prev) = (0.0, false); - accumulate_emission(&d, 50.0, true, 1.0, 0.1, &mut acc, &mut prev); - assert!((acc - 5.0).abs() < 1e-4, "rising mid-ramp: 50/s × 0.1 s"); - accumulate_emission(&d, 150.0, true, 1.0, 0.1, &mut acc, &mut prev); - assert!((acc - 10.0).abs() < 1e-4, "falling mid-ramp: 50/s again"); - accumulate_emission(&d, 500.0, true, 1.0, 0.1, &mut acc, &mut prev); - assert!( - (acc - 10.0).abs() < 1e-4, - "held last key 0: the ramp self-closes" - ); - } - /// The follow-delta response line (`0x7b5d30`): the line through the two authored /// (speed, fraction) samples; equal speeds degenerate to no response (the reference zeroes /// slope and intercept). diff --git a/crates/benilla/src/particles/model.rs b/crates/benilla/src/particles/model.rs index 02cd9c9d..29bba09c 100644 --- a/crates/benilla/src/particles/model.rs +++ b/crates/benilla/src/particles/model.rs @@ -133,7 +133,9 @@ pub(super) fn update_model_particles( continue; }; let u = (p.age / emitter.def.lifespan).clamp(0.0, 1.0); - let (rgba, size, _) = emitter.def.over_life.sample(u); + // 3-D model particles carry no texture atlas — only colour and size reach them. + let ol = emitter.def.over_life.sample(u); + let (rgba, size) = (ol.color, ol.size); let tf = if anchored { Transform { translation: emitter.anchor_pos + emitter.attach_rot * p.pos, diff --git a/crates/benilla/src/particles/quads.rs b/crates/benilla/src/particles/quads.rs index 18220a38..189006d5 100644 --- a/crates/benilla/src/particles/quads.rs +++ b/crates/benilla/src/particles/quads.rs @@ -108,9 +108,24 @@ pub(super) fn expand_quads( placement.rotation * (wow_to_bevy([-1.0, 0.0, 0.0]) * s), ) }); - let cols = def.tile_cols.max(1); - let rows = def.tile_rows.max(1); + // Atlas walk (byte-verified `0x7b2a50` @0x7b2bd5 head / @0x7b304e tail, wow-re + // `part-cell-flipbook-ramp.md` §4): `col = idx & (COLUMNS−1)`, `row = idx >> log2(COLUMNS)`. + // COLUMNS is a power of two by construction — the reference validates it at load and falls + // back to a 1×1 grid otherwise, which the parse mirrors. **The column WRAPS and the row does + // NOT**: there is no clamp into the atlas anywhere on the reference's path, so an index past + // the last cell yields `V ≥ 1.0` and is handed to the sampler's (repeat) addressing — landing + // back on row 0, not on the final cell. 553 emitters author exactly that, at the tail of a + // flame's flipbook (decision 0685); a `min(rows·cols − 1)` here held the last cell instead. + let (cols, rows) = (def.tile_cols, def.tile_rows); let (inv_cols, inv_rows) = (1.0 / cols as f32, 1.0 / rows as f32); + let cell_uv = |idx: u16| { + let cx = f32::from(idx & (cols - 1)); + let cy = f32::from(idx >> cols.trailing_zeros()); + ( + (cx * inv_cols, (cx + 1.0) * inv_cols), + (cy * inv_rows, (cy + 1.0) * inv_rows), + ) + }; for p in particles { let noise = twinkle_noise(def.twinkle_speed, p.age, p.phase); // The twinklePercent draw-gate (byte-verified `0x7b2adc`): while percent < 1 (never on @@ -120,7 +135,8 @@ pub(super) fn expand_quads( continue; } let u_age = (p.age / def.lifespan).clamp(0.0, 1.0); - let (rgba, size, cell) = def.over_life.sample(u_age); + let ol = def.over_life.sample(u_age); + let (rgba, size) = (ol.color, ol.size); // RAW authored RGB — the gamma-space decode happens ONCE, in the material's fragment // (`wow_particle.wgsl`, decision 0152), where it also covers the texture term that a // CPU-side vertex linearisation (0150) could not reach. Alpha is a blend weight — raw @@ -144,12 +160,6 @@ pub(super) fn expand_quads( // alike burn steady; the old base+rand reading collapsed the kobold candle to zero), // × the instance scale iff flagged. let half = size * def.twinkle(noise) * scale; - // Texture-atlas cell → sub-rect UV (v increases downward in image space). - let cell = cell.min(rows * cols - 1); - let cx = (cell % cols) as f32; - let cy = (cell / cols) as f32; - let (u0, u1) = (cx * inv_cols, (cx + 1.0) * inv_cols); - let (v0, v1) = (cy * inv_rows, (cy + 1.0) * inv_rows); let mut push_quad = |corners: [Vec3; 4], quv: [[f32; 2]; 4]| { let b = positions.len() as u32; for (c, t) in corners.iter().zip(quv) { @@ -169,6 +179,7 @@ pub(super) fn expand_quads( // HEAD quad (particleType 0/2), drawn first (`0x7b2bc9`): a camera-facing billboard, // or — XY-quad emitters — the flat plane basis computed above. if def.head_tail != 1 { + let ((u0, u1), (v0, v1)) = cell_uv(ol.head_cell); let (base_r, base_u) = plane_basis.unwrap_or((cam_right, cam_up)); // Quad spin (file +0x198): rotate in-plane by `angle = spin·age` (the fcos/fsin // fold at `0x7b2ddc`; on an XY quad the same form IS the reference's Rodrigues @@ -196,9 +207,18 @@ pub(super) fn expand_quads( // TAIL quad (particleType 1/2, byte-verified `0x7b3041`): a velocity-projected streak // trailing the motion — world length |velocity|·tailTime (flag 0x400 grows it from // zero with age), width 2·half perpendicular IN SCREEN SPACE, U running along the - // tail (0 at the particle → 1 at the tip), same flipbook cell. A view-parallel - // velocity (degenerate screen length) falls back to a plain billboard. + // tail (0 at the particle → 1 at the tip). A view-parallel velocity (degenerate screen + // length) falls back to a plain billboard. + // + // The streak reads its **own** flipbook cell — a second, independently authored ramp + // (file+0x174..+0x17b), not the head's. The reference emits two cell indices per particle + // and the tail block reads the second (`[ebp-0x40]` @0x7b3054). 14 shipped emitters on a + // real atlas — Fire Bolt / Flamestrike / Banish / Hellfire / Vanish impacts, the elemental + // totems — author a head ramp that walks all 64 cells and a tail ramp pinned at cell 0; + // handing the head's cell to the streak animated it through the whole sheet (decision + // 0685, correcting wow-re's own `part-quad-tail-twinkle.md` §2). if def.head_tail >= 1 { + let ((u0, u1), (v0, v1)) = cell_uv(ol.tail_cell); let vel_world = if anchored { frame.attach_rot * p.vel } else { diff --git a/crates/benilla/src/particles/sim.rs b/crates/benilla/src/particles/sim.rs index 6f4cd6de..14a284a7 100644 --- a/crates/benilla/src/particles/sim.rs +++ b/crates/benilla/src/particles/sim.rs @@ -109,7 +109,7 @@ fn inherit_trigger(accum: &mut f32, held: &mut Vec3, dt: f32, delta: Vec3, live: fn drive_child( child: &mut ChildEmitter, parent: &[Particle], - clip_ms: f32, + rate: f32, emitting: bool, scale: f32, dt: f32, @@ -120,8 +120,8 @@ fn drive_child( let origin = Vec3::from(child.def.position); for p in parent { accumulate_emission( - &child.def, - clip_ms, + child.def.burst(), + rate, emitting, scale, dt, @@ -214,6 +214,10 @@ pub(super) fn simulate_particles( Without, ), >, + // Hosted emitters' sequence source (the unit/GameObject lane): the host root's live + // `AnimationPlayer` names the playing sequence + clip time, same resolution as the + // material-alpha sampler (`doodad_anim::playing_seq`). + hosts: Query<(&AnimationPlayer, &benilla_assets::ModelAnimations)>, ) { let Ok((cam_tf, frustum)) = cam.single() else { return; @@ -384,6 +388,8 @@ pub(super) fn simulate_particles( inherit_vel, gate_prev, age, + host, + seq, rng, mesh, texture, @@ -549,23 +555,38 @@ pub(super) fn simulate_particles( // byte-verified): spawn count × clamp(1 − (camDist − 50)·0.02, 0.25, 1.0) — full rate // inside 50 yd, linear falloff, a 25% floor from 87.5 yd out, never zero — and × the // `particleDensity` CVar. - // The enabled M2Track (file +0x1dc) gates NEW emission on the same clip clock — the + // The enabled M2Track (file +0x1dc) gates NEW emission on the same clock — the // one-shot effects' choreography (a 200 ms hand flash inside a 1.0 s clip; the impact // model's six staggered windows). Live particles are untouched — they finish their // lifespans exactly like the drain above. - // The clip clock: emitter age in ms, WRAPPED when the model's first sequence loops - // (the client's m2_animate sequence time — a hold kit's windowed flash re-fires every - // pass); a clamped sequence's tracks end-hold, bounded by the instance reap. - let clip_ms = { - let ms = *age * 1000.0; - def.clip_wrap_ms.map_or(ms, |w| ms % w) + // The sequence clock (`EmitTiming`, decision 0641's structure one channel over): a HOSTED + // emitter reads its host's live playing sequence + clip time each frame — the reference's + // `m2_animate` emitter phase samples the CURRENT sequence record, which is why a quest + // GameObject's explosion fires in its one-shot clips and reads OFF in every idle window + // (bug B27). A host with no live player yet keeps its last slot at that slot's opening + // pose. Pinned lanes (doodads, effect rigs, booths) run their slot on the spawn-age + // clock; `EmitTiming` wraps a looping band and end-holds a clamped one. + let (clock_seq, elapsed_s) = match *host { + Some(h) => match hosts + .get(h) + .ok() + .and_then(|(p, a)| crate::doodad_anim::playing_seq(p, a)) + { + Some((s, t)) => { + *seq = Some(s); + (Some(s), t) + } + None => (*seq, 0.0), + }, + None => (*seq, *age), }; - let emitting = !*draining && def.enabled.on_at(clip_ms); + let emitting = !*draining && def.timing.emitting(clock_seq, elapsed_s); + let rate = def.timing.rate(clock_seq, elapsed_s); let dist_lod = (1.0 - (placement.translation.distance(e_cam_pos) - 50.0) * 0.02).clamp(0.25, 1.0); let burst = accumulate_emission( - def, - clip_ms, + def.burst(), + rate, emitting, density * dist_lod, dt, @@ -573,7 +594,7 @@ pub(super) fn simulate_particles( gate_prev, ); if burst > 0.0 && crate::dbg_trace::enabled() { - crate::dbg_trace::line("fx", &format!("burst n={burst} clip_ms={clip_ms:.0}")); + crate::dbg_trace::line("fx", &format!("burst n={burst} t={elapsed_s:.2}s")); } while *accumulator >= 1.0 && particles.len() < MAX_PARTICLES { *accumulator -= 1.0; @@ -607,7 +628,7 @@ pub(super) fn simulate_particles( if anchored && def.ground_snap() { let world = *anchor_pos + *attach_rot * pos; if let Some(hit) = spatial.cast_ray(world, Dir3::NEG_Y, 20.0, true, &snap_filter) { - let lifted = world.y - hit.distance + def.over_life.sample(0.0).1; + let lifted = world.y - hit.distance + def.over_life.sample(0.0).size; pos = attach_inv * (Vec3::new(world.x, lifted, world.z) - *anchor_pos); } } @@ -669,17 +690,17 @@ pub(super) fn simulate_particles( // 2b. CHILD emitters (wow-re `part-child-recursion.md`): each drives off the live pool // — post-integration, post-birth, exactly the window the reference's per-particle - // child loop sees — with the child's OWN rate/enabled tracks on its own clip wrap. + // child loop sees — with the child's OWN rate/enabled tracks on its own model's + // slot-0 clock (a recursion model has no host of its own; the parent's age drives). // A draining parent stops driving; child pools live out their spans (the despawn // above waits for them). for child in children.iter_mut() { - let raw_ms = *age * 1000.0; - let c_ms = child.def.clip_wrap_ms.map_or(raw_ms, |w| raw_ms % w); - let c_emitting = !*draining && child.def.enabled.on_at(c_ms); + let c_emitting = !*draining && child.def.timing.emitting(None, *age); + let c_rate = child.def.timing.rate(None, *age); drive_child( child, particles, - c_ms, + c_rate, c_emitting, density * dist_lod, dt, @@ -876,10 +897,7 @@ mod tests { area_width: 0.0, ..crate::particles::tests::plain_def() }); - child.def.emission_rate = benilla_formats::ValueTrack { - keys: vec![(0, 100.0)], - interp: 0, - }; + child.def.timing = benilla_formats::EmitTiming::constant(100.0); // Two live parents, far apart, distinct velocities. let parents = [ particle(Vec3::X * 10.0, Vec3::Y * 3.0), @@ -889,7 +907,7 @@ mod tests { super::drive_child( &mut child, &parents, - 0.0, + 100.0, true, 1.0, 0.1, @@ -914,7 +932,7 @@ mod tests { super::drive_child( &mut child, &[], - 0.0, + 100.0, true, 1.0, 0.1, diff --git a/crates/benilla/src/portrait/glue_booth.rs b/crates/benilla/src/portrait/glue_booth.rs index 3747fa7e..d418a755 100644 --- a/crates/benilla/src/portrait/glue_booth.rs +++ b/crates/benilla/src/portrait/glue_booth.rs @@ -624,6 +624,8 @@ pub(super) fn sync_glue_scene( Some(owner), None, // a backdrop scene is never an attached model Some(scene.root), + // A glue scene loops its one authored clip forever — the doodad law. + crate::particles::EmitClock::Pinned, ) { commands .entity(e) diff --git a/crates/benilla/src/terrain_stream.rs b/crates/benilla/src/terrain_stream.rs index 758de310..35e4b454 100644 --- a/crates/benilla/src/terrain_stream.rs +++ b/crates/benilla/src/terrain_stream.rs @@ -78,8 +78,18 @@ pub(crate) struct TerrainStreamer { /// The WDT failed to load (unheard of for a shipped map): stream ungated like pre-0476 rather /// than showing no world. Reset on map change. wdt_ungated: bool, + /// `true` once this map's global WMO has been registered as a placement (decision 0688) — on a + /// WMO-only map that one building is the whole world, so there is nothing else to stream. + /// Cleared on map change, which also releases the placement. + global_wmo: bool, } +/// The placement id the map-global WMO is registered under. A WMO-only map authors **no** ADT tiles +/// at all, so it contributes no MODF/MDDF uniqueIds this could collide with — and the value is the +/// one the file itself carries in the dead `uniqueId` slot (the reference overwrites it from its own +/// counter at `0xc9a320`, having no more use for it than we do). +const GLOBAL_WMO_UID: u32 = u32::MAX; + impl TerrainStreamer { /// Residency counts for the debug panel's World readout: `(spawned, requested)` — tiles whose /// terrain entity exists vs. every tile in the stream window (spawned + still loading). @@ -134,6 +144,10 @@ struct Placement { /// WMO doodad props (candle stands, banners) resolved once the WMO root loads — each its own M2, /// spawned across frames as its asset arrives. Empty for M2-doodad placements. doodads: Vec, + /// The placement's [`crate::wmo_portal::WmoPortalInstance`] entity, spawned with the building's + /// groups. Held so the props — which spawn later, as their own M2 assets land — can be tagged + /// with the same instance and cull alongside the group that owns them. + portal_instance: Option, /// How many loaded tiles reference this placement; despawned when it hits zero. refs: u32, } @@ -143,6 +157,10 @@ struct Placement { struct WmoDoodadInst { handle: Handle, transform: Transform, + /// The prop's owning WMO group (its MODR referencer) — the portal-cull key, so a prop is hidden + /// with the room it furnishes. `None` for a MODD no group references (the reference never + /// instantiates one at all; we still show it, uncullable, rather than change what draws today). + group: Option, /// The prop's lighting ([`PropLight`], from `WmoModel::doodad_base` composed with this /// placement): exterior sky-lit, or the interior MODD-colour base + its owning group's MOLR /// lights placed in world space — folded into the prop's SH probe once its M2 loads (the fold @@ -301,6 +319,9 @@ fn stream_terrain( release_placement(&mut commands, placements, uid); } } + if std::mem::take(&mut state.global_wmo) { + release_placement(&mut commands, placements, GLOBAL_WMO_UID); + } state.map_dir = Some(dir.clone()); state.wdt = Some(asset_server.load(format!("mpq://World/Maps/{dir}/{dir}.wdt"))); state.wdt_ungated = false; @@ -320,6 +341,34 @@ fn stream_terrain( } } + // A WMO-only map (decision 0688): the WDT says this map authors no terrain and its entire world + // is one building. Register it exactly like any ADT-placed WMO — same `Placement`, same + // spawn/collider/doodad/portal path — because that is what the reference does: its WDT branch + // hands the global MODF entry to the SAME consumer (`0x695650`) an ADT's MCRF walk does. It is + // registered once for the map and released on the map change above; nothing streams it in or + // out, since there is no "range" for a building that is the map. + if let Some(g) = wdt_index.and_then(|w| w.global_wmo()) { + if !state.global_wmo { + info!( + "terrain: map {dir} has no tiles — its world is one WMO ({})", + g.model + ); + register_wmo( + placements, + &asset_server, + &WmoInstance { + model: g.model.clone(), + position: g.position, + rotation: g.rotation, + unique_id: GLOBAL_WMO_UID, + doodad_set: g.doodad_set, + name_set: g.name_set, + }, + ); + state.global_wmo = true; + } + } + // Stream around the *view focus*: the avatar in third-person, but the free-flying camera itself // while detached or before we've connected — so the world loads around wherever you look. let center = if player.active && !player.detached { @@ -488,6 +537,26 @@ fn stream_terrain( .tiles .get(&(cx, cy)) .is_none_or(|t| t.entity.is_some()); + // Until the WDT answers we don't yet know what this map is made of, so nothing under the + // focus can be called resident. Without this the post-worldport frames before the index + // lands read as "ground is up" — harmless on an ADT map (the tile entries below close the + // gap a frame later) but on a WMO-only map, where `desired` is empty forever, it is the + // difference between a loading screen and a glimpse of the void. + if wdt_index.is_none() && !state.wdt_ungated { + p.focus_resident = false; + } + // A WMO-only map's residency IS its one building (0688) — there are no tiles to count, so + // the bar and the clear-condition ride the placement instead. Counting it keeps + // `total > 0`, which is what `is_ready` requires before it will clear the screen at all. + if state.global_wmo { + let up = placements + .by_id + .get(&GLOBAL_WMO_UID) + .is_some_and(|p| p.spawned); + p.total += 1; + p.ready += usize::from(up); + p.focus_resident &= up; + } } } @@ -513,6 +582,7 @@ fn register_doodad(placements: &mut Placements, asset_server: &AssetServer, d: & doodad_set: 0, // M2 doodads carry no doodad set name_set: 0, doodads: Vec::new(), + portal_instance: None, refs: 1, }, ); @@ -539,6 +609,7 @@ fn register_wmo(placements: &mut Placements, asset_server: &AssetServer, w: &Wmo doodad_set: w.doodad_set, name_set: w.name_set, doodads: Vec::new(), + portal_instance: None, refs: 1, }, ); diff --git a/crates/benilla/src/terrain_stream/spawn/fx.rs b/crates/benilla/src/terrain_stream/spawn/fx.rs index a0e8e85c..1a151d9a 100644 --- a/crates/benilla/src/terrain_stream/spawn/fx.rs +++ b/crates/benilla/src/terrain_stream/spawn/fx.rs @@ -143,8 +143,9 @@ pub(super) fn spawn_emitters_for( em, transform, owner, - None, // placed doodads/props are never attached models + None, // placed doodads/props are never attached models None, // anchor at the placement: an animated bone never drags the risen cloud + particles::EmitClock::Pinned, // a placed doodad's one-time arm: slot 0, spawn clock ) { commands.entity(e).insert(particles::EmitterFade { radius: fade_radius, diff --git a/crates/benilla/src/terrain_stream/spawn/mod.rs b/crates/benilla/src/terrain_stream/spawn/mod.rs index 855beed4..fc4b48cd 100644 --- a/crates/benilla/src/terrain_stream/spawn/mod.rs +++ b/crates/benilla/src/terrain_stream/spawn/mod.rs @@ -218,6 +218,9 @@ pub(super) fn spawn_loaded_placements( .entity(entity) .insert(WmoGroupVis { instance, group }); } + // The props spawn later — each waits on its own M2 — so they can't be + // tagged here; hold the instance for them (decision 0689). + p.portal_instance = Some(instance); } ents.push(instance); // despawns with the placement } @@ -226,14 +229,29 @@ pub(super) fn spawn_loaded_placements( // spawned at the placement transform on the shared per-kind material. Appended // AFTER the portal-instance zip above, which requires `ents` to still be exactly the // submeshes in order. - spawn_wmo_liquids( - &mut commands, - m.group_liquids.iter().flatten(), - liquid_assets.as_deref(), - &mut meshes, - p.transform, - &mut ents, - ); + // Spawned a group at a time so each surface can take that group's cull key: a + // pool belongs to the room it sits in, and a culled room's lava must go with it + // (decision 0689 — the same defect as the props, on the same building). + for (gi, lq) in m.group_liquids.iter().enumerate() { + let Some(lq) = lq else { continue }; + let first = ents.len(); + spawn_wmo_liquids( + &mut commands, + std::iter::once(lq), + liquid_assets.as_deref(), + &mut meshes, + p.transform, + &mut ents, + ); + if let Some(instance) = p.portal_instance { + for &e in &ents[first..] { + commands.entity(e).insert(WmoGroupVis { + instance, + group: gi as u16, + }); + } + } + } // Building colliders (avian): the flattened group tris baked at this placement's // transform — *two* meshes, because the client gathers different WMO faces for the // player body (walking: drops DETAIL) and the camera/LOS (drops NOCAMCOLLIDE, keeps @@ -302,6 +320,9 @@ pub(super) fn spawn_loaded_placements( // 2. Spawn each WMO doodad prop as its M2 asset finishes loading (across frames). Their // entities join `p.entities`, so they despawn with the placement. Empty for M2 doodads. + // Copied out before the loop borrows `p.doodads`: the props tag onto the same portal + // instance their building's groups did (decision 0689). + let portal_instance = p.portal_instance; for d in &mut p.doodads { if d.spawned { continue; @@ -375,6 +396,17 @@ pub(super) fn spawn_loaded_placements( if let (Some(slot), Some(&first)) = (interior_slot, ents.first()) { commands.entity(first).insert(PropProbeSlot(slot)); } + // Portal-cull the prop with the group that owns it — the reference commits a WMO's + // doodads per VISIBLE group (`0x695aa0` over the group's MODR refs, from the + // visible-group walk `0x698720`), so furniture never outlives its room. Every submesh + // spawned above is this one prop, so they all take the same key (decision 0689). + if let (Some(instance), Some(group)) = (portal_instance, d.group) { + for &entity in &ents { + commands + .entity(entity) + .insert(WmoGroupVis { instance, group }); + } + } // Prop collider (avian): a static trimesh from the prop's collision hull at its world // transform — collide-iff-hull, exactly like a map doodad, so a weapon rack / crate / cargo // net is solid to both the player and the camera while a hull-less prop (banner, small @@ -491,6 +523,9 @@ fn resolve_wmo_doodads( out.push(WmoDoodadInst { handle: asset_server.load(m2_url(&d.model)), transform: wmo_world.mul_transform(local), + // The MODR owner, resolved with the lighting base at load — the prop's portal-cull + // key, so a lantern is hidden with the room it hangs in (decision 0689). + group: wmo.doodad_owner.get(di).copied().flatten(), light, spawned: false, }); diff --git a/crates/benilla/src/wmo_portal/audit/mod.rs b/crates/benilla/src/wmo_portal/audit/mod.rs index 62af8f5f..abc0b428 100644 --- a/crates/benilla/src/wmo_portal/audit/mod.rs +++ b/crates/benilla/src/wmo_portal/audit/mod.rs @@ -223,6 +223,7 @@ fn load_subject(internal: &str, site: Option<&Site>) -> Subject { group_light_refs: Vec::new(), group_liquids: Vec::new(), doodad_base: Vec::new(), + doodad_owner: Vec::new(), }; Subject { model, diff --git a/crates/benilla/src/wmo_portal/interior.rs b/crates/benilla/src/wmo_portal/interior.rs index e0534fc8..9efe5eb8 100644 --- a/crates/benilla/src/wmo_portal/interior.rs +++ b/crates/benilla/src/wmo_portal/interior.rs @@ -439,6 +439,7 @@ mod tests { group_light_refs: Vec::new(), group_liquids: Vec::new(), doodad_base: Default::default(), + doodad_owner: Default::default(), } } diff --git a/crates/benilla/src/wmo_portal/mod.rs b/crates/benilla/src/wmo_portal/mod.rs index 4cfd7ffa..1c475286 100644 --- a/crates/benilla/src/wmo_portal/mod.rs +++ b/crates/benilla/src/wmo_portal/mod.rs @@ -755,6 +755,7 @@ mod tests { group_light_refs: Vec::new(), group_liquids: Vec::new(), doodad_base: Default::default(), + doodad_owner: Default::default(), } } }