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"); + } + } +}