diff --git a/crates/benilla-app/src/entities/wmo_props.rs b/crates/benilla-app/src/entities/wmo_props.rs index 80c0377e..e5a2d977 100644 --- a/crates/benilla-app/src/entities/wmo_props.rs +++ b/crates/benilla-app/src/entities/wmo_props.rs @@ -216,33 +216,42 @@ pub(super) fn spawn_wmo_gameobject_props( let (radius, center) = m2_fade(&m.bounds, prop.local.scale.x); let anim_bound = m2_anim_bound(&m.bounds); // Fold an interior prop's light once, through the host's current pose (module docs). - let interior_slot = prop.interior.as_ref().and_then(|lane| { - let ref_point = host_world.mul_transform(prop.local).transform_point(center); - let world_lights: Vec = lane - .lights - .iter() - .map(|&(pos, color_i, atten_start, atten_end)| PropLobeLight { - pos: host_world.transform_point(pos), - color_i, - atten_start, - atten_end, - }) - .collect(); - let slot = probes.alloc(fold_interior_probe( - lane.ambient, - lane.diffuse, - ref_point, - &world_lights, - )); - if slot.is_none() { - let (live, peak) = probes.occupancy(); - warn_once!( - "interior-prop probe table full (live {live}, peak {peak}); \ - cabin props fall back to exterior light" + let seat = prop.interior.as_ref().map_or( + benilla_world::mesh_tag::PropSeat::Exterior, + |lane| { + let ref_point = host_world.mul_transform(prop.local).transform_point(center); + let world_lights: Vec = lane + .lights + .iter() + .map(|&(pos, color_i, atten_start, atten_end)| PropLobeLight { + pos: host_world.transform_point(pos), + color_i, + atten_start, + atten_end, + }) + .collect(); + let coeffs = fold_interior_probe( + lane.ambient, + lane.diffuse, + ref_point, + &world_lights, ); - } - slot - }); + match probes.alloc(coeffs) { + Some(slot) => benilla_world::mesh_tag::PropSeat::Probe(slot), + None => { + // Still indoors, just unseated: the room's day/night law beats the sky + // inside a cabin. + let (live, peak) = probes.occupancy(); + warn_once!( + "interior-prop probe table full (live {live}, peak {peak}); \ + cabin props take the room's day/night law instead of their own \ + folded probe" + ); + benilla_world::mesh_tag::PropSeat::Matte + } + } + }, + ); // The identity only names the kind and the parts: a pick on the ship answers with the // GameObject's own `WorldObject`. let object = std::sync::Arc::new(benilla_world::interact::WorldObject { @@ -254,7 +263,7 @@ pub(super) fn spawn_wmo_gameobject_props( .unwrap_or_default(), id: 0, // The prop's lane and probe, as a terrain prop's identity names them. - detail: match (&prop.interior, interior_slot) { + detail: match (&prop.interior, seat.slot()) { (Some(lane), Some(slot)) => format!( "WMO gameobject prop · interior amb {} dif {} · {} MOLR · probe slot {slot}", hex_word(lane.ambient), @@ -262,7 +271,8 @@ pub(super) fn spawn_wmo_gameobject_props( lane.lights.len(), ), (Some(_), None) => { - "WMO gameobject prop · interior, NO PROBE SLOT (table full — sky-lit)".into() + "WMO gameobject prop · interior, NO PROBE SLOT (table full — matte indoor)" + .into() } (None, _) => "WMO gameobject prop · sky-lit".into(), }, @@ -282,7 +292,7 @@ pub(super) fn spawn_wmo_gameobject_props( prop.local, // doodad-local; the parent composes the world pose &object, shade, - interior_slot, + seat, // No draw-set gate, whose `EmitterFade` measures from a fixed world point; the // assembler builds its sphere from `radius` and `center` instead. None, @@ -301,7 +311,7 @@ pub(super) fn spawn_wmo_gameobject_props( ); commands.entity(entity).add_children(&ents); // The slot's component hook returns it to the table whoever despawns the prop. - if let (Some(slot), Some(&first)) = (interior_slot, ents.first()) { + if let (Some(slot), Some(&first)) = (seat.slot(), ents.first()) { commands.entity(first).insert(PropProbeSlot(slot)); interior += 1; } diff --git a/crates/benilla-world/src/mesh_tag.rs b/crates/benilla-world/src/mesh_tag.rs index 13b3f690..d1862991 100644 --- a/crates/benilla-world/src/mesh_tag.rs +++ b/crates/benilla-world/src/mesh_tag.rs @@ -120,6 +120,54 @@ pub(crate) fn probe_bits(slot: u16) -> u32 { INTERIOR_FOG_BIT | (u32::from(slot) << PROBE_SHIFT) | alpha_bits(1.0) } +/// **Where an M2 prop's interior light comes from**, which is also what its spawn tag encodes. +/// +/// The probe table is 13 tag bits wide and nothing can widen it ([`PROBE_MASK`] is the last of the +/// u32), so a dense interior can ask for more slots than exist. A prop that misses out is still +/// *indoors*, and saying so costs no bits: [`Self::Matte`] is the room's day/night law in the +/// exterior payload, the same fallback `interior::AppliedLaw::Matte` gives an entity whose +/// classification finds the table full. The difference it buys is a dim room instead of one lit +/// like the weather outside. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum PropSeat { + /// Not an interior prop: the sky lights it. + #[default] + Exterior, + /// The prop's folded SH probe, at this table slot. + Probe(u16), + /// An interior prop the probe table had no slot for. + Matte, +} + +impl PropSeat { + /// The table slot, for the holders that own and free it (`PropProbeSlot`, the merge and + /// retained-pass sites). + pub fn slot(self) -> Option { + match self { + Self::Probe(slot) => Some(slot), + Self::Exterior | Self::Matte => None, + } + } + + /// Whether the prop stands in a room at all, slot or no slot. + pub(crate) fn is_interior(self) -> bool { + !matches!(self, Self::Exterior) + } + + /// The batch's spawn tag: the probe payload for a seated prop, the saturated matte-indoor lane + /// for an unseated one, else the plain opaque exterior payload. + pub(crate) fn spawn_tag(self) -> u32 { + match self { + Self::Probe(slot) => probe_bits(slot), + // The spawn-time twin of [`with_matte_indoor`]: indoors from its first frame, with no + // slot to name and no ramp to wait for. The fog bit is left to [`with_interior_fog`], + // as it is for every other matte part. + Self::Matte => with_matte_indoor(alpha_bits(1.0), LANE_MAX), + Self::Exterior => alpha_bits(1.0), + } + } +} + /// The alpha field a whole-payload rewrite carries, the untagged `0` (which [`alpha_bits`] never /// writes) made opaque: it lets the classifier re-lane a part mid-fade. fn carried_alpha(tag: u32) -> u32 { @@ -352,6 +400,35 @@ mod tests { assert_eq!(shade_of(t, ext()), 10); } + /// The three prop seats, and the one that matters: a prop the table had no slot for must still + /// read as indoors, or a room full of furniture is lit by the weather outside. + #[test] + fn an_unseated_interior_prop_spawns_on_the_room_lane_not_the_sky() { + let seated = PropSeat::Probe(4321).spawn_tag(); + assert_eq!((seated & PROBE_MASK) >> PROBE_SHIFT, 4321); + assert_eq!(seated & INTERIOR_FOG_BIT, INTERIOR_FOG_BIT); + + let unseated = PropSeat::Matte.spawn_tag(); + assert_eq!(unseated & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT, "indoors"); + assert_eq!( + lane_of(unseated), + LANE_MAX, + "fully on the room lane, no ramp" + ); + assert_eq!(unseated & ALPHA_MASK, ALPHA_MASK, "opaque"); + assert_eq!(unseated & PROBE_MASK & !(MATTE_INDOOR_BIT | LANE_MASK), 0); + + let sky = PropSeat::Exterior.spawn_tag(); + assert_eq!(sky & MATTE_INDOOR_BIT, 0); + assert_eq!(lane_of(sky), 0); + assert_eq!(sky & ALPHA_MASK, ALPHA_MASK); + + // The shade ramp is the busiest writer over a prop's tag; it must not eat the lane. + let shaded = with_shade(unseated, 200, ext()); + assert_eq!(shaded & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT); + assert_eq!(lane_of(shaded), LANE_MAX); + } + #[test] fn probe_bits_compose_with_the_alpha_field() { let t = probe_bits(6660); diff --git a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs index 5a9399da..cb370502 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs @@ -12,7 +12,6 @@ use bevy::render::render_resource::Buffer; use crate::billboard::BillboardCard; use crate::doodad_anim::DoodadAnimHost; -use crate::mesh_tag::alpha_bits; use crate::model_fade::DoodadFade; use crate::model_render::{model_material, MaterialCache, ShadeSel}; use crate::model_render::{ModelKind, ModelPart}; @@ -69,8 +68,9 @@ pub fn spawn_model_entities( object: &Arc, // The MCSH terrain shade at the base; WMO group geometry and interior props ignore it. shade: ShadeSel, - // An interior M2 prop's SH-probe slot, carried in `MeshTag` on every batch, cards included. - interior_slot: Option, + // Where an interior M2 prop's light comes from, carried in `MeshTag` on every batch, cards + // included: its SH-probe slot, or the room's day/night lane when the table had none. + seat: crate::mesh_tag::PropSeat, // The placement's draw-set gate, shared with its emitters, ribbons, lights and anim host: a // meshless prop's only verdict. `None` on the entity-hosted lane, as a mover has no baked // world point to measure from; the mesh lane then builds a bare sphere from `radius`. @@ -153,13 +153,13 @@ pub fn spawn_model_entities( // a WMO batch also takes it into the clip-z nudge, as the reference draws MOBA batches in // strict file order (`0x6b4f10`/`0x6b5190`). let batch_order = u16::try_from(batch_idx + 1).unwrap_or(u16::MAX); - let interior = sub.interior || interior_slot.is_some(); + let interior = sub.interior || seat.is_interior(); // A billboard card culls by its `0x04` flag like any batch: the bone turns +X to the // viewer and the cull is GL_BACK/CCW (`0x70c2b3`), so a −X card is meant to go unseen. let two_sided = sub.two_sided; // An interior M2 prop's batches carry its SH-probe slot, cards included: the reference // shades every batch of an object through one light node. - let interior_probe = !is_wmo && interior_slot.is_some(); + let interior_probe = !is_wmo && seat.slot().is_some(); // Only a non-card interior batch is steady; a card still fades with its lamp. let steady_interior_prop = interior_probe && sub.billboard.is_none(); // A batch whose UV or tint loop differs by sequence gets a material keyed by this @@ -531,14 +531,18 @@ pub fn spawn_model_entities( ); } } - // `MeshTag`: an interior prop's probe slot through `mesh_tag::probe_bits`, else the fade - // alpha at 1.0. One predicate feeds both the tag and `InteriorProbePayload` below, so the - // payload's writer and readers cannot disagree. - let probe_slot = interior_slot.filter(|_| interior_probe); - let mesh_tag = match probe_slot { - Some(slot) => MeshTag(crate::mesh_tag::probe_bits(slot)), - None => MeshTag(alpha_bits(1.0)), + // `MeshTag`: the seat's own encoding ([`crate::mesh_tag::PropSeat::spawn_tag`]) — the + // probe payload for a seated interior prop, the matte-indoor room lane for one the table + // had no slot for, the plain opaque payload for anything else. WMO group geometry lights + // itself from its vertex colours and takes no seat. `probe_slot` feeds both the tag and + // `InteriorProbePayload` below, so the payload's writer and readers cannot disagree. + let seat = if is_wmo { + crate::mesh_tag::PropSeat::Exterior + } else { + seat }; + let probe_slot = seat.slot(); + let mesh_tag = MeshTag(seat.spawn_tag()); // A billboard card's transform is the billboard system's; it still fades with its doodad, // from its pivot, where the mesh is centred (`ZERO`). let (entity, fade_center) = if let Some(info) = &sub.billboard { diff --git a/crates/benilla-world/src/terrain_stream/spawn/mod.rs b/crates/benilla-world/src/terrain_stream/spawn/mod.rs index bcc2102e..68f15a86 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/mod.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/mod.rs @@ -197,7 +197,7 @@ pub(super) fn spawn_loaded_placements( p.transform, &object, shade, - None, // map doodad: exterior sky lighting (no interior probe) + crate::mesh_tag::PropSeat::Exterior, // a map doodad is sky-lit Some(&fade), radius, center, @@ -407,7 +407,8 @@ pub(super) fn spawn_loaded_placements( p.transform, &object, ShadeSel::Matte, // unread: WMO lights on the FFP N·L path - None, // WMO groups carry their own per-submesh interior flag + batch class + // WMO groups carry their own per-submesh interior flag + batch class. + crate::mesh_tag::PropSeat::Exterior, Some(&fade), f32::INFINITY, Vec3::ZERO, @@ -711,8 +712,8 @@ pub(super) fn spawn_loaded_placements( // An interior prop's light, folded once into its SH probe: the reference refolds at // each light commit, but every input here is static. The reference point is the placed // M2 box centre, the `[def+0x5c]` anchor of `0x6952a0`/`0x713680`. - let interior_slot = match &d.light { - PropLight::Exterior => None, + let seat = match &d.light { + PropLight::Exterior => crate::mesh_tag::PropSeat::Exterior, PropLight::Interior { ambient, diffuse, @@ -720,16 +721,21 @@ pub(super) fn spawn_loaded_placements( } => { let ref_point = d.transform.transform_point(center); let coeffs = fold_interior_probe(*ambient, *diffuse, ref_point, lights); - let slot = probes.alloc(coeffs); - if slot.is_none() { - // Once per burst, not per prop: a city WMO can flood thousands in a frame. - let (live, peak) = probes.occupancy(); - warn_once!( - "interior-prop probe table full (live {live}, peak {peak}); \ - overflowing props fall back to exterior light" - ); + match probes.alloc(coeffs) { + Some(slot) => crate::mesh_tag::PropSeat::Probe(slot), + None => { + // Once per burst, not per prop: a city WMO can flood thousands in a + // frame. The prop keeps the room's day/night law — it is still + // indoors, and only its *probe* is missing. + let (live, peak) = probes.occupancy(); + warn_once!( + "interior-prop probe table full (live {live}, peak {peak}); \ + overflowing props take the room's day/night law instead of their \ + own folded probe" + ); + crate::mesh_tag::PropSeat::Matte + } } - slot } }; let object = Arc::new(WorldObject { @@ -760,7 +766,7 @@ pub(super) fn spawn_loaded_placements( d.transform, &object, shade, - interior_slot, // interior props light off their folded probe, not the sky + seat, // interior props light off their folded probe, not the sky Some(&fade), radius, center, @@ -776,7 +782,7 @@ pub(super) fn spawn_loaded_placements( MergeSite::Prop { uid: unique_id, groups: &d.groups, - slot: interior_slot, + slot: seat.slot(), }, )), // The retained-pass region is the building's instance, its PVS key and lifetime; @@ -787,7 +793,7 @@ pub(super) fn spawn_loaded_placements( crate::static_gx::GxSite::Prop { instance, groups: &d.groups, - slot: interior_slot, + slot: seat.slot(), }, )), (None, Some(gx)) => { @@ -799,7 +805,7 @@ pub(super) fn spawn_loaded_placements( ); // A despawn hook returns the slot to the table. A prop whose every batch merged spawns // a bare carrier for it, since a blob cannot own one prop's slot. - if let Some(slot) = interior_slot { + if let Some(slot) = seat.slot() { let owner = match ents.first() { Some(&first) => first, None => {