From 300f0b0229d25d18dc3f2e2a866567f737cb8dcb Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 02:52:49 +0100
Subject: [PATCH] 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");
+ }
+ }
+}