diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs index 808bb9f0..229c0765 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -1240,6 +1240,24 @@ pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { benilla_formats::ParticleShape::Sphere => hit("shape:sphere"), benilla_formats::ParticleShape::Spline => hit("shape:SPLINE"), } + // ANISOTROPIC plane rectangles — the population on which the areaLength↔areaWidth axis + // pairing is observable at all (a SQUARE area renders identically either way). 0563 and + // 0566 both pre-named "a 90°-wrong ANISOTROPIC effect" as this lane's suspect, but no + // instrument could LIST that population, so the swapped pairing outlived both audits + // until Gressil's 0.1 × 1.1 blade smoke drew its curtain across the blade. Bucketed by + // aspect so a thin curtain (load-bearing) separates from a near-square (invisible). + if d.shape == benilla_formats::ParticleShape::Plane { + let lo = d.area_length.abs().min(d.area_width.abs()); + let hi = d.area_length.abs().max(d.area_width.abs()); + if hi > 1e-4 { + let aspect = if lo > 1e-4 { hi / lo } else { f32::INFINITY }; + if aspect >= 4.0 { + hit("plane-area:ANISOTROPIC >=4:1"); + } else if aspect >= 1.5 { + hit("plane-area:anisotropic >=1.5:1"); + } + } + } match d.head_tail { 0 => hit("type:head"), 1 => hit("type:tail"), diff --git a/crates/benilla-formats/src/emit_timing.rs b/crates/benilla-formats/src/emit_timing.rs index a8730c67..fb42a824 100644 --- a/crates/benilla-formats/src/emit_timing.rs +++ b/crates/benilla-formats/src/emit_timing.rs @@ -14,11 +14,13 @@ use crate::models::{bake_track, ScalarAnim, SeqSlot}; /// **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. +/// The clock law rides the **baked loop** ([`crate::models::KeyAnim::wrap`]), decided from the +/// slot at bake time: 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), and a gseq-tagged track always wraps on its own free clock +/// whatever the playing sequence does. 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). @@ -26,18 +28,18 @@ pub struct EmitTiming { /// 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). + /// Per file slot: sequence flags bit 0 CLEAR = the band loops. Carried for [`Self::idx`]'s + /// slot count and the dump instruments' view — the *sampling* clock is the baked loop's own. 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. + /// Bake both tracks against every file sequence slot; `gseq` is the global-sequence duration + /// table. Each slot carries its own loop flag, which becomes the baked loop's clock. 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, @@ -51,7 +53,7 @@ impl EmitTiming { Self { rate: per_slot(rate), enabled: per_slot(enabled), - looping, + looping: slots.iter().map(|s| s.looping).collect(), } } @@ -67,23 +69,19 @@ impl EmitTiming { /// 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) + .get(self.idx(seq)) .and_then(|o| o.as_ref()) - .is_none_or(|a| a.sample_clocked(elapsed, wrap, 1.0) > 0.5) + .is_none_or(|a| a.sample_or(elapsed, 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) + .get(self.idx(seq)) .and_then(|o| o.as_ref()) - .map_or(0.0, |a| a.sample_clocked(elapsed, wrap, 0.0)) + .map_or(0.0, |a| a.sample_or(elapsed, 0.0)) .max(0.0) } @@ -128,6 +126,7 @@ impl EmitTiming { rate: vec![Some(ScalarAnim { period: 0.0, step: true, + wrap: true, keys: vec![(0.0, rate)], })], enabled: vec![None], @@ -149,11 +148,16 @@ mod tests { } } - fn slots(bands: &[(u32, u32)]) -> Vec { - bands - .iter() + /// File sequence slots as `(band, loops)` — the loop flag is the slot's CLOCK, so these tests + /// spell it per slot rather than carry a parallel array beside the bands. + fn slots(spec: &[((u32, u32), bool)]) -> Vec { + spec.iter() .enumerate() - .map(|(index, &band)| SeqSlot { index, band }) + .map(|(index, &(band, looping))| SeqSlot { + index, + band, + looping, + }) .collect() } @@ -165,8 +169,7 @@ mod tests { let t = EmitTiming::bake( &track(0, &[(0, 0.0), (67, 30.0)], &[]), &M2ScalarTrack::default(), - &slots(&[(0, 1000)]), - vec![true], + &slots(&[((0, 1000), true)]), &[], ); assert_eq!(t.rate(None, 0.050), 0.0, "before the key: step holds 0"); @@ -182,8 +185,7 @@ mod tests { let t = EmitTiming::bake( &track(1, &[(0, 0.0), (100, 100.0), (200, 0.0)], &[]), &M2ScalarTrack::default(), - &slots(&[(0, 1000)]), - vec![true], + &slots(&[((0, 1000), true)]), &[], ); assert!((t.rate(None, 0.050) - 50.0).abs() < 1e-4, "rising mid-ramp"); @@ -210,8 +212,11 @@ mod tests { let t = EmitTiming::bake( &track(0, &[(0, 20.0)], &[]), &gate, - &slots(&[(1000, 2000), (2333, 2667), (3800, 4100)]), - vec![false, true, false], + &slots(&[ + ((1000, 2000), false), + ((2333, 2667), true), + ((3800, 4100), false), + ]), &[], ); // Idle (slot 1): the collapsed window (1,1) resolves to keys[1] = OFF — at every time, @@ -246,8 +251,7 @@ mod tests { let t = EmitTiming::bake( &track(0, &[(0, 40.0)], &[]), &gate, - &slots(&[(0, 1000)]), - vec![true], + &slots(&[((0, 1000), true)]), &[], ); assert!(t.emitting(None, 0.1), "first pass: inside the window"); @@ -282,8 +286,7 @@ mod tests { let t = EmitTiming::bake( &rate, &M2ScalarTrack::default(), - &slots(&[(0, 1333), (1367, 2667)]), - vec![true, true], + &slots(&[((0, 1333), true), ((1367, 2667), true)]), &[], ); // Slot 0 — what the old pinned consumer sampled. Silent across its whole band. diff --git a/crates/benilla-formats/src/models/anim.rs b/crates/benilla-formats/src/models/anim.rs index abeacfc9..b16e4608 100644 --- a/crates/benilla-formats/src/models/anim.rs +++ b/crates/benilla-formats/src/models/anim.rs @@ -1184,4 +1184,45 @@ mod tests { // …and an out-of-range bone index is a `None`, never a panic. assert_eq!(skel.billboard_host(999), None); } + + /// **An effect model can be pure camera-facing geometry with no emitters at all** — the premise + /// that makes a consumer's "keep the meshes, carry the emitters" split wrong on its own. + /// + /// `Spells\Enchantments\Sparkle_A.m2` is the shipped proof: **one** bone, spherical billboard + /// (flags `0x08`), **zero** particle emitters and zero ribbons — the whole model is a single + /// additive quad that faces the camera. `ItemVisuals` 28 hangs it on slot 4, and 10 of the 29604 + /// `ItemDisplayInfo` rows carry that visual, so a preview path that drops billboard batches and + /// keeps only emitters renders *nothing whatsoever* for those items (the select-screen half of + /// `#bugs` B118). Pinned here because the mistake is invisible in code that reads correct. + #[test] + fn a_real_item_glow_model_is_pure_billboard_geometry_with_no_emitters() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let bytes = chain + .read_file("Spells\\Enchantments\\Sparkle_A.m2") + .expect("read the Sparkle_A item glow"); + let skel = parse_m2_skeleton(&bytes).expect("skeleton"); + assert_eq!(skel.bones.len(), 1, "one bone — the quad's own"); + assert_eq!( + skel.bones[0].billboard, + Some(crate::BillboardKind::Spherical), + "…and it is a spherical billboard, so its geometry is a card" + ); + assert!( + crate::parse_m2_particle_emitters(&bytes) + .expect("emitters") + .is_empty(), + "no particle emitters: carrying only emitters carries nothing" + ); + assert!( + crate::parse_m2_ribbon_emitters(&bytes) + .expect("ribbons") + .is_empty(), + "nor ribbons" + ); + } } diff --git a/crates/benilla-formats/src/models/key_anim.rs b/crates/benilla-formats/src/models/key_anim.rs index 572170c6..f4e2ed14 100644 --- a/crates/benilla-formats/src/models/key_anim.rs +++ b/crates/benilla-formats/src/models/key_anim.rs @@ -52,16 +52,19 @@ impl Lerp for [f32; 4] { } /// The sequence a sequence-timeline track bakes against: the model's **file** sequence slot (which -/// 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. +/// is what indexes the track's [`M2Track::ranges`] — NOT an index into a filtered animation list), +/// that sequence's absolute `(start_ms, end_ms)` band, and whether the band LOOPS (sequence flags +/// bit 0 CLEAR) — which is the baked loop's clock law, see [`KeyAnim::wrap`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct SeqSlot { pub index: usize, pub band: (u32, u32), + pub looping: bool, } -/// One baked keyed loop, seconds: sample at `t mod period` (linear or step per [`Self::step`]), -/// holding the first/last key outside the keyed span. `period == 0` ⇒ a constant (single key). +/// One baked keyed loop, seconds: sample at `t mod period` / `min(t, period)` per [`Self::wrap`] +/// (linear or step per [`Self::step`]), holding the first/last key outside the keyed span. +/// `period == 0` ⇒ a constant (single key). #[derive(Clone, Debug, PartialEq)] pub struct KeyAnim { /// Loop period (secs): the global sequence's duration, or the sequence band's length. @@ -69,23 +72,30 @@ pub struct KeyAnim { pub period: f32, /// Step interpolation (`interp == 0`): hold each key until the next; else linear. pub step: bool, + /// **The clock law**, decided at bake time from what this loop is baked against — never by the + /// consumer, which is how it used to be got wrong. `true` = WRAP (`t mod period`): a global + /// sequence (its own free-running clock) or a sequence band whose sequence loops, where the + /// modulo is the kernel's own re-fire of a windowed track every pass. `false` = CLAMP + /// (`min(t, period)`): a band whose sequence is one-shot, where the animation clock parks at + /// (or just past) the band end and the track must hold its TAIL value there. + /// + /// The distinction is only ever visible at the very end of a one-shot clip — and that is + /// exactly where it is load-bearing, because Bevy's `ActiveAnimation::update` returns early on + /// completion *without* its own modulo, so a finished `RepeatAnimation::Never` clip parks at + /// `seek_time >= clip_duration == period`. Wrapping that aliases the end of the animation back + /// onto its **opening** value: an elemental's Death sequence fades every batch to alpha 0 and + /// then — one frame later, for ever — snapped back to a fully opaque body frozen in mid-air. + pub wrap: bool, /// `(secs from loop start, value)`, time-ascending. pub keys: Vec<(f32, V)>, } 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). + /// Sample at `elapsed` seconds on this loop's own clock ([`Self::wrap`]); `empty` is the + /// channel's identity for the keyless case (the bake never emits that — each channel's + /// `sample` supplies it). 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 wrap = self.wrap; let Some(&(t0, v0)) = self.keys.first() else { return empty; }; @@ -195,9 +205,12 @@ pub(crate) fn bake_track( } let keys: Vec<(u32, V)> = track.keys.iter().map(|&(t, v)| (t, proj(v))).collect(); let step = track.interp == 0; + // A constant has no clock at all (`period == 0` short-circuits the sampler), so `wrap` is + // arbitrary — `true` keeps it the identity value it has always been. let constant = |v: V| KeyAnim { period: 0.0, step, + wrap: true, keys: vec![(0.0, v)], }; // An all-equal track is a constant in every band: dropped when the static path owns it, else a @@ -220,13 +233,20 @@ pub(crate) fn bake_track( return Some(KeyAnim { period: period_ms as f32 / 1000.0, step, + // A global sequence runs its own free clock, the same loop in every animation — it + // always wraps, whatever the playing sequence's own loop flag says. + wrap: true, keys: keys.iter().map(|&(t, v)| (t as f32 / 1000.0, v)).collect(), }); } // Sequence-timeline track: rebuild the function the reference samples across THIS sequence's // band. Its key window is the track's own per-sequence `(lo, hi)` pair; an absent ranges array // is the reference's `[track+4] == 0` fallback — search the whole key list. - let SeqSlot { index, band } = seq?; + let SeqSlot { + index, + band, + looping, + } = seq?; let (start, end) = band; let (lo, hi) = match track.ranges.get(index) { Some(&(lo, hi)) => (lo as usize, hi as usize), @@ -258,6 +278,8 @@ pub(crate) fn bake_track( Some(KeyAnim { period: ((end - start) as f32 / 1000.0).max(0.001), step, + // The band's own loop flag IS this loop's clock: a one-shot sequence holds its tail. + wrap: looping, keys: baked, }) } diff --git a/crates/benilla-formats/src/models/m2_batches.rs b/crates/benilla-formats/src/models/m2_batches.rs index 4e537bc2..cb228df2 100644 --- a/crates/benilla-formats/src/models/m2_batches.rs +++ b/crates/benilla-formats/src/models/m2_batches.rs @@ -266,11 +266,14 @@ pub fn parse_m2_render_submeshes( .collect() }; - // Every sequence's (anim id, absolute time band): entry stride 0x44, anim id u16 at +0x00, - // band start/end u32 at +0x04/+0x08. The first entry's band is the loop a placed doodad's - // one-time arm plays; sequence-timeline colour/weight tracks bake against it (`mat_anim`); - // the full list feeds the billboard bones' per-sequence translation loops. - let seq_bands: Vec<(u16, (u32, u32))> = { + // Every sequence's (anim id, absolute time band, loops): entry stride 0x44, anim id u16 at + // +0x00, band start/end u32 at +0x04/+0x08, flags u32 at +0x10 (bit 0 CLEAR = the band loops — + // the same read `particles` makes for the emission-timing bake). The first entry's band is the + // loop a placed doodad's one-time arm plays; sequence-timeline colour/weight tracks bake + // against it (`mat_anim`); the full list feeds the billboard bones' per-sequence translation + // loops. The loop flag is the baked loop's CLOCK (`KeyAnim::wrap`) — reading the band without + // it is what left a one-shot Death band wrapping back onto its opening alpha. + let seq_bands: Vec<(u16, (u32, u32), bool)> = { let (n, o) = (le_u32(bytes, 0x1c) as usize, le_u32(bytes, 0x20) as usize); (0..n) .map_while(|i| { @@ -279,17 +282,22 @@ pub fn parse_m2_render_submeshes( ( le_u16(bytes, e), (le_u32(bytes, e + 0x04), le_u32(bytes, e + 0x08)), + le_u32(bytes, e + 0x10) & 1 == 0, ) }) }) .collect() }; - // The same list as the bake's addressing unit: file slot + band. The slot is what indexes a - // track's per-sequence key ranges, so it must stay the FILE order — no filtering. + // The same list as the bake's addressing unit: file slot + band + clock. The slot is what + // indexes a track's per-sequence key ranges, so it must stay the FILE order — no filtering. let seq_slots: Vec = seq_bands .iter() .enumerate() - .map(|(index, &(_, band))| SeqSlot { index, band }) + .map(|(index, &(_, band, looping))| SeqSlot { + index, + band, + looping, + }) .collect(); // Slot 0 — the loop a placed doodad's one-time arm plays, and the only band the shared-material // UV/tint registries can key on (see `tex_anim::bake_uv_anim`). @@ -454,7 +462,7 @@ pub fn parse_m2_render_submeshes( // (see the field doc). seq_translations: seq_bands .iter() - .filter_map(|&(id, band)| { + .filter_map(|&(id, band, _)| { Some((id, parse_bone_seq_translation(bytes, bone_idx, band)?)) }) .collect(), diff --git a/crates/benilla-formats/src/models/mat_anim.rs b/crates/benilla-formats/src/models/mat_anim.rs index 49c9b139..ff4f352a 100644 --- a/crates/benilla-formats/src/models/mat_anim.rs +++ b/crates/benilla-formats/src/models/mat_anim.rs @@ -439,9 +439,31 @@ mod tests { } } - /// Bake against sequence file slot `index` with band `band`. + /// Bake against a **looping** sequence file slot `index` with band `band`. The clamped clock + /// (the one-shot band that must hold its tail) is [`a_clamped_band_holds_its_faded_tail`]. fn bake_at(t: &M2ScalarTrack, index: usize, band: (u32, u32)) -> Option { - bake_scalar_anim(t, &[], Some(SeqSlot { index, band })) + bake_scalar_anim( + t, + &[], + Some(SeqSlot { + index, + band, + looping: true, + }), + ) + } + + /// Bake against a **clamped** (one-shot) sequence file slot — the Death-band shape. + fn bake_at_clamped(t: &M2ScalarTrack, index: usize, band: (u32, u32)) -> Option { + bake_scalar_anim( + t, + &[], + Some(SeqSlot { + index, + band, + looping: false, + }), + ) } /// The bake's contribution gate: keyless and constant-1 tracks vanish; a dimming constant is @@ -570,6 +592,95 @@ mod tests { assert_eq!(a.sample(0.5), 0.0, "past the last key: hold it"); } + /// **The Death-band clock** — the AirElemental shape, synthesized. A one-shot (clamped) band + /// whose transparency steps `1 → 0` partway through: the body dissolves, and the clock then + /// PARKS at (or just past) the band end for the whole life of the corpse. The baked loop must + /// hold that faded tail there. + /// + /// It used to wrap. Bevy's `ActiveAnimation::update` returns early once a + /// `RepeatAnimation::Never` clip completes — *before* its own modulo — so a finished Death clip + /// parks at `seek_time >= clip_duration == period`, and `t mod period` aliased that straight + /// back onto the band's opening value: one frame after the elemental had fully dissolved, its + /// whole body snapped back to full opacity and stayed there, frozen in mid-air, until the + /// server destroyed the corpse. 74 models author an aliasing Death band (elementals, voidwalker, + /// shade, ghost, wisp, banshee, lich, Ragnaros, Thunderaan, the totems…), 327 an aliasing + /// one-shot band of any kind. + #[test] + fn a_clamped_band_holds_its_faded_tail() { + // Band 43333..46333 (3 s), the elemental's Death; the body's weight steps to 0 at 867 ms. + let t = ranged(0, &[(3333, 1.0), (44200, 0.0)], &[(0, 1)]); + let a = bake_at_clamped(&t, 0, (43333, 46333)).expect("the band moves, so it bakes"); + assert!(!a.wrap, "a one-shot band clamps its clock"); + assert_eq!(a.sample(0.0), 1.0, "the body is still there as death opens"); + assert_eq!( + a.sample(2.999), + 0.0, + "…and gone by the end of the animation" + ); + assert_eq!(a.sample(3.0), 0.0, "t == period must not alias to the head"); + assert_eq!( + a.sample(3.008), + 0.0, + "where a finished Bevy clip actually parks" + ); + assert_eq!(a.sample(600.0), 0.0, "still gone a corpse-decay later"); + } + + /// The same band authored as a LOOPING sequence keeps the modulo wrap — the kernel re-fires a + /// windowed track every pass, which is what makes a cyclic flicker cycle. + #[test] + fn a_looping_band_still_wraps() { + let t = ranged(0, &[(3333, 1.0), (44200, 0.0)], &[(0, 1)]); + let a = bake_at(&t, 0, (43333, 46333)).expect("the band moves, so it bakes"); + assert!(a.wrap, "a looping band wraps its clock"); + assert_eq!(a.sample(2.999), 0.0, "first pass: faded out"); + assert_eq!(a.sample(3.0), 1.0, "second pass: the window re-fires"); + } + + /// The real `AirElemental.m2`, straight off the client data — the model the bug was reported + /// on. Its Death band (file slot 10) fades every body batch to 0, and the parked clock must + /// still read 0 there. Skips when the client data isn't present. + #[test] + fn air_elemental_death_leaves_no_opaque_body() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let bytes = chain + .read_file("Creature\\AirElemental\\AirElemental.m2") + .expect("read AirElemental.m2"); + let subs = super::super::parse_m2_render_submeshes(&bytes, "", &[]).expect("parse"); + // Slot 10 = anim id 1 (Death), clamped, band 43333..46333 → period exactly 3.0 s. + const DEATH: usize = 10; + let faded: Vec = subs + .iter() + .enumerate() + .filter(|(_, s)| { + s.alpha_anim + .as_ref() + .is_some_and(|a| a.sample(Some(DEATH), 2.999) <= 0.0) + }) + .map(|(i, _)| i) + .collect(); + assert!( + faded.len() > 20, + "the death animation hides most of the body by its end, got {}", + faded.len() + ); + for i in faded { + let a = subs[i].alpha_anim.as_ref().unwrap(); + for t in [3.0f32, 3.008, 10.0, 600.0] { + assert_eq!( + a.sample(Some(DEATH), t), + 0.0, + "batch {i} came back at t={t} — the parked clock aliased to the band head" + ); + } + } + } + /// Step interpolation holds each key until the next (the kernel's `interp == 0` leg). #[test] fn step_tracks_hold_between_keys() { @@ -583,6 +694,7 @@ mod tests { Some(ScalarAnim { period: 0.0, step: false, + wrap: true, keys: vec![(0.0, v)], }) } diff --git a/crates/benilla-formats/src/models/tex_anim.rs b/crates/benilla-formats/src/models/tex_anim.rs index 437303ed..5a091af9 100644 --- a/crates/benilla-formats/src/models/tex_anim.rs +++ b/crates/benilla-formats/src/models/tex_anim.rs @@ -72,7 +72,12 @@ mod tests { } fn bake(t: &M2Vec3Track, gseq: &[u32], seq0: Option<(u32, u32)>) -> Option { - let slot = seq0.map(|band| SeqSlot { index: 0, band }); + // Slot 0 as the UV lane meets it: the loop a placed doodad's one-time arm plays. + let slot = seq0.map(|band| SeqSlot { + index: 0, + band, + looping: true, + }); super::super::key_anim::bake_track(t, gseq, slot, |v| [v[0], v[1]], is_zero, is_zero) } diff --git a/crates/benilla-formats/src/particles.rs b/crates/benilla-formats/src/particles.rs index e1fb0a42..58749ed5 100644 --- a/crates/benilla-formats/src/particles.rs +++ b/crates/benilla-formats/src/particles.rs @@ -683,29 +683,25 @@ pub fn parse_m2_particle_emitters(bytes: &[u8]) -> Result = { let (n, o) = (le_u32(bytes, 0x1c) as usize, le_u32(bytes, 0x20) as usize); (0..n) .map_while(|i| { let e = o + i * 0x44; - (e + 0x44 <= bytes.len()).then(|| { - ( - SeqSlot { - index: i, - band: (le_u32(bytes, e + 0x04), le_u32(bytes, e + 0x08)), - }, - le_u32(bytes, e + 0x10) & 1 == 0, - ) + (e + 0x44 <= bytes.len()).then(|| SeqSlot { + index: i, + band: (le_u32(bytes, e + 0x04), le_u32(bytes, e + 0x08)), + looping: le_u32(bytes, e + 0x10) & 1 == 0, }) }) - .unzip::<_, _, Vec<_>, Vec<_>>() + .collect() }; if seq_slots.is_empty() { seq_slots.push(SeqSlot { index: 0, band: (0, u32::MAX), + looping: true, }); - 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. @@ -819,7 +815,6 @@ pub fn parse_m2_particle_emitters(bytes: &[u8]) -> Result -//! # e.g. … -- one pone One → Onepaladin, Onehunter, … +//! cargo run -p benilla-protocol --example seed_classes -- [flags] +//! --wipe delete EVERY existing character on the account first (see the warning below) +//! --level `.character level n` + `.learn all_myclass` (all class spells + talents) +//! --tier `.character premade gear -` + `.character premade spec `; +//! `` is `phase6-bis` (Naxx BiS), `preraid-bis`, or `r14` (rank-14 PvP +//! kit, custom templates 901–909 — decision 0825), role is per class below +//! --home `.tele` each body to its own race's capital +//! --spread which race spread to use (default `a`) +//! --reload-templates make the server re-read the premade tables before dressing (see below) +//! --host default `localhost` +//! +//! # the director's two accounts, rebuilt as geared 60s: +//! … -- one pone One --wipe --level 60 --tier phase6-bis --home --spread a +//! … -- two ptwo Two --wipe --level 60 --tier phase6-bis --home --spread b //! ``` +//! +//! Without `--wipe` it is idempotent **by class** — classes the account already has are skipped — so +//! it tops an account up to the full nine. Creating characters needs no GM level; the state flags do, +//! and that is where this silently half-applies if the account is short: **`.character premade +//! gear|spec` needs gmlevel 4 and `--level`'s two commands need 5**, and that level lives in +//! `realmd.account_access`, *not* `account.gmlevel` (see method.md, "The local vmangos server"). +//! Every server reply is echoed as `server says — …`, so a refusal is visible rather than silent. +//! +//! **`--wipe` deletes characters irreversibly, hand-made ones included.** It is deliberately not the +//! default, and it names each body and its level as it goes. Both factions on one account needs +//! `AllowTwoSide.Accounts = 1` in mangosd.conf — otherwise the Horde creates (shaman is Horde-only) +//! come back `CHAR_CREATE_PVP_TEAMS_VIOLATION` (0x33). +//! +//! Spell counts do **not** match across two accounts' same-class bodies, and that is not a +//! half-applied `.learn all_myclass`: an Alliance mage ends on 68 spells and a Horde one on 74, +//! because the command grants every spell the class *can* learn and the Alliance city +//! teleports/portals carry no gate that stops a Horde mage — so the Horde body collects all twelve +//! while the Alliance body gets only its own six. Each still has its own faction's full set. +//! +//! **Creating is idempotent; dressing is NOT.** `.character premade gear` *adds* a fresh copy of the +//! set every time it runs — it does not diff against what is worn. Dress an already-dressed body and +//! the second set lands in its bags, and any slot whose first-pass item is still equipped keeps the +//! duplicate in the backpack instead: observed as a warrior left with an empty finger2 and ten spare +//! epics, because both rings wanted finger1. So re-dress only behind `--wipe`; to fix a body that was +//! dressed twice, wipe and rebuild it rather than unpicking the bags. +//! +//! **`--reload-templates`:** the world server caches the premade tables at startup and has no +//! reload command, so a template edited in the DB is invisible to `.character premade gear` until +//! the next restart. `.character premade savegear ` re-reads them all as a side effect +//! (`CharacterCommands.cpp` → `LoadPlayerPremadeTemplates`; decision 0818's in-band trick), so this +//! flag sends `.character premade savegear zz-seed-reload` as the first command of the first body +//! dressed. The junk template it saves (the naked just-created body) is inert — no role a bot would +//! pick, self-naming, safe to leave or delete from `player_premade_item_template` later. -use benilla_protocol::{logon, messages, WorldSession, WORLD_PORT}; +use std::time::{Duration, Instant}; -/// (class id, class name, race id, race name, gender): the full vanilla class list with an -/// all-eight-races spread of valid pairs. -const ROSTER: [(u8, &str, u8, &str, u8); 9] = [ - (1, "warrior", 1, "Human", 0), - (2, "paladin", 3, "Dwarf", 0), - (3, "hunter", 4, "Night Elf", 1), - (4, "rogue", 7, "Gnome", 0), - (5, "priest", 8, "Troll", 1), - (7, "shaman", 2, "Orc", 0), - (8, "mage", 5, "Undead", 1), - (9, "warlock", 1, "Human", 1), - (11, "druid", 6, "Tauren", 0), +use anyhow::{bail, Context, Result}; +use benilla_protocol::{logon, messages, ServerPacket, WorldSession, WORLD_PORT}; + +/// `CHAT_MSG_SYSTEM` on the inbound `SMSG_MESSAGECHAT` type byte — every dot-command's +/// success/refusal text arrives as one of these. +const CHAT_MSG_SYSTEM: u8 = 0x0a; + +/// Per class: the premade templates that make a level-60 body of that class make sense. `role` joins +/// the `--tier` (`dps` + `phase6-bis` → `dps-phase6-bis`, a `player_premade_item_template.name`); +/// `spec` is a verbatim `player_premade_spell_template.name` (specs carry no tier suffix — there is +/// one per role at 60). Every name here was read off this deploy's world DB, and the roles spread +/// plate/mail/cloth and tank/heal/dps across the nine rather than making nine damage dealers. +const CLASSES: [(u8, &str, &str, &str); 9] = [ + // class, name, gear role, spec template + (1, "warrior", "dps", "fury-dw-pve"), + (2, "paladin", "tank", "protection-pve"), + (3, "hunter", "dps", "mm-sv-pve"), + (4, "rogue", "dps", "combat-swords-pve"), + (5, "priest", "heal", "holy-pve"), + (7, "shaman", "heal", "resto-pve"), + (8, "mage", "dps", "arcane-power-frost-pve"), + (9, "warlock", "dps", "ds-ruin-pve"), + (11, "druid", "tank", "feral-bear-pve"), ]; -fn main() -> anyhow::Result<()> { - let args: Vec = std::env::args().collect(); - let [user, pass, prefix] = &args[1..] else { - anyhow::bail!("usage: seed_classes "); - }; +/// `(race, gender)` per class, indexed to match [`CLASSES`]. Two spreads exist so two accounts can +/// hold **eighteen distinct** race/class/gender bodies instead of the same nine twice; each spread on +/// its own still covers all eight races, and every pair is a legal 1.12 combination (paladin is +/// Human/Dwarf only, shaman Orc/Tauren/Troll, druid Night Elf/Tauren). +const SPREAD_A: [(u8, u8); 9] = [ + (1, 0), // human warrior + (3, 0), // dwarf paladin + (4, 1), // night elf hunter + (7, 0), // gnome rogue + (8, 1), // troll priest + (2, 0), // orc shaman + (5, 1), // undead mage + (1, 1), // human warlock + (6, 0), // tauren druid +]; - let l = logon("localhost", user, pass)?; +/// The second spread — no race/class pair shared with [`SPREAD_A`]. +const SPREAD_B: [(u8, u8); 9] = [ + (3, 0), // dwarf warrior + (1, 0), // human paladin + (6, 0), // tauren hunter + (2, 0), // orc rogue + (5, 1), // undead priest + (8, 1), // troll shaman + (7, 1), // gnome mage + (5, 0), // undead warlock + (4, 1), // night elf druid +]; + +/// Race id → (display name, the `game_tele` name of its capital). Gnomes ride with the dwarves: +/// Gnomeregan is a dungeon, and `game_tele` has no gnome capital row. +fn race_info(id: u8) -> Option<(&'static str, &'static str)> { + Some(match id { + 1 => ("Human", "Stormwind"), + 2 => ("Orc", "Orgrimmar"), + 3 => ("Dwarf", "Ironforge"), + 4 => ("Night Elf", "Darnassus"), + 5 => ("Undead", "Undercity"), + 6 => ("Tauren", "ThunderBluff"), + 7 => ("Gnome", "Ironforge"), + 8 => ("Troll", "Orgrimmar"), + _ => return None, + }) +} + +struct Opts { + user: String, + pass: String, + prefix: String, + host: String, + wipe: bool, + level: Option, + tier: Option, + home: bool, + reload: bool, + spread: [(u8, u8); 9], +} + +const USAGE: &str = "usage: seed_classes [--wipe] [--level ] \ + [--tier ] [--home] [--spread ] \ + [--reload-templates] [--host ]"; + +fn parse_opts() -> Result { + let mut a = std::env::args().skip(1); + let (user, pass, prefix) = ( + a.next().context(USAGE)?, + a.next().context(USAGE)?, + a.next().context(USAGE)?, + ); + let mut o = Opts { + user, + pass, + prefix, + host: "localhost".into(), + wipe: false, + level: None, + tier: None, + home: false, + reload: false, + spread: SPREAD_A, + }; + while let Some(flag) = a.next() { + match flag.as_str() { + "--wipe" => o.wipe = true, + "--home" => o.home = true, + "--reload-templates" => o.reload = true, + "--level" => o.level = Some(a.next().context("--level needs a number")?.parse()?), + "--tier" => o.tier = Some(a.next().context("--tier needs a name")?), + "--host" => o.host = a.next().context("--host needs a hostname")?, + "--spread" => { + o.spread = match a.next().context("--spread needs a|b")?.as_str() { + "a" | "A" => SPREAD_A, + "b" | "B" => SPREAD_B, + other => bail!("unknown spread {other:?} — expected a or b"), + } + } + other => bail!("unknown flag {other:?}\n{USAGE}"), + } + } + Ok(o) +} + +fn connect(o: &Opts) -> Result { + let l = logon(&o.host, &o.user, &o.pass)?; let addr = l .realms .first() .map(|r| r.address.clone()) - .unwrap_or_else(|| format!("localhost:{WORLD_PORT}")); - let mut s = WorldSession::connect(&addr, user, l.session_key)?; + .unwrap_or_else(|| format!("{}:{WORLD_PORT}", o.host)); + WorldSession::connect(&addr, &o.user, l.session_key) +} +/// Read inbound packets for `window`, echoing every system-chat line — the server's own +/// success/refusal text for a dot-command, and the only thing separating a command that worked from +/// one that was silently refused. A read-timeout tick is the expected quiet case rather than an +/// error (the convention [`WorldSession::logout`]'s own poll loop uses), so draining runs to the +/// deadline either way. +fn drain(s: &mut WorldSession, window: Duration) { + let deadline = Instant::now() + window; + while Instant::now() < deadline { + if let Ok(ServerPacket::MessageChat(m)) = s.recv() { + if m.chat_type == CHAT_MSG_SYSTEM { + println!(" server says — {}", m.text); + } + } + } +} + +fn main() -> Result<()> { + let o = parse_opts()?; + let mut s = connect(&o)?; + + // ── 1 · wipe ── + // Blocking reads for the whole character-select phase: char_enum/create/delete each loop on + // `recv` until their own reply lands, so a read timeout here surfaces as a hard error. + s.set_read_timeout(None)?; let have = s.char_enum()?; - println!("account {user}: {} existing character(s)", have.len()); - for (class, class_name, race, race_name, gender) in ROSTER { - if have.iter().any(|c| c.class == class) { + println!("account {}: {} existing character(s)", o.user, have.len()); + if o.wipe { + for c in &have { + match s.delete_character(c.guid)? { + messages::CHAR_DELETE_SUCCESS => { + println!(" wiped {} (guid {}, level {})", c.name, c.guid, c.level); + } + other => bail!("deleting {} failed: {other:#x}", c.name), + } + } + } + + // ── 2 · create the nine ── + let have = s.char_enum()?; + for (i, (class, class_name, _, _)) in CLASSES.iter().enumerate() { + let (race, gender) = o.spread[i]; + let (race_name, _) = race_info(race).context("spread holds a known race")?; + if have.iter().any(|c| c.class == *class) { println!(" {class_name}: already present — skipped"); continue; } - let name = format!("{prefix}{class_name}"); + let name = format!("{}{class_name}", o.prefix); let req = messages::CharCreateReq { - name, + name: name.clone(), race, - class, + class: *class, gender, skin: 0, face: 0, @@ -60,23 +249,87 @@ fn main() -> anyhow::Result<()> { hair_color: 0, facial_hair: 0, }; - let name = &req.name; match s.create_character(&req)? { - messages::CHAR_CREATE_SUCCESS => println!(" {class_name}: created {name} ({race_name})"), - messages::CHAR_CREATE_NAME_IN_USE => println!(" {class_name}: name {name} in use — skipped"), - 0x33 => anyhow::bail!( - "{name}: CHAR_CREATE_PVP_TEAMS_VIOLATION — set AllowTwoSide.Accounts = 1 (see module doc)" + messages::CHAR_CREATE_SUCCESS => { + let sex = if gender == 1 { "female" } else { "male" }; + println!(" {class_name}: created {name} ({sex} {race_name})"); + } + messages::CHAR_CREATE_NAME_IN_USE => { + println!(" {class_name}: name {name} already in use — skipped"); + } + 0x33 => bail!( + "{name}: CHAR_CREATE_PVP_TEAMS_VIOLATION — set AllowTwoSide.Accounts = 1 \ + (see module doc)" ), - other => anyhow::bail!("{name}: create failed, result {other:#x}"), + other => bail!("{name}: create failed, result {other:#x}"), } } - println!("final roster:"); + // ── 3 · dress each body ── + // The re-enum is load-bearing, not a formality: `player_login` takes each character's chat + // tongue from the roster it last saw, and vmangos DROPS chat — dot-commands included — spoken in + // a language the character doesn't know. Skip it and every Horde body's commands vanish + // silently (decision 0392). + let roster = s.char_enum()?; + let mut dressed = 0usize; + if o.level.is_some() || o.tier.is_some() || o.home { + for (i, (class, class_name, role, spec)) in CLASSES.iter().enumerate() { + let Some(c) = roster.iter().find(|c| c.class == *class) else { + println!(" {class_name}: not on the account — nothing to dress"); + continue; + }; + let (_, capital) = race_info(o.spread[i].0).context("spread holds a known race")?; + let mut steps = Vec::new(); + if o.reload && dressed == 0 { + // Must run before the first `.character premade gear` — the server's template + // cache predates any DB edit this seeding is meant to pick up (see module doc). + steps.push(".character premade savegear zz-seed-reload".into()); + } + if let Some(l) = o.level { + // Level before gear (a premade template only levels *up*), spells after the level + // they belong to, and the premade spec after `all_myclass` so a real talent tree + // wins over its every-talent state — the order the rig established (decision 0651). + steps.push(format!(".character level {l}")); + steps.push(".learn all_myclass".into()); + } + if let Some(t) = &o.tier { + steps.push(format!(".character premade gear {role}-{t}")); + steps.push(format!(".character premade spec {spec}")); + } + if o.home { + steps.push(format!(".tele {capital}")); + } + + println!(" {}: entering world…", c.name); + s.player_login(c.guid)?; + s.set_active_mover(c.guid)?; + s.complete_cinematic()?; // a freshly created body is shown the intro cinematic + s.set_read_timeout(Some(Duration::from_millis(250)))?; + drain(&mut s, Duration::from_secs(2)); // a command into a half-built session is dropped + for step in &steps { + println!(" {step}"); + s.send_chat(step)?; + drain(&mut s, Duration::from_millis(1200)); + } + s.logout(Duration::from_secs(25)) + .with_context(|| format!("logging {} back out to character select", c.name))?; + s.set_read_timeout(None)?; + dressed += 1; + } + } + + // ── 4 · report ── + println!("final roster on {}:", o.user); for c in s.char_enum()? { + let (race_name, _) = race_info(c.race).unwrap_or(("?", "?")); println!( - " {} — class {} race {} gender {} level {}", - c.name, c.class, c.race, c.gender, c.level + " {:<12} level {:<3} {} {}", + c.name, + c.level, + if c.gender == 1 { "female" } else { "male" }, + race_name, ); } + println!("dressed {dressed} character(s)"); Ok(()) } diff --git a/crates/benilla/assets/shaders/wow_model.wgsl b/crates/benilla/assets/shaders/wow_model.wgsl index 8683472b..52acf58a 100644 --- a/crates/benilla/assets/shaders/wow_model.wgsl +++ b/crates/benilla/assets/shaders/wow_model.wgsl @@ -599,23 +599,28 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp // WMO MODD prop — the 2.5 site is one a MODD prop never reaches, §8b), <0.5 = statically // MCSH-shadowed (0.5). The ramp runs in animator units and the COMMIT clamps. // - // **The per-instance GAIN — half untied (0809).** The reference's own traces show a player + - // three NPCs committing their sun at gain EXACTLY 1.0, never 2.5 or 0.5, while the same frames' - // ADT doodads show the full 0.5/0.85/1.0/1.874/2.5 spread (wow-re - // `trace-forensics-northshire-d3d` §5). wow-re settled the mechanism in - // `unit-mcsh-shadow-target.md`: the 2.5/0.5 node TARGET is byte-shared between a unit and an ADT - // static (single 2.5 site `69e4ad`, shared MCSH sampler `0x69b350`), but DELIVERY splits at - // `0x672a20` — with `[model+0x3c0]==0` the null fallback commits the raw day/night pair with NO - // intensity multiply anywhere: hardwired ×1.0, position-independent, never sampling MCSH. A - // unit's node is born UNREGISTERED (`0x670db0`'s birth-register gate `670fca` is skipped for the - // model-arg-0 spawn), so that is the normal outdoor case. `entity_shade` models exactly that, - // pinning a unit's tag byte to the day/night point CPU-side — units no longer reach the clamp - // below to land on ×1.0, they are already there. + // **`min(I, 1)` is OURS, and it is the one unfaithful term in this lane (0803 §3, 0814, 0821).** + // The reference does not cap the gain. On the VS/SH lane — the default config, and the lane this + // shader IS — `0x71c4e0` bakes `[node+0xa4]` straight into the SH moments with no clamp; on the + // FFP lane `0x71c730`→`0x71ca80` clamps the **product** `D × I` (by max-channel, preserving hue), + // never the multiplier. So the cap below is a benilla choice, and it costs us twice: // - // What is left of the knot falls only on statics: `min(I,1)` puts an ADT doodad on lit ground at - // ×1.0 where the reference gives it ×2.5, so **doodads still read dimmer than the reference**. - // Cutting it is safe now that units are off the chain; before 0809 it would have landed every - // character at ×2.5, precisely the amplitude 0410 rejected. + // 1. **Brightness.** A lit ADT doodad commits ×1.0 where the reference gives ×2.5, so doodads + // and characters in sun read dimmer than the reference. Still open — lifting it pushes a + // sun-facing surface well past 1.0 (with an over-gamut sun, into green as well), which is a + // world-wide look change and the director's call, not one to self-grade. + // 2. **Timing — fixed CPU-side (0821).** Because the cap sits on the multiplier, every target + // from 2.5 down to 1.0 renders identically, so a unit ramping 2.5 → 0.5 spent its first + // 0.45 s (75 % of the chase) invisibly pinned at 1.0 and then dropped in 0.15 s. It read as a + // dead pause then a snap — what the director reported walking sun → shade. + // `entity_shade::LIT_T` now aims the LIT target at the value this cap can actually show + // (1.0), so the chase moves only through visible range, at the reference's own + // 3.3333 intensity-units/s. The cap is a backstop here, not the thing the ramp fights. + // + // Two faces of one bug, and they unwind together: **cut `min(I, 1)` and `LIT_T` goes back to 0.0** + // so the full 2.5 → 0.5 sweep becomes visible on its own. Do not cut one alone. (Units are on this + // chain again — 0809's flat ×1.0 pin was wrong and 0814 reverted it; the null fallback that + // motivated it is real, but it is a lifecycle state we do not model.) let inst_shade = select(f32((fade_tag >> 6u) & 0xffu) / 255.0, 0.0, interior_prop); let mat_shade = select(0.0, 1.0, m.sun_scale.x < 0.5); let shade_t = max(mat_shade, inst_shade); diff --git a/crates/benilla/src/capture/mod.rs b/crates/benilla/src/capture/mod.rs index e37ef85c..a128370d 100644 --- a/crates/benilla/src/capture/mod.rs +++ b/crates/benilla/src/capture/mod.rs @@ -1,8 +1,8 @@ //! Deterministic capture mode — the machine half of the Phase-5 visual A/B harness (decision 0008). //! //! With `$WOW_CAPTURE=` set, the app boots server-less (net disabled in `main`), pins the -//! game-clock and the camera to a named viewpoint, waits for terrain streaming to settle, writes one -//! PNG of the primary window to `$WOW_CAPTURE_OUT`, and exits. The render rework (decision 0008) is the +//! game-clock and the camera to a named viewpoint, waits for the rendered image to stop changing, +//! writes one PNG of the primary window to `$WOW_CAPTURE_OUT`, and exits. The render rework (decision 0008) is the //! single riskiest change in the architecture — it can regress the whole look at once — so it goes //! behind this harness: baselines are captured on the *current* pipeline, then every rework step is //! diffed against them by the `benilla-visual` tool, catching regressions by machine before the @@ -11,12 +11,30 @@ //! Captures contain Blizzard-derived imagery (rendered terrain/models), so they live under the //! gitignored `target/visual/` and are never committed. //! -//! ## Why the clock is frozen (decision 0723) +//! ## What makes a capture reproducible — two mechanisms, and that is all //! "Deterministic" is the whole claim: a golden diff is evidence only if two runs of one unchanged -//! build agree. Pinning the camera, the game clock and the clutter seed is not enough, because the -//! sims integrate in *seconds* while the settle counts *frames* — see [`CAPTURE_FRAME_DT`] and -//! [`hold_clock`], which together make a capture a pure function of the build. `scripts/visual.sh -//! selfcheck` is the tripwire that keeps it that way. +//! build agree. Pinning the camera, the game clock and the clutter seed is not enough. +//! +//! 1. **The shutter waits for the IMAGE to stop changing** ([`STABLE_FRAMES`], [`FrameWatch`], +//! decision 0815). The scene is built when the rendered frame stops moving — measured, by reading +//! the framebuffer back and comparing bytes. This replaced four *proxies* for the same question +//! (tile residency, `scene_ready`, outstanding placements, and a world-entity-count quiescence +//! counter), each of which was partial by construction and each of which had been added after the +//! previous one was caught missing something. Streaming, pipeline warm-up, late placements, late +//! M2 loads and the loading-screen fade are all *visible in the frame* — or they do not affect the +//! shot, in which case they were never the harness's business. +//! 2. **The game clock is frozen** ([`CAPTURE_FRAME_DT`], [`hold_clock`], decision 0723), because the +//! sims integrate in *seconds* while the harness counts *frames*. Held while the scene is being +//! built, then released for exactly [`age_frames`] fixed steps, so the shot's sim age is the same +//! duration on any machine. +//! +//! **Neither one fixes the known residual flake, and no gate here can.** `scripts/visual.sh +//! selfcheck` still finds a small number of scenarios that land in one of exactly two states. The +//! cause is not timing: it is that our draw order among *coplanar, equal-depth* batches follows spawn +//! order, and spawn order varies because asset loads complete on a thread pool (0723's Open section, +//! reached again by 0810 and 0815). Both states are perfectly stable — so waiting longer, waiting for +//! quiescence, or waiting for pixel stability cannot help. It is a renderer defect that the harness +//! correctly *reports*; do not try to gate it away here. Read 0815 before touching this file's phases. //! //! ## Running one capture by hand //! **Run through Cargo — never the built binary directly:** @@ -38,7 +56,7 @@ use std::path::Path; use std::time::Duration; use bevy::prelude::*; -use bevy::render::view::screenshot::{save_to_disk, Capturing, Screenshot}; +use bevy::render::view::screenshot::{save_to_disk, Capturing, Screenshot, ScreenshotCaptured}; use bevy::time::TimeUpdateStrategy; use benilla_assets::coords::wow_to_bevy; @@ -193,25 +211,33 @@ pub(crate) fn print_scenario_names() { #[derive(Resource)] pub(crate) struct CaptureMode; -/// Frames the scene must stay fully streamed (residency reached) before the shot. This window is also -/// what warms the renderer: wgpu specialises the terrain/model/sky pipelines on their first draw, and -/// until they are ready Bevy draws only the clear colour — so a too-short settle photographs a uniform -/// fog-blue frame. 150 frames (~2.5 s) is generous margin for pipeline warm-up after the tiles spawn, -/// and also covers clutter/water/lighting settling and the loading-screen fade. (Residency reporting -/// alone is not enough: tile *entities* exist well before their pipelines are warm.) -const SETTLE_FRAMES: u32 = 150; +/// Consecutive byte-identical framebuffer readbacks that mean "the scene is built" (decision 0815). +/// +/// This is the harness's ONE residency test, and it is a measurement rather than a proxy: whatever is +/// still arriving — a tile, a placement, an M2 that just finished loading, a pipeline wgpu has not +/// specialised yet (until it is warm Bevy draws only the clear colour, which is why a blind settle +/// could photograph a uniform fog-blue frame) — either changes the image, and resets this counter, or +/// does not affect the shot at all. +/// +/// 30 frames (~0.5 s of held-clock frames) is the same confidence window the entity-count counter it +/// replaced used. Raise it with `$WOW_CAPTURE_STABLE` for a scene with a very slow tail; this is the +/// knob that used to be `WOW_CAPTURE_SETTLE`, and unlike that one it buys *evidence* rather than a +/// guess at a duration. +const STABLE_FRAMES: u32 = 30; -/// The effective settle window: `$WOW_CAPTURE_SETTLE` overrides [`SETTLE_FRAMES`] for scenes whose -/// assets take longer to arrive. This buys **real** frames (loading, pipeline warm-up, spawning) — -/// for *sim* time, which is what a precipitation column or a particle pool needs, raise -/// [`age_frames`] instead; the two were the same knob until decision 0723 split them. -fn settle_frames() -> u32 { - std::env::var("WOW_CAPTURE_SETTLE") +/// The effective stability window — see [`STABLE_FRAMES`]. +fn stable_frames() -> u32 { + std::env::var("WOW_CAPTURE_STABLE") .ok() .and_then(|s| s.parse().ok()) - .unwrap_or(SETTLE_FRAMES) + .unwrap_or(STABLE_FRAMES) } +/// Hard cap on held frames spent waiting for [`STABLE_FRAMES`], so a scene whose image never settles +/// (a UI fixture with a live animation) shoots anyway rather than hanging — forfeiting the guarantee, +/// loudly. Deliberately generous: reaching it means the shot is not reproducible. +const BUILD_CAP_FRAMES: u32 = 1800; + /// Steps of [`CAPTURE_FRAME_DT`] the sims run, clock released, once the scene is built and /// quiescent — the shot's *sim age*. 150 = 2.5 s: past the 2 s spawn appear-fade /// (`model_fade::APPEAR_FADE_SECS`) and long past a flame pool's particle lifetime, so the scene is @@ -230,19 +256,11 @@ fn age_frames() -> u32 { .unwrap_or(AGE_FRAMES) } -/// Consecutive frames the world entity count must hold before [`Phase::Settling`] calls the scene -/// quiescent. Residency is not enough on its own: it counts terrain *tiles*, while a tile's doodads -/// and WMOs only get *registered* there and spawn later still, as their M2s finish loading under -/// `terrain_stream`'s wall-clock `SPAWN_BUDGET`. Every one of those carries the torches and braziers -/// whose flames this is all about, so a capture that starts its clock at residency still ages each -/// flame from whichever frame its model happened to land on. Waiting for the count to stop moving -/// is the generic form of "everything that was going to spawn has spawned". -const QUIESCE_FRAMES: u32 = 30; /// The frozen capture clock's frame step: every capture frame advances the game clock by exactly /// this, whatever the frame really cost. Anything integrated in *seconds* — particle pools, ribbon /// trails, animation, weather, fades — therefore reaches the shutter in the same state every run. /// -/// Without it the harness is only **frame**-pinned: [`settle_frames`] counts frames while the sims +/// Without it the harness is only **frame**-pinned: the build window counts frames while the sims /// advance on `time.delta_secs()`, i.e. real wall-clock, so two runs spend different real time per /// frame (streaming, pipeline warm-up, whatever else the machine is doing) and photograph the flames /// in different places. Measured on this tree before the change — the golden six captured twice from @@ -262,15 +280,47 @@ const EXIT_GRACE_FRAMES: u32 = 3; /// Frames the perf probe discards after uncapping vsync (present-mode switch + pipeline settle). const PROBE_WARMUP_FRAMES: u32 = 60; +/// Marks a framebuffer readback taken to test scene stability, so the real shot's `Capturing` wait +/// (see [`Phase::Saving`]) cannot mistake one for the screenshot it is waiting on. +#[derive(Component)] +struct StabilityShot; + +/// The image-stability tracker: the previous framebuffer, and how many consecutive readbacks have +/// matched it byte for byte (decision 0815). Bytes, not a hash — the buffer is tens of MB at most, +/// a `memcmp` is free next to the readback itself, and an exact compare needs no collision argument. +#[derive(Resource, Default)] +struct FrameWatch { + /// The last framebuffer read back, or `None` before the first one lands. + prev: Option>, + /// Consecutive readbacks identical to their predecessor. Reset to 0 by any change. + stable: u32, + /// A readback is outstanding. Only ever one at a time, so `stable` counts *distinct* frames + /// rather than however many requests the GPU happened to retire together. + in_flight: bool, +} + +/// Observer for a stability readback: compare against the previous frame, count, and free the slot. +fn watch_frame(shot: On, mut watch: ResMut) { + watch.in_flight = false; + let Some(bytes) = shot.image.data.as_ref() else { + return; // no readback payload (a zero-sized or unavailable target) — treat as no evidence + }; + watch.stable = if watch.prev.as_deref() == Some(bytes.as_slice()) { + watch.stable + 1 + } else { + 0 + }; + watch.prev = Some(bytes.clone()); +} + /// Capture lifecycle. Advances one step per frame in [`drive_capture`]. #[derive(Clone, Copy)] enum Phase { - /// Waiting for terrain streaming to report the scene fully resident. - Streaming, - /// Resident; counting settle frames — and waiting for the scene to stop growing — before the - /// shot. The game clock is held here (see [`hold_clock`]). - Settling(u32), - /// Scene built and quiescent, clock released: running exactly [`age_frames`] steps of + /// Clock held; waiting for the rendered image to stop changing ([`STABLE_FRAMES`]). This one + /// phase replaced `Streaming` + `Settling` and the four world-state proxies they waited on — see + /// this module's header. + Building(u32), + /// Scene built, clock released: running exactly [`age_frames`] steps of /// [`CAPTURE_FRAME_DT`] so every second-driven thing is at a known, machine-independent age. Aging(u32), /// fxview only: scene settled, fixture armed; waiting for the effect to attach and run its @@ -300,13 +350,6 @@ struct CaptureCtx { /// The frozen clock is in force ([`CAPTURE_FRAME_DT`]) — always, except under a perf probe, /// whose entire measurement *is* the real frame cost. frozen_clock: bool, - /// World entity count at the previous frame, and how many consecutive frames it has held — - /// the scene-quiescence test [`Phase::Settling`] waits on. See [`QUIESCE_FRAMES`]. - last_entities: usize, - stable_frames: u32, - /// Frames spent in [`Phase::Aging`] including restarts — the backstop that shoots anyway if a - /// scene never goes quiescent, so the harness cannot hang on one. - aging_total: u32, /// Probe samples (frame ms). probe_samples: Vec, /// Process CPU seconds at the first sampled frame — the window baseline for the probe line's @@ -527,16 +570,14 @@ impl Plugin for CapturePlugin { .add_systems(Startup, hold_clock); } app.insert_resource(CaptureMode) + .init_resource::() .insert_resource(CaptureCtx { scenario, out, - phase: Phase::Streaming, + phase: Phase::Building(0), ui_seeded: false, probe_frames, frozen_clock, - last_entities: 0, - stable_frames: 0, - aging_total: 0, probe_samples: Vec::new(), probe_cpu_start: None, }) @@ -615,15 +656,16 @@ fn pin_scene( } } -/// Hold the game clock at zero until the scene is resident; [`drive_capture`] releases it. +/// Hold the game clock at zero until the image stops changing; [`drive_capture`] releases it. /// /// The fixed frame step alone is not enough, because it only makes each frame the same *size* — a /// torch flame still ages from the frame its tile happened to spawn on, and tiles spawn under a /// wall-clock budget (`terrain_stream`'s `SPAWN_BUDGET`) against asset I/O that finishes on a -/// different frame every run. Held through streaming, every emitter in the scene is zero-age at -/// residency, so at the shutter each has run exactly `settle_frames()` steps no matter when it -/// appeared. Frame-driven work (streaming itself, the spawn budget, the screenshot save) is on the -/// real clock and runs on regardless. +/// different frame every run. Held through the whole build, every emitter in the scene is zero-age +/// when the clock is released, so at the shutter each has run exactly [`age_frames`] steps no matter +/// when it appeared. Frame-driven work (streaming itself, the spawn budget, the screenshot save) is +/// on the real clock and runs on regardless — which is what lets the scene finish arriving while the +/// sims stand still. fn hold_clock(mut clock: ResMut>) { clock.pause(); } @@ -632,8 +674,10 @@ fn hold_clock(mut clock: ResMut>) { #[allow(clippy::too_many_arguments)] fn drive_capture( mut ctx: ResMut, - progress: Res, - capturing: Query<(), With>, + mut watch: ResMut, + // Disjoint from the stability readbacks by the marker: without it, one of those in flight would + // satisfy this wait and the harness could call the real shot done before it was written. + capturing: Query<(), (With, Without)>, mut commands: Commands, mut exit: MessageWriter, time: Res>, @@ -652,44 +696,72 @@ fn drive_capture( game_time: Res