From 23576eaf50c0be7a4526d14a10a5bd1ab5f2f0cb Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Mon, 7 Sep 2026 20:51:20 +0100
Subject: [PATCH] MONKEY: portal lane cross-fade for entities
An entity crossing a WMO portal no longer pops between the exterior
day/night result and the dynamic room lane in one frame. The classifier
ramps a per-anchor lane weight at 2/s (first resolve snaps, so a unit
streaming in indoors never fades up from dark) and, while ramping, writes
its parts in exterior material mode with the weight in tag bits 15..=18
beside the MATTE_INDOOR bit; the Bake material commits only at full
weight. wow_model.wgsl mixes exterior-lit -> room-lit by that weight
(forced to 1 in interior material mode, where those bits are the probe
slot).
Co-Authored-By: Claude Fable 5.1
---
.../benilla-assets/src/shaders/wow_model.wgsl | 19 +-
crates/benilla-world/src/interior.rs | 186 ++++++++++++++++--
crates/benilla-world/src/mesh_tag.rs | 83 ++++++--
3 files changed, 260 insertions(+), 28 deletions(-)
diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl
index 3549370b..98424f15 100644
--- a/crates/benilla-assets/src/shaders/wow_model.wgsl
+++ b/crates/benilla-assets/src/shaders/wow_model.wgsl
@@ -1278,14 +1278,27 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
let matte_indoor = !is_interior && (fade_tag & 0x4000u) != 0u;
if ((is_interior || matte_indoor) && !is_wmo && !is_rig && wow_light.wmo_fog_params.w > 0.5) {
let room = interior_room_light(in.world_position.xyz, n_lit);
- lit_rgb = albedo * inst_tint * (vec3(1.0) - exp(-room * wow_light.point_count.w));
+ var room_rgb = albedo * inst_tint * (vec3(1.0) - exp(-room * wow_light.point_count.w));
let idbg = u32(max(wow_light.wmo_fog_params.w - 1.0, 0.0) + 0.5);
if (idbg == 2u) {
// The entity's OWN cube-map sampling as greyscale (the shared stub would show white).
- lit_rgb = vec3(torch_entity_debug_factor(in.world_position.xyz, n_lit));
+ room_rgb = vec3(torch_entity_debug_factor(in.world_position.xyz, n_lit));
} else {
- lit_rgb = shadow_hook::interior_debug_override(idbg, lit_rgb, in.world_position, n_lit);
+ room_rgb = shadow_hook::interior_debug_override(idbg, room_rgb, in.world_position, n_lit);
}
+ // MONKEY (portal lane fade): CROSSFADE, not a switch. `lit_rgb` still holds this part's
+ // EXTERIOR result (day/night × its ground-shade byte) — the "outside" colour — and the
+ // classifier ramps tag bits 15..=18 from 0 to 15 over ~half a second as the entity's
+ // anchor crosses the portal, so the two lanes blend the way the surfaces at the seam
+ // already do. Without it a character stepping through a doorway went from night-dark to
+ // torch-warm in ONE frame while the wall beside it faded.
+ //
+ // Forced to 1.0 in INTERIOR material mode: there those bits are the middle of the SH probe
+ // slot (`mesh_tag`'s two payload modes), so reading them as a weight would fade an indoor
+ // baked unit by its probe INDEX. The classifier only ever commits the interior material at
+ // full weight anyway — every frame of an actual blend is written in exterior mode.
+ let lane_w = select(f32((fade_tag >> 15u) & 0xfu) / 15.0, 1.0, is_interior);
+ lit_rgb = mix(lit_rgb, room_rgb, lane_w);
}
// The fullbright/UNLIT path takes the tint too, unlike the highlight: with GL_LIGHTING off the
// same gx state (SetState(1)) is a plain `glColor` modulate on the texture, while GL_EMISSION is
diff --git a/crates/benilla-world/src/interior.rs b/crates/benilla-world/src/interior.rs
index d2ac9702..3df84394 100644
--- a/crates/benilla-world/src/interior.rs
+++ b/crates/benilla-world/src/interior.rs
@@ -66,6 +66,17 @@ use benilla_assets::materials::WowModelMaterial;
/// 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
@@ -254,9 +265,54 @@ pub struct InteriorAnchor {
/// 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)
+ }
+
/// 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
@@ -271,6 +327,15 @@ impl InteriorAnchor {
}
}
+/// 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
@@ -301,6 +366,12 @@ pub struct InteriorLit {
/// 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 {
@@ -355,6 +426,7 @@ impl InteriorLit {
exterior,
applied: None,
fogged: false,
+ lane: 0,
}
}
@@ -372,6 +444,7 @@ impl InteriorLit {
exterior,
applied: Some(AppliedLaw::Bake(0)),
fogged: true,
+ lane: crate::mesh_tag::LANE_MAX,
}
}
}
@@ -551,9 +624,21 @@ pub fn classify_entity_interior(
// 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);
- if state.fog != fog {
+ // 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, lit_parts, &mut parts);
+ n_written += write_anchor_parts(
+ state.law,
+ fog,
+ state.lane_bits(),
+ lit_parts,
+ &mut parts,
+ );
}
continue;
}
@@ -606,19 +691,31 @@ pub fn classify_entity_interior(
resolve_us += _r.elapsed().as_secs_f32() * 1e6;
let fog = anchor_room_fogged(room, &instances);
let kind_bake = bake.is_some();
- let changed = match state.as_deref_mut() {
+ // 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 changed = state.law != law || state.fog != fog;
+ 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;
- changed
+ // 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,
@@ -626,13 +723,15 @@ pub fn classify_entity_interior(
last_pos: pos,
generation: residency.generation,
kind_bake,
+ lane,
});
- true
+ (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.
- if !changed {
+ // 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
@@ -647,7 +746,9 @@ pub fn classify_entity_interior(
// 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
@@ -697,7 +798,7 @@ pub fn classify_entity_interior(
}
);
}
- n_written += write_anchor_parts(law, fog, lit_parts, &mut parts);
+ 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
@@ -723,9 +824,13 @@ pub fn classify_entity_interior(
if matches!(lit.kind, InteriorKind::Bake { .. }) && !state.kind_bake {
commands.entity(edge.0).try_remove::();
}
+ // 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,
@@ -790,6 +895,7 @@ type PartWrite<'w, 's> = Query<
fn write_anchor_parts(
law: AppliedLaw,
fog: bool,
+ lane: u8,
lit_parts: Option<&LitParts>,
parts: &mut PartWrite,
) -> usize {
@@ -800,6 +906,7 @@ fn write_anchor_parts(
written += usize::from(write_part_law(
law,
fog,
+ lane,
&mut lit,
&mut material,
&mut tag,
@@ -838,20 +945,31 @@ fn write_anchor_parts(
/// `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,
tag: &mut MeshTag,
force: bool,
fade: Option<&crate::model_fade::FadeMaterials>,
) -> bool {
- if lit.applied == Some(law) && lit.fogged == fog && !force {
+ 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(),
@@ -863,14 +981,47 @@ fn write_part_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).
- AppliedLaw::Matte => crate::mesh_tag::with_matte_indoor(tag.0),
+ // 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.
@@ -1154,6 +1305,10 @@ mod tests {
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();
@@ -1272,6 +1427,7 @@ mod tests {
last_pos: Vec3::ZERO,
generation,
kind_bake: false,
+ lane: 1.0,
},
))
.id();
@@ -1318,6 +1474,7 @@ mod tests {
last_pos: Vec3::ZERO, // settled: the walk never re-rays this anchor
generation,
kind_bake: false,
+ lane: 1.0,
},
))
.id();
@@ -1387,6 +1544,7 @@ mod tests {
last_pos: Vec3::ZERO,
generation,
kind_bake: true,
+ lane: 1.0,
},
))
.id();
@@ -1457,6 +1615,7 @@ mod tests {
last_pos: Vec3::ZERO,
generation,
kind_bake: false,
+ lane: 0.0,
},
))
.id();
@@ -1546,6 +1705,7 @@ mod tests {
last_pos: Vec3::ZERO, // settled: the walk skips the ray, the drain does the work
generation,
kind_bake: true,
+ lane: 1.0,
},
))
.id();
diff --git a/crates/benilla-world/src/mesh_tag.rs b/crates/benilla-world/src/mesh_tag.rs
index 2954d23d..ad63d163 100644
--- a/crates/benilla-world/src/mesh_tag.rs
+++ b/crates/benilla-world/src/mesh_tag.rs
@@ -51,8 +51,12 @@
//! ([`MATTE_INDOOR_BIT`], MONKEY torch shadows Phase 3A): the classifier's `Matte` law — an
//! entity standing INDOORS whose material nonetheless stays in exterior mode (day/night, no
//! bake). `wow_model.wgsl` routes such a part to the dynamic-interior lane on it, so a unit
-//! whose verdict flickers `Bake`↔`Matte` at one spot keeps its lane. Bits 15..=18 are
-//! reserved (0).
+//! whose verdict flickers `Bake`↔`Matte` at one spot keeps its lane. Bits 15..=18 are the
+//! **lane weight** ([`LANE_MASK`], MONKEY portal lane fade): a 4-bit crossfade from the
+//! exterior result (`0`) to the room result (`15`), so an entity walking through a portal
+//! blends between the two lanes over the anchor's ramp instead of popping in one frame. It is
+//! meaningful only alongside bit 14 — [`with_matte_indoor`] writes the pair, and the shader
+//! mixes by it.
//! - **Interior probe slot** (interior M2 props/entities — material in interior mode,
//! `model_flags.z` set, not a WMO): bits 6..=18 carry the SH-probe TABLE SLOT (see
//! [`crate::lighting::PropProbes`]; 8192 slots = 13 bits), alpha and rig ride their fixed
@@ -146,6 +150,19 @@ const SHADE_SHIFT: u32 = 6;
/// Sits in the exterior payload's reserved bits, so [`with_shade`]/[`with_alpha`]/[`with_rig`]
/// carry it through and the shader's 8-bit shade decode never sees it.
pub(crate) const MATTE_INDOOR_BIT: u32 = 0x0000_4000;
+/// Bits 15..=18 of the exterior payload (MONKEY, portal lane fade): the **lane weight**, a 4-bit
+/// crossfade `0` (fully exterior-lit) … [`LANE_MAX`] (fully room-lit) that the shader mixes the
+/// two lanes by. It rides beside [`MATTE_INDOOR_BIT`] — the bit says "this part is on the room
+/// lane at all", the field says how far — and like it, sits above the shade byte so
+/// [`with_shade`]/[`with_alpha`]/[`with_rig`]/[`with_interior_fog`] carry it through and
+/// [`with_exterior_reset`] clears it. 16 steps is past perceptual for a half-second ramp (the
+/// same reasoning as the 6-bit alpha), and the four bits are exactly what the exterior payload
+/// had left over.
+const LANE_MASK: u32 = 0x0007_8000;
+const LANE_SHIFT: u32 = 15;
+/// The saturated lane weight — the quantization step count the ramp's owner
+/// (`crate::interior::InteriorAnchor`) rounds its `0..=1` fraction onto.
+pub(crate) const LANE_MAX: u8 = 15;
/// Bits 6..=18 of the interior payload: the SH-probe table slot (13 bits ⇔ 8192 slots).
const PROBE_MASK: u32 = 0x0007_ffc0;
const PROBE_SHIFT: u32 = 6;
@@ -207,11 +224,24 @@ pub(crate) fn with_exterior_reset(tag: u32) -> u32 {
(tag & RIG_MASK) | carried_alpha(tag)
}
-/// Rewrite a tag as a fresh exterior payload flagged MATTE-INDOOR ([`MATTE_INDOOR_BIT`]),
-/// preserving the rig and alpha fields: the classifier's `Matte`-law write (MONKEY, torch shadows
-/// Phase 3A). The shade writer re-asserts its byte the same frame and carries the bit through.
-pub(crate) fn with_matte_indoor(tag: u32) -> u32 {
- with_exterior_reset(tag) | MATTE_INDOOR_BIT
+/// Rewrite a tag as a fresh exterior payload flagged MATTE-INDOOR ([`MATTE_INDOOR_BIT`]) at lane
+/// weight `lane`, preserving the rig and alpha fields: the classifier's `Matte`-law write (MONKEY,
+/// torch shadows Phase 3A) and — since the portal lane fade — every write of a part whose anchor's
+/// crossfade is still in motion, whichever indoor law it is heading for. The shade writer
+/// re-asserts its byte the same frame and carries both the bit and the field through.
+///
+/// The two travel together on purpose: the shader reads the field ONLY under the bit, so a lane
+/// weight can never be mistaken for the reserved zeros of a plain exterior payload. `lane` is
+/// clamped to [`LANE_MAX`] rather than masked — a caller's rounding overshoot must saturate at
+/// "fully room-lit", not wrap to "fully exterior".
+pub(crate) fn with_matte_indoor(tag: u32, lane: u8) -> u32 {
+ with_exterior_reset(tag) | MATTE_INDOOR_BIT | (u32::from(lane.min(LANE_MAX)) << LANE_SHIFT)
+}
+
+/// Read back the lane weight of an exterior-payload tag (`0`..=[`LANE_MAX`]) — the probe readout
+/// and the tests. Meaningless on an interior payload, where these bits are part of the probe slot.
+pub(crate) fn lane_of(tag: u32) -> u8 {
+ ((tag & LANE_MASK) >> LANE_SHIFT) as u8
}
/// **A spawned part's initial `MeshTag`** — its rig slot and its starting render alpha, the two
@@ -339,10 +369,14 @@ pub fn describe(tag: u32) -> String {
(false, true) => " fog",
(false, false) => "",
};
+ // MONKEY (portal lane fade): the lane weight is printed with the exterior reading (`shade`),
+ // because it shares the same law — on an interior payload these bits are inside the `slot`
+ // beside it, exactly as the shade byte is.
format!(
- "{tag:#010x}{flags} α {:.3} shade {} / slot {} rig {}",
+ "{tag:#010x}{flags} α {:.3} shade {} lane {} / slot {} rig {}",
alpha_of(tag),
shade_of(tag),
+ lane_of(tag),
(tag & PROBE_MASK) >> PROBE_SHIFT,
rig_of(tag),
)
@@ -472,22 +506,47 @@ mod tests {
assert_eq!((probe_bits(6660) & PROBE_MASK) >> PROBE_SHIFT, 6660);
}
- /// MONKEY (torch shadows Phase 3A): the Matte-law indoor flag rides the exterior payload's
- /// reserved bit 14 — the per-frame shade/alpha writers carry it, the shade decode never sees
- /// it, and the exterior reclaim clears it.
+ /// MONKEY (torch shadows Phase 3A + portal lane fade): the Matte-law indoor flag rides the
+ /// exterior payload's reserved bit 14 and the lane weight bits 15..=18 — the per-frame
+ /// shade/alpha/rig/fog writers carry BOTH, the shade decode never sees either, and the
+ /// exterior reclaim clears both. The lane field is what makes the flag a crossfade instead of
+ /// a switch, so anything that carried the bit and dropped the field would silently snap an
+ /// entity to fully room-lit the first frame `entity_shade` re-asserted its byte.
#[test]
fn matte_indoor_bit_survives_the_field_writers_and_clears_on_exterior() {
- let t = with_matte_indoor(rig_bits(9) | alpha_bits(0.5));
+ let t = with_matte_indoor(rig_bits(9) | alpha_bits(0.5), 6);
assert_eq!(t & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
+ assert_eq!(lane_of(t), 6);
assert_eq!(rig_of(t), 9);
assert_eq!(t & ALPHA_MASK, alpha_bits(0.5));
assert_eq!(with_shade(t, 255) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
+ assert_eq!(lane_of(with_shade(t, 255)), 6);
assert_eq!(shade_of(with_shade(t, 255)), 255);
assert_eq!(with_alpha(t, 0.25) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
+ assert_eq!(lane_of(with_alpha(t, 0.25)), 6);
assert_eq!(with_rig(t, 3) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
+ assert_eq!(lane_of(with_rig(t, 3)), 6);
assert_eq!(with_interior_fog(t, true) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
+ assert_eq!(lane_of(with_interior_fog(t, true)), 6);
assert_eq!(with_exterior_reset(t) & MATTE_INDOOR_BIT, 0);
+ assert_eq!(lane_of(with_exterior_reset(t)), 0);
assert_eq!(shade_of(t), 0, "the flag is outside the shade byte");
+ // …and the lane is outside it too, at BOTH ends of its range: a saturated lane must not
+ // read back as a shade byte of 240 (the failure the pair of decodes is here to catch).
+ let full = with_matte_indoor(alpha_bits(1.0), LANE_MAX);
+ assert_eq!(lane_of(full), LANE_MAX);
+ assert_eq!(shade_of(full), 0);
+ assert_eq!(shade_of(with_shade(full, 128)), 128);
+ assert_eq!(lane_of(with_shade(full, 128)), LANE_MAX);
+ // An overshooting caller SATURATES rather than wrapping into the probe/rig neighbourhood.
+ let over = with_matte_indoor(alpha_bits(1.0), 200);
+ assert_eq!(lane_of(over), LANE_MAX);
+ assert_eq!(over & (RIG_MASK | SHADE_MASK), 0, "no spill into its neighbours");
+ // Lane 0 under the flag is legal (the first tick of a ramp) and is NOT the exterior reset:
+ // the bit is what routes the shader into the mix, the field is only its weight.
+ let zero = with_matte_indoor(alpha_bits(1.0), 0);
+ assert_eq!(lane_of(zero), 0);
+ assert_ne!(zero & MATTE_INDOOR_BIT, 0);
}
/// Decision 0755: the classifier's whole-payload rewrites carry the tag's ALPHA field, which