deaths dissolve properly and item effects reach every pane

- elementals no longer freeze mid-air as corpses: a one-shot death band's clock now clamps instead of wrapping, so the authored dissolve plays out
- an equipped item's sparkles, cards and glows reach the character-select booth and the character sheet's model pane, and a tint reaches a unit's attached items too
- unit lighting is back on the terrain-shade chain, and light ramps chase a clamped target so walking from sun into shade actually ramps
- test accounts gained a full-roster seeding verb (every class levelled, geared and specced in one command)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-30 19:41:05 +07:00
co-authored by Claude Fable 5
parent 3fbfebd604
commit 9562380a64
23 changed files with 2234 additions and 742 deletions
@@ -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"),
+35 -32
View File
@@ -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<Option<ScalarAnim>>,
/// 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<bool>,
}
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<bool>,
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<usize>, 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<usize>, 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<SeqSlot> {
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<SeqSlot> {
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.
+41
View File
@@ -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"
);
}
}
+38 -16
View File
@@ -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<V> {
/// Loop period (secs): the global sequence's duration, or the sequence band's length.
@@ -69,23 +72,30 @@ pub struct KeyAnim<V> {
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<V: Lerp> KeyAnim<V> {
/// 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<T: Copy, V: Lerp + PartialEq>(
}
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<T: Copy, V: Lerp + PartialEq>(
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<T: Copy, V: Lerp + PartialEq>(
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,
})
}
@@ -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<SeqSlot> = 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(),
+114 -2
View File
@@ -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<ScalarAnim> {
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<ScalarAnim> {
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<usize> = 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)],
})
}
@@ -72,7 +72,12 @@ mod tests {
}
fn bake(t: &M2Vec3Track, gseq: &[u32], seq0: Option<(u32, u32)>) -> Option<UvAnim> {
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)
}
+7 -12
View File
@@ -683,29 +683,25 @@ pub fn parse_m2_particle_emitters(bytes: &[u8]) -> Result<Vec<ParticleEmitterDef
// flags @ +0x10, bit 0 CLEAR = loops) — the addressing unit the per-sequence timing bake needs,
// the same walk as `m2_batches`. A sequence-less model gets one synthetic whole-timeline slot
// so an authored track still bakes (raw timestamps, the pre-sequence degenerate).
let (mut seq_slots, mut looping) = {
let mut seq_slots: Vec<SeqSlot> = {
let (n, o) = (le_u32(bytes, 0x1c) as usize, le_u32(bytes, 0x20) as usize);
(0..n)
.map_while(|i| {
let e = o + i * 0x44;
(e + 0x44 <= bytes.len()).then(|| {
(
SeqSlot {
index: i,
band: (le_u32(bytes, e + 0x04), le_u32(bytes, e + 0x08)),
},
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<Vec<ParticleEmitterDef
&read_raw_track(bytes, e + 0xdc, 4, le_f32),
&read_raw_track(bytes, e + 0x1dc, 1, |b, o| f32::from(b[o] != 0)),
&seq_slots,
looping.clone(),
&gseq,
),
area_length: track_first(bytes, e + 0xf8, 0.0),
+298 -45
View File
@@ -1,58 +1,247 @@
//! Dev helper: seed an account with one character of every vanilla class (`CMSG_CHAR_CREATE` over
//! a live session). Idempotent by **class** — classes the account already has are skipped — so it
//! tops an account up to the full nine. Race per class is a fixed spread that covers all eight
//! races with valid 1.12 pairs; appearance fields ride as zeros (the first options).
//!
//! Both factions on one account needs `AllowTwoSide.Accounts = 1` in mangosd.conf — otherwise the
//! Horde creates (Shaman lives Horde-side) come back `CHAR_CREATE_PVP_TEAMS_VIOLATION` (0x33).
//! Dev helper: put an account into the standard **full class set** — one character of every vanilla
//! class, optionally levelled, geared, specced and parked in its own capital. Headless: a bare
//! `WorldSession`, no Bevy app and no window, so one process dresses a whole nine-body account.
//!
//! ```text
//! cargo run -p benilla-protocol --example seed_classes -- <user> <pass> <name-prefix>
//! # e.g. … -- one pone One → Onepaladin, Onehunter, …
//! cargo run -p benilla-protocol --example seed_classes -- <user> <pass> <Prefix> [flags]
//! --wipe delete EVERY existing character on the account first (see the warning below)
//! --level <n> `.character level n` + `.learn all_myclass` (all class spells + talents)
//! --tier <t> `.character premade gear <role>-<t>` + `.character premade spec <spec>`;
//! `<t>` 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 <a|b> which race spread to use (default `a`)
//! --reload-templates make the server re-read the premade tables before dressing (see below)
//! --host <h> 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 <name>` 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<String> = std::env::args().collect();
let [user, pass, prefix] = &args[1..] else {
anyhow::bail!("usage: seed_classes <user> <pass> <name-prefix>");
};
/// `(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<u8>,
tier: Option<String>,
home: bool,
reload: bool,
spread: [(u8, u8); 9],
}
const USAGE: &str = "usage: seed_classes <user> <pass> <Prefix> [--wipe] [--level <n>] \
[--tier <phase6-bis|preraid-bis|r14>] [--home] [--spread <a|b>] \
[--reload-templates] [--host <h>]";
fn parse_opts() -> Result<Opts> {
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<WorldSession> {
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(())
}
+21 -16
View File
@@ -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);
+168 -165
View File
@@ -1,8 +1,8 @@
//! Deterministic capture mode — the machine half of the Phase-5 visual A/B harness (decision 0008).
//!
//! With `$WOW_CAPTURE=<scenario>` 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<Vec<u8>>,
/// 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<ScreenshotCaptured>, mut watch: ResMut<FrameWatch>) {
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<f32>,
/// 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::<FrameWatch>()
.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<Time<Virtual>>) {
clock.pause();
}
@@ -632,8 +674,10 @@ fn hold_clock(mut clock: ResMut<Time<Virtual>>) {
#[allow(clippy::too_many_arguments)]
fn drive_capture(
mut ctx: ResMut<CaptureCtx>,
progress: Res<WorldLoadProgress>,
capturing: Query<(), With<Capturing>>,
mut watch: ResMut<FrameWatch>,
// 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<Capturing>, Without<StabilityShot>)>,
mut commands: Commands,
mut exit: MessageWriter<AppExit>,
time: Res<Time<bevy::time::Real>>,
@@ -652,44 +696,72 @@ fn drive_capture(
game_time: Res<Time>,
mut clock: ResMut<Time<Virtual>>,
) {
// Residency, on the STRONGEST term the streamer publishes (0810). `ready == total` counts desired
// tiles spawned, and `focus_resident` only that the tile under the focus exists — neither says the
// tile's *placements* are up. `scene_ready` does exactly that (the focus tile and its 8 neighbours
// spawned with every WMO/doodad/prop they reference — `loading_screen`, 0737), and
// `placements_pending` is its outstanding count. The entity-count quiescence test below cannot
// cover the gap either: a placement that has not spawned yet moves no count, so the window can
// elapse while one is still arriving.
//
// **This is a gate correction, NOT the fix for the capture flake** (`creature-{sun,shade}-rear`
// still flip under `visual.sh selfcheck` at MAE ~4.27 with this in). 0810 has the open defect and,
// more importantly, the protocol: consecutive captures of ONE scenario share a warm asset cache
// and converge on a single state, so they always look deterministic. Test with a full-sweep
// selfcheck, never with repetition — that mistake produced three wrong diagnoses in a row.
let resident = progress.total > 0
&& progress.ready == progress.total
&& progress.focus_resident
&& progress.scene_ready
&& progress.placements_pending == 0;
// Both fixture viewers (fxview / waterfx) share the settle→arm→age→shoot flow; only the
// Both fixture viewers (fxview / waterfx) share the build→arm→age→shoot flow; only the
// requested age differs.
let fixture_age = fx_req
.as_ref()
.map(|r| r.age)
.or(wfx_req.as_ref().map(|r| r.age));
// Scene quiescence: how long the world entity count has held still (see [`QUIESCE_FRAMES`]).
let live = entities.iter().len();
ctx.stable_frames = if live == ctx.last_entities {
ctx.stable_frames + 1
} else {
0
};
ctx.last_entities = live;
ctx.phase = match ctx.phase {
Phase::Streaming => {
if resident {
Phase::Settling(0)
Phase::Building(n) => {
// One readback at a time, requested only here — so a leftover can never be mistaken for
// the real shot (the `StabilityShot` marker keeps `Saving`'s wait disjoint too).
if !watch.in_flight {
watch.in_flight = true;
commands
.spawn((Screenshot::primary_window(), StabilityShot))
.observe(watch_frame);
}
let capped = n + 1 >= BUILD_CAP_FRAMES;
if capped {
warn!(
"capture: the image never settled in {} frames ({} stable); the shot is not \
reproducible",
n + 1,
watch.stable,
);
}
if watch.stable < stable_frames() && !capped {
Phase::Building(n + 1)
} else if fixture_age.is_some() {
if let Some(state) = fx_state.as_deref_mut() {
state.armed = true; // scene ready — the fixture spawns now, age clock clean
}
Phase::FxAging
} else if ctx.probe_frames > 0 {
// Probe: uncap presentation so we measure true frame cost, not the vsync ceiling.
// `$WOW_PROBE_VSYNC=1` keeps vsync ON instead — the probe then measures the PRESENT
// ceiling itself (what fps the display sync actually grants this window), the
// instrument for "what is the vsync cap right now".
let keep_vsync = std::env::var("WOW_PROBE_VSYNC").as_deref() == Ok("1");
if !keep_vsync {
if let Ok(mut w) = windows.single_mut() {
w.present_mode = probe_uncap_mode();
}
}
info!(
"probe: image settled; vsync {}, warming {PROBE_WARMUP_FRAMES} frames",
if keep_vsync { "KEPT ON" } else { "off" }
);
Phase::ProbeWarmup(0)
} else if ctx.frozen_clock {
// Clock released here: the sims now run exactly `age_frames()` fixed steps, so the
// shot's sim age is the same on any machine (decision 0723).
info!(
"capture: image settled after {} frames, aging {}",
n + 1,
age_frames()
);
Phase::Aging(0)
} else {
Phase::Streaming
commands
.spawn(Screenshot::primary_window())
.observe(save_to_disk(ctx.out.clone()));
info!("capture: image settled, writing {}", ctx.out);
Phase::Saving {
frames: 0,
seen: false,
}
}
}
Phase::FxAging => {
@@ -714,81 +786,13 @@ fn drive_capture(
Phase::FxAging
}
}
Phase::Settling(n) => {
// Settled = the window elapsed AND the scene stopped growing. The quiescence half has
// a hard cap so a scene that never stops churning (a UI fixture with a live animation)
// still shoots rather than hanging — it just forfeits the guarantee, loudly.
let capped = n + 1 >= settle_frames() * 3;
if capped && ctx.stable_frames < QUIESCE_FRAMES {
warn!(
"capture: scene never went quiescent ({} frames); the shot is not reproducible",
n + 1
);
}
let settled =
n + 1 >= settle_frames() && (ctx.stable_frames >= QUIESCE_FRAMES || capped);
if !resident {
Phase::Streaming // a late tile dropped out of residency — wait for it again
} else if settled && fixture_age.is_some() {
if let Some(state) = fx_state.as_deref_mut() {
state.armed = true; // scene ready — the fixture spawns now, age clock clean
}
Phase::FxAging
} else if settled {
if ctx.probe_frames > 0 {
// Probe: uncap presentation so we measure true frame cost, not the vsync
// ceiling. `$WOW_PROBE_VSYNC=1` keeps vsync ON instead — the probe then
// measures the PRESENT ceiling itself (what fps the display sync actually
// grants this window), the instrument for "what is the vsync cap right now".
let keep_vsync = std::env::var("WOW_PROBE_VSYNC").as_deref() == Ok("1");
if !keep_vsync {
if let Ok(mut w) = windows.single_mut() {
w.present_mode = probe_uncap_mode();
}
}
info!(
"probe: settled; vsync {}, warming {PROBE_WARMUP_FRAMES} frames",
if keep_vsync { "KEPT ON" } else { "off" }
);
Phase::ProbeWarmup(0)
} else if ctx.frozen_clock {
// Clock released here: the sims now run exactly `age_frames()` fixed steps, so
// the shot's sim age is the same on any machine (decision 0723).
info!("capture: scene quiescent, aging {} frames", age_frames());
Phase::Aging(0)
} else {
commands
.spawn(Screenshot::primary_window())
.observe(save_to_disk(ctx.out.clone()));
info!("capture: scene settled, writing {}", ctx.out);
Phase::Saving {
frames: 0,
seen: false,
}
}
} else {
Phase::Settling(n + 1)
}
}
Phase::Aging(n) => {
// A straggler landed (a model whose M2 finished loading after the scene looked
// quiescent). The clock re-holds below and the window restarts, so that emitter and
// every older one still reach the shutter having run exactly `age_frames()` steps.
ctx.aging_total += 1;
let give_up = ctx.aging_total >= age_frames() * 4;
if give_up && ctx.stable_frames < QUIESCE_FRAMES {
warn!(
"capture: scene still churning after {} aging frames; the shot is not \
reproducible",
ctx.aging_total
);
}
if ctx.stable_frames < QUIESCE_FRAMES && !give_up {
if n > 0 {
info!("capture: scene moved during aging at frame {n}; restarting the window");
}
Phase::Aging(0)
} else if n + 1 >= age_frames() {
// No churn-restart here. The old one re-held the clock and restarted this window if the
// world entity count moved — and 0723 measured that it **never fired**, because the
// count-quiescence gate it shared a threshold with had already passed. What replaced it
// is upstream and stronger: the image itself stopped changing before the clock was
// released, so a straggler that would have mattered was already waited out (0815).
if n + 1 >= age_frames() {
commands
.spawn(Screenshot::primary_window())
.observe(save_to_disk(ctx.out.clone()));
@@ -914,12 +918,11 @@ fn drive_capture(
// built (see [`hold_clock`]), running from the moment it is quiescent. Re-held if a late tile
// drops the scene back out of residency, so the restarted settle is the same settle.
if ctx.frozen_clock {
// The invariant, in one line: **the clock runs only while the world is quiescent.** Held
// while the scene is still being built, and re-held the moment anything spawns, so no
// effect can ever age from the frame its model happened to arrive on.
// The invariant, in one line: **the clock runs only once the image has stopped changing.**
// Held while the scene is still being built, so no effect can ever age from the frame its
// model happened to arrive on.
let held = match ctx.phase {
Phase::Streaming | Phase::Settling(_) => true,
Phase::Aging(_) => ctx.stable_frames < QUIESCE_FRAMES,
Phase::Building(_) => true,
// Shutter open — hold the sims still. The screenshot is *requested* on one frame but
// the render world may serve it a frame or two later (pipelined rendering), and with
// the clock still running that is a one-step difference in every particle pool: the
+152 -148
View File
@@ -140,45 +140,29 @@ pub(super) const HOUSE_EYE: [f32; 3] = [-9439.1, 71.2, 68.0];
pub(super) const MAP_AZEROTH: u32 = 0;
pub(super) const MAP_KALIMDOR: u32 = 1;
/// THE golden baseline: six spots FRAMED BY THE DIRECTOR in the live client ([`/shot`], the
/// numbers below verbatim from their `~/.benilla/shots.txt`) — the Northshire overlook toward the
/// Abbey, a Stormwind canal view, Elwynn water, the Lion's Pride Inn common room, a Felwood hollow,
/// and an Elwynn fence lying across the sun's shadow line — each at noon and at night, except the
/// inn (which the clock barely reaches) and the fence, whose DAY cell keeps the director's own
/// 10:24 because its subject is where the shadow line falls; see both below.
/// THE golden baseline: **three spots FRAMED BY THE DIRECTOR, each at noon and at night — six
/// captures, and that is the whole sweep.** A Stormwind canal view, Elwynn water, and a Felwood
/// hollow on Kalimdor. The numbers below are verbatim from their `~/.benilla/shots.txt` ([`/shot`]).
///
/// Ten landscape shots of maximal mutual difference. The old set was thirty, and the director judged the
/// design bad: low-value repetition (four compass looks at one farmhouse, five frames from one
/// Northshire camera, sixteen UI windows over the same backdrop), each one a real window popping
/// open on their screen while they worked. The law is **a few spots chosen with the director, times
/// a couple of day times**; everything else lives in [`ON_DEMAND`], invocable by name but out of the
/// blessed sweep. (Decision 0632; ported from the sibling `substrate` fork's `notes/0025`, where the
/// redesign was made and where it caught a real determinism bug — see `liquid::animate_liquid` — on
/// its first run.)
/// **Held at six on purpose (decision 0817).** 0632 cut a thirty-shot set down to six for exactly
/// this reason and stated the law — *a few spots chosen with the director, times a couple of day
/// times* — and then the set grew back to **twenty-one** one well-argued addition at a time: an
/// interior, a second continent, a shadow-line fence, four creature cells, four chest cells, two
/// indoor creature cells. Every one had a real case. The aggregate was 42 windows popping open on the
/// director's screen per `selfcheck`, several of them not reproducible, and a standing invitation to
/// spend sessions chasing harness ghosts — which is what happened (0810, 0815). The director's call:
/// *"get rid of that shit, making more problems than it's helping… just keep the simple stuff we had
/// before, 3 frames 2 times of day."*
///
/// The first six were all EXTERIOR, all Azeroth, all Elwynn/Stormwind. The two spots added in
/// decision 0743 are the two things that set of six could not photograph at all: the inside of a
/// building (portal culling, the INT vertex bake, MOCV self-illum, MOLT point pools, props) and a
/// second continent (a different tileset, fog palette, liquid type and horizon). Both were framed
/// by the director after the agent's own framings of the same two subjects were judged bad — which
/// is 0632's rule working exactly as intended.
/// So growth is now the thing to resist, not to justify. **Nothing is deleted** — every evicted
/// viewpoint is still capturable by name from [`ON_DEMAND`], which is where a subject being worked on
/// belongs. A shot earns a place here only by the director putting it here.
///
/// The Northshire overlook toward the **Abbey** was 0632's shot 1 and is the one this set drops, at
/// the director's instruction, in favour of the Felwood hollow — which covers strictly more: a second
/// continent, its own tileset, fog palette, liquid type and WDL horizon, none of which the Elwynn
/// spots can photograph at all.
pub(super) const SCENARIOS: &[Scenario] = &[
Scenario {
name: "overlook-noon",
map: MAP_AZEROTH,
eye: OVERLOOK_EYE,
look: OVERLOOK_LOOK,
minute: 720,
ui: None,
},
Scenario {
name: "overlook-night",
map: MAP_AZEROTH,
eye: OVERLOOK_EYE,
look: OVERLOOK_LOOK,
minute: 0,
ui: None,
},
Scenario {
name: "canal-noon",
map: MAP_AZEROTH,
@@ -211,19 +195,6 @@ pub(super) const SCENARIOS: &[Scenario] = &[
minute: 0,
ui: None,
},
Scenario {
name: "inn-noon",
map: MAP_AZEROTH,
eye: INN_EYE,
look: INN_LOOK,
minute: 720,
ui: None,
},
// No `inn-night`: MEASURED at MAE 0.198 against `inn-noon`, 0.82 % of pixels — the room is lit
// by its hearth and candles, and the clock barely reaches it (the outdoor spots' own noon/night
// pairs differ by MAE 38-83 across ~100 % of the frame). A second window for 0.8 % of one shot
// is the low-value repetition 0632 exists to prevent, and noon is the richer of the two anyway:
// it has the daylight window AND the fire. Re-add it the day the interior light path earns it.
Scenario {
name: "felwood-noon",
map: MAP_KALIMDOR,
@@ -240,6 +211,138 @@ pub(super) const SCENARIOS: &[Scenario] = &[
minute: 0,
ui: None,
},
];
/// Director shot 1 — the Northshire overlook, north-east of the Abbey looking at it: terrain,
/// trees, the Abbey WMO, stained glass, props.
pub(super) const OVERLOOK_EYE: [f32; 3] = [-8955.0, -98.5, 91.1];
pub(super) const OVERLOOK_LOOK: [f32; 3] = [-8912.9, -125.4, 87.7];
/// Director shot 2 — the Stormwind canal (Cathedral Square side): water, bridge, walls, spires,
/// far mountains.
pub(super) const CANAL_EYE: [f32; 3] = [-8871.7, 724.6, 110.7];
pub(super) const CANAL_LOOK: [f32; 3] = [-8836.7, 760.3, 112.3];
/// Director shot 3 — Elwynn river, south-east of Northshire: open water dominant, shoreline blend,
/// murloc camp, fog.
pub(super) const WATER_EYE: [f32; 3] = [-9527.0, -310.6, 70.8];
pub(super) const WATER_LOOK: [f32; 3] = [-9499.4, -351.3, 61.4];
/// Director shot 4 (decision 0743) — INSIDE the Lion's Pride Inn at Goldshire, standing in the
/// common room: the hearth (the MOCV-alpha self-illum bake), a daylight window, the lit chandelier,
/// the stair run, floor boards and ceiling beams, and a room full of props. The sweep's only
/// interior, and the only shot that exercises portal culling, the INT bake, the MOLT point pools
/// and WMO props at all — the surface the ledger's dungeon reports live on, and the one B101's
/// prop-cull regression (which hid the Blackrock lava falls) crossed unseen.
///
/// The agent's own interior framing (`inn-interior`, a tight crop of the kitchen hearth) was judged
/// badly framed and stays in [`ON_DEMAND`]; this is the director's, and it frames the ROOM.
///
/// NB the camera must stand over a FLOOR FACE: over a floorless pocket the portal cull's down-ray
/// reads "outside" and faithfully culls the containing group, which vanishes the room.
pub(super) const INN_EYE: [f32; 3] = [-9471.4, 39.4, 59.9];
pub(super) const INN_LOOK: [f32; 3] = [-9458.8, -7.5, 48.2];
/// Director shot 6 (decision 0749) — an Elwynn rail fence lying ACROSS the sun's shadow boundary,
/// at 10:24: the right-hand span is in full sun, the left-hand span in shade, and the same edge
/// runs on across the road behind it. One frame holding **both** states of the MCSH sun term, on
/// terrain and on a doodad at once, with the ramp visible as the hard line between them.
///
/// The lighting matrix (0746) samples the lit and shadowed lanes as SEPARATE cells at separate
/// positions, so a regression that scaled both equally could pass both. Here the two states share
/// one frame, one model and one texture, so only their DIFFERENCE can carry the shot — the control
/// is inside the picture. It also lights the third lane neither matrix subject touches: a
/// world-placed **doodad**, which takes its shade per-vertex at bake rather than through
/// `entity_shade`'s per-object ramp.
///
/// Found by the director, who framed it because they had already spotted it was "both lit and unlit
/// from sun, half half".
pub(super) const FENCE_EYE: [f32; 3] = [-9511.9, -4.0, 61.9];
pub(super) const FENCE_LOOK: [f32; 3] = [-9552.0, 18.6, 42.4];
/// Director shot 5 (decision 0743) — a Felwood hollow on **Kalimdor** (`MAP_KALIMDOR`): the
/// corrupted forest floor's root mat, a stand of emissive `felwoodmushroom` doodads, a pool of
/// green sludge (a liquid type no other golden shot contains), the vast trunks behind, and the
/// zone's sick-green fog and light palette.
///
/// The sweep's only shot outside Azeroth and outside the Elwynn/Stormwind palette — until this one
/// landed, a fog, tileset, WDL-horizon or per-map lighting regression anywhere else in the world
/// had nowhere to show up. It is also what forced [`Scenario::map`]: this spot's ADT tile
/// (`33_24`) exists in Azeroth too, empty, so on the old table it would have photographed a void.
pub(super) const FELWOOD_EYE: [f32; 3] = [4060.9, -944.3, 256.8];
pub(super) const FELWOOD_LOOK: [f32; 3] = [4014.0, -954.4, 242.9];
// ---------------------------------------------------------------------------------------------
// The LIGHTING MATRIX (decision 0744) — one subject, three lanes, two sides.
//
// The golden spots are landscape: they photograph terrain, buildings and water, and (captures being
// server-less) contain no creature or GameObject at all. So the *object* light path — the one every
// creature, player, pet and chest in the game is lit by — had no regression coverage whatsoever,
// while the bug ledger's unit reports (blacked-out textures, mis-lit NPCs across city WMOs) sit
// squarely on it.
//
// The three positions are the three lanes an object's light can take, and they were CHOSEN FROM THE
// DATA, not by eye — `WOW_LIGHT_AT`/`WOW_LIGHT_GRID` (`wmo_portal::audit::light_probe`) reports the
// lane and the terrain MCSH bit at any world point:
//
// SUN (-9500, 56) terrain z 56.48 MCSH false lane exterior-on-terrain — sun term at full
// SHADE (-9500, 44) terrain z 55.95 MCSH true lane exterior-on-terrain — sun term dimmed
// INDOOR (-9469.4, 31.9) terrain z none zone-text indoor 5, lane BAKE g05 — no sun at all
//
// SUN and SHADE are twelve yards apart on the same open ground, so between that pair the ONLY
// changed input is the baked shadow bit — exactly the discriminator `entity_shade` ramps on
// (2.5 lit → 0.5 shadowed). INDOOR stands in the Goldshire inn's common room, where the interior
// classifier owns the tag instead and the sun never reaches.
//
// Two sides per lane, straddling the light: the game's LIGHTING sun is near-fixed at azimuth 45°
// (`sun::follow`), so `front` puts the camera on that bearing (sun behind us — the subject's lit
// face) and `rear` puts it opposite (the shadowed face). Indoors there is no sun, so the pair simply
// samples the bake from two sides. Cameras sit at a fixed offset off the subject's feet, eye a
// little above and aimed at the body, so every cell frames the subject identically and a diff is
// about light, never framing.
/// Lighting-matrix subject positions (feet, raw WoW coords) — see the note above.
pub(super) const SUBJECT_SUN: [f32; 3] = [-9500.0, 56.0, 56.48];
pub(super) const SUBJECT_SHADE: [f32; 3] = [-9500.0, 44.0, 55.95];
pub(super) const SUBJECT_INDOOR: [f32; 3] = [-9469.4, 31.9, 57.9];
/// Every remaining named viewpoint — the UI look-pass fixtures, the sun/moon/sky regression
/// fixtures, the house-compass and street scenes. Capturable by name (`WOW_CAPTURE=<name>`) for
/// debugging and look passes, but NOT part of the blessed baseline sweep.
pub(super) const ON_DEMAND: &[Scenario] = &[
// ---- Evicted from the blessed sweep by decision 0817, NOT deleted ----
// The sweep had grown from 0632's six to twenty-one, i.e. 42 windows on the director's screen per
// `selfcheck`, and the director cut it back to three spots x two day times. Everything below this
// note used to be in SCENARIOS and is still capturable by name — which is the right home for a
// subject somebody is actively working on. `chest-shade-{front,rear}` additionally measured NOT
// reproducible on the sweep that prompted the cut (MAE 2.721 / 2.649, ~7.8 % of pixels, two runs
// of one build) — same coplanar-batch draw-order defect as `chest-indoor-*` below (0815 Open).
Scenario {
name: "overlook-noon",
map: MAP_AZEROTH,
eye: OVERLOOK_EYE,
look: OVERLOOK_LOOK,
minute: 720,
ui: None,
},
Scenario {
name: "overlook-night",
map: MAP_AZEROTH,
eye: OVERLOOK_EYE,
look: OVERLOOK_LOOK,
minute: 0,
ui: None,
},
// The sweep's only interior until 0817 moved it here; no `inn-night` (measured MAE 0.198 against
// `inn-noon` — the room is lit by hearth and candles, so the clock barely reaches it).
Scenario {
name: "inn-noon",
map: MAP_AZEROTH,
eye: INN_EYE,
look: INN_LOOK,
minute: 720,
ui: None,
},
// The day cell keeps the director's own minute (10:24) rather than the sweep's noon: the subject
// IS the shadow boundary, and where it falls is a function of the sun's angle, so at noon it
// slides off the rails and the shot degrades into an ordinary fence.
@@ -375,105 +478,6 @@ pub(super) const SCENARIOS: &[Scenario] = &[
at: SUBJECT_SHADE,
}),
},
];
/// Director shot 1 — the Northshire overlook, north-east of the Abbey looking at it: terrain,
/// trees, the Abbey WMO, stained glass, props.
pub(super) const OVERLOOK_EYE: [f32; 3] = [-8955.0, -98.5, 91.1];
pub(super) const OVERLOOK_LOOK: [f32; 3] = [-8912.9, -125.4, 87.7];
/// Director shot 2 — the Stormwind canal (Cathedral Square side): water, bridge, walls, spires,
/// far mountains.
pub(super) const CANAL_EYE: [f32; 3] = [-8871.7, 724.6, 110.7];
pub(super) const CANAL_LOOK: [f32; 3] = [-8836.7, 760.3, 112.3];
/// Director shot 3 — Elwynn river, south-east of Northshire: open water dominant, shoreline blend,
/// murloc camp, fog.
pub(super) const WATER_EYE: [f32; 3] = [-9527.0, -310.6, 70.8];
pub(super) const WATER_LOOK: [f32; 3] = [-9499.4, -351.3, 61.4];
/// Director shot 4 (decision 0743) — INSIDE the Lion's Pride Inn at Goldshire, standing in the
/// common room: the hearth (the MOCV-alpha self-illum bake), a daylight window, the lit chandelier,
/// the stair run, floor boards and ceiling beams, and a room full of props. The sweep's only
/// interior, and the only shot that exercises portal culling, the INT bake, the MOLT point pools
/// and WMO props at all — the surface the ledger's dungeon reports live on, and the one B101's
/// prop-cull regression (which hid the Blackrock lava falls) crossed unseen.
///
/// The agent's own interior framing (`inn-interior`, a tight crop of the kitchen hearth) was judged
/// badly framed and stays in [`ON_DEMAND`]; this is the director's, and it frames the ROOM.
///
/// NB the camera must stand over a FLOOR FACE: over a floorless pocket the portal cull's down-ray
/// reads "outside" and faithfully culls the containing group, which vanishes the room.
pub(super) const INN_EYE: [f32; 3] = [-9471.4, 39.4, 59.9];
pub(super) const INN_LOOK: [f32; 3] = [-9458.8, -7.5, 48.2];
/// Director shot 6 (decision 0749) — an Elwynn rail fence lying ACROSS the sun's shadow boundary,
/// at 10:24: the right-hand span is in full sun, the left-hand span in shade, and the same edge
/// runs on across the road behind it. One frame holding **both** states of the MCSH sun term, on
/// terrain and on a doodad at once, with the ramp visible as the hard line between them.
///
/// The lighting matrix (0746) samples the lit and shadowed lanes as SEPARATE cells at separate
/// positions, so a regression that scaled both equally could pass both. Here the two states share
/// one frame, one model and one texture, so only their DIFFERENCE can carry the shot — the control
/// is inside the picture. It also lights the third lane neither matrix subject touches: a
/// world-placed **doodad**, which takes its shade per-vertex at bake rather than through
/// `entity_shade`'s per-object ramp.
///
/// Found by the director, who framed it because they had already spotted it was "both lit and unlit
/// from sun, half half".
pub(super) const FENCE_EYE: [f32; 3] = [-9511.9, -4.0, 61.9];
pub(super) const FENCE_LOOK: [f32; 3] = [-9552.0, 18.6, 42.4];
/// Director shot 5 (decision 0743) — a Felwood hollow on **Kalimdor** (`MAP_KALIMDOR`): the
/// corrupted forest floor's root mat, a stand of emissive `felwoodmushroom` doodads, a pool of
/// green sludge (a liquid type no other golden shot contains), the vast trunks behind, and the
/// zone's sick-green fog and light palette.
///
/// The sweep's only shot outside Azeroth and outside the Elwynn/Stormwind palette — until this one
/// landed, a fog, tileset, WDL-horizon or per-map lighting regression anywhere else in the world
/// had nowhere to show up. It is also what forced [`Scenario::map`]: this spot's ADT tile
/// (`33_24`) exists in Azeroth too, empty, so on the old table it would have photographed a void.
pub(super) const FELWOOD_EYE: [f32; 3] = [4060.9, -944.3, 256.8];
pub(super) const FELWOOD_LOOK: [f32; 3] = [4014.0, -954.4, 242.9];
// ---------------------------------------------------------------------------------------------
// The LIGHTING MATRIX (decision 0744) — one subject, three lanes, two sides.
//
// The golden spots are landscape: they photograph terrain, buildings and water, and (captures being
// server-less) contain no creature or GameObject at all. So the *object* light path — the one every
// creature, player, pet and chest in the game is lit by — had no regression coverage whatsoever,
// while the bug ledger's unit reports (blacked-out textures, mis-lit NPCs across city WMOs) sit
// squarely on it.
//
// The three positions are the three lanes an object's light can take, and they were CHOSEN FROM THE
// DATA, not by eye — `WOW_LIGHT_AT`/`WOW_LIGHT_GRID` (`wmo_portal::audit::light_probe`) reports the
// lane and the terrain MCSH bit at any world point:
//
// SUN (-9500, 56) terrain z 56.48 MCSH false lane exterior-on-terrain — sun term at full
// SHADE (-9500, 44) terrain z 55.95 MCSH true lane exterior-on-terrain — sun term dimmed
// INDOOR (-9469.4, 31.9) terrain z none zone-text indoor 5, lane BAKE g05 — no sun at all
//
// SUN and SHADE are twelve yards apart on the same open ground, so between that pair the ONLY
// changed input is the baked shadow bit — exactly the discriminator `entity_shade` ramps on
// (2.5 lit → 0.5 shadowed). INDOOR stands in the Goldshire inn's common room, where the interior
// classifier owns the tag instead and the sun never reaches.
//
// Two sides per lane, straddling the light: the game's LIGHTING sun is near-fixed at azimuth 45°
// (`sun::follow`), so `front` puts the camera on that bearing (sun behind us — the subject's lit
// face) and `rear` puts it opposite (the shadowed face). Indoors there is no sun, so the pair simply
// samples the bake from two sides. Cameras sit at a fixed offset off the subject's feet, eye a
// little above and aimed at the body, so every cell frames the subject identically and a diff is
// about light, never framing.
/// Lighting-matrix subject positions (feet, raw WoW coords) — see the note above.
pub(super) const SUBJECT_SUN: [f32; 3] = [-9500.0, 56.0, 56.48];
pub(super) const SUBJECT_SHADE: [f32; 3] = [-9500.0, 44.0, 55.95];
pub(super) const SUBJECT_INDOOR: [f32; 3] = [-9469.4, 31.9, 57.9];
/// Every remaining named viewpoint — the UI look-pass fixtures, the sun/moon/sky regression
/// fixtures, the house-compass and street scenes. Capturable by name (`WOW_CAPTURE=<name>`) for
/// debugging and look passes, but NOT part of the blessed baseline sweep.
pub(super) const ON_DEMAND: &[Scenario] = &[
// The chest INDOORS is out of the blessed sweep and in here instead: measured over two runs of
// one build it is not reproducible (front MAE 1.551 / 9.2 % of pixels, rear 0.529 / 7.6 %), and
// the diff is the whole body shifting brightness in perfect registration — LIGHT, not pose. The
+1
View File
@@ -822,6 +822,7 @@ mod tests {
let hidden = benilla_formats::ScalarAnim {
period: 0.0,
step: true,
wrap: true, // period 0: a constant has no clock
keys: vec![(0.0, 0.0)],
};
std::sync::Arc::new(
@@ -25,7 +25,7 @@ use bevy::prelude::*;
use crate::assets::WorldAssets;
use crate::lighting::SharedLightBuffer;
use crate::portrait::{
GlueLook, GluePreview, GluePreviewBake, PreviewBillboard, PreviewGlow, PreviewPart,
GlueLook, GluePreview, GluePreviewBake, PreviewBillboard, PreviewEffects, PreviewPart,
PreviewRider,
};
use crate::terrain::WowModelMaterial;
@@ -284,8 +284,9 @@ pub(in crate::entities) fn build_glue_preview(
// system — carry them through to the booth spawn, which seats each on its billboard bone's joint
// and re-faces it ([`crate::portrait::booth::face_booth_billboards`]). Geoset-gated like the body
// parts: the undead glow rides geoset 302 (the glowing "Features" variants), faithful to the DBC.
// The batch is fullbright (M2 unlit), so it keeps its built material — no char-skin swap.
let preview_billboards: Vec<PreviewBillboard> = parts
// The batch is fullbright (M2 unlit), so it keeps its built material — no char-skin swap. Offset
// `ZERO`: the body IS rigged here, so its billboard bone's joint already bakes the pivot.
let mut preview_billboards: Vec<PreviewBillboard> = parts
.iter()
.filter(|p| shows(p))
.filter_map(|p| {
@@ -294,6 +295,7 @@ pub(in crate::entities) fn build_glue_preview(
mesh: p.mesh.clone(),
material: p.material.clone(),
bone: info.bone,
offset: Vec3::ZERO,
kind: info.kind,
})
})
@@ -306,9 +308,18 @@ pub(in crate::entities) fn build_glue_preview(
// hands `ItemDisplayInfo+0x58` to `0x4798c0`), so a permanently-glowing weapon glows on the
// select screen too; the enum carries no enchant ids (vmangos `BuildEnumData` sends displayId
// + inventoryType only), so the fork's enchant half simply has no input here.
//
// An item model is not just meshes, and for a while this loop treated it as if it were — it kept
// the batches with no billboard flag and dropped everything else on the floor, so at character
// select a worn item's own **effects** (its emitters, its camera-facing batches) simply did not
// exist. That is `#bugs` B118 as nazriel filed it: the R14 PVP shoulders' sparkle is the item
// model's own emitters (decision 0813), and no part of it reached the booth. So each want
// contributes up to four things at one seat: plain meshes ([`PreviewRider`]), camera-facing
// batches ([`PreviewBillboard`]), its emitters ([`PreviewEffects`]), and — held weapons only —
// the same three off each glow model its `ItemVisuals` names.
let attach_point = |id: u16| dm.attachments.iter().find(|a| a.id == id);
let mut riders = Vec::new();
let mut preview_glows = Vec::new();
let mut preview_effects = Vec::new();
if let Some(d) = displays.as_deref() {
for w in &held {
let Some(point) = attach_point(w.attach) else {
@@ -320,12 +331,37 @@ pub(in crate::entities) fn build_glue_preview(
let Some(item_parts) = item.parts.as_deref() else {
continue;
};
for p in item_parts.iter().filter(|p| p.billboard.is_none()) {
riders.push(PreviewRider {
mesh: p.mesh.clone(),
material: p.material.clone(),
for p in item_parts.iter() {
// A camera-facing batch of the item — a wand's gem, a glowing rune (270 of the 2681
// `Item\` models author one). It can't be drawn as a plain rider mesh: the world
// splits it into a card, so the booth carries it as one too, seated at the attach
// point **plus its own model-local pivot** (an item model gets no rig here, so
// nothing else bakes that pivot).
match &p.billboard {
Some(info) => preview_billboards.push(PreviewBillboard {
mesh: p.mesh.clone(),
material: p.material.clone(),
bone: point.bone,
offset: point.offset + info.pivot,
kind: info.kind,
}),
None => riders.push(PreviewRider {
mesh: p.mesh.clone(),
material: p.material.clone(),
bone: point.bone,
offset: point.offset,
}),
}
}
// The item model's OWN particle emitters — the R14 PVP pauldron's `SPARKLE` twinkle
// (`#bugs` B118, decision 0813), the held torch's flame. 95 `Item\` models hang one on a
// billboard bone alone; the booth spawns them off a host at the attach point, each
// billboard-chain emitter through a booth-camera frame.
if !item.emitters.is_empty() {
preview_effects.push(PreviewEffects {
bone: point.bone,
offset: point.offset,
emitters: item.emitters.clone(),
});
}
let Some(paths) = glows.as_deref().and_then(|g| g.effects(w.visual)) else {
@@ -350,21 +386,30 @@ pub(in crate::entities) fn build_glue_preview(
continue;
};
let offset = point.offset + at;
for p in glow
.parts
.iter()
.flatten()
.filter(|p| p.billboard.is_none())
{
riders.push(PreviewRider {
mesh: p.mesh.clone(),
material: p.material.clone(),
bone: point.bone,
offset,
});
for p in glow.parts.iter().flatten() {
// Same split as the item's own batches above. This is not a rare arm:
// `Spells\Enchantments\Sparkle_A.m2` is *one* additive camera-facing quad and
// nothing else — no emitters at all — and `ItemVisuals` 28 (10 item displays)
// hangs it on slot 4, so dropping billboard batches here glowed those items
// nothing whatsoever at select.
match &p.billboard {
Some(info) => preview_billboards.push(PreviewBillboard {
mesh: p.mesh.clone(),
material: p.material.clone(),
bone: point.bone,
offset: offset + info.pivot,
kind: info.kind,
}),
None => riders.push(PreviewRider {
mesh: p.mesh.clone(),
material: p.material.clone(),
bone: point.bone,
offset,
}),
}
}
if !glow.emitters.is_empty() {
preview_glows.push(PreviewGlow {
preview_effects.push(PreviewEffects {
bone: point.bone,
offset,
emitters: glow.emitters.clone(),
@@ -376,11 +421,15 @@ pub(in crate::entities) fn build_glue_preview(
debug!(
"glue preview: race {race} sex {sex} display {display_id} → {} parts, {} riders, \
{} eye-glow, {} item glow ({})",
{} camera-facing, {} effect model(s) / {} emitter(s) ({})",
preview_parts.len(),
riders.len(),
preview_billboards.len(),
preview_glows.len(),
preview_effects.len(),
preview_effects
.iter()
.map(|e: &PreviewEffects| e.emitters.len())
.sum::<usize>(),
match look {
GlueLook::Create(_) => "create",
GlueLook::Select(_) => "select",
@@ -391,7 +440,7 @@ pub(in crate::entities) fn build_glue_preview(
display_id,
parts: preview_parts,
riders,
glows: preview_glows,
effects: preview_effects,
billboards: preview_billboards,
grip,
revision: bake.revision + 1,
+60 -41
View File
@@ -449,15 +449,14 @@ pub(super) fn attach_entity_visuals(
let bake_center = dm.map(|d| d.bake_center_local).unwrap_or(Vec3::ZERO);
// The dynamic ground-shade root (decision 0173): one light-node state per object, like
// the reference's `[obj+0xe0]`; every M2 part below (body, held items) reads it from the
// tree walk. The KIND decides how the node's light is delivered (0809): a GameObject
// samples the terrain MCSH under it and ramps its sun term over 2.5/0.5, while a
// unit/player takes `0x672a20`'s null fallback — a flat day/night ×1.0. `insert_if_new`
// so a gear-change re-attach keeps the already-ramped state instead of resetting it (no
// one-frame lighting pop); the kind it captures cannot change under a live display-id
// swap, the same argument the attach-mode split below rests on.
// tree walk. Kind-independent — a unit and a GameObject run the same 2.5/0.5 MCSH chase,
// which is the byte-shared target law (0814 restored this; 0809 had split it by kind on
// the strength of a delivery state we do not model). `insert_if_new` so a gear-change
// re-attach keeps the already-ramped state instead of resetting it (no one-frame
// lighting pop).
commands
.entity(entity)
.insert_if_new(crate::entity_shade::GroundShade::for_kind(net.kind));
.insert_if_new(crate::entity_shade::GroundShade::default());
// The root's canonical fold reference: held items share the root's interior verdict
// (one light node per unit — the reference aliases the wearer's collector into each
// equipped item, wow-re `unit-light-combine-storm.md`), and their classifier fold must
@@ -666,6 +665,11 @@ pub(super) fn attach_entity_visuals(
// load) can join the same ramp instead of popping in opaque or racing its own fade from
// zero. Decision 0032 read as a per-unit property, not a per-mesh-at-attach-time stamp.
let mut unit_will_fade = false;
// This unit's **instance slot**, in tag-field form — one value for the whole unit, worn by
// every part and card below. See the per-part note at the spawn for why it is the
// instance's identity rather than a skinning detail (decision 0812).
let inst_slot = skin.as_ref().map_or(0, |rb| rb.slot);
let rig_tag = crate::mesh_tag::rig_bits(inst_slot);
// Billboard batches (the brazier/lantern glow card) collected for the world-root card
// spawn below — inside the loop we only plant their anchor child.
let mut billboard_parts = Vec::new();
@@ -721,6 +725,11 @@ pub(super) fn attach_entity_visuals(
mesh: part.mesh.clone(),
material: part.material.clone(),
bone: info.bone,
// This host IS rigged, so its billboard bone's booth joint
// already bakes the pivot (the 0130 rig identity) — and the
// batch belongs to the host's body, so a mount's own glow card
// prunes with the mount (`DressedLook::collect`).
seat: crate::portrait::PortraitSeat::Body,
kind: info.kind,
},
))
@@ -772,16 +781,23 @@ pub(super) fn attach_entity_visuals(
Some(b) => (b.clone(), true),
None => (mat.clone(), false),
};
// The tag's slot field identifies the **instance**, not this part's skinning: it
// is the UNIT's slot on every part, even a boneless geoset. The vertex stage only
// reads it under `WOW_RIG_SKIN`, which `WowModelExt::specialize` keys on the
// mesh's own joint attributes — so a static mesh carrying a slot is not skinned
// by it — while the fragment stage reads it for the per-instance body tint
// (decision 0812). Giving it to every part is what makes a tinted unit tint
// *whole*, which is the reference's own rule: an attached/chained model inherits
// the parent CM2's computed colours (`0x714000` recursion). Hoisted above the
// loop — it is a property of the unit, not of the part.
//
// A skinned creature part draws its skinned-mesh twin (the WOW joint
// attributes → the owned-palette WOW_RIG_SKIN shader path, decision 0720);
// everything else — and the palette-full fallback (slot 0) — the static mesh.
let rig_slot = match (&skin, &part.skinned_mesh) {
(Some(rb), Some(_)) => rb.slot,
_ => 0,
};
let rig_tag = crate::mesh_tag::rig_bits(rig_slot);
let mesh = match (rig_slot, &part.skinned_mesh) {
(1.., Some(sm)) => sm.clone(),
// Keyed on the part having a twin, which the slot alone no longer implies.
let skinned = inst_slot != 0 && part.skinned_mesh.is_some();
let mesh = match &part.skinned_mesh {
Some(sm) if skinned => sm.clone(),
_ => part.mesh.clone(),
};
let mut child = parent.spawn((
@@ -804,14 +820,22 @@ pub(super) fn attach_entity_visuals(
},
object.clone(),
));
// Bind this part to the instance's palette rig (decision 0720) — all parts
// share the one joint set through the rig slot in their tag; `RigPart` is the
// CPU-side link (the mouseover picker's skinned ray test).
if rig_slot != 0 {
child.insert((
crate::rig_palette::RigPart(entity),
MeshTag(rig_tag | crate::mesh_tag::alpha_bits(1.0)),
));
// Every part gets a tag: the instance slot above plus the live alpha field. It is
// unconditional because the slot is now a per-instance identity every part needs
// (the tint) rather than a skinning detail only skinned parts have — and because
// the two conditional writers that used to seed it (the interior classifier, the
// animated-alpha compose) each covered only their own subset, so a part in
// neither carried no tag at all. The alpha honours `fading` like those two did:
// the old unconditional `1.0` here was overwritten by them on the parts they
// covered, and left a one-frame opaque flash on the parts they didn't.
child.insert(MeshTag(
rig_tag | crate::mesh_tag::alpha_bits(if fading { 0.0 } else { 1.0 }),
));
// `RigPart` stays gated on actually being skinned by that rig — it is the
// CPU-side link for the mouseover picker's skinned ray test (decision 0720),
// which has nothing to say about a static part.
if skinned {
child.insert(crate::rig_palette::RigPart(entity));
}
if let (Some(_), Some(_)) = (&skin, &part.skinned_mesh) {
// A streamed entity's M2 is **never view-culled** — the reference registers
@@ -851,10 +875,9 @@ pub(super) fn attach_entity_visuals(
if let Some(lit) =
part_interior_lit(mat, mat_interior, mat_bake, bake_center, entity)
{
// `rig_tag` rides every whole-tag write here and below (decision 0720).
let tag =
rig_tag | crate::mesh_tag::alpha_bits(if fading { 0.0 } else { 1.0 });
child.insert((MeshTag(tag), lit));
// The tag itself is already seeded above, with this same value — the
// classifier composes into it rather than owning it.
child.insert(lit);
}
// The batch's **animated material alpha** (the verified combine's runtime half,
// wow-re `m2-alpha-combine-cull.md`): a creature's colour-alpha/transparency
@@ -866,16 +889,8 @@ pub(super) fn attach_entity_visuals(
// factor through `entities::apply_unit_mat_alpha`. Nothing is inserted for the
// overwhelming majority of batches, which author no tracks at all.
if let Some(anim) = &part.alpha_anim {
// The compose needs a tag to write into; every part has one now (above).
child.insert(crate::doodad_anim::MatAnim::following(anim.clone(), entity));
// The compose needs a tag to write into. An interior-capable part already
// got one above; anything else (a WMO-display part, a model with no
// interior variants) is seeded with the same neutral value.
if mat_interior.is_none() {
child.insert(MeshTag(
rig_tag
| crate::mesh_tag::alpha_bits(if fading { 0.0 } else { 1.0 }),
));
}
}
// Queue the appear-fade on M2 parts — `arm_appear_fade` starts the ramp once the world
// is on-screen (not behind the loading screen), so it plays where the player sees it.
@@ -918,8 +933,15 @@ pub(super) fn attach_entity_visuals(
// A card takes its MODEL's indoor law, through the same constructor as the sibling
// meshes it was split out of (decision 0778). No char-slot variants are consulted:
// a body/hair/cape/skin-extra batch is never a billboard batch, so the slot lookup
// living past the `continue` above costs a card nothing. The rig field stays 0 —
// a card draws the static mesh whatever its host.
// living past the `continue` above costs a card nothing.
//
// A card carries the unit's INSTANCE slot like every sibling part, even though it is
// never skinned by it (it draws the static mesh whatever its host — that is what the
// vertex stage's attribute gate is for): it is a batch of the unit's own model, so a
// tinted unit tints its eye-glow and torch cards too (decision 0812). Seeded
// unconditionally for the same reason as the mesh parts — the two writers below cover
// only their own subsets.
card.insert(MeshTag(rig_tag | crate::mesh_tag::alpha_bits(1.0)));
if let Some(lit) = part_interior_lit(
&part.material,
part.material_interior.as_ref(),
@@ -927,7 +949,7 @@ pub(super) fn attach_entity_visuals(
bake_center,
entity,
) {
card.insert((MeshTag(crate::mesh_tag::alpha_bits(1.0)), lit));
card.insert(lit);
}
// A card shares its batch's per-sequence alpha loops (the billboard split copies
// them onto every group), sampled off the same unit clock as the mesh parts — a
@@ -937,9 +959,6 @@ pub(super) fn attach_entity_visuals(
// just for the batches that classified out above.
if let Some(anim) = &part.alpha_anim {
card.insert(crate::doodad_anim::MatAnim::following(anim.clone(), entity));
if part.material_interior.is_none() {
card.insert(MeshTag(crate::mesh_tag::alpha_bits(1.0)));
}
}
}
// Mirror the appear-fade clock onto the unit root (see `unit_will_fade` above): a held item
+274 -3
View File
@@ -46,6 +46,12 @@ pub(in crate::entities) fn attach_held_items(
// writes (`entities::attach`), and the held effects' draw-order rung is measured in the
// world yards that scale produces.
Option<&Transform>,
// The WEARER's rig, for its instance slot: every part spawned below carries it in its tag so
// the wearer's body tint reaches its helm, shoulders and held items (decision 0812 — the
// reference's attached models inherit the parent CM2's computed colours, `0x714000`). Never
// used to skin these parts: they are static meshes, and the vertex stage's slot read is gated
// on the mesh's own joint attributes.
Option<&crate::rig_palette::RigSkin>,
)>,
held: Option<Res<ItemDisplays>>,
time: Res<Time>,
@@ -54,7 +60,8 @@ pub(in crate::entities) fn attach_held_items(
return;
};
let now = time.elapsed_secs();
for (items, bones, attached, entity, unit_fade, body_center, unit_tf) in &mut units {
for (items, bones, attached, entity, unit_fade, body_center, unit_tf, skin) in &mut units {
let rig_tag = crate::mesh_tag::rig_bits(skin.map_or(0, |rb| rb.slot));
// A held item / helm / shoulder resolves and spawns asynchronously (a template round trip, a
// model load) — often *after* the body has already armed its appear-fade (decision 0032 is a
// per-unit property: the reference fades the whole unit, attachments included, as one). Read
@@ -119,6 +126,11 @@ pub(in crate::entities) fn attach_held_items(
display: hs.display,
kind: hs.kind,
visual: hs.visual,
// The item's seat on the BODY, carried so the glow attach can publish its own
// booth mirrors at a seat composed from it (decision 0822) — the glow spawns
// asynchronously and knows only this root.
bone,
offset,
});
}
// The engine-drawn bowstring (0408 §G2) — for the drawn BOW only: the ranged slot's
@@ -196,6 +208,14 @@ pub(in crate::entities) fn attach_held_items(
// once anchored these, and a swing alone could trip the resample gate and
// split the shield's light from the body's (director-caught, 2026-07-13).
// The fold reference is the wearer's BODY centre for the same reason.
//
// The tag is unconditional (it used to ride the classifier's `Some`): it carries
// the WEARER's instance slot, which is what puts a tinted body's colour on its
// helm and shoulders — the director's report on the dwarf Stoneform tint. A part
// with no interior variant got no tag at all before, so it was also invisible to
// the ground-shade ramp that darkens its wielder; both now follow the body, which
// is the same light-collector aliasing the comment above describes.
child.insert(MeshTag(rig_tag | crate::mesh_tag::alpha_bits(tag_alpha)));
if let Some(lit) = part_interior_lit(
&part.material,
part.material_interior.as_ref(),
@@ -203,7 +223,7 @@ pub(in crate::entities) fn attach_held_items(
body_center.map_or(dm.bake_center_local, |c| c.0),
entity,
) {
child.insert((MeshTag(crate::mesh_tag::alpha_bits(tag_alpha)), lit));
child.insert(lit);
}
// `FadeMaterials` is persistent bookkeeping (self-avatar zoom fade, decision 0032's
// despawn-fade-out), not tied to whether *this* spawn happens to join an in-flight
@@ -236,6 +256,23 @@ pub(in crate::entities) fn attach_held_items(
// gear change, so the card's lifecycle and frame both come for free (same owner
// contract as the item's emitters below).
for (info, part) in billboard_parts {
// …and, under `root`, the booth **mirror carrier** for that card (decision 0822).
// The card itself is a world-ROOT entity, so the portrait / paper-doll booths — which
// mirror the unit's dressed descendants — cannot see it; without this marker an
// item's camera-facing batch (a wand's gem, this torch's `GLOWWHITE32` halo) simply
// did not exist in those panes. Its seat is the attach point **plus the batch's own
// model-local pivot**: an item model spawns no rig, so nothing else bakes that pivot.
commands.entity(root).with_child((
Transform::default(),
Visibility::default(),
crate::portrait::PortraitBillboard {
mesh: part.mesh.clone(),
material: part.material.clone(),
bone,
seat: crate::portrait::PortraitSeat::Rider(offset + info.pivot),
kind: info.kind,
},
));
let mut card = commands.spawn((
Mesh3d(part.mesh.clone()),
MeshMaterial3d(part.material.clone()),
@@ -251,6 +288,9 @@ pub(in crate::entities) fn attach_held_items(
// glow card can never split from the arm holding it. Spawned at a steady alpha: a
// card joins no appear-fade today (it carries neither `RenderFade` nor
// `FadeMaterials`), and the classifier preserves the alpha field regardless.
// …and the wearer's instance slot, like its mesh siblings: a tinted body colours the
// torch's glow card too (decision 0812).
card.insert(MeshTag(rig_tag | crate::mesh_tag::alpha_bits(1.0)));
if let Some(lit) = part_interior_lit(
&part.material,
part.material_interior.as_ref(),
@@ -258,7 +298,7 @@ pub(in crate::entities) fn attach_held_items(
body_center.map_or(dm.bake_center_local, |c| c.0),
entity,
) {
card.insert((MeshTag(crate::mesh_tag::alpha_bits(1.0)), lit));
card.insert(lit);
}
}
// The item's own particle emitters — the held torch's flame (0130 phase 4: the same
@@ -277,6 +317,20 @@ pub(in crate::entities) fn attach_held_items(
let unit_scale = unit_tf.map_or(1.0, |t| t.scale.max_element());
let spawn_tf = Transform::from_translation(Vec3::splat(root.to_bits() as f32))
.with_scale(Vec3::splat(unit_scale));
// The booth mirror for those same emitters (decision 0822): they spawn as FREE entities
// below (the owner contract), never unit descendants, so a booth that mirrors the dressed
// tree cannot see them — which is why the R14 pauldron's sparkle was absent from the paper
// doll exactly as it was from the select screen (`#bugs` B118). One marker per item, on
// the root that already reaps with it.
if !dm.emitters.is_empty() {
commands
.entity(root)
.insert(crate::portrait::PortraitEffects {
bone,
offset,
emitters: dm.emitters.clone(),
});
}
for em in &dm.emitters {
// …with ONE exception to "the rest pose applies": a **billboard** bone in the
// emitter's chain. Its palette rows are replaced with the camera basis about its
@@ -356,3 +410,220 @@ pub(in crate::entities) fn attach_held_items(
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::super::super::display::{empty_display, EntityPart};
use super::super::{HeldSlot, ItemModelKind};
use super::*;
/// One synthetic item part. `interior` picks whether it carries an interior material variant —
/// the axis that used to decide whether the part got a `MeshTag` at all, and so whether it could
/// ever be tinted or ground-shaded.
fn part(interior: bool) -> EntityPart {
EntityPart {
mesh: Handle::default(),
skinned_mesh: None,
material: Handle::default(),
material_interior: interior.then(Handle::default),
material_interior_bake: None,
material_interior_bake_blend: None,
fade_blend: None,
blend: benilla_formats::ModelBlend::Opaque,
additive: false,
two_sided: false,
geoset_id: 0,
char_slot: None,
billboard: None,
alpha_anim: None,
rgb_anim: None,
ground_quad: None,
}
}
/// Spawn a wearer with one helm slot and run the attach. Returns the spawned parts' tags plus the
/// wearer's own instance slot (`0` when `rigged` is false — a boneless wearer).
fn attach_a_helm(rigged: bool, interior: bool) -> (Vec<u32>, u16) {
const HELM_KIND: ItemModelKind = ItemModelKind::Helm { race: 3, sex: 0 };
let mut app = App::new();
app.add_plugins(MinimalPlugins);
// `RigSkin`'s free hook frees the slot through this on teardown.
app.init_resource::<crate::rig_palette::RigPalettes>();
let mut displays = ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(HashMap::new()),
);
let mut dm = empty_display();
dm.parts = Some(vec![part(interior)]);
displays.models.insert((7, HELM_KIND), dm);
app.insert_resource(displays);
// The bone the helm hangs on, and the wearer.
let joint = app.world_mut().spawn(Transform::default()).id();
let bones = BoneAttach {
anchors: HashMap::from([(3u16, joint)]),
points: HashMap::from([(attach_id::HELM, (3u16, Vec3::ZERO))]),
markers: HashMap::new(),
};
let mut items = HeldItems::default();
items.slots[3] = Some(HeldSlot {
display: 7,
kind: HELM_KIND,
attach: attach_id::HELM,
visual: NO_GLOW,
});
let skin = rigged.then(|| {
crate::rig_palette::RigSkin::allocate_bones(
app.world_mut()
.resource_mut::<crate::rig_palette::RigPalettes>()
.as_mut(),
8,
Handle::default(),
)
.expect("a fresh palette has room")
});
let slot = skin.as_ref().map_or(0, |s| s.slot);
let mut wearer = app.world_mut().spawn((items, bones, Transform::default()));
if let Some(skin) = skin {
wearer.insert(skin);
}
app.add_systems(Update, attach_held_items);
app.update();
let mut tags: Vec<u32> = app
.world_mut()
.query::<&MeshTag>()
.iter(app.world())
.map(|t| t.0)
.collect();
tags.sort_unstable();
(tags, slot)
}
/// **The director's report (dwarf Stoneform tinted the body but not the helm/shoulders).** An
/// attachment carries its WEARER's instance slot, so the body tint reaches it — decision 0812's
/// named gap, closed. The reference's own rule: an attached model inherits the parent CM2's
/// computed colours (`0x714000`).
#[test]
fn an_attachment_wears_its_wearers_instance_slot() {
for interior in [true, false] {
let (tags, slot) = attach_a_helm(true, interior);
assert!(slot >= 1, "the wearer really has a rig");
assert_eq!(tags.len(), 1, "one part, one tag (interior={interior})");
assert_eq!(
crate::mesh_tag::rig_of(tags[0]),
slot,
"the wearer's slot, interior={interior}",
);
}
}
/// The tag is unconditional now. It used to ride the interior classifier's `Some`, so a part with
/// no interior variant carried none at all — invisible to the tint AND to the ground-shade ramp
/// that darkens its wielder. Both halves of that are asserted: a tag exists, and it is opaque.
#[test]
fn a_part_without_an_interior_variant_still_gets_a_tag() {
let (tags, _) = attach_a_helm(true, false);
assert_eq!(tags.len(), 1);
assert_ne!(tags[0], 0, "not the untagged ⇒ opaque sentinel");
assert!((crate::mesh_tag::alpha_of(tags[0]) - 1.0).abs() < 1.0 / 63.0);
}
/// A wearer with no rig at all (a boneless model holding something) lands on the identity slot 0
/// rather than borrowing someone else's colour.
#[test]
fn a_rigless_wearer_leaves_the_slot_at_identity() {
let (tags, _) = attach_a_helm(false, true);
assert_eq!(tags.len(), 1);
assert_eq!(crate::mesh_tag::rig_of(tags[0]), 0);
}
/// **What a booth can see of an equipped item** (decision 0822, `#bugs` B118's paper-doll half).
/// An item model's camera-facing batch spawns as a world-ROOT card and its emitters as free
/// owner-followed entities — neither is a unit descendant, so the portrait / paper-doll booths,
/// which mirror the dressed tree, could not see either one and a worn item's effects were absent
/// from every pane. The attach must therefore publish a mirror for both, at the seat the booth
/// needs: the attach point **plus the batch's own model-local pivot** for a card (an item spawns no
/// rig, so nothing else bakes that pivot), the bare attach point for the effect host.
///
/// Both offsets are asserted against a nonzero attach point AND a nonzero pivot, so publishing
/// either one alone — or adding them in the wrong frame — fails.
#[test]
fn an_equipped_items_card_and_emitters_are_published_for_the_booths() {
const SHOULDER_KIND: ItemModelKind = ItemModelKind::ShoulderRight;
const ATTACH: Vec3 = Vec3::new(0.21, 1.42, 0.06);
const PIVOT: Vec3 = Vec3::new(-0.06, 0.162, -0.012);
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<crate::rig_palette::RigPalettes>();
let mut displays = ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(HashMap::new()),
);
let mut dm = empty_display();
// The R14 pauldron's shape: a plain mesh batch, a camera-facing batch, and emitters.
let mut card = part(false);
card.billboard = Some(benilla_assets::BillboardInfo {
pivot: PIVOT,
bone: 1,
kind: benilla_formats::BillboardKind::Spherical,
scale_anim: None,
seq_translations: Vec::new(),
});
dm.parts = Some(vec![part(false), card]);
dm.emitters = vec![benilla_assets::ModelEmitter {
def: crate::particles::tests::plain_def(),
texture: None,
bone_pivot: [0.0; 3],
billboard: Some((benilla_formats::BillboardKind::Spherical, [0.0; 3])),
recursion: None,
geometry: None,
owner_reach: 0.0,
}];
displays.models.insert((7, SHOULDER_KIND), dm);
app.insert_resource(displays);
let joint = app.world_mut().spawn(Transform::default()).id();
let bones = BoneAttach {
anchors: HashMap::from([(3u16, joint)]),
points: HashMap::from([(attach_id::SHOULDER_RIGHT, (3u16, ATTACH))]),
markers: HashMap::new(),
};
let mut items = HeldItems::default();
items.slots[4] = Some(HeldSlot {
display: 7,
kind: SHOULDER_KIND,
attach: attach_id::SHOULDER_RIGHT,
visual: NO_GLOW,
});
app.world_mut().spawn((items, bones, Transform::default()));
app.add_systems(Update, attach_held_items);
app.update();
let mut cards = app
.world_mut()
.query::<&crate::portrait::PortraitBillboard>();
let published: Vec<_> = cards.iter(app.world()).collect();
assert_eq!(published.len(), 1, "one camera-facing batch, one mirror");
assert_eq!(published[0].bone, 3, "the BODY bone, not the item's bone 1");
assert_eq!(
published[0].seat,
crate::portrait::PortraitSeat::Rider(ATTACH + PIVOT),
"a rig-less rider's card carries attach + its own pivot",
);
let mut fx = app.world_mut().query::<&crate::portrait::PortraitEffects>();
let published: Vec<_> = fx.iter(app.world()).collect();
assert_eq!(published.len(), 1, "one effect-bearing model, one mirror");
assert_eq!(published[0].bone, 3);
assert_eq!(
published[0].offset, ATTACH,
"the host seats at the attach point; each emitter's own pivot is applied inside",
);
assert_eq!(published[0].emitters.len(), 1);
assert!(
published[0].emitters[0].billboard.is_some(),
"the billboard-chain arm survives the carry — it is what the booth builds a frame from",
);
}
}
+53
View File
@@ -145,6 +145,12 @@ pub(in crate::entities) struct ItemGlow {
pub(in crate::entities) display: u32,
pub(in crate::entities) kind: ItemModelKind,
pub(in crate::entities) visual: i32,
/// The ITEM's seat on the wearer's body — the attach point's bone and offset
/// ([`super::BoneAttach`]). Carried purely so this lane can publish the booth mirrors of what it
/// spawns (decision 0822): a glow's seat is this offset plus the slot's point on the item model,
/// and by the time the glow models load the attach path that knew the body bone is long gone.
pub(in crate::entities) bone: u16,
pub(in crate::entities) offset: bevy::prelude::Vec3,
}
/// Set once an item root's glow instances are spawned (or resolved to nothing) — the once-only
@@ -226,6 +232,53 @@ pub(super) fn attach_item_glows(
.spawn((Transform::from_translation(at), Visibility::default()))
.id();
commands.entity(root).add_child(instance);
// The booth mirrors for this instance (decision 0822), at its seat on the BODY: the item's
// attach point plus this slot's point on the item model. `attach_effect_visuals` below
// spawns the effect's real geometry and emitters — the meshes as instance children, the
// emitters as free owner-followed entities — and the shared spell-fx lane is deliberately
// not the place to stamp portrait markers (a *spell's* fx must never reach a booth), so
// this lane publishes its own from the same `dm`. Without them a permanently-glowing
// weapon glowed nothing in the character window: 32 of the 35 shipped glow models are pure
// emitters, and `Sparkle_A.m2` is a lone camera-facing quad.
let seat = glow.offset + at;
for p in dm.parts.iter().flatten() {
match &p.billboard {
Some(info) => {
commands.entity(instance).with_child((
Transform::default(),
Visibility::default(),
crate::portrait::PortraitBillboard {
mesh: p.mesh.clone(),
material: p.material.clone(),
bone: glow.bone,
seat: crate::portrait::PortraitSeat::Rider(seat + info.pivot),
kind: info.kind,
},
));
}
None => {
commands.entity(instance).with_child((
Transform::default(),
Visibility::default(),
crate::portrait::PortraitRider {
static_mesh: p.mesh.clone(),
material: p.material.clone(),
bone: glow.bone,
offset: seat,
},
));
}
}
}
if !dm.emitters.is_empty() {
commands
.entity(instance)
.insert(crate::portrait::PortraitEffects {
bone: glow.bone,
offset: seat,
emitters: dm.emitters.clone(),
});
}
attach_effect_visuals(
&mut commands,
instance,
+70 -69
View File
@@ -5,26 +5,29 @@
//! a per-frame MCSH sample at the object's node position and a linear intensity ramp (`0x69e770`, the
//! step constant `[0x810808] = 3.3333`/s), not a static spawn-time bake.
//!
//! **SETTLED (0809), and it split this file's premise in two.** §9's chain is byte-verified up to its
//! last link — "the unit's own light node fills the unit's committed light" — and that link was
//! INFERRED. Observation contradicted it: a player + three NPCs across two of the reference's own
//! apitraces commit their sun at gain **exactly 1.0**, never 2.5 or 0.5, while the same frames' ADT
//! doodads span the full 0.5/0.85/1.0/1.874/2.5 ramp (wow-re `trace-forensics-northshire-d3d` §5).
//! wow-re settled it in `unit-mcsh-shadow-target.md`: the 2.5/0.5 **target** law really is byte-shared
//! (one 2.5 site `69e4ad`, one MCSH sampler `0x69b350`, one `0x69e280` — so §9 was right that far), but
//! **delivery** splits at `0x672a20`. With `[model+0x3c0] == 0` the null fallback commits the raw
//! day/night ambient/diffuse with no intensity multiply anywhere — hardwired ×1.0, position-independent,
//! never sampling MCSH — and a unit's node is born UNREGISTERED (`0x670db0`'s birth-register gate
//! `670fca` is skipped for the model-arg-0 spawn). Their verdict to us: *"benilla must NOT light units
//! on the 2.5/0.5 static-doodad law by default; the outdoor unit base is day/night ×1.0."*
//! **The chain applies to units, and 0814 restored it after 0809 wrongly took it away.** The reference
//! has TWO real delivery states for a unit's committed light, and which one a given unit is in is a
//! *lifecycle* fact, not a category fact. Delivery splits at `0x672a20`: with `[model+0x3c0] != 0` the
//! node applies (`0x6a7300` multiplies the diffuse by the ramped `[+0xa4]` — the 2.5/0.5 chain this
//! file models); with `[model+0x3c0] == 0` the null fallback commits the raw day/night pair with no
//! intensity multiply at all — a hardwired ×1.0, position-independent, never sampling MCSH.
//! Registration fires on a model-set with the node present (`Node::SetModel 0x6716f0` ←
//! `0x613cf0`/`0x613d80`: equip / display-id / shapeshift); node birth (`0x670db0` from Activate
//! `0x613e10`) passes model arg 0, so its birth-time registration gate `670fca` is skipped and a unit
//! is born UNREGISTERED.
//!
//! So this system keeps the whole chain for **GameObjects** (which consume the node/bake) and, for
//! **units and players**, keeps only its ambient-word duty: their intensity is pinned flat at the
//! day/night point ([`GroundShade::null_fallback`]). What is still ours to answer, and wow-re says so
//! explicitly, is the **registration lifecycle** — which unit states carry `[model+0x3c0]` = node and
//! so earn the 2.5/0.5 chain back (their traces show a standing Stormwind player at ×2.5 against the
//! running Northshire player's ×1.0). Until that is measured, unregistered is the modelled default,
//! which is the state their traces show for a moving unit.
//! **We model REGISTERED, because that is the steady state of anything we draw.** Every unit benilla
//! renders has had its display model applied — which is exactly the node-present model-set that
//! registers it — so unregistered is the pre-display transient, not the resting state. wow-re's own
//! two frames bracket this and disagree with each other: a standing Stormwind player commits **×2.5**
//! (registered, MCSH-lit) while a running Northshire player commits ×1.0
//! (`unit-mcsh-shadow-target.md` §4). Their note marks the discriminant **Open** and hands it to us as
//! a benilla-side observable — so a single hardwired value cannot be read off it in either direction,
//! and 0809 read off the ×1.0 half. The director's eye settles the tie the way the ×2.5 frame does: a
//! character standing in shade reads visibly dimmer than one in sun, and flattening that was wrong.
//!
//! The null fallback is therefore real, verified, and **deliberately not modelled** — see 0814. If we
//! ever want it, it is a lifecycle flag flipped by the display-model apply, never a per-kind constant.
//!
//! Structure mirrors the reference's one-light-node-per-object: [`GroundShade`] lives on the **net
//! entity root** (the `[obj+0xe0]` twin), sampled at the root's feet and ramped there; the resulting
@@ -51,10 +54,11 @@ use crate::terrain_stream::{doodad_ground_shade, ShadeResolve, TerrainStreamer};
// and the ambient word chase the interior bake fold consumes. The MCSH sample now only picks the
// OUTDOOR target; the classifier's interior verdict overrides it with the day/night point.
/// How fast the shade mix `t` (0 = lit, 1 = shaded) moves toward its target: the binary's linear step
/// `[0x810808] = 3.3333` intensity-units/s over its lit→shaded span (2.5 → 0.5), i.e. the reference's
/// full transition takes 0.6 s. Since 0354 the shader's levels ARE the byte 2.5/0.5, so the
/// normalized-by-span mix is exactly the binary's ramp, not just its timing.
/// How fast the shade mix `t` (0 = intensity 2.5, 1 = 0.5) moves toward its target: the binary's
/// linear step `[0x810808] = 3.3333` **intensity-units/s**, expressed on `t` by dividing by the 2.0-wide
/// span the mix covers. The rate is on the intensity axis and is unchanged by [`LIT_T`] — what 0821
/// changed is the *distance travelled* (1.0 → 0.5 rather than 2.5 → 0.5), so a lit→shaded transition
/// now takes 0.15 s of fully visible movement instead of 0.6 s that was 75 % invisible.
const SHADE_RAMP_PER_SEC: f32 = 3.3333 / 2.0;
/// Squared distance (yd²) the root must move before the MCSH bit is re-sampled — same gate as the
@@ -68,12 +72,31 @@ const RESAMPLE_DIST_SQ: f32 = 0.25;
const AMBIENT_RAMP_PER_SEC: f32 = 2.0;
/// The shade mix `t` encoding the DAY/NIGHT intensity 1.0: `intensity = mix(2.5, 0.5, t)` ⇒
/// `t = 0.75`. **Two distinct mechanisms land on this same value** and the difference matters if
/// either moves: an indoor entity's node target (the reference's interior `[+0xf8] = 1.0`, written at
/// `69e36b` behind the `[+0xc]&2` interior gate), and `0x672a20`'s null-node fallback for an
/// unregistered unit (no intensity multiply at all — see [`GroundShade::null_fallback`]).
/// `t = 0.75`. **Two distinct reference mechanisms land on this same value** and only ONE of them is
/// modelled here, so keep them apart if either moves: the live one is an indoor entity's node target
/// (the interior `[+0xf8] = 1.0`, written at `69e36b` behind the `[+0xc]&2` interior gate); the other
/// is `0x672a20`'s null-node fallback for an unregistered unit (no intensity multiply at all), which
/// 0814 records as real but deliberately unmodelled. A value shared by a live law and a shelved one is
/// exactly how 0809 talked itself into pinning every unit here.
const DAYNIGHT_T: f32 = 0.75;
/// The LIT outdoor target (decision 0821). The reference's lit target is intensity **2.5**, and this is
/// **1.0** — not because the 2.5 is wrong, but because our shader cannot express it: `wow_model.wgsl`
/// caps the gain with `min(intensity, 1.0)`, so every value from 2.5 down to 1.0 renders *identically*.
///
/// **Clamp the TARGET, not the gain.** With the cap on the gain, `t` ramped 0 → 1 over 0.6 s while the
/// first 0.75 of that journey (0.45 s) was pinned at 1.0 and therefore **invisible** — the shade change
/// read as a dead pause followed by a snap, which is what the director reported walking from sun into
/// shade ("delayed by ~1 s"). Aiming the chase at the value the renderer can actually show removes the
/// invisible stretch without touching a single rendered pixel of the settled states: lit still commits
/// 1.0 (the cap was already delivering that) and shadowed still commits 0.5.
///
/// This is therefore a faithful ramp over an unfaithful *range*, and the range is the open item: **the
/// day the `min(I, 1)` cap is lifted, this constant goes back to 0.0** and the full 2.5 → 0.5 sweep
/// becomes visible on its own. The two must move together — see 0821, and 0803 §3 for why the cap is
/// still there.
const LIT_T: f32 = 0.75;
/// Settled-ramp epsilon (on `t` and each ambient channel) — under half a tag/colour byte.
const RAMP_EPS: f32 = 1.0 / 640.0;
@@ -83,10 +106,11 @@ const RAMP_EPS: f32 = 1.0 / 640.0;
/// word chase (`[+0x9c]`→`[+0xf4]`) — the pair `0x69e770` steps every frame.
#[derive(Component)]
pub(crate) struct GroundShade {
/// Current shade mix (0 = intensity 2.5, 1 = 0.5; [`DAYNIGHT_T`] = the day/night 1.0) — what the
/// Current shade mix (1 = intensity 0.5; [`LIT_T`] = the lit 1.0 our shader can express, and also
/// [`DAYNIGHT_T`]'s day/night 1.0 — the two coincide while the gain cap stands) — what the
/// parts' tags show, and what the interior bake fold scales its diffuse word by.
t: f32,
/// Where `t` is ramping to (0/1 from the last MCSH sample outdoors; [`DAYNIGHT_T`] indoors).
/// Where `t` is ramping to ([`LIT_T`]/1 from the last MCSH sample outdoors; [`DAYNIGHT_T`] indoors).
target: f32,
/// Root position at the last sample (the movement gate).
last_pos: Vec3,
@@ -97,7 +121,8 @@ pub(crate) struct GroundShade {
pub(crate) indoor: bool,
/// Standing on an outdoor-class WMO surface (street/deck/porch — `MOGI & 0x48`), published by
/// the classifier: the MCSH verdict of the terrain BENEATH the building is overridden by the
/// lit target 0 (intensity 2.5) — byte-verified (0477/0480, wow-re `unit-wmo-mcsh-gate.md`):
/// lit target ([`LIT_T`]; the reference's own value here is intensity 2.5) — byte-verified
/// (0477/0480, wow-re `unit-wmo-mcsh-gate.md`):
/// the down-ray attach's WMO branch sets the skip-shadow bit `[node+0xd]|=0x2` (`0x6a8bc7`,
/// every node subclass), the terrain branch clears it (`0x6a8bed`), and the exterior intensity
/// leg commits the constant 2.5 whenever it's set (`0x69e483`→`0x69e4ad` — the MCSH sample
@@ -109,19 +134,6 @@ pub(crate) struct GroundShade {
/// entry (from the scene ambient, so walking into a warm room ramps rather than pops).
pub(crate) ambient: Vec3,
pub(crate) ambient_target: Vec3,
/// This root's drawn model takes `0x672a20`'s **null-node fallback** — true for units and
/// players, false for GameObjects (0809; wow-re `unit-mcsh-shadow-target.md` §3/§4). The
/// fallback commits the raw day/night pair with no per-node multiply, so the intensity is a
/// hardwired ×1.0 that never samples MCSH and cannot vary with position: `t` pins to
/// [`DAYNIGHT_T`] outdoors instead of chasing the terrain verdict. The MCSH sample still runs
/// (it is cheap, gated on movement, and `self.target` stays live) so that the day this models
/// the registration lifecycle — the one piece wow-re handed back to us — a registered unit
/// resumes the real chase with no state to rebuild.
///
/// Set once from the entity kind at node attach; a live display-id swap cannot change the kind
/// (`entities::attach`, decision 0776 makes the same argument for `ContainmentAttach`), which is
/// why `insert_if_new` keeping the old value is correct rather than merely tolerable.
null_fallback: bool,
/// The last effective target the `WOW_INTERIOR_LOG` instrument printed (log-on-change; starts
/// off-scale so the first resolved target always prints).
logged_target: f32,
@@ -130,32 +142,20 @@ pub(crate) struct GroundShade {
impl Default for GroundShade {
fn default() -> Self {
Self {
t: 0.0,
target: 0.0,
t: LIT_T,
target: LIT_T,
last_pos: Vec3::ZERO,
sampled: false,
indoor: false,
on_wmo: false,
ambient: Vec3::ZERO,
ambient_target: Vec3::ZERO,
null_fallback: false,
logged_target: -1.0,
}
}
}
impl GroundShade {
/// The node state for a freshly attached root of this entity kind — the delivery split of
/// `0x672a20` (0809): a **GameObject** consumes its light node (the 2.5/0.5 terrain-shade
/// chain); a **unit or player** is born with its node unregistered and takes the null fallback's
/// flat day/night ×1.0. See [`Self::null_fallback`].
pub(crate) fn for_kind(kind: benilla_protocol::EntityKind) -> Self {
Self {
null_fallback: !matches!(kind, benilla_protocol::EntityKind::GameObject),
..Self::default()
}
}
/// The node's current committed intensity (`[node+0xa4]`): 2.5 lit → 0.5 MCSH-shadowed, the
/// day/night 1.0 at [`DAYNIGHT_T`] — the bake fold multiplies its diffuse word by this.
pub(crate) fn intensity(&self) -> f32 {
@@ -163,15 +163,16 @@ impl GroundShade {
}
/// The intensity chase's EFFECTIVE target: indoors the day/night point overrides the MCSH
/// verdict; a null-fallback root (unit/player) takes that same point everywhere outdoors,
/// because its commit never reads a per-node intensity at all; and on an outdoor-class WMO
/// surface the LIT point overrides (`self.target` keeps the raw sample, so a GameObject
/// stepping back onto terrain resumes from it).
/// verdict; on an outdoor-class WMO surface the LIT point overrides (`self.target` keeps the raw
/// sample, so a root stepping back onto terrain resumes from it); otherwise the MCSH verdict
/// stands, for a unit exactly as for a GameObject — the target law is byte-shared and single-site
/// (`69e4ad`/`69e496`, wow-re `unit-mcsh-shadow-target.md` §1) and we model the registered
/// delivery that consumes it (0814).
fn effective_target(&self) -> f32 {
if self.indoor || self.null_fallback {
if self.indoor {
DAYNIGHT_T
} else if self.on_wmo {
0.0
LIT_T
} else {
self.target
}
@@ -273,7 +274,7 @@ pub(crate) fn update_ground_shade(
if !shade.sampled || pos.distance_squared(shade.last_pos) >= RESAMPLE_DIST_SQ {
match doodad_ground_shade(&streamer, &adt_tiles, pos) {
ShadeResolve::Ready(shadowed) => {
shade.target = if shadowed { 1.0 } else { 0.0 };
shade.target = if shadowed { 1.0 } else { LIT_T };
shade.last_pos = pos;
if !shade.sampled {
// First landing: snap — an entity spawns already at its ground's shade
@@ -293,8 +294,8 @@ pub(crate) fn update_ground_shade(
// stepping back outside resumes from a fresh MCSH verdict.
let target = shade.effective_target();
// `WOW_INTERIOR_LOG=1`: one line whenever a node's intensity target moves — the live
// instrument for "which stage is this character actually in?" (exterior lit 2.5 ⇒ t 0,
// MCSH-shadowed 0.5 ⇒ t 1, day/night 1.0 ⇒ t 0.75).
// instrument for "which stage is this character actually in?" — MCSH-shadowed 0.5 ⇒ t 1;
// exterior lit and day/night both ⇒ t 0.75 / committed 1.0 while the gain cap stands (0821).
if (target - shade.logged_target).abs() > f32::EPSILON
&& std::env::var_os("WOW_INTERIOR_LOG").is_some()
{
@@ -346,9 +347,9 @@ pub(crate) fn update_ground_shade(
}
// The cards (0788's loose end). A card belongs to the same light node as the body it hangs off —
// the reference shades every batch of an object through one node (0778) — but it is a world root,
// so the walk above skips it and it kept the lit rung while its owner dimmed. Since 0809 that only
// ever showed on a **GameObject**: a unit's node is pinned to the day/night point, so its body no
// longer drops away from a card's default in the first place.
// so the walk above skips it and it kept the lit rung while its owner dimmed. 0811 scoped this to
// GameObjects because 0809 had pinned units flat; 0814 put units back on the chase, so it is once
// again every carried card — a torch's flame card dims with the hand that holds it.
for (card, mut tag) in &mut cards {
let Some(byte) = card
.follows()
+19 -5
View File
@@ -17,11 +17,25 @@
//! (`0x71c110` → collector `+0x184..`, lane by `[node+0xc]`; wow-re `m2-unit-interior-fog.md`).
//! Carried by BOTH indoor laws: [`probe_bits`] bakes it in, and the entity classifier ORs it
//! onto the Matte (day/night) payload. Masked off with bit 31 before the payload decode.
//! - **Rig slot** (bits 19..=29, BOTH payload modes — decision 0720): the instance's skin-palette
//! rig slot ([`crate::rig_palette`]), `0` = not skinned. The vertex stage reads it to index the
//! shared-buffer palette region; it is written ONCE at part spawn ([`rig_bits`]) and every
//! runtime writer below preserves it by construction (the `with_*` accessors carry bits the
//! writer doesn't own). 11 bits ⇔ [`MAX_RIG_SLOTS`] concurrent rigs.
//! - **Instance slot** (bits 19..=29, BOTH payload modes — decisions 0720, 0812, 0820): the
//! per-instance index into the shared buffer's slot-keyed regions. `0` is the world's shared
//! no-instance sentinel (terrain, WMO, doodads, clutter, and every part of a rig-less model). Two
//! consumers, reading it in different stages — which is why it is no longer called "the rig slot":
//! - the **vertex** stage indexes the skin-palette region with it ([`crate::rig_palette`], 0720)
//! — but only under `WOW_RIG_SKIN`, which `WowModelExt::specialize` compiles from the **mesh's
//! own joint attributes**, never from this field. A static mesh carrying a slot is not skinned.
//! - the **fragment** stage indexes the per-instance body-tint table with it
//! ([`crate::instance_tint`], 0812) — for every part, skinned or not.
//!
//! That asymmetry is why the field is written from the **unit** and not from the part: a unit's
//! boneless geosets, its helm and shoulders, its held items and its billboard cards all carry
//! their wearer's slot, so a tinted unit tints *whole* — the reference's own rule (an
//! attached/chained model inherits the parent CM2's computed colours, `0x714000`). Before 0820 the
//! field was skinning-only, and a dwarf's Stoneform tint stopped at his pauldrons.
//!
//! Written ONCE at part spawn ([`rig_bits`]); every runtime writer below preserves it by
//! construction (the `with_*` accessors carry bits the writer doesn't own). 11 bits ⇔
//! [`MAX_RIG_SLOTS`] concurrent instances.
//! - **Alpha** (bits 0..=5, BOTH payload modes): the fade alpha as a 6-bit fraction (`63` =
//! opaque), multiplying the cutout alpha. 64 steps is past perceptual for the sub-second fade
//! ramps that write it (it was u16 pre-0720 — the rig field bought its bits from here). A whole
+34 -9
View File
@@ -33,9 +33,14 @@ pub(super) fn rand_s11(state: &mut u32) -> f32 {
/// local WoW frame (Z up, origin at the emitter record's `position`). The caller applies the speed
/// roll and the space-mode transform.
///
/// - **Plane** (`0x7b8890`): position uniform in the ±½·area rectangle; direction a cone around +Z
/// with **symmetric** angles θ = S11·verticalRange, φ = S11·horizontalRange (the reference draws
/// ±range, not [0, range] — a one-sided cone tilted every flame the same way).
/// - **Plane** (`0x7b8890`): position uniform in the ±½·area rectangle — **local x takes
/// `areaLength`, local y takes `areaWidth`** (wow-re `part-shape-kernels.md` §4, VERIFIED:
/// `p = (r2·A_x·0.5, r1·A_y·0.5, 0)` with `A_x = rt+0x290 = EmissionAreaLength`,
/// `A_y = rt+0x294 = EmissionAreaWidth`). The pairing is only observable on an **anisotropic**
/// rectangle, which is why it stayed wrong through 0563/0566 — every emitter checked until
/// Gressil's blade smoke authored a square area. Direction: a cone around +Z with **symmetric**
/// angles θ = S11·verticalRange, φ = S11·horizontalRange (the reference draws ±range, not
/// [0, range] — a one-sided cone tilted every flame the same way).
/// - **Sphere** (`0x7b8d70`): radius uniform in [areaLength, areaWidth] (= min/max radius for this
/// shape), position on the shell at latitude S11·verticalRange / longitude S11·horizontalRange;
/// direction radial outward (flag `0x4000` ⇒ straight +Z instead).
@@ -96,10 +101,13 @@ pub(super) fn emit_local(def: &ParticleEmitterDef, rng: &mut u32) -> (Vec3, Vec3
let shell = Vec3::new(clat * clon, clat * slon, slat); // unit by construction
(r * shell, Some(shell))
} else {
// x ← areaLength (rt+0x290), y ← areaWidth (rt+0x294) — the VERIFIED pairing, see the
// Plane bullet above. Both draws are iid S11, so which one feeds which axis changes the
// rectangle's ORIENTATION but not its distribution; that is the whole bug.
(
Vec3::new(
rand_s11(rng) * 0.5 * def.area_width,
rand_s11(rng) * 0.5 * def.area_length,
rand_s11(rng) * 0.5 * def.area_width,
0.0,
),
None,
@@ -192,28 +200,45 @@ pub(crate) mod tests {
/// SYMMETRIC (θ = S11·range — wow-re `part-shape-kernels.md`'s correction of our old
/// [0, range) draw): with a wide sample, x-velocities must land on both signs. The
/// rectangle rides the R(+Z,90°) emitter frame (`emit_local`'s tail): the kernel's
/// width-along-x/length-along-y square lands width-along-y/length-along-x.
/// length-along-x/width-along-y rectangle lands **width-along-x, length-along-y**. Asserted on
/// an ANISOTROPIC area (2 × 4) so the pairing is actually pinned — a square area passes either
/// way, which is exactly how the swapped pairing survived 0563/0566 (Gressil's 0.1 × 1.1 blade
/// smoke drew the 1.1 yd curtain ACROSS the blade).
#[test]
fn plane_kernel_rect_bounds_and_symmetric_cone() {
let d = def(ParticleShape::Plane);
let mut rng = 12345u32;
let (mut neg, mut pos) = (false, false);
let (mut max_dx, mut max_dy) = (0.0f32, 0.0f32);
for _ in 0..256 {
let (p, dir) = emit_local(&d, &mut rng);
assert!(
(p.x - 1.0).abs() <= 1.0 + 1e-4,
"x within ±½·length of position (post-R)"
(p.x - 1.0).abs() <= 2.0 + 1e-4,
"x within ±½·width of position (post-R)"
);
assert!(
(p.y - 2.0).abs() <= 2.0 + 1e-4,
"y within ±½·width of position (post-R)"
(p.y - 2.0).abs() <= 1.0 + 1e-4,
"y within ±½·length of position (post-R)"
);
assert_eq!(p.z, 3.0);
assert!(dir.z > 0.0, "cone around +Z stays upward for range < π/2");
max_dx = max_dx.max((p.x - 1.0).abs());
max_dy = max_dy.max((p.y - 2.0).abs());
neg |= dir.x < -1e-3;
pos |= dir.x > 1e-3;
}
assert!(neg && pos, "symmetric cone covers both x signs");
// The PIN, not just the bound: the LONG extent must land on x. A swapped
// areaLength/areaWidth pairing caps `max_dx` at ½·length = 1.0 and pushes `max_dy` past it,
// so this pair of asserts is what fails on the 0563-era kernel.
assert!(
max_dx > 1.5,
"the wide extent (½·width = 2.0) rides x post-R, reached {max_dx}"
);
assert!(
max_dy <= 1.0 + 1e-4,
"the narrow extent (½·length = 1.0) rides y post-R, reached {max_dy}"
);
}
/// Sphere births sit on a shell with radius in [areaLength, areaWidth] (min/max radius for
+236 -9
View File
@@ -43,16 +43,19 @@ pub(super) struct BoothRider {
pub(super) offset: Vec3,
}
/// One character billboard batch headed into a booth bake — the undead/night-elf **eye-glow** (a
/// camera-facing quad on the eye bone, geoset 302 / geoset 0, additive-fullbright). The world path
/// splits these into camera-facing cards ([`crate::billboard`]); a booth is a *separate* camera, so
/// the booth seats the same centred quad on its billboard bone's joint and re-faces it to the booth
/// camera itself ([`face_booth_billboards`]). Its centred quad, fullbright material, the billboard
/// bone it rides, and the flag arm.
/// One camera-facing batch headed into a booth bake — the undead/night-elf **eye-glow** (a quad on
/// the eye bone, geoset 302 / geoset 0, additive-fullbright), or an equipped item's / item glow's own
/// such batch. The world path splits these into camera-facing cards ([`crate::billboard`]); a booth
/// is a *separate* camera, so the booth seats the same centred quad under its bone's joint and
/// re-faces it to the booth camera itself ([`face_booth_billboards`]). Its centred quad, material,
/// the bone it rides, where its pivot sits in that joint's frame, and the flag arm.
pub(super) struct BoothBillboardSpec {
pub(super) mesh: Handle<Mesh>,
pub(super) material: Handle<WowModelMaterial>,
pub(super) bone: u16,
/// The card pivot's offset under that bone's joint (Bevy axes). `ZERO` for a batch of the rigged
/// model itself — the joint frame already bakes the bone pivot (the 0130 rig identity).
pub(super) offset: Vec3,
pub(super) kind: BillboardKind,
}
@@ -64,6 +67,126 @@ pub(super) struct BoothBillboard {
kind: BillboardKind,
}
impl BoothBillboard {
/// A booth billboard **frame** — a [`BoothBillboard`] with no `Mesh3d`: an entity whose live
/// world rotation is a billboard bone's replaced palette basis about this booth's camera, for
/// the other consumers of that matrix to ride. It draws nothing; only its transform matters.
///
/// The booth twin of [`crate::billboard::BillboardCard::frame_following`], and the same
/// reasoning (decision 0813 §3, itself 0153's rule): same basis function, same law, one system
/// — not a second mechanism. Today's caller is the glue booth's equipment riders
/// ([`super::glue_booth`]): an item model spawns no rig, so nothing else would apply the
/// replacement to a particle emitter hanging under its billboard bone, and the reference folds
/// the emitter's record position through exactly this matrix (wow-re
/// `part-anchoring-live-bone.md` §1 row 3).
///
/// The caller owns the frame's **translation** (the billboard bone's pivot in its parent's
/// frame); [`face_booth_billboards`] writes only the rotation.
pub(super) fn frame(kind: BillboardKind) -> Self {
Self { kind }
}
}
/// One **effect-bearing model** headed into a booth bake, as its particle emitters plus the composed
/// seat: the body bone it rides and its offset in that bone's frame. The booth spawns one host there
/// and owns the emitters off it (the scene-brazier recipe, decision 0539 §5); any geometry the same
/// model carries rides the ordinary [`BoothRider`] / [`BoothBillboardSpec`] lists at the same seat.
///
/// The two lanes that feed it are the two lanes that mirror a dressed look — a live unit's
/// [`crate::portrait::PortraitEffects`] markers, and the glue bake's [`super::PreviewEffects`] —
/// and both mean the same two things: an equipped item's own emitters (decision 0813, `#bugs` B118)
/// and the `ItemVisuals` glow a held weapon hangs on its own attachment points (decision 0805).
pub(super) struct BoothEffects {
pub(super) bone: u16,
pub(super) offset: Vec3,
pub(super) emitters: Vec<benilla_assets::ModelEmitter>,
}
/// Spawn `effects` into a booth bake: one host per effect model, seated under its bone's `joints`
/// entry at the model's offset, with that model's emitters owned by the host. Returns
/// `(emitters, frames)` — how many emitters went up, and how many of them ride a billboard frame.
///
/// `light` is the booth's own light-storage buffer, bound onto every emitter
/// ([`crate::particles::buffer::EffectLightOverride`]) for the same reason a booth part gets a
/// material twin: the world buffer would fog and shade a pane by the world's time of day.
///
/// **Which booths call this is a fidelity split, not a cost one.** The body panes
/// (`<PlayerModel>` widgets — the character window's paper doll, the inspect twin) are *live* in the
/// reference: the widget renders through the per-frame `CSimpleModel` path whose light its ctor
/// stages every frame (`0x76d680`, decision 0638) — the same widget family as the animating glue
/// preview (decision 0423). The round unit-frame portraits are not: `SetPortraitTexture` bakes a
/// fresh instance in ONE draw and caches the texture by GUID/displayId, returning it with no
/// re-render (wow-re `portrait-render.md` §2). A one-frame draw of a freshly-born particle pool
/// yields nothing, so emitters have no place in a portrait — and a booth that owns emitters must
/// keep its camera awake ([`crate::portrait::Booth::live`]), which is exactly what a still should
/// never need to do.
pub(super) fn spawn_booth_effects(
commands: &mut Commands,
joints: &[Entity],
layer: &RenderLayers,
light: Option<&bevy::render::render_resource::Buffer>,
effects: &[BoothEffects],
) -> (usize, usize) {
let mut spawned = 0usize;
let mut frames = 0usize;
for fx in effects {
let Some(&joint) = joints.get(usize::from(fx.bone)) else {
continue; // bad bone index — bake the body without this model's effects
};
let host = commands
.spawn((
Transform::from_translation(fx.offset),
Visibility::default(),
ChildOf(joint),
layer.clone(),
))
.id();
for em in &fx.emitters {
// A **billboard** bone in the emitter's chain (decision 0813): its palette rows are
// replaced with the rendering camera's basis about the bone's own pivot, and children
// multiply onto that, so the origin is `pivot + camBasis·(position − pivot)`. A booth is
// its own camera, so the frame carrying the replacement is the booth twin of the world
// lane's card — a mesh-less [`BoothBillboard`], faced by [`face_booth_billboards`] and
// seated at the pivot under the host. No billboard in the chain (every shipped glow
// model, most items) → the host itself owns the emitter and the rest pose stands.
let owner = match em.billboard {
Some((kind, pivot)) => {
let frame = commands
.spawn((
Transform::from_translation(benilla_assets::coords::wow_to_bevy(pivot)),
layer.clone(),
ChildOf(host),
BoothBillboard::frame(kind),
))
.id();
frames += 1;
(frame, pivot)
}
None => (host, [0.0; 3]),
};
let Some(e) = crate::particles::spawn_emitter(
commands,
em,
Transform::IDENTITY,
Some(owner),
Some(host), // an attached model — its fan swings with the item
Some(host), // the cloud anchors at the MODEL; the bone composes births only
crate::particles::EmitClock::Pinned, // an item's effects loop forever
) else {
continue;
};
commands.entity(e).insert((layer.clone(), ChildOf(host)));
if let Some(buf) = light {
commands
.entity(e)
.insert(crate::particles::buffer::EffectLightOverride(buf.clone()));
}
spawned += 1;
}
}
(spawned, frames)
}
/// How the booth bake's `AnimationPlayer` runs. Portraits are a **still** ([`Self::Frozen`] — Stand
/// paused at t = 0, the ref bake); the char-create preview is a **live scene** ([`Self::Loop`] —
/// Stand looping), the one case where the ref screen itself animates (decision 0423).
@@ -249,8 +372,9 @@ pub(super) fn spawn_booth_model(
ChildOf(joint),
));
}
// The eye-glow (and any character billboard): seat the centred quad on its billboard bone's
// joint — the joint frame bakes the bone pivot, so the quad lands at the eye — and tag it for
// The camera-facing batches (the eye-glow, an item's gem, a glow model's quad): seat the centred
// quad under its bone's joint at the spec's offset — zero for the body's own batches, whose joint
// frame already bakes the bone pivot, so the quad lands at the eye — and tag it for
// [`face_booth_billboards`], which rewrites its rotation to the booth camera each frame. The
// rotation the joint carries here (its Stand pose) is countered there. A bone the rig lacks
// drops the card, like a rider.
@@ -261,7 +385,7 @@ pub(super) fn spawn_booth_model(
commands.spawn((
Mesh3d(bb.mesh.clone()),
MeshMaterial3d(bb.material.clone()),
Transform::IDENTITY,
Transform::from_translation(bb.offset),
layer.clone(),
ChildOf(joint),
BoothBillboard { kind: bb.kind },
@@ -372,6 +496,7 @@ mod tests {
weight: Some(benilla_formats::ScalarAnim {
period: 0.0,
step: true,
wrap: true, // period 0: a constant has no clock
keys: vec![(0.0, v)],
}),
}])
@@ -417,6 +542,108 @@ mod tests {
);
}
/// **The booth twin of the equipped-item emitter's billboard frame** (decision 0813, carried to
/// the select screen for `#bugs` B118). Same real numbers as
/// `billboard::tests::an_item_emitters_billboard_frame_puts_it_behind_the_pivot`
/// (`LShoulder_Mail_PVPAlliance_C_01`: billboard bone 1 pivot `(-0.012, 0.162, -0.060)`, sparkle
/// emitter `(-0.252, 0.178, -0.046)`, raw WoW model space), but through THIS system — and here the
/// frame is a **child**: the item's host sits under a posed body joint, so the law holds only if
/// the parent's rotation is cancelled rather than inherited. That cancellation is the one thing
/// that could silently be wrong on the booth path, so it is what the joint pose below is for.
///
/// Asserted: the frame sits AT the pivot; the emitter's composed origin is 0.24 yd along the
/// **booth** camera's view axis, away from the eye, with only centimetres off it; and it FOLLOWS
/// that camera — the half a rest-pose placement cannot do.
#[test]
fn a_booth_billboard_frame_puts_an_item_emitter_behind_its_pivot() {
use benilla_assets::coords::wow_to_bevy;
const PIVOT: [f32; 3] = [-0.012, 0.162, -0.060];
const EMITTER: [f32; 3] = [-0.252, 0.178, -0.046];
// What `spawn_emitter`'s pivot rebase stores: the chain offset, raw WoW axes.
let local = wow_to_bevy([
EMITTER[0] - PIVOT[0],
EMITTER[1] - PIVOT[1],
EMITTER[2] - PIVOT[2],
]);
let mut app = App::new();
app.add_systems(Update, face_booth_billboards);
let layer = RenderLayers::layer(9);
// The booth camera, aimed off every world axis so nothing can pass by coincidence.
let mut cam_tf = Transform::from_translation(Vec3::new(1.1, 1.6, 2.4))
.looking_at(Vec3::new(0.0, 1.1, 0.0), Vec3::Y);
let cam = app
.world_mut()
.spawn((
crate::portrait::BoothCam("glue".to_string()),
GlobalTransform::from(cam_tf),
layer.clone(),
))
.id();
// The item's host: the shoulder attach point under a joint holding a Stand-pose rotation.
let host_tf = Transform {
translation: Vec3::new(0.21, 1.42, 0.06),
rotation: Quat::from_euler(EulerRot::YXZ, 0.7, -0.3, 0.2),
scale: Vec3::ONE,
};
let host_gt = GlobalTransform::from(host_tf);
let host = app.world_mut().spawn(host_gt).id();
let frame = app
.world_mut()
.spawn((
Transform::from_translation(wow_to_bevy(PIVOT)),
layer.clone(),
ChildOf(host),
BoothBillboard::frame(BillboardKind::Spherical),
))
.id();
let pivot_world = host_gt.transform_point(wow_to_bevy(PIVOT));
// The system writes the frame's LOCAL transform; composing it onto the host is what
// propagation does in PostUpdate, before the particle sim reads the result.
let sparkle_offset = |app: &App| {
let local_tf = *app.world().entity(frame).get::<Transform>().unwrap();
let world = host_gt.mul_transform(local_tf).compute_transform();
assert!(
(world.translation - pivot_world).length() < 1e-5,
"the frame sits AT the billboard pivot — only the rotation is replaced"
);
world.transform_point(local) - pivot_world
};
app.update();
let fwd = *cam_tf.forward();
let sparkle = sparkle_offset(&app);
assert!(
(sparkle.dot(fwd) - 0.240).abs() < 2e-3,
"0.24 yd along the BOOTH camera's view axis, away from the eye: {sparkle:?}"
);
assert!(
(sparkle - fwd * sparkle.dot(fwd)).length() < 0.025,
"…and all but ~2 cm of the offset is in that one axis: {sparkle:?}"
);
// Move the booth camera (the preview's yaw drag does exactly this relative to the model):
// the offset must follow it. A rest-pose placement would return the same vector twice.
cam_tf = Transform::from_translation(Vec3::new(-2.6, 1.0, 0.4))
.looking_at(Vec3::new(0.0, 1.1, 0.0), Vec3::Y);
app.world_mut()
.entity_mut(cam)
.insert(GlobalTransform::from(cam_tf));
app.update();
let turned = sparkle_offset(&app);
let fwd2 = *cam_tf.forward();
assert!(
(turned.dot(fwd2) - 0.240).abs() < 2e-3
&& (turned - fwd2 * turned.dot(fwd2)).length() < 0.025,
"the same 0.24 yd, now along the new view axis: {turned:?}"
);
assert!(
(turned - sparkle).length() > 0.2,
"…which means it MOVED — a rest-pose placement would not have"
);
}
/// An unmarked part is not ours to write: the world lane's parts carry `MatAnim` too, and their
/// alpha is composed by the visibility authority against the fade and the interior classifier.
#[test]
+78 -68
View File
@@ -27,9 +27,9 @@ use crate::terrain::WowModelMaterial;
use super::framing::{attachment_point, DIAG_TO_VERT};
use super::{
aim, body_frame, new_target_image, spawn_booth_model, Booth, BoothBillboardSpec, BoothCam,
BoothLight, BoothMotion, BoothPart, BoothRider, Booths, PortraitImages, PortraitSource,
GLUE_LAYER,
aim, body_frame, new_target_image, spawn_booth_effects, spawn_booth_model, Booth,
BoothBillboardSpec, BoothCam, BoothEffects, BoothLight, BoothMotion, BoothPart, BoothRider,
Booths, PortraitImages, PortraitSource, GLUE_LAYER,
};
/// The glue booth slot token (its key in [`super::PortraitImages`] / [`Booths`]).
@@ -293,28 +293,50 @@ pub(crate) struct PreviewRider {
pub(crate) offset: Vec3,
}
/// One character billboard batch on the assembled preview — the undead/night-elf **eye-glow** (the
/// world path's camera-facing card, [`crate::billboard`]). The booth is a separate camera, so the
/// glow can't ride the world card system; it's rebuilt here for the booth's own camera
/// ([`super::booth::face_booth_billboards`]). Its centred quad, fullbright material, and the
/// billboard bone/flag it rides.
/// One **camera-facing batch** on the assembled preview — the world path's billboard card
/// ([`crate::billboard`]). The booth is a separate camera, so such a batch can't ride the world card
/// system; it's rebuilt here for the booth's own camera ([`super::booth::face_booth_billboards`]).
/// Its centred quad, its material, and the billboard bone/flag it rides.
///
/// Three sources, all seated the same way — under the body bone's joint at [`Self::offset`]:
///
/// - The character's own **eye-glow** (undead/night-elf, geoset 302). Seated on its billboard
/// bone's joint, whose frame already bakes the bone pivot (the 0130 rig identity), so its offset
/// is `ZERO` — the rigged case, exactly like the world's `BillboardCard::following_joint`.
/// - An **equipped item's** camera-facing batch — a wand's gem, a glowing rune on a weapon. 270 of
/// the 2681 `Item\` models author one. An item model gets no rig here, so its offset carries the
/// attach point *and* the batch's own model-local pivot.
/// - An **item glow's** camera-facing batch (decision 0805) — `Spells\Enchantments\Sparkle_A.m2` is
/// exactly one such quad and nothing else, and `ItemVisuals` 28 (10 item displays) hangs it. Same
/// rig-less seat, plus the glow slot's offset on the weapon model.
#[derive(Clone)]
pub(crate) struct PreviewBillboard {
pub(crate) mesh: Handle<Mesh>,
pub(crate) material: Handle<WowModelMaterial>,
pub(crate) bone: u16,
/// Where the card's **pivot** sits in that bone's joint frame (Bevy axes). `ZERO` for a batch of
/// the rigged body itself — its joint already bakes the pivot.
pub(crate) offset: Vec3,
pub(crate) kind: benilla_formats::BillboardKind,
}
/// One **item glow** on the assembled preview (decision 0805): the `Spells\Enchantments\*.mdx`
/// effect a held weapon's `ItemVisuals` id hangs on the weapon's own attachment point. Carried as
/// the glow model's particle emitters — all but three of the 35 shipped glow models are pure
/// emitters — plus the composed seat: the BODY bone the weapon rides, and the offset of the
/// weapon's own attachment point expressed in that bone's frame. The booth spawns one host there
/// and owns the emitters off it, exactly as [`sync_glue_scene`] does for a backdrop's braziers; a
/// glow's rare geometry rides the ordinary [`PreviewRider`] list at the same seat.
/// One **effect-bearing model** on the assembled preview, carried as its particle emitters plus the
/// composed seat: the BODY bone it rides and its offset expressed in that bone's frame. The booth
/// spawns one host there and owns the emitters off it, exactly as [`sync_glue_scene`] does for a
/// backdrop's braziers; any geometry the same model carries rides the ordinary [`PreviewRider`]
/// list at the same seat.
///
/// Two sources produce these, and the booth treats them identically:
///
/// - **An equipped item's own emitters** — the R14 PVP pauldron's `SPARKLE` twinkle, the held
/// torch's flame (decision 0813; `#bugs` B118 is *this* case, and the select screen showed
/// nothing at all until it was carried here). Seat = the body's attach point for that slot.
/// - **An item glow** (decision 0805) — the `Spells\Enchantments\*.mdx` effect a held weapon's
/// `ItemVisuals` id hangs on the weapon's own attachment point; all but three of the 35 shipped
/// glow models are pure emitters. Seat = the body attach point **plus** the slot's offset on the
/// weapon model.
#[derive(Clone)]
pub(crate) struct PreviewGlow {
pub(crate) struct PreviewEffects {
pub(crate) bone: u16,
pub(crate) offset: Vec3,
pub(crate) emitters: Vec<benilla_assets::ModelEmitter>,
@@ -331,10 +353,12 @@ pub(crate) struct GluePreviewBake {
pub(crate) display_id: u32,
pub(crate) parts: Vec<PreviewPart>,
pub(crate) riders: Vec<PreviewRider>,
/// The held items' glow effects (decision 0805) — see [`PreviewGlow`].
pub(crate) glows: Vec<PreviewGlow>,
/// The look's character billboard batches (the eye-glow) — geoset-gated like `parts`, but seated
/// on their billboard bone's joint and camera-faced by the booth, not drawn as plain body meshes.
/// Every emitter-bearing model the look wears — the equipped items' own effects (decision 0813)
/// and the held items' glows (decision 0805). See [`PreviewEffects`].
pub(crate) effects: Vec<PreviewEffects>,
/// The look's camera-facing batches (the eye-glow, a wand's gem, a glow model's quad) — seated at
/// their seat under a body joint and faced to the booth camera, not drawn as plain meshes. See
/// [`PreviewBillboard`].
pub(crate) billboards: Vec<PreviewBillboard>,
/// Per-hand weapon grip `[right, left]` — a hand whose attach point holds a weapon closes into the
/// `HandsClosed` finger pose (wow-re `hand-grip-mechanism.md`). The builder knows the held items'
@@ -398,6 +422,9 @@ pub(super) fn spawn_glue_booth(
// active exactly while a glue scene shows (the scene is LIVE — looping animation,
// global sequences, particle emitters — so it renders continuously, unlike the stills).
wake: 0,
// …which is `live_scene` in the gate, keyed on a scene being up rather than on this
// flag: the glue booth is live from the moment a screen shows, before any bake.
live: false,
pending: Vec::new(),
},
);
@@ -827,8 +854,9 @@ pub(super) fn sync_glue_booth(
offset: r.offset,
})
.collect();
// The eye-glow billboards, relit onto the booth's buffer like everything else (harmless —
// the glow batch is fullbright, so it ignores the buffer; the variant just keeps parity).
// The camera-facing batches (the eye-glow, a wand's gem, a glow model's quad), relit onto the
// booth's buffer like everything else (harmless where the batch is fullbright — the variant
// just keeps parity).
let booth_billboards: Vec<BoothBillboardSpec> = bake
.billboards
.iter()
@@ -836,6 +864,7 @@ pub(super) fn sync_glue_booth(
mesh: b.mesh.clone(),
material: relight(&b.material, &mut scene, &mut booth_light, &mut materials),
bone: b.bone,
offset: b.offset,
kind: b.kind,
})
.collect();
@@ -855,53 +884,34 @@ pub(super) fn sync_glue_booth(
bake.grip, // close each hand that draws a weapon (the ref's paperdoll rule)
&booth_billboards,
);
// The held items' glows (decision 0805): one host per glow at its weapon's attachment
// point, with the effect model's emitters owned by it — the scene-brazier recipe (0539
// §5), so they billboard against THIS camera and fog on the scene's own light. Children
// of the booth root through their joint, so the next re-bake's `despawn_related` reaps
// them. The reference glows a weapon at character select through the very same attach
// primitive it uses in the world (`0x472c91` → `0x47a0c0` → `0x4798c0`), and the enum
// carries no enchant, so what shows here is the item's intrinsic visual only.
let mut glow_emitters = 0usize;
for glow in &bake.glows {
let Some(&joint) = joints.get(glow.bone as usize) else {
continue;
};
let host = commands
.spawn((
Transform::from_translation(glow.offset),
Visibility::default(),
ChildOf(joint),
booth.layer.clone(),
))
.id();
for em in &glow.emitters {
let Some(e) = crate::particles::spawn_emitter(
&mut commands,
em,
Transform::IDENTITY,
Some((host, [0.0; 3])), // whole-model owner: a glow model poses at rest
Some(host), // an attached model — it swings with the weapon
Some(host),
crate::particles::EmitClock::Pinned, // the glow loops forever
) else {
continue;
};
commands
.entity(e)
.insert((booth.layer.clone(), ChildOf(host)));
if let Some(buf) = &scene_buf {
commands
.entity(e)
.insert(crate::particles::buffer::EffectLightOverride(buf.clone()));
}
glow_emitters += 1;
}
}
if glow_emitters > 0 {
// The worn items' effects: an equipped item model's OWN emitters (decision 0813 — the R14
// pauldron's sparkle, the torch's flame; `#bugs` B118) and the held weapons' `ItemVisuals`
// glows (decision 0805). One host per effect model at its seat, emitters owned by it — the
// scene-brazier recipe (0539 §5), so they draw against THIS camera and fog on the scene's own
// light. Children of the booth root through their joint, so the next re-bake's
// `despawn_related` reaps them. The reference reaches both through the very same attach
// primitive it uses in the world (`0x472c91` → `0x47a0c0` → `0x4798c0`), and the enum carries
// no enchant, so what shows here is each item's intrinsic effects only.
let (fx_emitters, fx_frames) = spawn_booth_effects(
&mut commands,
&joints,
&booth.layer,
scene_buf.as_ref(),
&bake
.effects
.iter()
.map(|fx| BoothEffects {
bone: fx.bone,
offset: fx.offset,
emitters: fx.emitters.clone(),
})
.collect::<Vec<_>>(),
);
if fx_emitters > 0 {
info!(
"glue booth: {glow_emitters} item-glow emitter(s) up on {} glow instance(s)",
bake.glows.len()
"glue booth: {fx_emitters} item emitter(s) up on {} effect model(s), \
{fx_frames} on a billboard frame",
bake.effects.len()
);
}
// The character-only fallback framing (no scene up — art missing / still loading): the
+421 -68
View File
@@ -80,11 +80,14 @@ mod framing;
pub(crate) use framing::{attachment_point, head_anchor, PortraitAnchors};
use framing::{body_frame, frame, PORTRAIT_FOV};
mod booth;
use booth::{spawn_booth_model, BoothBillboardSpec, BoothMotion, BoothPart, BoothRider};
use booth::{
spawn_booth_effects, spawn_booth_model, BoothBillboardSpec, BoothEffects, BoothMotion,
BoothPart, BoothRider,
};
mod glue_booth;
pub(crate) use glue_booth::{
CreateLook, GlueLook, GluePreview, GluePreviewBake, GlueScene, PreviewBillboard, PreviewGlow,
PreviewPart, PreviewRider, SelectLook, GLUE_SLOT,
CreateLook, GlueLook, GluePreview, GluePreviewBake, GlueScene, PreviewBillboard,
PreviewEffects, PreviewPart, PreviewRider, SelectLook, GLUE_SLOT,
};
mod light;
use light::{material_variant, model_pane_light_rows, studio_light_rows, BoothLight};
@@ -161,6 +164,11 @@ pub(crate) struct PortraitPart {
/// sits — the body bone it rides and the attach-point offset under that bone. The posed booth
/// seats the rider under its throwaway skeleton's joint (so it rides the Stand pose exactly like
/// the world instance rides its gait); the ref resets the attach *sockets* the same way (RE C3).
///
/// It also rides a **mirror-only carrier** — a marker child that draws nothing — where the world
/// geometry it names is not a unit descendant we can stamp: an item glow's own render batches, which
/// the shared effect lane spawns under its own rig ([`crate::entities::item_glow`], decision 0822).
/// A booth bakes those at the bind pose, like any other rider.
#[derive(Component)]
pub(crate) struct PortraitRider {
pub(crate) static_mesh: Handle<Mesh>,
@@ -171,23 +179,85 @@ pub(crate) struct PortraitRider {
pub(crate) offset: Vec3,
}
/// Stamped on a lightweight **anchor child** the attach path plants under a unit for each of its
/// character billboard batches — the undead/night-elf **eye-glow** (geoset 302 / geoset 0, a
/// camera-facing fullbright quad on the eye bone). The visible world card is a *root-spawned*
/// entity ([`crate::billboard::BillboardCard`]), never a unit descendant, so it can't be mirrored;
/// this marker rides the unit's tree purely so the portrait / paper-doll booths (which mirror the
/// unit's dressed descendants) can rebuild the glow as a booth card ([`BoothBillboardSpec`] +
/// Stamped on a lightweight **anchor child** the attach path plants under a unit for each
/// camera-facing batch it dresses in. The visible world card is a *root-spawned* entity
/// ([`crate::billboard::BillboardCard`]), never a unit descendant, so it can't be mirrored; this
/// marker rides the unit's tree purely so the portrait / paper-doll booths (which mirror the unit's
/// dressed descendants) can rebuild the batch as a booth card ([`BoothBillboardSpec`] +
/// [`booth::face_booth_billboards`]) — the same reconstruction the char-create glue path does from
/// its own parts. Carries the centred quad, its fullbright material, and the billboard bone/flag.
/// its own parts ([`PreviewBillboard`]). Carries the centred quad, its material, where it sits, and
/// the billboard flag.
///
/// Three sources plant one, exactly as on the glue path (decision 0822):
///
/// - The character's own **eye-glow** (undead/night-elf, geoset 302 / geoset 0 — a fullbright quad
/// on the eye bone), planted by [`crate::entities::attach`].
/// - An **equipped item's** camera-facing batch — a wand's gem, the held torch's `GLOWWHITE32` halo
/// (270 of the 2681 `Item\` models author one), planted by
/// [`crate::entities::equipment`]'s attach.
/// - An **item glow's** batch (decision 0805) — `Spells\Enchantments\Sparkle_A.m2` is one additive
/// quad and nothing else — planted by [`crate::entities::item_glow`].
#[derive(Component)]
pub(crate) struct PortraitBillboard {
pub(crate) mesh: Handle<Mesh>,
pub(crate) material: Handle<WowModelMaterial>,
/// The billboard bone (the eye bone) whose booth joint the card seats on.
/// The BODY bone whose booth joint the card seats on — the eye bone for the character's own
/// glow, the item's attach-point bone for anything an item wears.
pub(crate) bone: u16,
pub(crate) seat: PortraitSeat,
pub(crate) kind: benilla_formats::BillboardKind,
}
/// Whose model a mirrored camera-facing batch belongs to — which decides both where its pivot comes
/// from and whether a *mounted* unit's booth keeps it.
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) enum PortraitSeat {
/// A batch of the **rigged host model** itself (the eye-glow). Its bone's joint frame already
/// bakes the bone pivot (the 0130 rig identity), so it needs no offset — and it belongs to that
/// model's body, so a mount's own glow card prunes with the mount's meshes (a portrait shows the
/// rider alone, never the horse — decision 0441).
Body,
/// A batch of a **rig-less rider** — an equipped item's, an item glow's — at its seat in the
/// bone's joint frame (Bevy axes): the attach point **plus the batch's own model-local pivot**,
/// because with no rig nothing else will bake that pivot. Collected like a [`PortraitRider`]
/// whatever the mount state: while mounted, the character's own joints re-root INSIDE the mount
/// subtree, and the rider's gear must survive that.
Rider(Vec3),
}
impl PortraitSeat {
/// The offset a booth seats this batch at under its bone's joint.
pub(crate) fn offset(self) -> Vec3 {
match self {
PortraitSeat::Body => Vec3::ZERO,
PortraitSeat::Rider(at) => at,
}
}
}
/// Stamped on an **effect-bearing model** riding a unit — the equipped item whose own emitters are
/// its whole look (the R14 PVP pauldron's `SPARKLE` twinkle, the held torch's flame — decision 0813,
/// `#bugs` B118) and the `ItemVisuals` glow a held weapon hangs on its own attachment points
/// (decision 0805). The world emitters are *free* entities the owner contract walks
/// ([`crate::particles::spawn_emitter`]), never unit descendants, so — like [`PortraitBillboard`] —
/// this marker is how a booth learns they exist at all: the mirror carries the emitter records plus
/// the composed seat, and the booth spawns its own copies against its own camera
/// ([`booth::spawn_booth_effects`]). The glue path's [`PreviewEffects`] is the same carry assembled
/// from data instead of mirrored from entities.
///
/// Which booths act on it is a *fidelity* split, not a cost one — see
/// [`booth::spawn_booth_effects`]: the body panes are live `<PlayerModel>` widgets in the reference,
/// the round portraits a one-shot cached bake.
#[derive(Component)]
pub(crate) struct PortraitEffects {
/// The BODY bone whose booth joint the effect model's host seats on.
pub(crate) bone: u16,
/// The host's offset in that bone's joint frame (Bevy axes) — the item's attach point, plus the
/// glow slot's offset on the item's own model when this is a glow.
pub(crate) offset: Vec3,
pub(crate) emitters: Vec<benilla_assets::ModelEmitter>,
}
/// What a portrait slot currently shows — the booth's live bake, or the ref's 2D stand-in file
/// while a unit's model is still streaming in (RE C5).
#[derive(Clone, PartialEq)]
@@ -241,10 +311,50 @@ impl Default for InspectBooth {
}
}
/// The key identifying what a booth currently has baked: the (mesh, material) identity of every
/// mirrored [`PortraitPart`]. Any change in the unit's dressed look (gear swap, appearance refresh,
/// different unit) changes the key → re-bake.
type PartsKey = Vec<(AssetId<Mesh>, AssetId<WowModelMaterial>)>;
/// The key identifying what a booth currently has baked. Any change in the unit's dressed look
/// (gear swap, appearance refresh, different unit) changes it → re-bake.
#[derive(PartialEq)]
struct LookKey {
/// The (mesh, material) identity of every mirrored draw — [`PortraitPart`],
/// [`PortraitRider`], [`PortraitBillboard`].
draws: Vec<(AssetId<Mesh>, AssetId<WowModelMaterial>)>,
/// One entry per mirrored [`PortraitEffects`]: its seat (bone + offset bits) and how many
/// emitters it carries. Effects carry no asset handle to key on, and they can arrive **later**
/// than the meshes they ride with — an item glow resolves asynchronously
/// ([`crate::entities::item_glow`]: a template round trip, then the effect model's own load) —
/// so a key blind to them would leave the glow out of the bake until some unrelated edge forced
/// a re-bake.
effects: Vec<(u16, [u32; 3], usize)>,
}
impl LookKey {
/// The key for one mirrored dressed look, in the order [`DressedLook::collect`] returns it.
fn build(
parts: &[&PortraitPart],
riders: &[&PortraitRider],
billboards: &[&PortraitBillboard],
effects: &[&PortraitEffects],
) -> Self {
LookKey {
draws: parts
.iter()
.map(|p| (p.static_mesh.id(), p.material.id()))
.chain(riders.iter().map(|r| (r.static_mesh.id(), r.material.id())))
.chain(billboards.iter().map(|b| (b.mesh.id(), b.material.id())))
.collect(),
effects: effects
.iter()
.map(|e| {
(
e.bone,
e.offset.to_array().map(f32::to_bits),
e.emitters.len(),
)
})
.collect(),
}
}
}
/// Per-slot booth: its render layer, the model-root entity (children = the baked model's meshes),
/// its render-target image (so the bridge can flip back to `Live` after a `File` stand-in), and the
@@ -253,12 +363,17 @@ struct Booth {
layer: RenderLayers,
root: Entity,
target: Handle<Image>,
baked: Option<PartsKey>,
baked: Option<LookKey>,
/// Demand-render window (decision 0540): frames [`gate_booth_cameras`] still keeps this
/// booth's camera active. Armed to [`BOOTH_SETTLE_FRAMES`] by every content edge (bake,
/// empty, framing/yaw write); 0 with `pending` drained = the camera sleeps and the target
/// keeps the last render — a still costs nothing per frame.
wake: u32,
/// The bake standing in this booth contains **live** content — particle emitters
/// ([`booth::spawn_booth_effects`]) — so its camera never sleeps: a still of a cloud mid-birth
/// is not what the pane shows. The generalization of the glue booth's own always-on rule
/// (`live_scene` below, decision 0540); only the body panes can set it (decision 0822).
live: bool,
/// Textures the last bake referenced that were not yet resident: the camera stays awake
/// until each lands (an `mpq://` image arriving after the bake would otherwise be frozen
/// OUT of the still forever), then renders one final resident frame.
@@ -281,9 +396,23 @@ fn booth_log() -> bool {
/// many parts/riders did the bake see, and did it ever re-bake when the rest arrived".
/// `verb`: `bake` committed · `wait` abandoned (a source material was not resident, 0744) ·
/// `stand-in` the 2D fallback while no parts are attached · `empty` the booth was cleared.
fn log_bake(token: &str, verb: &str, parts: usize, riders: usize, billboards: usize) {
fn log_bake(
token: &str,
verb: &str,
parts: &[&PortraitPart],
riders: &[&PortraitRider],
billboards: &[&PortraitBillboard],
effects: &[&PortraitEffects],
) {
if booth_log() {
eprintln!("[booth] {token} {verb} parts={parts} riders={riders} billboards={billboards}");
eprintln!(
"[booth] {token} {verb} parts={} riders={} billboards={} fx={}/{}",
parts.len(),
riders.len(),
billboards.len(),
effects.len(),
effects.iter().map(|e| e.emitters.len()).sum::<usize>(),
);
}
}
@@ -550,6 +679,7 @@ fn setup_booths(
target: image,
baked: None,
wake: 0,
live: false,
pending: Vec::new(),
},
);
@@ -613,6 +743,7 @@ fn setup_booths(
target: image,
baked: None,
wake: 0,
live: false,
pending: Vec::new(),
},
);
@@ -638,11 +769,12 @@ struct DressedLook<'w, 's> {
parts: Query<'w, 's, &'static PortraitPart>,
riders: Query<'w, 's, &'static PortraitRider>,
billboards: Query<'w, 's, &'static PortraitBillboard>,
effects: Query<'w, 's, &'static PortraitEffects>,
mounts: Query<'w, 's, (), With<crate::entities::mount::MountBody>>,
}
impl DressedLook<'_, '_> {
/// Walk `unit`'s descendants once, collecting its part + rider children. Both empty while the
/// Walk `unit`'s descendants once, collecting its part + rider children. All empty while the
/// unit's model is still loading / cube-fallback (no attach path has spawned the parts yet).
///
/// A mounted unit's MOUNT child (decision 0441) is a second creature under the unit — a
@@ -651,7 +783,9 @@ impl DressedLook<'_, '_> {
/// skipped by pruning on the [`mount::MountBody`] marker; **riders** stay collected from the
/// whole tree, because the rider's own helm/shoulder/held riders hang under the rider's
/// joints, which re-root under the mount's seat anchor INSIDE the mount subtree while
/// mounted — and a mount model never carries equipment riders of its own.
/// mounted — and a mount model never carries equipment riders of its own. An item's
/// camera-facing batches and effects hang off those same joints, so they follow the rider rule,
/// not the part rule; only the CHARACTER's own billboard (the eye-glow) prunes with the body.
fn collect(
&self,
unit: Entity,
@@ -659,10 +793,12 @@ impl DressedLook<'_, '_> {
Vec<&PortraitPart>,
Vec<&PortraitRider>,
Vec<&PortraitBillboard>,
Vec<&PortraitEffects>,
) {
let mut parts = Vec::new();
let mut riders = Vec::new();
let mut billboards = Vec::new();
let mut effects = Vec::new();
let mut stack: Vec<(Entity, bool)> = vec![(unit, false)];
while let Some((e, mut in_mount)) = stack.pop() {
in_mount |= self.mounts.contains(e);
@@ -670,20 +806,29 @@ impl DressedLook<'_, '_> {
if let Ok(p) = self.parts.get(e) {
parts.push(p);
}
// The eye-glow rides the character, not the mount (a portrait shows the rider
// alone), so it prunes on `in_mount` exactly like a body part.
if let Ok(b) = self.billboards.get(e) {
billboards.push(b);
}
}
if let Ok(r) = self.riders.get(e) {
riders.push(r);
}
// A camera-facing batch prunes by whose model it is ([`PortraitSeat`]): a mount's own
// glow card goes with the mount's meshes, an item's card rides an attach joint that
// re-roots INSIDE the mount subtree while mounted and must survive it like a rider.
if let Ok(b) = self.billboards.get(e) {
if !in_mount || b.seat != PortraitSeat::Body {
billboards.push(b);
}
}
// Never pruned: every publisher of this marker is an ITEM's model (its own emitters, or
// its `ItemVisuals` glow's), seated on an attach joint — the rider rule. Nothing
// publishes a mount's own emitters, so there is no mount case to exclude.
if let Ok(fx) = self.effects.get(e) {
effects.push(fx);
}
if let Ok(c) = self.children.get(e) {
stack.extend(c.iter().map(|child| (child, in_mount)));
}
}
(parts, riders, billboards)
(parts, riders, billboards, effects)
}
}
@@ -759,9 +904,9 @@ fn sync_portraits(
};
// The unit's dressed look: its attach-spawned part children (geosets filtered, composited
// materials) + its bone riders (helm/shoulders/held — grandchildren under joint entities)
// + its eye-glow billboard anchors, one descendants walk. Parts empty while the model is
// still loading / cube-fallback.
let (parts, riders, billboards) = look.collect(unit);
// + the camera-facing batches and effect models it wears, one descendants walk. Parts empty
// while the model is still loading / cube-fallback.
let (parts, riders, billboards, effects) = look.collect(unit);
if parts.is_empty() {
// Model not attached yet → the ref's own 2D stand-in (RE C5): sex/race for a player
// body, the Monster art for a creature (our pick — the ref's `-Pet` file belongs to
@@ -773,12 +918,7 @@ fn sync_portraits(
}
continue;
}
let key: PartsKey = parts
.iter()
.map(|p| (p.static_mesh.id(), p.material.id()))
.chain(riders.iter().map(|r| (r.static_mesh.id(), r.material.id())))
.chain(billboards.iter().map(|b| (b.mesh.id(), b.material.id())))
.collect();
let key = LookKey::build(&parts, &riders, &billboards, &effects);
if booth.baked.as_ref() != Some(&key) {
let display_id = ent_q.get(unit).ok().and_then(|n| n.display_id);
// The look changed — re-bake: studio-lit twins of the exact dressed materials, posed
@@ -792,9 +932,10 @@ fn sync_portraits(
log_bake(
token,
"wait-anchors",
parts.len(),
riders.len(),
billboards.len(),
&parts,
&riders,
&billboards,
&effects,
);
continue;
};
@@ -822,14 +963,16 @@ fn sync_portraits(
offset: r.offset,
})
.collect();
// The eye-glow, seated on its eye bone's booth joint and camera-faced by the booth
// (relit onto the studio buffer like everything else — harmless, the glow is fullbright).
// The camera-facing batches — the eye-glow on its eye bone, an item's gem/halo at its
// attach point — seated on their bone's booth joint and camera-faced by the booth (relit
// onto the studio buffer like everything else; harmless where the batch is fullbright).
let booth_billboards: Vec<BoothBillboardSpec> = billboards
.iter()
.map(|b| BoothBillboardSpec {
mesh: b.mesh.clone(),
material: booth_light.studio.variant(&b.material, &mut wow_mats),
bone: b.bone,
offset: b.seat.offset(),
kind: b.kind,
})
.collect();
@@ -860,11 +1003,21 @@ fn sync_portraits(
[false, false], // a still portrait sheaths its weapons — no in-hand grip
&booth_billboards,
);
// **No emitters here, and that is the reference's own answer** (decision 0822): the
// round portrait is a ONE-SHOT bake — a fresh M2 scene + instance, one `0x707680` draw,
// the texture cached by GUID/displayId and returned with *no re-render* on a hit, nothing
// persisting between bakes (wow-re `portrait-render.md` §2, byte-verified). A particle
// emitter contributes nothing to a single frame of a freshly-born pool, so the ref's
// portrait shows none — and a booth that spawned them would either freeze a cloud
// mid-birth into the still or have to render forever for a 256² face. The batches above
// *are* geometry, so they draw in that one frame and belong here. The body panes are a
// different widget with a different law — see [`sync_body_booth`].
//
// Frame through the display's authored portrait camera (heuristic anchors for the
// camera-less few), resolved above before anything was torn down.
log_frame(token, &anchors, &frame(&anchors).0);
aim(&mut cams, token, &frame(&anchors));
log_bake(token, "bake", parts.len(), riders.len(), billboards.len());
log_bake(token, "bake", &parts, &riders, &billboards, &effects);
wake_booth(
booth,
&wow_mats,
@@ -1007,11 +1160,11 @@ fn sync_body_booth(
if portraits.0.get(slot) != Some(&live) {
portraits.0.insert(slot.to_string(), live);
}
// The unit's dressed look — the same `PortraitPart`/`PortraitRider` descendants the portrait
// slots mirror. Empty while there's no unit, or its model hasn't attached yet.
let (parts, riders, billboards) = match unit {
// The unit's dressed look — the same mirrored descendants the portrait slots collect. Empty
// while there's no unit, or its model hasn't attached yet.
let (parts, riders, billboards, effects) = match unit {
Some(unit) => look.collect(unit),
None => (Vec::new(), Vec::new(), Vec::new()),
None => (Vec::new(), Vec::new(), Vec::new(), Vec::new()),
};
if parts.is_empty() {
// No unit / model not attached → empty the booth and forget the applied yaw (so it
@@ -1020,19 +1173,16 @@ fn sync_body_booth(
commands.entity(booth.root).despawn_related::<Children>();
booth.baked = None;
*last_yaw = None;
// Render the emptied stage before sleeping (decision 0540).
// Render the emptied stage before sleeping (decision 0540) — and the emptied stage has
// no emitters left, so the pane stops being live.
booth.wake = BOOTH_SETTLE_FRAMES;
booth.live = false;
booth.pending.clear();
}
return;
}
let unit = unit.expect("unit present — parts came from its descendants");
let key: PartsKey = parts
.iter()
.map(|p| (p.static_mesh.id(), p.material.id()))
.chain(riders.iter().map(|r| (r.static_mesh.id(), r.material.id())))
.chain(billboards.iter().map(|b| (b.mesh.id(), b.material.id())))
.collect();
let key = LookKey::build(&parts, &riders, &billboards, &effects);
let parts_changed = booth.baked.as_ref() != Some(&key);
if parts_changed {
let display_id = ent_q.get(unit).ok().and_then(|n| n.display_id);
@@ -1040,13 +1190,7 @@ fn sync_body_booth(
// fabricated zero bounds (see the portrait site, and `booth_anchors`).
let Some(anchors) = booth_anchors(creatures, display_id) else {
booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES);
log_bake(
slot,
"wait-anchors",
parts.len(),
riders.len(),
billboards.len(),
);
log_bake(slot, "wait-anchors", &parts, &riders, &billboards, &effects);
return;
};
let rig = creatures
@@ -1071,17 +1215,31 @@ fn sync_body_booth(
offset: r.offset,
})
.collect();
// The eye-glow, seated on its eye bone's booth joint and camera-faced by the booth (relit
// onto the studio buffer like everything else — harmless, the glow is fullbright).
// The camera-facing batches — the eye-glow on its eye bone, a wand's gem or the held torch's
// halo at its attach point — seated on their bone's booth joint and camera-faced by the booth
// (relit onto the pane buffer like everything else; harmless where the batch is fullbright).
let booth_billboards: Vec<BoothBillboardSpec> = billboards
.iter()
.map(|b| BoothBillboardSpec {
mesh: b.mesh.clone(),
material: booth_light.pane.variant(&b.material, wow_mats),
bone: b.bone,
offset: b.seat.offset(),
kind: b.kind,
})
.collect();
// The worn items' effects (decision 0822) — an equipped item's own emitters (0813, `#bugs`
// B118) and a held weapon's `ItemVisuals` glow (0805). Collected BEFORE the teardown for the
// same reason as everything else here; spawned after the model, which is what hands us the
// joints they seat on.
let booth_effects: Vec<BoothEffects> = effects
.iter()
.map(|fx| BoothEffects {
bone: fx.bone,
offset: fx.offset,
emitters: fx.emitters.clone(),
})
.collect();
// Same law as the portrait bake: never latch a world-lane material into the pane. Leave
// `booth.baked` alone and retry next frame (see the portrait site for the full note).
if booth_light.pane.take_unready() {
@@ -1089,7 +1247,7 @@ fn sync_body_booth(
return;
}
commands.entity(booth.root).despawn_related::<Children>();
spawn_booth_model(
let joints = spawn_booth_model(
commands,
palettes,
booth.root,
@@ -1106,11 +1264,24 @@ fn sync_body_booth(
[false, false], // a still portrait sheaths its weapons — no in-hand grip
&booth_billboards,
);
// The item effects go up on the posed skeleton's joints — the body pane is the lane that gets
// them (the reference's `<PlayerModel>` widget renders live; the round portraits are a
// one-shot cached bake — [`spawn_booth_effects`]). A booth holding emitters is LIVE: its
// camera must not sleep on a cloud mid-birth, so `wake` alone can't gate it. The body itself
// stays `Frozen` — matching the ref's animating widget is its own look call (decision 0822).
let (fx_emitters, _) = spawn_booth_effects(
commands,
&joints,
&booth.layer,
booth_light.pane.buffer.as_ref(),
&booth_effects,
);
booth.live = fx_emitters > 0;
// Body framing from the display's bounds — the full standing figure, feet-to-crown.
// Resolved before the teardown above; see the portrait site for why it cannot be faked.
log_frame(slot, &anchors, &body_frame(&anchors).0);
aim(cams, slot, &body_frame(&anchors));
log_bake(slot, "bake", parts.len(), riders.len(), billboards.len());
log_bake(slot, "bake", &parts, &riders, &billboards, &effects);
wake_booth(
booth,
wow_mats,
@@ -1137,10 +1308,12 @@ fn sync_body_booth(
/// The demand-render gate (decision 0540): each booth camera is active only while its booth has
/// something new to show — [`Booth::wake`] frames after a content edge, or a bake texture still
/// in flight ([`Booth::pending`]) — except the glue booth, whose scene is live-animated (looping
/// sequences, global-sequence bones, particle emitters) and renders continuously while a glue
/// screen shows. A sleeping camera skips its whole pass (clear + model + FFXGlow chain); its
/// target keeps the last render — exactly right for a still (the 0105 bake, frozen at Stand).
/// in flight ([`Booth::pending`]) — except the booths whose content is **live**, which render
/// continuously: the glue booth, whose whole scene is animated (looping sequences, global-sequence
/// bones, particle emitters) while a glue screen shows, and any booth whose bake spawned particle
/// emitters ([`Booth::live`] — a body pane wearing an effect item, decision 0822). A sleeping camera
/// skips its whole pass (clear + model + FFXGlow chain); its target keeps the last render — exactly
/// right for a still (the 0105 bake, frozen at Stand).
/// With `WOW_PORTRAIT_TEST` set the gate stands down (the eyeball harness wants live cameras).
fn gate_booth_cameras(
mut booths: ResMut<Booths>,
@@ -1162,7 +1335,8 @@ fn gate_booth_cameras(
booth.wake = booth.wake.max(1);
}
let live_scene = token.as_str() == GLUE_SLOT && preview.scene.is_some();
let active = test || live_scene || booth.wake > 0 || !booth.pending.is_empty();
let active =
test || live_scene || booth.live || booth.wake > 0 || !booth.pending.is_empty();
// `WOW_BOOTH_LOG=1`: the gate's timeline — every activity flip and every armed frame,
// wall-stamped (the first-login black-pane hunt).
static LOG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
@@ -1230,3 +1404,182 @@ fn aim(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn body_part() -> PortraitPart {
PortraitPart {
static_mesh: Handle::default(),
skinned_mesh: None,
material: Handle::default(),
}
}
fn card(seat: PortraitSeat) -> PortraitBillboard {
PortraitBillboard {
mesh: Handle::default(),
material: Handle::default(),
bone: 4,
seat,
kind: benilla_formats::BillboardKind::Spherical,
}
}
fn effects(bone: u16, offset: Vec3, count: usize) -> PortraitEffects {
PortraitEffects {
bone,
offset,
emitters: (0..count)
.map(|_| benilla_assets::ModelEmitter {
def: crate::particles::tests::plain_def(),
texture: None,
bone_pivot: [0.0; 3],
billboard: None,
recursion: None,
geometry: None,
owner_reach: 0.0,
})
.collect(),
}
}
/// What [`DressedLook::collect`] found, flattened to what the assertions care about.
#[derive(Resource, Default)]
struct Collected {
parts: usize,
riders: usize,
billboards: Vec<PortraitSeat>,
effects: Vec<(u16, usize)>,
}
/// Run the walk over `unit` in `app` and hand back what it collected.
fn walk(app: &mut App, unit: Entity) -> Collected {
app.init_resource::<Collected>();
app.insert_resource(Unit(unit));
app.add_systems(
Update,
|unit: Res<Unit>, look: DressedLook, mut out: ResMut<Collected>| {
let (parts, riders, billboards, effects) = look.collect(unit.0);
*out = Collected {
parts: parts.len(),
riders: riders.len(),
billboards: billboards.iter().map(|b| b.seat).collect(),
effects: effects.iter().map(|e| (e.bone, e.emitters.len())).collect(),
};
},
);
app.update();
std::mem::take(app.world_mut().resource_mut::<Collected>().as_mut())
}
#[derive(Resource)]
struct Unit(Entity);
/// **A mounted unit's booth keeps the rider's gear and drops the horse's.** The mount prune is
/// what makes a portrait show the character alone (decision 0441) — but while mounted the
/// character's own attach joints re-root INSIDE the mount subtree, so pruning everything under it
/// would take the rider's equipment with the horse. Riders were already exempt; an equipped item's
/// camera-facing batch and its effects have to be too (decision 0822), and the discriminator is
/// [`PortraitSeat`] — whose model the batch belongs to — not where in the tree it happens to sit.
#[test]
fn a_mounts_own_glow_card_prunes_but_the_riders_gear_does_not() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
let unit = app.world_mut().spawn(body_part()).id();
// The character's own eye-glow, outside the mount subtree.
app.world_mut()
.spawn((card(PortraitSeat::Body), ChildOf(unit)));
let mount = app
.world_mut()
.spawn((
body_part(),
crate::entities::mount::MountBody { host: unit },
ChildOf(unit),
))
.id();
// The mount's OWN glow card (its lantern) — a batch of the mount's rigged body.
app.world_mut()
.spawn((card(PortraitSeat::Body), ChildOf(mount)));
// …and the seated rider's gear, re-rooted under the mount: a shoulder rider, its
// camera-facing batch and its emitters.
let seat = app.world_mut().spawn(ChildOf(mount)).id();
app.world_mut().spawn((
PortraitRider {
static_mesh: Handle::default(),
material: Handle::default(),
bone: 4,
offset: Vec3::new(0.21, 1.42, 0.06),
},
ChildOf(seat),
));
app.world_mut().spawn((
card(PortraitSeat::Rider(Vec3::new(0.15, 1.58, 0.05))),
ChildOf(seat),
));
app.world_mut()
.spawn((effects(4, Vec3::new(0.21, 1.42, 0.06), 2), ChildOf(seat)));
let got = walk(&mut app, unit);
assert_eq!(got.parts, 1, "the character's body, never the mount's");
assert_eq!(
got.riders, 1,
"the rider's shoulder survives the mount subtree"
);
// Two `Body` cards exist in this tree — the character's eye-glow and the mount's lantern —
// and exactly one may survive; the item's `Rider` card must, wherever it sits.
let (body, rider): (Vec<PortraitSeat>, Vec<PortraitSeat>) = got
.billboards
.iter()
.partition(|s| **s == PortraitSeat::Body);
assert_eq!(
body.len(),
1,
"the character's eye-glow, not the mount's lantern"
);
assert_eq!(
rider,
vec![PortraitSeat::Rider(Vec3::new(0.15, 1.58, 0.05))],
"the item's card survives the mount subtree, like the rider it belongs to",
);
assert_eq!(
got.effects,
vec![(4, 2)],
"an item's emitters are never pruned"
);
}
/// **A key blind to effects would never re-bake for a glow.** An item glow resolves
/// asynchronously (`entities::item_glow`: the item's template, then the effect model's own load),
/// so its mirror lands *after* the meshes it rides with — a later frame, with every mesh handle
/// identical. If the bake key ignored effects, the compare would say "unchanged" and the glow
/// would stay out of the pane until some unrelated content edge forced a re-bake.
#[test]
fn a_glow_arriving_after_its_item_still_changes_the_bake_key() {
let parts = [body_part()];
let riders = [PortraitRider {
static_mesh: Handle::default(),
material: Handle::default(),
bone: 4,
offset: Vec3::new(0.21, 1.42, 0.06),
}];
let parts: Vec<&PortraitPart> = parts.iter().collect();
let riders: Vec<&PortraitRider> = riders.iter().collect();
let before = LookKey::build(&parts, &riders, &[], &[]);
let glow = effects(4, Vec3::new(0.21, 1.42, 0.06), 1);
let after = LookKey::build(&parts, &riders, &[], &[&glow]);
assert!(before != after, "the glow's arrival is a content edge");
// …and a *different* seat for the same emitter count is a different bake too: two glow slots
// on one weapon differ by nothing else.
let moved = effects(4, Vec3::new(0.21, 1.55, 0.06), 1);
assert!(
LookKey::build(&parts, &riders, &[], &[&moved]) != after,
"the seat is part of the key",
);
// The same look twice is the same key — the compare must not re-bake every frame.
assert!(LookKey::build(&parts, &riders, &[], &[&glow]) == after);
}
}