//! Interior lighting for entity M2s (units, players, GameObjects, held items): which stand in a //! WMO room, and the light law they take there. //! //! One law lights every entity M2 (`Node::SetModel` `0x6716f0`, dispatched `0x672a20`): indoors, //! the node's attach rays onto the WMO render mesh and bakes the hit's barycentric MOCV //! (floor-168 / cap-96) on the fixed interior axis, plus the hit group's MOLR lobes, folded here //! into an SH probe. Indoors is the lighting class (`[node+0xc]`: outdoor iff `MOGI & 0x48`), not //! the zone-text bit, which calls `0x40`-only streets indoors. One verdict per unit, at its anchor. use std::collections::HashSet; use benilla_assets::{cap96, floor168, AdtTile, WmoModel}; use bevy::asset::AssetId; use bevy::ecs::lifecycle::HookContext; use bevy::ecs::relationship::RelationshipTarget as _; use bevy::ecs::world::DeferredWorld; use bevy::mesh::MeshTag; use bevy::prelude::*; use crate::lighting::{PropProbeSlot, PropProbes}; use crate::model_fade::{PendingAppearFade, RenderFade}; use crate::terrain_stream::{ fold_interior_probe, interior_light_up, PropLobeLight, TerrainStreamer, }; use crate::wmo_portal::{indoor_verdict_at, IndoorVerdict, WmoPortalInstance}; use benilla_assets::materials::WowModelMaterial; /// Squared distance (yd²) before a re-test: an epsilon, since the reference re-runs a unit's /// classify and footprint chain every tick (`0x69e280`), so the MOCV light must never step. const RESAMPLE_DIST_SQ: f32 = 1e-4; /// MONKEY (portal lane fade): how fast an anchor's crossfade weight travels between the exterior /// lane (`0`) and the room lane (`1`), in weight-units/s. The same 2.0/s the ground-shade node /// ramps its intensity at ([`crate::entity_shade`]'s ambient chase, the binary's `[0x810804]`) — /// deliberately, because the two are the SAME transition seen from two sides: crossing a portal /// re-seeds the node's ambient chase at the moment it re-lanes the part, and a half-second lane /// blend under a quarter-second light chase (or vice versa) reads as two events, not one. /// /// Surfaces already blend across a portal seam by geometry; entities had nothing, so an NPC /// stepping through a doorway swapped from night-dark to torch-warm in ONE frame. const LANE_RAMP_PER_SEC: f32 = 2.0; /// Which WMO placements are resident — a generation counter the classifier re-evaluates entities on /// (a building streaming in under a standing NPC must re-light it even though it didn't move). /// Rebuilt each frame by the streamer ([`crate::terrain_stream`]) from its live placements; the /// down-ray itself reads the live [`WmoPortalInstance`]s, so this only carries the change signal. #[derive(Resource, Default)] pub struct WmoResidency { resident: HashSet>, generation: u32, } impl WmoResidency { /// The change counter — bumped whenever the resident set actually changes. Read by the per-unit /// room claim (`wmo_portal::track_unit_interiors`), whose re-test gate is otherwise movement /// alone: a building streaming in under a STANDING unit must still re-claim it. /// /// MONKEY (GO room claims): `pub` — a CARRIED light's room-claim set is keyed to a resident /// placement, so it must be rebuilt on the same tick a building streams in or out /// (`benilla_app::entities::carried_light`). Same generation, same gate, one counter. pub fn generation(&self) -> u32 { self.generation } /// Bumps the generation only on a real change, so the per-frame rebuild keeps anchors settled. pub(crate) fn update(&mut self, next: impl IntoIterator>) { let next_ids: HashSet> = next.into_iter().collect(); if next_ids != self.resident { self.resident = next_ids; self.generation = self.generation.wrapping_add(1); } } } /// The indoor law a part takes, fixed at build time by whether it built a bake variant. #[derive(Clone)] pub(crate) enum InteriorKind { /// A part with no bake variant: indoors it keeps the day/night matte at gain 1.0. Matte, /// The footprint-MOCV bake: `material` is the prop-lane variant, which evaluates the SH probe /// the `MeshTag` slot names, and `center` the M2 vertex-box centre (model-local), the fold's /// MOLR reference point. Bake { material: Handle, center: Vec3, }, } /// One M2 batch's interior-light membership. Every batch of a model takes it, billboard cards /// included: the reference shades every batch through the object's one node (`0x7192b0`), and a /// billboard bone turns geometry, not light. `anchor` is the model's net entity root, so a model /// never splits across laws; `None` is a WMO-display part, with no interior variant. The caller /// seeds the part's [`MeshTag`](bevy::mesh::MeshTag) (rig slot, fade alpha); the classifier /// composes into it. pub fn part_interior_lit( exterior: &Handle, interior: Option<&Handle>, bake: Option<&Handle>, center: Vec3, anchor: Entity, ) -> Option<(InteriorLit, ClassifiedBy)> { interior?; let kind = match bake { Some(material) => InteriorKind::Bake { material: material.clone(), center, }, None => InteriorKind::Matte, }; Some(( InteriorLit::new(kind, exterior.clone()), ClassifiedBy(anchor), )) } /// The law a part renders under. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum AppliedLaw { Exterior, /// Indoors on the day/night pair, unmodified, at gain 1.0: the reference's null-node fallback /// (`0x672a2f`), for a footprint ray that missed or hit a MOPY&1 face, or a bake-less part. Matte, /// Indoors on the footprint bake, evaluated from this probe-table slot. Bake(u16), } /// The body model's vertex-box centre (model-local) on the net entity root: the MOLR reference /// point for every part under the root, held items included, since an item aliases its wearer's /// light collector (`[item+0x3b8]=[wearer+0x3b8]`, `0x718960`). For a [`ContainmentAttach`] /// anchor it is also the attach point, the reference's `[node+0x5c]`. #[derive(Component, Clone, Copy)] pub struct BodyBakeCenter(pub Vec3); /// This anchor's node takes the containment attach from its bounding-box centre (`0x6a8c10`), not /// the down-ray from its position (`0x6a8a20`): `[node+0x90]` bit 13, set from the TYPEMASK at node /// creation (`0x613e10`/`0x670db0`) for GameObjects alone, forked at `0x6a86d0`. A GameObject's /// origin often sits at or under its own floor. #[derive(Component)] pub struct ContainmentAttach; /// A part's net entity root, its anchor; bevy keeps the anchor's [`LitParts`] in step. #[derive(Component)] #[relationship(relationship_target = LitParts)] pub struct ClassifiedBy(pub Entity); /// An anchor's parts, maintained by [`ClassifiedBy`]; never edited by hand. #[derive(Component)] #[relationship_target(relationship = ClassifiedBy)] pub struct LitParts(Vec); /// A lit particle emitter's anchor, the net entity root whose light node it draws under. The /// reference builds the node from the object's type (`0x613e10` → `0x670db0` → `0x7134b0`) and /// commits its light to a particle draw as to a submesh (`0x70baf0`, the material synthesized per /// draw at `0x70d8b0`), so an object with only unlit mesh batches is classified for its emitters. #[derive(Component)] #[relationship(relationship_target = LitEmitters)] pub struct EmitterLitBy(pub Entity); /// An anchor's lit emitters, maintained by [`EmitterLitBy`]; never edited by hand. #[derive(Component)] #[relationship_target(relationship = EmitterLitBy)] pub struct LitEmitters(Vec); /// The constant a lit particle of this anchor multiplies by: the anchor's whole committed term /// (`ambient + 0.9·diffuse + Σ lamps`) along world up, a particle quad's only normal (one constant /// per draw, `0x7b3fd0`, `0x58b0b0`). Present only on the Bake law; absent, the scene's light. #[derive(Component, Clone, Copy, PartialEq, Debug)] pub struct ParticleLight(pub [f32; 3]); /// The ambient word alone (`GroundShade::ambient`, ramping toward `cap96(MOCV)`), for a draw whose /// vertex format has no normal and so evaluates only `Σ ambient × colour` (`0x592a60`): the weapon /// trail, lit by its wearer's node (`0x70d982` → `0x70ca50` → `0x70baf0`). Present only on the Bake /// law; outdoors the day/night ambient applies (`0x69e4ad`). #[derive(Component, Clone, Copy, PartialEq, Debug)] pub struct NodeAmbient(pub [f32; 3]); /// An anchor's classification: its parts' law and room, and the movement/residency re-test gate. #[derive(Component)] pub struct InteriorAnchor { law: AppliedLaw, /// The attach's room, `None` outside; kept while settled, as its fog gate tracks the camera. room: Option, /// That room's fog verdict, re-read every frame ([`anchor_room_fogged`]). fog: bool, /// The position and residency generation at the last ray: the re-test gate. last_pos: Vec3, generation: u32, /// Resolved from a bake-capable part; a bake part joining a matte-resolved anchor re-resolves. kind_bake: bool, /// MONKEY (portal lane fade): the crossfade weight this anchor's parts currently render at — /// `0` fully on the exterior result, `1` fully on the room result, chasing /// [`Self::lane_target`] at [`LANE_RAMP_PER_SEC`]. It lives on the ANCHOR and not per part for /// the same reason the law does: a body must never split across the blend (a half-faded head /// on a fully-faded torso is worse than the pop it replaces), and one `f32` per light node is /// nothing beside the `Vec3` position it already keeps. /// /// The FIRST resolve snaps it to the target instead of ramping (see the insert site): an NPC /// or GameObject that streams in already standing indoors has no "outside" to come from, and /// fading it up from the exterior night result would make every indoor spawn flash dark — /// which is the very artifact this ramp exists to remove, dressed as its cure. lane: f32, } impl InteriorAnchor { /// Where the lane weight is heading: the room result for either indoor law, the exterior one /// outside. Both indoor laws share it because the shader gives them the SAME room lighting /// (`wow_model.wgsl` routes `is_interior || matte_indoor` through `interior_room_light`), so a /// `Bake`↔`Matte` flicker at a doorway must not restart the blend. fn lane_target(law: AppliedLaw) -> f32 { match law { AppliedLaw::Exterior => 0.0, AppliedLaw::Matte | AppliedLaw::Bake(_) => 1.0, } } /// Step the crossfade toward this anchor's STANDING law's target, answering whether it moved — /// which is what puts the anchor's parts back in the write path for a frame (the walk's /// `changed` disjunct). Exact at both ends: the clamp lands the weight ON the target, so the /// frame after a blend finishes reports `false` and the anchor is silent again. fn step_lane(&mut self, dt: f32) -> bool { let target = Self::lane_target(self.law); if self.lane == target { return false; } let step = dt * LANE_RAMP_PER_SEC; self.lane = match self.lane < target { true => (self.lane + step).min(target), false => (self.lane - step).max(target), }; true } /// The weight as the tag's 4-bit field ([`crate::mesh_tag::LANE_MAX`] steps). fn lane_bits(&self) -> u8 { lane_bits(self.lane) } /// MONKEY (fire GO lights): the room this anchor's own attach ray claimed — `None` outdoors. /// /// Exposed so the app can hand a GameObject's/creature's CARRIED lights the same room its /// meshes were classified into (`benilla_app::entities::carried_light`'s room claim). Two /// things need it: the faithful gate (a torch in a culled room lights nothing — /// [`crate::lighting::LightRooms`], decision 0689), and cube-shadow eligibility, since /// `torch_shadow's candidate query is `With, With` and a light with no /// room can never be promoted to a caster however indoors it stands. pub fn room(&self) -> Option { self.room } /// MONKEY (carried light stability): the anchor's own crossfade weight - `0` fully exterior, /// `1` fully in [`Self::room`], ramping at [`LANE_RAMP_PER_SEC`] (a half-second full travel). /// /// Exposed because it is the ONE already-smoothed interior/exterior signal a bearer carries, /// and a CARRIED light's lane must be read off it rather than off the raw per-frame law. The /// law itself is known to alternate frame to frame at a threshold - `WOW_INTERIOR_LOG` from /// the 2026-09-09 run shows anchor `320v2` flipping `matte`<->`bake` on ten consecutive /// resolves between t 73.75 and t 74.14 - and a light that re-decided its lane on that raw /// verdict would swap consumer families (the exterior `point_light_sum` <-> the interior room /// lane) at the same rate, i.e. strobe. One frame of a wrong verdict moves this weight by /// `dt * LANE_RAMP_PER_SEC`, so a blip can never cross the `0.5` midpoint a consumer keys on; /// only a verdict that HOLDS for ~0.25 s does. pub fn lane(&self) -> f32 { self.lane } /// One line naming the lane this anchor's parts render under — the inspect card's light /// readout (`crate::interact`). "Which lane is this object on?" was the exact question decision /// 0776 was found by, and answering it took a rebuild with `WOW_INTERIOR_LOG` plus an offline /// `WOW_LIGHT_AT` probe; on the card it is a hover. Reads "exterior", "interior day/night", or /// "interior bake (probe N)". pub fn law_label(&self) -> String { match self.law { AppliedLaw::Exterior => "exterior".into(), AppliedLaw::Matte => "interior day/night".into(), AppliedLaw::Bake(slot) => format!("interior bake (probe {slot})"), } } } /// MONKEY (portal lane fade): quantize a `0..=1` crossfade weight onto the tag's 4-bit lane field. /// Rounds rather than truncates, so a settled weight of exactly `1.0` reaches the saturated /// [`crate::mesh_tag::LANE_MAX`] — a truncating quantizer would leave every indoor entity one step /// short of the room lane forever. fn lane_bits(lane: f32) -> u8 { let max = f32::from(crate::mesh_tag::LANE_MAX); (lane * max).round().clamp(0.0, max) as u8 } /// Parts whose material/tag need re-authoring from their anchor's current law — the classifier's /// convergence queue (0734), replacing the per-part sweep's repair duty. Fed by the /// [`InteriorLit`] `on_add` hook (a fresh part joining a settled anchor), the fade-latch observer /// ([`enqueue_on_fade_latch`] — a part re-entering the write query after a fade owned its /// channel), and the self-avatar zoom feather's release edge. Drained every classifier run; /// entries whose part is still excluded (or gone) are dropped — the next edge re-enqueues. #[derive(Resource, Default)] pub struct InteriorReauthor(pub Vec); /// One M2 entity part's material variants, which [`classify_entity_interior`] swaps; adding it /// queues the part, so a part joining a settled anchor still takes the standing law. #[derive(Component)] #[component(on_add = enqueue_new_part)] pub struct InteriorLit { kind: InteriorKind, /// The exterior material, which the Matte law shares: day/night is the intensity byte at 1.0. exterior: Handle, /// The law last written, the write gate; the anchor's [`InteriorAnchor`] is the authority. applied: Option, /// The [`crate::mesh_tag::INTERIOR_FOG_BIT`] last written; it follows the camera, not the law. fogged: bool, /// MONKEY (portal lane fade): the tag's lane field as last written, the third component of the /// write gate. It has to be recorded separately from `applied` because two writes can name the /// same EFFECTIVE law and differ only in their blend weight — which is exactly what every /// frame of a portal crossing looks like — and a gate that saw only the law would write the /// first step of the blend and then freeze the entity mid-fade. lane: u8, } impl InteriorLit { /// On the Bake law: the tag carries the probe slot, so [`crate::entity_shade`] must not write /// the shade byte over it. pub(crate) fn is_bake(&self) -> bool { matches!(self.applied, Some(AppliedLaw::Bake(_))) } /// The steady material for the current law, which the fade writers settle onto: the bake /// variant on the Bake law, else the exterior one (a matte-kind part has no bake variant). pub(crate) fn steady_material(&self) -> &Handle { match (self.applied, &self.kind) { (Some(AppliedLaw::Bake(_)), InteriorKind::Bake { material, .. }) => material, _ => &self.exterior, } } /// Re-points the variants at a re-dressed material set (`entities::attach::redress`) and /// returns what to draw; the applied law stays, since a re-dress moves nothing. pub fn repoint( &mut self, exterior: &Handle, bake: Option<&Handle>, ) -> &Handle { self.exterior = exterior.clone(); if let (InteriorKind::Bake { material, .. }, Some(b)) = (&mut self.kind, bake) { *material = b.clone(); } self.steady_material() } pub(crate) fn new(kind: InteriorKind, exterior: Handle) -> Self { Self { kind, exterior, applied: None, fogged: false, lane: 0, } } /// Test-only: a part already on the Bake law, for tests outside this module. #[cfg(test)] pub(crate) fn applied_bake_for_test( kind: InteriorKind, exterior: Handle, ) -> Self { Self { kind, exterior, applied: Some(AppliedLaw::Bake(0)), fogged: true, lane: crate::mesh_tag::LANE_MAX, } } } /// Queues a new part: one joining a settled anchor would get no law-change write otherwise. fn enqueue_new_part(mut world: DeferredWorld, ctx: HookContext) { if let Some(mut queue) = world.get_resource_mut::() { queue.0.push(ctx.entity); } } /// Queues a part whose `RenderFade` leaves. The re-author is normally a no-op, kept as the /// backstop against a part left on a freed probe slot, which renders black. fn enqueue_on_fade_latch( fade_end: On, parts: Query<(), With>, mut queue: ResMut, ) { if parts.contains(fade_end.entity) { queue.0.push(fade_end.entity); } } /// The residency registry, the reauthor queue with its fade-latch observer, and the classifier. pub(crate) struct InteriorPlugin; impl Plugin for InteriorPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() // After the room flood: the fog half of a part's channel is the flood's `[0xca7f00]` // chain, and before it a camera move would read last frame's rooms. .add_systems( Update, classify_entity_interior .after(crate::wmo_portal::WmoPvsSet) // The fold reads the resolved `WowLighting`. .in_set(crate::lighting::LightingConsumeSet), ) .add_observer(enqueue_on_fade_latch); } } /// The bake fold's ray products on the anchor, refolded without a new ray while the node's ramps /// still move and the entity does not. #[derive(Component)] pub struct BakeState { /// floor-168 of the footprint MOCV (0..1): the fold's diffuse, before the node intensity. word: Vec3, /// The hit group's windowed MOLR lobes (world space, pre-gained). lobes: Vec, /// The fold's MOLR reference point (world space) at the last ray. ref_point: Vec3, } /// Lights each entity part by where its anchor stands: outside, the global SH × the ramped /// intensity byte; in a room, the footprint bake folded into the anchor's own SH probe, or the /// day/night matte. An anchor rays again only when it moves or a building streams; its parts are /// written only when its law changes, or through [`InteriorReauthor`]. #[allow(clippy::type_complexity)] pub fn classify_entity_interior( mut commands: Commands, time: Res