From 082f3ebe501fbfa78a3f9234a39e2d2aaec9f6a5 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Wed, 9 Sep 2026 16:08:36 +0100
Subject: [PATCH] =?UTF-8?q?MONKEY:=20lighting=20round=20=E2=80=94=20fire/l?=
=?UTF-8?q?amp=20lights,=20light=20lanes,=20room=20gating,=20static=20torc?=
=?UTF-8?q?h=20cache?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Point lights
- Synthesised lights for fire and lamp models that author none (benilla-formats
fire_light.rs: flame route from the particle emitter's over-life colour, name and
emissive-glass routes for lampposts/lanterns/candelabra; NoOmni braziers included —
they are Orgrimmar's street lighting). Spawned on every lane: ADT doodads, WMO props
in interior and exterior groups (2.5 yd dedupe against authored MOLT fixtures),
GameObjects and creatures (carried). Cvar fireLightGain (synthetic only).
- Light LANE decided by a down-ray at the light's position: exterior consumers
(terrain, models, WMO exterior batches — new per-vertex point term) read exterior
lights; the interior room lane reads interior fixtures. Fixes interior fixtures
leaking onto lawns and street torches lighting nothing.
- Interior attenuation: the authored MOLT reach replaces the flat 48 yd, with a soft
long-tailed profile (constant 1.75 yd core so energy no longer scales with reach),
cvar interiorAttenScale; a 1.6x default.
- ROOM GATE: a WMO surface takes an interior fixture only if the fixture claims its
group — claims = containment ∪ MOLR ∪ portal-adjacent rooms (2 hops), shared rule in
benilla-formats room_claim.rs, packed per light into a 32 KB storage buffer; portal
claims are weighted (1 across the door frame, fading into the next room) so doorways
carry no seam; in-building exterior-class groups take claimed fixtures. Carried
lights (GameObjects, pets) claim rooms from their world position once settled.
Cvar interiorRoomGate.
Torch shadows
- Casters ranked by contribution at the player with hysteresis and fade weights
(no pop-in); eligibility 4R+3 and the shadowDistance setting; caster geometry
gathered around the fixtures, not the camera.
- Static cube maps cached per fixture (rendered once), entity casters rendered per
frame only for the nearest interiorShadowDynamic fixtures via a depth copy; up to
16 resident maps (interiorShadowCasters), interiorShadowSoft PCF radius.
- Carried lights: rest-pose flame position (no hand-bone jitter), lane from the
bearer's cross-fade weight, caster only while settled — the imp no longer strobes.
Entities
- Portal lane cross-fade (tag bits 15..18) and MATTE_INDOOR bit so a unit keeps its
room lighting across a Bake/Matte flicker; hover highlight folded into the room lane;
the blob shadow returns whenever the realtime sun lane is inactive (night, beyond
shadow distance) with a crossfade at dusk/dawn.
Robustness
- A placement or WMO prop whose model fails to load is retired instead of pinning
the loading screen forever (Stormwind's alliancebrasscannon_flat.m2).
- benilla-extract: wmolights (MOLT + claims + group classes), wmolamps (corpus audit),
m2firescan (synthesis routes).
Co-Authored-By: Claude Fable 5.1
---
crates/benilla-app/src/blob_shadow.rs | 114 +-
crates/benilla-app/src/cvars.rs | 85 +-
crates/benilla-app/src/dynamic_interior.rs | 27 +-
crates/benilla-app/src/entities.rs | 25 +
.../benilla-app/src/entities/carried_light.rs | 658 ++++++++-
crates/benilla-app/src/entities/wmo_props.rs | 27 +-
crates/benilla-app/src/portrait/glue_booth.rs | 1 +
crates/benilla-app/src/torch_shadow.rs | 795 +++++++++--
crates/benilla-app/src/video.rs | 47 +
crates/benilla-assets/src/m2.rs | 93 +-
crates/benilla-assets/src/materials.rs | 10 +-
.../src/shaders/shadow_hook.wgsl | 12 +-
.../benilla-assets/src/shaders/terrain.wgsl | 14 +-
.../benilla-assets/src/shaders/wow_model.wgsl | 283 +++-
.../src/bin/benilla-extract/main.rs | 34 +
.../src/bin/benilla-extract/scan/lighting.rs | 683 +++++++++-
.../src/bin/benilla-extract/scan/mod.rs | 2 +-
crates/benilla-formats/src/fire_light.rs | 1189 +++++++++++++++++
crates/benilla-formats/src/lib.rs | 12 +
crates/benilla-formats/src/room_claim.rs | 676 ++++++++++
crates/benilla-world/src/interior.rs | 34 +-
.../src/lighting/global_light.rs | 1165 +++++++++++++++-
crates/benilla-world/src/lighting/mod.rs | 9 +-
.../benilla-world/src/shaders/static_gx.wgsl | 492 ++++++-
crates/benilla-world/src/static_gx/render.rs | 131 +-
.../src/static_gx/torch_depth.rs | 472 +++++--
crates/benilla-world/src/terrain_stream.rs | 5 +-
.../src/terrain_stream/spawn/fx.rs | 534 +++++++-
.../src/terrain_stream/spawn/mod.rs | 110 +-
crates/benilla-world/src/wmo_portal/mod.rs | 16 +
30 files changed, 7369 insertions(+), 386 deletions(-)
create mode 100644 crates/benilla-formats/src/fire_light.rs
create mode 100644 crates/benilla-formats/src/room_claim.rs
diff --git a/crates/benilla-app/src/blob_shadow.rs b/crates/benilla-app/src/blob_shadow.rs
index 5acf0792..bc280f5c 100644
--- a/crates/benilla-app/src/blob_shadow.rs
+++ b/crates/benilla-app/src/blob_shadow.rs
@@ -66,6 +66,7 @@ use crate::creature_anim::AnimData;
use crate::net::{Embodied, NetEntity};
use crate::player::CameraControl;
use benilla_world::decal::{DecalFrame, WorldDecal};
+use benilla_world::lighting::WowLighting;
use benilla_world::model_fade::{fade_alpha, RenderFade};
use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex};
use benilla_world::schedule::WorldStage;
@@ -81,6 +82,10 @@ const BOX_CLAMP: f32 = 5.0;
/// Degenerate-box epsilon (the reference's `[0x8029d4]` = 2.384e-7): a zero horizontal extent is
/// the no-op exit — no shadow.
const DEGENERATE_EPS: f32 = 2.384e-7;
+/// MONKEY (night blob): the realtime lane's edge band — mirrors `SHADOW_EDGE_BAND` in
+/// `shadow_hook.wgsl`, where the realtime shadow lightens over the last yards of the cascade.
+/// The oval fades back IN across the same band, so the handover is a crossfade at both ends.
+const SHADOW_EDGE_BAND: f32 = 14.0;
/// One unit's shadow record (a top-level entity — no render components; the cached projection
/// rides the effect stream). Despawned when the owner goes.
@@ -190,6 +195,12 @@ fn update_shadows(
shadow_assets: Option>,
images: Res>,
decals: WorldDecal,
+ // MONKEY (night blob): the live celestial sun, for the realtime lane's day strength — the SAME
+ // `celestial_dir` height `global_light` packs the shader's night fade from, so the oval's
+ // crossfade and the realtime shadow's fade are two halves of one number.
+ lighting: Res,
+ // …and the camera, for the realtime lane's EDGE fade (its cascade ends at `shadowDistance`).
+ cam_pos: Query<&GlobalTransform, With>,
// Spawn/despawn fades live on the model *part* entities; attribute each to its unit root so
// the shadow can ride the same alpha the body renders with (O(#currently-fading parts) — zero
// in the steady state).
@@ -226,11 +237,26 @@ fn update_shadows(
mut census_at: Local,
) {
// `characterShadows 1`: the realtime shadow-map path owns OUTDOOR unit shadows — the per-unit
- // gate in the loop below hides the oval for outdoor units so they don't wear both, while INDOOR
- // units keep it (the realtime sun shadow is gated out of interiors, so an indoor unit would
- // otherwise have no ground shadow at all). (This keys on the CHARACTER lane, not the world lane:
- // the oval is a character's shadow, so `worldShadows` alone must leave it be.) With the cvar off
- // — the shipped default — that gate is a single false branch and this is the reference path.
+ // gate in the loop below fades the oval out for outdoor units so they don't wear both, while
+ // INDOOR units keep it at full strength (the realtime sun shadow is gated out of interiors, so
+ // an indoor unit would otherwise have no ground shadow at all). (This keys on the CHARACTER
+ // lane, not the world lane: the oval is a character's shadow, so `worldShadows` alone must
+ // leave it be.) With the cvar off — the shipped default — that gate is a single false branch
+ // and this is the reference path.
+ //
+ // MONKEY (night blob): it is a FADE, not the hard hide it used to be. The realtime lane's own
+ // strength is `night · (1 − edge_fade)` (`shadow_hook.wgsl::realtime_shadow`), and `night` is
+ // `sun_shadow_strength(celestial sun height)` — exactly ZERO once the sun is under the horizon.
+ // So with the old hide, an outdoor unit between dusk and dawn had NO shadow of any kind: no
+ // realtime cast (faded to nothing) and no oval (hidden by the cvar). The reference client draws
+ // the oval all night, which is what the complement below restores. The oval is NOT replaced by
+ // a moon-lit realtime cast: benilla's rig aims only at the celestial SUN (`shadow_sun_travel`),
+ // the moon direction is `pub(super)` inside `benilla-world`'s day/night module and never
+ // reaches a shadow lane, and the packed `night` scalar would zero a moon cast anyway.
+ let sun_strength = sun_shadow_strength(lighting.celestial_dir().y);
+ // No camera yet → treat every unit as beyond the cascade, i.e. keep its oval. Failing toward
+ // "has a shadow" is the safe direction for a lane whose bug is a missing shadow.
+ let camera_pos = cam_pos.iter().next().map(GlobalTransform::translation);
let now = time.elapsed_secs();
let census = now >= *census_at;
if census {
@@ -261,14 +287,24 @@ fn update_shadows(
n_no_owner += 1;
continue;
};
- // `characterShadows`: the realtime cast owns OUTDOOR units, so hide the oval there (no
- // double shadow). INDOOR units (`UnitWmoRoom::room()` = Some) keep it — the realtime sun
- // shadow is gated out of interiors, so without this an indoor unit has no ground shadow at
- // all. Absent room component / no-room claim = outdoors.
+ // `characterShadows`: the realtime cast owns OUTDOOR units, so the oval yields to it there
+ // (no double shadow). INDOOR units (`UnitWmoRoom::room()` = Some) keep it at full strength —
+ // the realtime sun shadow is gated out of interiors, so without this an indoor unit has no
+ // ground shadow at all. Absent room component / no-room claim = outdoors.
+ //
+ // MONKEY (night blob): "yields to it" is now proportional to how much the realtime lane
+ // actually casts for THIS unit — full oval at night and past the cascade edge, none in
+ // broad daylight up close, a crossfade between. Below a colour step of oval there is
+ // nothing left to draw, so that stays the early-out the hard hide used to be.
let indoors = room.is_some_and(|r| r.room().is_some());
+ let mut lane_w = 1.0;
if video.character_shadows && !indoors {
- hide(&mut key, &mut verts);
- continue;
+ let cam_dist = camera_pos.map_or(f32::INFINITY, |c| unit.translation.distance(c));
+ lane_w = blob_weight(sun_strength, cam_dist, video.shadow_distance);
+ if lane_w <= 1.0 / 255.0 {
+ hide(&mut key, &mut verts);
+ continue;
+ }
}
// An owner that is not drawn casts nothing. The director's report from inside Caverns of
// Time (decision 1277): the exterior election had correctly stopped drawing the Tanaris
@@ -319,6 +355,12 @@ fn update_shadows(
// The self first-person fade rides the same model-fade slot in the reference.
alpha *= rig.as_deref().map_or(1.0, CameraControl::self_fade);
}
+ // MONKEY (night blob): the realtime-lane handover rides the SAME alpha the spawn/despawn
+ // fades do — the oval is a Multiply decal, so scaling its alpha scales its darkening, and
+ // `ShadowKey::alpha` already re-arms the projection when it moves by a colour step. `1.0`
+ // whenever the realtime lane is off, indoors, or contributing nothing, so the shipped
+ // default path is untouched.
+ alpha *= lane_w;
if alpha <= 0.0 {
hide(&mut key, &mut verts);
continue;
@@ -545,6 +587,30 @@ fn key_changed(a: &ShadowKey, b: &ShadowKey) -> bool {
|| a.surfaces != b.surfaces
}
+/// MONKEY (night blob): the realtime sun lane's DAY strength — 0 at or below the horizon, 1 by
+/// ~12° of elevation, smoothstepped. A three-line mirror of `sun_shadow_strength` in
+/// `benilla_world::lighting::global_light` (private there; that copy is what gets packed into the
+/// shader's `night` scalar). The pair is pinned by `oval_is_the_complement_of_the_realtime_lane`
+/// below — if the curve is ever tuned, tune both or the handover gains a seam.
+fn sun_shadow_strength(sun_height: f32) -> f32 {
+ let t = (sun_height / 0.208).clamp(0.0, 1.0); // 0.208 ≈ sin(12°)
+ t * t * (3.0 - 2.0 * t)
+}
+
+/// MONKEY (night blob): how much OVAL a unit still wears while `characterShadows` owns it.
+///
+/// `shadow_hook.wgsl::realtime_shadow` returns `1 − (1 − shadow) · night · (1 − edge_fade)`, so
+/// `night · (1 − edge_fade)` is precisely the fraction of a realtime cast that survives to the
+/// screen. The oval takes the COMPLEMENT of that: full where the realtime lane lands nothing (all
+/// night; past the cascade's last [`SHADOW_EDGE_BAND`] yards), zero where it lands everything
+/// (daylight, close in), a continuous crossfade through dusk and dawn and across the edge band —
+/// so neither shadow pops in or out.
+fn blob_weight(sun_strength: f32, cam_dist: f32, shadow_range: f32) -> f32 {
+ let t = ((cam_dist - (shadow_range - SHADOW_EDGE_BAND)) / SHADOW_EDGE_BAND).clamp(0.0, 1.0);
+ let edge_fade = t * t * (3.0 - 2.0 * t);
+ (1.0 - sun_strength.clamp(0.0, 1.0) * (1.0 - edge_fade)).clamp(0.0, 1.0)
+}
+
/// The reference's trapezoid alpha ramp (`0x6d81a0`/`0x6d82d0`, diffed bit-exact in wow-re:
/// `x = 12·u` — rise `x<2 → x/2`, flat `2≤x<10 → 1`, fall `x≥10 → (12−x)/2`, clamped at 0).
fn shadow_ramp(u: f32) -> f32 {
@@ -578,6 +644,32 @@ mod tests {
assert_eq!(shadow_ramp(2.0), 0.0);
}
+ /// MONKEY (night blob): the oval is the exact complement of what the realtime lane casts —
+ /// gone in daylight up close (the old hide), FULL at night (the bug: the sun lane's `night`
+ /// scalar is 0 under the horizon, so nothing was casting and nothing was drawing either), and
+ /// full again past the cascade's edge band. Monotone in between, so no pop at dusk or dawn.
+ #[test]
+ fn oval_is_the_complement_of_the_realtime_lane() {
+ // Sun down (and the whole night): the realtime lane casts nothing → the full oval.
+ assert_eq!(sun_shadow_strength(-0.3), 0.0);
+ assert_eq!(blob_weight(sun_shadow_strength(-0.3), 10.0, 70.0), 1.0);
+ // High sun, well inside the cascade: the realtime cast owns it → no oval.
+ assert_eq!(sun_shadow_strength(0.9), 1.0);
+ assert_eq!(blob_weight(sun_shadow_strength(0.9), 10.0, 70.0), 0.0);
+ // Same high sun, but past the cascade edge: the realtime cast has faded → the oval is back.
+ assert_eq!(blob_weight(sun_shadow_strength(0.9), 80.0, 70.0), 1.0);
+ // Dusk sweeps the weight monotonically from 0 up to 1 with no jump.
+ let mut prev = 0.0;
+ for step in 0..=20 {
+ let height = 0.25 - 0.25 * step as f32 / 20.0; // ~14° down to the horizon
+ let w = blob_weight(sun_shadow_strength(height), 10.0, 70.0);
+ assert!(w >= prev - 1e-6, "dusk weight went backwards at step {step}");
+ assert!(w - prev < 0.35, "dusk weight jumped at step {step}");
+ prev = w;
+ }
+ assert!((prev - 1.0).abs() < 1e-6);
+ }
+
/// The box law: clamp INTO ±5 pre-scale (a cap, not a floor), then scale.
#[test]
fn box_clamp_caps_pre_scale() {
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index bf3d08e1..701cb66d 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -624,17 +624,76 @@ pub(crate) const REGISTERED: &[Registered] = &[
"2.5",
"benilla's own: interior light-budget multiplier before the soft rolloff, 0.25..8",
),
+ // MONKEY (soft falloff): the live scale on every interior fixture's AUTHORED attenuation
+ // window (WMO MOLT `+0x28/+0x2c`; M2 sources bucket by intensity instead — their authored pair
+ // is a template default, not a reach). A fixture's EFFECTIVE RADIUS is `authored end × this`.
+ // The artists' own ends — Goldshire inn 6.97-9.53 yd over 10 fixtures, its blacksmith 6.0,
+ // NSabbey 4.17-5.56, Stormwind's 606 median 6.94 — are where FULL brightness ends, not where
+ // light stops, so `1` drew a hard-edged disc at exactly that radius with black beyond it (the
+ // abbey candelabra ring). **2.5** is the default: the pool now tails smoothly to 2.5× the
+ // authored end, reading ~⅓ of its 1 yd brightness AT the authored end and ~8 % at twice it.
+ // `>1.6` widens further, `<1.6` tightens, `0` switches the window off (the 48 yd lane), so the
+ // whole shape A/Bs from chat.
+ ours(
+ "interiorAttenScale",
+ "1.6",
+ "benilla's own: scale on interior fixtures' authored attenuation window = their effective \
+ radius, 0..8 (0 = no window, the old flat lane)",
+ ),
+ // MONKEY (room gate): whether an interior fixture may light only the rooms it CLAIMS — its
+ // authored MOLR groups unioned with the interior groups whose MOGI bounding box it stands
+ // inside (`LightLitRooms` carries the corpus evidence for why MOLR alone is far too sparse:
+ // the Goldshire inn authors one on 2 of its 12 groups). Off restores the pre-gate behaviour:
+ // every interior fixture in range lights every interior surface in range, so an inn's
+ // ground-floor candles light its basement through the floor. Kept as a dial because a room the
+ // gate leaves on ambient alone looks the same as a bug, and this tells the two apart in one
+ // keystroke.
+ ours(
+ "interiorRoomGate",
+ "1",
+ "benilla's own: an interior fixture lights only the WMO groups it claims (0 = the old \
+ leak-through-walls behaviour)",
+ ),
ours(
"interiorShadows",
"1",
"benilla's own: interior fixtures cast real shadows (Stage B, the nearest few); needs \
interiorLight",
),
+ // MONKEY (static torch cache): residency and per-frame work have separate live budgets.
+ ours(
+ "interiorShadowCasters",
+ "12",
+ "benilla's own: resident interior fixture shadow maps, 1..16",
+ ),
+ ours(
+ "interiorShadowDynamic",
+ "4",
+ "benilla's own: nearest promoted fixtures with moving entity shadows, 0..16",
+ ),
+ // MONKEY (torch caster selection): the PCF tap radius on the torch maps. Candle clusters read
+ // very hard-edged at 1 (a half-texel box on a 512² face); 2 is a visible softening for four
+ // extra texel-neighbourhood taps' worth of cache pressure, no extra samples.
+ ours(
+ "interiorShadowSoft",
+ "1",
+ "benilla's own: torch-shadow edge softness — the PCF tap radius scale, 0.5..3",
+ ),
ours(
"interiorDebug",
"0",
"benilla's own: interior diagnostic overlay — 1 classification, 2 shadow, 3 caster count",
),
+ // MONKEY (fire GO lights): the gain on lights SYNTHESISED from a model's flame emitter for the
+ // ~410 fire props the artists never gave a light block (campfires, wall torches, magic
+ // braziers, forges, candles). Live, like the interior knobs — and `0` is the kill switch for
+ // the whole invented-light lane, which matters because unlike everything beside it this one is
+ // a heuristic over content rather than a byte-verified mechanism.
+ ours(
+ "fireLightGain",
+ "1",
+ "benilla's own: brightness of lights synthesised from fire props' flame emitters (0 = off)",
+ ),
// **Display mode** (decisions 1627, 1650) — 1.12's own `gxWindow`, worn since 1650 as modern
// Classic's two-entry *Display Mode* dropdown rather than 1.12's *Windowed Mode* checkbox: the
// two states 1627 settled on ARE that client's two (its own `Graphics.lua` builds the list from
@@ -1126,8 +1185,23 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
"interiorambient" => knobs.video.interior_ambient = v.clamp(0.0, 1.0),
"interiorfill" => knobs.video.interior_fill = v.clamp(0.0, 2.0),
"interiorexposure" => knobs.video.interior_exposure = v.clamp(0.25, 8.0),
+ // MONKEY (interior attenuation): the authored-window scale. `0` is a MEANINGFUL value here
+ // (the window off), so the range floors at 0 rather than at a small positive.
+ "interiorattenscale" => knobs.video.interior_atten_scale = v.clamp(0.0, 8.0),
+ "interiorroomgate" => knobs.video.interior_room_gate = v != 0.0,
"interiorshadows" => knobs.video.interior_shadows = v != 0.0,
+ // MONKEY (torch caster selection): the working-set size and the PCF radius, clamped at the
+ // edge like every other numeric row. `casters` floors at 1, not 0 — `interiorShadows 0` is
+ // already the off switch, and a 0 here would be a second, confusing one.
+ "interiorshadowcasters" => {
+ knobs.video.interior_shadow_casters = (v.max(1.0) as u32).clamp(1, 16);
+ }
+ // MONKEY (static torch cache): zero is useful for static-only rooms.
+ "interiorshadowdynamic" => knobs.video.interior_shadow_dynamic = (v.max(0.0) as u32).min(16),
+ "interiorshadowsoft" => knobs.video.interior_shadow_soft = v.clamp(0.5, 3.0),
"interiordebug" => knobs.video.interior_debug = (v.max(0.0) as u32).min(3),
+ // MONKEY (fire GO lights): the synthesised-fire gain, clamped at the edge like the rest.
+ "firelightgain" => knobs.video.fire_light_gain = v.clamp(0.0, 4.0),
// Display mode (1627) — a flag like every other checkbox here, and the reference's own
// polarity: `1` is WINDOWED (the row is "Windowed Mode"). `video::apply_window_mode`
// watches the value and pushes it to the window; nothing else reads it.
@@ -1430,7 +1504,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
- let session: [(&str, String); 53] = [
+ let session: [(&str, String); 59] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1483,8 +1557,17 @@ fn sync_cvars(
("interiorAmbient", video.interior_ambient.to_string()),
("interiorFill", video.interior_fill.to_string()),
("interiorExposure", video.interior_exposure.to_string()),
+ ("interiorAttenScale", video.interior_atten_scale.to_string()),
+ ("interiorRoomGate", flag(video.interior_room_gate)),
("interiorShadows", flag(video.interior_shadows)),
+ (
+ "interiorShadowCasters",
+ video.interior_shadow_casters.to_string(),
+ ),
+ ("interiorShadowDynamic", video.interior_shadow_dynamic.to_string()),
+ ("interiorShadowSoft", video.interior_shadow_soft.to_string()),
("interiorDebug", video.interior_debug.to_string()),
+ ("fireLightGain", video.fire_light_gain.to_string()),
// The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state.
(
"gxWindow",
diff --git a/crates/benilla-app/src/dynamic_interior.rs b/crates/benilla-app/src/dynamic_interior.rs
index 8d25440e..d4ad633c 100644
--- a/crates/benilla-app/src/dynamic_interior.rs
+++ b/crates/benilla-app/src/dynamic_interior.rs
@@ -8,7 +8,12 @@
//!
//! The knobs are LIVE — `/script SetCVar("interiorExposure", 2)` in chat retunes the room without
//! a rebuild, which is how the defaults were found.
-use benilla_world::lighting::DynamicInteriors;
+//!
+//! MONKEY (fire GO lights): the same bridge also carries `fireLightGain` →
+//! [`benilla_world::lighting::FireLightGain`]. Same shape, same guard, same liveness requirement —
+//! and `fireLightGain 0` is the kill switch for the synthesised-fire lane the way `interiorLight 0`
+//! is for this one.
+use benilla_world::lighting::{DynamicInteriors, FireLightGain};
use bevy::prelude::*;
use crate::video::VideoConfig;
@@ -23,12 +28,30 @@ impl Plugin for DynamicInteriorPlugin {
/// Publish the cvars to benilla-world when they change. Guarded so the resource's change ticks
/// stay quiet on idle frames.
-fn bridge(video: Res, mut out: ResMut) {
+///
+/// MONKEY (fire GO lights): `fireLightGain` rides along here rather than in a module of its own —
+/// it is the same shape (a live video cvar → a benilla-world resource the light packer reads) and
+/// the same one-line guard, and a second plugin to carry one `f32` is not worth its wiring.
+fn bridge(
+ video: Res,
+ mut out: ResMut,
+ mut fire: ResMut,
+) {
+ if fire.0 != video.fire_light_gain {
+ fire.0 = video.fire_light_gain;
+ }
let want = DynamicInteriors {
enabled: video.interior_light,
ambient: video.interior_ambient,
fill: video.interior_fill,
exposure: video.interior_exposure,
+ // MONKEY (interior attenuation): the authored-window scale rides the same bridge — the
+ // light packer folds it into every interior entry's packed reach, so
+ // `SetCVar("interiorAttenScale", 2)` widens every pool on the very next frame.
+ atten_scale: video.interior_atten_scale,
+ // MONKEY (room gate): the packer turns this into a per-light claim head, so
+ // `SetCVar("interiorRoomGate", 0)` restores the ungated look on the very next frame.
+ room_gate: video.interior_room_gate,
debug: video.interior_debug,
};
if *out != want {
diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs
index 1ca6a018..833a0bd9 100644
--- a/crates/benilla-app/src/entities.rs
+++ b/crates/benilla-app/src/entities.rs
@@ -47,6 +47,9 @@ use attach::{attach_entity_visuals, build_dressup_preview, build_glue_pet, build
/// decision 0016's law applied to the *entity* half of the scene, not just the placed half.
mod carried_light;
use carried_light::spawn_carried_lights;
+// MONKEY (carried light stability): `torch_shadow` needs the settle verdict to refuse a MOVING
+// carried light a cube-shadow slot.
+pub(crate) use carried_light::CarriedLightMotion;
/// Equipment visuals (decisions 0072/0074): held items (weapon/shield/ranged) plus worn-armor and
/// helm/shoulder resolution, all resolved from the unit descriptor + ItemDisplayInfo and spawned as
@@ -855,6 +858,28 @@ impl Plugin for EntitiesPlugin {
.after(apply_render_fade)
.before(crate::player::apply_self_model_fade),
)
+ // MONKEY (fire GO lights): copy each carried light's owner room onto the light, so an
+ // entity's torch/brazier is portal-gated like a building's own fixture AND is eligible for
+ // a cube shadow. In `Update` **after the classifier** — that is the frame's only writer of
+ // `InteriorAnchor::room`, and the whole of `Update` precedes `PostUpdate`'s light pack
+ // (`lighting::build_light_data`), so a claim made here is always in this frame's table.
+ .add_systems(
+ Update,
+ carried_light::claim_carried_light_rooms
+ .after(benilla_world::interior::classify_entity_interior),
+ )
+ // MONKEY (carried light stability): how long each carried light has been standing still,
+ // which is what lets `torch_shadow` refuse a MOVING one a cube-shadow slot (a walking
+ // bearer invalidated its cached depth map every frame, and a withdrawn map zeroes the
+ // slot's cross-fade weight with no ramp — the imp's strobing green pool). `PostUpdate`
+ // after transform propagation, because a child light's `GlobalTransform` is only this
+ // frame's once `Propagate` has run; `update_torch_shadows` reads it later the SAME frame,
+ // in `Last`.
+ .add_systems(
+ PostUpdate,
+ carried_light::track_carried_light_motion
+ .after(bevy::transform::TransformSystems::Propagate),
+ )
// The aura CharProc layer (`crate::aura_visual`): the state kit's effect on the BODY.
// The drain installs/removes this frame's nodes; the author then owns the render alpha
// of every part under a translucent unit — after the three steady-state alpha authors
diff --git a/crates/benilla-app/src/entities/carried_light.rs b/crates/benilla-app/src/entities/carried_light.rs
index 560d0086..401041a6 100644
--- a/crates/benilla-app/src/entities/carried_light.rs
+++ b/crates/benilla-app/src/entities/carried_light.rs
@@ -26,7 +26,12 @@ use benilla_assets::coords::wow_to_bevy;
use benilla_assets::ModelLight;
use bevy::prelude::*;
-use benilla_world::terrain_stream::point_light;
+use benilla_world::interior::{InteriorAnchor, WmoResidency};
+use benilla_world::lighting::{
+ LightLane, LightLitRooms, LightRooms, ShadowProxyLight, SyntheticFireLight,
+};
+use benilla_world::terrain_stream::{carried_light_claims, point_light, CarriedClaimSet};
+use benilla_world::wmo_portal::{WmoGroupVis, WmoPortalInstance, WmoRoom};
/// Spawn a `PointLight` child for each **casting** (`type==1`, not visibility-gated dark) M2 light of
/// an entity's model.
@@ -49,7 +54,30 @@ pub(super) fn spawn_carried_lights(
if !l.def.casts() {
continue; // directionals feed an ambient term; a static `0` visibility key is dark
}
- let (parent, local) = match joint(l.def.bone) {
+ // MONKEY (carried light stability): a SYNTHESISED light (`fire_light` — the imp's hand
+ // flames, a brazier GameObject, a fire elemental) does NOT ride its host bone; an
+ // AUTHORED one still does.
+ //
+ // The distinction is what the position MEANS. An authored light block is a fixture an
+ // artist put on a bone, and `0x718960` re-registering it through the live bone matrix is
+ // the reference's own behaviour — the held torch that tracks the swinging hand, and that
+ // case takes the boneless arm below anyway (a held item spawns no skeleton). A synthesised
+ // one is a stand-in for a whole flame VOLUME whose position we GUESSED off the emitter
+ // record: it is already approximate to within the flame's own size, and riding a hand bone
+ // through an idle animation adds high-frequency motion that every consumer downstream
+ // reads as an EVENT rather than as a wobble. The per-vertex nearest-3 in `point_light_sum`
+ // re-ranks; and the cube-shadow cache tripped its 0.1 yd staleness test
+ // (`torch_shadow::map_publishable`) every single frame and withdrew its map, which
+ // `TorchTable::pack` answers by forcing that slot's cross-fade weight to 0 with no ramp —
+ // a full-strength shadow toggling at frame rate. That is the reported "the imp produces a
+ // DISCO with its light".
+ //
+ // The rest pose is identity about the pivot ([`ModelLight::bone_pivot`]), so hanging the
+ // light off `frame` at the raw model-space `def.position` puts it EXACTLY where the bone
+ // ride puts it in the rest pose — and `frame` is the model root, so the light still
+ // follows the unit's own motion with zero lag. Only the animation is dropped.
+ let host = if l.synthetic { None } else { joint(l.def.bone) };
+ let (parent, local) = match host {
Some(j) => (
j,
[
@@ -60,21 +88,493 @@ pub(super) fn spawn_carried_lights(
),
None => (frame, l.def.position),
};
- let glow = commands
- .spawn((
- point_light(l.def.diffuse_color, l.def.diffuse_intensity),
- Transform::from_translation(wow_to_bevy(local)),
- Visibility::default(),
- ))
- .id();
+ let mut glow = commands.spawn((
+ point_light(l.def.diffuse_color, l.def.diffuse_intensity),
+ Transform::from_translation(wow_to_bevy(local)),
+ Visibility::default(),
+ ));
+ // MONKEY (fire GO lights): a light we DERIVED from the model's flame emitter is tagged, so
+ // the packer can ride the live `fireLightGain` over it (and only over it). The entity lanes
+ // take synthetic lights: a placed brazier GameObject, a fire elemental, a flaming helm have
+ // no MOLT fixture standing beside them the way a WMO's own wall torches do.
+ if l.synthetic {
+ glow.insert(SyntheticFireLight);
+ }
+ let glow = glow.id();
commands.entity(parent).add_child(glow);
}
}
+/// MONKEY (fire GO lights): how far up the hierarchy the room walk looks before giving up. A
+/// carried light's anchor is at most `light → joint → … → net entity`, and a rig's joint chain is
+/// the deep part; 64 clears every shipped skeleton with room to spare, and the cap is there so a
+/// malformed cycle can never hang the frame.
+const ANCHOR_WALK_DEPTH: usize = 64;
+
+/// MONKEY (carried light stability): the bearer crossfade weight at which a carried light COMMITS
+/// to its owner's room. [`InteriorAnchor::lane`] ramps at 2/s, so crossing this midpoint needs
+/// ~0.25 s of a HELD verdict; one frame of a wrong law moves the weight by `dt * 2` and can never
+/// cross it. That IS the hysteresis — no second timer, and because it is the very signal the
+/// bearer's own meshes crossfade their lighting on, the light changes lane with the body.
+const LANE_INTERIOR: f32 = 0.5;
+
+/// MONKEY (carried light stability): how far a carried light may drift and still count as standing
+/// still. Deliberately HALF `torch_shadow`'s 0.1 yd cache-staleness radius: the settle test has to
+/// be the stricter of the two, or a light could qualify as a shadow caster while already sitting
+/// far enough from its baked map to have that map withdrawn again on the next step.
+const STILL_DRIFT: f32 = 0.05;
+
+/// MONKEY (carried light stability): how long a carried light must hold still before it may cast.
+/// Long enough that a pet fidgeting between waypoints never qualifies, short enough that a placed
+/// brazier GameObject is eligible well inside the first second it exists.
+const STILL_HOLD: f32 = 0.75;
+
+/// MONKEY (carried light stability): how long a carried light has been standing still, so the
+/// torch-shadow lane can refuse a MOVING one.
+///
+/// The cube-shadow cache is built for fixtures that do not move: a slot keeps its baked depth only
+/// while its fixture stays within 0.1 yd of where the map was baked
+/// (`torch_shadow::map_publishable`), and a slot whose map is withdrawn has its cross-fade weight
+/// forced to 0 with no ramp (`static_gx::torch_depth`'s `TorchTable::pack`). A walking bearer
+/// crossed that threshold every frame, which is a full-strength shadow blinking at frame rate —
+/// and worse, it burned the lane's global two-rebuilds-a-frame budget re-baking a mesh that was
+/// stale again immediately, starving the STATIC candles around it so those flickered too. One
+/// pet's shadow is not worth that.
+///
+/// A carried light is not excluded outright, because the same `ChildOf` spawn path carries the
+/// stationary cases the shadow lane was written for: a GM-placed brazier GameObject in the Lion's
+/// Pride Inn, a campfire. Those settle within [`STILL_HOLD`] and cast normally; only things that
+/// actually move are refused. Refusal is a *candidacy* verdict, so an incumbent that starts
+/// walking is cross-faded out by the lane's own eviction path rather than cut.
+#[derive(Component)]
+pub(crate) struct CarriedLightMotion {
+ /// World position at the last frame the light was judged to have MOVED. Not re-anchored while
+ /// it is still, so a slow creep accumulates and eventually trips the test — which is right,
+ /// because the shadow cache's own staleness test is likewise absolute from the bake point.
+ last: Vec3,
+ /// Seconds held within [`STILL_DRIFT`] of `last`, capped at [`STILL_HOLD`]. The cap is what
+ /// keeps a settled light a pure READ: no field write, so no change tick, so nothing
+ /// downstream is woken by a light that is doing nothing.
+ still_for: f32,
+}
+
+impl CarriedLightMotion {
+ /// Has this light held still long enough to be trusted with a cached cube shadow?
+ pub(crate) fn settled(&self) -> bool {
+ self.still_for >= STILL_HOLD
+ }
+}
+
+/// MONKEY (carried light stability): maintain [`CarriedLightMotion`] for every carried light.
+///
+/// `PostUpdate`, **after transform propagation**, for the same reason the packer is there: a
+/// carried light is a child of a moving parent, so its `GlobalTransform` is only this frame's once
+/// `Propagate` has run. `update_torch_shadows` reads the verdict in `Last`, i.e. later in the same
+/// frame — never one stale.
+pub(crate) fn track_carried_light_motion(
+ mut commands: Commands,
+ time: Res,
+ mut lights: Query<
+ (Entity, &GlobalTransform, Option<&mut CarriedLightMotion>),
+ (With, With, Without),
+ >,
+) {
+ let dt = time.delta_secs();
+ for (light, gt, motion) in &mut lights {
+ let p = gt.translation();
+ match motion {
+ Some(mut m) => {
+ if p.distance_squared(m.last) > STILL_DRIFT * STILL_DRIFT {
+ m.last = p;
+ m.still_for = 0.0;
+ } else if m.still_for < STILL_HOLD {
+ m.still_for += dt;
+ }
+ }
+ // Born moving: a light that spawns mid-stride must not be eligible on its first frame
+ // merely because it has no history yet.
+ None => {
+ commands.entity(light).try_insert(CarriedLightMotion {
+ last: p,
+ still_for: 0.0,
+ });
+ }
+ }
+ }
+}
+
+/// The bearer state a carried light's claim is derived from — the room its [`InteriorAnchor`]
+/// names and that anchor's crossfade WEIGHT — walked up the hierarchy from the light itself,
+/// because the light hangs off a BONE JOINT (or the model frame) and the anchor is the model's
+/// net-entity root several links above it (`interior::part_interior_lit`: "`anchor` is the model's
+/// NET ENTITY root for every caller").
+fn owner_lane(
+ light: Entity,
+ parents: &Query<&ChildOf>,
+ anchors: &Query<&InteriorAnchor>,
+) -> (Option, f32) {
+ let mut e = light;
+ for _ in 0..ANCHOR_WALK_DEPTH {
+ if let Ok(anchor) = anchors.get(e) {
+ return (anchor.room(), anchor.lane());
+ }
+ match parents.get(e) {
+ Ok(c) => e = c.parent(),
+ Err(_) => break,
+ }
+ }
+ // No anchor above us at all (a bearer with neither a lit part nor a lit emitter): exterior,
+ // and settled there — the same answer the old walk's `None` produced.
+ (None, 0.0)
+}
+
+/// The room a light's [`LightRooms`] claim currently stands for — our own record of it, because
+/// `LightRooms` wraps a `WmoGroupVis` whose fields are benilla-world-private and cannot be read
+/// back for a comparison. Absent = never claimed.
+#[derive(Component, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct ClaimedRoom {
+ /// What the light's `LightRooms` / `LightLane` currently stand for. `None` = exterior lane.
+ applied: Option,
+ /// MONKEY (carried light stability): the last room the bearer's anchor actually NAMED. Held
+ /// across the exterior ramp: the frame a bearer's law flips to `Exterior` its `room` goes
+ /// `None` at once, while the lane weight the claim is judged on takes ~0.25 s to fall past
+ /// [`LANE_INTERIOR`]. Without this there would be no room left to keep claiming during that
+ /// window, and the light would drop to the exterior lane on the raw flip — exactly the
+ /// one-frame verdict the weight exists to smooth.
+ seen: Option,
+ /// MONKEY (GO room claims): the [`LightLane`] currently on the light. The lane used to be a
+ /// pure function of `applied` (`is_some()`), so it needed no record; it is now also a function
+ /// of the CLAIM SET (a light standing in an exterior-class group that claims a real room moves
+ /// onto the interior lane — see [`CarriedClaims`]), and those two move independently. Recorded
+ /// so the `LightLane` insert stays change-gated: an insert is an archetype write, and the
+ /// render extraction behind it wakes on every one.
+ lane: bool,
+}
+
+/// MONKEY (GO room claims): how far a carried light may drift (yd) before its claim set is
+/// rebuilt. Ten times [`STILL_DRIFT`] on purpose — the settle test is about whether a CACHE of the
+/// light's surroundings is still valid frame to frame, this one is about whether a set of ROOMS is
+/// still the right set, and a room is yards across. Half a yard is well inside the smallest shipped
+/// doorway, so a light cannot cross a portal without tripping it.
+const CLAIM_DRIFT: f32 = 0.5;
+
+/// MONKEY (GO room claims): a carried light's [`LightLitRooms`] claim set, plus the three inputs
+/// the rebuild is gated on. Our own record, for the same reason [`ClaimedRoom`] is one:
+/// `LightLitRooms` wraps a crate-private `WmoGroupVis` and cannot be read back for a comparison.
+#[derive(Component)]
+pub(crate) struct CarriedClaims {
+ /// The set currently applied — `None` when the light carries no claims at all (outdoors, or
+ /// moving, or standing in no building's authored box).
+ set: Option,
+ /// The world position the set was decided at. Absolute, not re-anchored: a light that creeps
+ /// out of the room it claimed must eventually rebuild, and the drift budget is the room's.
+ at: Vec3,
+ /// The [`WmoResidency`] generation it was decided under — a light that claimed nothing because
+ /// its building had not streamed in yet has to be re-asked once it has, and the reverse (the
+ /// placement entity a claim is keyed to is gone) is a stale instance id in the GPU claim head.
+ generation: u32,
+ /// The bearer room it was decided under: the light walked through a door, so the claim seed
+ /// (and the preferred placement) moved even if the light itself barely did.
+ room: Option,
+}
+
+impl CarriedClaims {
+ /// MONKEY (GO room claims): **the recompute gate.** `true` = the applied set still stands for
+ /// this position, this residency and this bearer room, so the whole claim walk is skipped.
+ ///
+ /// Split out as a pure function so it can be tested without a world: the walk it guards is the
+ /// expensive half of this system (an O(groups) box scan per resident placement), and a gate
+ /// that silently stopped firing would show up not as a wrong picture but as a stale one.
+ fn fresh(&self, at: Vec3, generation: u32, room: Option) -> bool {
+ self.generation == generation
+ && self.room == room
+ && self.at.distance_squared(at) <= CLAIM_DRIFT * CLAIM_DRIFT
+ }
+}
+
+/// MONKEY (GO room claims): does this claim set justify moving the light onto the INTERIOR lane?
+///
+/// Two conditions, and the second is the safety half.
+///
+/// 1. **It must name an interior-class group.** Claims are read by exactly one function,
+/// `static_gx.wgsl`'s `interior_room_light`, and that function skips every table entry whose
+/// colour-row `.w < 0.5` — i.e. every EXTERIOR-lane light. So on the exterior lane a claim set
+/// is inert: it is packed as the UNGATED head (`build_light_data` only calls `RoomClaim::build`
+/// on the interior arm) and no shader ever looks at it. A light that claims a real room and
+/// stays exterior has therefore gained nothing, which is the whole reason the lane moves.
+/// 2. **Every claim must be exterior-lane eligible** (no `LIT_ROOM_EXT_DENY` bit). Moving onto the
+/// interior lane is a TRADE: the light leaves `point_light_sum` (terrain, doodads, NPCs) and
+/// `wmo_exterior_point_sum` (a WMO's exterior-class walls and cobbles) and joins
+/// `interior_room_light`, which reaches an exterior-class surface only through the strict
+/// exterior-batch room term — and that term admits a claim only when it carries `CLAIM_EXT_OK`.
+/// A district-scale shell claim (Orgrimmar's whole valley, Stormwind's districts) carries the
+/// deny bit, so a light standing in one would be taken off the very cobbles it lights today and
+/// given nothing back that reached them. Those keep the exterior lane and behave exactly as
+/// before; a BUILDING-scale exterior group (the Goldshire inn's shell, the canyon nook a shop
+/// door opens onto) is eligible, so its floor keeps the light through the strict term while the
+/// interior rooms one portal hop away gain it.
+///
+/// The trade the rule still accepts, stated plainly: a brazier that moves onto the interior lane
+/// stops feeding the nearest-3 exterior term, so ADT TERRAIN under it and the NPCs standing beside
+/// it lose that light and gain the room lane instead. That is the correct side for a fixture
+/// genuinely inside a building's claimable volume — it is what a MOLT fixture in the same spot
+/// already does — and condition 2 is what keeps it from happening out on a district-scale street.
+fn lane_worthy(set: &CarriedClaimSet) -> bool {
+ set.any_interior && set.all_ext_ok()
+}
+
+/// MONKEY (GO room claims): the claim set of a light at `at`, from the first resident WMO placement
+/// whose authored MOGI boxes hold it.
+///
+/// `prefer` is tried in order before the scan — the bearer's own anchor placement and the one the
+/// light claimed last time. In the steady state one of those two always wins on the first probe,
+/// which is what keeps a residency-generation tick (a building streaming in three streets away
+/// re-gates every carried light in the world) from turning into a full scan per light. The scan is
+/// the cold path: a GameObject standing in an EXTERIOR-class group has no anchor room at all, so
+/// its very first claim has nowhere else to come from.
+fn claims_at(
+ wmos: &Assets,
+ placements: &Query<(Entity, &WmoPortalInstance)>,
+ prefer: [Option; 2],
+ at: Vec3,
+ reach: f32,
+) -> Option {
+ let one = |e: Entity| {
+ let (e, inst) = placements.get(e).ok()?;
+ carried_light_claims(wmos.get(&inst.handle)?, e, inst.world_from_local, at, reach)
+ };
+ for e in prefer.into_iter().flatten() {
+ if let Some(set) = one(e) {
+ return Some(set);
+ }
+ }
+ placements.iter().find_map(|(e, inst)| {
+ if prefer.contains(&Some(e)) {
+ return None; // already probed above
+ }
+ carried_light_claims(wmos.get(&inst.handle)?, e, inst.world_from_local, at, reach)
+ })
+}
+
+/// MONKEY (fire GO lights): give a CARRIED light the room its owner was classified into.
+///
+/// A world-baked light gets its rooms at spawn from the building's own MODR/MOLR tables. An
+/// entity's light cannot: a GameObject walks (or is placed) into a building the tables know nothing
+/// about, and its room is only ever known from the interior classifier's down-ray. So this system
+/// copies that verdict onto the light, and two things follow from it:
+///
+/// - **The faithful gate.** A torch in a portal-culled room stops lighting the hillside outside it
+/// ([`LightRooms`], decision 0689) — until now a placed brazier GameObject lit through walls
+/// because it claimed no room at all and `room_admits` admits `None` unconditionally.
+/// - **Shadow eligibility.** `torch_shadow`'s caster query is `With, With`,
+/// so a roomless light can never be promoted to a cube-map caster however deep indoors it stands.
+/// A GM-placed brazier in the Lion's Pride Inn now throws real shadows like a MOLT fixture.
+///
+/// Applies to synthetic and authored carried lights alike — the room is a fact about the OWNER,
+/// not about where the light's colour came from.
+///
+/// MONKEY (carried light stability): the verdict is taken from the anchor's crossfade WEIGHT, not
+/// from its raw per-frame law. The law alternates: `WOW_INTERIOR_LOG` from the 2026-09-09 run has
+/// anchor `320v2` resolving `matte`↔`bake` on ten consecutive frames (t 73.75 … t 74.14), and
+/// the same threshold noise puts an `Exterior` between two interior verdicts at a doorway. A light
+/// that re-decided on that raw answer would hand itself back and forth between two DIFFERENT
+/// consumer families every frame — the exterior `point_light_sum` and the interior room lane —
+/// which is an on/off strobe of the pool, not a subtle difference. Reading `lane >= LANE_INTERIOR`
+/// costs nothing and cannot be crossed by a blip (see [`LANE_INTERIOR`]).
+///
+/// MONKEY (GO room claims): **and it now builds the light's [`LightLitRooms`] too** — the rooms it
+/// may LIGHT, as opposed to the one it must be VISIBLE from. The bearer's anchor answers "which
+/// group is this standing in", singular, and one group was the whole gate: a brazier inside the
+/// Lion's Pride Inn lit its own room and stopped dead at the doorway, and a brazier in one of
+/// Orgrimmar's 123 canyon placements named no group at all (`MOGP & 0x48` ⇒ the anchor is `None`)
+/// so it could never reach the shops the canyon opens onto. The fix is not a second classifier: it
+/// is the SAME claim rule the WMO's own MOLT fixtures and MODD props run
+/// (`benilla_world::terrain_stream::carried_light_claims` → `benilla_formats::room_claims`:
+/// containment tightest-first, then one portal hop within reach), asked at the light's world
+/// position. Reach is the M2 intensity bucket scaled by the DEFAULT `interiorAttenScale`, exactly
+/// as the prop lane sizes its own hop — the live cvar must not be able to open a doorway that was
+/// gated when the set was built, or every claim in the world would rebuild on a slider drag.
+///
+/// **Gated on four things**, because the walk is an O(groups) box scan per resident placement and
+/// Orgrimmar has ~150 of these lights: the light must be SETTLED
+/// ([`CarriedLightMotion::settled`] — a walking pet's light re-claims no more often than the settle
+/// cadence, and while it is unsettled and off its build point it carries NO claims, i.e. exactly
+/// the single-anchor-room behaviour it had before this change), and the set is rebuilt only when
+/// the light drifts past [`CLAIM_DRIFT`], when [`WmoResidency`] ticks, or when the bearer's room
+/// changes ([`CarriedClaims::fresh`]).
+///
+/// Every component write below is change-gated on [`ClaimedRoom`]/[`CarriedClaims`]: a settled
+/// light is one hierarchy walk and three compares per frame, and nothing is written, so the light's
+/// archetype (and the render extraction behind it) stays quiet while an NPC stands still in a room.
+#[allow(clippy::type_complexity)]
+pub(crate) fn claim_carried_light_rooms(
+ mut commands: Commands,
+ // Carried lights only — `With` IS the distinction: a world-baked doodad/MOLT light
+ // spawns as a free root with its rooms already correct, and re-deciding them from a hierarchy
+ // it isn't in would clear them.
+ mut lights: Query<
+ (
+ Entity,
+ // MONKEY (GO room claims): the light's own WORLD position (the claim rule's input) and
+ // its intensity (which sizes the portal hop). `Update` reads last frame's propagation,
+ // which is this frame's answer for anything settled — and only settled lights claim.
+ &GlobalTransform,
+ &PointLight,
+ Option<&CarriedLightMotion>,
+ Option<&mut ClaimedRoom>,
+ Option<&mut CarriedClaims>,
+ ),
+ (With, Without),
+ >,
+ parents: Query<&ChildOf>,
+ anchors: Query<&InteriorAnchor>,
+ // MONKEY (GO room claims): the resident placements + their models — the claim rule's tables.
+ placements: Query<(Entity, &WmoPortalInstance)>,
+ wmos: Res>,
+ residency: Res,
+) {
+ let generation = residency.generation();
+ for (light, gt, pl, motion, mut claimed, mut claims) in &mut lights {
+ let (room, lane) = owner_lane(light, &parents, &anchors);
+ // The room to claim IF we are on the interior lane: the one the anchor names this frame,
+ // else the last one it named (the ramp-down window described on `ClaimedRoom::seen`).
+ let seen = room.or_else(|| claimed.as_deref().and_then(|c| c.seen));
+ let want = seen.filter(|_| lane >= LANE_INTERIOR);
+
+ // MONKEY (GO room claims): the LIT-room claim set, rebuilt only on the gates above.
+ // `None` below means "keep what is already applied"; `Some(x)` means "apply x".
+ let at = gt.translation();
+ let fresh = claims
+ .as_deref()
+ .is_some_and(|c| c.fresh(at, generation, want));
+ let set = if fresh {
+ None
+ } else if motion.is_some_and(CarriedLightMotion::settled) {
+ // The prop lane's own sizing: the intensity bucket (`PointLight` premultiplied 4π at
+ // spawn, so this inverts it exactly) through the DEFAULT atten scale.
+ let reach = benilla_formats::room_claim::claim_reach(
+ benilla_formats::room_claim::m2_light_reach(
+ pl.intensity / (4.0 * std::f32::consts::PI),
+ ),
+ );
+ let prefer = [
+ want.map(|r| r.instance),
+ claims
+ .as_deref()
+ .and_then(|c| c.set.as_ref())
+ .map(|s| s.instance),
+ ];
+ Some(claims_at(&wmos, &placements, prefer, at, reach))
+ } else {
+ // Moved off its build point and not standing still: drop to the pre-claims behaviour
+ // rather than assert a set for a position it was not measured at.
+ Some(None)
+ };
+ // The set that stands THIS frame: the one just decided, else the one already applied. Read
+ // from the local rather than back off the component, because on a light's first frame the
+ // component is still a queued command and re-reading it would see `None` and remove the
+ // very `LightLitRooms` this pass is inserting.
+ let had = claims.as_deref().and_then(|c| c.set.as_ref());
+ let next_set: Option<&CarriedClaimSet> = match &set {
+ Some(next) => next.as_ref(),
+ None => had,
+ };
+ let set_changed = match (next_set, had) {
+ (None, None) => false,
+ // MONKEY (soft portal claims): the FADES are part of the set. A light that drifts
+ // across a room keeps claiming the same groups while the reach it has left past each
+ // doorway shrinks — comparing ids alone would leave it faded by the distances it had
+ // at its first build point, for as long as it never changed rooms.
+ (Some(next), Some(had)) => {
+ next.instance != had.instance
+ || next.groups != had.groups
+ || next.fades != had.fades
+ }
+ _ => true,
+ };
+ // MONKEY (GO room claims): the lane is the OR of the two halves — the bearer's own room,
+ // and (new) a claim set that earns the interior lane on its own (see [`lane_worthy`]).
+ let want_lane = want.is_some() || next_set.is_some_and(lane_worthy);
+
+ // What the light already carries, so each insert below is earned.
+ let (had_room, had_lane) = claimed
+ .as_deref()
+ .map_or((None, None), |c| (Some(c.applied), Some(c.lane)));
+
+ let mut e = commands.entity(light);
+ // MONKEY (light lane by position): the carried lane's own lane verdict. A bearer's room
+ // came from the interior classifier's ray at the bearer's own feet, so "my owner is in a
+ // room" IS "I am physically inside one" — no second ray, and the static classifier
+ // (`classify_light_lanes`) deliberately skips `With` so the two never fight.
+ // MONKEY (GO room claims): plus the claim half — a light standing in an EXTERIOR-class
+ // group (so: no anchor room) that nonetheless claims a real interior room through a portal
+ // has to be on the interior lane for those claims to be read at all (see [`lane_worthy`]).
+ if had_lane != Some(want_lane) {
+ e.insert(LightLane::carried(want_lane));
+ }
+ if had_room != Some(want) {
+ match want {
+ Some(room) => {
+ e.insert(LightRooms::new(WmoGroupVis::single(room.instance, room.group)));
+ }
+ // Back outdoors (or the anchor went away): drop the claim rather than leave a
+ // stale room, which would gate the light on a building it has walked out of.
+ None => {
+ e.remove::();
+ }
+ }
+ }
+ if set_changed {
+ match next_set {
+ Some(next) => {
+ e.insert(next.component());
+ }
+ None => {
+ e.remove::();
+ }
+ }
+ }
+
+ // Bookkeeping LAST — every verdict above borrows it. `seen` is a record, not a component:
+ // updating it must not touch the light's COMPONENTS, or a bearer walking a corridor would
+ // churn the archetype for nothing.
+ match claimed.as_mut() {
+ Some(c) => {
+ let (applied, lane) = (c.applied, c.lane);
+ c.set_if_neq(ClaimedRoom { applied, seen, lane });
+ c.applied = want;
+ c.lane = want_lane;
+ }
+ None => {
+ e.try_insert(ClaimedRoom {
+ applied: want,
+ seen,
+ lane: want_lane,
+ });
+ }
+ }
+ if let Some(next) = set {
+ let next = CarriedClaims {
+ set: next,
+ at,
+ generation,
+ room: want,
+ };
+ match claims.as_mut() {
+ Some(c) => **c = next,
+ None => {
+ e.try_insert(next);
+ }
+ }
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
use benilla_formats::M2Light;
+ use std::time::Duration;
fn light(light_type: u16, bone: i16, position: [f32; 3], visibility_off: bool) -> ModelLight {
ModelLight {
@@ -92,6 +592,7 @@ mod tests {
visibility_off,
},
bone_pivot: [1.0, 0.0, 0.5],
+ synthetic: false,
}
}
@@ -154,4 +655,143 @@ mod tests {
"colour × intensity survives the packing"
);
}
+
+ /// GOLDEN — MONKEY (carried light stability). A SYNTHESISED light does NOT ride its host
+ /// bone even when the instance carries that joint: it hangs off the model FRAME at the raw
+ /// model-space position, i.e. exactly where the bone ride would put it in the REST pose. The
+ /// frame is the model root, so the light still follows the unit with zero lag; what is dropped
+ /// is the per-frame animation swing that made the imp's green pool strobe (it re-ranked the
+ /// shaders' per-vertex nearest-3 and invalidated the cube-shadow cache every frame).
+ #[test]
+ fn a_synthetic_light_stays_on_the_rest_pose_pivot() {
+ let mut app = App::new();
+ let frame = app.world_mut().spawn(Transform::IDENTITY).id();
+ let joint = app.world_mut().spawn(Transform::IDENTITY).id();
+ let mut synth = light(1, 3, [2.0, 0.0, 1.5], false);
+ synth.synthetic = true;
+ let lights = [synth];
+ app.world_mut().commands().queue(move |world: &mut World| {
+ let mut q = world.commands();
+ spawn_carried_lights(&mut q, &lights, frame, move |bone| {
+ (bone == 3).then_some(joint)
+ });
+ });
+ app.world_mut().flush();
+
+ let spawned: Vec<(Vec3, Entity)> = app
+ .world_mut()
+ .query::<(&PointLight, &Transform, &ChildOf)>()
+ .iter(app.world())
+ .map(|(_, t, c)| (t.translation, c.parent()))
+ .collect();
+ assert_eq!(spawned.len(), 1);
+ assert_eq!(spawned[0].1, frame, "the frame, never the joint");
+ assert_eq!(
+ spawned[0].0,
+ wow_to_bevy([2.0, 0.0, 1.5]),
+ "raw model-space position, NOT rebased by the bone pivot"
+ );
+ // The tag still rides it: the packer must keep folding `fireLightGain` over this one.
+ assert!(app
+ .world_mut()
+ .query::<&SyntheticFireLight>()
+ .iter(app.world())
+ .next()
+ .is_some());
+ }
+
+ /// GOLDEN — MONKEY (carried light stability). A carried light earns its cube-shadow
+ /// eligibility by HOLDING STILL for [`STILL_HOLD`], and loses it the instant it moves further
+ /// than [`STILL_DRIFT`]. This is what keeps a walking bearer out of `torch_shadow`'s candidate
+ /// set: a moving fixture invalidates its cached depth map every frame, and a withdrawn map
+ /// zeroes that slot's cross-fade weight with no ramp — a shadow blinking at frame rate.
+ #[test]
+ fn a_carried_light_casts_only_after_it_holds_still() {
+ let mut app = App::new();
+ app.insert_resource(Time::<()>::default());
+ app.add_systems(Update, track_carried_light_motion);
+ let bearer = app.world_mut().spawn(Transform::IDENTITY).id();
+ let light = app
+ .world_mut()
+ .spawn((
+ PointLight::default(),
+ Transform::IDENTITY,
+ GlobalTransform::IDENTITY,
+ ChildOf(bearer),
+ ))
+ .id();
+
+ // First sight of it: history exists, but nothing has been HELD yet.
+ app.update();
+ let settled = |app: &App, e: Entity| {
+ app.world()
+ .entity(e)
+ .get::()
+ .is_some_and(CarriedLightMotion::settled)
+ };
+ assert!(!settled(&app, light), "born moving is not settled");
+
+ // A second of standing still clears the hold.
+ app.world_mut()
+ .resource_mut::()
+ .advance_by(Duration::from_secs_f32(1.0));
+ app.update();
+ assert!(settled(&app, light), "held still past STILL_HOLD");
+
+ // One stride and it is disqualified again, with no credit for the second it banked.
+ *app.world_mut().entity_mut(light).get_mut::().unwrap() =
+ GlobalTransform::from_translation(Vec3::new(1.0, 0.0, 0.0));
+ app.world_mut()
+ .resource_mut::()
+ .advance_by(Duration::from_secs_f32(1.0 / 60.0));
+ app.update();
+ assert!(!settled(&app, light), "moved further than STILL_DRIFT");
+ }
+
+ /// GOLDEN — MONKEY (GO room claims). The claim-set RECOMPUTE GATE. Building a carried light's
+ /// lit-room set is an O(groups) authored-box scan per resident WMO placement, and Orgrimmar
+ /// alone places ~150 of these lights, so the walk must run on a change and never on a frame.
+ /// All three inputs invalidate, and none of them alone is enough to be left out:
+ /// residency (a building streamed in under a light that had claimed nothing), the bearer's room
+ /// (it walked through a door, so the claim seed moved even though the light barely did), and
+ /// drift past [`CLAIM_DRIFT`] (it is no longer where the set was measured).
+ #[test]
+ fn a_claim_set_is_rebuilt_only_on_residency_room_or_drift() {
+ let room = |group| {
+ Some(WmoRoom {
+ instance: Entity::PLACEHOLDER,
+ group,
+ })
+ };
+ let held = CarriedClaims {
+ set: None,
+ at: Vec3::new(10.0, 2.0, -4.0),
+ generation: 7,
+ room: room(3),
+ };
+ assert!(
+ held.fresh(held.at, 7, room(3)),
+ "nothing moved: no walk this frame"
+ );
+ assert!(
+ held.fresh(held.at + Vec3::X * (CLAIM_DRIFT * 0.9), 7, room(3)),
+ "a wobble inside the drift budget still stands"
+ );
+ assert!(
+ !held.fresh(held.at + Vec3::X * (CLAIM_DRIFT * 1.1), 7, room(3)),
+ "past CLAIM_DRIFT the set was measured somewhere else"
+ );
+ assert!(
+ !held.fresh(held.at, 8, room(3)),
+ "a placement streamed in or out: the claim's instance key may be stale"
+ );
+ assert!(
+ !held.fresh(held.at, 7, room(4)),
+ "the bearer changed room"
+ );
+ assert!(
+ !held.fresh(held.at, 7, None),
+ "the bearer left the building"
+ );
+ }
}
diff --git a/crates/benilla-app/src/entities/wmo_props.rs b/crates/benilla-app/src/entities/wmo_props.rs
index e9e437d5..7c68b355 100644
--- a/crates/benilla-app/src/entities/wmo_props.rs
+++ b/crates/benilla-app/src/entities/wmo_props.rs
@@ -419,19 +419,24 @@ pub(super) fn spawn_wmo_gameobject_props(
}
// M2 point lights (the lantern's glow source): a CHILD at the prop-local
// position — propagation carries the source with the hull.
- for l in m.lights.iter().map(|l| &l.def) {
- if !l.casts() {
+ for l in m.lights.iter() {
+ if !l.def.casts() {
continue; // directional lights feed an ambient term; a static `0` visibility key is dark
}
- let glow = commands
- .spawn((
- point_light(l.diffuse_color, l.diffuse_intensity),
- Transform::from_translation(
- prop.local.transform_point(wow_to_bevy(l.position)),
- ),
- Visibility::default(),
- ))
- .id();
+ let mut glow = commands.spawn((
+ point_light(l.def.diffuse_color, l.def.diffuse_intensity),
+ Transform::from_translation(
+ prop.local.transform_point(wow_to_bevy(l.def.position)),
+ ),
+ Visibility::default(),
+ ));
+ // MONKEY (fire GO lights): a transport prop takes SYNTHESISED lights like the
+ // other entity lanes (the deck braziers on a zeppelin/boat author none), tagged
+ // so the live `fireLightGain` reaches them.
+ if l.synthetic {
+ glow.insert(benilla_world::lighting::SyntheticFireLight);
+ }
+ let glow = glow.id();
commands.entity(entity).add_child(glow);
lights += 1;
}
diff --git a/crates/benilla-app/src/portrait/glue_booth.rs b/crates/benilla-app/src/portrait/glue_booth.rs
index e2633396..ab61a9db 100644
--- a/crates/benilla-app/src/portrait/glue_booth.rs
+++ b/crates/benilla-app/src/portrait/glue_booth.rs
@@ -2035,6 +2035,7 @@ mod tests {
visibility_off: false,
},
bone_pivot: [0.0; 3],
+ synthetic: false,
}
}
diff --git a/crates/benilla-app/src/torch_shadow.rs b/crates/benilla-app/src/torch_shadow.rs
index 492d3eb1..5abb7dce 100644
--- a/crates/benilla-app/src/torch_shadow.rs
+++ b/crates/benilla-app/src/torch_shadow.rs
@@ -5,15 +5,34 @@
//! `bevy::light::cluster::ClusterConfig::None`, which disables Bevy's point/spot clustering AND
//! starves the point-light shadow-map prep — so `fetch_point_shadow` / `clusterable_objects` are
//! DEAD here. The old design (promote the nearest fixtures to shadow-casting `PointLight` proxies and
-//! sample their cube maps) could never fire, so it is gone. In its place: this lane picks the nearest
-//! ≤4 interior fixtures, computes a down-looking reverse-Z `view_proj` for each, builds the retained
-//! interior caster geometry, and publishes it all as [`TorchShadowViews`] (an [`ExtractResource`]).
-//! The render world then renders a depth map per fixture (`benilla_world::static_gx::torch_depth`)
-//! and `static_gx.wgsl`'s interior surface lane samples it so a pillar throws a radial shadow.
+//! sample their cube maps) could never fire, so it is gone. In its place: this lane picks the
+//! interior fixtures that actually LIGHT the player, computes a down-looking reverse-Z `view_proj` for each, builds the
+//! retained interior caster geometry, and publishes it all as [`TorchShadowViews`] (an
+//! [`ExtractResource`]). The render world then renders a depth map per fixture
+//! (`benilla_world::static_gx::torch_depth`) and `static_gx.wgsl`'s interior surface lane samples
+//! it so a pillar throws a radial shadow.
+//!
+//! **MONKEY (torch caster selection).** Which fixtures get maps used to be "the four nearest the
+//! camera", recomputed and hard-switched every frame. That is the shadow POP: in Northshire Abbey
+//! (42 candelabra, ~5 yd apart) the nearest-four set churns every few steps, and every fixture
+//! outside it lit with no shadow at all, so a fixture's shadow appeared as you walked up to it and
+//! vanished as you moved on. The selection is now (a) ranked by the fixture's own DIRECT
+//! CONTRIBUTION at the player using the shaders' profile ([`fixture_score`]), (b) hysteretic
+//! (a challenger must beat an incumbent by [`TORCH_SWAP_RATIO`], one swap per
+//! [`TORCH_SWAP_COOLDOWN`]), and (c) CROSS-FADED — every slot carries a weight the receivers
+//! `mix` with, so a promotion ramps in and a demotion ramps out before the slot is reused. The
+//! budget is [`MAX_TORCH_CASTERS`] slots, of which `interiorShadowCasters` (default 12) are filled.
//!
//! Gated on `interiorLight` + `interiorShadows` ([`VideoConfig`]); declares demand so the shared rig
//! stays up. Remove this module + its plugin + the `interiorShadows` cvar and the other lanes are
-//! untouched. (Phase 1 receiver = static_gx WMO surfaces only; wow_model/entities/terrain are later.)
+//! untouched. MONKEY (static torch cache): static_gx and wow_model share the 6416-byte table.
+//! Camera distance must be <= ShadowDistance, AND player distance <= 4R+3. Contribution ranking,
+//! 1.5x hysteresis and fades choose up to twelve of sixteen resident slots; this is still a finite
+//! budget, not a promise that a room with forty eligible candles can give all forty cube maps.
+//! Rigid furniture joins the cached static geometry. Only the nearest interiorShadowDynamic
+//! promoted fixtures (default four, measured from the player) overlay creatures/animated parts.
+
+use std::hash::{Hash, Hasher};
use bevy::math::{DMat4, DVec3, DVec4};
use bevy::prelude::*;
@@ -22,33 +41,82 @@ use bevy::render::extract_resource::ExtractResourcePlugin;
use bevy::pbr::MeshMaterial3d;
use benilla_assets::materials::WowModelMaterial;
+use benilla_assets::coords::wow_to_bevy;
+use benilla_formats::ModelBlend;
use benilla_world::billboard::BillboardCard;
use benilla_world::interact::PickMesh;
-use benilla_world::lighting::LightRooms;
-use benilla_world::model_render::{ModelPart, ShadowOccluder};
+use benilla_world::lighting::{
+ interior_reach, m2_light_reach, DynamicInteriors, FireLightGain, LightLane, LightLitRooms,
+ LightReach, LightRooms, ShadowDistance, SyntheticFireLight,
+};
+// MONKEY (carried light stability): the settle verdict a carried light earns by standing still.
+use crate::entities::CarriedLightMotion;
+use benilla_world::model_render::{ModelKind, ModelPart, ShadowOccluder};
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
use benilla_world::static_gx::{StaticGx, TorchShadowViews};
use benilla_world::view::WorldCamera;
use crate::char_select::ClientState;
+use crate::net::SelfPlayer;
use crate::shadow_core::{
- collect_entity_geometry, empty_shadow_mesh, restore_mesh_buffers, take_mesh_buffers,
- ShadowDemand, ShadowFrame, ShadowSet, STATIC_REBUILD_STEP,
+ empty_shadow_mesh, restore_mesh_buffers, take_mesh_buffers,
+ ShadowDemand, ShadowFrame, ShadowSet,
};
use crate::video::VideoConfig;
-/// How many interior fixtures cast a depth map at once. The nearest N to the camera win, so the room
-/// you are in casts. Matches `torch_depth::MAX_TORCH_MAPS`.
-const MAX_TORCH_CASTERS: usize = 4;
-/// How far from the camera to look for an interior fixture to promote (yd).
-const TORCH_SEARCH_RADIUS: f32 = 55.0;
-/// The map's shadow-cast range / perspective far plane (yd) — matches the WMO fixture range.
+/// How many interior fixtures can hold a depth map at once — the ALLOCATION. Matches
+/// `torch_depth::MAX_TORCH_MAPS`; the number actually filled is the live `interiorShadowCasters`
+/// cvar (default 12), so raising the cap does not by itself cost a frame.
+const MAX_TORCH_CASTERS: usize = 16;
+/// MONKEY (torch caster reach): promote out to FOUR times the fixture's effective reach. Its
+/// pool still needs shadows when viewed across the room, long before it lights the player's body.
+const TORCH_ELIGIBLE_REACH_MULT: f32 = 4.0;
+/// MONKEY (static torch cache): minimum coarse CAMERA search; the live search is
+/// max(TORCH_SEARCH_RADIUS, ShadowDistance), so it never truncates the shadow-distance setting.
+const TORCH_SEARCH_RADIUS: f32 = TORCH_ELIGIBLE_REACH_MULT * TORCH_RANGE + TORCH_ELIGIBLE_SLACK;
+/// MONKEY (torch caster reach): extra eligibility slack beyond the expanded 4R window (yd).
+const TORCH_ELIGIBLE_SLACK: f32 = 3.0;
+/// MONKEY (torch caster selection): a challenger must beat the weakest incumbent by this FACTOR
+/// before it may take its slot. Pure "best N" thrashes: two candles a step apart trade the lead
+/// every few frames as you walk between them, and each trade used to be an instant on/off. 1.5 is
+/// wide enough that a real change of room wins immediately and a step sideways never does.
+const TORCH_SWAP_RATIO: f32 = 1.5;
+/// MONKEY (torch caster selection): and at most ONE contested swap this often (s). The ratio alone
+/// still permits a cascade — six slots could each lose their contest in the same frame, which is
+/// six shadows changing at once no matter how smooth each fade is.
+const TORCH_SWAP_COOLDOWN: f64 = 0.25;
+/// MONKEY (torch caster selection): the cross-fade rate (weight per second) — ~1/3 s to ramp a
+/// shadow fully in or out. Slow enough to read as "the light reaches you now", fast enough that
+/// the geometry is not visibly shadow-less while you stand in front of it.
+const TORCH_FADE_RATE: f32 = 3.0;
+/// MONKEY (torch caster selection): the interior direct-light profile, MIRRORED from the
+/// `interior_room_light` block that both `static_gx.wgsl` and `wow_model.wgsl` carry ("MONKEY (soft
+/// falloff)" — keep the three in sync). Ranking by the shader's OWN contribution is the whole point
+/// of this change: distance ranked a bright forge behind you below a dead candle at your feet.
+const INTERIOR_CORE_FRAC: f32 = 0.26;
+const INTERIOR_CORE_GAIN: f32 = 1.5;
+const INTERIOR_DIRECT_POW: f32 = 10.0;
+/// MONKEY (torch caster reach): unchanged fixture-centred far plane. `interior_reach` caps R at
+/// 48 yd; seeing that pool from 4R away needs earlier promotion, not a longer projection.
const TORCH_RANGE: f32 = 48.0;
-/// MONKEY (Phase 3B): how far from the camera (yd) an ENTITY — furniture, NPC, the player — is
-/// gathered into the per-frame torch caster. Covers the farthest promoted fixture's range.
-const ENTITY_REACH: f32 = 60.0;
/// MONKEY (Phase 5): six cube faces per fixture. Keep in sync with `torch_depth::CUBE_FACES`.
const CUBE_FACES: usize = 6;
+/// MONKEY (static torch cache): how many resident slots have their source FINGERPRINT recomputed
+/// per frame. Hashing one fixture's 48 yd source set walks every resident `GxItem` and every model
+/// part; doing that for all twelve residents every frame hands the CPU back the cost the GPU cache
+/// just saved (~12 full scene scans per frame, and it is a per-frame cost even in a room where
+/// nothing ever changes). Four slots, cursor-rotated, still notices any streaming change within
+/// three frames — under a tenth of a second, and the map it then rebuilds is the same one.
+const TORCH_SCAN_PER_FRAME: usize = 4;
+/// MONKEY (static torch cache): how far (yd²) a fixture may drift from the position its cached map
+/// was rendered at before that map is WITHDRAWN. A cached map that is merely STALE (the room
+/// streamed a chair in since it was baked) keeps being published — the shadow is a few frames out
+/// of date, which is invisible. A map whose fixture MOVED is not stale but WRONG: every projection
+/// in it aims from somewhere else, and the published `view_projs` (rebuilt from the live position
+/// each frame) no longer address it. Withdrawing a merely-stale map instead was a hard on/off
+/// blink: an unready slot's `positions[i].w` is forced to 0 with no fade — precisely the pop the
+/// whole cross-fade exists to prevent — for however many frames the rebuild budget takes to reach it.
+const TORCH_STALE_DRIFT_SQ: f32 = 0.01;
/// Each cube face's FOV: 90° plus a hair, so a direction exactly on a face border still lands
/// inside the face the shader picks for it (the projector returns "lit" outside its frustum).
const TORCH_FACE_FOV: f32 = std::f32::consts::FRAC_PI_2 + 0.02;
@@ -65,15 +133,137 @@ impl Plugin for TorchShadowPlugin {
}
}
-/// The torch lane's retained state: the interior caster mesh (rebuilt on camera drift) and the
-/// camera position it was last built at.
+/// MONKEY (torch caster selection): one OCCUPIED caster slot — a fixture that holds (or is
+/// releasing) six depth-map layers. Slots are retained across frames, which is what makes
+/// hysteresis and cross-fading possible at all: the old lane recomputed "the nearest four" from
+/// scratch every frame and so had no notion of an incumbent to keep.
+struct TorchSlot {
+ // MONKEY (static torch cache): physical residency survives Vec compaction and rank changes.
+ cache_slot: usize,
+ mesh: Option>,
+ geometry_key: Option,
+ /// MONKEY (static torch cache): the fixture position `mesh` was actually gathered AT, which is
+ /// not necessarily `pos` — the cursor scans only [`TORCH_SCAN_PER_FRAME`] slots a frame, so a
+ /// slot can be published several frames after it last rebuilt. `None` until the first build.
+ /// See [`TORCH_STALE_DRIFT_SQ`] for why staleness publishes and drift does not.
+ built_at: Option,
+ /// The fixture entity — the identity the slot is held BY. Matching on entity rather than on
+ /// position is what lets a CARRIED torch (which moves every frame) keep its slot.
+ fixture: Entity,
+ /// Its world position this frame (absolute Bevy), the shader's correlation key.
+ pos: Vec3,
+ /// Its contribution score at the player this frame (see [`fixture_score`]).
+ score: f32,
+ /// The cross-fade weight, published as `positions[i].w` and applied as `mix(1, shadow, w)` by
+ /// both receivers.
+ w: f32,
+ /// Set once the slot has lost its contest (or its fixture went out of reach, or the budget
+ /// shrank). It keeps rendering while `w` ramps down, and the slot is freed for a challenger
+ /// only when `w` reaches 0 — "fade, not switch".
+ evicting: bool,
+}
+
+/// MONKEY (static torch cache): stable physical slots and per-fixture source meshes.
+/// Camera drift never rebuilds static geometry; only a source-set/fixture change does.
#[derive(Resource, Default)]
struct TorchLane {
- caster_mesh: Option>,
- caster_at: Option,
- /// MONKEY (Phase 3B): the per-frame ENTITY caster (furniture, NPCs, the player) — rebuilt every
- /// frame because entities move; the static `caster_mesh` only rebuilds on camera drift.
+ // MONKEY (static torch cache): only moving casters are rebuilt every frame. Each slot owns
+ // its own static geometry handle; a replacement gets a NEW asset id, so the render world
+ // cannot certify an old GPU mesh as the newly streamed geometry while uploads catch up.
entity_mesh: Option>,
+ rebuild_cursor: usize,
+ /// MONKEY (torch caster selection): the occupied slots, at most `interiorShadowCasters` of them
+ /// (plus any still fading out after the budget shrank). Published in this order.
+ slots: Vec,
+ /// When the last CONTESTED swap was started (seconds of `Time::elapsed`), for the cooldown.
+ last_swap: f64,
+}
+
+/// MONKEY (torch caster selection): the interior direct term this fixture delivers at distance `d`
+/// — the shaders' own profile (core inverse-square with a soft core, times the C1 window), times
+/// the fixture's luminous intensity. This is the ranking key, and it is the answer to the bug: at
+/// Northshire's candelabra spacing "nearest 4" and "the 4 that light you" are different sets, and
+/// only the second one is stable as you walk, because a fixture's contribution changes smoothly
+/// with distance while its RANK by distance changes in steps.
+///
+/// MONKEY (torch caster reach): retain the intensity and soft inverse-square core at the player's
+/// distance, but stretch the selection window to 4R (including eligibility slack). This is a
+/// ranking extension, not a lighting change: at 3R the score is small, positive and still falling.
+/// No window floor flattens distant candidates; the window reaches zero at the eligibility edge.
+fn fixture_score(intensity: f32, d: f32, r: f32) -> f32 {
+ let r0 = (INTERIOR_CORE_FRAC * r).max(1e-3);
+ let atten = INTERIOR_CORE_GAIN / (1.0 + (d / r0) * (d / r0));
+ let extent = TORCH_ELIGIBLE_REACH_MULT * r + TORCH_ELIGIBLE_SLACK;
+ let w = (1.0 - (d / extent.max(1e-4)).clamp(0.0, 1.0).powf(INTERIOR_DIRECT_POW)).clamp(0.0, 1.0);
+ intensity * atten * w * w
+}
+
+/// MONKEY (review fixes): rank only light the interior receivers actually sample. Match the
+/// packer's unresolved-lane fallback AND its synthetic-only gain; a disabled flame or a resolved
+/// exterior fixture must score zero before it can evict a useful caster. Reach still uses the
+/// authored intensity, so dimming a flame changes its contribution without moving its pool.
+fn candidate_score(
+ intensity: f32, d: f32, r: f32, lane: Option<&LightLane>, has_rooms: bool,
+ synthetic: bool, fire_gain: f32,
+) -> f32 {
+ if !lane.map_or(has_rooms, |l| l.interior) {
+ return 0.0;
+ }
+ fixture_score(intensity, d, r) * if synthetic { fire_gain.max(0.0) } else { 1.0 }
+}
+
+/// MONKEY (static torch cache): camera distance uses the world-shadow resource's Bevy-yard
+/// yardstick. The player-centred 4R+3 window still limits/ranks useful pools; BOTH tests apply.
+/// The coarse camera search grows with shadowDistance so it can never silently shorten that dial.
+fn fixture_eligible(player_d: f32, camera_d: f32, r: f32, distance: f32) -> bool {
+ camera_d <= TORCH_SEARCH_RADIUS.max(distance)
+ && camera_d <= distance
+ && player_d <= TORCH_ELIGIBLE_REACH_MULT * r + TORCH_ELIGIBLE_SLACK
+}
+
+/// MONKEY (static torch cache): may a slot publish its cached map this frame? Yes while the
+/// fixture still stands where the map was baked from (see [`TORCH_STALE_DRIFT_SQ`]) — staleness in
+/// the SURROUNDINGS is invisible for the few frames a rebuild takes, whereas withdrawing the map
+/// zeroes the slot's weight with no fade. No when the fixture has moved: the six projections are
+/// republished from the live position every frame and would no longer address that depth.
+fn map_publishable(built_at: Option, pos: Vec3) -> bool {
+ built_at.is_some_and(|p| p.distance_squared(pos) <= TORCH_STALE_DRIFT_SQ)
+}
+
+/// MONKEY (static torch cache): the nearest promoted fixtures to the player receive entities.
+/// Stable physical-slot tie breaking keeps coincident/equidistant candles deterministic; this
+/// subset is independent of contribution ranking and never moves the resident static maps.
+fn dynamic_set(fixtures: &[(usize, Vec3)], anchor: Vec3, budget: usize) -> u32 {
+ let mut nearest = fixtures.to_vec();
+ nearest.sort_by(|a, b| a.1.distance_squared(anchor).total_cmp(&b.1.distance_squared(anchor))
+ .then(a.0.cmp(&b.0)));
+ nearest.iter().take(budget.min(MAX_TORCH_CASTERS)).fold(0, |mask, (i, _)| mask | (1 << i))
+}
+
+/// MONKEY (torch caster selection): the entity-caster query trio, bundled as ONE `SystemParam`.
+/// Not tidiness — [`update_torch_shadows`] sat at 15 of Bevy's 16 system params before this change
+/// added the player anchor and the interior knobs, and three separate members would not compile.
+#[derive(bevy::ecs::system::SystemParam)]
+struct EntityCasters<'w, 's> {
+ /// The SAME query the world lane passes to `collect_entity_geometry` (written inline by that
+ /// contract so a lane can pass its own).
+ parts: Query<
+ 'w,
+ 's,
+ (
+ Entity,
+ &'static PickMesh,
+ &'static ModelPart,
+ Option<&'static GlobalTransform>,
+ Option<&'static RigPart>,
+ &'static ShadowOccluder,
+ Option<&'static MeshMaterial3d>,
+ ),
+ Without,
+ >,
+ rigs: Query<'w, 's, &'static RigSkin>,
+ palettes: Res<'w, RigPalettes>,
+ distance: Res<'w, ShadowDistance>,
}
/// The lane is active when the dynamic-interior lane is on AND its shadow toggle is on.
@@ -128,24 +318,33 @@ fn update_torch_shadows(
mut meshes: ResMut>,
mut views: ResMut,
cameras: Query<&GlobalTransform, With>,
- // Interior WMO fixtures: a `PointLight` carrying a room claim.
- torches: Query<&GlobalTransform, (With, With)>,
- gx: Option>,
- // MONKEY (Phase 3B): the entity caster inputs — the SAME query the world lane passes to
- // `collect_entity_geometry` (written inline by that contract so a lane can pass its own).
- parts: Query<
- (
- &PickMesh,
- &ModelPart,
- Option<&GlobalTransform>,
- Option<&RigPart>,
- &ShadowOccluder,
- Option<&MeshMaterial3d>,
- ),
- Without,
+ // MONKEY (torch caster selection): the RANKING ANCHOR. Shadows are for the player's own
+ // surroundings — a free-look camera swung across the room must not re-pick the casters — so
+ // scoring is done at the body, and only falls back to the camera when there is none (the glue
+ // scene, a capture fixture, the frame before the player streams in).
+ player: Query<&GlobalTransform, With>,
+ // Interior WMO fixtures: a `PointLight` carrying a room claim. MONKEY (torch caster selection):
+ // the intensity/colour and the authored reach ride along now — the score is the light's own
+ // contribution, so the lane must read exactly what the packer reads.
+ // MONKEY (review fixes): containment is membership evidence even with no MOLR; keep the
+ // visibility membership separate so the missing-lane fallback stays identical to the packer.
+ // MONKEY (carried light stability): `Has` + `Option<&CarriedLightMotion>` ride the
+ // tuple so the filter below can refuse a MOVING carried light a slot. Both are cheap table
+ // reads on a query that already walks these entities.
+ torches: Query<
+ (Entity, &GlobalTransform, &PointLight, Option<&LightReach>, Option<&LightLane>,
+ Has, Has, Has,
+ Option<&crate::entities::CarriedLightMotion>),
+ Or<(With, With)>,
>,
- rigs: Query<&RigSkin>,
- palettes: Res,
+ // MONKEY (torch caster selection): `atten_scale` — a fixture's EFFECTIVE radius is its authored
+ // end times this live cvar, and the score's window is a fraction of that radius. Reading the
+ // same resource the packer folds into the shader's `.w` keeps ranking and rendering agreed.
+ interiors: Res,
+ fire_gain: Res,
+ gx: Option>,
+ // MONKEY (Phase 3B): the entity caster inputs, bundled (see [`EntityCasters`]).
+ ents: EntityCasters,
time: Res,
mut last_trace: Local,
) {
@@ -161,113 +360,475 @@ fn update_torch_shadows(
return;
};
- // (1) The N nearest interior fixtures to the camera, nearest first.
- let mut scored: Vec<(f32, Vec3)> = torches
+ // (1) MONKEY (torch caster selection): who casts. Three rules, in this order.
+ //
+ // RANK BY CONTRIBUTION, NOT DISTANCE. The old lane took the four fixtures nearest the camera
+ // and hard-switched the set every frame. In a candle-dense room that set churns every few
+ // steps — Northshire Abbey authors 42 candelabra about 5 yd apart — and every fixture NOT
+ // in it lit with no shadow at all, so shadows visibly popped in as you approached a candle
+ // and out again as you passed it. Scoring by the light's own direct term at the PLAYER makes
+ // the ranking a smooth function of position instead of a step function, and makes it right:
+ // a bright forge across the room outranks a guttering candle at your feet, which is what the
+ // eye expects of a shadow.
+ //
+ // HYSTERESIS. A smooth score still crosses. An incumbent keeps its slot unless a challenger
+ // beats it by TORCH_SWAP_RATIO, and at most one contested swap starts per
+ // TORCH_SWAP_COOLDOWN — so walking the candle corridor does not trade slots every step.
+ //
+ // FADE, NOT SWITCH. A promoted slot ramps its weight 0 to 1; a losing one ramps 1 to 0 and is
+ // only REPLACED at 0 (it keeps rendering while it fades). The receivers apply the weight as
+ // `mix(1, shadow, w)`, so a shadow dissolves rather than blinking.
+ let anchor = player
.iter()
- .filter_map(|t| {
- let p = t.translation();
- let d2 = p.distance_squared(cam);
- (d2 <= TORCH_SEARCH_RADIUS * TORCH_SEARCH_RADIUS).then_some((d2, p))
+ .next()
+ .map(GlobalTransform::translation)
+ .unwrap_or(cam);
+ let want = (video.interior_shadow_casters as usize).clamp(1, MAX_TORCH_CASTERS);
+ let dt = time.delta_secs();
+ let now = time.elapsed_secs_f64();
+
+ // (1a) MONKEY (torch caster reach): each pool is eligible from 4R + slack away. Rank with the
+ // same soft core/intensity so nearby contributors still win over pools seen across the room.
+ let mut cands: Vec<(Entity, Vec3, f32)> = torches
+ .iter()
+ .filter_map(|(e, gt, pl, reach, light_lane, has_rooms, synthetic, carried, motion)| {
+ // MONKEY (carried light stability): a CARRIED light (`ChildOf` — a pet's hand flame,
+ // an NPC's torch, a transport's deck brazier) casts only while it is STANDING STILL.
+ //
+ // This whole lane caches a depth cube per fixture and keeps it only while the fixture
+ // stays within `TORCH_STALE_DRIFT_SQ` of where it was baked ([`map_publishable`]); a
+ // slot whose map is withdrawn has its cross-fade weight forced to 0 with NO ramp
+ // (`static_gx::torch_depth`'s `TorchTable::pack`). A moving fixture therefore toggled
+ // a full-strength shadow at frame rate — the reported epileptic pool around a
+ // summoned imp — and consumed the lane's global two-rebuilds-a-frame budget re-baking
+ // a mesh that was stale again on arrival, so the STATIC candles beside it flickered too.
+ //
+ // Gated on MOTION rather than excluded outright, because the stationary members of the
+ // same `ChildOf` family are exactly what the entity half of this lane was built for: a
+ // placed brazier GameObject, a campfire. They settle in well under a second and cast
+ // like a MOLT fixture. And because this is a CANDIDACY verdict, an incumbent that
+ // starts walking drops out of `cands` and is cross-faded out by (1b)/(1c) rather than
+ // cut — "fade, not switch" holds for the eviction as much as for a swap.
+ if carried && !motion.is_some_and(CarriedLightMotion::settled) {
+ return None;
+ }
+ let p = gt.translation();
+ let d = p.distance(anchor);
+ if p.distance(cam) > TORCH_SEARCH_RADIUS.max(ents.distance.0) {
+ return None;
+ }
+ // The packer's own recovery of authored colour x intensity (`spawn_point_light`
+ // premultiplied 4*pi), and the packer's own reach: the authored MOLT end where there is
+ // one, the intensity bucket otherwise, times the live `interiorAttenScale`.
+ let base = pl.intensity / (4.0 * std::f32::consts::PI);
+ let authored = reach
+ .map(|r| r.0)
+ .filter(|r| *r > 0.5)
+ .unwrap_or_else(|| m2_light_reach(base));
+ let r = interior_reach(authored, interiors.atten_scale);
+ if !fixture_eligible(d, p.distance(cam), r, ents.distance.0) {
+ return None;
+ }
+ let c = pl.color.to_linear();
+ // Rec.709 luminance of the committed colour: ONE scalar for "how much light is this",
+ // so a dim blue magic brazier cannot outrank a bright hearth on channel count.
+ let lum = (0.2126 * c.red + 0.7152 * c.green + 0.0722 * c.blue).max(0.0) * base.max(0.0);
+ let score = candidate_score(lum, d, r, light_lane, has_rooms, synthetic, fire_gain.0);
+ (score > 0.0).then_some((e, p, score))
})
.collect();
- scored.sort_by(|a, b| a.0.total_cmp(&b.0));
- scored.truncate(MAX_TORCH_CASTERS);
- let nearest: Vec = scored.into_iter().map(|(_, p)| p).collect();
+ cands.sort_by(|a, b| b.2.total_cmp(&a.2));
- // (2) Build/refresh the interior caster mesh (ALWAYS when the lane is on — the depth node needs
- // it regardless of `worldShadows`). Rebuilt from the same retained `StaticGx` geometry the world
- // lane uses, on camera drift.
- if let Some(gx) = gx.as_ref() {
- if lane.caster_mesh.is_none() {
- lane.caster_mesh = Some(meshes.add(empty_shadow_mesh()));
- lane.caster_at = None;
+ // (1b) Refresh the incumbents against this frame's candidates. A fixture that dropped out of
+ // the set (it moved away, despawned, its room streamed out) starts fading rather than
+ // vanishing. A slot past the live budget does the same, so lowering `interiorShadowCasters`
+ // fades its extra shadows out instead of cutting them.
+ for (i, slot) in lane.slots.iter_mut().enumerate() {
+ match cands.iter().find(|(e, _, _)| *e == slot.fixture) {
+ Some((_, p, sc)) => {
+ slot.pos = *p;
+ slot.score = *sc;
+ }
+ None => {
+ slot.score = 0.0;
+ slot.evicting = true;
+ }
}
- let due = lane
- .caster_at
- .is_none_or(|at| at.distance(cam) > STATIC_REBUILD_STEP);
- if due {
- if let Some(handle) = lane.caster_mesh.clone() {
- if let Some(mesh) = meshes.get_mut(&handle) {
- let (mut positions, mut indices) = take_mesh_buffers(mesh);
- gx.append_shadow_triangles(cam, TORCH_RANGE, &mut positions, &mut indices);
- restore_mesh_buffers(mesh, positions, indices);
- lane.caster_at = Some(cam);
- }
+ if i >= want {
+ slot.evicting = true;
+ }
+ }
+
+ // (1c) Ramp the weights, then release the slots that finished fading out.
+ let step = TORCH_FADE_RATE * dt;
+ for slot in lane.slots.iter_mut() {
+ slot.w = if slot.evicting {
+ (slot.w - step).max(0.0)
+ } else {
+ (slot.w + step).min(1.0)
+ };
+ }
+ lane.slots.retain(|s| {
+ let release = s.evicting && s.w <= 0.0;
+ if release {
+ if let Some(mesh) = &s.mesh { meshes.remove(mesh.id()); }
+ }
+ !release
+ });
+
+ // (1d) Fill free slots from the best unassigned candidates. A newly filled slot starts at
+ // w = 0 and fades IN. Note this can only run once a fading slot has fully drained, which is
+ // what stops a churn from being visible even when the SELECTION churns.
+ // MONKEY (static torch cache): at most two new residents per frame, including startup.
+ let mut promotions = 0;
+ while lane.slots.len() < want && promotions < 2 {
+ let Some(&(e, p, sc)) = cands
+ .iter()
+ .find(|(e, _, _)| !lane.slots.iter().any(|s| s.fixture == *e))
+ else {
+ break;
+ };
+ let cache_slot = (0..MAX_TORCH_CASTERS)
+ .find(|i| !lane.slots.iter().any(|s| s.cache_slot == *i)).unwrap();
+ promotions += 1;
+ lane.slots.push(TorchSlot {
+ cache_slot,
+ mesh: None,
+ geometry_key: None,
+ built_at: None,
+ fixture: e,
+ pos: p,
+ score: sc,
+ w: 0.0,
+ evicting: false,
+ });
+ }
+
+ // (1e) The contested swap: the best unassigned candidate takes the weakest incumbent's slot
+ // only if it beats it by the ratio AND the cooldown has elapsed. Marking `evicting` rather
+ // than replacing is the whole of "fade, not switch" — (1c) drains it and (1d) refills it a
+ // fade later, by which time the challenger has been re-scored at the player's new position.
+ if now - lane.last_swap >= TORCH_SWAP_COOLDOWN {
+ let weakest = lane
+ .slots
+ .iter()
+ .enumerate()
+ .filter(|(_, s)| !s.evicting)
+ .min_by(|a, b| a.1.score.total_cmp(&b.1.score))
+ .map(|(i, s)| (i, s.score));
+ let challenger = cands
+ .iter()
+ .find(|(e, _, _)| !lane.slots.iter().any(|s| s.fixture == *e))
+ .map(|(_, _, sc)| *sc);
+ if let (Some((idx, weak)), Some(best)) = (weakest, challenger) {
+ if best > weak * TORCH_SWAP_RATIO {
+ lane.slots[idx].evicting = true;
+ lane.last_swap = now;
}
}
}
- // (2b) MONKEY (Phase 3B): the per-frame ENTITY caster — every frame (entities move), the same
- // collector the world lane feeds the directional map: creatures + gameobjects AND environment
- // (furniture, props) within ENTITY_REACH of the camera, in absolute Bevy world space like the
- // static caster, so the same torch `view_proj` projects both. A table or a character now throws
- // a torch shadow onto the floor the static_gx lane already receives.
- let mut ent_admitted = 0u32;
- if lane.entity_mesh.is_none() {
- lane.entity_mesh = Some(meshes.add(empty_shadow_mesh()));
- }
- if let Some(handle) = lane.entity_mesh.clone() {
- if let Some(mesh) = meshes.get_mut(&handle) {
- let (mut positions, mut indices) = take_mesh_buffers(mesh);
- let (admitted, _rejected) = collect_entity_geometry(
- &parts,
- &rigs,
- &palettes,
- true, // creatures + gameobjects (NPCs, the player, placed chairs/tables)
- true, // environment (doodads / WMO props)
- cam,
- ENTITY_REACH,
- ENTITY_REACH,
- &mut positions,
- &mut indices,
- );
- ent_admitted = admitted;
- restore_mesh_buffers(mesh, positions, indices);
- }
- }
+ // The published set, in slot order: fixture, position, score (trace only) and fade weight,
+ // capped at the table's allocation.
+ let promoted: Vec<(usize, Entity, Vec3, f32, f32)> = lane
+ .slots
+ .iter()
+ .take(MAX_TORCH_CASTERS)
+ .map(|s| (s.cache_slot, s.fixture, s.pos, s.score, s.w))
+ .collect();
- // (3) Publish the fixtures + their down-looking matrices + both caster mesh ids for the render world.
+ // (2) MONKEY (static torch cache): fingerprint resident SOURCE geometry per fixture, not
+ // StaticGx's change tick (frame bookkeeping changes it continuously). This catches arrivals,
+ // removals and replacements even with a stationary camera. Entity-resident rigid furniture
+ // joins the static mesh; creatures and skinned/animated parts belong to the dynamic overlay.
+ let fixture_positions: Vec<_> = promoted.iter().map(|(i, _, p, _, _)| (*i, *p)).collect();
+ let dynamic = dynamic_set(&fixture_positions, anchor, video.interior_shadow_dynamic as usize);
+ let mut rebuilt = 0;
+ let mut static_rebuilt = 0u32;
let mut published = TorchShadowViews {
- count: nearest.len() as u32,
- caster_mesh: lane.caster_mesh.as_ref().map(Handle::id),
- entity_mesh: lane.entity_mesh.as_ref().map(Handle::id),
+ count: promoted.iter().map(|(i, ..)| *i as u32 + 1).max().unwrap_or(0),
+ soft: video.interior_shadow_soft,
+ dynamic_mask: dynamic,
..Default::default()
};
- // Six cube faces per fixture, independent of camera and player: layer = fixture * 6 + face.
- for (i, p) in nearest.iter().enumerate() {
- published.positions[i] = p.extend(TORCH_RANGE);
- for (f, vp) in cube_view_projs(*p).into_iter().enumerate() {
+ // MONKEY (static torch cache): rotate service order so two moving/carried fixtures cannot
+ // consume the rebuild budget forever and starve a newly resident room's static maps. The
+ // rotation bounds the SCAN as well as the rebuild (see TORCH_SCAN_PER_FRAME): fingerprinting a
+ // fixture's surroundings is the one part of this lane that is O(resident scene), so doing it
+ // for every slot every frame would be the new cost ceiling — and at most two of those slots
+ // could act on the answer anyway.
+ let slot_count = lane.slots.len();
+ let start = lane.rebuild_cursor;
+ let scan = slot_count.min(TORCH_SCAN_PER_FRAME);
+ for offset in 0..scan {
+ let index = (start + offset) % slot_count;
+ let slot = &mut lane.slots[index];
+ let i = slot.cache_slot;
+ let mut hash = std::collections::hash_map::DefaultHasher::new();
+ slot.fixture.hash(&mut hash);
+ slot.pos.to_array().map(f32::to_bits).hash(&mut hash);
+ gx.as_ref().map(|gx| gx.torch_geometry_key(slot.pos, TORCH_RANGE)).hash(&mut hash);
+ let mut entity_key = 0u64;
+ for (entity, pick, part, global, rig, occluder, _) in &ents.parts {
+ if !static_part(part, rig.is_some()) || !occluder.0 { continue; }
+ let Some(global) = global else { continue };
+ if global.translation().distance_squared(slot.pos) > TORCH_RANGE * TORCH_RANGE { continue; }
+ // Arc identity catches geometry replacement, transform bits catch moved furniture.
+ let mut part_hash = std::collections::hash_map::DefaultHasher::new();
+ entity.hash(&mut part_hash);
+ (std::sync::Arc::as_ptr(&pick.0) as usize).hash(&mut part_hash);
+ global.to_matrix().to_cols_array().map(f32::to_bits).hash(&mut part_hash);
+ entity_key = entity_key.wrapping_add(part_hash.finish());
+ }
+ entity_key.hash(&mut hash);
+ let key = hash.finish();
+ let dirty = slot.geometry_key != Some(key);
+ if dirty && rebuilt < 2 {
+ let mut positions = Vec::new();
+ let mut indices = Vec::new();
+ if let Some(gx) = &gx {
+ gx.append_torch_triangles(slot.pos, TORCH_RANGE, &mut positions, &mut indices);
+ }
+ collect_torch_entities(&ents, true, &[(i, slot.pos)], &mut positions, &mut indices);
+ let mut mesh = empty_shadow_mesh();
+ restore_mesh_buffers(&mut mesh, positions, indices);
+ if let Some(old) = slot.mesh.replace(meshes.add(mesh)) { meshes.remove(old.id()); }
+ slot.geometry_key = Some(key);
+ slot.built_at = Some(slot.pos);
+ rebuilt += 1;
+ static_rebuilt |= 1 << i;
+ }
+ }
+ // Advance past everything scanned, not just what was rebuilt, so the window sweeps the whole
+ // resident set at a steady rate instead of re-examining the head of it.
+ lane.rebuild_cursor = (start + scan) % slot_count.max(1);
+
+ // MONKEY (static torch cache): publish EVERY slot every frame — position, fade weight and the
+ // six projections all move with the fixture and the cross-fade, and only the caster MESH is
+ // cached. A slot whose map is stale (dirty, awaiting its rebuild turn) still publishes it; a
+ // slot whose fixture has drifted away from where its map was baked publishes none, which forces
+ // its weight to zero rather than showing shadows cast from a position the light has left.
+ for slot in &lane.slots {
+ let i = slot.cache_slot;
+ // MONKEY (carried light stability): where this slot casts FROM this frame. Normally the
+ // live fixture position. A slot that has drifted off its baked map AND is on its way out
+ // publishes from `built_at` instead of withdrawing: withdrawing clears the slot's
+ // `ready_mask` bit, and `TorchTable::pack` answers that by forcing the cross-fade weight
+ // to 0 with no ramp — a hard shadow-off in one frame, which is the very pop the "fade,
+ // not switch" law of this lane exists to remove. Frozen, the shadow dissolves over
+ // `TORCH_FADE_RATE` from the last place it was correct; a third of a second later the
+ // fixture is a stride away and the shadow is already gone, which reads as a shadow being
+ // left behind rather than as a blink. A slot that is NOT evicting still withdraws — a
+ // resident fixture mid-rebuild must never show shadows cast from a position it has left.
+ let origin = if map_publishable(slot.built_at, slot.pos) {
+ Some(slot.pos)
+ } else {
+ slot.built_at.filter(|_| slot.evicting)
+ };
+ if origin.is_some() {
+ published.caster_meshes[i] = slot.mesh.as_ref().map(Handle::id);
+ }
+ let at = origin.unwrap_or(slot.pos);
+ published.positions[i] = at.extend(slot.w);
+ for (f, vp) in cube_view_projs(at).into_iter().enumerate() {
published.view_projs[i * CUBE_FACES + f] = vp;
}
}
+ // MONKEY (static torch cache): gather only around the dynamic subset, not every promoted
+ // fixture. A single mesh serves those N maps and contains no rigid furniture already cached.
+ let dynamic_positions: Vec<_> = fixture_positions.iter().copied()
+ .filter(|(i, _)| dynamic & (1 << i) != 0).collect();
+ let mut ent_admitted = 0;
+ if dynamic != 0 {
+ if lane.entity_mesh.is_none() { lane.entity_mesh = Some(meshes.add(empty_shadow_mesh())); }
+ if let Some(mesh) = lane.entity_mesh.as_ref().and_then(|h| meshes.get_mut(h)) {
+ let (mut positions, mut indices) = take_mesh_buffers(mesh);
+ ent_admitted = collect_torch_entities(&ents, false, &dynamic_positions, &mut positions, &mut indices);
+ restore_mesh_buffers(mesh, positions, indices);
+ }
+ published.entity_mesh = lane.entity_mesh.as_ref().map(Handle::id);
+ }
*views = published;
// `WOW_TORCH_TRACE=1` — once a second, what the lane published: fixture distances + whether a
// caster mesh exists. The app-side half of the debug (the shader group-3 sample is the GPU half).
if std::env::var_os("WOW_TORCH_TRACE").is_some() {
- let now = time.elapsed_secs_f64();
if now - *last_trace >= 1.0 {
*last_trace = now;
- let dists: Vec = nearest.iter().map(|p| p.distance(cam)).collect();
info!(
- "torch-trace: {} maps (caster {}, {} entity casters) — fixture dists {:?}",
- nearest.len(),
- if lane.caster_mesh.is_some() { "built" } else { "none" },
+ "torch-trace: {}/{} slots of {} ({} candidates, caster {}, {} entity casters)",
+ promoted.len(),
+ want,
+ MAX_TORCH_CASTERS,
+ cands.len(),
+ if lane.slots.iter().any(|s| s.mesh.is_some()) {
+ "built"
+ } else {
+ "none"
+ },
ent_admitted,
- dists,
);
+ // MONKEY (torch caster selection): per SLOT, the three numbers the selection is made
+ // of — which fixture, what it scores at the player, and how far its cross-fade has
+ // ramped. A shadow that pops is a `w` that jumped; a shadow that should be there and
+ // is not is a fixture missing from this list (then read `cands`, above).
+ // MONKEY (torch caster reach): scientific scores expose the small distant tail;
+ // distance is still from the player, and eligibility now extends to 4R + 3 yd.
+ for (i, e, p, sc, w) in &promoted {
+ info!(
+ " slot {i}: {e} at [{:.1},{:.1},{:.1}] d(player) {:.1} score {:.4e} w {:.2} (camera <= shadowDistance AND player <= 4R+3) static {} dynamic {}",
+ p.x,
+ p.y,
+ p.z,
+ p.distance(anchor),
+ sc,
+ w,
+ if static_rebuilt & (1 << i) != 0 { "rebuilt (GPU pending)" } else { "cached/requested" },
+ if dynamic & (1 << i) != 0 { "yes" } else { "no" },
+ );
+ }
}
}
}
+// MONKEY (static torch cache): a rig can deform even while its root stands still. Cache only
+// rigid non-creature parts; animated furniture follows the same nearest-N policy as characters.
+fn static_part(part: &ModelPart, rigged: bool) -> bool {
+ part.kind != ModelKind::Creature && !rigged && part.blend == ModelBlend::Opaque
+}
+
+fn collect_torch_entities(
+ ents: &EntityCasters, want_static: bool, fixtures: &[(usize, Vec3)],
+ positions: &mut Vec<[f32; 3]>, indices: &mut Vec,
+) -> u32 {
+ let mut admitted = 0;
+ for (_, pick, part, global, rig_part, occluder, _) in &ents.parts {
+ if !occluder.0 || static_part(part, rig_part.is_some()) != want_static { continue; }
+ let solid = part.blend == ModelBlend::Opaque
+ || (part.kind == ModelKind::Creature && part.blend == ModelBlend::AlphaTest);
+ if !solid { continue; }
+ let rig = rig_part.and_then(|r| ents.rigs.get(r.0).ok());
+ let origin = global.map(GlobalTransform::translation)
+ .or_else(|| rig.and_then(|r| ents.palettes.slot_origin(r.slot)));
+ let Some(origin) = origin else { continue };
+ if !fixtures.iter().any(|(_, p)| p.distance_squared(origin) <= TORCH_RANGE * TORCH_RANGE) { continue; }
+ let base = positions.len() as u32;
+ if let Some(rig) = rig {
+ let Some(palette) = ents.palettes.world_palette(rig.slot, rig.bones() as usize) else { continue };
+ for (v, p) in pick.0.positions.iter().enumerate() {
+ let p = wow_to_bevy(*p);
+ let (Some(joints), Some(weights)) = (pick.0.joints.get(v), pick.0.weights.get(v)) else {
+ positions.push(palette.first().map(|m| m.transform_point3(p)).unwrap_or(origin).to_array());
+ continue;
+ };
+ let mut skinned = Vec3::ZERO;
+ for lane in 0..4 {
+ if weights[lane] > 0.0 {
+ if let Some(m) = palette.get(joints[lane] as usize) {
+ skinned += m.transform_point3(p) * weights[lane];
+ }
+ }
+ }
+ positions.push(skinned.to_array());
+ }
+ } else if rig_part.is_none() {
+ if let Some(global) = global {
+ positions.extend(pick.0.positions.iter().map(|p| global.transform_point(wow_to_bevy(*p)).to_array()));
+ }
+ }
+ let added = positions.len() as u32 - base;
+ if added == 0 { continue; }
+ for tri in pick.0.indices.chunks_exact(3) {
+ if tri.iter().all(|i| *i < added) { indices.extend(tri.iter().map(|i| base + *i)); }
+ }
+ admitted += 1;
+ }
+ admitted
+}
+
/// Tear down the caster mesh and clear the published views (the lane went off, or we left the world).
fn teardown(lane: &mut TorchLane, meshes: &mut Assets, views: &mut TorchShadowViews) {
- if let Some(handle) = lane.caster_mesh.take() {
- meshes.remove(handle.id());
+ for slot in &mut lane.slots {
+ if let Some(handle) = slot.mesh.take() { meshes.remove(handle.id()); }
}
- if let Some(handle) = lane.entity_mesh.take() {
- meshes.remove(handle.id());
- }
- lane.caster_at = None;
+ if let Some(handle) = lane.entity_mesh.take() { meshes.remove(handle.id()); }
+ // MONKEY (torch caster selection): the slot table goes with them — an incumbent kept across a
+ // teardown would come back at full weight in a room it is no longer in.
+ lane.slots.clear();
*views = TorchShadowViews::default();
}
+
+// MONKEY (torch caster reach): regression boundaries and the unfloored distant ranking tail.
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // MONKEY (review fixes): useful interior casters win regardless of an exterior fixture's
+ // brightness; the fire dial scales only invented sources, including its live kill switch.
+ #[test]
+ fn candidates_follow_the_packed_lane_and_fire_gain() {
+ let interior = LightLane::carried(true);
+ let exterior = LightLane::carried(false);
+ let authored = candidate_score(1.0, 10.0, 10.0, Some(&interior), true, false, 0.0);
+ assert!(authored > 0.0);
+ assert_eq!(candidate_score(100.0, 1.0, 10.0, Some(&exterior), true, false, 1.0), 0.0);
+ for gain in [0.0, -1.0] {
+ assert_eq!(candidate_score(100.0, 1.0, 10.0, Some(&interior), true, true, gain), 0.0);
+ }
+ assert_eq!(candidate_score(1.0, 10.0, 10.0, Some(&interior), true, true, 0.5), authored * 0.5);
+ // No MOLR is needed once a containment-only fixture resolves interior; before resolution
+ // the old LightRooms fallback is shared with build_light_data, in both directions.
+ assert_eq!(candidate_score(1.0, 10.0, 10.0, Some(&interior), false, false, 0.0), authored);
+ assert_eq!(candidate_score(1.0, 10.0, 10.0, None, true, false, 0.0), authored);
+ assert_eq!(candidate_score(1.0, 10.0, 10.0, None, false, false, 0.0), 0.0);
+ }
+
+ #[test]
+ fn eligibility_covers_four_reaches_with_slack() {
+ for r in [7.0, 15.0, 48.0] {
+ assert!(fixture_eligible(3.5 * r, 80.0, r, 80.0));
+ assert!(fixture_eligible(4.0 * r + 3.0, 80.0, r, 80.0));
+ assert!(!fixture_eligible(4.2 * r + 3.0, 80.0, r, 80.0));
+ }
+ assert!(!fixture_eligible(1.0, 80.01, 48.0, 80.0));
+ assert!(fixture_eligible(1.0, 250.0, 48.0, 300.0));
+ assert!(!fixture_eligible(32.0, 1.0, 7.0, 80.0));
+ assert!(!fixture_eligible(1.0, f32::NAN, 48.0, 80.0));
+ }
+
+ // MONKEY (static torch cache): sparse physical slots, tie stability and live shrink/off.
+ #[test]
+ fn dynamic_subset_is_nearest_and_deterministic() {
+ let fixtures = [(15, Vec3::X), (8, -Vec3::X), (2, Vec3::X * 20.0), (4, Vec3::ZERO)];
+ assert_eq!(dynamic_set(&fixtures, Vec3::ZERO, 2), (1 << 4) | (1 << 8));
+ assert_eq!(dynamic_set(&fixtures, Vec3::X * 20.0, 1), 1 << 2);
+ assert_eq!(dynamic_set(&fixtures, Vec3::ZERO, 0), 0);
+ assert_eq!(dynamic_set(&fixtures, Vec3::ZERO, 16).count_ones(), 4);
+ }
+
+ // MONKEY (static torch cache): a never-built slot publishes nothing; a merely STALE map keeps
+ // being published (its shadow must not blink off while the rebuild cursor reaches it); a map
+ // whose fixture has MOVED is withdrawn, because the projections no longer address it.
+ #[test]
+ fn a_cached_map_survives_staleness_but_not_fixture_drift() {
+ assert!(!map_publishable(None, Vec3::ZERO));
+ assert!(map_publishable(Some(Vec3::ZERO), Vec3::ZERO));
+ assert!(map_publishable(Some(Vec3::ZERO), Vec3::X * 0.09));
+ assert!(!map_publishable(Some(Vec3::ZERO), Vec3::X * 0.2));
+ assert!(!map_publishable(Some(Vec3::ZERO), Vec3::splat(10.0)));
+ }
+
+ #[test]
+ fn distant_scores_stay_positive_and_ordered() {
+ let r = 10.0;
+ let near = fixture_score(1.0, r, r);
+ let far = fixture_score(1.0, 3.0 * r, r);
+ let farther = fixture_score(1.0, 3.5 * r, r);
+ let slack = fixture_score(1.0, 4.0 * r + 2.0, r);
+ assert!(near > far && far > farther && farther > slack && slack > 0.0);
+ assert!(far < near * 0.2);
+ assert_eq!(fixture_score(0.0, 3.0 * r, r), 0.0);
+ assert_eq!(fixture_score(1.0, 4.0 * r + 3.0, r), 0.0);
+ assert!(fixture_score(2.0, 3.0 * r, r) > far);
+ }
+}
diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs
index 6f2feb41..ccb1e050 100644
--- a/crates/benilla-app/src/video.rs
+++ b/crates/benilla-app/src/video.rs
@@ -234,12 +234,47 @@ pub(crate) struct VideoConfig {
pub(crate) interior_ambient: f32,
pub(crate) interior_fill: f32,
pub(crate) interior_exposure: f32,
+ /// MONKEY (soft falloff): live scale on every interior fixture's AUTHORED attenuation window
+ /// (`interiorAttenScale`, 0..8) — the fixture's EFFECTIVE RADIUS is `authored end × this`. A
+ /// WMO MOLT record's `+0x2c` (an M2 source buckets by intensity, its authored pair being a
+ /// template default rather than a reach) is a "full brightness ends here" number, not a
+ /// "nothing past here" one, so `1` gave a hard-edged disc at exactly the authored end. The
+ /// default is **2.5**: a 5 yd candle now tails smoothly out to 12.5, reading ~⅓ of its 1 yd
+ /// brightness at the authored 5 and ~8 % at 10. `0` still means "no window" (the 48 yd lane).
+ /// Bridged to benilla-world's `DynamicInteriors::atten_scale`, which the light packer folds
+ /// into each interior entry's packed radius, so the dial moves the frame it changes.
+ pub(crate) interior_atten_scale: f32,
/// MONKEY (torch shadows, Stage B): whether interior fixtures cast real shadows (the nearest few
/// promoted to cube-map casters — `torch_shadow`). Only meaningful with `interior_light` on.
pub(crate) interior_shadows: bool,
+ /// MONKEY (static torch cache): resident fixture budget (1..16, default 12). Static
+ /// geometry renders only on promotion/residency changes; lowering this fades extra slots out.
+ pub(crate) interior_shadow_casters: u32,
+ /// MONKEY (static torch cache): nearest promoted fixtures with per-frame entity overlays
+ /// (0..16, default 4). Zero keeps all static shadows and disables only the moving casters.
+ pub(crate) interior_shadow_dynamic: u32,
+ /// MONKEY (torch caster selection): the PCF tap-radius scale for the torch maps
+ /// (`interiorShadowSoft`, 0.5..3, default 1). A candle cluster casts many hard-edged
+ /// overlapping shadows; widening the 4-tap kernel is the cheap softening. Rides the torch
+ /// table's `count.y` (as `x100`) rather than a `DynamicInteriors` field, because it belongs
+ /// to the shadow table's own bytes.
+ pub(crate) interior_shadow_soft: f32,
+ /// MONKEY (room gate): whether an interior fixture may only light the ROOMS IT CLAIMS
+ /// (`interiorRoomGate`, default on). Off = the pre-gate behaviour, where every interior fixture
+ /// in range lights every interior surface in range and the only occlusion is the handful of
+ /// promoted cube-shadow casters — the live A/B for "did the gate darken this room, or was it
+ /// always unlit?". Bridged to benilla-world's `DynamicInteriors::room_gate`, which the light
+ /// packer applies at PACK time (an ungated pack is one `count = 0` head per light), so it moves
+ /// the frame it changes and costs the shader nothing.
+ pub(crate) interior_room_gate: bool,
/// MONKEY (interior debug): the interior-lane diagnostic overlay (`interiorDebug`, 0..3). See
/// [`benilla_world::lighting::DynamicInteriors::debug`].
pub(crate) interior_debug: u32,
+ /// MONKEY (fire GO lights): gain on every light SYNTHESISED from a fire prop's flame emitter
+ /// (`fireLightGain`, 0..4; `0` = the invented-light lane off). Bridged to benilla-world's
+ /// [`benilla_world::lighting::FireLightGain`] by `dynamic_interior`, and applied at PACK time
+ /// so it is live.
+ pub(crate) fire_light_gain: f32,
pub(crate) display: DisplayMode,
/// The windowed size, `gxResolution`. Kept while fullscreen so leaving it can restore it.
pub(crate) windowed: UVec2,
@@ -257,8 +292,18 @@ impl Default for VideoConfig {
interior_ambient: 0.15,
interior_fill: 0.12,
interior_exposure: 2.5,
+ // MONKEY (soft falloff): 2.5, not 1 — see the field doc.
+ interior_atten_scale: 1.6,
interior_shadows: true,
+ // MONKEY (static torch cache): 12 resident maps, four moving-caster overlays.
+ interior_shadow_casters: 12,
+ interior_shadow_dynamic: 4,
+ interior_shadow_soft: 1.0,
+ // MONKEY (room gate): on — without it a building's fixtures light through its own
+ // floors and walls.
+ interior_room_gate: true,
interior_debug: 0,
+ fire_light_gain: 1.0,
display: if windowed_env() {
DisplayMode::Windowed
} else {
@@ -574,6 +619,8 @@ mod tests {
shadow_distance: 80.0,
display: DisplayMode::Fullscreen,
windowed: UVec2::new(1024, 768),
+ // MONKEY (review fixes): this window test inherits unrelated lighting defaults.
+ ..Default::default()
})
.add_systems(Update, apply_window_mode);
let win = app
diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs
index acd203a4..5f05806d 100644
--- a/crates/benilla-assets/src/m2.rs
+++ b/crates/benilla-assets/src/m2.rs
@@ -289,6 +289,18 @@ pub struct ModelRibbon {
pub struct ModelLight {
pub def: M2Light,
pub bone_pivot: [f32; 3],
+ /// MONKEY (fire GO lights): `true` when this light was **synthesised** from the model's flame
+ /// particle emitter rather than read off an authored light block
+ /// ([`benilla_formats::fire_light`]). Only ~17 of the ~430 fire-ish props in the chain author
+ /// one, so without this every wall torch, magic brazier, forge and candle in the game lights
+ /// nothing.
+ ///
+ /// The flag exists so a lane can REFUSE it, and one does: the WMO MODD prop lane. A building's
+ /// own MOLT fixtures already sit at its wall torches' flames (the artists place a companion
+ /// MOLT per fixture doodad — NSabbey ships one per candelabra), so a synthetic light on the
+ /// prop would double-light exactly the interiors that are already correct. Every other lane —
+ /// ADT map doodads, GameObjects/creatures, transport props — takes it like an authored one.
+ pub synthetic: bool,
}
/// Bevy [`AssetLoader`] decoding `*.m2` → [`M2Model`].
@@ -468,7 +480,7 @@ impl AssetLoader for M2ModelLoader {
// M2 lights: the same host-bone pivot bake, so a light on an animating model (the torch in
// an NPC's hand) can ride its bone's joint instead of freezing at the rest-pose spot.
- let lights = light_defs
+ let mut lights: Vec = light_defs
.into_iter()
.map(|def| ModelLight {
bone_pivot: skeleton_raw
@@ -476,8 +488,87 @@ impl AssetLoader for M2ModelLoader {
.get(def.bone as usize)
.map_or([0.0; 3], |b| b.pivot),
def,
+ synthetic: false,
})
.collect();
+ // MONKEY (fire GO lights) / MONKEY (lamp lights): a prop that authors NO casting light gets
+ // ONE derived from what it DOES author — its flame emitter, or (lamps, lanterns, sconces,
+ // chandeliers, which author no particles whatsoever) its unlit glass geoset. Both rules,
+ // their calibration and the `benilla-extract m2firescan` audit live in
+ // [`benilla_formats::fire_light`]. This is the single site the synthesis happens, so every
+ // lane that already consumes `M2Model::lights` inherits it for free and nothing downstream
+ // has to know an emitter or a render batch was ever read.
+ //
+ // Gated on `casts()` and not on emptiness: an authored block ALWAYS wins, including the
+ // directional-only and visibility-off shapes — a model whose one light block is authored
+ // dark was authored dark on purpose.
+ if !lights.iter().any(|l| l.def.casts()) {
+ let path = ctx.path().path().to_string_lossy().to_string();
+ // The FLAME route first, then the LAMP route — never both. The flame wins because it
+ // carries a REAL colour (read off the artist's own over-life ramp) where the lamp route
+ // can only pick a plausible one from a name; a model with both a flame and lamp glass
+ // (`OrcBrazierStreetLamp`) should take the measured hue, not the guessed one. Each
+ // yields the same four things: model-space position, host bone, colour, intensity.
+ let synth = benilla_formats::synthesize_fire_light(
+ &path,
+ emitters.iter().map(|e| &e.def),
+ )
+ .map(|fire| {
+ let src = &emitters[fire.emitter].def;
+ (src.position, src.bone, fire.color, fire.intensity)
+ })
+ .or_else(|| {
+ // MONKEY (lamp lights): a lamppost/lantern/chandelier authors NO particle emitter
+ // at all — its glow is an UNLIT (render-flag 0x01) glass geoset. The rule reads the
+ // already-built render batches, so nothing extra is parsed; the position it returns
+ // is that geoset's CENTROID (the lamp head, 4-5 yd up a lamppost), never the model
+ // origin at the base of the pole. `bounds` is the fallback for a name-route hit
+ // whose glass we can't find — see `fire_light::lamp_position`.
+ let batches: Vec> = submeshes
+ .iter()
+ .map(|s| benilla_formats::EmissiveBatch::from(&*s.geometry))
+ .collect();
+ let bbox = bounds.as_ref().map(|b| (b.bbox_min, b.bbox_max));
+ benilla_formats::synthesize_lamp_light(&path, &batches, bbox)
+ .map(|l| (l.position, l.bone, l.color, l.intensity))
+ });
+ if let Some((position, src_bone, color, intensity)) = synth {
+ // The light sits at the FLAME (or the lamp glass), not the model origin: a brazier's
+ // origin is under its bowl, and a light there back-lights the bowl into every
+ // surface it should be lighting (and self-shadows through the prop's own caster
+ // mesh, which the torch lane includes). Same bone convention as an authored light —
+ // a bone the skeleton doesn't carry reads as `-1` (model origin), which is what a
+ // boneless prop is.
+ let bone = i16::try_from(src_bone)
+ .ok()
+ .filter(|_| (src_bone as usize) < skeleton_raw.bones.len())
+ .unwrap_or(-1);
+ lights.push(ModelLight {
+ def: M2Light {
+ light_type: 1, // point — the hot-spot caster; the whole reason we're here
+ bone,
+ position,
+ // Diffuse only, like every world light the reference commits (its ambient
+ // and specular are zero on world props — decision 0273).
+ ambient_color: [0.0; 3],
+ ambient_intensity: 0.0,
+ diffuse_color: color,
+ diffuse_intensity: intensity,
+ // The GL curve is fixed (`1/(0.7d+0.03d²)`) and ignores these; they are a
+ // cull hint no consumer reads, so a synthesised light authors none.
+ attenuation_start: 0.0,
+ attenuation_end: 0.0,
+ bone_z: [0.0, 0.0, 1.0], // point lights have no direction basis
+ visibility_off: false,
+ },
+ bone_pivot: skeleton_raw
+ .bones
+ .get(src_bone as usize)
+ .map_or([0.0; 3], |b| b.pivot),
+ synthetic: true,
+ });
+ }
+ }
let (skeleton, inverse_bindposes) = build_skeleton(&skeleton_raw);
let inverse_bindposes = ctx.add_labeled_asset(
"inverse_bindposes".to_string(),
diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs
index 673d0a51..4b93f1da 100644
--- a/crates/benilla-assets/src/materials.rs
+++ b/crates/benilla-assets/src/materials.rs
@@ -234,14 +234,14 @@ pub struct WowModelExt {
#[texture(91, dimension = "2d_array", sample_type = "depth", visibility(fragment))]
#[sampler(92, sampler_type = "comparison", visibility(fragment))]
pub torch_depth: Handle,
- /// MONKEY (torch shadows Phase 3A): the ≤4-fixture torch TABLE — the same 1616-byte
- /// `TorchTableUniform` bytes static_gx's group-3 uniform carries (count / positions[4] /
- /// view_projs[24]), as a SEPARATE shared raw buffer (`SharedTorchBuffer`) rewritten in place
+ /// MONKEY (static torch cache): the ≤16-fixture torch TABLE — the same 6416-byte
+ /// `TorchTableUniform` bytes static_gx's group-3 uniform carries (count / positions[16] /
+ /// view_projs[96]), as a SEPARATE shared raw buffer (`SharedTorchBuffer`) rewritten in place
/// every frame from `TorchShadowViews`. A raw `Buffer` like `light_buf`, deliberately NOT a
/// `#[uniform]` field (a per-frame-mutated uniform re-prepares every material every frame)
/// and NOT a region of the shared light blob (whose `LightStd430` mirror + the booth packer
/// would all have to grow in lock-step). `wow_model.wgsl` reads it as
- /// `var torch_table` (std430 of this struct == the std140 1616 bytes: every
+ /// `var torch_table` (std430 of this struct == the std140 6416 bytes: every
/// member is 16-aligned). Set once, never mutated.
#[storage(93, read_only, buffer, visibility(fragment))]
pub torch_buf: Buffer,
@@ -257,7 +257,7 @@ pub struct WowModelExt {
pub struct TorchBinds {
/// The shared torch depth array image (→ `WowModelExt::torch_depth`).
pub depth: Handle,
- /// The shared 1616-byte torch table buffer (→ `WowModelExt::torch_buf`).
+ /// The shared 6416-byte torch table buffer (→ `WowModelExt::torch_buf`).
pub table: Buffer,
}
diff --git a/crates/benilla-assets/src/shaders/shadow_hook.wgsl b/crates/benilla-assets/src/shaders/shadow_hook.wgsl
index b660396a..ac004e86 100644
--- a/crates/benilla-assets/src/shaders/shadow_hook.wgsl
+++ b/crates/benilla-assets/src/shaders/shadow_hook.wgsl
@@ -86,6 +86,12 @@ fn torch_caster_count() -> u32 {
// - `world_pos` the receiving fragment's world position.
// - `depth_tex`/`comp` the group-3 depth array + `GreaterEqual` comparison sampler.
// - `bias` reverse-Z receiver bias (nudges the compare ref up to kill self-shadow acne).
+// - `soft` MONKEY (torch caster selection): the PCF tap-radius scale (`interiorShadowSoft`,
+// 0.5..3, carried in the table's `count.y` as `soft x 100`). The four taps sit at
+// ±0.5 texel × this; 1 is the historical half-texel box. A candle cluster casts
+// many overlapping penumbra-less edges, and widening the kernel is the cheapest
+// softening available here (a real penumbra would need the blocker distance and a
+// variable kernel — not worth a second sampling pass on a 512² face).
// Returns 1.0 outside the frustum / behind the light; a 4-tap PCF factor otherwise (0 = shadowed).
fn torch_map_shadow(
view_proj: mat4x4,
@@ -94,6 +100,7 @@ fn torch_map_shadow(
depth_tex: texture_depth_2d_array,
comp: sampler_comparison,
bias: f32,
+ soft: f32,
) -> f32 {
let clip = view_proj * vec4(world_pos, 1.0);
if (clip.w <= 0.0) {
@@ -104,7 +111,10 @@ fn torch_map_shadow(
return 1.0;
}
let uv = ndc.xy * vec2(0.5, -0.5) + vec2(0.5);
- let texel = 1.0 / vec2(textureDimensions(depth_tex).xy);
+ // MONKEY (torch caster selection): the tap radius is `soft` texels, clamped off zero so a
+ // table that never got a scale still samples a real (hard) 4-tap box rather than four copies
+ // of one texel.
+ let texel = (1.0 / vec2(textureDimensions(depth_tex).xy)) * max(soft, 0.05);
// Reverse-Z: the fragment is lit iff its own depth is at least the stored nearest depth, so the
// compare ref is `ndc.z + bias` against `GreaterEqual`.
let ref_depth = ndc.z + bias;
diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl
index 8f8f87fe..7b718ef0 100644
--- a/crates/benilla-assets/src/shaders/terrain.wgsl
+++ b/crates/benilla-assets/src/shaders/terrain.wgsl
@@ -70,7 +70,9 @@ struct WowLight {
grade: vec4, // reserved (the 0282 interior A/B retired; 0163). Layout only.
_wmo_fog: array, 2>, // rows 18-19: the interior fog triple — unread by terrain.
// The dynamic point-light table (decision 0278), packed by `global_light::build_light_data`:
- // row 20 `.x` = live entry count; then TWO rows per light — `[pos.xyz, range]`, `[rgb, 0]`.
+ // row 20 `.x` = live entry count; then TWO rows per light — `[pos.xyz, range]`, `[rgb, lane]`.
+ // MONKEY (light lanes): `lane` = 0 for an EXTERIOR light, > 0.5 (and equal to the fixture's
+ // reach in yards) for an INTERIOR one. Terrain consumes only the exterior half.
point_count: vec4,
points: array, 512>,
};
@@ -138,6 +140,16 @@ fn point_light_sum(P: vec3, N: vec3, anchor: vec3) -> vec3 {
var sel = array(0u, 0u, 0u);
var sd = array(1e30, 1e30, 1e30);
for (var i = 0u; i < count; i = i + 1u) {
+ // MONKEY (light lanes): skip INTERIOR fixtures. The colour row's `.w` is `0` on an exterior
+ // source and the fixture's reach in yards (always ≥ 1) on one that claims a room, so
+ // `> 0.5` is the lane test. Terrain is exterior by definition: an inn's candles reaching
+ // the grass at the base of its wall — a warm pool on the lawn at night — is the whole bug,
+ // and it is here rather than in the pack because the fixtures MUST stay packed for the
+ // interior lane to light the room they belong to. Skipped BEFORE the ≤3 ranking, so an
+ // interior fixture cannot even occupy a slot an outdoor fire should have had.
+ if (wow_light.points[2u * i + 1u].w > 0.5) {
+ continue;
+ }
let pos_range = wow_light.points[2u * i];
let dv = pos_range.xyz - anchor;
let d2 = dot(dv, dv);
diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl
index 98424f15..5913db49 100644
--- a/crates/benilla-assets/src/shaders/wow_model.wgsl
+++ b/crates/benilla-assets/src/shaders/wow_model.wgsl
@@ -164,9 +164,16 @@ struct WowLight {
wmo_fog_color: vec4, // rgb interior fog (gamma); w = enable (mirrors fog_color.w)
wmo_fog_params: vec4, // x = start yd; y = end yd; zw = free lanes (retired A/B dials)
// The dynamic point-light table (decision 0278), packed by `global_light::build_light_data`:
- // row 20 `.x` = live entry count; then TWO rows per light — `[pos.xyz, range]`, `[rgb, 0]`.
+ // row 20 `.x` = live entry count; then TWO rows per light — `[pos.xyz, range]`, `[rgb, lane]`.
// Rides this buffer (not bevy's clusterables) because the view layout exposes those to the
// fragment stage only, and the Gouraud term is evaluated in the VERTEX stage.
+ //
+ // MONKEY (light lanes): the colour row's `.w` splits the table into two DISJOINT halves.
+ // `0` = an EXTERIOR light (nothing claims a room) — read only by `point_light_sum`. Anything
+ // `> 0.5` = an INTERIOR fixture — read only by `interior_room_light`, and the value itself is
+ // that fixture's REACH IN YARDS (its authored MOLT attenuation end, or the M2 intensity
+ // bucket, already scaled by the live `interiorAttenScale`). One float carries both because a
+ // packed reach is always ≥ 1 yd and can never be mistaken for the exterior 0.
point_count: vec4,
points: array, 512>,
// The interior-prop SH probe table (lighting::prop_probes — 7 rows per slot, 8192 slots; keep in
@@ -205,14 +212,17 @@ struct WowLight {
// MONKEY (torch shadows Phase 3A): the ENTITY receiver's torch bindings — the SAME depth array
// static_gx's group 3 samples (one shared `Image`, rendered by `static_gx::torch_depth`) and the
-// SAME 1616-byte table, riding the material's own group (`WowModelExt` bindings 91/92/93) because
+// SAME 6416-byte table, riding the material's own group (`WowModelExt` bindings 91/92/93) because
// a Bevy material draw sets groups 0/1/2 only. ALWAYS bound (the image and buffer exist from
// startup), so no shader-def guards this block; only the fragment stage reads it. The struct is
// std430 here and std140 in static_gx — identical bytes, every member is 16-aligned.
struct TorchTable {
- count: vec4, // .x = live fixture count
- positions: array, 4>, // xyz = fixture world pos, w = range
- view_projs: array, 24>, // 6 cube faces per fixture: [fixture*6 + face]
+ // MONKEY (static torch cache): byte-identical in BOTH shaders and TorchTableUniform.
+ // count@0 (16): x high-water slot count, y soft*100, z dynamic/live-bank mask, w reserved.
+ // positions@16 (256), view_projs@272 (6144): total 6416 bytes. A mismatch hides buildings.
+ count: vec4,
+ positions: array, 16>,
+ view_projs: array, 96>,
}
@group(#{MATERIAL_BIND_GROUP}) @binding(91) var torch_depth: texture_depth_2d_array;
@group(#{MATERIAL_BIND_GROUP}) @binding(92) var torch_samp: sampler_comparison;
@@ -227,6 +237,11 @@ const TORCH_BIAS: f32 = 0.001;
// interiorDebug 2, flickering shadows in the real render). Pushing the sample point out of the skin
// makes self-occlusion impossible while a pillar or another character still casts onto it.
const TORCH_NORMAL_OFFSET: f32 = 0.15;
+// MONKEY (torch caster selection, MIRRORED from static_gx.wgsl): the live PCF tap-radius scale,
+// unpacked from the table's `count.y` (stored x100 — the row is `vec4`).
+fn torch_soft() -> f32 {
+ return max(f32(torch_table.count.y) * 0.01, 0.05);
+}
// MONKEY (Phase 5, MIRRORED from static_gx.wgsl — keep in sync): the cube face that contains
// direction `d` (fixture → fragment) — the major axis, signed. Face order is the contract with
@@ -251,11 +266,19 @@ fn torch_entity_shadow(light_pos: vec3, P: vec3, N: vec3) -> f32
// Normal-offset the sample point out of the body (see TORCH_NORMAL_OFFSET).
let Ps = P + N * TORCH_NORMAL_OFFSET;
for (var i = 0u; i < torch_table.count.x; i = i + 1u) {
+ // MONKEY (static torch cache): holes and pending uploads never sample stale layers.
+ if (torch_table.positions[i].w <= 0.0) { continue; }
let fixture = torch_table.positions[i].xyz;
if (distance(fixture, light_pos) < 1.0) {
let layer = i * 6u + torch_face(Ps - fixture);
- return shadow_hook::torch_map_shadow(
- torch_table.view_projs[layer], i32(layer), Ps, torch_depth, torch_samp, TORCH_BIAS);
+ let s = shadow_hook::torch_map_shadow(
+ torch_table.view_projs[layer], i32(layer + select(0u, 96u, (torch_table.count.z & (1u << i)) != 0u)), Ps, torch_depth, torch_samp, TORCH_BIAS,
+ torch_soft());
+ // MONKEY (torch caster selection, MIRRORED from static_gx.wgsl): `.w` is the slot's
+ // FADE WEIGHT, so a promoted fixture's shadow ramps in over ~1/3 s and a demoted one
+ // ramps out. An entity and the floor under it MUST use the same weight or the NPC's
+ // shadow would pop while the floor's faded.
+ return mix(1.0, s, torch_table.positions[i].w);
}
}
return 1.0;
@@ -269,9 +292,13 @@ fn torch_entity_debug_factor(P: vec3, N: vec3) -> f32 {
let Ps = P + N * TORCH_NORMAL_OFFSET;
var s = 1.0;
for (var i = 0u; i < torch_table.count.x; i = i + 1u) {
+ if (torch_table.positions[i].w <= 0.0) { continue; }
let layer = i * 6u + torch_face(Ps - torch_table.positions[i].xyz);
- s = min(s, shadow_hook::torch_map_shadow(
- torch_table.view_projs[layer], i32(layer), Ps, torch_depth, torch_samp, TORCH_BIAS));
+ let raw = shadow_hook::torch_map_shadow(
+ torch_table.view_projs[layer], i32(layer + select(0u, 96u, (torch_table.count.z & (1u << i)) != 0u)), Ps, torch_depth, torch_samp, TORCH_BIAS,
+ torch_soft());
+ // MONKEY (torch caster selection): the WEIGHTED factor, matching the real render.
+ s = min(s, mix(1.0, raw, torch_table.positions[i].w));
}
return s;
}
@@ -358,6 +385,13 @@ fn point_light_sum(P: vec3, N: vec3, anchor: vec3) -> vec3 {
var sel = array(0u, 0u, 0u);
var sd = array(1e30, 1e30, 1e30);
for (var i = 0u; i < count; i = i + 1u) {
+ // MONKEY (light lanes): skip INTERIOR fixtures (colour row `.w > 0.5`) — they light the
+ // room that claims them and nothing else. Before the split, a proximity-admitted inn
+ // fixture reached every exterior receiver near the building through its own walls. Skipped
+ // BEFORE the ≤3 ranking, so it cannot take a slot an outdoor fire should have had.
+ if (wow_light.points[2u * i + 1u].w > 0.5) {
+ continue;
+ }
let pos_range = wow_light.points[2u * i];
let dv = pos_range.xyz - anchor;
let d2 = dot(dv, dv);
@@ -391,7 +425,14 @@ fn point_light_sum(P: vec3, N: vec3, anchor: vec3) -> vec3 {
}
// MONKEY (dynamic interiors): the per-FRAGMENT room light — MIRRORED from `static_gx.wgsl`
-// (`interior_room_light`; keep in sync). Every in-range light of the room-gated table, no
+// (`interior_room_light`; keep in sync). MONKEY (light lanes / interior attenuation): the loop is
+// over the INTERIOR half of the table only (colour row `.w > 0.5`), and that same `.w` is each
+// fixture's REACH in yards — its authored MOLT attenuation end (M2 sources bucket by intensity),
+// scaled live by `interiorAttenScale`; it bounds the loop, shapes the fill and drives the direct
+// window. `interiorAttenScale 0` packs the legacy 48 yd — under the soft profile below that is the
+// widest, flattest pool the lane can make, the nearest thing left to the pre-window flat lane (no
+// longer the byte-exact restore it was, because the window's SHAPE moved with it).
+// Every in-range light of the room-gated table, no
// nearest-3 selection: DIRECT (falloff × wrapped Lambert, so a floor-level hearth still lights
// the floor) + FILL (normal-free, half-desaturated bounce) + the base ambient. The knobs are the
// live cvars packed into `point_count.yzw` (`.y` ambient, `.z` fill gain, `.w` exposure — the
@@ -400,24 +441,125 @@ fn point_light_sum(P: vec3, N: vec3, anchor: vec3) -> vec3 {
// day/night CGLight, and takes the SAME per-fixture torch shadows (Phase 3A: the shared depth
// array through this material's own group-2 bindings) as the surfaces.
const INTERIOR_WRAP: f32 = 0.5;
-fn interior_room_light(P: vec3, N: vec3) -> vec3 {
+// MONKEY (soft falloff): the interior pool's PROFILE — **keep this whole block byte-identical
+// between wow_model.wgsl and static_gx.wgsl.**
+//
+// The colour row's `.w` packs each fixture's EFFECTIVE RADIUS `R`: its authored MOLT
+// `attenuation_end` (an M2 source buckets by intensity instead) already multiplied by the live
+// `interiorAttenScale` (default 1.6). MONKEY (pool energy): the EXTENT terms — the direct window
+// and the fill span — are still written as fractions of `R`, so the cvar stays a pure zoom on how
+// far every pool REACHES; the core is not, so it is no longer also a brightness dial.
+//
+// The RETIRED profile was the reference's `1/(0.7d + 0.03d²)` windowed by
+// `1 − smoothstep(0.65R, R, d)` at `R` = the authored reach. That window closed a still-BRIGHT
+// curve — 0.28 of its 1 yd value at 0.65R — over the last 35 % of the radius, i.e. a bright disc
+// with a visible rim and black beyond it. That is the abbey candelabra ring. Three changes replace
+// it, and the ring cannot come back because the curve is already dim wherever the window bites:
+// · CORE — `1/(1 + (d/r0)²)`: inverse square with a soft core. MONKEY (pool energy): `r0` is a
+// CONSTANT `INTERIOR_CORE_YD` **yards**, not a fraction of `R` any more. It was `0.26·R`, and
+// that tied a fixture's BRIGHTNESS to its REACH — the pool's total ENERGY grew as `R²`. The
+// Goldshire inn's `R ≈ 11-15` fixtures therefore ran a 2.1× (at 3 yd) to 2.9× (at 5 yd) hotter
+// curve than Northshire abbey's `R ≈ 7`; ten of them overlap in one room, the
+// `1 − exp(−x·exposure)` rolloff saturated, and the inn read as one flat yellow while the
+// abbey — whose pools the owner signed off — sat exactly where it should. Reach must BOUND a
+// pool, not fuel it. So the energy is now one curve for every fixture and only the WINDOW and
+// the fill span still scale with `R`: two fixtures of different reach read IDENTICALLY until
+// the shorter one's window closes. `1.75 yd` is what the abbey already had (`0.26 × 6.7` — its
+// median authored end 4.2 at the default `interiorAttenScale` 1.6), so the under-candle level
+// the exposure was tuned against is preserved to within ±10 % across the abbey's own reach
+// spread (−5 % at its median fixture) and nothing about the approved look moves.
+// · GAIN — `INTERIOR_CORE_GAIN` normalises the core against the retired hyperbolic's 1 yd value,
+// `1/(0.7 + 0.03) = 1.3699`. With a CONSTANT `r0` that normalisation is the same for every
+// fixture instead of holding only at the median reach, which is the whole point; it lands at
+// `1.5/(1 + (1/1.75)²) = 1.1308`, i.e. 0.83 × the hyperbolic — the abbey's own current level.
+// (Gain 1.817 would restore 1.3699 exactly; that is a 21 % brighter room than the approved
+// one, so it is deliberately NOT taken. Change the GAIN, never the core radius, to re-level.)
+// · WINDOW — `(clamp01(1 − (d/R)^p))²`, the UE4-style windowing: value AND derivative both vanish
+// at `d = R` (C1, so no rim), ≈1 over most of the radius, and it only ever removes energy the
+// curve has already lost.
+// MONKEY (pool energy): the direct term (before the wrapped Lambert) at d = 1 / 3 / 5 / 8 / 12 yd,
+// abbey-class `R = 7` beside inn-class `R = 13` —
+// was R=7 1.152 / 0.403 / 0.164 / 0 / 0 R=13 1.379 / 0.839 / 0.470 / 0.224 / 0.033
+// now R=7 1.131 / 0.381 / 0.153 / 0 / 0 R=13 1.131 / 0.381 / 0.164 / 0.067 / 0.009
+// The two "now" rows are the SAME curve until R=7's window bites (where it is already down to
+// 0.15); R=13 only keeps a longer, dimmer TAIL, because its window is wider. That is the fix.
+//
+// FILL keeps its EXACT form (`(1 − d/r)²`, which is `interior_window(d, r, 1.0)`) and only widens:
+// its radius is `INTERIOR_FILL_SPAN·R`, so the floor BETWEEN two pools takes a gentle wash instead
+// of the bare ambient floor. Its value at the fixture is still 1, so `interiorFill` keeps the
+// meaning it was tuned with.
+const INTERIOR_CORE_YD: f32 = 1.75;
+const INTERIOR_CORE_GAIN: f32 = 1.5;
+const INTERIOR_DIRECT_POW: f32 = 10.0;
+const INTERIOR_FILL_SPAN: f32 = 1.5;
+const INTERIOR_FILL_POW: f32 = 1.0;
+// `(clamp01(1 − (d/r)^p))²`. The inner clamp keeps `pow` off a negative base; `max(r, …)` keeps it
+// off a zero divisor (the packer floors R at 1.0, but a shader must not lean on a producer's
+// invariant). `clamp(…, 0, 1)` rather than `saturate` — nothing else in this shader set uses
+// `saturate`, and the two are the same instruction.
+fn interior_window(d: f32, r: f32, p: f32) -> f32 {
+ let w = clamp(1.0 - pow(clamp(d / max(r, 1e-4), 0.0, 1.0), p), 0.0, 1.0);
+ return w * w;
+}
+// MONKEY (room gate): the ENTITY/prop copy has no claim-table binding and no per-fragment room
+// key (`static_gx.wgsl` owns both — the claim table hangs off its own group 2), so every fixture is
+// admitted here, exactly as before this change. Follow-up: an entity already knows the room it
+// STANDS in (the interior classifier's down-ray, `carried_light`'s `WmoGroupVis::single`), so the
+// same gate could ride a per-instance key pushed through the model material.
+fn interior_room_admits(i: u32, room_inst: u32, room_group: u32) -> bool {
+ return true;
+}
+fn interior_room_light(P: vec3, N: vec3, room_inst: u32, room_group: u32) -> vec3 {
let count = u32(wow_light.point_count.x);
let k_fill = wow_light.point_count.z;
var direct = vec3(0.0);
var fill = vec3(0.0);
for (var i = 0u; i < count; i = i + 1u) {
+ let color_lane = wow_light.points[2u * i + 1u];
+ // MONKEY (light lanes): only a fixture that CLAIMS a room lights this room (keep in sync
+ // with static_gx.wgsl). `.w` is 0 on every exterior source, so a campfire burning outside
+ // the door stops reaching the floor inside it.
+ if (color_lane.w < 0.5) {
+ continue;
+ }
+ // MONKEY (room gate): and only a fixture that claims THIS ROOM lights this room's surfaces.
+ // Before this, the whole INT half of the table lit every interior fragment of every
+ // building in range and the only occlusion was the <=6 promoted cube-shadow casters — so an
+ // inn's ground-floor candles lit its basement THROUGH the floor, and an upstairs corridor
+ // wall glowed from the fixture in the room behind it. Tested here, before the distance
+ // test, because it is the term that throws away the most: a room claims one or two of the
+ // table's fixtures, not all of them.
+ if (!interior_room_admits(i, room_inst, room_group)) {
+ continue;
+ }
+ // MONKEY (soft falloff): `.w` is ALSO this fixture's EFFECTIVE RADIUS `R` in yards — the
+ // authored MOLT `attenuation_end` (or the M2 intensity bucket) already multiplied by the
+ // live `interiorAttenScale` at pack time. It replaces the flat 48 yd candidacy radius in
+ // `pos_range.w` on this lane, which is why a 10-candle inn read as one uniform wash.
+ let reach_yd = color_lane.w;
+ // Candidacy is the FILL radius, not R: the wash reaches further than the direct pool (see
+ // the profile block), and rejecting at R would cut it off exactly at the pool's own edge —
+ // putting the rim back one term down.
+ let fill_yd = INTERIOR_FILL_SPAN * reach_yd;
let pos_range = wow_light.points[2u * i];
let to_light = pos_range.xyz - P;
let d2 = dot(to_light, to_light);
- if (d2 > pos_range.w * pos_range.w) {
+ if (d2 > fill_yd * fill_yd) {
continue;
}
let d = sqrt(d2);
- let c = wow_light.points[2u * i + 1u].rgb;
+ let c = color_lane.rgb;
// Normalised for BOTH terms (keep in sync with static_gx.wgsl): the table commits RAW
// over-gamut colour × intensity; a hot forge would wash the direct term to white.
let c_norm = c / max(1.0, max(c.r, max(c.g, c.b)));
- let atten = 1.0 / (0.7 * d + 0.03 * d2);
+ // MONKEY (soft falloff): inverse square with the authored-start soft core, normalised so
+ // the 1 yd value is the retired hyperbolic's (profile block above).
+ // MONKEY (pool energy): a CONSTANT core radius in yards (profile block above) — the reach
+ // no longer scales the pool's brightness, only its window. `max` keeps the divide honest
+ // if the constant is ever tuned toward 0.
+ let r0 = max(INTERIOR_CORE_YD, 1e-3);
+ let atten = INTERIOR_CORE_GAIN / (1.0 + (d / r0) * (d / r0));
+ let window = interior_window(d, reach_yd, INTERIOR_DIRECT_POW);
let nl = max(
(dot(N, to_light / max(d, 1e-4)) + INTERIOR_WRAP) / (1.0 + INTERIOR_WRAP),
0.0,
@@ -427,12 +569,16 @@ fn interior_room_light(P: vec3, N: vec3) -> vec3 {
// material's group-2 torch bindings — a pillar between an NPC and torch A darkens A's
// term without touching torch B's, exactly as on the surfaces around it.
let s = torch_entity_shadow(pos_range.xyz, P, N);
- direct += c_norm * (atten * nl) * s;
- let reach = 1.0 - d / max(pos_range.w, 1e-4);
+ direct += c_norm * (atten * nl * window) * s;
+ // MONKEY (soft falloff): the fill's profile is UNCHANGED in FORM — `(1 − d/r)²`, which is
+ // exactly `interior_window(d, r, 1.0)` — and only its radius moved, from R to
+ // `INTERIOR_FILL_SPAN·R`. That is the whole "gentle wash between the pools": at the fixture
+ // it is still 1 (so `interiorFill` keeps its tuned meaning) and it decays to 0 at 2R with a
+ // vanishing derivative, so the floor half-way between two candles is dim, not black.
let c_fill = mix(c_norm, vec3(dot(c_norm, vec3(0.299, 0.587, 0.114))), 0.5);
// DOMINANT fixture, not the sum (keep in sync with static_gx.wgsl): fill must not scale
// with candle count or a dense room saturates flat. Fill is indirect, so not shadowed.
- fill = max(fill, c_fill * (k_fill * reach * reach));
+ fill = max(fill, c_fill * (k_fill * interior_window(d, fill_yd, INTERIOR_FILL_POW)));
}
return direct + fill + vec3(wow_light.point_count.y);
}
@@ -451,6 +597,61 @@ fn mcnk_cell_anchor(P: vec3) -> vec3 {
return vec3((ix + 0.5) * cell - half, P.y, (iz + 0.5) * cell - half);
}
+// MONKEY (wmo exterior points): the EXTERIOR-lane point term for a WMO's OUTDOOR-class surfaces —
+// MIRRORED VERBATIM from `static_gx.wgsl` (`wmo_exterior_point_sum`; that file owns the full
+// rationale — keep the two bodies identical). This copy exists because the retained WMO collector
+// declines a small minority of batches (env-mapped, depth-flag oddities) and they fall through to
+// this entity pipeline: a Trade District street lit on one pipeline and black on the other is
+// worse than either. It reuses this file's OWN `mcnk_cell_anchor` (identical constants to
+// static_gx's private copy) — a WMO batch's anchor is its PLACEMENT origin, one point for the whole
+// of Stormwind, so ranking from it would commit the same three lights to every street in the city;
+// the 33.33 yd MCNK cell is the unit terrain ranks by, so a street and the road it runs into rank
+// the SAME candidates and agree at the seam. Candidacy is terrain's Chebyshev box, not this file's
+// 48 yd sphere, for that same agreement reason.
+const WMO_EXT_REACH: f32 = 33.570166;
+fn wmo_exterior_point_sum(P: vec3, N: vec3) -> vec3 {
+ let anchor = mcnk_cell_anchor(P);
+ let count = u32(wow_light.point_count.x);
+ var sel = array(0u, 0u, 0u);
+ var sd = array(1e30, 1e30, 1e30);
+ for (var i = 0u; i < count; i = i + 1u) {
+ // Exterior lane only: an interior fixture's `.w` is its reach (≥ 1), an exterior source's
+ // is 0. A building's own candles must not pool on the street outside its wall.
+ if (wow_light.points[2u * i + 1u].w > 0.5) {
+ continue;
+ }
+ let dv = wow_light.points[2u * i].xyz - anchor;
+ // Terrain's hash sweep is horizontal and unbounded vertically; the ranking below is the
+ // full 3-D distance, as `0x71bf90` does.
+ if (max(abs(dv.x), abs(dv.z)) > WMO_EXT_REACH) {
+ continue;
+ }
+ let d2 = dot(dv, dv);
+ if (d2 < sd[0]) {
+ sd[2] = sd[1]; sel[2] = sel[1];
+ sd[1] = sd[0]; sel[1] = sel[0];
+ sd[0] = d2; sel[0] = i;
+ } else if (d2 < sd[1]) {
+ sd[2] = sd[1]; sel[2] = sel[1];
+ sd[1] = d2; sel[1] = i;
+ } else if (d2 < sd[2]) {
+ sd[2] = d2; sel[2] = i;
+ }
+ }
+ var sum = vec3(0.0);
+ for (var s = 0u; s < 3u; s = s + 1u) {
+ if (sd[s] > 9.9e29) {
+ break;
+ }
+ let to_light = wow_light.points[2u * sel[s]].xyz - P;
+ let d = length(to_light);
+ let atten = 1.0 / (0.7 * d + 0.03 * d * d);
+ let nl = max(dot(N, to_light / max(d, 1e-4)), 0.0);
+ sum += wow_light.points[2u * sel[s] + 1u].rgb * (atten * nl);
+ }
+ return sum;
+}
+
// The vertex input — bevy 0.18's `forward_io::Vertex` fields at bevy's shader locations (the
// VERTEX_* defs come from the base mesh-pipeline specialize, driven by what the mesh authors;
// tangents / morphs never — no model mesh has them), PLUS the owned-palette joint attributes at
@@ -705,14 +906,24 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
#endif
// Dynamic point lights, by receiver class (wow-re trace-forensics-abbey-interior-d3d §2/§4):
- // WMO surfaces take NONE (zero point lights on every observed WMO surface batch — the earlier
- // "point-lit abbey wall" was a mis-identified unit draw), and interior M2 props take none HERE
- // (their group-MOLR point lobes are folded into the per-instance SH probe). Everything else —
+ // INTERIOR WMO surfaces take NONE (zero point lights on every observed WMO surface batch —
+ // the earlier "point-lit abbey wall" was a mis-identified unit draw; they light per FRAGMENT
+ // instead, `interior_room_light`), and interior M2 props take none HERE (their group-MOLR
+ // point lobes are folded into the per-instance SH probe). Everything else —
// exterior doodads, entities, clutter — keeps the FFP ≤3-nearest selection: clutter (merged in
// world space) anchors at its MCNK chunk cell (the terrain draw unit it belongs to), every M2
// at its INSTANCE origin (wow-re wmo-surface-dynamic-light §6 — the receiving unit's own
// position, deliberately not the skinned per-vertex matrix).
- if (m.model_flags.x > 0.5 || m.model_flags.z > 0.5) {
+ if (m.model_flags.x > 0.5 && m.model_flags.z < 0.5) {
+ // MONKEY (wmo exterior points): an EXTERIOR-class WMO group (`model_flags.z` clear = MOGP
+ // `& 0x48` set) is a street, a courtyard, a porch — drawn by the exterior law, the same law
+ // the terrain beside it is drawn by, so it takes the exterior point term terrain takes. The
+ // §2 "zero on every WMO surface" finding was measured on abbey INTERIOR rooms and reading
+ // it as a blanket zero is what left Stormwind's Trade District torches lighting nothing but
+ // the NPC beside them. Mirrors `static_gx.wgsl`'s branch (which draws the vast majority of
+ // these batches) — keep the two in step.
+ out.point_lit = wmo_exterior_point_sum(out.world_position.xyz, out.world_normal);
+ } else if (m.model_flags.x > 0.5 || m.model_flags.z > 0.5) {
out.point_lit = vec3(0.0);
} else {
var anchor = mesh_world_from_local[3].xyz;
@@ -1180,9 +1391,12 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
let sidn_e = m.sidn.rgb * (wow_light.grade.x * sidn_w);
// WoW dynamic point lights (decisions 0016/0273/0278, selection 0285) — exterior doodads,
// entities, clutter, and terrain receive their unit's committed lights: the ≤3 NEAREST to the
- // receiving unit's own position, never the whole scene (`point_light_sum`). WMO surfaces take
- // ZERO (observed on every WMO surface batch in the abbey capture) and interior props fold their
- // group-MOLR lobes into the SH probe instead — both zeroed in the VERTEX stage. The term
+ // receiving unit's own position, never the whole scene (`point_light_sum`). INTERIOR WMO
+ // surfaces take ZERO (observed on every WMO surface batch in the abbey capture — an interior
+ // capture, hence the MONKEY split above) and interior props fold their group-MOLR lobes into
+ // the SH probe instead — both zeroed in the VERTEX stage; an EXTERIOR-class WMO group takes
+ // the exterior-lane term (`wmo_exterior_point_sum`) there instead, ranked from its MCNK cell
+ // like the terrain it adjoins, which is what puts a street torch on the cobbles. The term
// arrives GOURAUD-INTERPOLATED — per-vertex like the reference FFP, whose tessellation-scale
// smoothing is the authored look. Diffuse-only (committed ambient/specular are zero).
let point_diffuse = in.point_lit;
@@ -1277,8 +1491,25 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// in exterior material mode: in interior mode those bits are the probe slot.
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);
- var room_rgb = albedo * inst_tint * (vec3(1.0) - exp(-room * wow_light.point_count.w));
+ let room = interior_room_light(in.world_position.xyz, n_lit, 0u, 0u);
+ // MONKEY (indoor highlight): the hover/target emissive (`highlight`, tag bit 31) rides THIS
+ // lane's light sum too. It is the same GL_EMISSION placement as the exterior branches above
+ // — added to the material's ambient+diffuse product INSIDE the [0,1] saturate, with the
+ // texture (`albedo`) modulating the clamped result — so an indoor chair lifts by exactly the
+ // +64/255 an outdoor one does. It has to be folded HERE, not left in `lit_rgb`, because this
+ // lane REPLACES the exterior result a few lines down (`mix(lit_rgb, room_rgb, lane_w)`):
+ // with `lane_w` at 1 (a settled indoor unit) the exterior sum that carried the lift was
+ // discarded wholesale, which is why hovering a chair inside a Stormwind house brightened
+ // nothing at all while the same chair on the street did.
+ // The clamp is a no-op when nothing is hovered (`highlight` 0, and both factors are already
+ // ≤1), so the un-hovered indoor look is bit-identical to before.
+ // Placed BEFORE the debug branch on purpose: modes 1/2/3 all discard `room_rgb` and return
+ // their own diagnostic colour, so `interiorDebug` is untouched by this.
+ var room_rgb = albedo * clamp(
+ inst_tint * (vec3(1.0) - exp(-room * wow_light.point_count.w)) + vec3(highlight),
+ vec3(0.0),
+ vec3(1.0),
+ );
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).
diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs
index 732c43d1..da484352 100644
--- a/crates/benilla-formats/src/bin/benilla-extract/main.rs
+++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs
@@ -522,6 +522,15 @@ enum Command {
/// models if omitted.
prefix: Option,
},
+ /// MONKEY (fire GO lights): the twin of `m2lightscan` for the models that author NO light —
+ /// which ones would take a **synthesised** one from their flame particle emitter, and what
+ /// colour/intensity the heuristic derives (`benilla_formats::fire_light`). The offline audit
+ /// of a rule that is applied to ~430 fire props at load, none of which the reference lights.
+ M2firescan {
+ /// Internal-path prefix filter (e.g. `world\generic`), case-insensitive; all models if
+ /// omitted.
+ prefix: Option,
+ },
/// Sweep every WMO ROOT and cross-tab the two halves of the skybox mechanism: the root's
/// **MOSB** skybox model against its groups' `0x40000` flag. `0x40000` is undocumented, so this
/// is what *identifies* it — across all 815 roots the bit never appears without a MOSB. Note
@@ -627,6 +636,26 @@ enum Command {
/// Case-insensitive substring of the prop's model path (e.g. `lightray`); all if omitted.
filter: Option,
},
+ /// MONKEY (interior attenuation): dump one WMO root's **MOLT fixture table** — type,
+ /// `useAtten`, colour × intensity, the authored attenuation start/end (yd), model-space
+ /// position, and the groups whose MOLR names each fixture (its rooms). Closes with what our
+ /// fixed falloff `1/(0.7d + 0.03d²)` is still worth at each fixture's own authored end: the
+ /// number behind "why does a 20-candle room read uniform".
+ Wmolights {
+ /// Internal path to the WMO **root** (forward or back slashes accepted).
+ internal_path: String,
+ },
+ /// MONKEY (interior prop lights): sweep every WMO **root** and audit whether its rooms have
+ /// any light at all — MOLT omni fixtures, MODD props that would SYNTHESISE one (the
+ /// `fire_light` flame/lamp routes), how many of those the 2.5 yd MOLT dedupe drops, and how
+ /// many INTERIOR groups no source claims, before and after the prop lane is admitted indoors.
+ Wmolamps {
+ /// Only roots whose internal path starts with this (e.g. `world\wmo\azeroth`).
+ prefix: Option,
+ /// Also name the still-unlit interior groups of every root whose path contains this.
+ #[arg(long)]
+ detail: Option,
+ },
/// Sweep every WMO **root** (optionally under a path prefix) and list the placed MODD props
/// the INTERIOR lighting lane commits as **literal black**. That lane's entire base light is
/// the MODD entry's own baked colour (ambient `cap96`, diffuse `floor112` — `0x694e90` →
@@ -870,6 +899,7 @@ fn main() -> Result<()> {
scan::partscan(&mut chain, mask, prefix.as_deref())?;
}
Command::M2lightscan { prefix } => scan::m2lightscan(&mut chain, prefix.as_deref())?,
+ Command::M2firescan { prefix } => scan::m2firescan(&mut chain, prefix.as_deref())?,
Command::Skyboxscan => scan::skyboxscan(&mut chain)?,
Command::Lightbands {
map,
@@ -910,6 +940,10 @@ fn main() -> Result<()> {
internal_path,
filter,
} => scan::wmodoodads(&mut chain, &internal_path, filter.as_deref())?,
+ Command::Wmolights { internal_path } => scan::wmolights(&mut chain, &internal_path)?,
+ Command::Wmolamps { prefix, detail } => {
+ scan::wmolamps(&mut chain, prefix.as_deref(), detail.as_deref())?
+ }
Command::Darkpropscan { prefix } => scan::darkpropscan(&mut chain, prefix.as_deref())?,
Command::Placescan {
map,
diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs
index 6d0bb77d..6f5fd156 100644
--- a/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs
+++ b/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs
@@ -2,8 +2,10 @@
//! lanes, on both sides of the WMO/M2 boundary.
//!
//! `darkpropscan` asks which placed WMO props the interior lane commits as literal black
-//! (decision 0969's census), `m2lightscan` which M2s author dynamic light blocks at all, and
-//! `shadeat` reads the terrain MCSH shadow bit that decides a doodad's sun gain.
+//! (decision 0969's census), `m2lightscan` which M2s author dynamic light blocks at all,
+//! `m2firescan` which of the ones that DON'T would have a light SYNTHESISED from their flame
+//! emitter (MONKEY, fire GO lights), and `shadeat` reads the terrain MCSH shadow bit that decides a
+//! doodad's sun gain.
use std::collections::BTreeMap;
@@ -313,6 +315,167 @@ pub fn m2lightscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
Ok(())
}
+/// MONKEY (fire GO lights) / MONKEY (lamp lights): sweep every `.m2` (optionally under a prefix)
+/// and report which models would take a **synthesised** light, and BY WHICH ROUTE — the offline
+/// audit of [`benilla_formats::fire_light`]'s two heuristics, which is the only way to see what a
+/// rule applied to ~4000 world props actually does before shipping it.
+///
+/// The three routes, in the order the bake tries them:
+/// - `flame` — a flame PARTICLE EMITTER. Per hit: the winning emitter's index/texture, the colour
+/// read off its over-life ramp, the size bucket and the emitter's model-local position.
+/// - `name` — the model's basename is lamp/lantern/chandelier/sconce vocabulary.
+/// - `emissive` — no name hit, but an UNLIT + transparent + glow-textured render batch.
+///
+/// The lamp routes print `pos` as the emissive geoset's CENTROID and `+Zbase` as its height above
+/// the model's own bounding-box floor — the number the whole lamp feature turns on, because a light
+/// at the model origin sits at the foot of the pole. `BBOX` marks a hit that found no unlit
+/// geometry and fell back to the bounding-box top centre.
+///
+/// Models that ALREADY author a casting point light are counted separately and skipped — the
+/// authored block always wins, and the populations must never be conflated. The closing summary is
+/// the deliverable: per-route totals, buckets, per-family counts, and a colour tally so an
+/// over-broad texture key shows up as a flood of one hue.
+pub fn m2firescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
+ let names = super::m2_names(chain, prefix)?;
+ let (mut scanned, mut authored, mut hits) = (0u32, 0u32, 0u32);
+ let mut buckets: BTreeMap<&'static str, u32> = BTreeMap::new();
+ // MONKEY (lamp lights): which of the three detection routes fired, per model.
+ let mut routes: BTreeMap<&'static str, u32> = BTreeMap::new();
+ let mut families: BTreeMap = BTreeMap::new();
+ // Rounded `(r, g, b) × 100` -> (count, one example).
+ let mut color_tally: BTreeMap<(i32, i32, i32), (u32, String)> = BTreeMap::new();
+ let mut rows: Vec = Vec::new();
+
+ for name in names {
+ let Ok(bytes) = chain.read_file(&name) else {
+ continue;
+ };
+ scanned += 1;
+ // The authored block wins entirely — same gate the asset bake applies.
+ if benilla_formats::parse_m2_lights(&bytes).iter().any(|l| l.casts()) {
+ authored += 1;
+ continue;
+ }
+ let emitters = benilla_formats::parse_m2_particle_emitters(&bytes).unwrap_or_default();
+ // MONKEY (lamp lights): route 1 is the flame emitter; routes 2/3 read the model's RENDER
+ // BATCHES, so the submesh parse only runs where the flame route found nothing (which is
+ // most of the corpus, but the parse is the same one the asset bake does anyway).
+ let (route, color, intensity, bucket, detail) = match
+ benilla_formats::synthesize_fire_light(&name, &emitters)
+ {
+ Some(fire) => {
+ let e = &emitters[fire.emitter];
+ let detail = format!(
+ "E{} {:<44} size {:.3} rate {:.1} strength {:.3} pos ({:.2}, {:.2}, {:.2}) bone {}",
+ fire.emitter,
+ e.texture.as_deref().unwrap_or("-"),
+ benilla_formats::fire_light::peak_size(e),
+ e.timing.peak_rate(),
+ benilla_formats::fire_light::fire_strength(e),
+ e.position[0], e.position[1], e.position[2],
+ e.bone,
+ );
+ (
+ benilla_formats::LightRoute::Flame,
+ fire.color,
+ fire.intensity,
+ fire.bucket,
+ detail,
+ )
+ }
+ None => {
+ let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, "", &[]) else {
+ continue;
+ };
+ let batches: Vec> =
+ subs.iter().map(benilla_formats::EmissiveBatch::from).collect();
+ let bounds = benilla_formats::parse_m2_bounds(&bytes).ok();
+ let bbox = bounds.as_ref().map(|b| (b.bbox_min, b.bbox_max));
+ let Some(lamp) = benilla_formats::synthesize_lamp_light(&name, &batches, bbox)
+ else {
+ continue;
+ };
+ // How high the light ended up above the model's own floor — the whole point of
+ // reading the geoset instead of using the origin. A lamppost must read ~4-5 yd.
+ let base = bbox.map_or(0.0, |(lo, _)| lo[2]);
+ let detail = format!(
+ "{:<6} {:<44} pos ({:.2}, {:.2}, {:.2}) +Zbase {:.2} bone {}",
+ match lamp.batch {
+ Some(i) => format!("B{i}"),
+ None => "BBOX".to_string(),
+ },
+ lamp.batch
+ .and_then(|i| subs[i].texture.as_deref())
+ .unwrap_or("(bbox top centre)"),
+ lamp.position[0], lamp.position[1], lamp.position[2],
+ lamp.position[2] - base,
+ lamp.bone,
+ );
+ (lamp.route, lamp.color, lamp.intensity, lamp.bucket, detail)
+ }
+ };
+ hits += 1;
+ *routes.entry(route.label()).or_default() += 1;
+ *buckets.entry(bucket).or_default() += 1;
+ *families.entry(super::family_of(&name)).or_default() += 1;
+ let key = (
+ (color[0] * 100.0).round() as i32,
+ (color[1] * 100.0).round() as i32,
+ (color[2] * 100.0).round() as i32,
+ );
+ color_tally.entry(key).or_insert_with(|| (0, name.clone())).0 += 1;
+ rows.push(format!(
+ "{name}\n [{:<8}] rgb ({:.3}, {:.3}, {:.3}) {:<11} x{:.2} {detail}",
+ route.label(),
+ color[0], color[1], color[2],
+ bucket, intensity,
+ ));
+ }
+
+ rows.sort();
+ for r in &rows {
+ println!("{r}");
+ }
+ println!();
+ println!(
+ "=== summary === {scanned} models scanned, {authored} already author a casting point light (skipped), {hits} would SYNTHESISE one"
+ );
+ println!();
+ println!("=== by detection route ===");
+ for (r, n) in &routes {
+ println!("{r:<10} {n:>4}");
+ }
+ println!();
+ println!("=== by intensity bucket ===");
+ for (b, n) in &buckets {
+ println!("{b:<12} {n:>4}");
+ }
+ println!();
+ println!("=== by content family ===");
+ for (f, n) in &families {
+ println!("{f:<32} {n:>4}");
+ }
+ println!();
+ println!("=== derived colour tally (peak-normalised, rounded to 0.01) ===");
+ let mut ranked: Vec<_> = color_tally.iter().collect();
+ ranked.sort_by_key(|(_, (count, _))| std::cmp::Reverse(*count));
+ for (key, (count, example)) in ranked.iter().take(TALLY_ROWS) {
+ let (r, g, b) = (
+ key.0 as f32 / 100.0,
+ key.1 as f32 / 100.0,
+ key.2 as f32 / 100.0,
+ );
+ println!(
+ "{count:>4}x ({r:.2}, {g:.2}, {b:.2}) {:<5} e.g. {example}",
+ hue_tag(r, g, b)
+ );
+ }
+ if let Some(rest) = ranked.len().checked_sub(TALLY_ROWS).filter(|n| *n > 0) {
+ println!(" … and {rest} rarer colours (top {TALLY_ROWS} shown)");
+ }
+ Ok(())
+}
+
/// The terrain MCSH shadow bit at a world position + an ASCII texel neighborhood (`#` shadowed,
/// `.` lit, `?` off-tile/no-chunk). One MCSH texel is `TILE_SIZE/1024` ≈ 0.52 yd; the grid spans
/// ±8 texels so a doodad base sitting one texel from a shadow edge — the 2.5-vs-0.5 intensity
@@ -354,3 +517,519 @@ pub fn shadeat(chain: &mut Chain, map: &str, x: f32, y: f32) -> Result<()> {
}
Ok(())
}
+
+/// MONKEY (interior attenuation): dump ONE WMO root's **MOLT fixture table** — the authored
+/// numbers the interior lane is calibrated against, which nothing could read before this.
+///
+/// Per fixture: type, the `useAtten` byte, the raw colour, the intensity, the product the packer
+/// commits (`colour × intensity`, [`benilla_world::lighting::commit_raw`]'s input), the authored
+/// attenuation **start/end** in yards (`+0x28`/`+0x2c` — see `read_wmo_light`), model-space
+/// position, and the groups whose MOLR names it (its ROOMS: a fixture no group names keeps none
+/// and stays ungated). The closing block is the number the flatness question actually needs: for
+/// each omni fixture, the value of our fixed falloff `1/(0.7d + 0.03d²)` at its own authored end
+/// — i.e. how much reach the curve still has where the artist said the light stops.
+/// MONKEY (portal claims): everything ONE WMO root's room-claim rule needs, read off the chain in a
+/// single group-file pass — the offline twin of what `benilla_world`'s spawner holds in the loaded
+/// `WmoModel`. Both feed [`benilla_formats::room_claims`], so the claim set this tool prints IS the
+/// claim set the shader will gate on.
+struct RootRooms {
+ infos: Vec,
+ /// Per absolute group: its MOGP flags and its `(portal_ref_start, portal_ref_count)` slice.
+ flags: Vec,
+ slices: Vec<(u16, u16)>,
+ portals: benilla_formats::WmoPortals,
+ /// Per absolute group: the MOLR light indices it names.
+ light_refs: Vec>,
+ /// MODD index -> the groups whose MODR names it (the prop's rooms).
+ doodad_groups: BTreeMap>,
+}
+
+impl RootRooms {
+ fn graph(&self) -> benilla_formats::PortalGraph<'_> {
+ benilla_formats::PortalGraph {
+ vertices: &self.portals.vertices,
+ infos: &self.portals.infos,
+ refs: &self.portals.refs,
+ slices: &self.slices,
+ }
+ }
+
+ /// The groups whose MOLR names light `i` — the claim rule's MOLR input.
+ fn molr(&self, i: u16) -> Vec {
+ self.light_refs
+ .iter()
+ .enumerate()
+ .filter(|(_, refs)| refs.contains(&i))
+ .map(|(g, _)| g as u16)
+ .collect()
+ }
+}
+
+fn root_rooms(
+ chain: &mut Chain,
+ root: &benilla_formats::WmoRoot,
+ root_path: &str,
+ bytes: &[u8],
+) -> RootRooms {
+ let stem = root_path
+ .to_ascii_lowercase()
+ .strip_suffix(".wmo")
+ .unwrap_or(root_path)
+ .to_string();
+ let n = root.group_count();
+ let (mut flags, mut slices, mut light_refs) = (Vec::new(), Vec::new(), Vec::new());
+ let mut doodad_groups: BTreeMap> = BTreeMap::new();
+ for gi in 0..n {
+ let gbytes = chain.read_file(&format!("{stem}_{gi:03}.wmo")).ok();
+ let h = gbytes
+ .as_deref()
+ .and_then(benilla_formats::wmo_group_header);
+ flags.push(h.as_ref().map_or(0, |h| h.flags));
+ slices.push(h.map_or((0, 0), |h| (h.portal_ref_start, h.portal_ref_count)));
+ light_refs.push(
+ gbytes
+ .as_deref()
+ .map(benilla_formats::wmo_group_light_refs)
+ .unwrap_or_default(),
+ );
+ for r in gbytes
+ .as_deref()
+ .map(benilla_formats::wmo_group_doodad_refs)
+ .unwrap_or_default()
+ {
+ let e = doodad_groups.entry(r).or_default();
+ if e.last() != Some(&(gi as u16)) {
+ e.push(gi as u16);
+ }
+ }
+ }
+ RootRooms {
+ infos: root.group_infos().to_vec(),
+ flags,
+ slices,
+ portals: benilla_formats::parse_wmo_portals(bytes),
+ light_refs,
+ doodad_groups,
+ }
+}
+
+/// One claim set as a compact cell: `g4e:in g7i:portal1 3.2/w5.0`. A trailing `*` marks a claim the
+/// EXTERIOR batch lane will NOT honour (a district-scale shell — `room_claim::CLAIM_EXT_SHELL_YD`);
+/// it still gates the fixture, it just cannot light a whole city block.
+///
+/// MONKEY (soft portal claims): a portal claim prints
+/// `portal /w+x` — the four numbers the
+/// shader's weight is built from (`w = entry * (1 - smoothstep(0, radius, |P - door| - slack))`),
+/// so the instrument can be read against a screenshot of the seam. A base claim prints no numbers:
+/// it is HARD, weight 1 across the whole group.
+fn claim_cell(claims: &[benilla_formats::Claim]) -> String {
+ if claims.is_empty() {
+ return "- (UNGATED: no room claims it)".to_string();
+ }
+ claims
+ .iter()
+ .map(|c| {
+ let (hop, d) = if c.how == benilla_formats::ClaimHow::Portal {
+ (
+ c.hops.to_string(),
+ format!(
+ " {:.1}/w{:.1}+{:.1}x{:.2}",
+ c.distance, c.fade_radius, c.fade_slack, c.fade_entry,
+ ),
+ )
+ } else {
+ (String::new(), String::new())
+ };
+ format!(
+ "g{}{}:{}{hop}{d}{}",
+ c.group,
+ if c.interior { "i" } else { "e" },
+ c.how.tag(),
+ if c.ext_ok { "" } else { "*" },
+ )
+ })
+ .collect::>()
+ .join(" ")
+}
+
+pub fn wmolights(chain: &mut Chain, raw_path: &str) -> Result<()> {
+ let root_path = raw_path.replace('/', "\\").to_ascii_lowercase();
+ let bytes = chain
+ .read_file(&root_path)
+ .with_context(|| format!("reading WMO root '{root_path}'"))?;
+ let root = benilla_formats::parse_wmo_root(&bytes)
+ .with_context(|| format!("parsing WMO root '{root_path}'"))?;
+ let lights = benilla_formats::parse_wmo_lights(&bytes);
+ // MONKEY (portal claims): the group table + the portal graph, read once — the same inputs the
+ // spawner feeds `room_claims`, so what prints below is what the room gate will enforce.
+ let rr = root_rooms(chain, &root, &root_path, &bytes);
+ let infos = root.group_infos();
+
+ println!(
+ "{root_path} — {} MOLT light(s), {} group(s), {} portal(s)",
+ lights.len(),
+ root.group_count(),
+ rr.portals.infos.len(),
+ );
+ // The group table: the class the whole lighting split turns on. An `e` group INSIDE a building
+ // (an inn's basement stairwell, a covered porch) is drawn by the EXTERIOR law and takes the
+ // night sky unless an interior fixture claims it — that is bug B's second cause, and this is
+ // where you read which groups are in that population.
+ println!("=== groups (class, MOGP flags, MOGI box, portals, MOLR fixtures) ===");
+ for gi in 0..root.group_count() as usize {
+ let Some(g) = infos.get(gi) else { continue };
+ let (_, pc) = rr.slices.get(gi).copied().unwrap_or((0, 0));
+ println!(
+ " g{gi:<3} {} flags {:#010x} box ({:>8.2},{:>8.2},{:>7.2})..({:>8.2},{:>8.2},{:>7.2}) portals {pc:<3} molr {:?}",
+ if g.interior { "INT" } else { "ext" },
+ rr.flags.get(gi).copied().unwrap_or(0),
+ g.bbox_min[0], g.bbox_min[1], g.bbox_min[2],
+ g.bbox_max[0], g.bbox_max[1], g.bbox_max[2],
+ rr.light_refs.get(gi).map(|v| v.len()).unwrap_or(0),
+ );
+ }
+ println!();
+ for (i, l) in lights.iter().enumerate() {
+ let prod = [
+ l.color[0] * l.intensity,
+ l.color[1] * l.intensity,
+ l.color[2] * l.intensity,
+ ];
+ let molr = rr.molr(i as u16);
+ let reach = benilla_formats::room_claim::claim_reach(l.attenuation_end);
+ let claims = benilla_formats::room_claims(&rr.infos, rr.graph(), l.position, reach, &molr);
+ println!(
+ " L{i:<3} type {}{} colour ({:.3}, {:.3}, {:.3}) x {:.3} = ({:.3}, {:.3}, {:.3}) atten [{:.3}, {:.3}] R {reach:.1} pos ({:>8.2},{:>8.2},{:>8.2})",
+ l.light_type,
+ if l.use_atten { " atten" } else { " " },
+ l.color[0], l.color[1], l.color[2],
+ l.intensity,
+ prod[0], prod[1], prod[2],
+ l.attenuation_start, l.attenuation_end,
+ l.position[0], l.position[1], l.position[2],
+ );
+ if l.is_omni() {
+ println!(" claims {}", claim_cell(&claims));
+ }
+ }
+
+ // What our FIXED curve is still worth at the artist's own cutoff — the flatness measurement.
+ println!();
+ println!("=== the fixed falloff 1/(0.7d + 0.03d²) at each omni fixture's authored end ===");
+ let omni: Vec<&benilla_formats::WmoLight> = lights.iter().filter(|l| l.is_omni()).collect();
+ for (i, l) in omni.iter().enumerate() {
+ let d = l.attenuation_end.max(1e-3);
+ println!(
+ " omni {i:<3} end {:>7.3} yd atten(end) {:.4} atten(end)/atten(1yd) {:.4}",
+ l.attenuation_end,
+ 1.0 / (0.7 * d + 0.03 * d * d),
+ (1.0 / (0.7 * d + 0.03 * d * d)) / (1.0 / 0.73),
+ );
+ }
+ let ends: Vec = omni.iter().map(|l| l.attenuation_end).collect();
+ if !ends.is_empty() {
+ let mut sorted = ends.clone();
+ sorted.sort_by(f32::total_cmp);
+ println!(
+ " {} omni: end min {:.3} median {:.3} max {:.3}",
+ sorted.len(),
+ sorted[0],
+ sorted[sorted.len() / 2],
+ sorted[sorted.len() - 1],
+ );
+ }
+ Ok(())
+}
+
+/// MONKEY (interior prop lights): what ONE prop model contributes as a light source, resolved once
+/// per model path and memoised across the whole sweep (a city references the same lantern hundreds
+/// of times).
+#[derive(Clone)]
+enum PropLightKind {
+ /// The M2 authors a casting light block — every lane already spawns it, synthetic or not.
+ Authored,
+ /// No authored block, but `fire_light`'s flame or lamp route derives one: the model-space
+ /// position of the FLAME (or the lamp glass) and its intensity.
+ Synth { pos: [f32; 3], intensity: f32 },
+ /// Neither — a chair, a barrel, a rug.
+ None,
+}
+
+/// Rotate `v` by the MODD orientation quaternion `(x, y, z, w)` — `v + 2q×(q×v + wv)`. Written out
+/// rather than pulled from a math crate because `benilla-formats` deliberately carries none, and
+/// this is the only place the offline audit needs to place a prop.
+fn quat_rotate(q: [f32; 4], v: [f32; 3]) -> [f32; 3] {
+ let (x, y, z, w) = (q[0], q[1], q[2], q[3]);
+ let cross = |a: [f32; 3], b: [f32; 3]| {
+ [
+ a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0],
+ ]
+ };
+ let u = [x, y, z];
+ let t = cross(u, v);
+ let t = [t[0] + w * v[0], t[1] + w * v[1], t[2] + w * v[2]];
+ let t = cross(u, t);
+ [v[0] + 2.0 * t[0], v[1] + 2.0 * t[1], v[2] + 2.0 * t[2]]
+}
+
+/// The prop-light rule resolved for one model path: the SAME chain `benilla_assets::m2` applies at
+/// asset load (an authored block wins; else the flame route; else the lamp route), so a prop this
+/// sweep counts as a light source is exactly one the runtime will spawn a light for.
+fn prop_light_kind(chain: &mut Chain, key: &str) -> PropLightKind {
+ let Ok(bytes) = chain.read_file(key) else {
+ return PropLightKind::None; // a prop the client doesn't ship
+ };
+ if benilla_formats::parse_m2_lights(&bytes)
+ .iter()
+ .any(|l| l.casts())
+ {
+ return PropLightKind::Authored;
+ }
+ let emitters = benilla_formats::parse_m2_particle_emitters(&bytes).unwrap_or_default();
+ if let Some(fire) = benilla_formats::synthesize_fire_light(key, &emitters) {
+ return PropLightKind::Synth {
+ pos: emitters[fire.emitter].position,
+ intensity: fire.intensity,
+ };
+ }
+ let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, "", &[]) else {
+ return PropLightKind::None;
+ };
+ let batches: Vec> = subs
+ .iter()
+ .map(benilla_formats::EmissiveBatch::from)
+ .collect();
+ let bounds = benilla_formats::parse_m2_bounds(&bytes).ok();
+ let bbox = bounds.as_ref().map(|b| (b.bbox_min, b.bbox_max));
+ match benilla_formats::synthesize_lamp_light(key, &batches, bbox) {
+ Some(lamp) => PropLightKind::Synth {
+ pos: lamp.position,
+ intensity: lamp.intensity,
+ },
+ None => PropLightKind::None,
+ }
+}
+
+/// MONKEY (wmo exterior points): how close an authored MOLT fixture has to stand to a prop's flame
+/// (yd) for the synthesised light to be dropped as a duplicate. **Keep equal to
+/// `benilla_world::terrain_stream::spawn::fx`'s `MODD_SYNTH_DEDUPE`** — the point of this sweep is
+/// to predict what that dedupe does across the corpus.
+const MODD_SYNTH_DEDUPE: f32 = 2.5;
+
+/// One root's audit numbers.
+struct LampRow {
+ path: String,
+ groups: usize,
+ interior_groups: usize,
+ molt: usize,
+ props: usize,
+ authored: usize,
+ synth: usize,
+ deduped: usize,
+ /// Interior groups no MOLT fixture claims (the pre-fix darkness).
+ dark_molt: usize,
+ /// …and still none after the synthesised prop lights are admitted indoors.
+ dark_after: usize,
+}
+
+/// MONKEY (interior prop lights): the corpus audit behind "make sure all WMOs with built-in light
+/// sources have light working". For every WMO root in the chain: its MOLT omni fixtures, its MODD
+/// props that WOULD synthesise a light (`fire_light`'s flame/lamp routes, run on the prop's own M2
+/// exactly as the asset bake runs them), how many of those the 2.5 yd MOLT dedupe drops, and how
+/// many INTERIOR groups end up with no light source claiming them at all — before and after the
+/// prop lane is admitted indoors.
+///
+/// A group left in the `darkAFTER` column is either the artist's intent (a cellar, a closet, a
+/// sealed shaft) or a detection miss; `detail` names them per root so the two can be told apart.
+pub fn wmolamps(chain: &mut Chain, prefix: Option<&str>, detail: Option<&str>) -> Result<()> {
+ let roots = super::wmo_roots(chain, prefix)?;
+ // The buildings the owner actually walks through — always reported, wherever they rank.
+ const LANDMARKS: [&str; 16] = [
+ "buildings\\stormwind\\stormwind.wmo",
+ "buildings\\goldshireinn\\goldshireinn.wmo",
+ "buildings\\nsabbey\\nsabbey.wmo",
+ "cities\\ironforge\\ironforge.wmo",
+ "undercity\\undercity.wmo",
+ "ogrimmar.wmo",
+ "darnassis.wmo",
+ "az_deadmines_a.wmo",
+ "az_deadmines_b.wmo",
+ "stormwindjail.wmo",
+ "stormwindprison.wmo",
+ "westfall_inn.wmo",
+ "duskwood_inn.wmo",
+ "redridge_inn.wmo",
+ "human_farm",
+ "monestary_cathedral.wmo",
+ ];
+ let mut cache: BTreeMap = BTreeMap::new();
+ let mut rows: Vec = Vec::new();
+ let mut n_roots = 0u32;
+ for root_path in roots {
+ let Ok(bytes) = chain.read_file(&root_path) else {
+ continue;
+ };
+ // The listfile's own casing varies; every match below (landmarks, `detail`) is on the
+ // lowercased path, which is also the casing `root_rooms` builds its group-file names in.
+ let root_path = root_path.to_ascii_lowercase();
+ let Ok(root) = benilla_formats::parse_wmo_root(&bytes) else {
+ continue;
+ };
+ let lights = benilla_formats::parse_wmo_lights(&bytes);
+ let omni: Vec =
+ lights.iter().filter(|l| l.is_omni()).cloned().collect();
+ // Nothing to say about a root with no interior geometry and no fixtures (a bridge, a fence).
+ if root.group_infos().iter().all(|g| !g.interior) && omni.is_empty() {
+ continue;
+ }
+ n_roots += 1;
+ let rr = root_rooms(chain, &root, &root_path, &bytes);
+ let n_groups = rr.infos.len();
+ // Which groups anything claims — the MOLT fixtures first, then the props.
+ let mut lit_by_molt = vec![false; n_groups];
+ let mut lit_by_prop = vec![false; n_groups];
+ for (i, l) in lights.iter().enumerate() {
+ if !l.is_omni() {
+ continue;
+ }
+ let molr = rr.molr(i as u16);
+ let reach = benilla_formats::room_claim::claim_reach(l.attenuation_end);
+ for c in benilla_formats::room_claims(&rr.infos, rr.graph(), l.position, reach, &molr) {
+ if let Some(slot) = lit_by_molt.get_mut(usize::from(c.group)) {
+ *slot = true;
+ }
+ }
+ }
+ let (mut props, mut authored, mut synth, mut deduped) = (0usize, 0usize, 0usize, 0usize);
+ for (di, d) in root.doodads().iter().enumerate() {
+ if d.model.is_empty() {
+ continue;
+ }
+ props += 1;
+ let key = model_key(&d.model);
+ if !cache.contains_key(&key) {
+ let k = prop_light_kind(chain, &key);
+ cache.insert(key.clone(), k);
+ }
+ let (pos, intensity) = match cache[&key] {
+ PropLightKind::Authored => {
+ authored += 1;
+ continue; // an authored prop light is not this fix's population
+ }
+ PropLightKind::None => continue,
+ PropLightKind::Synth { pos, intensity } => (pos, intensity),
+ };
+ // The flame in WMO model space: the prop's placement applied to the M2-local point.
+ let r = quat_rotate(
+ d.orientation,
+ [pos[0] * d.scale, pos[1] * d.scale, pos[2] * d.scale],
+ );
+ let world = [
+ d.position[0] + r[0],
+ d.position[1] + r[1],
+ d.position[2] + r[2],
+ ];
+ let dup = omni.iter().any(|l| {
+ let dv = [
+ l.position[0] - world[0],
+ l.position[1] - world[1],
+ l.position[2] - world[2],
+ ];
+ dv[0] * dv[0] + dv[1] * dv[1] + dv[2] * dv[2]
+ < MODD_SYNTH_DEDUPE * MODD_SYNTH_DEDUPE
+ });
+ if dup {
+ deduped += 1;
+ continue;
+ }
+ synth += 1;
+ let molr = rr
+ .doodad_groups
+ .get(&(di as u16))
+ .cloned()
+ .unwrap_or_default();
+ let reach = benilla_formats::room_claim::claim_reach(
+ benilla_formats::room_claim::m2_light_reach(intensity),
+ );
+ for c in benilla_formats::room_claims(&rr.infos, rr.graph(), world, reach, &molr) {
+ if let Some(slot) = lit_by_prop.get_mut(usize::from(c.group)) {
+ *slot = true;
+ }
+ }
+ }
+ let interior: Vec = (0..n_groups).filter(|&g| rr.infos[g].interior).collect();
+ let dark_molt = interior.iter().filter(|&&g| !lit_by_molt[g]).count();
+ let dark: Vec = interior
+ .iter()
+ .copied()
+ .filter(|&g| !lit_by_molt[g] && !lit_by_prop[g])
+ .collect();
+ if detail.is_some_and(|d| root_path.contains(&d.to_ascii_lowercase())) && !dark.is_empty() {
+ let names: Vec = dark.iter().map(|g| format!("g{g}")).collect();
+ println!("{root_path}: interior groups still unlit: {}", names.join(" "));
+ }
+ rows.push(LampRow {
+ path: root_path.clone(),
+ groups: n_groups,
+ interior_groups: interior.len(),
+ molt: omni.len(),
+ props,
+ authored,
+ synth,
+ deduped,
+ dark_molt,
+ dark_after: dark.len(),
+ });
+ }
+
+ let sum = |f: fn(&LampRow) -> usize| rows.iter().map(f).sum::();
+ println!();
+ println!("=== wmolamps: {n_roots} WMO root(s) with interior geometry or MOLT fixtures ===");
+ println!(
+ " MOLT omni fixtures {} MODD props {} props with an AUTHORED light {}",
+ sum(|r| r.molt),
+ sum(|r| r.props),
+ sum(|r| r.authored),
+ );
+ println!(
+ " props that SYNTHESISE a light {} of which dropped by the {MODD_SYNTH_DEDUPE} yd MOLT dedupe {} kept {}",
+ sum(|r| r.synth) + sum(|r| r.deduped),
+ sum(|r| r.deduped),
+ sum(|r| r.synth),
+ );
+ println!(
+ " INTERIOR groups {} claimed by no MOLT fixture {} still unclaimed after the prop lights {}",
+ sum(|r| r.interior_groups),
+ sum(|r| r.dark_molt),
+ sum(|r| r.dark_after),
+ );
+ let header = " root grp int MOLT props synth dedup darkMOLT darkAFTER";
+ println!();
+ println!("=== landmarks ===");
+ println!("{header}");
+ for row in rows
+ .iter()
+ .filter(|r| LANDMARKS.iter().any(|l| r.path.contains(l)))
+ {
+ print_lamp_row(row);
+ }
+ let mut ranked: Vec<&LampRow> = rows.iter().filter(|r| r.dark_molt > 0).collect();
+ ranked.sort_by(|a, b| b.dark_molt.cmp(&a.dark_molt).then(b.molt.cmp(&a.molt)));
+ println!();
+ println!("=== the 25 roots with the most interior rooms no MOLT fixture reaches ===");
+ println!("{header}");
+ for row in ranked.into_iter().take(25) {
+ print_lamp_row(row);
+ }
+ Ok(())
+}
+
+fn print_lamp_row(r: &LampRow) {
+ let short = r.path.rsplit('\\').next().unwrap_or(&r.path);
+ println!(
+ " {short:<58} {:>3} {:>3} {:>4} {:>5} {:>5} {:>5} {:>8} {:>9}",
+ r.groups, r.interior_groups, r.molt, r.props, r.synth, r.deduped, r.dark_molt, r.dark_after,
+ );
+}
diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs
index eb5ea367..f660643e 100644
--- a/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs
+++ b/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs
@@ -30,7 +30,7 @@ mod skeleton;
mod world;
pub use geometry::{animboundscan, bbfacescan, bbscan, geosetscan, groundscan, normalscan};
-pub use lighting::{darkpropscan, m2lightscan, shadeat};
+pub use lighting::{darkpropscan, m2firescan, m2lightscan, shadeat, wmolamps, wmolights};
pub use material::{alphascan, blendscan, envmapscan, texmodescan, uvslotscan, uvwrapscan};
pub use particles::{
cellscan, fxordercensus, partcensus, partscan, partslotscan, ribbonscan, shardcensus,
diff --git a/crates/benilla-formats/src/fire_light.rs b/crates/benilla-formats/src/fire_light.rs
new file mode 100644
index 00000000..7341165a
--- /dev/null
+++ b/crates/benilla-formats/src/fire_light.rs
@@ -0,0 +1,1189 @@
+//! MONKEY (fire GO lights): **synthesising** a point light for a fire prop that authors none.
+//!
+//! The mechanism the reference commits — an M2 `type == 1` light block becoming a hardware
+//! `GL_LIGHT` at the fixed `1/(0.7d + 0.03d²)` falloff (decision 0016) — is already parsed
+//! ([`crate::M2Light`]) and already spawned by every lane. The problem is the CONTENT: a
+//! client-wide sweep finds **17 of ~430** fire-ish GameObject display models authoring a light
+//! block at all. `ElwynnCampfire`, `DwarvenBrazier01`, `Large/SmallFirePit01` do; every Ogre wall
+//! torch, `HumanBrazierMagic`, `StormwindBrazier01`, `Forgebonfire`, every candle and every
+//! `OrcBonFire*` does **not**. So a torch-lined hall is drawn by its flames and lit by nothing.
+//!
+//! The flame is nevertheless fully described by the model — in its PARTICLE EMITTER. The emitter
+//! names a flame texture, blends additive, and carries the fire's own colour in its over-life ramp
+//! ([`OverLife::color`]). This module reads exactly that and derives the light the artist did not
+//! author: which emitter is the fire, what hue it burns, and how bright it should be.
+//!
+//! It lives in `benilla-formats` beside [`ParticleEmitterDef`] rather than in the asset layer that
+//! *calls* it (`benilla_assets::m2`, where the [`crate::M2Light`] is manufactured and pushed into
+//! the model's light list) for one reason: `benilla-extract`'s offline sweep — the only instrument
+//! that can audit a heuristic across 430 models — is a `benilla-formats` binary and cannot reach
+//! upward into the asset crate. One rule, one implementation, auditable offline and shipped live.
+//!
+//! **This is a synthesis, not a parse.** Nothing here is byte-verified against the reference,
+//! because the reference does not do it — a vanilla client leaves those props dark. Everything a
+//! synthesised light touches is therefore flagged (`ModelLight::synthetic`) so a consumer can
+//! refuse it: the WMO MODD prop lane does, because a building's own MOLT fixtures already light
+//! its wall torches and a second source would double them.
+
+use crate::{OverLife, ParticleBlend, ParticleEmitterDef};
+
+/// Texture-basename keys that mark an emitter as FIRE. Matched case-insensitively as substrings of
+/// the emitter texture's basename (`ITEM\…\FLAMELICKSMALL.BLP` → `FLAMELICKSMALL`).
+///
+/// The list is the shipped flame/ember/candle art vocabulary, and its exclusions matter as much as
+/// its entries: **`GLOW` is deliberately absent**. Half the runestone/crystal/portal corpus emits
+/// `GLOW*.BLP`, and admitting it would light Dalaran-blue everywhere a quest crystal sits — a glow
+/// card is a self-illuminated surface, not a fire. `SMOKE`, `SPARK` and `DUST` are absent for the
+/// same reason from the other side: they ride real fires as SECOND emitters, and letting one win
+/// would put the light at the smoke column's height in ash grey.
+pub const FIRE_TEXTURE_KEYS: [&str; 8] = [
+ "FLAMELICK", "BONFIRE", "BRAZIER", "CANDLE", "TORCH", "FLAME", "EMBER", "FIRE",
+];
+
+/// The fallback hue for a fire whose over-life ramp is all-but-white (an emitter that tints from
+/// its texture rather than its ramp): plain warm orange. Peak-normalised like every derived colour.
+pub const DEFAULT_WARM: [f32; 3] = [1.0, 0.55, 0.2];
+
+/// Below this saturation (`max−min` of the linear RGB) an over-life key is "near-white" and carries
+/// no hue worth taking. Every shipped flame's *characteristic* key clears it by a wide margin
+/// (`OgreWallTorchpurple`'s purple 0.79, `HumanBrazierMagic`'s green 0.99, the plain torch's orange
+/// 1.00), and the keys it rejects are exactly the white hot-cores and the white burn-outs that both
+/// ends of a flame ramp are usually authored as.
+const MIN_SATURATION: f32 = 0.15;
+
+/// The smallest authored particle (yards, the over-life size ramp's peak) that counts as a FLAME
+/// rather than a SPARK. Measured, not guessed: the corpus's real candles sit at 0.056–0.069
+/// (`GeneralCandelabra01`, `CandelabraTallWall01`), while the ember/exhaust trails that share their
+/// texture vocabulary — a zeppelin's `ember_grey`, a mount's `ember_offset`, the sparks on a
+/// flaming helm, a fire elemental's secondary cinders — sit at 0.019–0.028 and are the whole of the
+/// false-positive population. The floor is the one number separating the two, so it goes just under
+/// the smallest real candle.
+const MIN_FLAME_SIZE: f32 = 0.04;
+
+/// One synthesised fire light: which emitter it was derived from, plus the light itself. The caller
+/// turns this into an [`crate::M2Light`] at that emitter's model-local position/bone — **the
+/// flame**, never the model origin, or a brazier's light sits inside its own bowl and the prop
+/// self-shadows every surface it should be lighting.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct SyntheticFire {
+ /// Index into the emitter slice handed in — the winning (strongest) fire emitter.
+ pub emitter: usize,
+ /// Peak-normalised linear RGB (see [`flame_color`]).
+ pub color: [f32; 3],
+ /// The light's `diffuse_intensity` (see [`fire_intensity`]).
+ pub intensity: f32,
+ /// The size bucket the intensity came from — for the offline audit's readout only.
+ pub bucket: &'static str,
+}
+
+/// Is this model path a SPELL effect? Spell models emit flame for a second and vanish; giving them
+/// a light would strobe the world on every Fireball and blow the 256-entry point table in a raid.
+/// Matched on the path segment so `WORLD\…\SPELLSTONE.M2` can't trip it.
+pub fn is_spell_path(path: &str) -> bool {
+ let p = path.replace('/', "\\").to_ascii_uppercase();
+ p.starts_with("SPELLS\\") || p.contains("\\SPELLS\\")
+}
+
+/// `Medium/Small/TallBrazierNoOmni01` — emitter-identical twins of the lit `…Brazier01` models
+/// WITHOUT the authored M2 omni light. The first reading of the name ("the artist's dark twin,
+/// honour it as a veto") was WRONG: the May-2026 server dump places 123 of these in Orgrimmar as
+/// the city's PRIMARY street lighting (69 Medium / 32 Small / 22 Tall — `NoOmni` there means
+/// "the engine's fixed-function omni is not wanted, the flame IS the light"), and vetoing them
+/// left 106 of Orgrimmar's 129 rooms with no light source at all. So `NoOmni` is no longer a
+/// veto anywhere: the flame route still demands a real additive flame emitter, which is what
+/// separates a burning brazier from a cold one. Kept only as a name test for the offline scans
+/// (MONKEY, Orgrimmar finding 2026-09-09).
+pub fn is_no_omni(path: &str) -> bool {
+ path.rsplit(['\\', '/'])
+ .next()
+ .unwrap_or(path)
+ .to_ascii_uppercase()
+ .contains("NOOMNI")
+}
+
+/// Does this texture path name flame art? Basename, extension stripped, case-insensitive
+/// [`FIRE_TEXTURE_KEYS`] substring.
+pub fn fire_texture(texture: Option<&str>) -> bool {
+ let Some(t) = texture else { return false };
+ let base = t.rsplit(['\\', '/']).next().unwrap_or(t);
+ let stem = base.split('.').next().unwrap_or(base).to_ascii_uppercase();
+ FIRE_TEXTURE_KEYS.iter().any(|k| stem.contains(k))
+}
+
+/// The three-part emitter gate: flame ART, flame BLEND, flame SIZE.
+///
+/// The blend arm is what keeps the smoke out. Every shipped flame is additive (`3` NoAlphaAdd / `4`
+/// Add — the campfire is 4), and the same fire model's smoke column is `2` Alpha; a name gate alone
+/// admits `FIRESMOKE.BLP`-class art and puts the light up in the plume. (Additive implies
+/// `!def.lit` across nearly all of this corpus, so testing both would reject nothing while reading
+/// as if it did.)
+///
+/// The size arm ([`MIN_FLAME_SIZE`]) is what keeps the SPARKS out, and it is the one the corpus
+/// sweep added: every fire texture in the game is also used for ember trails, and a zeppelin's
+/// exhaust cinders would otherwise light Booty Bay.
+pub fn fire_emitter(def: &ParticleEmitterDef) -> bool {
+ fire_texture(def.texture.as_deref())
+ && def.blend == ParticleBlend::Add
+ && peak_size(def) >= MIN_FLAME_SIZE
+ && def.timing.peak_rate() > 0.0
+}
+
+/// The flame's colour from its over-life ramp: **the most saturated key**, peak-normalised to 1.0.
+///
+/// Not an average, and not key 0. A flame ramp is authored as *hue → hue → burnout*, and both the
+/// mean and the endpoints wash toward the white the burnout ends on: averaging
+/// `OgreWallTorchpurple`'s `(1,1,1) / (0.565,0.212,1.0) / (0.42,0,0.745)` gives a pale lilac, and
+/// key 0 gives literal white. The most-saturated key is the one an artist chose to say "this fire
+/// is purple", and taking it reproduces all three of the calibration models: purple stays purple,
+/// `HumanBrazierMagic` comes out green, the plain torch comes out orange.
+///
+/// Peak-normalising (divide by the largest channel) separates HUE from BRIGHTNESS, which is
+/// [`fire_intensity`]'s job — a dim ember and a roaring bonfire of the same hue must not differ in
+/// colour, or the intensity bucket would be applied twice.
+pub fn flame_color(over_life: &OverLife) -> [f32; 3] {
+ let sat = |k: &[f32; 4]| {
+ let (lo, hi) = k[..3]
+ .iter()
+ .fold((f32::MAX, f32::MIN), |(lo, hi), &v| (lo.min(v), hi.max(v)));
+ hi - lo
+ };
+ let best = over_life
+ .color
+ .iter()
+ .filter(|k| sat(k) >= MIN_SATURATION)
+ .max_by(|a, b| sat(a).total_cmp(&sat(b)));
+ let rgb = match best {
+ Some(k) => [k[0], k[1], k[2]],
+ None => return DEFAULT_WARM, // an all-white ramp tints from its texture; assume a fire
+ };
+ let peak = rgb[0].max(rgb[1]).max(rgb[2]);
+ if peak <= 1e-4 {
+ return DEFAULT_WARM; // a black ramp is not a colour; don't divide by it
+ }
+ [rgb[0] / peak, rgb[1] / peak, rgb[2] / peak]
+}
+
+/// The emitter's peak authored particle half-extent (the over-life size ramp's largest key, yards)
+/// — the size discriminator [`fire_intensity`] buckets on.
+pub fn peak_size(def: &ParticleEmitterDef) -> f32 {
+ def.over_life.scale.iter().copied().fold(0.0, f32::max)
+}
+
+/// The fire's "how big is this flame" score: `peak particle size × ⁴√(rate)`.
+///
+/// Size is the honest signal — a candle authors ~0.1 yd particles, a wall torch ~0.3, a brazier
+/// ~0.6, a bonfire ~1.5 — but size alone can't separate a fat, lazy two-particle-a-second ember bed
+/// from a real fire. The rate enters under a FOURTH root deliberately: emission rates range over
+/// two orders of magnitude across this corpus (2 → 200) and any stronger weighting lets a dense
+/// spark shower outrank the flame it belongs to. Used both to pick the winning emitter and to
+/// bucket its intensity.
+pub fn fire_strength(def: &ParticleEmitterDef) -> f32 {
+ peak_size(def) * def.timing.peak_rate().max(0.0).sqrt().sqrt()
+}
+
+/// Bucket a fire's [`fire_strength`] onto a light intensity, calibrated against the AUTHORED corpus
+/// so a synthesised torch sits in the same range as `MediumBrazier01`/`ElwynnCampfire` rather than
+/// out-blazing them (the authored fire family clusters at `diffuse_color (0.467,0.290,0.133)` with
+/// intensities 1–3). Returns the intensity and the bucket's name for the offline readout.
+///
+/// The cut points come off the `m2firescan` sweep, chosen so the named landmarks fall where their
+/// names say: `GeneralCandelabra01` 0.09 and `CandelabraTallWall01` 0.07 candle; `OgreWallTorch`
+/// 1.06, `StormwindBrazier01` 0.82 and `HumanBrazierMagic` 1.30 in the middle two;
+/// `Forgebonfire` 2.03 and `OrcBonFire` 3.25 at the top. Note that the middle pair is not cleanly
+/// separable by any function of the record — the ogre WALL TORCH authors a bigger flame than the
+/// Stormwind BRAZIER — which is exactly why the two buckets differ by 0.5 and not by more:
+/// misfiling between them has to be cosmetically invisible, because it will happen.
+pub fn fire_intensity(strength: f32) -> (f32, &'static str) {
+ match strength {
+ s if s < 0.25 => (0.6, "candle"),
+ s if s < 0.70 => (1.5, "torch"),
+ s if s < 1.60 => (2.0, "brazier"),
+ _ => (3.0, "bonfire"),
+ }
+}
+
+/// Derive **at most one** light for a model that authors no casting light block: pick the strongest
+/// fire emitter ([`fire_strength`]), take its hue ([`flame_color`]) and its bucket
+/// ([`fire_intensity`]). `None` when the model is a spell effect or has no fire emitter.
+///
+/// One light per model, never one per emitter. A campfire authors 3–5 emitters (core flame, licks,
+/// embers, smoke) and every one of them that passed the gate would stack a full-strength light on
+/// the same spot — five torches' worth of blaze from one campfire, and five of the 256 table slots.
+pub fn synthesize_fire_light<'a>(
+ path: &str,
+ emitters: impl IntoIterator- ,
+) -> Option
{
+ if is_spell_path(path) {
+ return None;
+ }
+ let (emitter, def, strength) = emitters
+ .into_iter()
+ .enumerate()
+ .filter(|(_, d)| fire_emitter(d))
+ .map(|(i, d)| (i, d, fire_strength(d)))
+ // A hand-rolled max rather than `max_by`, which keeps the LAST maximum on a tie: the
+ // FIRST-wins arm (`>=` on the incumbent) is what makes the pick stable when a model
+ // authors two identical flames, and the ties are real (mirrored torch pairs).
+ .fold(None, |best: Option<(usize, &ParticleEmitterDef, f32)>, cur| {
+ match best {
+ Some(b) if b.2 >= cur.2 => Some(b),
+ _ => Some(cur),
+ }
+ })?;
+ let (intensity, bucket) = fire_intensity(strength);
+ Some(SyntheticFire {
+ emitter,
+ color: flame_color(&def.over_life),
+ intensity,
+ bucket,
+ })
+}
+
+// ---------------------------------------------------------------------------------------------
+// MONKEY (lamp lights): the SECOND detection route — an emissive GEOSET, not a particle emitter.
+// ---------------------------------------------------------------------------------------------
+//
+// The flame route above reads a fire's PARTICLE EMITTER, and everything with a visible flame is
+// caught by it. A LAMPPOST is not: `StormwindStreetlamp01`, `DuskwoodLamppost`,
+// `GeneralHangingLantern01`, every `WestfallLampPost*` — none of them authors a single emitter.
+// Their glow is authored as GEOMETRY: a small glass geoset carrying the M2 material render-flag
+// `0x01` (UNLIT — "draw me at full texture brightness, ignore the sun"), usually with a second
+// additive billboard corona card over it. So the whole street-lighting corpus — the Valley of
+// Heroes, Stormwind Park, the Duskwood road — sailed straight past `fire_emitter` and lit nothing.
+//
+// The lamp route detects that shape. It is deliberately WEAKER evidence than a flame — an unlit
+// batch says "this surface is self-illuminated", not "this object emits light" — so it is fenced
+// three ways: by the model's NAME (a lamp/lantern/chandelier vocabulary), by the batch's TEXTURE
+// (glow/lamp art), and by a content-family gate that keeps it out of characters, creatures, items
+// and spell effects, where unlit additive geometry is the *norm* rather than a signal.
+//
+// The flame route always wins where both fire: it derives a real colour from the artist's own ramp,
+// whereas this route can only pick a plausible one from a name (see [`lamp_color`]).
+
+/// Model-basename keys that name a luminous prop, most specific first — the order is load-bearing,
+/// because [`lamp_kind`] classifies on the FIRST match (`DuskwoodLamppost` must read as LAMPPOST,
+/// not as the `LAMP` substring inside it; `CHANDELIER` before `CANDLE` so a chandelier is never
+/// filed on the candle rung; and `CANDELABRA` before `CANDLE` — which costs nothing here because
+/// `CANDELABRA` does not contain `CANDLE`, but the Scholomance corpus spells it both ways).
+pub const LAMP_NAME_KEYS: [&str; 13] = [
+ "CHANDELIER",
+ "CANDELABRA",
+ "LAMPPOST",
+ "LAMP POST",
+ "LIGHTPOST",
+ "STREETLAMP",
+ "STREETLIGHT",
+ "LANTERN",
+ "SCONCE",
+ "LAMP",
+ "CANDLE",
+ "BRAZIER",
+ "TORCH",
+];
+
+/// DIRECTORY words that mark a folder as a light-fixture folder. This is route (b)'s real gate.
+///
+/// The first cut of route (b) — "an unlit, transparent, glow-textured batch on any world model" —
+/// fired on 149 models, and reading the list is what wrote this constant: blood-elf BANNERS,
+/// Ghostlands TREES, PvP RUNES, Uldaman TELEPORT PADS, Booty Bay HOLOGRAMS, silithid EGGS, a
+/// Karazhan owl STATUE. Self-illuminated geometry is simply how vanilla draws "this thing is
+/// magic", and it says nothing at all about whether the thing casts light.
+///
+/// What *does* say so is where the artist filed the model. A prop under `…\Lamps\`, `…\Lanterns\`,
+/// `…\Candles\`, `…\Sconces\` or `…\Lights\` was authored as a light FIXTURE, whatever it is named
+/// (`Scholme_Candelabra`, `TS_LightPole`, `BFD_Wisp01`, `GnomeHazardLight01`). Route (b) is
+/// therefore "a fixture folder, corroborated by unlit glow geometry" — which is what it always
+/// meant, stated in the one place the corpus actually records it.
+const LAMP_DIR_KEYS: [&str; 7] = [
+ "LAMP",
+ "LANTERN",
+ "CANDLE",
+ "SCONCE",
+ "CHANDELIER",
+ "LIGHTS",
+ "TORCH",
+];
+
+/// Names that describe LIT AIR rather than a light source — god-rays, sun shafts, spotlight cones.
+/// `World\NoDXT\…\VolumetricLights\LightShaftA` sits in a `…Lights\` folder and draws exactly the
+/// unlit additive glow card route (b) looks for, but it is the *beam*, and its geoset centroid is
+/// halfway down the shaft: a point light there lights the floor from mid-air, in a spot the artist
+/// already painted bright. Eleven of them ship. Vetoed by name, on both routes.
+const BEAM_KEYS: [&str; 3] = ["SHAFT", "BEAM", "SKYLIGHT"];
+
+/// Texture-basename keys that mark a render batch's art as GLOW art. Wider than
+/// [`FIRE_TEXTURE_KEYS`] by exactly the entries that list deliberately excludes — `GLOW`, `LIGHT`,
+/// `FLARE` — because here they are read in a much narrower context: an UNLIT batch on a model that
+/// already cleared the family gate. `Glow32.blp` is the shipped corona card on half the lampposts
+/// in the game and the single most diagnostic texture in this route; on a runestone it would be a
+/// false positive, which is what [`lamp_family_ok`] is for.
+pub const GLOW_TEXTURE_KEYS: [&str; 9] = [
+ "GLOW", "LIGHT", "LAMP", "LANTERN", "FLARE", "FIRE", "FLAME", "CANDLE", "TORCH",
+];
+
+/// The BROKEN veto. The corpus ships a dark twin of nearly every lamp — `DuskwoodLamppostBroken`,
+/// `UldamanLampAndPostBusted01`, `DarnassusWreckedStreetLamp01`, `BE_lantern_busted_001`,
+/// `UldamanLampFallen`, `WesternPlaguelandsLampostStraightOff` — and a level designer places one
+/// exactly where a lit lamp is wrong (a ruined road, a sacked village). Several still carry the
+/// unlit glass geoset they inherited from the whole model, so nothing but the name tells them
+/// apart. Same logic as [`is_no_omni`]: it is the artist's own opt-out, and it is honoured.
+const BROKEN_KEYS: [&str; 7] = [
+ "BROKEN", "BUSTED", "WRECKED", "SMASHED", "DESTROY", "FALLEN", "UNLIT",
+];
+
+/// The warm-lamp default hue: an oil/candle lamp behind amber glass. Deliberately paler and less
+/// orange than [`DEFAULT_WARM`] (the open-flame default) — a lamp's light has passed through a
+/// glass shade, and a street lit at the flame's own `(1, 0.55, 0.2)` reads as a fire alarm.
+pub const DEFAULT_LAMP: [f32; 3] = [1.0, 0.72, 0.40];
+
+/// Which luminous-prop family a name lands in — the classification both [`lamp_color`] and
+/// [`lamp_intensity`] read. `None` when no [`LAMP_NAME_KEYS`] entry matches (the model then reaches
+/// the route only through its geometry, and is treated as a generic lamp).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum LampKind {
+ /// A hanging multi-candle fixture: a hall's whole light source.
+ Chandelier,
+ /// A single small candle / candelabra — the dimmest rung.
+ Candle,
+ /// A lamppost, street lamp, hanging lantern or wall sconce — the population this exists for.
+ Lamp,
+ /// An open-fire BRAZIER — the authored fire family's own rung.
+ Brazier,
+ /// An open-fire TORCH — the torch rung, the same one a lamp takes.
+ Torch,
+}
+
+impl LampKind {
+ /// Is this an OPEN-FIRE container (a brazier or a torch) rather than a fixture?
+ ///
+ /// The distinction exists for exactly one rule: [`lamp_position`]'s bounding-box fallback is
+ /// refused to the fire kinds. A lamp whose glass isn't flagged unlit is still a lamp
+ /// (`UldamanLamp`, `IronForgeHangingLantern01`, `AhnQirajSconce01` — all real fixtures the
+ /// fallback rescues), but a brazier with neither a flame emitter nor one glowing polygon is a
+ /// brazier with nothing burning in it: `SummerFest_Brazier_02`, `LordaeronBrazier01`,
+ /// `UldamanBrazier01` and `IT_Brazier22` are cold props, and lighting them was the first cut of
+ /// this rule's most visible error.
+ pub fn is_fire(self) -> bool {
+ matches!(self, LampKind::Brazier | LampKind::Torch)
+ }
+}
+
+/// Which of the two synthesis routes produced a light — reported by the offline sweep, and the
+/// first thing to read when a model glows that shouldn't.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum LightRoute {
+ /// The model authors a flame PARTICLE EMITTER ([`synthesize_fire_light`]).
+ Flame,
+ /// The model's NAME says lamp/lantern/chandelier ([`synthesize_lamp_light`], route a).
+ Name,
+ /// No name hit — an UNLIT render batch carrying glow art gave it away (route b).
+ Emissive,
+}
+
+impl LightRoute {
+ /// Short label for the offline readout.
+ pub fn label(self) -> &'static str {
+ match self {
+ LightRoute::Flame => "flame",
+ LightRoute::Name => "name",
+ LightRoute::Emissive => "emissive",
+ }
+ }
+}
+
+/// The model basename, extension stripped, uppercased — the string every name rule below matches.
+fn basename_stem(path: &str) -> String {
+ let base = path.rsplit(['\\', '/']).next().unwrap_or(path);
+ base.split('.').next().unwrap_or(base).to_ascii_uppercase()
+}
+
+/// Classify a model path by [`LAMP_NAME_KEYS`] (first match wins — the list is ordered).
+pub fn lamp_kind(path: &str) -> Option {
+ let stem = basename_stem(path);
+ let key = LAMP_NAME_KEYS.iter().find(|k| stem.contains(**k))?;
+ Some(match *key {
+ "CHANDELIER" => LampKind::Chandelier,
+ "CANDLE" | "CANDELABRA" => LampKind::Candle,
+ "BRAZIER" => LampKind::Brazier,
+ "TORCH" => LampKind::Torch,
+ _ => LampKind::Lamp,
+ })
+}
+
+/// The artist's dark twin ([`BROKEN_KEYS`]), plus the `…Off` suffix that means the same thing.
+///
+/// The suffix is tested as a SUFFIX **after the trailing serial number is stripped**, never as a
+/// substring: `Offset`, `Office` and `_offhand` would take half the corpus with them, while
+/// `CandleOff01`/`02`/`03` — which Stormwind's WMO places **65 times**, more than any other candle —
+/// only reads as extinguished once `01` comes off the end. `WesternPlaguelandsLampostStraightOff`
+/// has no serial and is caught either way. Getting this wrong put 65 lights inside one city.
+pub fn is_broken_or_off(path: &str) -> bool {
+ let stem = basename_stem(path);
+ if BROKEN_KEYS.iter().any(|k| stem.contains(k)) {
+ return true;
+ }
+ stem.trim_end_matches(|c: char| c.is_ascii_digit())
+ .trim_end_matches(['_', '-'])
+ .ends_with("OFF")
+}
+
+/// A model that draws a LIGHT BEAM rather than a light source ([`BEAM_KEYS`]).
+pub fn is_light_beam(path: &str) -> bool {
+ let stem = basename_stem(path);
+ BEAM_KEYS.iter().any(|k| stem.contains(k))
+}
+
+/// Does any DIRECTORY segment of this path name a light-fixture folder ([`LAMP_DIR_KEYS`])? The
+/// basename is excluded deliberately — that is route (a)'s job, and reading it here would make the
+/// two routes indistinguishable in the audit.
+pub fn in_lamp_dir(path: &str) -> bool {
+ let p = path.replace('/', "\\").to_ascii_uppercase();
+ let Some(dir) = p.rfind('\\').map(|i| &p[..i]) else {
+ return false;
+ };
+ LAMP_DIR_KEYS.iter().any(|k| dir.contains(k))
+}
+
+/// The content-family gate for the LAMP route: which trees may synthesise from mere unlit geometry.
+///
+/// Unlit + additive is the ordinary way to draw a highlight on a sword, a ghost's shroud, an
+/// elemental's core, a spell's flash — thousands of batches whose models emit no light in any
+/// client. Admitting them would turn every equipped weapon and every caster into a lamp. So the
+/// route is confined to placed WORLD scenery, which is the entire population it exists for:
+/// lampposts, lanterns, chandelier and sconce props. `ITEM\ObjectComponents\` is named explicitly
+/// in the exclusion because that is where the two hand-held lanterns live
+/// (`Misc_1H_Lantern_A_01`) — a carried lamp is a real light, but the FLAME route already handles
+/// the ones whose artist authored a flame, and lighting a lantern *in a bank slot* is not worth it.
+pub fn lamp_family_ok(path: &str) -> bool {
+ let p = path.replace('/', "\\").to_ascii_uppercase();
+ let world = p.starts_with("WORLD\\") || p.starts_with("DUNGEONS\\");
+ world && !p.contains("\\SPELLS\\") && !p.starts_with("ITEM\\OBJECTCOMPONENTS\\")
+}
+
+/// Does this texture path name glow art? Basename, extension stripped, case-insensitive
+/// [`GLOW_TEXTURE_KEYS`] substring — **minus** the [`BEAM_KEYS`] art, for the same reason
+/// [`is_light_beam`] exists one level up: `HazardLightBeam.blp` and `TJ_LightShaft.blp` paint the
+/// CONE a lamp throws, not the lamp. Their geometry is a long card whose centroid sits a yard or
+/// two out along the beam, so taking it as the lamp head puts the point light in mid-air beside the
+/// fixture, in the exact spot the artist already painted bright. The lamp-route twin of
+/// [`fire_texture`].
+pub fn glow_texture(texture: Option<&str>) -> bool {
+ let Some(t) = texture else { return false };
+ let base = t.rsplit(['\\', '/']).next().unwrap_or(t);
+ let stem = base.split('.').next().unwrap_or(base).to_ascii_uppercase();
+ !BEAM_KEYS.iter().any(|k| stem.contains(k))
+ && GLOW_TEXTURE_KEYS.iter().any(|k| stem.contains(k))
+}
+
+/// One render batch as the lamp route reads it — the minimum a caller must expose. Built from a
+/// [`crate::RenderSubmesh`] by the [`From`] impl below; hand-built in the tests, which is why it is
+/// a borrowed view of five fields rather than the 30-field submesh itself.
+#[derive(Debug, Clone, Copy)]
+pub struct EmissiveBatch<'a> {
+ /// The batch's resolved `.blp` path, if it carries one.
+ pub texture: Option<&'a str>,
+ /// M2 material render-flag `0x01` — **UNLIT**. The signal this route is built on.
+ pub unlit: bool,
+ /// M2 blend mode `2` (alpha) / `3` (NoAlphaAdd) / `4` (Add) — the transparent family a glow
+ /// card is drawn in. NOT required by route (a): the most common lamp shape in the corpus
+ /// (`StormwindStreetlamp01`, `DuskwoodLamppost`) authors its glass as **blend 0, unlit** —
+ /// opaque coloured glass — and gating the name route on the blend would have missed exactly the
+ /// models this was written for. Route (b) does require it, as corroboration for a nameless hit.
+ pub blended: bool,
+ /// Model-space vertices (WoW axes, Z up) — the centroid is where the light goes.
+ pub positions: &'a [[f32; 3]],
+ /// The batch's dominant bone (the mode of its per-vertex primary joint), or 0 for a boneless
+ /// batch — so an animating lamp's light can ride the joint its glass rides.
+ pub bone: u16,
+}
+
+impl<'a> From<&'a crate::RenderSubmesh> for EmissiveBatch<'a> {
+ fn from(s: &'a crate::RenderSubmesh) -> Self {
+ // The dominant bone: the most frequent PRIMARY joint across the batch's vertices. Lamp
+ // glass is a rigid 16-vertex box skinned 100 % to one bone, so this is normally unanimous;
+ // the histogram is there for the swinging hanging-lantern shapes, whose glass and chain sit
+ // on different bones and whose batches can straddle both.
+ //
+ // Skipped entirely for a LIT batch, and that is not an optimisation detail: this conversion
+ // runs at asset-load time over EVERY batch of every model that authors no light and no
+ // flame — the whole character/creature corpus included — and only an unlit batch's bone is
+ // ever read back ([`lamp_position`] returns a bone from the batch it picked, and it only
+ // picks unlit ones). A per-vertex map insert across a 5000-vertex creature, for a field
+ // nothing will look at, is load-time cost for nothing.
+ let mut best = (0u16, 0usize);
+ if s.emissive {
+ let mut counts: std::collections::BTreeMap =
+ std::collections::BTreeMap::new();
+ for j in &s.joints {
+ let c = counts.entry(j[0]).or_default();
+ *c += 1;
+ if *c > best.1 {
+ best = (j[0], *c);
+ }
+ }
+ }
+ EmissiveBatch {
+ texture: s.texture.as_deref(),
+ unlit: s.emissive,
+ blended: s.additive || matches!(s.blend, crate::ModelBlend::Blend),
+ positions: &s.positions,
+ bone: best.0,
+ }
+ }
+}
+
+/// One synthesised lamp light. The caller turns this into an [`crate::M2Light`] exactly as it does
+/// a [`SyntheticFire`] — same `position`/`bone` convention, same `synthetic: true` flag.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct SyntheticLamp {
+ /// Which route fired ([`LightRoute::Name`] / [`LightRoute::Emissive`]) — audit only.
+ pub route: LightRoute,
+ /// The batch the position came from, or `None` when it fell back to the bounding box.
+ pub batch: Option,
+ /// Model-space position of the light (WoW axes, Z up) — see [`lamp_position`].
+ pub position: [f32; 3],
+ /// The position batch's dominant bone (`0` on the bbox fallback).
+ pub bone: u16,
+ /// Peak-normalised linear RGB (see [`lamp_color`]).
+ pub color: [f32; 3],
+ /// The light's `diffuse_intensity` (see [`lamp_intensity`]).
+ pub intensity: f32,
+ /// The intensity bucket's name — for the offline readout, and so the two routes' readouts stay
+ /// directly comparable.
+ pub bucket: &'static str,
+}
+
+/// The lamp's colour. **No texture is sampled** — deliberately.
+///
+/// Decoding the glass BLP would mean IO inside a rule that has to run both in the offline sweep and
+/// in the asset loader's bake, and the answer would nearly always be the same anyway: shipped lamp
+/// glass is authored amber. So the rule is a documented table, in priority order:
+///
+/// 1. **A magic hue in the model name wins** — `NightElfLanternBlue`, `BE_lantern_red_001`,
+/// `NE_LanternBlue01`. These are the models where the default would be visibly, obviously wrong
+/// (a moonwell-blue night-elf lantern glowing orange), and the artist wrote the colour into the
+/// filename precisely because it is the point of the model.
+/// 2. **A lamp/lantern/post/sconce/chandelier/candle name** ⇒ [`DEFAULT_LAMP`] `(1.0, 0.72, 0.40)`:
+/// warm amber, softened by the glass shade it shines through.
+/// 3. **Anything else** (the nameless emissive route (b), and the brazier/torch end of the
+/// vocabulary) ⇒ [`DEFAULT_WARM`] `(1.0, 0.55, 0.2)`: open flame — the same default the particle
+/// route falls back to, so the two routes agree wherever they describe the same thing.
+pub fn lamp_color(path: &str) -> [f32; 3] {
+ let stem = basename_stem(path);
+ for (key, rgb) in [
+ ("BLUE", [0.45, 0.65, 1.0]),
+ ("GREEN", [0.40, 1.0, 0.45]),
+ ("PURPLE", [0.70, 0.40, 1.0]),
+ ("ARCANE", [0.70, 0.40, 1.0]),
+ ("RED", [1.0, 0.35, 0.25]),
+ ] {
+ if stem.contains(key) {
+ return rgb;
+ }
+ }
+ // The lamp family gets the shaded amber; a brazier/torch name gets the open-flame default.
+ if ["LAMP", "LANTERN", "POST", "SCONCE", "CANDLE", "CHANDELIER"]
+ .iter()
+ .any(|k| stem.contains(k))
+ {
+ DEFAULT_LAMP
+ } else {
+ DEFAULT_WARM
+ }
+}
+
+/// Bucket a lamp onto the SAME intensity rungs the flame route uses (0.6 / 1.5 / 2.0 / 3.0), so the
+/// downstream reach table (`benilla_world::lighting::m2_light_reach`, which buckets 0.6 → 6 yd,
+/// 1.5 → 12, 2.0 → 16, 3.0 → 24) needs no new rung and a synthesised lamp sits in the same range as
+/// an authored `MediumBrazier01`.
+///
+/// - A **chandelier** is a hall's whole light source (a dozen candles on one fixture) and a
+/// **brazier** is the authored corpus's own 2.0: the brazier rung, 2.0 / 16 yd.
+/// - A **candle** is one flame in a glass: the candle rung, 0.6 / 6 yd.
+/// - Everything else — lamppost, street lamp, hanging lantern, sconce, torch — is the torch rung,
+/// 1.5 / 12 yd. That is the number the whole feature turns on: a Valley of Heroes lamppost throws
+/// 12 yd, which reaches the road under it and the wall beside it without crossing the canal.
+///
+/// Nothing here reaches the 3.0 bonfire rung. A lamp is not a bonfire, and a synthesised light that
+/// out-blazes `Forgebonfire` would be visibly wrong in exactly the place it is most looked at.
+pub fn lamp_intensity(kind: Option) -> (f32, &'static str) {
+ match kind {
+ Some(LampKind::Chandelier) => (2.0, "chandelier"),
+ Some(LampKind::Candle) => (0.6, "candle"),
+ // A brazier lands on the authored fire family's own rung; a torch on the torch rung. Both
+ // reach here only WITH glowing geometry (see [`LampKind::is_fire`]), so this one is lit.
+ Some(LampKind::Brazier) => (2.0, "brazier"),
+ Some(LampKind::Torch) => (1.5, "torch"),
+ _ => (1.5, "lamp"),
+ }
+}
+
+/// Pick the batch whose centroid is the light's position, and the position itself.
+///
+/// **Not the model origin.** A lamppost's origin is at its BASE, on the ground: a light there is
+/// buried inside the post, lights the cobbles in a ring around the foot of the pole, and (on the
+/// torch-shadow lane, which includes the prop's own caster mesh) shadows everything the lamp is
+/// supposed to be illuminating. The glass is 4–5 yd up. That vertical offset is the whole feature.
+///
+/// The pick, in order:
+/// 1. The UNLIT batch carrying GLOW art with the most vertices — the glass box (16 verts) rather
+/// than the additive corona card (4 verts, a billboard whose two triangles say much less about
+/// where the lamp head actually is).
+/// 2. Failing that, any unlit batch — a name-route hit whose glass carries unrecognised art.
+/// 3. Failing that, the model's bounding-box TOP CENTRE `(cx, cy, zmax)` — but ONLY when `bbox_ok`
+/// (see [`LampKind::Fire`]: a cold brazier is refused it). A name-route hit with no unlit
+/// geometry is a lamp whose head cannot be located from the geometry; the top of its box is the
+/// least-wrong guess for a pole-shaped prop, and the sweep reports it as a fallback. It is the
+/// right answer more often than it looks — `StormwindCanalLamp01` is one 21 yd batch whose lamp
+/// head is its own bbox top — and merely *high* on a hanging lantern, whose box top is the
+/// ceiling hook a yard above the glass.
+pub fn lamp_position(
+ batches: &[EmissiveBatch<'_>],
+ bounds: Option<([f32; 3], [f32; 3])>,
+ bbox_ok: bool,
+) -> Option<(Option, [f32; 3], u16)> {
+ let centroid = |b: &EmissiveBatch| {
+ let n = b.positions.len() as f32;
+ let s = b
+ .positions
+ .iter()
+ .fold([0.0f32; 3], |a, p| [a[0] + p[0], a[1] + p[1], a[2] + p[2]]);
+ [s[0] / n, s[1] / n, s[2] / n]
+ };
+ let pick = |glow_only: bool| {
+ batches
+ .iter()
+ .enumerate()
+ .filter(|(_, b)| {
+ b.unlit && !b.positions.is_empty() && (!glow_only || glow_texture(b.texture))
+ })
+ .max_by_key(|(_, b)| b.positions.len())
+ };
+ if let Some((i, b)) = pick(true).or_else(|| pick(false)) {
+ return Some((Some(i), centroid(b), b.bone));
+ }
+ if !bbox_ok {
+ return None;
+ }
+ let (lo, hi) = bounds?;
+ Some((None, [(lo[0] + hi[0]) * 0.5, (lo[1] + hi[1]) * 0.5, hi[2]], 0))
+}
+
+/// Derive **at most one** light for a model that authors neither a casting light block nor a flame
+/// emitter, from its NAME and/or its emissive geometry. `None` for everything else.
+///
+/// Two routes, evaluated in that order:
+///
+/// - **(a) NAME.** The basename matches [`LAMP_NAME_KEYS`]. This is the route the whole street-lamp
+/// corpus takes, and it does not require the geometry to prove anything — `StormwindStreetlamp01`
+/// authors its glass as an *opaque* unlit batch, which no blend-mode test would admit.
+/// - **(b) EMISSIVE.** No name hit, but the model sits in a light-FIXTURE FOLDER ([`in_lamp_dir`])
+/// AND carries an UNLIT batch that is also in the transparent family (blend 2/3/4) AND whose
+/// texture is glow art ([`GLOW_TEXTURE_KEYS`]). This is the "lamp glass without a lamp name"
+/// catcher — `Scholme_Candelabra`, `TS_LightPole`, `GnomeHazardLight01`, `BFD_Wisp01`: fixtures
+/// named after their building or their species rather than after the thing. It is much the
+/// STRICTER of the two, because unlit glow geometry on its own means nothing at all (the sweep's
+/// first cut lit blood-elf banners, Ghostlands trees, PvP runes and a Karazhan owl statue).
+///
+/// Vetoed throughout: spell effects ([`is_spell_path`]), light BEAMS ([`is_light_beam`]), the
+/// artist's dark twins ([`is_broken_or_off`]; `NoOmni` is NOT one — see [`is_no_omni`]) and everything outside placed world
+/// scenery ([`lamp_family_ok`]).
+pub fn synthesize_lamp_light(
+ path: &str,
+ batches: &[EmissiveBatch<'_>],
+ bounds: Option<([f32; 3], [f32; 3])>,
+) -> Option {
+ if is_spell_path(path)
+ || is_broken_or_off(path)
+ || is_light_beam(path)
+ || !lamp_family_ok(path)
+ {
+ return None;
+ }
+ let kind = lamp_kind(path);
+ let route = if kind.is_some() {
+ LightRoute::Name
+ } else if in_lamp_dir(path)
+ && batches
+ .iter()
+ .any(|b| b.unlit && b.blended && glow_texture(b.texture))
+ {
+ LightRoute::Emissive
+ } else {
+ return None;
+ };
+ // The bounding-box fallback is for FIXTURES, not fire containers — see [`LampKind::is_fire`].
+ let bbox_ok = !kind.is_some_and(LampKind::is_fire);
+ let (batch, position, bone) = lamp_position(batches, bounds, bbox_ok)?;
+ let (intensity, bucket) = lamp_intensity(kind);
+ Some(SyntheticLamp {
+ route,
+ batch,
+ position,
+ bone,
+ color: lamp_color(path),
+ intensity,
+ bucket,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{CellRamp, EmitParams, EmitTiming, ParamsNow, ParticleShape};
+
+ /// A minimal emitter def — only the fields the fire rule reads carry meaning.
+ fn def(
+ texture: Option<&str>,
+ blend: ParticleBlend,
+ keys: [[f32; 4]; 3],
+ size: f32,
+ rate: f32,
+ ) -> ParticleEmitterDef {
+ ParticleEmitterDef {
+ flags: 0,
+ position: [0.0, 0.0, 1.0],
+ bone: 0,
+ shape: ParticleShape::Plane,
+ blend,
+ lit: false,
+ texture: texture.map(str::to_string),
+ tile_rows: 1,
+ tile_cols: 1,
+ head_tail: 0,
+ timing: EmitTiming::constant(rate),
+ params: EmitParams::constant(ParamsNow::default()),
+ drag: 0.0,
+ tail_time: 0.0,
+ spline: None,
+ geometry_model: None,
+ recursion_model: None,
+ angular_velocity_min: [0.0; 3],
+ angular_velocity_max: [0.0; 3],
+ inherit_scale: 0.0,
+ follow_speed1: 0.0,
+ follow_scale1: 0.0,
+ follow_speed2: 0.0,
+ follow_scale2: 0.0,
+ twinkle_speed: 0.0,
+ twinkle_percent: 1.0,
+ twinkle_min: 0.0,
+ twinkle_max: 0.0,
+ spin: 0.0,
+ over_life: OverLife {
+ mid: 0.5,
+ color: keys,
+ scale: [size; 3],
+ head_cells: [CellRamp::new(0, 0); 2],
+ tail_cells: [CellRamp::new(0, 0); 2],
+ repeat: [1.0; 2],
+ },
+ }
+ }
+
+ fn close(a: [f32; 3], b: [f32; 3]) -> bool {
+ a.iter().zip(b).all(|(x, y)| (x - y).abs() < 2e-3)
+ }
+
+ /// GOLDEN — the colour rule on the three REAL ramps the heuristic was calibrated against
+ /// (dumped with `benilla-extract m2part`). Each is authored white-cored, so key 0 or a mean
+ /// would wash all three to the same pale nothing; the most-saturated key recovers the hue the
+ /// artist painted, and peak-normalising strips the brightness out of it.
+ #[test]
+ fn flame_color_takes_the_most_saturated_key() {
+ // OgreWallTorchpurple — FLAMELICKSMALLMAGICBLUE: white → violet → deep violet.
+ let purple = def(
+ Some("WORLD\\GENERIC\\OGRE\\TORCHES\\FLAMELICKSMALLMAGICBLUE.BLP"),
+ ParticleBlend::Add,
+ [
+ [1.0, 1.0, 1.0, 1.0],
+ [0.565, 0.212, 1.0, 1.0],
+ [0.42, 0.0, 0.745, 1.0],
+ ],
+ 0.3,
+ 20.0,
+ );
+ assert!(close(flame_color(&purple.over_life), [0.565, 0.212, 1.0]));
+
+ // OgreWallTorch — FLAMELICKSMALL: orange → tan → white burnout.
+ let warm = def(
+ Some("ITEM\\OBJECTCOMPONENTS\\FLAMELICKSMALL.BLP"),
+ ParticleBlend::Add,
+ [
+ [1.0, 0.282, 0.0, 1.0],
+ [0.875, 0.541, 0.184, 1.0],
+ [1.0, 1.0, 1.0, 1.0],
+ ],
+ 0.3,
+ 20.0,
+ );
+ assert!(close(flame_color(&warm.over_life), [1.0, 0.282, 0.0]));
+
+ // HumanBrazierMagic — FLAMELICK: green → pale green → near white.
+ let green = def(
+ Some("INTERFACE\\GLUES\\FLAMELICK.BLP"),
+ ParticleBlend::Add,
+ [
+ [0.0, 0.988, 0.0, 1.0],
+ [0.486, 0.976, 0.51, 1.0],
+ [0.851, 1.0, 0.843, 1.0],
+ ],
+ 0.6,
+ 30.0,
+ );
+ assert!(close(flame_color(&green.over_life), [0.0, 1.0, 0.0]));
+
+ // An all-white ramp carries no hue at all — the warm default, not white light.
+ let white = def(
+ Some("FIRE.BLP"),
+ ParticleBlend::Add,
+ [[1.0, 1.0, 1.0, 1.0]; 3],
+ 0.3,
+ 20.0,
+ );
+ assert_eq!(flame_color(&white.over_life), DEFAULT_WARM);
+ }
+
+ /// GOLDEN — the DETECTOR: flame art plus additive blend, and every near-miss that must stay
+ /// out. `GLOW` is the load-bearing exclusion (crystals/runestones), smoke is caught by blend.
+ #[test]
+ fn detector_admits_flames_and_refuses_the_near_misses() {
+ let keys = [[1.0, 0.3, 0.0, 1.0]; 3];
+ let fire = |t: Option<&str>, b| def(t, b, keys, 0.3, 20.0);
+
+ assert!(fire_emitter(&fire(Some("X\\FLAMELICKSMALL.BLP"), ParticleBlend::Add)));
+ assert!(fire_emitter(&fire(Some("X\\CANDLEFLAME.BLP"), ParticleBlend::Add)));
+ assert!(fire_emitter(&fire(Some("X\\TORCHFIRE.BLP"), ParticleBlend::Add)));
+ // Art that is not fire.
+ assert!(!fire_emitter(&fire(Some("X\\GLOW.BLP"), ParticleBlend::Add)));
+ assert!(!fire_emitter(&fire(Some("X\\SPELLGLOW32.BLP"), ParticleBlend::Add)));
+ assert!(!fire_emitter(&fire(Some("X\\SMOKE01.BLP"), ParticleBlend::Add)));
+ assert!(!fire_emitter(&fire(None, ParticleBlend::Add)));
+ // Fire art on a NON-additive emitter is the same model's smoke plume, not its flame.
+ assert!(!fire_emitter(&fire(
+ Some("X\\FIRESMOKE.BLP"),
+ ParticleBlend::Alpha
+ )));
+ // A dead emitter (zero rate) is not a light source.
+ assert!(!fire_emitter(&def(
+ Some("X\\FLAMELICK.BLP"),
+ ParticleBlend::Add,
+ keys,
+ 0.3,
+ 0.0
+ )));
+ // A SPARK — fire art, additive, but a 0.025 yd particle: a zeppelin's exhaust cinders, a
+ // flaming helm's glitter, a fire elemental's secondary embers. Below the flame floor.
+ assert!(!fire_emitter(&def(
+ Some("X\\EMBER_OFFSET.BLP"),
+ ParticleBlend::Add,
+ keys,
+ 0.025,
+ 200.0
+ )));
+ // A real candle clears it by a hair — the floor's whole calibration.
+ assert!(fire_emitter(&def(
+ Some("X\\FLAMELICKSMALL.BLP"),
+ ParticleBlend::Add,
+ keys,
+ 0.056,
+ 6.0
+ )));
+ }
+
+ /// GOLDEN — one light per model, from the STRONGEST fire emitter, and never for a spell.
+ #[test]
+ fn one_light_from_the_strongest_emitter() {
+ let embers = def(
+ Some("X\\EMBER.BLP"),
+ ParticleBlend::Add,
+ [[1.0, 0.2, 0.0, 1.0]; 3],
+ 0.08,
+ 40.0,
+ );
+ let flame = def(
+ Some("X\\FLAMELICKLARGE.BLP"),
+ ParticleBlend::Add,
+ [[0.2, 0.4, 1.0, 1.0]; 3],
+ 1.4,
+ 60.0,
+ );
+ let smoke = def(
+ Some("X\\FIRESMOKE.BLP"),
+ ParticleBlend::Alpha,
+ [[0.5, 0.5, 0.5, 1.0]; 3],
+ 2.0,
+ 10.0,
+ );
+ let got = synthesize_fire_light("WORLD\\GOBER\\BONFIRE.M2", [&embers, &flame, &smoke])
+ .expect("a fire");
+ assert_eq!(got.emitter, 1, "the big flame wins, not the ember shower");
+ assert!(close(got.color, [0.2, 0.4, 1.0]));
+ assert_eq!(got.bucket, "bonfire");
+ assert_eq!(got.intensity, 3.0);
+
+ // The candle end of the scale lands in its own bucket.
+ let candle = def(
+ Some("X\\CANDLEFLAME.BLP"),
+ ParticleBlend::Add,
+ [[1.0, 0.5, 0.1, 1.0]; 3],
+ 0.06,
+ 8.0,
+ );
+ let got = synthesize_fire_light("WORLD\\GOBER\\CANDLE.M2", [&candle]).expect("a fire");
+ assert_eq!(got.bucket, "candle");
+
+ // A spell effect never synthesises, however fiery — it would strobe the point table.
+ assert_eq!(
+ synthesize_fire_light("SPELLS\\FIREBALL_MISSILE.M2", [&flame]),
+ None
+ );
+ // The `NoOmni` twin DOES synthesise — it is Orgrimmar's street lighting (123 placed);
+ // only the absence of a flame emitter keeps a brazier cold.
+ assert!(
+ synthesize_fire_light("World\\Generic\\Orc\\Braziers\\TallBrazierNoOmni01.m2", [&flame])
+ .is_some()
+ );
+ assert_eq!(synthesize_fire_light("WORLD\\X.M2", [&smoke]), None);
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // MONKEY (lamp lights): the emissive-geoset route.
+ // -----------------------------------------------------------------------------------------
+
+ /// A minimal render-batch view. `positions` is handed in by the caller because
+ /// [`EmissiveBatch`] borrows it — a lamp's glass is a 16-vertex box, and only its centroid and
+ /// its vertex count are ever read.
+ fn batch<'a>(
+ texture: Option<&'a str>,
+ unlit: bool,
+ blended: bool,
+ positions: &'a [[f32; 3]],
+ ) -> EmissiveBatch<'a> {
+ EmissiveBatch {
+ texture,
+ unlit,
+ blended,
+ positions,
+ bone: 0,
+ }
+ }
+
+ /// GOLDEN — route (a), the NAME hit, on the model the whole feature was written for.
+ /// `StormwindStreetlamp01` (40 placements inside the Stormwind WMO) authors NO particle
+ /// emitter at all and draws its glass **opaque + unlit** — blend 0 — so neither the flame rule
+ /// nor any blend-mode test sees it. The light must land on the glass at z ≈ 4.35, four yards
+ /// above the post's own origin at the base: that vertical offset IS the fix.
+ #[test]
+ fn name_route_lights_a_lamppost_at_its_glass() {
+ let post: Vec<[f32; 3]> = vec![[0.0, 0.0, 0.0], [0.5, 0.0, 4.0], [-0.5, 0.0, 2.0]];
+ let glass: Vec<[f32; 3]> = vec![
+ [0.5, 0.5, 4.1],
+ [-0.5, 0.5, 4.1],
+ [0.5, -0.5, 4.6],
+ [-0.5, -0.5, 4.6],
+ ];
+ let batches = [
+ batch(Some("X\\STREETLAMP.BLP"), false, false, &post),
+ batch(Some("DUNGEONS\\STORMWINDLAMPGLASS.BLP"), true, false, &glass),
+ ];
+ let got = synthesize_lamp_light(
+ "World\\Generic\\Human\\Passive Doodads\\Lamps\\StormwindStreetlamp01.m2",
+ &batches,
+ Some(([-0.6, -0.6, 0.0], [0.6, 0.6, 4.9])),
+ )
+ .expect("a lamppost");
+ assert_eq!(got.route, LightRoute::Name);
+ assert_eq!(got.batch, Some(1), "the glass, not the post");
+ assert!(
+ (got.position[2] - 4.35).abs() < 1e-3,
+ "the light rides the glass, not the origin: {:?}",
+ got.position
+ );
+ assert_eq!(got.color, DEFAULT_LAMP);
+ assert_eq!((got.intensity, got.bucket), (1.5, "lamp"));
+ }
+
+ /// GOLDEN — route (b), the NAMELESS hit: a fixture-folder model whose name says nothing
+ /// (`Scholme_Candelabra`-shaped), carried entirely by its unlit + transparent + glow-textured
+ /// batch. All three signals are required, and the folder is the load-bearing one.
+ #[test]
+ fn emissive_route_needs_a_fixture_folder_and_all_three_signals() {
+ let body: Vec<[f32; 3]> = vec![[0.0, 0.0, 0.0]];
+ let flame: Vec<[f32; 3]> = vec![[0.0, 0.0, 1.0], [0.2, 0.0, 1.4]];
+ let path = "World\\Lordaeron\\Scholomance\\PassiveDoodads\\Candles\\Scholme_Wax03.m2";
+
+ let hit = [
+ batch(Some("X\\WAX.BLP"), false, false, &body),
+ batch(Some("X\\GENERICGLOW64.BLP"), true, true, &flame),
+ ];
+ let got = synthesize_lamp_light(path, &hit, None).expect("a fixture");
+ assert_eq!(got.route, LightRoute::Emissive);
+ assert_eq!(got.batch, Some(1));
+ assert!((got.position[2] - 1.2).abs() < 1e-3, "{:?}", got.position);
+ // No name to classify on ⇒ the generic lamp rung and the open-flame default hue.
+ assert_eq!((got.intensity, got.bucket), (1.5, "lamp"));
+ assert_eq!(got.color, DEFAULT_WARM);
+
+ // The same geometry filed OUTSIDE a fixture folder is a blood-elf banner / a Ghostlands
+ // tree / a PvP rune — the 149-model false-positive population the folder gate removed.
+ assert_eq!(
+ synthesize_lamp_light(
+ "World\\Expansion01\\Doodads\\Generic\\BloodElf\\Banners\\BE_Banner02.m2",
+ &hit,
+ None
+ ),
+ None
+ );
+ // In the folder, but the batch is LIT (no 0x01) — ordinary painted geometry.
+ let lit = [batch(Some("X\\GENERICGLOW64.BLP"), false, true, &flame)];
+ assert_eq!(synthesize_lamp_light(path, &lit, None), None);
+ // In the folder and unlit, but OPAQUE (blend 0): no corroboration for a nameless hit.
+ let opaque = [batch(Some("X\\GENERICGLOW64.BLP"), true, false, &flame)];
+ assert_eq!(synthesize_lamp_light(path, &opaque, None), None);
+ // In the folder, unlit and transparent, but the art is not glow art.
+ let plain = [batch(Some("X\\WOOD01.BLP"), true, true, &flame)];
+ assert_eq!(synthesize_lamp_light(path, &plain, None), None);
+ }
+
+ /// GOLDEN — the NEAR-MISSES that must stay dark. Each one shipped, each one reached the first
+ /// cut of the rule, and each one would have been visible as a wrongly glowing object.
+ #[test]
+ fn lamp_route_refuses_the_near_misses() {
+ let verts: Vec<[f32; 3]> = vec![[0.0, 0.0, 1.0], [0.0, 0.0, 2.0]];
+ let glow = [batch(Some("X\\GLOW32.BLP"), true, true, &verts)];
+ let bbox = Some(([-1.0, -1.0, 0.0], [1.0, 1.0, 3.0]));
+
+ // A GLOW card on a model that is not a fixture and not in a fixture folder: the whole
+ // magic-prop corpus (runestones, jewels, holograms, teleport pads, banners, trees).
+ assert_eq!(
+ synthesize_lamp_light(
+ "World\\Expansion01\\Doodads\\Generic\\BloodElf\\RuneStone\\BE_Runestone01.m2",
+ &glow,
+ bbox
+ ),
+ None
+ );
+ // A light BEAM in a `…Lights\` folder — lit air, not a source. Its centroid is mid-shaft.
+ assert_eq!(
+ synthesize_lamp_light(
+ "World\\NoDXT\\Generic\\PassiveDoodads\\VolumetricLights\\LightShaftA.m2",
+ &glow,
+ bbox
+ ),
+ None
+ );
+ // The artist's extinguished twin — `CandleOff01`, which Stormwind's WMO places 65 times.
+ // The serial number has to come off before the `OFF` suffix is read.
+ assert_eq!(
+ synthesize_lamp_light(
+ "World\\Generic\\PassiveDoodads\\Lights\\CandleOff01.m2",
+ &glow,
+ bbox
+ ),
+ None
+ );
+ assert_eq!(
+ synthesize_lamp_light(
+ "World\\Generic\\Human\\Passive Doodads\\LampPosts\\DuskwoodLamppostBroken.m2",
+ &glow,
+ bbox
+ ),
+ None
+ );
+ assert_eq!(
+ synthesize_lamp_light(
+ "world\\expansion07\\doodads\\dungeon\\doodads\\8du_waycrest_candlestand01_unlit.m2",
+ &glow,
+ bbox
+ ),
+ None
+ );
+ // A COLD brazier: a fire container with no flame emitter and not one glowing polygon. The
+ // bounding-box fallback is refused to it, so it stays dark.
+ let cold: Vec<[f32; 3]> = vec![[0.0, 0.0, 0.5]];
+ let plain = [batch(Some("X\\METAL01.BLP"), false, false, &cold)];
+ assert_eq!(
+ synthesize_lamp_light(
+ "World\\KhazModan\\Uldaman\\PassiveDoodads\\Braziers\\UldamanBrazier01.m2",
+ &plain,
+ bbox
+ ),
+ None
+ );
+ // …while a LAMP with the same unflagged geometry keeps the fallback: `UldamanLamp`,
+ // `IronForgeHangingLantern01`, `AhnQirajSconce01` are real fixtures whose glass simply
+ // isn't flagged, and the box top is the least-wrong guess for a pole-shaped prop.
+ let lamp = synthesize_lamp_light(
+ "World\\KhazModan\\Uldaman\\PassiveDoodads\\Lamps\\UldamanLamp.m2",
+ &plain,
+ bbox,
+ )
+ .expect("a lamp");
+ assert_eq!(lamp.batch, None, "the bbox fallback");
+ assert_eq!(lamp.position, [0.0, 0.0, 3.0], "box top centre");
+ // A fixture folder whose only unlit card is the BEAM it throws (`HazardLightBeam.blp`):
+ // the cone, not the source, and its centroid is a yard out along the beam.
+ let beam = [batch(Some("X\\HAZARDLIGHTBEAM.BLP"), true, true, &verts)];
+ assert!(!glow_texture(Some("X\\HAZARDLIGHTBEAM.BLP")));
+ assert_eq!(
+ synthesize_lamp_light(
+ "World\\Generic\\Gnome\\Passive Doodads\\HazardLights\\GnomeHazardLight01.m2",
+ &beam,
+ bbox
+ ),
+ None
+ );
+ // Outside placed world scenery entirely — a carried lantern, a spell, an item.
+ assert_eq!(
+ synthesize_lamp_light(
+ "Item\\ObjectComponents\\Weapon\\Misc_1H_Lantern_A_01.m2",
+ &glow,
+ bbox
+ ),
+ None
+ );
+ assert_eq!(
+ synthesize_lamp_light("SPELLS\\Lantern_Impact.m2", &glow, bbox),
+ None
+ );
+ }
+
+ /// GOLDEN — the kind/colour/intensity table: the three rungs, and the magic-hue override that
+ /// the artist wrote into the filename precisely because the warm default would be wrong there.
+ #[test]
+ fn lamp_kind_colour_and_intensity() {
+ assert_eq!(lamp_kind("x\\KarazanChandelier_01.m2"), Some(LampKind::Chandelier));
+ assert_eq!(lamp_kind("x\\Scholme_Candelabra.m2"), Some(LampKind::Candle));
+ assert_eq!(lamp_kind("x\\SkullCandle01.m2"), Some(LampKind::Candle));
+ assert_eq!(lamp_kind("x\\DuskwoodLamppost.m2"), Some(LampKind::Lamp));
+ assert_eq!(lamp_kind("x\\GeneralHangingLantern01.m2"), Some(LampKind::Lamp));
+ assert_eq!(lamp_kind("x\\UldamanBrazier01.m2"), Some(LampKind::Brazier));
+ assert_eq!(lamp_kind("x\\NA_Torch01.m2"), Some(LampKind::Torch));
+ assert_eq!(lamp_kind("x\\BE_Banner02.m2"), None);
+
+ assert_eq!(lamp_intensity(Some(LampKind::Chandelier)), (2.0, "chandelier"));
+ assert_eq!(lamp_intensity(Some(LampKind::Candle)), (0.6, "candle"));
+ assert_eq!(lamp_intensity(Some(LampKind::Lamp)), (1.5, "lamp"));
+ assert_eq!(lamp_intensity(None), (1.5, "lamp"));
+
+ // Every rung must be one the reach table already knows (6 / 12 / 16 / 24 yd).
+ for k in [
+ None,
+ Some(LampKind::Chandelier),
+ Some(LampKind::Candle),
+ Some(LampKind::Brazier),
+ Some(LampKind::Torch),
+ ] {
+ let i = lamp_intensity(k).0;
+ assert!([0.6f32, 1.5, 2.0, 3.0].contains(&i), "off-rung intensity {i}");
+ }
+
+ assert_eq!(lamp_color("x\\StormwindStreetlamp01.m2"), DEFAULT_LAMP);
+ assert_eq!(lamp_color("x\\NE_LanternBlue01.m2"), [0.45, 0.65, 1.0]);
+ assert_eq!(lamp_color("x\\BE_lantern_red_001.m2"), [1.0, 0.35, 0.25]);
+ assert_eq!(lamp_color("x\\Scholme_GreenCandelabra.m2"), [0.40, 1.0, 0.45]);
+ // No lamp word at all ⇒ the open-flame default, shared with the particle route.
+ assert_eq!(lamp_color("x\\Scholme_Wax03.m2"), DEFAULT_WARM);
+ }
+}
diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs
index 86bc0f05..bd92f5e1 100644
--- a/crates/benilla-formats/src/lib.rs
+++ b/crates/benilla-formats/src/lib.rs
@@ -218,6 +218,18 @@ pub use particles::{
parse_m2_particle_emitters, CellRamp, OverLife, OverLifeSample, ParticleBlend,
ParticleEmitterDef, ParticleShape, SplineData,
};
+// MONKEY (fire GO lights): the SYNTHESISED-light rule for fire props that author no light block —
+// derived from the model's own flame emitter. Beside `particles` because that is its whole input.
+pub mod fire_light;
+pub use fire_light::{synthesize_fire_light, SyntheticFire};
+// MONKEY (lamp lights): the SECOND route in that same module — an emissive lamp/lantern
+// GEOSET rather than a flame emitter (a lamppost authors no particles at all).
+pub use fire_light::{synthesize_lamp_light, EmissiveBatch, LightRoute, SyntheticLamp};
+// MONKEY (portal claims): which ROOMS one fixture may light -- containment, MOLR, and one portal
+// hop. Shared by the runtime spawner and the `wmolights`/`wmolamps` audits so the gate the shader
+// enforces and the gate the instrument prints can never be two different rules.
+pub mod room_claim;
+pub use room_claim::{claim_groups, room_claims, Claim, ClaimHow, PortalGraph};
mod ribbons;
pub use ribbons::{parse_m2_ribbon_emitters, RibbonEmitterDef, RibbonVisibility};
mod value_track;
diff --git a/crates/benilla-formats/src/room_claim.rs b/crates/benilla-formats/src/room_claim.rs
new file mode 100644
index 00000000..31c78b2e
--- /dev/null
+++ b/crates/benilla-formats/src/room_claim.rs
@@ -0,0 +1,676 @@
+//! MONKEY (portal claims): **which rooms one light fixture may light** — the single rule, shared by
+//! the runtime spawner (`benilla_world::terrain_stream::spawn`'s MOLT and MODD-prop lanes, which
+//! turn it into the per-light claim list the room gate packs) and the offline audit
+//! (`benilla-extract wmolights` / `wmolamps`, which is the only instrument that can measure it
+//! across the shipped corpus). One rule, one implementation — the `fire_light` precedent.
+//!
+//! The gate itself lives in `static_gx.wgsl`: a WMO surface of group `G` takes an interior fixture
+//! only if that fixture's claim set contains `G`. The set has three sources, in this priority:
+//!
+//! 1. **CONTAINMENT** — the interior groups whose authored MOGI bounding box holds the fixture.
+//! The dense supplement to the sparse MOLR relation (the Goldshire inn authors a MOLR on 2 of
+//! its 12 groups; Northshire abbey leaves 7 of 14 unnamed), ordered TIGHTEST BOX FIRST so the
+//! room a fixture actually stands in outranks the building-spanning shell that also contains it.
+//! 2. **MOLR** — the groups whose authored light-ref list names the fixture. Authored for the
+//! reference's own purpose (register a GL light while drawing a visible group's doodads), which
+//! is why it cannot carry the gate alone, but it is the artist speaking and it is never wrong.
+//! 3. **PORTAL-ADJACENT** — the groups up to [`ROOM_CLAIM_HOPS`] portal hops from a claimed group,
+//! when the doorways lie within the fixture's effective radius. This is the "light crosses an
+//! open doorway" rule: without it a fixture stops dead at its room's edge and a continuous floor
+//! changes brightness in a straight line at the group boundary, which is exactly the seam the
+//! owner reported. Nearest portal first, so a doorway three rooms away never takes the slot a
+//! doorway in this wall wants.
+//!
+//! MONKEY (soft portal claims): a portal claim is **not binary**. It carries the doorway it came
+//! through ([`Claim::fade_center`]/[`Claim::fade_slack`]) and the reach left over on the far side
+//! ([`Claim::fade_radius`]), and the shader turns those into a WEIGHT in `[0, 1]` — 1 in the
+//! doorway, 0 where the reach runs out. That is what actually closes the seam: a binary claim
+//! merely moved the step from "this room" to "the last room the hop reached", and admitting a
+//! fixture on one side of a group plane while refusing it on the other is a step wherever the
+//! FLOOR crosses that plane, which is precisely what a doorway is. Containment and MOLR claims
+//! stay hard (weight 1) — a fixture standing in a room lights all of it.
+//!
+//! Sources 1+2 are the **base**: they must never be truncated (a dropped containment claim is a
+//! black room, and the packer's overflow arm deliberately fails OPEN for that reason). Source 3 only
+//! ever fills slots the base left free — a dropped portal claim is one doorway that does not carry
+//! light, which is the behaviour we already had.
+
+use crate::{WmoGroupInfo, WmoPortalInfo, WmoPortalRef};
+
+/// Claims one fixture can carry. **Must equal `benilla_world::lighting::ROOM_CLAIM_MAX` and
+/// `ROOM_CLAIM_MAX` in `static_gx.wgsl`** — past it the packer gives up and packs the fixture
+/// UNGATED, so a claim list built here longer than this is not a wider gate, it is no gate at all.
+pub const ROOM_CLAIM_MAX: usize = 6;
+
+/// The portal graph of ONE WMO root, as the claim rule reads it: the MOPV vertex pool, the MOPT
+/// portal records (each a slice of that pool plus its plane), the MOPR edges, and the per-group
+/// slice of those edges (`MOGP +0x24/+0x26`, i.e. [`crate::WmoGroupHeader::portal_ref_start`] /
+/// `portal_ref_count`) indexed by ABSOLUTE group index. All in WMO model space, WoW axes — the same
+/// space [`WmoGroupInfo`]'s boxes and a MOLT record's position are in, which is why the whole rule
+/// can run before anything is placed in the world.
+#[derive(Clone, Copy, Default)]
+pub struct PortalGraph<'a> {
+ pub vertices: &'a [[f32; 3]],
+ pub infos: &'a [WmoPortalInfo],
+ pub refs: &'a [WmoPortalRef],
+ pub slices: &'a [(u16, u16)],
+}
+
+/// How a group came to be claimed — the audit's readout, and the reason the list is ordered.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum ClaimHow {
+ /// The group's MOGI box contains the fixture.
+ Contains,
+ /// The group's MOLR names the fixture.
+ Molr,
+ /// One portal hop from a claimed group, the portal within the fixture's reach.
+ Portal,
+}
+
+impl ClaimHow {
+ pub fn tag(self) -> &'static str {
+ match self {
+ ClaimHow::Contains => "in",
+ ClaimHow::Molr => "molr",
+ ClaimHow::Portal => "portal",
+ }
+ }
+}
+
+/// One claimed group.
+#[derive(Clone, Copy, Debug)]
+pub struct Claim {
+ pub group: u16,
+ pub how: ClaimHow,
+ /// Yards from the fixture to the portal it came through ([`ClaimHow::Portal`] only; 0 else).
+ pub distance: f32,
+ /// The claimed group's own class — `false` for an EXTERIOR-flagged group (MOGP `& 0x48`).
+ /// Carried because an exterior-class group inside a building (an inn's basement stairwell, a
+ /// covered porch) is drawn by the exterior law and needs a claim to be lit at all.
+ pub interior: bool,
+ /// May the EXTERIOR batch lane honour this claim? See [`CLAIM_EXT_SHELL_YD`]: a district-scale
+ /// shell is kept in the set (dropping it would leave the fixture with NO claims, and the
+ /// packer's no-claims arm fails OPEN — a far worse leak than the one being avoided) but is
+ /// barred from the exterior lane, where it would put a tavern candle on the street outside.
+ /// The interior lane is unaffected either way: it never runs on an exterior-class group's
+ /// surfaces, so an exterior claim exists for the exterior lane and for nothing else.
+ pub ext_ok: bool,
+ /// MONKEY (soft portal claims): how many doorways the light had to cross to reach this group —
+ /// `0` for the base (containment/MOLR), `1`/`2` for a portal hop. Ordering and audit readout
+ /// only; the shader never sees it (the fade radius below already encodes "how much reach is
+ /// left", which is the same fact measured in yards instead of doors).
+ pub hops: u8,
+ /// MONKEY (soft portal claims): the centre of the portal this claim came through, in the SAME
+ /// space `pos` is in (WMO model space, WoW axes) — the point the shader's weight fades away
+ /// from. Meaningless when `fade_radius == 0`.
+ pub fade_center: [f32; 3],
+ /// MONKEY (soft portal claims): the portal polygon's own bounding-SPHERE radius (yd). Distance
+ /// measured from `fade_center` is reduced by this before the fade, so every fragment standing
+ /// IN the doorway reads weight 1 — which is what makes the far side of the threshold
+ /// continuous with the near side (see [`Claim::fade_radius`]).
+ pub fade_slack: f32,
+ /// MONKEY (soft portal claims): the yards of the fixture's reach still unspent when it arrived
+ /// at `fade_center` — the distance over which the shader smoothsteps this claim's weight from
+ /// 1 (at the doorway) to 0. **`0` means a HARD claim: weight 1 everywhere**, which is what
+ /// every containment/MOLR claim carries, because a fixture standing in a room lights all of it.
+ ///
+ /// This is what turns the binary gate soft. The gate used to answer yes/no per group, so a
+ /// fixture admitted on one side of a group boundary and refused on the other put a straight
+ /// brightness STEP across any floor that crossed it — the scar on the Lion's Pride Inn's
+ /// upstairs landing, where two groups meet mid-plank. With a weight the near side is 1 by
+ /// containment, the far side is ~1 at the threshold (distance to the doorway ~ 0) and decays
+ /// to 0 where the fixture's reach runs out anyway, so nothing steps at the plane.
+ pub fade_radius: f32,
+ /// MONKEY (soft portal claims): the weight this claim ALREADY carries at its own doorway — 1
+ /// for a base claim and for a first hop, and for a second hop the weight the FIRST hop's fade
+ /// had already fallen to by the time it reached this door. The shader multiplies its
+ /// smoothstep by it.
+ ///
+ /// Without it the fade is continuous at the first threshold and discontinuous at the second,
+ /// which is the same bug one room further in: the near room reads (say) 0.4 at its far door
+ /// because the first fade has been decaying across it, while the room beyond restarts its own
+ /// fade at 1.0 — a step, and a step the WRONG WAY (the further room brighter than the nearer
+ /// one). Seeding each hop with its predecessor's value at that point makes the whole chain
+ /// continuous by construction, and monotone: light never gets stronger further from its source.
+ pub fade_entry: f32,
+}
+
+/// The `interiorAttenScale` DEFAULT. A claim set is built ONCE, at spawn, and never rebuilt — but
+/// the reach the shader windows a fixture with is live (the cvar). So the portal hop is measured
+/// against the DEFAULT reach rather than the live one: turning the cvar up widens every pool but
+/// cannot open a doorway that was gated at spawn, and turning it down cannot slam one shut
+/// mid-frame. That is the stable half of the trade, and it keeps the claim table free of per-frame
+/// work; the alternative (re-claiming every fixture whenever the cvar moves) buys a doorway's worth
+/// of light for a rebuild of the whole table. **Mirrors `DynamicInteriors::atten_scale`'s default.**
+pub const CLAIM_ATTEN_SCALE: f32 = 1.6;
+
+/// The cap on any claim reach — `benilla_world::lighting`'s `INTERIOR_LEGACY_REACH` /
+/// `POINT_LIGHT_RANGE`, i.e. the widest a fixture's window is ever packed.
+pub const CLAIM_REACH_MAX: f32 = 48.0;
+
+/// The effective radius (yd) a fixture's portal hop is measured against: its AUTHORED MOLT
+/// attenuation end scaled by [`CLAIM_ATTEN_SCALE`], clamped exactly as the packer's
+/// `interior_reach` clamps it.
+pub fn claim_reach(authored_end: f32) -> f32 {
+ (authored_end * CLAIM_ATTEN_SCALE).clamp(1.0, CLAIM_REACH_MAX)
+}
+
+/// The interior reach (yd) of an **M2** point light of committed intensity `i` — the bucket ladder
+/// [`crate::fire_light::fire_intensity`] buckets a synthesised flame onto (candle 0.6 / torch 1.5 /
+/// brazier 2.0 / bonfire 3.0), because the authored M2 attenuation pair is demonstrably not a reach
+/// in this corpus (a Karazhan BONFIRE authors `end = 0.97` yd). Lives here rather than in the
+/// packer that reads it so the offline audit buckets a prop's synthesised light onto the very same
+/// rung the runtime will; `benilla_world::lighting::m2_light_reach` delegates to it.
+pub fn m2_light_reach(intensity: f32) -> f32 {
+ match intensity {
+ i if i < 1.0 => 6.0, // candle
+ i if i < 1.75 => 12.0, // torch / campfire
+ i if i < 2.5 => 16.0, // brazier
+ _ => 24.0, // bonfire / forge
+ }
+}
+
+/// The AABB of one portal's polygon, or `None` when the record names no usable vertices.
+fn portal_box(portals: &PortalGraph, portal: u16) -> Option<([f32; 3], [f32; 3])> {
+ let info = portals.infos.get(usize::from(portal))?;
+ let start = usize::from(info.start_vertex);
+ let end = start.checked_add(usize::from(info.count))?;
+ let verts = portals.vertices.get(start..end)?;
+ let (first, rest) = verts.split_first()?;
+ let mut lo = *first;
+ let mut hi = *first;
+ for v in rest {
+ for a in 0..3 {
+ lo[a] = lo[a].min(v[a]);
+ hi[a] = hi[a].max(v[a]);
+ }
+ }
+ Some((lo, hi))
+}
+
+/// A portal as the claim rule measures it, from `pos`: `(distance, centre, slack)`.
+///
+/// **distance** — yards from `pos` to the portal's polygon, approximated by its AABB (the closest
+/// point on the box), which is exact for the axis-aligned doorways most portals are and never
+/// over-states the distance for the rest (erring toward admitting a doorway, which is the safe
+/// direction: an extra claim is a room lit one doorway too far, a missing one is the seam we are
+/// fixing).
+///
+/// MONKEY (soft portal claims): **centre** and **slack** (the box's half-diagonal) are the fade
+/// geometry the shader gets. Fading from the centre alone would put a step back across the WIDTH
+/// of the door — a fragment at the edge of a 1.2 x 2.5 yd doorway is ~1.4 yd from its centre, so
+/// with a short remaining reach it would read half-lit one plank away from a fully lit fragment on
+/// the other side of the plane. Subtracting the slack collapses the whole doorway to distance 0, so
+/// the threshold is weight 1 on BOTH sides by construction and the fade starts where the door frame
+/// ends. A sphere rather than the box because it is one number: slightly generous just outside the
+/// frame, which is the direction that cannot re-introduce an edge.
+fn portal_reach(portals: &PortalGraph, portal: u16, pos: [f32; 3]) -> Option<(f32, [f32; 3], f32)> {
+ let (lo, hi) = portal_box(portals, portal)?;
+ let mut d2 = 0.0f32;
+ let mut center = [0.0f32; 3];
+ let mut diag2 = 0.0f32;
+ for a in 0..3 {
+ let over = (lo[a] - pos[a]).max(pos[a] - hi[a]).max(0.0);
+ d2 += over * over;
+ center[a] = 0.5 * (lo[a] + hi[a]);
+ let half = 0.5 * (hi[a] - lo[a]).max(0.0);
+ diag2 += half * half;
+ }
+ Some((d2.sqrt(), center, diag2.sqrt()))
+}
+
+/// MONKEY (soft portal claims): how many portal hops a claim may travel. **1 is not enough**: a
+/// fixture two rooms from a fragment is claimed on the near side of a group boundary (one hop) and
+/// not at all on the far side, which is a hard step again — the very seam the fade exists to
+/// remove, moved one room outward. Two hops means the far side also carries the fixture, at the
+/// small weight its remaining reach affords. Three would buy nothing measurable: by the second
+/// doorway the unspent reach is already at [`CLAIM_FADE_MIN_YD`] in most vanilla interiors, and
+/// each extra hop multiplies the frontier while competing for the same six slots.
+pub const ROOM_CLAIM_HOPS: u8 = 2;
+
+/// MONKEY (soft portal claims): the floor on a portal claim's fade radius (yd). A fixture that only
+/// just reaches a doorway has ~0 reach left, and a 0-yard smoothstep is a step function — i.e. the
+/// hard edge again, at the doorway instead of at the group plane. Two yards is the shortest fade
+/// that reads as a fade at walking speed, and a fixture that far gone contributes almost nothing
+/// anyway (its own `interior_window` is at the end of its curve), so the floor cannot leak light.
+pub const CLAIM_FADE_MIN_YD: f32 = 2.0;
+
+/// MONKEY (soft portal claims): a hop whose [`Claim::fade_entry`] is already below this is not
+/// worth one of the six slots — it can contribute at most this fraction of one fixture's already
+/// attenuated pool, and the slot is better left to a doorway that can be seen. Dropping it cannot
+/// re-open the seam it exists to close: the room on the NEAR side of that door is, by definition,
+/// this dim there too, so both sides read ~0 and the boundary stays continuous.
+pub const CLAIM_FADE_MIN_ENTRY: f32 = 0.02;
+
+/// `smoothstep(0, edge, x)` — the shader's own curve, so the entry weight this rule bakes and the
+/// weight `static_gx.wgsl` evaluates are the same function sampled at the same point. (Rust has no
+/// `smoothstep`; WGSL's is the Hermite `t*t*(3 - 2t)` on the clamped ratio.)
+fn smoothstep0(edge: f32, x: f32) -> f32 {
+ let t = (x / edge.max(1e-4)).clamp(0.0, 1.0);
+ t * t * (3.0 - 2.0 * t)
+}
+
+/// The largest HORIZONTAL extent (yd) an EXTERIOR-class group may have and still be honoured by the
+/// EXTERIOR batch lane — "a building, not a district".
+///
+/// An exterior-class group is drawn by the exterior law and takes no interior fixture, so a claim on
+/// one is consumed by exactly one thing: the exterior batch's claim-gated room term (bug B's second
+/// cause — the inn's basement stairwell is an EXTERIOR group inside a building and reads night-sky
+/// green next to its candle-lit neighbours). That term must reach an annex like the inn's shell
+/// (`57.7 x 32.2 yd`) and must NOT reach a city's district shell, whose MOGI box swallows the
+/// interiors inside it: measured on `Stormwind.wmo`, 494 of its 606 fixtures stand inside one of
+/// ~8 exterior shells spanning 122-660 yd (g107 alone contains 90 of them), and admitting those
+/// would put a tavern candle's pool on the cobbles outside the tavern wall. The shipped corpus
+/// leaves a wide gap between the two populations — no Stormwind shell is under 122 yd — so the cut
+/// sits in the middle of it rather than on either edge.
+///
+/// A PORTAL claim is exempt: a portal is the artist saying these two groups share a doorway, and
+/// light through a door onto the street is right (it is how a lit doorway reads at night).
+pub const CLAIM_EXT_SHELL_YD: f32 = 96.0;
+
+/// Is a BOX claim (containment or MOLR) on this group eligible for the exterior lane? Interior
+/// groups trivially are (the exterior lane never draws them); an exterior group only at building
+/// scale (see [`CLAIM_EXT_SHELL_YD`]).
+fn claimable_by_box(g: &WmoGroupInfo) -> bool {
+ g.interior
+ || (0..2).all(|a| (g.bbox_max[a] - g.bbox_min[a]) <= CLAIM_EXT_SHELL_YD)
+}
+
+/// `pos` inside a group's authored MOGI box.
+fn contains(g: &WmoGroupInfo, pos: [f32; 3]) -> bool {
+ (0..3).all(|a| pos[a] >= g.bbox_min[a] && pos[a] <= g.bbox_max[a])
+}
+
+/// The box's volume — the containment ORDER key. A room is small, the shell that spans the whole
+/// building is not, and when six slots have to be spent the room the fixture stands in must get one.
+fn volume(g: &WmoGroupInfo) -> f32 {
+ (0..3)
+ .map(|a| (g.bbox_max[a] - g.bbox_min[a]).max(0.0))
+ .product()
+}
+
+/// The claim set of ONE fixture at `pos` (WMO model space, WoW axes) with effective radius `reach`
+/// yards, whose MOLR referrers are `molr`. Ordered by the priority in the module doc and capped at
+/// [`ROOM_CLAIM_MAX`] — **except** that the base (containment ∪ MOLR) is returned whole even when it
+/// is longer: the packer's own overflow arm decides that case (MONKEY (GO room claims): it keeps
+/// the first [`ROOM_CLAIM_MAX`] in this priority order — it used to fail open, which lit the whole
+/// building through its walls), so this function keeps the full ordered set for the audits.
+///
+/// `reach` of 0 or less disables the portal hop entirely (a fixture with no usable window).
+pub fn room_claims(
+ groups: &[WmoGroupInfo],
+ portals: PortalGraph<'_>,
+ pos: [f32; 3],
+ reach: f32,
+ molr: &[u16],
+) -> Vec {
+ let mut out: Vec = Vec::new();
+ // MONKEY (soft portal claims): `fade` is `None` for a HARD claim (containment/MOLR — weight 1
+ // everywhere) and `Some((centre, slack, radius))` for a portal hop.
+ let push = |out: &mut Vec,
+ group: u16,
+ how: ClaimHow,
+ distance: f32,
+ hops: u8,
+ fade: Option<([f32; 3], f32, f32, f32)>| {
+ if out.iter().any(|c| c.group == group) {
+ return;
+ }
+ let g = groups.get(usize::from(group));
+ let (fade_center, fade_slack, fade_radius, fade_entry) =
+ fade.unwrap_or(([0.0; 3], 0.0, 0.0, 1.0));
+ out.push(Claim {
+ group,
+ how,
+ distance,
+ interior: g.is_some_and(|g| g.interior),
+ // A portal is the artist saying these two groups share a doorway, so it is always
+ // exterior-lane eligible; a box claim has to be building-scale to be.
+ ext_ok: how == ClaimHow::Portal || g.is_none_or(claimable_by_box),
+ hops,
+ fade_center,
+ fade_slack,
+ fade_radius,
+ fade_entry,
+ });
+ };
+ // 1. CONTAINMENT, tightest box first.
+ let mut inside: Vec<(usize, bool, f32)> = groups
+ .iter()
+ .enumerate()
+ .filter(|(_, g)| contains(g, pos))
+ .map(|(i, g)| (i, !claimable_by_box(g), volume(g)))
+ .collect();
+ // Tightest box first, and a district-scale shell (which no lane will light from) after every
+ // real room, so it can never take the slot a portal hop into the next room wanted.
+ inside.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.total_cmp(&b.2)));
+ for (i, _, _) in inside {
+ push(&mut out, i as u16, ClaimHow::Contains, 0.0, 0, None);
+ }
+ // 2. MOLR.
+ for &g in molr {
+ push(&mut out, g, ClaimHow::Molr, 0.0, 0, None);
+ }
+ // 3. PORTAL-ADJACENT — up to [`ROOM_CLAIM_HOPS`] doorways off the base, nearest portal first
+ // within each hop, and every hop's claim carries the fade that makes it SOFT.
+ //
+ // MONKEY (soft portal claims): this used to stop at ONE hop, and stopping there was itself a
+ // hard edge. A fixture two rooms from a group boundary is claimed on the near side (one hop)
+ // and refused on the far side, so the binary gate's step simply moved one room outward —
+ // the same scar, a doorway further along. The wavefront below SPENDS the fixture's reach as
+ // it walks: each hop's remaining reach becomes the fade radius past that doorway, so by the
+ // second door the weight is already small and a third door would not be worth a slot.
+ if out.len() >= ROOM_CLAIM_MAX || reach <= 0.0 || portals.infos.is_empty() {
+ return out;
+ }
+ // The wavefront: `(group, yards ALREADY SPENT getting there)`. The base is at zero — the
+ // fixture physically stands in those groups.
+ // `(group, yards spent, index of the claim in `out`)` — the index is what lets the next hop
+ // sample its predecessor's fade at the door it is about to cross (see [`Claim::fade_entry`]).
+ let mut frontier: Vec<(u16, f32, usize)> = out
+ .iter()
+ .enumerate()
+ .map(|(i, c)| (c.group, 0.0, i))
+ .collect();
+ for hop in 1..=ROOM_CLAIM_HOPS {
+ if out.len() >= ROOM_CLAIM_MAX {
+ break;
+ }
+ // `(group, spent, portal centre, portal slack, seed claim)`, deduped to the CHEAPEST
+ // way in.
+ let mut next: Vec<(u16, f32, [f32; 3], f32, usize)> = Vec::new();
+ for &(g, spent, seed) in &frontier {
+ let Some(&(start, count)) = portals.slices.get(usize::from(g)) else {
+ continue;
+ };
+ let from = usize::from(start);
+ let to = from.saturating_add(usize::from(count));
+ for r in portals.refs.get(from..to).unwrap_or(&[]) {
+ if out.iter().any(|c| c.group == r.group) {
+ continue; // already claimed, by a tighter source or a cheaper hop
+ }
+ let Some((d, center, slack)) = portal_reach(&portals, r.portal, pos) else {
+ continue;
+ };
+ // The straight line to the SECOND door can be shorter than to the first (an
+ // L-shaped pair of rooms folds back toward the fixture), but the light still had
+ // to cross the first one. `max` keeps the spend monotone along the path, so a
+ // second-hop room can never be claimed more cheaply than the room it hides behind.
+ let d = d.max(spent);
+ if d > reach {
+ continue;
+ }
+ match next.iter_mut().find(|(n, ..)| *n == r.group) {
+ Some(slot) if slot.1 <= d => {}
+ Some(slot) => *slot = (r.group, d, center, slack, seed),
+ None => next.push((r.group, d, center, slack, seed)),
+ }
+ }
+ }
+ next.sort_by(|a, b| a.1.total_cmp(&b.1));
+ frontier.clear();
+ for (g, d, center, slack, seed) in next {
+ if out.len() >= ROOM_CLAIM_MAX {
+ break;
+ }
+ // The weight the SEEDING claim still has at this doorway — 1 off a base claim (which
+ // is hard), and the first hop's decayed value off a first hop. This is the whole of
+ // the multi-hop continuity argument: hop N+1 starts at exactly the value hop N ends
+ // at, so the chain has no step at any threshold and never brightens with distance.
+ let seed = &out[seed];
+ let entry = if seed.fade_radius <= 0.0 {
+ 1.0
+ } else {
+ let gap = (0..3)
+ .map(|a| (center[a] - seed.fade_center[a]).powi(2))
+ .sum::()
+ .sqrt();
+ seed.fade_entry
+ * (1.0 - smoothstep0(seed.fade_radius, (gap - seed.fade_slack).max(0.0)))
+ };
+ if entry < CLAIM_FADE_MIN_ENTRY {
+ continue; // too dim to be worth a slot (see the constant)
+ }
+ // The fade radius is what is LEFT of the reach past this doorway, floored (see
+ // [`CLAIM_FADE_MIN_YD`]: a zero-length smoothstep is a step function again).
+ let radius = (reach - d).max(CLAIM_FADE_MIN_YD);
+ push(
+ &mut out,
+ g,
+ ClaimHow::Portal,
+ d,
+ hop,
+ Some((center, slack, radius, entry)),
+ );
+ frontier.push((g, d, out.len() - 1));
+ }
+ }
+ out
+}
+
+/// [`room_claims`] reduced to the group ids the packer wants.
+pub fn claim_groups(
+ groups: &[WmoGroupInfo],
+ portals: PortalGraph<'_>,
+ pos: [f32; 3],
+ reach: f32,
+ molr: &[u16],
+) -> Vec {
+ room_claims(groups, portals, pos, reach, molr)
+ .into_iter()
+ .map(|c| c.group)
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn g(interior: bool, lo: [f32; 3], hi: [f32; 3]) -> WmoGroupInfo {
+ WmoGroupInfo {
+ interior,
+ show_skybox: false,
+ bbox_min: lo,
+ bbox_max: hi,
+ }
+ }
+
+ /// A quad portal in the x = 5 plane, spanning y 0..2, z 0..3.
+ fn one_portal() -> (Vec<[f32; 3]>, Vec) {
+ (
+ vec![
+ [5.0, 0.0, 0.0],
+ [5.0, 2.0, 0.0],
+ [5.0, 2.0, 3.0],
+ [5.0, 0.0, 3.0],
+ ],
+ vec![WmoPortalInfo {
+ start_vertex: 0,
+ count: 4,
+ plane: [1.0, 0.0, 0.0, -5.0],
+ }],
+ )
+ }
+
+ #[test]
+ fn containment_is_tightest_first_and_molr_follows() {
+ let groups = [
+ g(true, [-50.0, -50.0, -10.0], [50.0, 50.0, 30.0]), // g0 the building shell
+ g(true, [-2.0, -2.0, 0.0], [4.0, 4.0, 5.0]), // g1 the room it stands in
+ ];
+ let c = room_claims(&groups, PortalGraph::default(), [0.0, 0.0, 1.0], 8.0, &[7]);
+ assert_eq!(
+ c.iter().map(|c| c.group).collect::>(),
+ vec![1, 0, 7],
+ "the tight room outranks the shell, and MOLR follows both"
+ );
+ assert_eq!(c[0].how, ClaimHow::Contains);
+ assert_eq!(c[2].how, ClaimHow::Molr);
+ }
+
+ #[test]
+ fn a_portal_within_reach_claims_the_next_room() {
+ let groups = [
+ g(true, [-5.0, -5.0, 0.0], [5.0, 5.0, 6.0]), // g0, holds the fixture
+ g(true, [5.0, -5.0, 0.0], [15.0, 5.0, 6.0]), // g1, through the doorway
+ ];
+ let (verts, infos) = one_portal();
+ let refs = [
+ WmoPortalRef {
+ portal: 0,
+ group: 1,
+ side: 1,
+ },
+ WmoPortalRef {
+ portal: 0,
+ group: 0,
+ side: -1,
+ },
+ ];
+ let slices = [(0u16, 1u16), (1u16, 1u16)];
+ let portals = PortalGraph {
+ vertices: &verts,
+ infos: &infos,
+ refs: &refs,
+ slices: &slices,
+ };
+ // 3 yd from the doorway, reach 8 ⇒ the next room is claimed…
+ let c = room_claims(&groups, portals, [2.0, 1.0, 1.0], 8.0, &[]);
+ assert_eq!(c.iter().map(|c| c.group).collect::>(), vec![0, 1]);
+ assert_eq!(c[1].how, ClaimHow::Portal);
+ // …and out of reach it is not: the far room stays gated, which is the whole point.
+ let c = room_claims(&groups, portals, [-4.0, 1.0, 1.0], 5.0, &[]);
+ assert_eq!(c.iter().map(|c| c.group).collect::>(), vec![0]);
+ }
+
+ #[test]
+ fn portal_claims_never_displace_the_base() {
+ // Six containing groups fill every slot; the doorway beyond gets nothing.
+ let mut groups: Vec = (0..6)
+ .map(|i| g(true, [-5.0, -5.0, 0.0], [5.0 + i as f32, 5.0, 6.0]))
+ .collect();
+ groups.push(g(true, [5.0, -5.0, 0.0], [15.0, 5.0, 6.0]));
+ let (verts, infos) = one_portal();
+ let refs = [WmoPortalRef {
+ portal: 0,
+ group: 6,
+ side: 1,
+ }];
+ let mut slices = vec![(0u16, 1u16)];
+ slices.resize(7, (0, 0));
+ let portals = PortalGraph {
+ vertices: &verts,
+ infos: &infos,
+ refs: &refs,
+ slices: &slices,
+ };
+ let c = room_claims(&groups, portals, [0.0, 0.0, 1.0], 20.0, &[]);
+ assert_eq!(c.len(), ROOM_CLAIM_MAX);
+ assert!(c.iter().all(|c| c.how == ClaimHow::Contains));
+ }
+
+ /// MONKEY (soft portal claims): a portal claim carries the DOORWAY it came through and the
+ /// reach left past it — the two numbers the shader's weight is built from. A base claim
+ /// carries neither, which is how the shader tells "hard" from "faded".
+ #[test]
+ fn a_portal_claim_carries_its_doorway_and_the_reach_left_past_it() {
+ let groups = [
+ g(true, [-5.0, -5.0, 0.0], [5.0, 5.0, 6.0]),
+ g(true, [5.0, -5.0, 0.0], [15.0, 5.0, 6.0]),
+ ];
+ let (verts, infos) = one_portal();
+ let refs = [
+ WmoPortalRef { portal: 0, group: 1, side: 1 },
+ WmoPortalRef { portal: 0, group: 0, side: -1 },
+ ];
+ let slices = [(0u16, 1u16), (1u16, 1u16)];
+ let portals = PortalGraph {
+ vertices: &verts,
+ infos: &infos,
+ refs: &refs,
+ slices: &slices,
+ };
+ // 3 yd short of the doorway (x = 5), reach 8 ⇒ 5 yd of reach left on the far side.
+ let c = room_claims(&groups, portals, [2.0, 1.0, 1.0], 8.0, &[]);
+ assert_eq!(c[0].fade_radius, 0.0, "a containment claim is HARD: weight 1 everywhere");
+ assert_eq!(c[0].hops, 0);
+ let door = c[1];
+ assert_eq!(door.how, ClaimHow::Portal);
+ assert_eq!(door.hops, 1);
+ // The quad spans y 0..2, z 0..3 in the x = 5 plane: centre (5, 1, 1.5), half-diagonal
+ // sqrt(0 + 1 + 1.5^2) = 1.803.
+ assert_eq!(door.fade_center, [5.0, 1.0, 1.5]);
+ assert!((door.fade_slack - 1.802_775).abs() < 1e-4, "{door:?}");
+ assert!((door.distance - 3.0).abs() < 1e-5, "closest point on the quad, not its centre");
+ assert!((door.fade_radius - 5.0).abs() < 1e-5, "reach 8 minus the 3 yd spent");
+ assert_eq!(door.fade_entry, 1.0, "a first hop is at full weight in its own doorway");
+
+ // The floor on the fade: a fixture that only just reaches the doorway must still FADE
+ // there, not step. Reach 3.0 leaves 0 yd, and a 0-yard smoothstep is the hard edge again.
+ let c = room_claims(&groups, portals, [2.0, 1.0, 1.0], 3.0, &[]);
+ assert_eq!(c[1].fade_radius, CLAIM_FADE_MIN_YD, "clamped, never zero");
+ }
+
+ /// MONKEY (soft portal claims): TWO hops. A fixture two rooms away has to be claimed on BOTH
+ /// sides of the far group boundary or the seam simply moves one room outward — and the
+ /// second hop's fade is measured from the SECOND doorway, with what is left of the reach.
+ #[test]
+ fn the_second_hop_claims_the_room_beyond_and_fades_from_its_own_door() {
+ // Three rooms in a row along x, doors at x = 5 and x = 15.
+ let groups = [
+ g(true, [-5.0, -5.0, 0.0], [5.0, 5.0, 6.0]),
+ g(true, [5.0, -5.0, 0.0], [15.0, 5.0, 6.0]),
+ g(true, [15.0, -5.0, 0.0], [25.0, 5.0, 6.0]),
+ ];
+ let verts = vec![
+ [5.0, 0.0, 0.0], [5.0, 2.0, 0.0], [5.0, 2.0, 3.0], [5.0, 0.0, 3.0],
+ [15.0, 0.0, 0.0], [15.0, 2.0, 0.0], [15.0, 2.0, 3.0], [15.0, 0.0, 3.0],
+ ];
+ let infos = vec![
+ WmoPortalInfo { start_vertex: 0, count: 4, plane: [1.0, 0.0, 0.0, -5.0] },
+ WmoPortalInfo { start_vertex: 4, count: 4, plane: [1.0, 0.0, 0.0, -15.0] },
+ ];
+ let refs = [
+ WmoPortalRef { portal: 0, group: 1, side: 1 }, // g0 -> g1
+ WmoPortalRef { portal: 0, group: 0, side: -1 }, // g1 -> g0
+ WmoPortalRef { portal: 1, group: 2, side: 1 }, // g1 -> g2
+ WmoPortalRef { portal: 1, group: 1, side: -1 }, // g2 -> g1
+ ];
+ let slices = [(0u16, 1u16), (1u16, 2u16), (3u16, 1u16)];
+ let portals = PortalGraph {
+ vertices: &verts,
+ infos: &infos,
+ refs: &refs,
+ slices: &slices,
+ };
+ // The fixture stands in g0 at x = 2, reach 20: 3 yd to the first door, 13 to the second.
+ let c = room_claims(&groups, portals, [2.0, 1.0, 1.0], 20.0, &[]);
+ assert_eq!(
+ c.iter().map(|c| (c.group, c.hops)).collect::>(),
+ vec![(0, 0), (1, 1), (2, 2)],
+ "one claim per room, in hop order"
+ );
+ assert_eq!(c[2].fade_center[0], 15.0, "the SECOND door is what g2 fades from");
+ assert!((c[2].fade_radius - 7.0).abs() < 1e-5, "20 yd reach, 13 spent: {:?}", c[2]);
+ // CONTINUITY at the second threshold: g2 must START at exactly the weight g1's own fade
+ // has reached there, or the far room reads BRIGHTER than the near one across one plank.
+ // g1's door is at (5, 1, 1.5) with slack 1.803 and radius 17; g2's is at (15, 1, 1.5),
+ // 10 yd away, so g1 is at 1 - smoothstep(0, 17, 10 - 1.803) = 1 - 0.3835.
+ let g1 = c[1];
+ let gap = 10.0f32 - g1.fade_slack;
+ let t = gap / g1.fade_radius;
+ let expect = 1.0 - t * t * (3.0 - 2.0 * t);
+ assert_eq!(c[0].fade_entry, 1.0, "a base claim enters at full weight");
+ assert_eq!(g1.fade_entry, 1.0, "so does the first hop, at its own door");
+ assert!(
+ (c[2].fade_entry - expect).abs() < 1e-4,
+ "the second hop enters at the first hop's value there: {} vs {expect}",
+ c[2].fade_entry,
+ );
+
+ // Out of the reach of the second door, the third room stays unclaimed — the walk spends
+ // reach, it does not flood.
+ let c = room_claims(&groups, portals, [2.0, 1.0, 1.0], 10.0, &[]);
+ assert_eq!(c.iter().map(|c| c.group).collect::>(), vec![0, 1]);
+ }
+}
diff --git a/crates/benilla-world/src/interior.rs b/crates/benilla-world/src/interior.rs
index 3df84394..3d067dad 100644
--- a/crates/benilla-world/src/interior.rs
+++ b/crates/benilla-world/src/interior.rs
@@ -91,7 +91,11 @@ 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.
- pub(crate) fn generation(&self) -> u32 {
+ ///
+ /// 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
}
@@ -313,6 +317,34 @@ impl InteriorAnchor {
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, With` and a light with no
+ /// room can never be promoted to a caster however indoors it stands.
+ pub fn room(&self) -> Option {
+ 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
diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs
index 0adfe16c..f93a6c40 100644
--- a/crates/benilla-world/src/lighting/global_light.rs
+++ b/crates/benilla-world/src/lighting/global_light.rs
@@ -43,9 +43,23 @@ use crate::view::WorldCamera;
/// camera-in-WMO MFOG crossfade; == the scene fog outdoors). Read only by `wow_model.wgsl`'s
/// interior lanes (round-6 Q-I consumer map); terrain mirrors the rows for layout only ·
/// 20 point_count (x = live entries) · 21+ the point-light table, TWO rows per light:
-/// `[pos.xyz, range]`, `[rgb, 0]` (decision 0278 — the Gouraud point term reads this in the
+/// `[pos.xyz, range]`, `[rgb, lane]` (decision 0278 — the Gouraud point term reads this in the
/// VERTEX stage; bevy's own clusterable buffer is fragment-only in the view bind-group layout,
/// so the lights ride our buffer instead).
+/// MONKEY (light lanes): the colour row's `.w` — free until now — is the **lane discriminator
+/// AND the interior EFFECTIVE RADIUS in one float**: `0.0` = an EXTERIOR light, anything
+/// `> 0.5` = an INTERIOR fixture whose value IS its radius `R` in yards ([`interior_reach`] —
+/// the authored MOLT end × `interiorAttenScale`, always clamped ≥ 1.0, so the two cases can
+/// never be confused; the shader derives the whole soft profile from `R`, out to a `2R` fill
+/// dome). MONKEY (light lane by position): which side a light falls on is decided by WHERE IT
+/// STANDS ([`LightLane`] / [`classify_light_lanes`]), not by whether it claims a room — that
+/// is what lets Stormwind's street torches light the cobbles while the Goldshire inn's
+/// fixtures still stay off the lawn. The two consumer families are DISJOINT: the exterior
+/// `point_light_sum` / `wmo_exterior_point_sum` (terrain/wow_model/static_gx) skip `> 0.5`
+/// and the interior `interior_room_light` (wow_model/static_gx) skips `< 0.5`, which is what
+/// stops an inn's candles pooling on the grass outside its wall and an outdoor campfire
+/// lighting the floor through a closed door. `[pos.xyz, range]`'s `range` is UNTOUCHED
+/// (still the 48 yd candidacy constant), so the exterior lanes are byte-identical.
///
/// The GPU buffer is LARGER than this per-frame blob: the interior-prop probe table
/// (`lighting::prop_probes`, 7 rows per slot) lives at its tail — [`light_blob_bytes`] sizes the
@@ -185,6 +199,314 @@ const POINT_PACK_RADIUS: f32 = 300.0;
#[derive(Component)]
pub struct LightRooms(pub(crate) crate::wmo_portal::WmoGroupVis);
+impl LightRooms {
+ /// MONKEY (fire GO lights): claim a room for a light spawned OUTSIDE this crate — the app-side
+ /// carried lights (`benilla_app::entities::carried_light`), whose owner's room comes from the
+ /// interior classifier rather than from a WMO's own MODD/MOLR tables. Without a constructor the
+ /// tuple field's `pub(crate)` made the component unbuildable in benilla-app, so a GameObject's
+ /// torch could never carry a room — and therefore could never be promoted to a cube-map shadow
+ /// caster (`torch_shadow`'s candidate query is `With, With`).
+ pub fn new(rooms: crate::wmo_portal::WmoGroupVis) -> Self {
+ Self(rooms)
+ }
+}
+
+/// MONKEY (room gate): the rooms a fixture may **LIGHT** — deliberately a different question from
+/// [`LightRooms`], which answers "which rooms must be VISIBLE for this light to exist at all".
+///
+/// The gate needs a set that covers every room the fixture actually stands in the middle of, and
+/// the authored MOLR relation alone does not supply one. Measured on the shipped 1.12 corpus
+/// (`benilla-extract wmolights` + the group flags):
+/// * **Goldshire inn** — 12 groups, and only TWO author a MOLR at all (MOGP `0x200`). Nine of its
+/// ten fixtures are claimed by `g4 "upstairs"`, which is the EXTERIOR-flagged shell spanning
+/// the whole building (`z -0.2 .. 23.3`); the tenth by `g3 "entry"`. Its kitchen, common room,
+/// hall, guest rooms and basement claim NOTHING.
+/// * **NSabbey** — 42 fixtures over 14 groups, but `Main Hall`, `LftWng`, `RtWng`, `Library`,
+/// `Library2`, `Library Wing` and `Stairs2` are named by no MOLR either.
+/// Gating purely on MOLR would therefore black out most of both buildings — the abbey's look is
+/// the one the owner signed off, so that is a regression, not a fix. MOLR is authored for the
+/// reference's own purpose (register GL lights while drawing a VISIBLE group's doodads and units),
+/// not to describe which walls a fixture lights, which the reference took from the MOCV bake.
+///
+/// So the claim set is the UNION of MOLR with the interior groups whose authored MOGI **bounding
+/// box contains the fixture** — dense, authored, static, and resolved once at spawn. On the inn
+/// that gives every room its own fixtures back while leaving the basement (`z <= 0.45`, no fixture
+/// inside) and the far guest room claimed by nobody, which is exactly the reported leak; on the
+/// abbey every interior group gets at least one fixture, so its look is preserved.
+///
+/// MONKEY (portal claims): the set is now built by [`benilla_formats::room_claims`] and is the
+/// COMPLETE, ordered claim list — MOLR folded in, plus the containment above, plus the groups one
+/// open PORTAL away within the fixture's reach (the "light crosses a doorway" rule that stops a
+/// continuous floor changing brightness in a straight line at a group boundary). Because it already
+/// contains MOLR, [`RoomClaim::build`] takes it INSTEAD of the MOLR list rather than unioning the
+/// two: re-adding MOLR there would re-admit exactly the district-scale shells the rule deliberately
+/// marks exterior-lane-ineligible.
+///
+/// Each entry is `group | `[`LIT_ROOM_EXT_DENY`]: bit 15 marks a claim that gates the fixture but
+/// must not be honoured by the exterior batch lane (see the constant).
+///
+/// The WMO MOLT lane inserts this, and since MONKEY (interior prop lights) so does the WMO MODD
+/// PROP lane — a hanging lantern whose flame light we synthesise needs the same room claim an
+/// authored fixture gets, or the gate would refuse it in the very room it hangs in. A
+/// carried/GameObject light already carries the single room the interior classifier put it in,
+/// through [`LightRooms`], and needs no supplement.
+#[derive(Component)]
+// MONKEY (review fixes): the placement belongs to the lighting claims themselves. A fixture
+// contained by a room can have NO MOLR/MODR visibility membership; it still needs a GPU room key.
+pub struct LightLitRooms {
+ pub(crate) rooms: crate::wmo_portal::WmoGroupVis,
+ /// MONKEY (soft portal claims): index-parallel with `rooms.groups` — each claim's softness.
+ /// A separate array rather than more bits in the `u16` id because a fade is 4 floats and a
+ /// group id has 15 spare BITS; index-parallel rather than a map because the packer walks the
+ /// two together and the GPU record interleaves them at a fixed stride, so a length mismatch is
+ /// a claim gated by another claim's doorway. Built by the one producer
+ /// (`terrain_stream::spawn::fx`'s `pack_claims`), which is why the invariant holds by
+ /// construction; a legacy/fallback path that has no fades supplies an EMPTY slice, read as
+ /// "every claim is hard", i.e. exactly the binary gate this replaced.
+ pub(crate) fades: std::sync::Arc<[ClaimFade]>,
+}
+
+/// MONKEY (soft portal claims): the softness of ONE claim — the doorway the fixture's light came
+/// through and how far past it the light still reaches.
+///
+/// `radius == 0` is the HARD claim (containment/MOLR): weight 1 everywhere in the group, which is
+/// what the gate did for every claim before this — and it is [`ClaimFade::default`], so every path
+/// that has no fade to give (a carried light's raw MOLR rooms) gates exactly as it always did. A
+/// portal claim carries the real numbers, and the shader turns them into
+/// `w = entry * (1 − smoothstep(0, radius, max(|P − center| − slack, 0)))`.
+///
+/// **`center` is in BEVY WORLD space**, not the WMO model space `benilla_formats::room_claims`
+/// measured it in: the shader has only the fragment's world position, and converting there would
+/// need the placement's matrix per fragment. The producer holds that matrix already, so the
+/// conversion happens once, at claim time.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ClaimFade {
+ /// The portal's centre, BEVY WORLD space.
+ pub center: Vec3,
+ /// The portal polygon's bounding-sphere radius (yd) — distance inside it counts as zero, so
+ /// the doorway itself is weight 1 on both sides of the group plane.
+ pub slack: f32,
+ /// The fade length (yd): the fixture's reach still unspent at the doorway. **0 = hard claim.**
+ pub radius: f32,
+ /// The weight the claim already carries AT `center` — 1 for a first hop, and for a second hop
+ /// whatever the first hop's fade had decayed to by that door. Multiplies the smoothstep, which
+ /// is what keeps a two-hop chain continuous (and monotone) at every threshold.
+ pub entry: f32,
+}
+
+/// MONKEY (portal claims): bit 15 of a [`LightLitRooms`] entry — "this claim gates the fixture, but
+/// the EXTERIOR batch lane must ignore it". A group index cannot reach it (the record's room key is
+/// 12 bits), so it rides for free in the id.
+///
+/// It exists for one population: a city's district-scale EXTERIOR shell, whose MOGI box swallows
+/// the interiors inside it (`Stormwind.wmo`: 470 of its 606 fixtures stand inside one). Such a
+/// claim must stay in the set, because a fixture stripped of its only claim is packed UNGATED and
+/// the ungated arm fails OPEN — one candle lighting every room of the building. But it must not
+/// reach the exterior lane, which would put that candle's pool on the cobbles outside the wall.
+/// A building-scale exterior group (the Goldshire inn's shell, `57.7 x 32.2 yd`) carries no deny
+/// bit and IS lit — that is bug B's second half.
+pub const LIT_ROOM_EXT_DENY: u16 = 0x8000;
+
+/// MONKEY (room gate): u32 per packed light in the claim table — `[instance, count, g0..g5]`,
+/// MONKEY (soft portal claims) followed by one 4-word FADE record per claim slot
+/// (`[center.x, center.y, center.z, radius|slack]`) at [`ROOM_CLAIM_FADE`].
+/// **Must equal `ROOM_CLAIM_STRIDE` in `static_gx.wgsl`** — a mismatch reads every fixture's
+/// claims out of another fixture's record, which blanks (or floods) every building at once.
+pub const ROOM_CLAIM_STRIDE: usize = 32;
+/// MONKEY (soft portal claims): the first FADE word of a claim record, i.e. `2 + ROOM_CLAIM_MAX`.
+/// Slot `k`'s fade is `[ROOM_CLAIM_FADE + 4k .. +4]`. **Must equal `ROOM_CLAIM_FADE` in
+/// `static_gx.wgsl`.**
+pub const ROOM_CLAIM_FADE: usize = 8;
+/// MONKEY (soft portal claims): the fixed-point scale of the packed radius/slack pair — both ride
+/// one u32 as `u16` yards x 256 (low half radius, high half slack). 1/256 yd is far below anything
+/// a smoothstep over 2..48 yd can show, and the range tops out at 255.99 yd, which is five times
+/// the widest reach the packer will ever hand out (`CLAIM_REACH_MAX` = 48). Fixed point rather
+/// than two f32 lanes because the alternative is a 40-word stride for two numbers that are already
+/// coarse; fixed point rather than f16 because both sides are three arithmetic ops with no
+/// bit-twiddling to get wrong. **Must equal `CLAIM_FADE_SCALE` in `static_gx.wgsl`.**
+pub const CLAIM_FADE_SCALE: f32 = 256.0;
+/// MONKEY (room gate): claims a fixture can carry before it is packed UNGATED instead (fail-open —
+/// a truncated list would black out the rooms that fell off the end). **Must equal
+/// `ROOM_CLAIM_MAX` in `static_gx.wgsl`.**
+pub const ROOM_CLAIM_MAX: usize = 6;
+
+/// MONKEY (portal claims): bit 16 of a packed GPU claim word — "the EXTERIOR batch lane may honour
+/// this claim". The CPU side of the same fact is [`LIT_ROOM_EXT_DENY`] (inverted, see
+/// [`RoomClaim::write`]). **Must equal `CLAIM_EXT_OK` in `static_gx.wgsl`**, and must stay clear of
+/// the 12-bit `+1` group id below it.
+pub const CLAIM_EXT_OK: u32 = 1 << 16;
+
+/// MONKEY (soft portal claims): bits 17..=24 of a packed GPU claim word — [`ClaimFade::entry`] as
+/// a byte (`round(entry * 255)`). It rides the id word rather than the fade record because the id
+/// word has 15 bits going spare above [`CLAIM_EXT_OK`] (a group index is 12) while the fade record
+/// is four words of exact f32 centre plus a full u16/u16 pair, and widening the stride for one
+/// byte would cost 256 x 4 more bytes of buffer for nothing. A HARD claim writes 0 here and the
+/// shader never reads it (it returns 1.0 the moment it sees `radius == 0`), so the zero padding
+/// cannot be mistaken for "contributes nothing". **Must equal `CLAIM_ENTRY_SHIFT` in
+/// `static_gx.wgsl`.**
+pub const CLAIM_ENTRY_SHIFT: u32 = 17;
+
+/// MONKEY (room gate): this frame's claim table, index-parallel with [`WowLightData`]'s point
+/// entries. Its own resource and its own GPU buffer on purpose: [`LightStd430`] is mirrored by
+/// three shaders plus the portrait booth and must never be resized, and only `static_gx` reads
+/// this. `static_gx::render` owns the buffer and the binding.
+#[derive(Resource, Clone, ExtractResource)]
+pub struct RoomClaimTable(pub Box<[u32; ROOM_CLAIM_STRIDE * MAX_POINT_LIGHTS]>);
+
+/// MONKEY (room gate): the claim table's byte size — the one place `static_gx::render` sizes its
+/// GPU buffer from, so the table cannot grow here and leave the binding short (a bound storage
+/// buffer smaller than the shader's runtime-sized array fails validation at draw time, which
+/// vanishes every building).
+pub fn room_claim_bytes() -> u64 {
+ (ROOM_CLAIM_STRIDE * MAX_POINT_LIGHTS * std::mem::size_of::()) as u64
+}
+
+impl Default for RoomClaimTable {
+ fn default() -> Self {
+ Self(Box::new([0; ROOM_CLAIM_STRIDE * MAX_POINT_LIGHTS]))
+ }
+}
+
+/// MONKEY (interior attenuation): a fixture's **authored attenuation end** in yards — the WMO
+/// MOLT record's `attenuation_end` (`+0x2c`), carried from the spawn to the packer because
+/// `PointLight` has nowhere to put it and `PointLight::range` means something else entirely (the
+/// 48 yd *candidacy* constant, which the exterior lanes still read).
+///
+/// Only [`crate::terrain_stream::spawn`]'s MOLT lane inserts it. Every other source is an **M2**
+/// light, whose authored attenuation pair is demonstrably NOT a reach in this corpus — a Karazhan
+/// BONFIRE authors `end = 0.97` yd and the whole orc brazier/firepit family `0.33/0.97`, while
+/// `2.22/5.56` is a template default stamped on half the weapons and glue models.
+/// `benilla_formats::M2Light`'s own doc says it: "the GL curve is fixed and ignores these" — the
+/// fields are parsed as a cull hint no consumer reads. So M2 sources get the bucketed
+/// [`m2_light_reach`] instead, derived in the packer from the intensity they already carry — which
+/// also means the app-side spawners (carried torches, transport props) need no change at all.
+///
+/// MOLT is the opposite case and the reason this exists: the reference really does fold a MOLT
+/// fixture through its authored window (wow-re `trace-forensics-abbey-interior-d3d` §4 fitted the
+/// fold at exactly the `+0x28/+0x2c` values), and the vanilla numbers are sane — Goldshire inn
+/// 6.97-9.53 yd over 10 fixtures, its blacksmith 6.0 over 3, NSabbey 4.17-5.56 over 42.
+#[derive(Component)]
+pub struct LightReach(pub f32);
+
+/// MONKEY (light lane by position): which consumer family a point light belongs to — decided by
+/// WHERE THE LIGHT PHYSICALLY IS, not by whether it claims a room.
+///
+/// [`build_light_data`] used `Has` for this, and that conflated two different
+/// questions. `LightRooms` answers "which rooms must be visible for this light to exist" (the
+/// portal gate + the torch-shadow eligibility, decision 0689) — a MODR/MOLR *reference*. The lane
+/// answers "is this light inside a sealed room, or out on the street", which is a fact about
+/// geometry. In vanilla content the two disagree constantly, in both directions:
+///
+/// - **Stormwind.** 235 of its 606 MOLT fixtures are named ONLY by EXTERIOR-class groups (MOGP
+/// `& 0x48`) — the Trade District's street torches and hanging lanterns — and 45 more are named
+/// by no group at all. Under the old rule every one of the 235 was filed INTERIOR, so the
+/// exterior consumers skipped it and the interior consumer never runs on a street: they lit
+/// *nothing at all*. That is bug B's first cause.
+/// - **The Goldshire inn.** Its 10 MOLT fixtures claim group 4, which IS exterior-flagged, yet
+/// they physically stand inside the inn's rooms. A rule keyed on the claimed group's FLAGS would
+/// file them EXTERIOR and put a warm pool on the lawn outside the wall — precisely the leak the
+/// lane split was introduced to stop. A rule keyed on POSITION files them interior, because the
+/// classifier's down-ray from each fixture lands on the inn's own interior-class floor.
+///
+/// So the verdict is the client's own light-attach ray ([`crate::wmo_portal::indoor_verdict_at`]
+/// with `LightAttach::DownRay` — the same predicate that classifies a standing NPC), run ONCE per
+/// static light in [`classify_light_lanes`] and refreshed only when the resident WMO set changes.
+/// A CARRIED light takes its lane from its owner's already-resolved room instead
+/// (`benilla_app::entities::carried_light`): the classifier has run for the bearer, and re-raying
+/// from a swinging torch would be one ray per light per frame for an answer we already hold.
+#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)]
+pub struct LightLane {
+ /// `true` ⇒ the packer writes the interior reach into the colour row's `.w`; `false` ⇒ `0`.
+ pub interior: bool,
+ /// The [`WmoResidency`](crate::interior::WmoResidency) generation this verdict was taken at —
+ /// a light classified before its building streamed in reads `Outdoors`, and must be re-asked
+ /// once the building is there. [`SETTLED`](Self::SETTLED) means "never re-ask".
+ pub generation: u32,
+}
+
+impl LightLane {
+ /// A generation that never goes stale — the carried lane's stamp (see the type doc).
+ pub const SETTLED: u32 = u32::MAX;
+
+ /// The carried lane's constructor: interior iff the bearer is standing in a room.
+ pub fn carried(interior: bool) -> Self {
+ Self {
+ interior,
+ generation: Self::SETTLED,
+ }
+ }
+}
+
+/// MONKEY (interior attenuation): the interior reach (yd) of an **M2** point light of committed
+/// intensity `i`, bucketed on the same ladder [`benilla_formats::fire_intensity`] buckets a
+/// synthesised flame onto (candle 0.6 · torch 1.5 · brazier 2.0 · bonfire 3.0), so a synthesised
+/// source lands on its own bucket exactly and an authored one is filed by how bright it is.
+///
+/// Bucketing rather than reading the record is the finding, not a shortcut — see [`LightReach`]:
+/// the authored M2 attenuation pair does not describe a reach in this corpus. The yard figures are
+/// sized so the pool matches the flame you can see: a table candle lights its table, a wall torch
+/// its corner, a brazier the end of a hall, a bonfire the clearing around it.
+pub fn m2_light_reach(intensity: f32) -> f32 {
+ // MONKEY (portal claims): the ladder itself lives in `benilla_formats::room_claim` — the spawner
+ // needs it to size a synthesised prop light's PORTAL HOP, and the offline `wmolamps` audit needs
+ // the identical rungs to predict what the runtime will claim. One ladder, both readers.
+ benilla_formats::room_claim::m2_light_reach(intensity)
+}
+
+/// MONKEY (soft falloff): the **effective radius** packed into an interior entry's lane — the
+/// authored reach after the live `interiorAttenScale` ([`DynamicInteriors::atten_scale`], default
+/// 1.6). The shader derives its whole profile from this one number as a fraction of it (soft core
+/// at `0.26 R`, window vanishing at `R`, fill dome out to `1.5 R` — see `interior_room_light`), so
+/// the cvar is a pure zoom on every pool at once rather than three separate knobs.
+///
+/// `scale == 0` is the A/B **off** switch and maps to [`INTERIOR_LEGACY_REACH`] — the 48 yd the
+/// lane used before there was a window at all. Under the soft profile that is the widest, flattest
+/// pool the lane can make (the core alone is ~12.5 yd wide), i.e. the nearest thing left to the
+/// pre-window flat lane; it is no longer the byte-exact restore it was, because the window's SHAPE
+/// moved with the change. The `max(…, 1.0)` floor keeps the value clear of the `0.5` lane
+/// threshold at any scale.
+///
+/// MONKEY (torch caster selection): `pub` so `benilla_app::torch_shadow` can rank fixtures by the
+/// SAME effective radius the shader windows them with. Recomputing it there would be a second
+/// copy of a value the packer already owns, and a drift between them would rank a fixture the
+/// shader has already faded to black.
+pub fn interior_reach(reach: f32, scale: f32) -> f32 {
+ if scale <= 0.0 {
+ return INTERIOR_LEGACY_REACH;
+ }
+ (reach * scale).clamp(1.0, INTERIOR_LEGACY_REACH)
+}
+
+/// The reach an interior fixture had before the authored window existed: `spawn::POINT_LIGHT_RANGE`.
+/// Mirrored as a plain constant because it is now a *legacy* value — the A/B's off arm — rather
+/// than the lane's working range, and also the cap on a widened one (past it the packed table's own
+/// membership rules, not the window, decide what a fragment sees).
+const INTERIOR_LEGACY_REACH: f32 = 48.0;
+
+/// MONKEY (fire GO lights): marks a `PointLight` whose colour/intensity were SYNTHESISED from a
+/// model's flame particle emitter rather than authored ([`benilla_assets::ModelLight::synthetic`]).
+///
+/// Two lanes read it, both in [`build_light_data`]: the `fireLightGain` cvar multiplies the
+/// intensity of exactly these entries (so the dial is live — retuning it does not respawn a single
+/// prop, which is the whole point of packing it here rather than folding it in at spawn), and the
+/// `WOW_POINTS_DUMP` census counts them, so "is that light in the table one we invented?" is a
+/// number rather than a guess.
+#[derive(Component)]
+pub struct SyntheticFireLight;
+
+/// MONKEY (fire GO lights): the live gain on every SYNTHESISED fire light (`fireLightGain`, default
+/// 1.0), bridged from benilla-app's cvars the way [`DynamicInteriors`] is. `0` turns the whole
+/// invented-light lane off without touching the authored ones — the kill switch for a heuristic
+/// that, unlike everything around it, is not byte-verified against anything.
+#[derive(Resource, Clone, Copy, PartialEq, Debug)]
+pub struct FireLightGain(pub f32);
+
+impl Default for FireLightGain {
+ fn default() -> Self {
+ Self(1.0)
+ }
+}
+
/// MONKEY (torch shadows, Stage B): marks a `PointLight` that exists ONLY to render a cube shadow
/// map (`benilla_app::torch_shadow`'s promoted fixtures). Such a proxy must NOT enter the
/// `wow_light` light table — benilla's receivers would treat it as a real fixture and its nominal
@@ -242,11 +564,16 @@ pub struct SharedLightBuffer(pub Buffer);
/// creation, and the render-world upload.
pub(super) fn register(app: &mut App) {
app.init_resource::()
+ // MONKEY (room gate): the per-fixture room claims, packed beside the point table.
+ .init_resource::()
.init_resource::()
.init_resource::()
.init_resource::()
+ // MONKEY (fire GO lights): the live gain on synthesised fire lights.
+ .init_resource::()
.init_resource::()
.add_plugins(ExtractResourcePlugin::::default())
+ .add_plugins(ExtractResourcePlugin::::default())
.add_plugins(ExtractResourcePlugin::::default())
.add_plugins(ExtractResourcePlugin::::default())
// PostUpdate, **after transform propagation**: the point table is packed from each light's
@@ -257,7 +584,12 @@ pub(super) fn register(app: &mut App) {
// never moves, which is why this was invisible until entities started carrying lights.
.add_systems(
PostUpdate,
- build_light_data
+ // MONKEY (light lane by position): the lane classifier is chained BEFORE the packer, so
+ // a light that has stood for a frame is packed on this frame's verdict. Its writes go
+ // through `Commands`, so a NEWLY spawned light is still packed on the fail-safe
+ // fallback for one frame — see that fallback's note in `build_light_data`.
+ (classify_light_lanes, build_light_data)
+ .chain()
.after(bevy::transform::TransformSystems::Propagate)
.after(super::update_time_lighting),
)
@@ -334,6 +666,18 @@ pub struct DynamicInteriors {
pub fill: f32,
/// Multiplier on the whole light budget before the soft rolloff — the room's brightness.
pub exposure: f32,
+ /// MONKEY (soft falloff): live scale on every interior fixture's authored attenuation window
+ /// (`interiorAttenScale`) — the fixture's EFFECTIVE RADIUS is `authored end × this`. Default
+ /// **2.5**: the artists' end is where FULL brightness stops, not where light does, so `1` drew
+ /// a hard-edged disc at exactly that radius. `>2.5` widens the pools, `<2.5` tightens them,
+ /// **`0` disables the window** ([`interior_reach`]). Folded into the packed lane rather than a header lane: the
+ /// window is per-fixture anyway, the header's 21 rows are fully claimed, and the fold keeps the
+ /// shader from carrying a second knob it would have to combine per light.
+ pub atten_scale: f32,
+ /// MONKEY (room gate): the per-room fixture gate (`interiorRoomGate`). `false` packs every
+ /// fixture UNGATED, i.e. the pre-gate behaviour where every INT fixture lights every interior
+ /// surface in range — the live A/B for "is the gate what darkened this room?".
+ pub room_gate: bool,
/// MONKEY (interior debug): diagnostic overlay for the interior lane (`interiorDebug`). 0 = off
/// (normal shading). 1 = CLASSIFICATION: interior-lit fragments render solid green, so a wrongly
/// interior-classified OUTDOOR entity shows up. 2 = SHADOW: the torch-shadow factor as greyscale
@@ -351,11 +695,166 @@ impl Default for DynamicInteriors {
ambient: 0.15,
fill: 0.12,
exposure: 2.5,
+ // MONKEY (soft falloff): 1.6, matching the `interiorAttenScale` cvar default (2.5 read oversaturated — the soft core `0.26·R` widens with it too).
+ atten_scale: 1.6,
+ // MONKEY (room gate): on by default — without it a building's fixtures light through
+ // its own floors and walls.
+ room_gate: true,
debug: 0,
}
}
}
+/// MONKEY (room gate): one packed fixture's room claim — the CPU form of the eight u32
+/// `static_gx.wgsl` reads (`[instance, count, g0..g5]`). `count == 0` is the UNGATED head every
+/// fail-open arm packs: an exterior-lane light, a fixture that names no room, and every light
+/// while `interiorRoomGate` is off. MONKEY (GO room claims): an OVERFLOWING list no longer fails
+/// open — it keeps its [`ROOM_CLAIM_MAX`] highest-priority claims (the spawner orders them
+/// tightest-containment → MOLR → nearest portal hop, so the dropped tail is the outermost shells /
+/// farthest rooms). Failing open here lit every room of the building through its walls, and a
+/// carried light inside nested MOGI shells can now reach the cap where a MOLT fixture never did.
+#[derive(Clone, Copy)]
+struct RoomClaim {
+ instance: u32,
+ n: u8,
+ groups: [u16; ROOM_CLAIM_MAX],
+ /// MONKEY (soft portal claims): index-parallel with `groups`. A default (`radius == 0`) is the
+ /// HARD claim every pre-fade path produces, so a fixture whose claims arrived without fades
+ /// gates exactly as it did before.
+ fades: [ClaimFade; ROOM_CLAIM_MAX],
+}
+
+impl Default for ClaimFade {
+ /// The HARD claim: no doorway, no fade, full weight. Spelled out rather than derived because
+ /// `entry` must default to **1**, not 0 — a derived zero would mean "this claim contributes
+ /// nothing", i.e. every fallback path would silently black out its rooms.
+ fn default() -> Self {
+ Self {
+ center: Vec3::ZERO,
+ slack: 0.0,
+ radius: 0.0,
+ entry: 1.0,
+ }
+ }
+}
+
+impl RoomClaim {
+ const UNGATED: Self = Self {
+ instance: 0,
+ n: 0,
+ groups: [0; ROOM_CLAIM_MAX],
+ fades: [ClaimFade {
+ center: Vec3::ZERO,
+ slack: 0.0,
+ radius: 0.0,
+ entry: 1.0,
+ }; ROOM_CLAIM_MAX],
+ };
+
+ /// The fixture's claim list. MONKEY (portal claims): when the spawner built one
+ /// ([`LightLitRooms`]) it IS the answer — it already folds the MOLR rooms in, in priority order,
+ /// with each claim's exterior-lane eligibility resolved. Only a light that carries no such list
+ /// (a carried torch, a transport prop) falls back to its raw MOLR rooms. MONKEY (review fixes):
+ /// each list carries its own placement, independently of whether MOLR names the fixture.
+ fn build(rooms: Option<&LightRooms>, lit: Option<&LightLitRooms>) -> Self {
+ // MONKEY (soft portal claims): the fades ride the SAME arm the groups came from — the
+ // MOLR fallback has none, and reading them off `lit` while the groups came off `rooms`
+ // would fade one room by another room's doorway.
+ let lit = lit.filter(|l| !l.rooms.groups.is_empty());
+ let fades: &[ClaimFade] = lit.map_or(&[], |l| &l.fades[..]);
+ let Some(claims) = lit.map(|l| &l.rooms).or_else(|| rooms.map(|r| &r.0)) else {
+ return Self::UNGATED; // nothing claims this light: it lights everything, as before
+ };
+ let instance = claims.instance.index().index();
+ // The identity travels to the shader through an f32 lane (`static_gx::render`'s cell
+ // uniform), which is exact only below 2^24 — and index 0 is the "terrain cell / no
+ // building" sentinel there. Outside that range the receiving side packs no key at all, so
+ // this side must not gate either, or the building would go black.
+ if instance == 0 || instance >= (1 << 24) {
+ return Self::UNGATED;
+ }
+ let mut out = Self {
+ instance,
+ ..Self::UNGATED
+ };
+ for (i, &g) in claims.groups.iter().enumerate() {
+ if out.groups[..usize::from(out.n)].contains(&g) {
+ continue;
+ }
+ if usize::from(out.n) == ROOM_CLAIM_MAX {
+ break; // overflow keeps the six highest-priority claims (see the struct doc)
+ }
+ out.groups[usize::from(out.n)] = g;
+ // A missing fade is a HARD claim, not a dropped one: the fallback paths (a carried
+ // light's raw MOLR rooms) legitimately have none, and the pre-fade behaviour is
+ // exactly "weight 1 wherever the group matches".
+ out.fades[usize::from(out.n)] = fades.get(i).copied().unwrap_or_default();
+ out.n += 1;
+ }
+ out
+ }
+
+ /// Write the eight-u32 GPU form. Groups are stored `+ 1` so the shader can keep 0 for "empty"
+ /// (and for "this fragment names no room") — group 0 is a real, common group id.
+ ///
+ /// MONKEY (portal claims): [`LIT_ROOM_EXT_DENY`] (bit 15 of the CPU id) is re-encoded as
+ /// [`CLAIM_EXT_OK`] (bit 16 of the GPU word), positively: the exterior batch lane takes a claim
+ /// only with that bit set, so every legacy/fallback path that never learned about the flag
+ /// (a carried light's raw MOLR rooms) reads as "not eligible" rather than as a leak.
+ fn write(&self, dst: &mut [u32]) {
+ dst[0] = self.instance;
+ dst[1] = u32::from(self.n);
+ for (i, slot) in dst[2..2 + ROOM_CLAIM_MAX].iter_mut().enumerate() {
+ // Past the count the shader never looks, but a `+1` on a padding zero would read as a
+ // claim on group 0 to anything that ever did — so the padding stays literally empty.
+ *slot = match self.groups.get(i).filter(|_| i < usize::from(self.n)) {
+ Some(g) => {
+ // MONKEY (soft portal claims): the entry weight rides the spare high bits.
+ let entry = self.fades[i].entry.clamp(0.0, 1.0);
+ u32::from(*g & !LIT_ROOM_EXT_DENY) + 1
+ | if *g & LIT_ROOM_EXT_DENY == 0 {
+ CLAIM_EXT_OK
+ } else {
+ 0
+ }
+ | ((entry * 255.0).round() as u32) << CLAIM_ENTRY_SHIFT
+ }
+ None => 0,
+ };
+ }
+ // MONKEY (soft portal claims): the fade records. Centre as raw f32 bits (the shader
+ // `bitcast`s them straight back — a world coordinate reaches +-17000 yd, which no fixed
+ // point or f16 lane could carry); radius and slack as `u16` yards x `CLAIM_FADE_SCALE` in
+ // one word. A padded slot writes zeros, and `radius == 0` IS the hard claim, so the padding
+ // can never be read as a fade even by a reader that ignores the count.
+ let q = |v: f32| ((v.max(0.0) * CLAIM_FADE_SCALE).round() as u32).min(0xffff);
+ for i in 0..ROOM_CLAIM_MAX {
+ let f = &mut dst[ROOM_CLAIM_FADE + 4 * i..][..4];
+ let Some(fade) = self.fades.get(i).filter(|_| i < usize::from(self.n)) else {
+ f.fill(0);
+ continue;
+ };
+ f[0] = fade.center.x.to_bits();
+ f[1] = fade.center.y.to_bits();
+ f[2] = fade.center.z.to_bits();
+ f[3] = q(fade.radius) | (q(fade.slack) << 16);
+ }
+ }
+}
+
+impl std::fmt::Display for RoomClaim {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ if self.n == 0 {
+ return write!(f, "UNGATED");
+ }
+ write!(f, "inst {} g", self.instance)?;
+ for (i, g) in self.groups[..usize::from(self.n)].iter().enumerate() {
+ write!(f, "{}{g}", if i == 0 { "" } else { "," })?;
+ }
+ Ok(())
+ }
+}
+
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn build_light_data(
light: Res,
@@ -364,10 +863,32 @@ fn build_light_data(
cam: Query<&GlobalTransform, With>,
// `Without`: the torch-shadow proxies are `PointLight`s too, but they exist
// only to cast a cube map — never to light benilla's receivers (see [`ShadowProxyLight`]).
- lights_q: Query<(&PointLight, &GlobalTransform, Option<&LightRooms>), Without>,
+ // MONKEY (fire GO lights): `Has` rides along so the live `fireLightGain`
+ // can scale exactly the invented sources at PACK time — no respawn, no per-spawn bake.
+ // MONKEY (interior attenuation): `Option<&LightReach>` too — a MOLT fixture's authored
+ // attenuation end, `None` on every M2 source (which is bucketed below instead).
+ // MONKEY (light lane by position): `Option<&LightLane>` decides the interior/exterior split —
+ // `LightRooms` stays in the tuple, but only for the portal ROOM gate below (and as the
+ // fail-safe lane for the frame or two before the classifier has answered).
+ lights_q: Query<
+ (
+ &PointLight,
+ &GlobalTransform,
+ Option<&LightRooms>,
+ Has,
+ Option<&LightReach>,
+ Option<&LightLane>,
+ // MONKEY (room gate): the bbox-derived supplement to the MOLR claim set.
+ Option<&LightLitRooms>,
+ ),
+ Without,
+ >,
// The per-frame portal PVS, for the room term below ([`LightRooms`]).
portals: Query<&crate::wmo_portal::WmoPortalInstance>,
mut data: ResMut,
+ // MONKEY (room gate): packed in the same walk as the point table, so the two can never
+ // disagree about which entry is which.
+ mut claims: ResMut,
time: Res,
// MONKEY (world shadows): the `worldShadows` lane flag, packed into `sh_c16.w` for the MCSH gate.
world_shadow: Res,
@@ -375,6 +896,8 @@ fn build_light_data(
shadow_distance: Res,
// MONKEY (dynamic interiors): the interior lane's on/off + live knobs, packed for `static_gx.wgsl`.
dynamic_interiors: Res,
+ // MONKEY (fire GO lights): the live gain on synthesised fire lights (0 = the lane off).
+ fire_gain: Res,
mut last_dump: Local,
mut last_rows_dump: Local,
) {
@@ -442,9 +965,20 @@ fn build_light_data(
// identity, over-gamut preserved). Entries past `count` stay stale in the blob — the count row
// guards every reader.
let cam_pos = cam.single().map(|t| t.translation()).unwrap_or(Vec3::ZERO);
- let mut pts: Vec<(f32, Vec3, f32, [f32; 3])> = lights_q
+ // MONKEY (fire GO lights): `(…, synthetic)` rides the tuple so the census below can count the
+ // invented entries without a second query.
+ // MONKEY (light lanes): the tuple's last member is the packed COLOUR-ROW `.w` — 0 for an
+ // exterior light, the interior reach in yards for a fixture that claims a room (see the layout
+ // doc). It is resolved here, in the one place that already knows both the rooms and the
+ // recovered intensity, so neither shader has to reconstruct either.
+ // MONKEY (GO room claims): the trailing `usize` is the RAW `LightLitRooms` claim count —
+ // what the spawner (or the app's carried-light claimer) built, before the lane/`room_gate`
+ // filter below decides whether it is packed at all. Printed by `WOW_POINTS_DUMP` so an
+ // EXTERIOR-lane entry that HAS claims is visible as such: the shader reads claims only on
+ // the interior lane, so `claims 4` beside `EXT` is the readout of exactly that trade.
+ let mut pts: Vec<(f32, Vec3, f32, [f32; 3], bool, f32, RoomClaim, usize)> = lights_q
.iter()
- .filter(|(_, gt, rooms)| {
+ .filter(|(_, gt, rooms, _, _, _, _)| {
// The ROOM term (decision 0689's law, fourth lane — see [`LightRooms`]). Not a
// visibility test bolted onto a faithful gather: the reference's register walk has no
// such term either, it simply never has a culled room's torch to register. Ungated for
@@ -462,14 +996,55 @@ fn build_light_data(
&& gt.translation().distance_squared(cam_pos)
< INTERIOR_NEAR_ADMIT * INTERIOR_NEAR_ADMIT)
})
- .filter_map(|(pl, gt, _)| {
+ .filter_map(|(pl, gt, rooms, synthetic, reach, lane_of, lit_rooms)| {
let p = gt.translation();
let d2 = p.distance_squared(cam_pos);
(d2 < POINT_PACK_RADIUS * POINT_PACK_RADIUS).then(|| {
let c = pl.color.to_linear();
- let s = pl.intensity / (4.0 * std::f32::consts::PI);
+ // The authored intensity, BEFORE the fire gain: the pool's geometry must not move
+ // when the user dims the invented lights, only its brightness.
+ let base = pl.intensity / (4.0 * std::f32::consts::PI);
+ // MONKEY (fire GO lights): the live `fireLightGain` folds in HERE, over the
+ // recovered colour×intensity, and only for a synthesised source. At spawn it
+ // would need a world respawn to retune; here the dial moves the frame it changes.
+ let s = base * if synthetic { fire_gain.0.max(0.0) } else { 1.0 };
let rgb = commit_raw([c.red * s, c.green * s, c.blue * s]);
- (d2, p, pl.range, rgb)
+ // MONKEY (light lane by position): a light is INTERIOR iff it PHYSICALLY STANDS in
+ // an interior-class WMO group — the verdict [`classify_light_lanes`] (static) or
+ // the carried-light claim (entities) wrote onto it. Claiming a room is no longer
+ // the test: Stormwind's street torches claim exterior-class groups and must light
+ // the cobbles, while the Goldshire inn's fixtures claim an exterior-flagged group
+ // from inside the building and must NOT light the lawn (see [`LightLane`]).
+ //
+ // No lane yet ⇒ fall back to the OLD `LightRooms` rule rather than to "exterior".
+ // A light lives one or two frames before the classifier's first pass (it spawns in
+ // the stream stage, the classifier runs the frame after), and the conservative
+ // arm of that gap is the pre-change behaviour: a MOLT fixture starts interior and
+ // is corrected outward, never the reverse — so the gap can never flash a pool onto
+ // an inn's lawn.
+ let interior = lane_of.map_or_else(|| rooms.is_some(), |l| l.interior);
+ // A fixture's reach is its authored MOLT end where there is one and the M2 bucket
+ // otherwise, with a fail-open default for a MOLT record whose end is absent or
+ // degenerate (a few author 0 with `useAtten` clear).
+ let lane = if interior {
+ let r = reach
+ .map(|r| r.0)
+ .filter(|r| *r > 0.5)
+ .unwrap_or_else(|| m2_light_reach(base));
+ interior_reach(r, dynamic_interiors.atten_scale)
+ } else {
+ 0.0
+ };
+ // MONKEY (room gate): the fixture's claim set, resolved HERE because this is the
+ // one place that already knows both the rooms and the interior verdict. An
+ // EXTERIOR-lane light is never read by the gate, so it packs the ungated head.
+ let claim = if interior && dynamic_interiors.room_gate {
+ RoomClaim::build(rooms, lit_rooms)
+ } else {
+ RoomClaim::UNGATED
+ };
+ let lit_n = lit_rooms.map_or(0, |l| l.rooms.groups.len());
+ (d2, p, pl.range, rgb, synthetic, lane, claim, lit_n)
})
})
.collect();
@@ -483,9 +1058,16 @@ fn build_light_data(
dynamic_interiors.fill,
dynamic_interiors.exposure,
];
- for (i, (_, p, range, rgb)) in pts.iter().enumerate() {
+ for (i, (_, p, range, rgb, _, lane, claim, _)) in pts.iter().enumerate() {
data.0.points[2 * i] = [p.x, p.y, p.z, *range];
- data.0.points[2 * i + 1] = [rgb[0], rgb[1], rgb[2], 0.0];
+ data.0.points[2 * i + 1] = [rgb[0], rgb[1], rgb[2], *lane];
+ claim.write(&mut claims.0[i * ROOM_CLAIM_STRIDE..][..ROOM_CLAIM_STRIDE]);
+ }
+ // Entries past the count are stale in the point table by design (the count row guards every
+ // reader) — but the claim table is read at the SAME index, so a stale head there would gate a
+ // live light with a dead building's identity. Clear the tail instead of trusting the count.
+ for slot in claims.0[pts.len() * ROOM_CLAIM_STRIDE..].iter_mut() {
+ *slot = 0;
}
// `WOW_POINTS_DUMP=1`: print the committed point table once a second — the numeric probe for
// "what is actually lighting this ground". A pool that reads wrong is one of a small set of
@@ -512,26 +1094,56 @@ fn build_light_data(
let snap = |v: f32| (((half + v) / cell).floor() + 0.5) * cell - half;
let anchor = Vec3::new(snap(cam_pos.x), cam_pos.y, snap(cam_pos.z));
let (mut boxed, mut sphere) = (0usize, 0usize);
- for (_, p, _, _) in &pts {
+ for (_, p, _, _, _, _, _, _) in &pts {
let dv = *p - anchor;
boxed += usize::from(dv.x.abs().max(dv.z.abs()) <= 33.570_166);
sphere += usize::from(dv.length() <= 48.0);
}
+ // MONKEY (fire GO lights): how many of the packed entries we INVENTED. The whole
+ // feature is a heuristic over content, so "the inn is too bright" has to be separable
+ // into "too many synthetic sources" and "the authored ones changed" without a rebuild.
+ let synth = pts.iter().filter(|(.., s, _, _, _)| *s).count();
+ // MONKEY (light lanes): how the table splits between the two now-disjoint consumer
+ // families. "The inn's candles are lighting the lawn" is an INT count on a row the
+ // exterior lane should never have seen — a number, printed per row below as INT/EXT.
+ let interior = pts.iter().filter(|(.., lane, _, _)| *lane > 0.5).count();
eprintln!(
- "[points] {} packed, cam {cam_pos:.1?} — this chunk's candidates: {boxed} (was {sphere} at the 48 yd sphere), 3 slots",
- pts.len()
+ "[points] {} packed ({interior} INT / {} EXT, {synth} synthetic, gain {:.2}, atten x{:.2}), cam {cam_pos:.1?} — this chunk's candidates: {boxed} (was {sphere} at the 48 yd sphere), 3 slots",
+ pts.len(),
+ pts.len() - interior,
+ fire_gain.0,
+ dynamic_interiors.atten_scale,
);
- for (d2, p, _, rgb) in pts.iter().take(8) {
+ for (d2, p, _, rgb, synthetic, lane, claim, lit_n) in pts.iter().take(8) {
eprintln!(
- " d {:6.2} at [{:8.2},{:7.2},{:8.2}] rgb [{:.3},{:.3},{:.3}]",
+ " d {:6.2} at [{:8.2},{:7.2},{:8.2}] rgb [{:.3},{:.3},{:.3}] {}{}",
d2.sqrt(),
p.x,
p.y,
p.z,
rgb[0],
rgb[1],
- rgb[2]
+ rgb[2],
+ if *lane > 0.5 {
+ format!("INT reach {lane:5.2}")
+ } else {
+ "EXT".to_string()
+ },
+ if *synthetic { " SYNTH" } else { "" },
);
+ // MONKEY (GO room claims): how many rooms this entry CLAIMS, whether or not the
+ // lane lets the shader read them. A carried light (a brazier GameObject) now builds
+ // its claims from its own world position through the placed lanes' rule, so "why is
+ // the shop next door still dark" is `claims 1` vs `claims 3` here rather than a
+ // guess — and on an `EXT` row it says the claims exist but are not being read.
+ if *lit_n > 0 {
+ eprintln!(" claims {lit_n}");
+ }
+ // MONKEY (room gate): which rooms this entry is allowed to light — the number
+ // behind "why is this room dark" / "why does that wall still glow".
+ if *lane > 0.5 {
+ eprintln!(" rooms {claim}");
+ }
}
}
}
@@ -574,6 +1186,102 @@ fn build_light_data(
}
}
+/// MONKEY (light lane by position): decide each STATIC point light's [`LightLane`] from where it
+/// physically stands, using the client's own light-attach down-ray
+/// ([`crate::wmo_portal::indoor_verdict_at`], `LightAttach::DownRay` — the predicate that
+/// classifies a standing NPC). See [`LightLane`] for WHY the lane cannot be read off `LightRooms`.
+///
+/// The verdict maps straight across:
+/// - [`IndoorVerdict::DayNight`] / [`IndoorVerdict::Baked`] — the nearest face under the light is
+/// an interior-class one (MOGP `& 0x48 == 0`). INTERIOR. This is what keeps the Goldshire inn's
+/// ten fixtures interior even though the group that NAMES them is exterior-flagged: the ray from
+/// each fixture lands on the inn's own room floor, one storey down at most, and the answer is a
+/// fact about that floor rather than about the MOLR list.
+/// - [`IndoorVerdict::OutdoorsOnWmo`] — a definite claim by an EXTERIOR-class face: a street, a
+/// courtyard, a porch, a deck. EXTERIOR. Stormwind's 235 street fixtures land here.
+/// - MONKEY (review fixes): [`IndoorVerdict::Outdoors`] with a terrain hit is resolved EXTERIOR,
+/// even if MOLR/MODR names the fixture — a courtyard torch must still light its ground. Only
+/// an empty ray (no WMO and no terrain) keeps the old `LightRooms` fallback: a streaming gap
+/// supplies no evidence that a previously interior fixture should move outdoors.
+///
+/// **Cost.** One ray per static light per [`WmoResidency`](crate::interior::WmoResidency)
+/// generation, and nothing at all once a neighbourhood is settled: a world-baked light never
+/// moves, so the verdict is stable, and the generation only ticks when a building streams in or
+/// out. `Without` keeps the carried lights out — they are children of a bone joint and
+/// take their lane from their bearer's already-resolved room instead.
+#[allow(clippy::type_complexity)]
+pub fn classify_light_lanes(
+ mut commands: Commands,
+ residency: Res,
+ wmos: Res>,
+ instances: Query<&crate::wmo_portal::WmoPortalInstance>,
+ streamer: Res,
+ adt_tiles: Res>,
+ lights: Query<
+ (Entity, &GlobalTransform, Option<&LightRooms>, Option<&LightLane>),
+ (
+ With,
+ Without,
+ Without,
+ ),
+ >,
+) {
+ let generation = residency.generation();
+ // Collected once per frame that has work; the ray walk borrows it per light. An empty world
+ // (no placement yet) still short-circuits below on the `settled` check for every light.
+ let mut instance_list: Option> = None;
+ for (light, gt, rooms, lane) in &lights {
+ if lane.is_some_and(|l| l.generation == generation || l.generation == LightLane::SETTLED) {
+ continue; // settled — no ray, no archetype write
+ }
+ let instances = instance_list.get_or_insert_with(|| instances.iter().collect());
+ let (verdict, _) = crate::wmo_portal::indoor_verdict_at(
+ &wmos,
+ instances.iter().map(|i| ((), *i)),
+ &streamer,
+ &adt_tiles,
+ gt.translation(),
+ crate::wmo_portal::LightAttach::DownRay,
+ );
+ // MONKEY (review fixes): `Outdoors` alone conflates terrain winning the ray with no
+ // resident surface at all. Reuse its terrain probe only on that ambiguous arm; the
+ // winning WMO verdicts already carry all the information the lane needs. Terrain above
+ // the anchor is not a DOWN-ray hit (the same buried-terrain rule as down_ray_claim).
+ let probe = gt.translation() + Vec3::Y * crate::wmo_portal::POSITION_PROBE_LIFT;
+ let terrain_hit = matches!(verdict, crate::wmo_portal::IndoorVerdict::Outdoors)
+ && crate::terrain_stream::terrain_height_under(
+ &streamer,
+ &adt_tiles,
+ probe,
+ ).is_some_and(|height| height <= probe.y);
+ let interior = light_verdict_interior(&verdict, terrain_hit, rooms.is_some());
+ let want = LightLane {
+ interior,
+ generation,
+ };
+ // Always re-stamped when the generation moved, even if the verdict didn't — the stamp is
+ // what stops the next frame re-raying the same light.
+ if lane != Some(&want) {
+ commands.entity(light).insert(want);
+ }
+ }
+}
+
+// MONKEY (review fixes): room references are a fallback for an unresolved ray, never a veto on
+// resolved outdoor geometry. Keep this decision explicit so both outdoor arms stay covered.
+fn light_verdict_interior(
+ verdict: &crate::wmo_portal::IndoorVerdict,
+ terrain_hit: bool,
+ has_rooms: bool,
+) -> bool {
+ use crate::wmo_portal::IndoorVerdict;
+ match verdict {
+ IndoorVerdict::DayNight | IndoorVerdict::Baked { .. } => true,
+ IndoorVerdict::OutdoorsOnWmo => false,
+ IndoorVerdict::Outdoors => !terrain_hit && has_rooms,
+ }
+}
+
/// Render-world: write the packed light into the shared buffer in place, before any draw reads it
/// (`RenderSystems::PrepareResources`). One small upload per frame, independent of material count.
fn upload_light(
@@ -591,6 +1299,22 @@ fn upload_light(
mod tests {
use super::*;
+ // MONKEY (review fixes): a reference cannot override resolved terrain or an outdoor WMO
+ // face. An empty ray still preserves the old rule until residency supplies real evidence.
+ #[test]
+ fn light_lanes_distinguish_resolved_outdoors_from_an_empty_ray() {
+ use crate::wmo_portal::IndoorVerdict;
+ for has_rooms in [false, true] {
+ assert!(!light_verdict_interior(&IndoorVerdict::Outdoors, true, has_rooms));
+ assert!(!light_verdict_interior(&IndoorVerdict::OutdoorsOnWmo, false, has_rooms));
+ assert!(light_verdict_interior(&IndoorVerdict::DayNight, true, has_rooms));
+ assert!(light_verdict_interior(
+ &IndoorVerdict::Baked { mocv: [0; 3], lobes: Vec::new() }, false, has_rooms,
+ ));
+ assert_eq!(light_verdict_interior(&IndoorVerdict::Outdoors, false, has_rooms), has_rooms);
+ }
+ }
+
/// GOLDEN — the **live exterior M2 response** (0803): `wow_model.wgsl`'s doodad/entity lane must
/// reproduce `E = A + I·D·(4/17)(0.375 + 2μ + 1.875μ²)` off the rows [`pack_model_core_rows`]
/// writes, with `A` NOT scaling by the per-instance intensity and every sun band scaling by it
@@ -688,15 +1412,7 @@ mod tests {
/// reference and the ground pool read white instead of flame-orange.
#[test]
fn the_torch_commits_the_raw_authored_product() {
- let mut app = App::new();
- app.init_resource::()
- .init_resource::()
- .init_resource::()
- .init_resource::()
- .init_resource::()
- .add_systems(Update, build_light_data);
- app.world_mut()
- .spawn((crate::view::WorldCamera, GlobalTransform::IDENTITY));
+ let mut app = packer_app();
// The real authored torch light, through the real spawn recipe.
app.world_mut().spawn((
crate::terrain_stream::point_light([0.466_666_7, 0.290_196_1, 0.133_333_34], 3.0),
@@ -723,6 +1439,405 @@ mod tests {
assert!((rgb[2] - 0.4).abs() < 1e-4, "blue commits raw: {rgb:?}");
}
+ /// A minimal app around the real [`build_light_data`]: every resource the system takes, plus a
+ /// world camera at the origin. Was inline in the torch golden and short three resources — the
+ /// system had grown `WorldShadowActive`/`ShadowDistance`/`DynamicInteriors` params since, and
+ /// the test had been failing param validation rather than asserting anything. One builder now,
+ /// so a fourth param can't silently red the same way twice.
+ fn packer_app() -> App {
+ let mut app = App::new();
+ app.init_resource::()
+ .init_resource::