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