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:
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user