From 6f30b5466b211500997ae1ec52c4117eb75f1d29 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 02:52:49 +0100
Subject: [PATCH 1/6] MONKEY (daylight): city daylight census instrument +
daylight- capture scenarios
Co-Authored-By: Claude Opus 5.5
---
crates/benilla-app/src/capture/scenarios.rs | 26 ++
crates/benilla-world/src/lighting/daylight.rs | 4 +
.../src/lighting/daylight/census.rs | 224 ++++++++++++++++++
3 files changed, 254 insertions(+)
create mode 100644 crates/benilla-world/src/lighting/daylight/census.rs
diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs
index d970a9fd..ff57a2af 100644
--- a/crates/benilla-app/src/capture/scenarios.rs
+++ b/crates/benilla-app/src/capture/scenarios.rs
@@ -1378,6 +1378,32 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
look: LAVA_RIVER_LOOK,
minute: 0,
ui: None,
+ }, // MONKEY (daylight): city interiors by day and terrain torch shadows at night. World coords
+ // from the census (`lighting::daylight::census`, Stormwind uid 10047, Ironforge uid 7706).
+ // The Gilded Rose ground floor (group g268, portal + aperture seeds).
+ Scenario {
+ name: "daylight-sw-inn", map: Some(MAP_AZEROTH),
+ eye: [-8871.0, 681.0, 99.8], look: [-8858.0, 668.0, 98.6], minute: 720, ui: None,
+ },
+ // The Cathedral of Light nave (groups g135/g146, lit only by two EXT-class window batches).
+ Scenario {
+ name: "daylight-sw-cathedral", map: Some(MAP_AZEROTH),
+ eye: [-8556.0, 826.0, 109.0], look: [-8515.0, 862.0, 112.0], minute: 720, ui: None,
+ },
+ // A Trade District shop (group g123, one exterior portal).
+ Scenario {
+ name: "daylight-sw-shop", map: Some(MAP_AZEROTH),
+ eye: [-8776.0, 722.0, 101.5], look: [-8764.0, 711.0, 100.2], minute: 720, ui: None,
+ },
+ // Ironforge: the Great Forge hall (g64, no opening to the sky) and the gate hall (g7, the
+ // city's one exterior portal).
+ Scenario {
+ name: "daylight-if-forge", map: Some(MAP_AZEROTH),
+ eye: [-4930.0, -945.0, 503.5], look: [-4890.0, -985.0, 503.0], minute: 720, ui: None,
+ },
+ Scenario {
+ name: "daylight-if-gate", map: Some(MAP_AZEROTH),
+ eye: [-4975.0, -895.0, 503.5], look: [-5005.0, -852.0, 506.0], minute: 720, ui: None,
},
];
diff --git a/crates/benilla-world/src/lighting/daylight.rs b/crates/benilla-world/src/lighting/daylight.rs
index 27ee4a3c..014df956 100644
--- a/crates/benilla-world/src/lighting/daylight.rs
+++ b/crates/benilla-world/src/lighting/daylight.rs
@@ -2035,6 +2035,10 @@ pub(super) fn register(app: &mut App) {
);
}
+// MONKEY (daylight): the city census instrument.
+#[cfg(test)]
+mod census;
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/benilla-world/src/lighting/daylight/census.rs b/crates/benilla-world/src/lighting/daylight/census.rs
new file mode 100644
index 00000000..7dac8fe7
--- /dev/null
+++ b/crates/benilla-world/src/lighting/daylight/census.rs
@@ -0,0 +1,224 @@
+//! MONKEY (daylight): the city daylight census (test-only instrument). Loads a WMO root from the
+//! install, runs the live selection rule over it and prints which interior rooms get a daylight
+//! seed, which do not, and what each unseeded room authors (batch classes, WINDOW / SIDN
+//! materials, portal neighbours), plus the cost of each seed pass.
+//!
+//! `WOW_DATA= cargo test -p benilla-world --lib lighting::daylight::census -- --ignored --nocapture`
+//! `WOW_CENSUS_WMO=` picks the root (default: Stormwind then Ironforge).
+
+use super::*;
+use benilla_formats::{open_chain, parse_wmo_portals, parse_wmo_root, wmo_group_header};
+
+struct Loaded {
+ groups: Vec,
+ names: Vec,
+ portals: benilla_formats::WmoPortals,
+ slices: Vec<(u16, u16)>,
+ /// `(group, class, window, sidn, positions)` per render batch.
+ batches: Vec<(u16, benilla_formats::WmoBatchClass, bool, bool, Vec<[f32; 3]>)>,
+}
+
+fn group_names(bytes: &[u8]) -> Vec {
+ let (mut mogn, mut mogi) = (None, None);
+ let mut o = 0usize;
+ while o + 8 <= bytes.len() {
+ let tag = [bytes[o + 3], bytes[o + 2], bytes[o + 1], bytes[o]];
+ let n = u32::from_le_bytes(bytes[o + 4..o + 8].try_into().unwrap()) as usize;
+ let body = bytes.get(o + 8..o + 8 + n);
+ match (&tag, body) {
+ (b"MOGN", Some(b)) => mogn = Some(b),
+ (b"MOGI", Some(b)) => mogi = Some(b),
+ _ => {}
+ }
+ o += 8 + n;
+ }
+ let (Some(mogn), Some(mogi)) = (mogn, mogi) else {
+ return Vec::new();
+ };
+ mogi.chunks_exact(32)
+ .map(|r| {
+ let off = i32::from_le_bytes(r[28..32].try_into().unwrap());
+ usize::try_from(off)
+ .ok()
+ .and_then(|off| mogn.get(off..))
+ .map(|rest| {
+ let end = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
+ String::from_utf8_lossy(&rest[..end]).into_owned()
+ })
+ .unwrap_or_default()
+ })
+ .collect()
+}
+
+fn load(path: &str) -> Loaded {
+ let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
+ let mut chain = open_chain(&data).expect("open MPQ chain");
+ let root_path = path.replace('/', "\\").to_ascii_lowercase();
+ let bytes = chain.read_file(&root_path).expect("read root");
+ let root = parse_wmo_root(&bytes).expect("parse root");
+ let stem = root_path.strip_suffix(".wmo").unwrap().to_string();
+ let mut slices = Vec::new();
+ let mut batches = Vec::new();
+ for gi in 0..root.group_count() {
+ let Ok(gbytes) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else {
+ slices.push((0, 0));
+ continue;
+ };
+ let h = wmo_group_header(&gbytes);
+ slices.push(h.map_or((0, 0), |h| (h.portal_ref_start, h.portal_ref_count)));
+ for s in benilla_formats::wmo_group_submeshes(&gbytes, &root).unwrap_or_default() {
+ if let Some(class) = s.wmo_batch {
+ batches.push((gi as u16, class, s.window, s.sidn.is_some(), s.positions));
+ }
+ }
+ }
+ Loaded {
+ groups: root.group_infos().to_vec(),
+ names: group_names(&bytes),
+ portals: parse_wmo_portals(&bytes),
+ slices,
+ batches,
+ }
+}
+
+fn census(path: &str) {
+ let l = load(path);
+ let graph = PortalGraph {
+ vertices: &l.portals.vertices,
+ infos: &l.portals.infos,
+ refs: &l.portals.refs,
+ slices: &l.slices,
+ };
+ let feed: Vec<(u16, bool, &[[f32; 3]])> = l
+ .batches
+ .iter()
+ .map(|(g, c, _, _, p)| (*g, matches!(c, benilla_formats::WmoBatchClass::Ext), &p[..]))
+ .collect();
+ let verts: usize = feed.iter().map(|b| b.2.len()).sum();
+ let rooms = l.groups.iter().filter(|g| g.interior).count();
+ println!("=== {path}: {} groups, {rooms} interior, {} batches, {verts} batch verts", l.groups.len(), feed.len());
+
+ let t = std::time::Instant::now();
+ let ranked = daylight_seeds_ranked(&l.groups, graph, feed.iter().copied());
+ let t_ranked = t.elapsed();
+ let t = std::time::Instant::now();
+ let bounds = boundary_clusters(&l.groups, &feed);
+ let t_bound = t.elapsed();
+ let (day, bleed) = placement_openings(&l.groups, graph, feed.iter().copied());
+ println!(
+ "live ranked seeds {} ({:.1} ms); boundary clusters if forced {} ({:.1} ms); kept {} day + {} bleed of budget {}",
+ ranked.len(),
+ t_ranked.as_secs_f64() * 1e3,
+ bounds.len(),
+ t_bound.as_secs_f64() * 1e3,
+ day.len(),
+ bleed.len(),
+ daylight_budget(&l.groups),
+ );
+ let mut by_how: HashMap<&str, usize> = HashMap::new();
+ for s in &day {
+ *by_how.entry(s.how.tag()).or_default() += 1;
+ }
+ println!("kept daylight by rule: {by_how:?}");
+ // Which rooms each lane actually REACHES: the fixture's own claim set (containment, portal hop,
+ // split), the same set the packer's room gate enforces.
+ let mut day_claim: HashMap> = HashMap::new();
+ for s in &day {
+ for c in daylight_claims(&l.groups, graph, s) {
+ day_claim
+ .entry(c.group)
+ .or_default()
+ .push(format!("{}@g{}{:?}", s.how.tag(), s.group, c.how));
+ }
+ }
+ let mut bleed_claim: HashSet = HashSet::new();
+ for s in &bleed {
+ for c in s.claims(&l.groups, graph) {
+ bleed_claim.insert(c.group);
+ }
+ }
+ let bound_groups: HashSet = bounds.iter().map(|b| b.0).collect();
+ let place = placement();
+ let (mut n_day, mut n_bleed, mut n_none) = (0, 0, 0);
+ for (gi, g) in l.groups.iter().enumerate() {
+ if !g.interior {
+ continue;
+ }
+ let gid = gi as u16;
+ let tag = if day_claim.contains_key(&gid) {
+ n_day += 1;
+ "DAY "
+ } else if bleed_claim.contains(&gid) {
+ n_bleed += 1;
+ "bleed"
+ } else {
+ n_none += 1;
+ "NONE "
+ };
+ let (start, count) = l.slices[gi];
+ let mut nb = Vec::new();
+ for r in l.portals.refs.iter().skip(start as usize).take(count as usize) {
+ let other = l.groups.get(r.group as usize).map_or('?', |o| if o.interior { 'i' } else { 'e' });
+ let area = benilla_formats::room_claim::portal_area(&graph, r.portal).unwrap_or(0.0);
+ nb.push(format!("p{}->g{}{}({:.0})", r.portal, r.group, other, area));
+ }
+ let (mut e, mut w) = (0, 0);
+ for (bg, c, win, _, _) in &l.batches {
+ if usize::from(*bg) == gi {
+ e += usize::from(matches!(c, benilla_formats::WmoBatchClass::Ext));
+ w += usize::from(*win);
+ }
+ }
+ let c = [
+ 0.5 * (g.bbox_min[0] + g.bbox_max[0]),
+ 0.5 * (g.bbox_min[1] + g.bbox_max[1]),
+ 0.5 * (g.bbox_min[2] + g.bbox_max[2]),
+ ];
+ let wc = place.map_or(c, |m| {
+ benilla_assets::coords::bevy_to_wow(
+ m.transform_point3(benilla_assets::coords::wow_to_bevy(c)),
+ )
+ });
+ println!(
+ " {tag} g{gi:<3} {:<18} at ({:8.1},{:7.1},{:6.1}) size ({:5.1},{:5.1},{:5.1}) E{e} W{w}{} {} {}",
+ l.names.get(gi).map(String::as_str).unwrap_or("-"),
+ wc[0], wc[1], wc[2],
+ g.bbox_max[0] - g.bbox_min[0],
+ g.bbox_max[1] - g.bbox_min[1],
+ g.bbox_max[2] - g.bbox_min[2],
+ if bound_groups.contains(&gid) { " stitch" } else { "" },
+ nb.join(" "),
+ day_claim.get(&gid).map(|v| v.iter().take(3).cloned().collect::>().join(",")).unwrap_or_default(),
+ );
+ }
+ println!("interior rooms: {n_day} reached by daylight, {n_bleed} by bleed only, {n_none} by neither");
+}
+
+/// `WOW_CENSUS_PLACE=map,tx,ty,uid`: the MODF placement, for world coordinates in the table.
+fn placement() -> Option {
+ let spec = std::env::var("WOW_CENSUS_PLACE").ok()?;
+ let p: Vec<&str> = spec.split(',').collect();
+ let (map, tx, ty, uid) = (p[0], p[1].parse().ok()?, p[2].parse().ok()?, p[3].parse::().ok()?);
+ let data = benilla_formats::wow_data()?;
+ let mut chain = open_chain(&data).ok()?;
+ let tile = benilla_formats::load_tile_mesh(&mut chain, map, tx, ty).ok()?;
+ let w = tile.wmos.iter().find(|w| w.unique_id == uid)?;
+ println!("placement uid {uid} pos {:?} rot {:?}", w.position, w.rotation);
+ Some(bevy::math::Affine3A::from_scale_rotation_translation(
+ Vec3::ONE,
+ benilla_assets::coords::placement_rotation(w.rotation),
+ benilla_assets::coords::wow_to_bevy(w.position),
+ ))
+}
+
+#[test]
+#[ignore = "instrument: needs WOW_DATA; run by hand with --ignored --nocapture"]
+fn city_daylight_census() {
+ match std::env::var("WOW_CENSUS_WMO") {
+ Ok(p) => census(&p),
+ Err(_) => {
+ census(r"World\wmo\Azeroth\Buildings\Stormwind\Stormwind.wmo");
+ census(r"World\wmo\KhazModan\Cities\Ironforge\ironforge.wmo");
+ }
+ }
+}
From adc9d277e7b6d84826292fbffc9028231321e2be Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 02:52:49 +0100
Subject: [PATCH 2/6] MONKEY (daylight): terrain as a caster in exterior torch
cube maps (torchTerrainShadows, default off)
Co-Authored-By: Claude Opus 5.5
---
crates/benilla-app/assets/ui/OptionsFrame.xml | 38 +++++-
crates/benilla-app/src/cvars.rs | 12 +-
crates/benilla-app/src/torch_shadow.rs | 47 ++++++-
.../src/ui_script/options_tests.rs | 5 +
crates/benilla-app/src/video.rs | 8 ++
crates/benilla-world/src/terrain_stream.rs | 3 +
.../src/terrain_stream/torch_terrain.rs | 115 ++++++++++++++++++
7 files changed, 223 insertions(+), 5 deletions(-)
create mode 100644 crates/benilla-world/src/terrain_stream/torch_terrain.rs
diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 71e19e82..2598e806 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -513,6 +513,7 @@
interiorLight = "Dynamic Interior Lighting",
interiorShadows = "Indoor Torch Shadows",
exteriorShadows = "Outdoor Torch Shadows",
+ torchTerrainShadows = "Terrain Blocks Torchlight",
torchSoft = "Torch Shadow Softness",
fireLight = "Fire Light Brightness",
fireFlicker = "Fire Flicker",
@@ -578,6 +579,13 @@
"The same for fires out in the open: campfires, braziers and lampposts "
.. "cast real shadows after dark.\n\n"
.. "It does nothing by day and does not require Dynamic Interior Lighting.",
+ TORCH_TERRAIN_SHADOWS =
+ "Lets the ground itself take part in outdoor torch shadows: a hill or a "
+ .. "bank between a fire and the slope behind it blocks the fire's light.
+
+"
+ .. "Adds the nearby terrain to each outdoor fire's shadow map when it is "
+ .. "rebuilt; the per-frame cost is small. Needs Outdoor Torch Shadows.",
TORCH_SOFT =
"How soft the edge of a torch shadow is where the object meets the "
.. "ground. Low is hard and crisp; high spreads the edge the way a real "
@@ -637,6 +645,7 @@
interiorLight = "Динамическое освещение помещений",
interiorShadows = "Тени факелов в помещениях",
exteriorShadows = "Тени огней под открытым небом",
+ torchTerrainShadows = "Рельеф заслоняет огни",
torchSoft = "Мягкость теней от огня",
fireLight = "Яркость огня",
fireFlicker = "Мерцание огня",
@@ -710,6 +719,14 @@
"То же для огней под открытым небом: костры, жаровни и фонари "
.. "отбрасывают настоящие тени после наступления темноты.\n\n"
.. "Днём не действует и не требует динамического освещения помещений.",
+ TORCH_TERRAIN_SHADOWS =
+ "Земля сама участвует в тенях от огней снаружи: холм или склон между "
+ .. "костром и местностью за ним заслоняет свет огня.
+
+"
+ .. "Добавляет ближний рельеф в карту теней каждого уличного огня при её "
+ .. "перестройке; затраты на кадр невелики. Требует теней огней под "
+ .. "открытым небом.",
TORCH_SOFT =
"Насколько мягок край тени от огня там, где предмет касается пола. "
.. "Низкое значение — резкий край, высокое — размытый, как от "
@@ -798,7 +815,9 @@
"RowLightingQuality", "RowWaterQuality", "RowVolumetricFog",
"RowCharacterShadows", "RowWorldShadows", "RowShadowDistance",
"RowShadowResolution", "RowMoonShadows",
- "RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft",
+ "RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows",
+ -- MONKEY (daylight: terrain torch casters)
+ "RowTorchTerrainShadows", "RowTorchSoft",
"RowFireLight", "RowFireFlicker", "RowSpellLights", "RowLavaGlow",
"RowNightDarkness", "RowInteriorDarkness",
},
@@ -1315,6 +1334,9 @@
local torch = (interior and GetCVar("interiorShadows") ~= "0")
or GetCVar("exteriorShadows") ~= "0"
OptionsRow_SetEnabled(getglobal(base .. "RowTorchSoft"), torch)
+ -- MONKEY (daylight: terrain torch casters): the ground only casts for outdoor fires.
+ OptionsRow_SetEnabled(getglobal(base .. "RowTorchTerrainShadows"),
+ GetCVar("exteriorShadows") ~= "0")
return
end
if key == "Controls" then
@@ -2721,11 +2743,23 @@
"BENILLA_TOOLTIP_EXTERIOR_SHADOWS")
-
+
+
+
+ OptionsRow_OnLoad(self, "torchTerrainShadows",
+ BENILLA_ADVGFX.torchTerrainShadows,
+ "BENILLA_TOOLTIP_TORCH_TERRAIN_SHADOWS")
+
+
+
+
+
+
+
-- 0.5..3, the CVar's own clamp verbatim: it is a
-- SCALE on the PCF tap radius at a contact, so the
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index 31a7eced..2235a622 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -1070,6 +1070,12 @@ pub(crate) const REGISTERED: &[Registered] = &[
"1",
"benilla's own: outdoor fire lights (campfires, braziers, lampposts) cast real shadows at night; no effect by day",
),
+ // MONKEY (daylight: terrain torch casters): the ground as a torch caster.
+ ours(
+ "torchTerrainShadows",
+ "0",
+ "benilla's own: the ground casts into outdoor fire shadows (hills and banks block a fire's light); needs exteriorShadows",
+ ),
// MONKEY (static torch cache): residency and per-frame work have separate live budgets.
ours(
"interiorShadowCasters",
@@ -2898,7 +2904,7 @@ mod tests {
let shadows = VideoConfig::default();
let flag = |b: bool| if b { 1.0 } else { 0.0 };
// MONKEY (volumetric fog): include the atmospheric tier in this fixed-size default table.
- let lighting: [(&str, f32); 33] = [
+ let lighting: [(&str, f32); 34] = [
("waterQuality", shadows.water_quality as f32),
// MONKEY (volumetric fog): weld registry and renderer defaults.
("volumetricFog", shadows.volumetric_fog as f32),
@@ -2926,6 +2932,8 @@ mod tests {
// MONKEY (torch shadows): the cube-map lane, indoors and out.
("interiorShadows", flag(shadows.interior_shadows)),
("exteriorShadows", flag(shadows.exterior_shadows)),
+ // MONKEY (daylight: terrain torch casters)
+ ("torchTerrainShadows", flag(shadows.torch_terrain_shadows)),
(
"interiorShadowCasters",
shadows.interior_shadow_casters as f32,
@@ -2957,7 +2965,7 @@ mod tests {
// row the client does not have; the length is the other half — 33 rows, 33 welds.
let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect();
// MONKEY (volumetric fog): the atmospheric tier joins the default-consumer weld.
- assert_eq!(welded.len(), 33, "the lighting lane welds 33 distinct rows");
+ assert_eq!(welded.len(), 34, "the lighting lane welds 34 distinct rows");
for name in &welded {
assert!(
REGISTERED.iter().any(|r| r.name == *name),
diff --git a/crates/benilla-app/src/torch_shadow.rs b/crates/benilla-app/src/torch_shadow.rs
index 26d8f3d7..6f31663b 100644
--- a/crates/benilla-app/src/torch_shadow.rs
+++ b/crates/benilla-app/src/torch_shadow.rs
@@ -645,6 +645,23 @@ struct EntityCasters<'w, 's> {
/// bundle rather than being a 17th system param because `update_torch_shadows` already sits at
/// Bevy's 16-param ceiling — the same reason `distance` is here.
lighting: Res<'w, WowLighting>,
+ /// MONKEY (daylight: terrain torch casters): the resident ADT tiles, for the ground half of an
+ /// exterior slot's static caster mesh (`torchTerrainShadows`). `Option` so a scene with no
+ /// terrain streamer (the glue, a booth) is simply terrain-less.
+ terrain: Option>,
+ adt_tiles: Option>>,
+}
+
+/// MONKEY (daylight: terrain torch casters): does an EXTERIOR slot's static map include the ground?
+/// The cvar `torchTerrainShadows`, or `WOW_TORCH_TERRAIN=0|1` (read once) for hermetic captures.
+fn terrain_casters_on(video: &VideoConfig) -> bool {
+ static ENV: std::sync::OnceLock