Files
benilla-octo/crates/benilla-world/src/interior.rs
T
pkuzicandClaude Fable 5.1 839bc58d1b Merge upstream main (samwhosung/benilla edec5b22) into lighting
Ports the lighting settings into upstream's single settings registry and the lighting plugins
into game_plugins.rs. 1108 world+formats tests green; world scenes (inn, Northshire dusk,
Stormwind) render clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 21:17:41 +01:00

1949 lines
97 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Interior lighting classification for ENTITIES: which unit/GameObject M2s stand inside a WMO room.
//!
//! ONE law lights every indoor entity M2 (wow-re `unit-m2-shader-light.md`, the Goldshire-inn
//! capture's byte-arbitrated trio — superseding `wmo-lit-selector.md` §3.3's class split):
//!
//! - **Every entity M2** — unit, player, GameObject, held item — is registered with the same
//! entity-node fill (`Node::SetModel 0x6716f0` ← the model setters, dispatched `0x672a20`): its
//! env-update attach down-rays the WMO render mesh under the entity and bakes the hit's
//! barycentric MOCV — **floor-168/cap-96** — as a directional on the fixed interior axis, plus
//! the hit group's MOLR point lobes. Decoded live at machine zero off the abbey INNBENCH draws
//! (GameObject) and bit-exact off an inn character draw (unit). Indoors we fold that into an SH
//! probe ([`InteriorKind::Bake`]) — the same probe table the MODD props ride.
//! - The raw **day/night pair at gain 1.0** is the *null-node fallback* (`0x672a2f`: a model whose
//! node isn't registered — and our lane when the footprint ray misses or hits a MOPY&1 face).
//! The abbey capture's flat-lit characters were this state, not a character-path law; the pair
//! itself is NEVER indoor-modified. [`InteriorKind::Matte`] keeps it for bake-less parts.
//!
//! One SHADING law, **two ATTACHES.** The law above is type-blind; how a node *finds* its group is
//! not. `0x6a86d0` forks on `[node+0x90]` bit 13 (`6a8714 test ah,0x20`), written at node creation
//! from the descriptor TYPEMASK — units/players/doodads take the down-ray attach `0x6a8a20` from
//! the node POSITION, **GameObjects take the containment attach `0x6a8c10` from the node's world
//! bounding-box CENTRE** (`[node+0x5c]`), whose face query retries UPWARD on a miss. There is no
//! subclass test anywhere in the dispatch: the mode bit is the whole fork. We carry it as
//! [`ContainmentAttach`] on the anchor and as [`crate::wmo_portal::LightAttach`] through the
//! verdict. Decision 0776 — a GameObject's origin routinely sits at or below its own floor, and
//! sharing the unit down-ray put those objects outdoors in the middle of a building.
//!
//! Entities move and stream independently of their building, so [`classify_entity_interior`]
//! re-tests them against the placed WMOs. The indoor test is the client's own: a faces-only
//! ray from the attach's anchor onto the placed groups' geometry
//! ([`crate::wmo_portal::indoor_verdict_at`] — the LIGHTING-class fork `[node+0xc]`, outdoor iff
//! the hit group's `MOGI & 0x48` — NOT the zone-text `[node+0x90]` bit-0 predicate, which keys on
//! `0x8` alone and so calls the `0x40`-only city street groups "indoors"; decision 0475 — and an
//! outdoor-class WMO surface forces the LIT target, no MCSH beneath the building: the WMO-linked
//! skip-shadow bit, byte-verified; decisions 0477/0480). One verdict per UNIT, sampled at its
//! [`InteriorLit::anchor`] — a body's parts must never split across light laws (group bounding
//! boxes did exactly that at floor level; director-caught, 2026-07-12), and a held/equipped item
//! M2 anchors at its WEARER's root, never its own carried position: the reference aliases the
//! wearer's light collector into each item by pointer (`[item+0x3b8]=[wearer+0x3b8]`, `0x718960` —
//! `unit-light-combine-storm.md`; a hand-anchored shield split from its body, director-caught,
//! 2026-07-13).
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²) an entity must move before it's re-tested: an epsilon — the reference
/// runs the classify + footprint chain EVERY frame for units (the node is unlinked, so the
/// WorldFrame ramp tail reaches `0x69e280` per tick; wow-re `unit-light-combine-storm.md` c1), so
/// a moving entity re-samples per frame and the continuous MOCV field never quantizes into steps
/// (the 0.5-yd gate here was the forge's per-step light flash). A standing entity still costs one
/// position compare and nothing else.
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<AssetId<WmoModel>>,
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
}
/// Replace the resident set, bumping the generation only when it actually changed
/// (order-independent, by asset id) — so the per-frame rebuild doesn't defeat the classifier's
/// movement gate.
pub(crate) fn update(&mut self, next: impl IntoIterator<Item = AssetId<WmoModel>>) {
let next_ids: HashSet<AssetId<WmoModel>> = next.into_iter().collect();
if next_ids != self.resident {
self.resident = next_ids;
self.generation = self.generation.wrapping_add(1);
}
}
}
/// The indoor light LAW an entity's model takes — decided at build time by whether the part built
/// a bake variant (every M2 does; module docs).
#[derive(Clone)]
pub(crate) enum InteriorKind {
/// The plain day/night matte at sun ×1.0 indoors — the reference's null-node fallback. Only
/// for parts with no bake variant; the Bake law also lands here on a footprint miss.
Matte,
/// Every entity M2 (unit/player/GameObject/held): the footprint-MOCV bake indoors — `material`
/// is the interior PROP-lane variant (the shader evaluates the model's SH probe by the
/// `MeshTag` slot), `center` the M2 vertex-box centre in Bevy model-local (the fold's MOLR
/// reference point, the byte-cited anchor family).
Bake {
material: Handle<WowModelMaterial>,
center: Vec3,
},
}
/// The interior-light membership of ONE M2 batch — the single place a spawn site decides whether
/// a batch joins its model's law, and under which [`InteriorKind`]. `None` classifies the batch
/// out entirely (a WMO-display part, which has no interior variant to swap to).
///
/// **Every batch of a model goes through this, cards included.** The reference has one light node
/// per object and every batch shades through the same node fill (wow-re `unit-m2-shader-light.md`);
/// a billboard BONE re-orients geometry, it does not re-route light. Our billboard batches spawn as
/// world ROOTS so the facing system can own their transform (0153) — an implementation detail that
/// must not reach the light, and which did while this policy was written out longhand at each spawn
/// site and one of them omitted it: a Stratholme hanging sign baked from the room it hangs in while
/// its own chain cards stayed on the exterior material and lit from the sky (decision 0778).
///
/// `anchor` is the model's NET ENTITY root for every caller — body mesh, held item, and card alike
/// — so a model can never split across the two light laws ([`BodyBakeCenter`] for why an item
/// aliases its wearer rather than folding from its own position). Each site still writes its own
/// [`MeshTag`](bevy::mesh::MeshTag): the rig slot and the fade alpha are that site's to seed, and
/// the classifier composes into them.
pub fn part_interior_lit(
exterior: &Handle<WowModelMaterial>,
interior: Option<&Handle<WowModelMaterial>>,
bake: Option<&Handle<WowModelMaterial>>,
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 currently renders under (`None` until first classified).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum AppliedLaw {
Exterior,
/// Indoors on the plain matte (a footprint ray that missed or hit a MOPY&1 face, or a
/// bake-less part — the day/night null-node lane).
Matte,
/// Indoors on the footprint bake, evaluated from this probe-table slot.
Bake(u16),
}
/// The unit's own body-model bake centre (M2 vertex-box centre, model-local) on the net entity
/// ROOT — the interior fold's MOLR reference point for EVERY part that shares the root's verdict,
/// held items included. The reference has exactly one light node per unit; an equipped item M2
/// aliases the wearer's collector by pointer (`[item+0x3b8]=[wearer+0x3b8]`, `0x718960` — wow-re
/// `unit-light-combine-storm.md`), so an item never folds from its own carried position.
///
/// For a [`ContainmentAttach`] anchor it is also the **attach anchor** — the reference's
/// `[node+0x5c]`, the same world point (decision 0776).
#[derive(Component, Clone, Copy)]
pub struct BodyBakeCenter(pub Vec3);
/// This anchor's light node runs the **containment attach** (`0x6a8c10`), not the down-ray one —
/// the reference's `[node+0x90]` bit 13, set at node creation from the descriptor TYPEMASK
/// (`0x613e10`/`0x670db0`) and dispatched at `0x6a86d0`. In 1.12 exactly one object class carries
/// it: **GameObjects**. See [`crate::wmo_portal::LightAttach`] for the two lanes at the bytes, and
/// decision 0776 for what it corrects — a GameObject's origin is frequently at or below its own
/// floor (a Stratholme portcullis spawns 15 cm under the corridor slab), so the down-ray this lane
/// used to share left it outside the building on the outdoor light while its identical neighbour
/// two doors down baked from the same floor.
#[derive(Component)]
pub struct ContainmentAttach;
/// The part → anchor edge of the classifier's registry (0734): every [`InteriorLit`] part names
/// its NET ENTITY root here — body parts and held/equipped items alike (module docs — the
/// reference has one light node per unit and items alias it). Bevy's relationship hooks maintain
/// the anchor-side [`LitParts`] list through spawn, gear-swap despawn, and teardown, so a law
/// change can write exactly its own parts and a settled anchor touches none.
#[derive(Component)]
#[relationship(relationship_target = LitParts)]
pub struct ClassifiedBy(pub Entity);
/// The anchor-side part list [`ClassifiedBy`] maintains — the classifier's write fan-out. Never
/// mutated by hand; bevy removes it when the last part leaves.
#[derive(Component)]
#[relationship_target(relationship = ClassifiedBy)]
pub struct LitParts(Vec<Entity>);
/// The emitter → anchor edge of the same registry: a **LIT** particle emitter names the net-entity
/// root whose light node it draws under, exactly as a lit mesh batch names it through
/// [`ClassifiedBy`].
///
/// It exists because a light node has **two** consumer classes and only one of them is a mesh. The
/// reference creates the node from the object's TYPEID — `0x613e10` → `0x670db0` → `0x7134b0` for
/// every UNIT/PLAYER/GAMEOBJECT/DYNAMICOBJECT/CORPSE — never from what its batches happen to do,
/// and a particle draw commits that node's light through the same per-batch state producer a
/// submesh does (the reference has no particle material: it synthesizes an `M2Material` from the
/// emitter's file record every draw). wow-re `part-lit-normal-space.md` §6.2/§6.7.
///
/// Onyxia's lava trap is the case that proves the split: all five of `ONYZIASLAIRLAVATRAP.M2`'s
/// mesh batches are UNLIT (`m2batch`: flags 0x13) while emitters #0/#1/#2 are lit, so the object
/// registered no [`LitParts`] at all and the classifier never visited it — 104 of them stood in a
/// WMO interior taking the day/night sun.
#[derive(Component)]
#[relationship(relationship_target = LitEmitters)]
pub struct EmitterLitBy(pub Entity);
/// The anchor-side emitter list [`EmitterLitBy`] maintains — the second reason an anchor is
/// classified at all. Never mutated by hand; bevy removes it when the last emitter leaves, which
/// is what releases an emitter-only anchor's probe slot when its cloud streams out.
#[derive(Component)]
#[relationship_target(relationship = EmitterLitBy)]
pub struct LitEmitters(Vec<Entity>);
/// The constant RGB a LIT particle of this anchor's model is multiplied by — the anchor's own
/// committed light evaluated along the **world up axis**, which is the only normal a particle quad
/// ever carries (one constant per draw, wow-re `part-lit-normal-space.md` §2/§3; decision 1696).
///
/// Present only while the anchor is on the [`AppliedLaw::Bake`] law. Its ABSENCE is the exterior
/// lane and means "take the scene's light", which the effect shader applies by itself — so an
/// outdoor emitter costs nothing here and reads exactly as it did before this component existed.
#[derive(Component, Clone, Copy, PartialEq, Debug)]
pub struct ParticleLight(pub [f32; 3]);
/// The **ambient half alone** of this anchor's committed words — `[`GroundShade::ambient`], the
/// ramped chase toward `cap96(MOCV)`, with no diffuse lobe and no MOLT points.
///
/// [`ParticleLight`] is the whole fixed-function term (`ambient + 0.9·diffuse + Σ lamps`) because
/// that is what a lit particle quad receives. One consumer needs strictly less: a **vertex format
/// with no normal** disables the normal array outright, so its draw evaluates only the ambient
/// product — `out = (Σ enabled lights' Ambient) × authoredColour` (wow-re
/// `gx/scratch/format7-lighting-term.md`). The weapon swing trail is that draw, and it inherits
/// the *wearer's* committed light, four-way byte-derived through
/// `0x70d982 → 0x70ca50 → 0x70baf0` plus the held-weapon `[+0x3b8]` alias.
///
/// Same lifetime as [`ParticleLight`], for the same reason: present only on [`AppliedLaw::Bake`],
/// and its ABSENCE is the exterior lane — outdoors a unit's ambient **is** the day/night ambient
/// (`0x69e4ad`'s exterior intensity leg), so a consumer falls back to the scene's own.
#[derive(Component, Clone, Copy, PartialEq, Debug)]
pub struct NodeAmbient(pub [f32; 3]);
/// The anchor's classification record (0734) — the law its parts render under, plus the
/// movement/residency gate that used to live per part. Inserted by the classifier on the first
/// resolve; a settled anchor is one distance compare per frame, whatever its part count.
#[derive(Component)]
pub struct InteriorAnchor {
law: AppliedLaw,
/// The room the law's own attach ray claimed — `None` on the exterior law. It is the key the
/// per-frame interior-FOG gate is asked at, which is why a settled anchor keeps it: the gate's
/// answer moves with the CAMERA, so an anchor that never rays again still needs its room.
room: Option<crate::wmo_portal::WmoRoom>,
/// The standing fog verdict for that room, re-read every frame (see [`anchor_room_fogged`]).
fog: bool,
/// Anchor position at the last down-ray + the residency generation then — the re-test gate.
last_pos: Vec3,
generation: u32,
/// Whether the law was resolved from a bake-capable part's kind — a bake-capable part
/// joining a matte-resolved anchor must force a re-resolve (the reauthor drain checks this),
/// or it would ride the matte fallback until the anchor next moves.
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<WorldPointLight>, With<LightRooms>` and a light with no
/// room can never be promoted to a caster however indoors it stands.
pub fn room(&self) -> Option<crate::wmo_portal::WmoRoom> {
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<Entity>);
/// The interior/exterior material variants for one entity submesh part, so [`classify_entity_interior`]
/// can swap by the model's current location without rebuilding. Attached only to M2 entity parts (WMO
/// group geometry carries per-submesh interior in its own material + baked MOCV); its anchor edge
/// is the sibling [`ClassifiedBy`]. The `on_add` hook enqueues the part for authoring, so a
/// gear-swap part joining an already-settled anchor still gets the standing law.
#[derive(Component)]
#[component(on_add = enqueue_new_part)]
pub struct InteriorLit {
/// The model's indoor law ([`InteriorKind`]) — uniform across an anchor's parts.
kind: InteriorKind,
/// The exterior/day-night material (the global-SH lane): since 0354 the Matte law rides it
/// too — day/night is the intensity byte at the 1.0 point, not a separate material.
exterior: Handle<WowModelMaterial>,
/// Last applied law — the part's last-written record (the anchor's [`InteriorAnchor`] is the
/// authority): the write gate, and [`Self::is_bake`]'s source. `None` until first written.
applied: Option<AppliedLaw>,
/// The [`crate::mesh_tag::INTERIOR_FOG_BIT`] as last written, beside the law rather than inside
/// it: the two are decided by different questions (the law by where this part STANDS, the fog
/// by whether that room is on the camera's chain this frame — decision 1792 §4), so a part can
/// need a rewrite for one with the other unchanged. Meaningless while `applied` is `None`.
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 {
/// Whether this part currently rides the footprint-BAKE lane — the intensity-byte writer's
/// skip test: a bake part's tag payload is its probe SLOT, so [`crate::entity_shade`] must not
/// write the shade byte over it (every other law carries the byte — since 0354 the day/night
/// state is the byte at the intensity-1.0 point, not a material swap).
pub(crate) fn is_bake(&self) -> bool {
matches!(self.applied, Some(AppliedLaw::Bake(_)))
}
/// The steady (non-feathering) material for the part's CURRENT law — the classifier's own
/// choice, exposed so the fade writers settle a part onto exactly what the classifier would
/// have written rather than onto a `cutout` latched before the law was known (decision 0755).
///
/// The exterior and day/night (Matte) states share the exterior material: since 0354 the
/// difference between them is the node's intensity target (the tag byte `entity_shade` ramps),
/// not a separate material. Only the footprint bake swaps the variant — and a matte-KIND part
/// under a bake-law anchor has no bake variant to swap to, so it keeps the exterior one.
pub(crate) fn steady_material(&self) -> &Handle<WowModelMaterial> {
match (self.applied, &self.kind) {
(Some(AppliedLaw::Bake(_)), InteriorKind::Bake { material, .. }) => material,
_ => &self.exterior,
}
}
/// Re-point a standing part's law variants at a freshly-built material set, and answer what it
/// should now draw — the character **re-dress** (`entities::attach::redress`): a gear change
/// re-composites the body atlas and re-resolves the cape texture, so every variant's handle
/// changes while the part, the room and therefore the *law* stay exactly as they were.
///
/// The applied law is deliberately KEPT, which is what makes this a re-point rather than a
/// re-classification: nothing moved, so re-running the down-ray would answer the same thing at
/// the cost of a frame on the wrong material. A part's `kind` cannot change under it either —
/// bake-capability is a property of the batch's build, and a re-dress never changes which batch
/// a part is.
pub fn repoint(
&mut self,
exterior: &Handle<WowModelMaterial>,
bake: Option<&Handle<WowModelMaterial>>,
) -> &Handle<WowModelMaterial> {
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<WowModelMaterial>) -> Self {
Self {
kind,
exterior,
applied: None,
fogged: false,
lane: 0,
}
}
/// Test-only: a part already recorded as riding the Bake law, so
/// [`crate::model_fade::FadeMaterials::material_for`] can be exercised over every law without
/// driving a full classifier run from another module ([`AppliedLaw`] is this module's business
/// and stays private).
#[cfg(test)]
pub(crate) fn applied_bake_for_test(
kind: InteriorKind,
exterior: Handle<WowModelMaterial>,
) -> Self {
Self {
kind,
exterior,
applied: Some(AppliedLaw::Bake(0)),
fogged: true,
lane: crate::mesh_tag::LANE_MAX,
}
}
}
/// `on_add` hook: a freshly spawned part asks for its anchor's standing law (drained by
/// [`classify_entity_interior`] — a first-resolving anchor covers its parts anyway, but a part
/// joining a SETTLED anchor gets no law-change write without this).
fn enqueue_new_part(mut world: DeferredWorld, ctx: HookContext) {
if let Some(mut queue) = world.get_resource_mut::<InteriorReauthor>() {
queue.0.push(ctx.entity);
}
}
/// Fade latch: re-author the part from its anchor's law the moment `RenderFade` leaves.
///
/// Since 0755 this is a **backstop**, not the convergence path it once was — the classifier now
/// tracks a part's law right through its ramp, and `apply_render_fade` settles the material onto
/// that law itself at the latch, so the forced re-author is normally a no-op. It stays because its
/// failure mode is the "unit stays black indoors" bug (a probe slot freed while its part sat
/// unauthored), which has cost us twice, and one forced write per part at the end of a ramp is
/// nothing. Removal on despawn also lands here; the drain drops dead entries.
fn enqueue_on_fade_latch(
fade_end: On<Remove, RenderFade>,
parts: Query<(), With<InteriorLit>>,
mut queue: ResMut<InteriorReauthor>,
) {
if parts.contains(fade_end.entity) {
queue.0.push(fade_end.entity);
}
}
/// Registers the residency registry, the reauthor queue + its fade-latch observer, and the
/// per-frame entity classifier (the streamer fills the registry).
pub(crate) struct InteriorPlugin;
impl Plugin for InteriorPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<WmoResidency>()
.init_resource::<InteriorReauthor>()
// **After the flood**, like every other reader of the per-group gate
// (`model_render::ModelVisSet`): since the interior-FOG half of a part's channel is
// `[0xca7f00]` (decision 1792 §4), classifying before the flood would answer a
// camera-move with last frame's rooms. The ray half wants the same order anyway — the
// placements it rays are the ones this frame's residency published.
.add_systems(
Update,
classify_entity_interior
.after(crate::wmo_portal::WmoPvsSet)
// The fold reads the resolved `WowLighting`, so it belongs on the resolve's
// read side (`lighting::LightingConsumeSet`, decision 2032).
.in_set(crate::lighting::LightingConsumeSet),
)
.add_observer(enqueue_on_fade_latch);
}
}
/// The bake fold's cached ray products, on the ANCHOR (net entity root): while the node's ramps
/// move without the entity moving (it stopped just inside the forge's warm zone), the per-frame
/// refold re-uses these instead of re-running the down-ray. Written on every ray that lands a
/// Baked verdict; removed with the law.
#[derive(Component)]
pub struct BakeState {
/// floor-168 of the footprint MOCV word (0..1) — the fold's diffuse, × the node intensity.
word: Vec3,
/// The hit group's windowed MOLR lobes (world space, pre-gained).
lobes: Vec<PropLobeLight>,
/// The fold's MOLR reference point (world space) at the last ray.
ref_point: Vec3,
}
/// Light each entity part by where its model stands. Outside ⇒ the exterior lane (the global SH ×
/// the ramped intensity byte). Inside a WMO room ⇒ the footprint-MOCV bake folded into the
/// anchor's OWNED SH probe (refolded per frame while the node moves or its ramps chase — the
/// reference's per-tick env update, decision 0354), or the day/night state = the same exterior
/// material at the intensity-1.0 byte point. One law for every entity M2, unit and GameObject
/// alike (module docs). The verdict is the client's faces-only down-ray at the model's anchor —
/// one ray per UNIT per re-test, re-run only when the anchor moves or a building streams in/out.
///
/// The walk is over ANCHORS, not parts (0734): a settled anchor is one distance compare, whatever
/// its part count, and parts are written only when their anchor's law changes (or through the
/// [`InteriorReauthor`] drain — a fresh part, a fade latch, the zoom feather's release).
#[allow(clippy::type_complexity)]
pub fn classify_entity_interior(
mut commands: Commands,
time: Res<Time>,
residency: Res<WmoResidency>,
wmos: Res<Assets<WmoModel>>,
instances: Query<(Entity, &WmoPortalInstance)>,
streamer: Res<TerrainStreamer>,
adt_tiles: Res<Assets<AdtTile>>,
lighting: Res<crate::lighting::WowLighting>,
mut probes: ResMut<PropProbes>,
// The walk is over LIGHT NODES: an anchor is visited when it has a lit mesh part **or** a lit
// particle emitter, because those are the node's two consumer classes ([`EmitterLitBy`]). The
// filter is what keeps it a light-node walk and not a walk over every entity in the world —
// a node with no consumer would still ray, and would still take a probe slot from a table
// that has a floor.
mut anchors: Query<
(
Entity,
&GlobalTransform,
Option<&LitParts>,
Option<&mut InteriorAnchor>,
Has<ContainmentAttach>,
Option<&BodyBakeCenter>,
Option<&LitEmitters>,
Option<&mut ParticleLight>,
Option<&mut NodeAmbient>,
),
Or<(With<LitParts>, With<LitEmitters>)>,
>,
mut nodes: Query<&mut crate::entity_shade::GroundShade>,
bake_states: Query<&BakeState>,
seats: Query<&PropProbeSlot>,
mut queue: ResMut<InteriorReauthor>,
part_anchors: Query<&ClassifiedBy>,
// Fading parts are **included** (decision 0755). The light law and the fade alpha are
// orthogonal, and they are deconflicted by field, not by lockout: the classifier's payload
// writes carry the tag's alpha field through (`mesh_tag::with_interior_probe` /
// `with_exterior_reset`), and a fading part takes the BLEND twin of its law from the very
// rule the ramp itself applies (`FadeMaterials::material_for`) — same handle, either order,
// no fight. Excluding them is what left a streamed indoor entity with no law at all for the
// whole 2 s ramp, so it appeared under exterior light and swapped to its room's in one frame
// at the latch.
mut parts: PartWrite,
) {
let _t0 = std::time::Instant::now();
let (mut n_anchors, mut n_resolved, mut n_written) = (0usize, 0usize, 0usize);
// Anchors that wanted a resolve but had every part fade-excluded — the appear-fade lockout.
let mut n_fade_blocked = 0usize;
let mut resolve_us = 0.0f32;
for (
anchor,
anchor_t,
lit_parts,
mut state,
containment,
bake_center,
lit_emitters,
particle_light,
node_ambient,
) in &mut anchors
{
n_anchors += 1;
let pos = anchor_t.translation();
let had_state = state.is_some();
// Skip the down-ray entirely for a settled anchor (no movement, no building streamed) —
// this is what keeps a town full of standing NPCs/props at one compare per frame. A
// Bake-law anchor whose node ramps still chase keeps refolding (from the cached ray
// products — no new ray), so a unit that stops just inside a warm zone finishes its
// transition instead of freezing mid-ramp.
if let Some(state) = state.as_deref_mut() {
let settled = state.generation == residency.generation
&& pos.distance_squared(state.last_pos) < RESAMPLE_DIST_SQ;
if settled {
if let AppliedLaw::Bake(slot) = state.law {
if let (Ok(node), Ok(bake)) = (nodes.get(anchor), bake_states.get(anchor)) {
if !node.ramps_settled() {
let words = (
node.ambient.to_array(),
(bake.word * node.intensity()).to_array(),
);
probes.update_owned(
slot,
fold_interior_probe(words.0, words.1, bake.ref_point, &bake.lobes),
);
// The mesh's probe and the emitters' constant off the SAME committed
// words, through the two lanes' different curves (1709).
if let Some(mut light) = particle_light {
light.set_if_neq(ParticleLight(interior_light_up(
words.0,
words.1,
bake.ref_point,
&bake.lobes,
)));
}
// …and the ambient word on its own, for the normal-less draws whose
// term is that product and nothing else ([`NodeAmbient`]).
if let Some(mut amb) = node_ambient {
amb.set_if_neq(NodeAmbient(words.0));
}
}
}
}
// The law is settled; the FOG is not. Its gate is the camera's chain, not this
// anchor's position (decision 1792 §4) — a unit standing perfectly still leaves
// and rejoins its building's MFOG as the camera walks between rooms — so the one
// thing a settled anchor still re-reads is this bit. A compare per anchor, and a
// part write only on the frames the verdict actually moves.
let fog = anchor_room_fogged(state.room, &instances);
// MONKEY (portal lane fade): the settled gate above is MOVEMENT, and the blend is
// TIME — a unit that steps through a doorway and stops dead in it (or is stopped
// by the server's next position update landing on the same spot) still owes the
// rest of its crossfade. So the lane steps here too, and a lane in motion puts the
// anchor's parts back through the write loop for exactly the frames it moves.
let lane_moved = state.step_lane(time.delta_secs());
if state.fog != fog || lane_moved {
state.fog = fog;
n_written += write_anchor_parts(
state.law,
fog,
state.lane_bits(),
lit_parts,
&mut parts,
);
}
continue;
}
}
// Re-resolving. The law is anchor-level and reduces to ONE input — the footprint fold's
// reference point, or `None` for the day/night matte — so this asks the anchor's first
// classifiable part for it. Since 0755 a fading part is classifiable, so the part arm only
// comes up empty on an anchor whose parts have all been despawned (a teardown mid-frame).
//
// An anchor with NO mesh part at all is the emitter-only node: nothing to ask, and every
// entity M2 bakes (see [`InteriorKind`]), so it takes the footprint law from its own bake
// centre. `fade_blocked` stays as the tripwire that the 0755 lockout has not crept back in.
let part_bake = lit_parts
.into_iter()
.flat_map(LitParts::iter)
.find_map(|part| {
parts.get(part).ok().map(|(lit, ..)| match &lit.kind {
InteriorKind::Bake { center, .. } => Some(*center),
InteriorKind::Matte => None,
})
});
let bake = match part_bake {
Some(center) => center,
None if lit_emitters.is_some() => Some(bake_center.map_or(Vec3::ZERO, |c| c.0)),
None => {
n_fade_blocked += 1;
continue;
}
};
let seated = seats.get(anchor).ok().map(|s| s.0);
n_resolved += 1;
let _r = std::time::Instant::now();
let (attach, attach_at) = attach_anchor(containment, bake_center, anchor_t);
let (law, room) = resolve_anchor_law(
&mut commands,
&mut probes,
&wmos,
&instances,
&streamer,
&adt_tiles,
&lighting,
&mut nodes,
anchor,
anchor_t,
attach,
attach_at,
bake,
seated,
);
resolve_us += _r.elapsed().as_secs_f32() * 1e6;
let fog = anchor_room_fogged(room, &instances);
let kind_bake = bake.is_some();
// MONKEY (portal lane fade): the law flip and the blend step are two DIFFERENT reasons to
// rewrite this anchor's parts, kept apart because only the first is an event — the second
// is the ~half-second of frames after it, and folding them into one `changed` would spam
// `WOW_INTERIOR_LOG` with 30 identical lines per doorway.
let (law_changed, lane_moved, lane) = match state.as_deref_mut() {
Some(state) => {
let law_changed = state.law != law || state.fog != fog;
state.law = law;
state.room = room;
state.fog = fog;
state.last_pos = pos;
state.generation = residency.generation;
state.kind_bake = kind_bake;
// Stepped AFTER the law lands, so the blend chases the verdict resolved THIS
// frame — on the flip frame it moves one step off the lane it was on, never a
// frame of the old target.
let lane_moved = state.step_lane(time.delta_secs());
(law_changed, lane_moved, state.lane_bits())
}
None => {
// `try_insert`: the anchor may carry a same-frame despawn already queued.
// The first resolve SNAPS the blend to its target rather than ramping into it
// (see [`InteriorAnchor::lane`]): an entity that streams in already indoors has
// no exterior state to come from, and fading it up would flash it dark.
let lane = InteriorAnchor::lane_target(law);
commands.entity(anchor).try_insert(InteriorAnchor {
law,
room,
fog,
last_pos: pos,
generation: residency.generation,
kind_bake,
lane,
});
(true, false, lane_bits(lane))
}
};
// Write the parts only when the law actually changed (so re-testing a moving NPC mid-room
// doesn't churn the render extraction) — or while its blend is still travelling, which is
// a handful of parts on the few entities actually mid-portal.
if !law_changed && !lane_moved {
continue;
}
// `WOW_INTERIOR_LOG=1`: print interior classifications — the live-probe instrument for
// "did this entity actually classify indoors, and under which law?". Scoped to interior
// verdicts (plus interior→exterior flips) so the world's exterior masses stay silent. The
// ATTACH and the point it probed are printed too (0776): a line that says only "exterior"
// can't be read without knowing which lane produced it, and the two lanes now probe
// different points.
// Scoped to interior verdicts (plus interior→exterior flips) so the world's exterior
// masses stay silent — but `WOW_INTERIOR_LOG=all` prints the first-resolve exteriors too,
// which is the ONLY reading that answers "this thing is standing in a room and isn't
// lighting like it": a node that resolves exterior and stays there is otherwise invisible
// to this instrument, and looks identical to a node the walk never visited.
let log = std::env::var("WOW_INTERIOR_LOG").ok();
// `law_changed` and not the write condition: the blend's frames are not classifications.
if log.is_some()
&& law_changed
&& (law != AppliedLaw::Exterior || had_state || log.as_deref() == Some("all"))
{
// The PART COUNT is load-bearing, not decoration: a lane readout says which law the
// model took, never which of its batches actually joined. A billboard card that
// silently classified out is invisible to every other reading of this line — the
// Stratholme sign baked correctly *and* its chains lit from the sky, and the anchor
// log said only "INTERIOR bake" for weeks (decision 0778). Compare it against the
// model's batch count (`benilla-extract m2batch <model>`).
//
// The EMITTER count is the same reading for the node's other consumer class: a model
// whose batches are all unlit registers zero parts and is here entirely on its lit
// emitters, and "0 parts / 0 emitters" is impossible — so the pair says which
// consumer put this node in front of the classifier, which is otherwise invisible.
// The ROOM and its fog gate ride the same line as the law, because since 1792 §5 they
// are the other half of what a part's channel says: "INTERIOR matte" alone cannot
// distinguish a unit wearing its building's MFOG from one on the scene fog two rooms
// out, and that difference is the whole of B335's colour.
let room = match room {
Some(r) => format!(
" room g{} of {:?} · fog {}",
r.group,
r.instance,
if fog { "INTERIOR" } else { "scene (off-chain)" }
),
None => String::new(),
};
eprintln!(
"[interior] t {:.2} anchor {anchor:?} ({} parts, {} emitters) at \
({:.1}, {:.1}, {:.1}) {} probe ({:.1}, {:.1}, {:.1}) -> {}{room}",
time.elapsed_secs(),
lit_parts.map_or(0, |p| p.len()),
lit_emitters.map_or(0, |e| e.len()),
pos.x,
pos.y,
pos.z,
match attach {
crate::wmo_portal::LightAttach::Containment => "containment",
crate::wmo_portal::LightAttach::DownRay => "down-ray",
},
attach_at.x,
attach_at.y,
attach_at.z,
match law {
AppliedLaw::Exterior => "exterior".to_string(),
AppliedLaw::Matte => "INTERIOR matte".to_string(),
AppliedLaw::Bake(s) => format!("INTERIOR bake slot {s}"),
}
);
}
n_written += write_anchor_parts(law, fog, lane, lit_parts, &mut parts);
}
// Drain the convergence queue: each entry re-authors from its anchor's standing law. Forced
// through the part's change gate — the enqueuing edges (fade latch, zoom release) mean a
// transient author overwrote the material/tag while `applied` stayed current.
for part in std::mem::take(&mut queue.0) {
let Ok(edge) = part_anchors.get(part) else {
continue; // despawned since enqueue
};
let Ok((_, _, _, mut state, ..)) = anchors.get_mut(edge.0) else {
continue;
};
let Some(state) = state.as_deref_mut() else {
continue; // law not resolved yet — the anchor's first resolve writes every part
};
let Ok((mut lit, mut material, mut tag, fm, ramping, pending)) = parts.get_mut(part) else {
continue; // despawned between the enqueue and the drain
};
let fade = fm.filter(|_| ramping || pending);
// The mixed-kind hole: a bake-capable part joining an anchor whose law was resolved from
// a matte-kind part must force a re-resolve (drop the record; next frame re-rays) — the
// standing law can't say Bake. Written with the standing law this frame regardless, so
// the part isn't naked for the gap.
if matches!(lit.kind, InteriorKind::Bake { .. }) && !state.kind_bake {
commands.entity(edge.0).try_remove::<InteriorAnchor>();
}
// MONKEY (portal lane fade): the anchor's CURRENT blend weight, so a part that converges
// mid-crossing lands on exactly what the walk wrote its siblings this frame — a fresh
// gear part authored at the settled weight would pop while the body it hangs off faded.
n_written += usize::from(write_part_law(
state.law,
state.fog,
state.lane_bits(),
&mut lit,
&mut material,
&mut tag,
true,
fade,
));
}
// `WOW_INTERIOR_COST=1`: this lane's per-frame cost, in the terms that diagnose it — how many
// anchors the walk visits vs how many actually re-resolve vs how many PARTS got written, and
// what the resolves cost. The split is the whole diagnosis (it sent the 2026-07-27 hunt into
// the WMO column rays, decision 0711, and priced 0732's slice C — the 13.7k-part walk this
// anchor walk replaced). Cheap enough to leave in: three counters and one `Instant` per frame.
static COST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
if *COST.get_or_init(|| std::env::var_os("WOW_INTERIOR_COST").is_some()) {
// `WOW_COLUMN_COST=1` adds the column-query split to the same line: this lane IS the
// grid's caller, so its per-frame traffic belongs beside its per-frame cost. `spanning` was
// 0711's residual — the oversized floor slabs tested on every query, 99.26 % of everything
// tested when 1351 finally measured it. It is now the coarse level's residue and should
// read near zero; `spanning_pct` climbing is the regression watch.
let column = if benilla_assets::column_grid::column_cost_enabled() {
let (queries, binned, coarse, spanning) =
benilla_assets::column_grid::take_column_query_stats();
let tested = binned + coarse + spanning;
let pct = if tested == 0 {
0.0
} else {
100.0 * spanning as f32 / tested as f32
};
format!(
" col_queries={queries} col_tested={tested} col_binned={binned} col_coarse={coarse} col_spanning={spanning} spanning_pct={pct:.1}"
)
} else {
String::new()
};
eprintln!(
"[interior-cost] anchors={n_anchors} resolved={n_resolved} fade_blocked={n_fade_blocked} parts_written={n_written} resolve_ms={:.2} total_ms={:.2}{column}",
resolve_us / 1000.0,
_t0.elapsed().as_secs_f32() * 1000.0
);
}
}
/// The classifier's part-write query: a part's law record, the material and tag the law authors,
/// and the fade state that picks the material's blend twin. Named because both the system and its
/// shared write loop ([`write_anchor_parts`]) take it.
type PartWrite<'w, 's> = Query<
'w,
's,
(
&'static mut InteriorLit,
&'static mut MeshMaterial3d<WowModelMaterial>,
&'static mut MeshTag,
Option<&'static crate::model_fade::FadeMaterials>,
Has<RenderFade>,
Has<PendingAppearFade>,
),
>;
/// Author every part of one anchor from its standing `(law, fog)` — the classifier's ONE part-write
/// loop, shared by the law resolve and by the per-frame fog gate, so the two can never write the
/// channel differently. Returns how many parts actually took a write.
fn write_anchor_parts(
law: AppliedLaw,
fog: bool,
lane: u8,
lit_parts: Option<&LitParts>,
parts: &mut PartWrite,
) -> usize {
let mut written = 0;
for part in lit_parts.into_iter().flat_map(LitParts::iter) {
if let Ok((mut lit, mut material, mut tag, fm, ramping, pending)) = parts.get_mut(part) {
let fade = fm.filter(|_| ramping || pending);
written += usize::from(write_part_law(
law,
fog,
lane,
&mut lit,
&mut material,
&mut tag,
false,
fade,
));
}
}
written
}
/// Write one part's material + tag for `law` — the single place a part's channel is authored.
/// Change-gated on the part's last-written record unless `force` (a transient author — the zoom
/// feather — overwrote the channel while `applied` stayed current). Returns whether it wrote.
///
/// The tag: the Bake law's payload carries the probe SLOT in its bits-6..=18 field; the other laws
/// reset to the plain exterior payload (shade byte 0 — `entity_shade` runs after the classifier and
/// re-asserts the ramped intensity byte the same frame; it skips only Bake parts). Both writes
/// carry the tag's **alpha** field through, so a law change lands cleanly mid-fade. Every arm
/// carries the part's rig field through (decision 0720): a skinned part keeps its palette across
/// the indoor/outdoor transition.
///
/// `fog` is the INTERIOR_FOG_BIT, decided by the CALLER and orthogonal to the law: the reference
/// fogs a unit by its own interior classification **and** by whether the room it attached to is on
/// the camera's `[0xca7f00]` chain this frame — `[node+0xc]&2` is necessary, `[P+0x98] != 0` is the
/// other half (decision 1792 §4). An exterior-law part is never fogged, which the caller asserts by
/// passing `false`; that is the only coupling left between the two.
///
/// `fade` is `Some` only while an appear/despawn ramp owns the part (live **or** pending), and it
/// selects the law's BLEND twin instead of its steady material (decision 0755). It is the same
/// [`crate::model_fade::FadeMaterials::material_for`] rule the ramp itself applies every frame, so
/// the two writers produce the identical handle and can never fight, in either order.
///
/// Writing the material here rather than leaving it entirely to the ramp is what keeps the
/// **material mode and the tag payload mode naming the same law at every instant** — the invariant
/// `mesh_tag::describe` exists to catch a violation of (0355 broke exactly this way). Skipping it
/// would leave a part that classifies indoors while still *pending* carrying a probe slot on the
/// exterior material, where the shader decodes those bits as a ground-shade byte.
///
/// `lane` is the anchor's crossfade weight as tag bits (MONKEY, portal lane fade) and it is the
/// reason this function has an EFFECTIVE law distinct from its argument — see [`effective_law`].
fn write_part_law(
law: AppliedLaw,
fog: bool,
lane: u8,
lit: &mut InteriorLit,
material: &mut MeshMaterial3d<WowModelMaterial>,
tag: &mut MeshTag,
force: bool,
fade: Option<&crate::model_fade::FadeMaterials>,
) -> bool {
let law = effective_law(law, lane);
// The gate is the full triple (law, fog, lane): two consecutive frames of a crossing name the
// same effective law and differ only in the weight, so a law+fog gate would write the blend's
// first step and freeze there. Exactly-gated in the other direction too — the weight reaches
// its target exactly (`InteriorAnchor::step_lane` clamps onto it), so a settled part still
// costs zero writes however long it stands there.
if lit.applied == Some(law) && lit.fogged == fog && lit.lane == lane && !force {
return false;
}
lit.applied = Some(law);
lit.fogged = fog;
lit.lane = lane;
let want = match fade {
Some(fm) => fm.material_for(Some(&*lit), true).clone(),
None => lit.steady_material().clone(),
};
if material.0 != want {
material.0 = want;
}
let payload = match law {
AppliedLaw::Bake(slot) => crate::mesh_tag::with_interior_probe(tag.0, slot),
// MONKEY (torch shadows Phase 3A): the Matte law keeps the exterior material but flags
// the part INDOORS, so the shader's dynamic-interior lane holds across a Bake↔Matte
// flicker (the down-ray marginally hitting the baked floor at one spot). Since the portal
// lane fade it also carries the blend WEIGHT the shader mixes the two lanes by.
AppliedLaw::Matte => crate::mesh_tag::with_matte_indoor(tag.0, lane),
AppliedLaw::Exterior => crate::mesh_tag::with_exterior_reset(tag.0),
};
tag.0 = crate::mesh_tag::with_interior_fog(payload, fog);
true
}
/// MONKEY (portal lane fade): the law a part is actually AUTHORED under, given its anchor's
/// standing law and the blend weight that law is being reached at.
///
/// **The blend only ever exists in exterior material mode.** The exterior half of the crossfade is
/// the day/night result the exterior material computes, so both halves have to be available in one
/// fragment — and `Bake` is a different MATERIAL (interior mode, the probe-slot payload), which
/// cannot produce an exterior day/night result to fade from. So a part heading for `Bake` is
/// written as `Matte` — the exterior material, flagged indoors, at the current weight — for the
/// whole of the ramp, and commits the interior material and its probe slot only on the frame the
/// weight saturates. There is no visual seam at the commit: the shader routes `is_interior` and
/// the matte-indoor flag through the *same* `interior_room_light`, so the two agree at weight 1.
///
/// The mirror case is leaving: the standing law is already `Exterior` while the weight is still
/// unwinding, and until it reaches zero the part must keep the flag (and the exterior material it
/// already has) so the shader still has a room lane to fade OUT of. At zero it becomes the plain
/// exterior reset, which is what clears the flag and the field together.
///
/// The probe-slot lifecycle (`seat_probe_slot`/`BakeState`/`ParticleLight`) deliberately does NOT
/// wait on this: it turns over at the law flip, so a slot is seated (and the SH fold running)
/// slightly before the part starts reading it, and released slightly after it stops. That is the
/// harmless order — the alternative, a part on the interior material naming a slot the anchor has
/// already released, is the stuck-black-unit bug (0734).
fn effective_law(law: AppliedLaw, lane: u8) -> AppliedLaw {
match law {
// Mid-ramp toward the room: hold the exterior material and blend on the tag.
AppliedLaw::Bake(_) if lane < crate::mesh_tag::LANE_MAX => AppliedLaw::Matte,
// Mid-ramp back out: the verdict is outdoors, the blend is not there yet.
AppliedLaw::Exterior if lane > 0 => AppliedLaw::Matte,
settled => settled,
}
}
/// **Is this anchor's room on the camera's interior-fog chain this frame?** — the `[P+0x98]` half
/// of a unit's fog lane (decision 1792 §4), resolved through the same chain the room-visibility
/// leg walks: anchor record → placement instance → the flood's per-group gate.
///
/// Fails CLOSED at every seam (no room, despawned placement, a group past the set), which is
/// [`crate::wmo_portal::WmoPortalInstance::fogs_group`]'s own convention: the scene fog is what
/// every un-gated draw already wears, and a lookup miss must not paint a building's MFOG onto
/// something the flood never placed in it. An exterior-law anchor holds no room, so it answers
/// `false` without a lookup.
fn anchor_room_fogged(
room: Option<crate::wmo_portal::WmoRoom>,
instances: &Query<(Entity, &WmoPortalInstance)>,
) -> bool {
let Some(room) = room else {
return false;
};
let Ok((_, inst)) = instances.get(room.instance) else {
return false;
};
inst.fogs_group(room.group)
}
/// The attach and its anchor point — one choice, because `0x6a86d0`'s mode fork picks both the
/// routine and the field it reads: a GameObject attaches by CONTAINMENT from its world
/// bounding-box centre (`[node+0x5c]`), everything else DOWN-RAYS from its position
/// (`[node+0xa8]`). Decision 0776.
fn attach_anchor(
containment: bool,
bake_center: Option<&BodyBakeCenter>,
anchor_t: &GlobalTransform,
) -> (crate::wmo_portal::LightAttach, Vec3) {
use crate::wmo_portal::LightAttach;
match (containment, bake_center) {
(true, Some(BodyBakeCenter(center))) => {
(LightAttach::Containment, anchor_t.transform_point(*center))
}
// A containment anchor whose body model carries no bounds yet degrades to its origin — the
// same point the down-ray lane uses, never a wrong one.
(true, None) => (LightAttach::Containment, anchor_t.translation()),
(false, _) => (LightAttach::DownRay, anchor_t.translation()),
}
}
/// Resolve one anchor's indoor law: the down-ray verdict, the node's target/seed updates, and for
/// the Bake law the footprint fold into the anchor's OWNED probe slot. (The settled ramp-only
/// refold from the cached ray products lives in the caller's walk — this always rays.) `seated` —
/// the anchor's live [`PropProbeSlot`] — is the ONLY authority on that slot: Bake stays on it,
/// entry/exit is judged by it, and a part-cached `Bake(slot)` is never believed (a fresh part
/// once re-allocated here and freed the seated slot under the anchor's other parts — the
/// stuck-black-unit bug; the fade-latch reauthor is the other half). The slot component lives on
/// the ANCHOR — its on-remove hook frees the slot on despawn; law transitions remove/insert it
/// here.
fn resolve_anchor_law(
commands: &mut Commands,
probes: &mut PropProbes,
wmos: &Assets<WmoModel>,
instances: &Query<(Entity, &WmoPortalInstance)>,
streamer: &TerrainStreamer,
adt_tiles: &Assets<AdtTile>,
lighting: &crate::lighting::WowLighting,
nodes: &mut Query<&mut crate::entity_shade::GroundShade>,
anchor: Entity,
anchor_t: &GlobalTransform,
attach: crate::wmo_portal::LightAttach,
attach_at: Vec3,
bake_center: Option<Vec3>,
seated: Option<u16>,
) -> (AppliedLaw, Option<crate::wmo_portal::WmoRoom>) {
let (verdict, claimed) = indoor_verdict_at(
wmos,
instances.iter(),
streamer,
adt_tiles,
attach_at,
attach,
);
// Publish the outdoor GROUND kind to the node before the law resolves: standing on an
// outdoor-class WMO surface (street/deck/porch) forces the lit 2.5 target — the WMO-linked
// skip-shadow bit, byte-verified (0477/0480; `entity_shade` reads it).
let on_wmo = matches!(verdict, IndoorVerdict::OutdoorsOnWmo);
if let Ok(mut node) = nodes.get_mut(anchor) {
if node.on_wmo != on_wmo {
node.on_wmo = on_wmo;
}
}
let law = match bake_center {
None => match verdict {
IndoorVerdict::DayNight | IndoorVerdict::Baked { .. } => AppliedLaw::Matte,
IndoorVerdict::Outdoors | IndoorVerdict::OutdoorsOnWmo => AppliedLaw::Exterior,
},
Some(center) => {
match verdict {
IndoorVerdict::Outdoors | IndoorVerdict::OutdoorsOnWmo => AppliedLaw::Exterior,
IndoorVerdict::DayNight => AppliedLaw::Matte,
IndoorVerdict::Baked { mocv, lobes } => {
// The committed words: ambient chases cap96(MOCV) through the node's 2.0/s
// ramp (seeded from the scene ambient on lane entry, so walking into a warm
// room ramps rather than pops — the reference's `[+0x9c]` carries across the
// leg flip); diffuse = floor-168(MOCV) × the node's ramped intensity (1.0
// settled indoors; >1 transient while descending from an exterior 2.5 — the
// trace's "instance E") on the fixed axis + the hit group's windowed MOLR
// lobes from the model's bbox-centre reference point. Refolded per frame
// while the entity moves or the chases run (the reference re-runs its attach
// per env update — for a settled entity every input is time-independent).
let ref_point = anchor_t.transform_point(center);
let word = Vec3::from_array(floor168(mocv));
let (ambient, intensity) = match nodes.get_mut(anchor) {
Ok(mut node) => {
let target = Vec3::from_array(cap96(mocv));
// Lane ENTRY is "the anchor holds no slot" — a fresh part joining an
// already-seated anchor (a gear swap indoors) must neither reseed the
// ambient ramp nor re-allocate; the anchor is mid-lane.
if seated.is_none() {
node.seed_ambient(Vec3::from_array(lighting.ambient), target);
} else {
node.ambient_target = target;
}
(node.ambient, node.intensity())
}
// A bake-capable anchor without a node (no GroundShade yet): the settled
// committed words, directly.
Err(_) => (Vec3::from_array(cap96(mocv)), 1.0),
};
let words = (ambient.to_array(), (word * intensity).to_array());
let coeffs = fold_interior_probe(words.0, words.1, ref_point, &lobes);
let emitter_light = interior_light_up(words.0, words.1, ref_point, &lobes);
let slot = match seated {
// Staying in Bake: the anchor keeps its owned slot, rewritten in place —
// no component churn, no extraction churn.
Some(slot) => {
probes.update_owned(slot, coeffs);
Some(slot)
}
None => probes.alloc_owned(coeffs),
};
match slot {
Some(slot) => {
// `try_insert`: the anchor may have a same-frame despawn already
// queued (the net teardown on a stream drop) — despawn discards
// this pure cache, and the insert must not panic at apply time.
commands.entity(anchor).try_insert((
BakeState {
word,
lobes,
ref_point,
},
// The emitters' half of the same committed words — a different
// curve, because a particle draw takes the fixed-function lane
// where a mesh batch takes the SH one ([`interior_light_up`]).
ParticleLight(emitter_light),
// …and the ambient half alone, which is the whole term for a
// draw whose vertex format carries no normal ([`NodeAmbient`]).
NodeAmbient(words.0),
));
AppliedLaw::Bake(slot)
}
None => {
let (live, peak) = probes.occupancy();
warn_once!(
"interior-prop probe table full (live {live}, peak {peak}); \
indoor entities fall back to the day/night law"
);
AppliedLaw::Matte
}
}
}
}
}
};
// Publish the indoor verdict to the node — `entity_shade` picks the intensity target from it
// (2.5/0.5 by MCSH outdoors; the day/night 1.0 indoors, Matte and Bake alike).
if let Ok(mut node) = nodes.get_mut(anchor) {
let indoor = law != AppliedLaw::Exterior;
if node.indoor != indoor {
node.indoor = indoor;
}
}
// Slot lifecycle on the anchor, judged by the SEATED state (never a part's memory): entering
// Bake inserts the owned slot's component (its on-remove hook frees the slot); leaving
// removes it (and the fold cache). Staying rewrites the same slot in place above — no
// component cycling.
match (seated, law) {
(Some(old), AppliedLaw::Bake(new)) if old == new => {}
(_, AppliedLaw::Bake(new)) => seat_probe_slot(commands, anchor, new),
(Some(_), _) => {
// Leaving Bake: on a despawned anchor the despawn itself already ran the slot
// hook — the removes just must not panic.
commands
.entity(anchor)
.try_remove::<PropProbeSlot>()
.try_remove::<BakeState>()
.try_remove::<ParticleLight>()
.try_remove::<NodeAmbient>();
}
_ => {}
}
// The room rides back with the law, but only for the indoor laws: the exterior law is the
// "no record" arm, and holding a room there would let the fog gate fire on a unit standing on
// a porch. (`claimed` is already `None` on both outdoor verdicts; the probe-table-full fallback
// to Matte is the one arm that keeps a room the verdict resolved.)
let room = claimed
.filter(|_| law != AppliedLaw::Exterior)
.map(|(instance, group)| crate::wmo_portal::WmoRoom {
instance,
group: group as u16,
});
(law, room)
}
/// Queue the Bake slot swap on `anchor`, tolerant at APPLY time: the anchor may carry a
/// same-frame despawn queued earlier by the net teardown (a world-stream drop mid-transition),
/// which applies before this command and used to panic the classifier. Alive → the normal
/// remove/insert swap (the remove's hook frees the old slot); dead → the fresh slot's component
/// never lands, so its on-remove hook (the pool's only freer) never runs — release the orphan
/// directly instead of leaking it in a pool that never resets.
fn seat_probe_slot(commands: &mut Commands, anchor: Entity, new: u16) {
commands.queue(
move |world: &mut World| match world.get_entity_mut(anchor) {
Ok(mut e) => {
// One insert: the slot's `on_replace` hook frees the outgoing slot (2005).
e.insert(PropProbeSlot(new));
}
Err(_) => world.resource_mut::<PropProbes>().release(new),
},
);
}
#[cfg(test)]
mod tests {
use super::*;
fn classifier_world() -> World {
let mut world = World::new();
world.init_resource::<Time>();
world.init_resource::<WmoResidency>();
world.init_resource::<Assets<WmoModel>>();
world.init_resource::<Assets<AdtTile>>();
world.init_resource::<crate::lighting::WowLighting>();
world.init_resource::<PropProbes>();
world.init_resource::<TerrainStreamer>();
world.init_resource::<InteriorReauthor>();
world
}
/// The stuck-black repair, on 0734's queue: a part whose last-written record names a slot the
/// anchor no longer owns (it sat outside the classifier's write query — a fade window — across
/// a slot change) converges to the anchor's standing law even while everything stands
/// perfectly still, because re-entering the world enqueues it (here via the `on_add` hook; at
/// runtime the fade-latch observer is the same edge). Pre-0734's ancestor bug: the resolver
/// trusted the stale part's slot, `update_owned` on the freed slot silently no-opped, and the
/// unit rendered the freed slot's zeroed rows — a black silhouette that survived any in-room
/// movement until the law itself changed (director-caught: charge across the doorway
/// un-blacked it).
#[test]
fn a_stale_part_converges_to_the_anchors_standing_law() {
use bevy::ecs::system::RunSystemOnce;
let mut world = classifier_world();
let coeffs = [Vec4::ZERO; 7];
// The anchor's live owned slot — and a defunct one its part still remembers.
let live = world
.resource_mut::<PropProbes>()
.alloc_owned(coeffs)
.unwrap();
let stale = world
.resource_mut::<PropProbes>()
.alloc_owned(coeffs)
.unwrap();
world.resource_mut::<PropProbes>().release(stale);
let generation = world.resource::<WmoResidency>().generation;
// In a room whose gate is on, so the reauthored tag is the whole Bake payload — slot and
// fog flag together, which is what `probe_bits` names.
let instance = placement_with_gate(&mut world, true);
let anchor = world
.spawn((
GlobalTransform::default(),
PropProbeSlot(live),
InteriorAnchor {
law: AppliedLaw::Bake(live),
room: Some(crate::wmo_portal::WmoRoom { instance, group: 0 }),
fog: true,
last_pos: Vec3::ZERO, // matches the transform: the settled gate sees NO movement
generation,
kind_bake: true,
// MONKEY (portal lane fade): a SETTLED anchor — the blend already sits on its
// law's target, so `step_lane` is a no-op and the walk reads exactly as it did
// before the crossfade existed. Every fixture below is settled the same way.
lane: 1.0,
},
))
.id();
let mut lit = InteriorLit::new(
InteriorKind::Bake {
material: Handle::default(),
center: Vec3::ZERO,
},
Handle::default(),
);
lit.applied = Some(AppliedLaw::Bake(stale));
let part = world
.spawn((
lit,
ClassifiedBy(anchor),
MeshMaterial3d::<WowModelMaterial>(Handle::default()),
MeshTag(crate::mesh_tag::probe_bits(stale)),
))
.id();
world.run_system_once(classify_entity_interior).unwrap();
let lit = world.get::<InteriorLit>(part).unwrap();
assert!(
matches!(lit.applied, Some(AppliedLaw::Bake(s)) if s == live),
"the part's law re-anchors on the anchor's standing slot"
);
assert_eq!(
world.get::<MeshTag>(part).unwrap().0,
crate::mesh_tag::probe_bits(live),
"the tag reads the anchor's live slot, not the freed (black) one"
);
let (occupancy, _) = world.resource::<PropProbes>().occupancy();
assert_eq!(
occupancy, 1,
"repair neither re-allocates nor frees the live slot"
);
}
/// **The light node is the object's, not its mesh's.** An object whose every mesh batch is
/// UNLIT registers no [`LitParts`] at all — and before this, the classifier's walk was over
/// part lists, so it never visited one. Onyxia's 104 lava traps are exactly that shape
/// (`m2batch`: all five batches flags 0x13) with three LIT emitters each, and they stood in a
/// WMO interior taking the day/night sun. The reference builds the node from the object's
/// TYPEID and fills it every frame regardless of what its batches do (wow-re
/// `part-lit-normal-space.md` §6.2), so a lit EMITTER is a consumer of it exactly as a lit
/// batch is.
///
/// The control is the second anchor: an object with emitters but none of them lit registers
/// no edge, is not visited, and costs the walk nothing.
#[test]
fn an_object_whose_only_lit_consumer_is_an_emitter_is_still_classified() {
use bevy::ecs::system::RunSystemOnce;
let mut world = classifier_world();
// A GameObject: no mesh part joins the registry, one lit emitter does.
let trap = world
.spawn((
GlobalTransform::default(),
ContainmentAttach,
BodyBakeCenter(Vec3::new(0.0, 1.0, 0.0)),
))
.id();
world.spawn(EmitterLitBy(trap));
// The control: emitters, none lit, so nothing registers.
let unlit = world.spawn(GlobalTransform::default()).id();
world.run_system_once(classify_entity_interior).unwrap();
let state = world
.get::<InteriorAnchor>(trap)
.expect("a light node with a lit emitter is classified even with no lit mesh part");
assert!(
state.kind_bake,
"with no part to ask, the node takes the footprint law from its own bake centre"
);
assert!(
world.get::<InteriorAnchor>(unlit).is_none(),
"a node with no lit consumer is never visited"
);
}
/// A one-group placement whose interior-fog gate is `on`/`off` this frame — the `[0xca7f00]`
/// half of a unit's fog lane, as something the classifier tests can actually set.
fn placement_with_gate(world: &mut World, gate: bool) -> Entity {
world
.spawn(WmoPortalInstance {
handle: Handle::default(),
world_from_local: bevy::math::Affine3A::IDENTITY,
name_set: 0,
visible: vec![true],
interior_fog: vec![gate],
liquid_visited: vec![false],
flooded: vec![None],
})
.id()
}
/// A part spawned onto a SETTLED anchor (the gear-swap-indoors case) takes the standing law
/// through the `on_add` hook + drain — no law change, no anchor movement, and still the fresh
/// part's material/tag land on the anchor's law the very next classifier run.
#[test]
fn a_fresh_part_on_a_settled_anchor_takes_the_standing_law() {
use bevy::ecs::system::RunSystemOnce;
let mut world = classifier_world();
let generation = world.resource::<WmoResidency>().generation;
let instance = placement_with_gate(&mut world, true);
let anchor = world
.spawn((
GlobalTransform::default(),
InteriorAnchor {
law: AppliedLaw::Matte,
room: Some(crate::wmo_portal::WmoRoom { instance, group: 0 }),
fog: true,
last_pos: Vec3::ZERO,
generation,
kind_bake: false,
lane: 1.0,
},
))
.id();
let part = world
.spawn((
InteriorLit::new(InteriorKind::Matte, Handle::default()),
ClassifiedBy(anchor),
MeshMaterial3d::<WowModelMaterial>(Handle::default()),
MeshTag(0),
))
.id();
world.run_system_once(classify_entity_interior).unwrap();
let lit = world.get::<InteriorLit>(part).unwrap();
assert_eq!(lit.applied, Some(AppliedLaw::Matte));
assert_ne!(
world.get::<MeshTag>(part).unwrap().0 & crate::mesh_tag::INTERIOR_FOG_BIT,
0,
"the day/night law in a room whose gate is on carries the room's fog bit"
);
}
/// **The `[P+0x98]` conjunct** (decision 1792 §4): standing indoors is not enough. A settled
/// anchor whose room drops off the camera's interior-fog chain must return to the SCENE fog
/// without moving an inch and without changing its light law — the camera walked, not the
/// unit. This is B335's own artifact seen from one room further in: at Shadowfang the walls of
/// a room across the courtyard already read the scene fog (1787), and before this the mob
/// standing in it did not.
#[test]
fn a_settled_anchor_follows_its_rooms_fog_gate_without_moving() {
use bevy::ecs::system::RunSystemOnce;
let mut world = classifier_world();
let generation = world.resource::<WmoResidency>().generation;
let instance = placement_with_gate(&mut world, true);
let anchor = world
.spawn((
GlobalTransform::default(),
InteriorAnchor {
law: AppliedLaw::Matte,
room: Some(crate::wmo_portal::WmoRoom { instance, group: 0 }),
fog: true,
last_pos: Vec3::ZERO, // settled: the walk never re-rays this anchor
generation,
kind_bake: false,
lane: 1.0,
},
))
.id();
let part = world
.spawn((
InteriorLit::new(InteriorKind::Matte, Handle::default()),
ClassifiedBy(anchor),
MeshMaterial3d::<WowModelMaterial>(Handle::default()),
MeshTag(0),
))
.id();
world.run_system_once(classify_entity_interior).unwrap();
let fog_of = |world: &World, part| {
world.get::<MeshTag>(part).unwrap().0 & crate::mesh_tag::INTERIOR_FOG_BIT != 0
};
assert!(fog_of(&world, part), "gate on ⇒ the room's fog");
// The camera moves out of the chain; the anchor does not move at all.
world
.get_mut::<WmoPortalInstance>(instance)
.unwrap()
.interior_fog[0] = false;
world.run_system_once(classify_entity_interior).unwrap();
assert!(!fog_of(&world, part), "gate off ⇒ back on the scene fog");
assert_eq!(
world.get::<InteriorLit>(part).unwrap().applied,
Some(AppliedLaw::Matte),
"and the LIGHT law is untouched — the unit is still standing in the same room"
);
// …and back, so the gate is a live read rather than a one-way latch.
world
.get_mut::<WmoPortalInstance>(instance)
.unwrap()
.interior_fog[0] = true;
world.run_system_once(classify_entity_interior).unwrap();
assert!(fog_of(&world, part), "the camera comes back into the chain");
}
/// Decision 0778: a model's BILLBOARD batch takes the same law as its mesh batches. The card
/// spawns as a world ROOT (the facing system owns its transform, 0153) rather than as a child
/// of the model, and that is the whole difference — it goes through the same
/// [`part_interior_lit`] and names the same anchor, so both converge on the same law. The bug
/// this pins: a Stratholme hanging sign baked from its room while its own chain cards, never
/// classified at all, stayed on the exterior material and lit from the sky.
///
/// Also pins the classify-OUT arm — a WMO-display part builds no interior variant and must not
/// join at all, or it would be relit off a material it doesn't have.
#[test]
fn a_models_billboard_card_takes_the_same_law_as_its_mesh_parts() {
use bevy::ecs::system::RunSystemOnce;
let mut world = classifier_world();
let generation = world.resource::<WmoResidency>().generation;
let slot = world
.resource_mut::<PropProbes>()
.alloc_owned([Vec4::ZERO; 7])
.unwrap();
let anchor = world
.spawn((
GlobalTransform::default(),
PropProbeSlot(slot),
InteriorAnchor {
law: AppliedLaw::Bake(slot),
room: None,
fog: false,
last_pos: Vec3::ZERO,
generation,
kind_bake: true,
lane: 1.0,
},
))
.id();
let bake = Handle::default();
let exterior = Handle::default();
let lit_for = |anchor| {
part_interior_lit(&exterior, Some(&exterior), Some(&bake), Vec3::ZERO, anchor)
.expect("an entity M2 batch always builds an interior variant")
};
// The mesh batch rides the model's tree; the card is a world root. Nothing else differs.
let mesh = world
.spawn((
lit_for(anchor),
MeshMaterial3d::<WowModelMaterial>(Handle::default()),
MeshTag(0),
))
.id();
world.entity_mut(anchor).add_child(mesh);
let card = world
.spawn((
lit_for(anchor),
MeshMaterial3d::<WowModelMaterial>(Handle::default()),
MeshTag(crate::mesh_tag::alpha_bits(1.0)),
))
.id();
world.run_system_once(classify_entity_interior).unwrap();
assert_eq!(
world.get::<InteriorLit>(card).unwrap().applied,
Some(AppliedLaw::Bake(slot)),
"the world-root card takes its model's law, not the world's"
);
assert_eq!(
world.get::<InteriorLit>(mesh).unwrap().applied,
world.get::<InteriorLit>(card).unwrap().applied,
"a model never splits across the two light laws at the billboard seam"
);
assert_eq!(
world.get::<MeshTag>(card).unwrap().0,
crate::mesh_tag::with_interior_probe(crate::mesh_tag::alpha_bits(1.0), slot),
"the law composes into the tag — the probe slot lands, the card's alpha survives"
);
assert!(
part_interior_lit(&exterior, None, None, Vec3::ZERO, anchor).is_none(),
"a WMO-display part has no interior variant and classifies out entirely"
);
}
/// The mixed-kind hole (0734 §3): a bake-capable part joining an anchor whose law was
/// resolved from a matte-kind part drops the anchor's record — the next run re-rays with the
/// bake kind in reach instead of riding the matte fallback until the anchor happens to move.
#[test]
fn a_bake_part_joining_a_matte_resolved_anchor_forces_a_re_resolve() {
use bevy::ecs::system::RunSystemOnce;
let mut world = classifier_world();
let generation = world.resource::<WmoResidency>().generation;
let anchor = world
.spawn((
GlobalTransform::default(),
InteriorAnchor {
law: AppliedLaw::Exterior,
room: None,
fog: false,
last_pos: Vec3::ZERO,
generation,
kind_bake: false,
lane: 0.0,
},
))
.id();
world.spawn((
InteriorLit::new(
InteriorKind::Bake {
material: Handle::default(),
center: Vec3::ZERO,
},
Handle::default(),
),
ClassifiedBy(anchor),
MeshMaterial3d::<WowModelMaterial>(Handle::default()),
MeshTag(0),
));
world.run_system_once(classify_entity_interior).unwrap();
assert!(
world.get::<InteriorAnchor>(anchor).is_none(),
"the matte-resolved record is dropped so the next run re-rays"
);
}
/// The 0755 regression, at the anchor walk: an anchor whose parts are ALL mid-appear-fade
/// still resolves its law. Pre-0755 the classifier's write query excluded fading parts, so
/// such an anchor was skipped outright — a freshly-streamed indoor entity had no light law for
/// the whole 2 s ramp, appeared under the exterior lane, and swapped laws in a single frame the
/// instant the ramp latched (director-reported: "it swaps lighting in an instant once it's
/// fully faded in", measured at 2.02 s after the fade armed).
#[test]
fn an_anchor_whose_parts_are_all_fading_still_resolves() {
use bevy::ecs::system::RunSystemOnce;
let mut world = classifier_world();
let anchor = world.spawn(GlobalTransform::default()).id();
let part = world
.spawn((
InteriorLit::new(InteriorKind::Matte, Handle::default()),
ClassifiedBy(anchor),
MeshMaterial3d::<WowModelMaterial>(Handle::default()),
MeshTag(crate::mesh_tag::alpha_bits(0.125)),
crate::model_fade::RenderFade {
started: 0.0,
duration: 2.0,
from: 0.0,
to: 1.0,
curve: crate::model_fade::FadeCurve::Cubic,
},
))
.id();
world.run_system_once(classify_entity_interior).unwrap();
assert!(
world.get::<InteriorAnchor>(anchor).is_some(),
"the anchor resolves during the ramp, not two seconds after it"
);
assert!(
world.get::<InteriorLit>(part).unwrap().applied.is_some(),
"and the part records the law it resolved to"
);
}
/// The other half of 0755: writing a law onto a part whose ramp is live must carry the ramp's
/// alpha through the payload rewrite (the classifier used to hardcode opaque, which is exactly
/// why it had to be locked out), and must land the part on that law's BLEND twin — so the
/// material mode and the tag payload mode name the same law at every instant, mid-ramp
/// included.
#[test]
fn a_law_written_mid_ramp_keeps_the_alpha_and_takes_the_laws_blend_twin() {
use bevy::ecs::system::RunSystemOnce;
let mut world = classifier_world();
let slot = world
.resource_mut::<PropProbes>()
.alloc_owned([Vec4::ZERO; 7])
.unwrap();
let generation = world.resource::<WmoResidency>().generation;
let anchor = world
.spawn((
GlobalTransform::default(),
PropProbeSlot(slot),
InteriorAnchor {
law: AppliedLaw::Bake(slot),
room: None,
fog: false,
last_pos: Vec3::ZERO, // settled: the walk skips the ray, the drain does the work
generation,
kind_bake: true,
lane: 1.0,
},
))
.id();
// The steady bake variant, and the probe-lit blend twin the ramp must feather on.
let bake: Handle<WowModelMaterial> =
bevy::asset::uuid_handle!("ba000000-0000-4000-8000-00000000ba1e");
let bake_blend: Handle<WowModelMaterial> =
bevy::asset::uuid_handle!("bb000000-0000-4000-8000-00000000b1e4");
let exterior_blend: Handle<WowModelMaterial> =
bevy::asset::uuid_handle!("e0000000-0000-4000-8000-00000000b1e4");
let mid_ramp = crate::mesh_tag::alpha_bits(0.25);
let part = world
.spawn((
InteriorLit::new(
InteriorKind::Bake {
material: bake.clone(),
center: Vec3::ZERO,
},
Handle::default(),
),
ClassifiedBy(anchor),
// Spawned on the EXTERIOR blend twin, as a streamed part is before it classifies.
MeshMaterial3d::<WowModelMaterial>(exterior_blend.clone()),
MeshTag(mid_ramp),
crate::model_fade::FadeMaterials {
cutout: Handle::default(),
blend: exterior_blend.clone(),
bake_blend: Some(bake_blend.clone()),
zfill: None,
},
crate::model_fade::RenderFade {
started: 0.0,
duration: 2.0,
from: 0.0,
to: 1.0,
curve: crate::model_fade::FadeCurve::Cubic,
},
))
.id();
world.run_system_once(classify_entity_interior).unwrap();
let tag = world.get::<MeshTag>(part).unwrap().0;
assert!(
world.get::<InteriorLit>(part).unwrap().is_bake(),
"the ramping part takes the room's bake law"
);
assert_eq!(
tag,
crate::mesh_tag::with_interior_probe(mid_ramp, slot),
"the probe slot lands on top of the ramp's alpha, not over it"
);
assert_ne!(
tag,
crate::mesh_tag::probe_bits(slot),
"the payload must not force a mid-ramp part opaque (the pre-0755 write)"
);
let material = world
.get::<MeshMaterial3d<WowModelMaterial>>(part)
.unwrap()
.0
.clone();
assert_eq!(
material, bake_blend,
"a ramping part takes the law's PROBE-LIT blend twin — not the exterior one it \
spawned on (its light would read as full outdoor intensity), and not the steady bake \
variant (the ramp would stop feathering)"
);
assert_ne!(material, exterior_blend);
assert_ne!(material, bake);
}
/// The fade-latch edge: removing a part's `RenderFade` re-enqueues it for authoring — the
/// event that closes every fade-exclusion window (0734 §3; the old settled-path sweep is
/// gone, so this observer IS the convergence path).
#[test]
fn a_fade_latch_enqueues_the_part_for_reauthoring() {
let mut world = classifier_world();
world.add_observer(enqueue_on_fade_latch);
let part = world
.spawn(InteriorLit::new(InteriorKind::Matte, Handle::default()))
.id();
world.resource_mut::<InteriorReauthor>().0.clear(); // drop the on_add entry
world
.entity_mut(part)
.insert(crate::model_fade::RenderFade {
started: 0.0,
duration: 1.0,
from: 0.0,
to: 1.0,
curve: crate::model_fade::FadeCurve::Cubic,
});
world
.entity_mut(part)
.remove::<crate::model_fade::RenderFade>();
assert_eq!(
world.resource::<InteriorReauthor>().0,
vec![part],
"the latch is the re-entry edge"
);
}
/// **The 0776 fork.** A GameObject anchor probes at its world bounding-box CENTRE, a unit at
/// its position — and the centre is where the difference bites: the Stratholme portcullis whose
/// spawn z sits 15 cm *under* the corridor slab rays into open air from its origin and into the
/// room from its centre (measured: `exterior` at z 125.354, `BAKE g02` at 125.40 and above).
/// The scale leg matters too — the centre is model-local, so a scaled placement must carry it
/// through the transform rather than adding a raw offset.
#[test]
fn a_gameobject_anchors_at_its_box_centre_and_a_unit_at_its_position() {
use crate::wmo_portal::LightAttach;
let at = GlobalTransform::from(
Transform::from_translation(Vec3::new(10.0, 100.0, -5.0)).with_scale(Vec3::splat(2.0)),
);
let centre = BodyBakeCenter(Vec3::new(0.0, 3.5, 0.0));
let (attach, anchor) = attach_anchor(true, Some(&centre), &at);
assert_eq!(attach, LightAttach::Containment);
assert_eq!(
anchor,
Vec3::new(10.0, 107.0, -5.0),
"the containment anchor is the box centre through the placement — scale included"
);
let (attach, anchor) = attach_anchor(false, Some(&centre), &at);
assert_eq!(attach, LightAttach::DownRay);
assert_eq!(
anchor,
at.translation(),
"a unit still rays from its position, box centre or not"
);
// No bounds yet (the model is still streaming): degrade to the origin, never to a wrong
// point — the containment lane's other legs still apply.
let (attach, anchor) = attach_anchor(true, None, &at);
assert_eq!(attach, LightAttach::Containment);
assert_eq!(anchor, at.translation());
}
/// The teardown race, both arms: a slot seated on a live anchor swaps normally; one seated
/// on an anchor whose despawn applied first neither panics (the old crash) nor leaks the
/// freshly allocated slot (the pool never resets — an orphan would be held forever).
#[test]
fn slot_seat_survives_a_despawned_anchor_and_releases_the_orphan() {
let mut world = World::new();
world.init_resource::<PropProbes>();
let coeffs = [Vec4::ZERO; 7];
let alive = world.spawn_empty().id();
let slot_a = world
.resource_mut::<PropProbes>()
.alloc_owned(coeffs)
.unwrap();
seat_probe_slot(&mut world.commands(), alive, slot_a);
world.flush();
assert_eq!(world.get::<PropProbeSlot>(alive).unwrap().0, slot_a);
let doomed = world.spawn_empty().id();
let slot_b = world
.resource_mut::<PropProbes>()
.alloc_owned(coeffs)
.unwrap();
let (live_before, _) = world.resource::<PropProbes>().occupancy();
world.entity_mut(doomed).despawn();
seat_probe_slot(&mut world.commands(), doomed, slot_b);
world.flush();
let (live_after, _) = world.resource::<PropProbes>().occupancy();
assert_eq!(
live_after,
live_before - 1,
"the orphan slot is released, not leaked"
);
}
}