//! Entity ground shade: a unit, player or GameObject re-samples the terrain MCSH under it as it //! moves, rather than baking it at spawn, and ramps its light node's intensity between the //! reference's 2.5 lit and 0.5 shadowed (`0x69e770`, step `[0x810808]` = 3.3333/s). //! //! The reference lights a unit through the node only once registered (`0x672a20`, `[model+0x3c0]`; //! `0x6a7300` scales the diffuse by `[+0xa4]`), which a model-set does (`0x6716f0` from //! `0x613cf0`/`0x613d80`: equip, display id, shapeshift); a unit born unregistered (`0x670db0`, //! from `0x613e10`) commits the raw day/night pair at ×1.0. Every drawn unit has had its display //! model set, so only the registered state is modelled; which units stay unregistered is untraced //! (reference frames show a standing player at ×2.5 and a running one at ×1.0). //! //! [`GroundShade`] sits on the net entity root (the `[obj+0xe0]` node) and its byte goes to every //! M2 part below it, so a held weapon dims with its wielder: the reference lights attachments from //! the owner's node. use benilla_assets::AdtTile; use bevy::mesh::MeshTag; use bevy::prelude::*; use crate::interior::{classify_entity_interior, InteriorLit}; use crate::mesh_tag::{exterior_payload, shade_of, with_shade, InteriorProbePayload}; use crate::terrain_stream::{doodad_ground_shade, ShadeResolve, TerrainStreamer}; /// The rate of `t` (0 = intensity 2.5, 1 = 0.5): the reference's linear step `[0x810808]` = /// 3.3333 intensity units/s over the mix's 2.0-wide span. const SHADE_RAMP_PER_SEC: f32 = 3.3333 / 2.0; /// Squared distance (yd²) a root moves before the MCSH bit is re-sampled, about one MCSH texel. const RESAMPLE_DIST_SQ: f32 = 0.25; /// The ambient word's rate, `[0x810804]` = 2.0 colour units/s (`[+0x9c]` → `[+0xf4]`). const AMBIENT_RAMP_PER_SEC: f32 = 2.0; /// `t` at the day/night intensity 1.0, an indoor node's target (`[+0xf8]` = 1.0 at `0x69e36b`, /// behind the `[+0xc] & 2` interior gate). The unregistered fallback's ×1.0 is another law. const DAYNIGHT_T: f32 = 0.75; /// Deviation: the lit outdoor target is intensity 1.0, not the reference's 2.5, because /// `wow_model.wgsl` caps the intensity at 1.0 (`min(I, 1)`) and a chase through the capped span /// would stall unseen. Back to 0.0 together with lifting that cap. const LIT_T: f32 = 0.75; /// The settled-ramp epsilon on `t` and each ambient channel, under half a byte. const RAMP_EPS: f32 = 1.0 / 640.0; /// An entity root's light node, the chase pair `0x69e770` steps each frame: intensity /// (`[+0xa4]` → `[+0xf8]`, held as `t`) and the ambient word (`[+0x9c]` → `[+0xf4]`). #[derive(Component)] pub struct GroundShade { /// The mix, 1 being intensity 0.5: the parts' tag byte and the bake fold's diffuse scale. t: f32, /// The last MCSH sample's target, [`LIT_T`] or 1. target: f32, /// Root position at the last sample (the movement gate). last_pos: Vec3, /// Whether the first sample landed; it snaps `t`, so a spawn never ramps in. sampled: bool, /// The interior classifier's verdict; indoors [`DAYNIGHT_T`] overrides the MCSH sample. pub(crate) indoor: bool, /// On an outdoor-class WMO surface (`MOGI & 0x48`), the lit target overrides the MCSH: the /// reference's WMO down-ray sets skip-shadow `[node+0xd] |= 0x2` (`0x6a8bc7`, cleared on /// terrain at `0x6a8bed`), and the exterior leg then commits 2.5 (`0x69e483` → `0x69e4ad`). pub(crate) on_wmo: bool, /// The ramped ambient word (0..1 per channel), the bake fold's input; the classifier seeds it. pub(crate) ambient: Vec3, pub(crate) ambient_target: Vec3, /// The last target `WOW_INTERIOR_LOG` printed; starts off-scale so the first one prints. logged_target: f32, /// The byte the walk last pushed to the parts; while it matches and no other lane rewrote a /// tag, the walk is skipped. `None` when fresh or hidden, so a wake re-asserts. asserted: Option, } impl Default for GroundShade { fn default() -> Self { Self { t: LIT_T, target: LIT_T, last_pos: Vec3::ZERO, sampled: false, indoor: false, on_wmo: false, ambient: Vec3::ZERO, ambient_target: Vec3::ZERO, logged_target: -1.0, asserted: None, } } } impl GroundShade { /// The committed intensity (`[node+0xa4]`), which the bake fold multiplies its diffuse word by. pub(crate) fn intensity(&self) -> f32 { 2.5 - 2.0 * self.t } /// The chase's target: day/night indoors, lit on a WMO surface, else the kept MCSH sample, for /// a unit as for a GameObject (one law, `0x69e4ad`/`0x69e496`). fn effective_target(&self) -> f32 { if self.indoor { DAYNIGHT_T } else if self.on_wmo { LIT_T } else { self.target } } /// Whether both chases sit on their effective targets, the classifier's refold gate. pub(crate) fn ramps_settled(&self) -> bool { (self.t - self.effective_target()).abs() < RAMP_EPS && (self.ambient - self.ambient_target).abs().max_element() < RAMP_EPS } /// Seed the ambient chase on entering the footprint bake, from the scene ambient toward the /// floor's cap-96 word, as the reference's node carries `[+0x9c]` across the leg flip. pub(crate) fn seed_ambient(&mut self, from: Vec3, target: Vec3) { self.ambient = from; self.ambient_target = target; } } /// A WMO doodad prop on a streamed GameObject (a transport's cargo), lit by its own `CMapDoodadDef` /// (`0x6a8050`), never the host's light node, so the walk passes it by. In the reference a /// transport's WMO is in the global map-object list (`0xca7d98`), and a def's `[def+0xa4]` has four /// writers, none the node: `0x6a7d73` (0.0), `0x695bb3` and `0x6b01bb` (1.0), and `0x698cb4` /// (0.5), an MCSH sample taken once at residency (`0x698c50`). #[derive(Component)] pub struct DoodadDefLit; /// Roots owed a re-assert at an unchanged byte: another lane (the classifier's exterior reclaim, a /// newly added part) rewrote a part's `MeshTag` since [`detect_shade_reclaims`] ran. #[derive(Resource, Default)] pub(crate) struct ShadeDirtyRoots(bevy::ecs::entity::EntityHashSet); /// Flag the root above every part whose `MeshTag` changed; its own system, since the walk holds /// `&mut MeshTag`. The walk's own writes echo back for one frame, which then writes nothing. pub(crate) fn detect_shade_reclaims( changed_parts: Query, Without)>, shade_roots: Query<(), With>, child_of: Query<&ChildOf>, mut dirty: ResMut, ) { dirty.0.clear(); for part in &changed_parts { let mut node = part; loop { if shade_roots.contains(node) { dirty.0.insert(node); break; } let Ok(up) = child_of.get(node) else { break }; node = up.parent(); } } } /// `WOW_SHADE_CENSUS=` (unparseable: 5): a periodic count of the [`DoodadDefLit`] parts and /// of the tagged parts under a shade root, the marked ones among them. fn shade_census_every() -> Option { static EVERY: std::sync::OnceLock> = std::sync::OnceLock::new(); *EVERY.get_or_init(|| { std::env::var("WOW_SHADE_CENSUS") .ok() .map(|v| v.parse().unwrap_or(5.0)) }) } /// The `WOW_SHADE_CENSUS` printer. fn census_shade_marks( marked: Query<(), With>, roots: Query>, children: Query<&Children>, tagged: Query<(), With>, time: Res