mesh_tag: an interior prop with no probe slot is still indoors

The SH-probe table is 13 tag bits wide and nothing can widen it — the probe
field is the last of the u32 — so a dense interior can ask for more slots than
exist. His session hit it (`live 8192, peak 8192`), and every prop that missed
out was then tagged as plain exterior: furniture lit by the sky, which in a Dun
Morogh snowstorm reads as the weather coming indoors.

A prop that misses a slot is still *indoors*, and saying so costs no bits.
`PropSeat` names the three states a spawner can be in — `Exterior`, `Probe(slot)`
and `Matte` — and `Matte` is the exterior payload with `MATTE_INDOOR_BIT` and a
saturated lane: the room's day/night law, which is exactly what
`interior::AppliedLaw::Matte` already gives an entity whose classification finds
the table full. A dim room instead of a sunlit one.

The seat also replaces the bare `Option<u16>` the assembler took, so a caller
now says which of the three it means rather than leaving `None` to stand for
both "sky-lit" and "wanted a probe and lost".

Still true: the table can fill, and the props past it lose their room's *baked*
light. Lowering that demand (sharing by a quantized fold, or scoping the
allocation) is the next lever and wants a measurement first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-17 18:35:43 -06:00
co-authored by Claude Opus 5
parent 4e92fcf1a1
commit 994da2ba85
4 changed files with 156 additions and 59 deletions
+40 -30
View File
@@ -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<PropLobeLight> = 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<PropLobeLight> = 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;
}
+77
View File
@@ -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<u16> {
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);
@@ -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<crate::interact::WorldObject>,
// 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<u16>,
// 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 {
@@ -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 => {