From 77c3559791d687297e9a0e6c817def35eb3b3bb9 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 02:53:01 +0100 Subject: [PATCH] MONKEY (skybox): LightSkybox reader for both layouts + zone_skyboxes walk Reads the 1.12 two-field and the extended ID/Name/Flags/CelestialName table by header field count; zone_skyboxes() runs the modern collector over the sphere-weighted Light walk. Co-Authored-By: Claude Opus 5.5 --- crates/benilla-formats/src/lib.rs | 6 +- crates/benilla-formats/src/light.rs | 34 ++- crates/benilla-formats/src/light/skybox.rs | 249 +++++++++++++++++++++ 3 files changed, 267 insertions(+), 22 deletions(-) create mode 100644 crates/benilla-formats/src/light/skybox.rs diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index ad87a31e..83813eda 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -127,7 +127,11 @@ pub use ground_effects::{ GroundEffectCatalog, FRILL_DENSITY, FRILL_DENSITY_MAX, }; mod light; -pub use light::{Atmosphere, LightCatalog, Submersion, ZERO_KEY_COLOR, ZERO_KEY_SCALAR}; +// MONKEY (skybox): the skybox row and the zone walk's entries. +pub use light::{ + Atmosphere, LightCatalog, SkyboxDef, Submersion, ZoneSkybox, SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY, + SKYBOX_KEEP_CELESTIAL, ZERO_KEY_COLOR, ZERO_KEY_SCALAR, +}; mod loading_screen; pub use loading_screen::{load_loading_screens, LoadingScreenCatalog}; mod liquid; diff --git a/crates/benilla-formats/src/light.rs b/crates/benilla-formats/src/light.rs index 3edc847e..471e2ccf 100644 --- a/crates/benilla-formats/src/light.rs +++ b/crates/benilla-formats/src/light.rs @@ -7,13 +7,18 @@ use std::collections::HashMap; use anyhow::{Context, Result}; use benilla_dbc::{FieldType, Schema, SchemaField}; -use crate::dbc::{f32_at, parse, str_at, u32_at}; +use crate::dbc::{f32_at, parse, u32_at}; use crate::Chain; mod atmosphere; +// MONKEY (skybox): the extended LightSkybox layout and the zone skybox walk. +mod skybox; mod tables; pub use atmosphere::Atmosphere; +pub use skybox::{ + SkyboxDef, ZoneSkybox, SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY, SKYBOX_KEEP_CELESTIAL, +}; use atmosphere::{ FB_CLOUD_DENSITY, FB_FOG_END, FB_FOG_START_MULT, IB_AMBIENT, IB_CLOUD_GBASE, IB_CLOUD_SLOPE, IB_CLOUD_SUN, IB_DIFFUSE, IB_FOG_COLOR, IB_OCEAN_DEEP, IB_OCEAN_SHALLOW, IB_RIVER_DEEP, @@ -171,9 +176,10 @@ pub struct LightCatalog { /// `LightParams` id → `lightSkyboxID` ([`LP_SKYBOX`]), for its non-zero rows only. light_params_skybox: HashMap, /// `LightSkybox.dbc` id → model chain path, spelled as a WMO MOSB skybox is so one model never - /// builds twice. Only id 3 (`DeathClouds.mdx`) is reachable from `LightParams`; the other five - /// rows are MOSB skyboxes. - skyboxes: HashMap, + /// builds twice. Stock 1.12 reaches only id 3 (`DeathClouds.mdx`) from `LightParams`; the + /// other rows are MOSB skyboxes. MONKEY (skybox): flags and the celestial model ride along + /// ([`SkyboxDef`]), read from either table layout. + skyboxes: HashMap, } fn light_schema() -> Schema { @@ -319,22 +325,8 @@ impl LightCatalog { } (glow, highlight, water_alpha, skybox) }; - let skyboxes = { - let bytes = chain - .read_file(LIGHT_SKYBOX) - .with_context(|| format!("reading {LIGHT_SKYBOX}"))?; - let mut schema = Schema::new("LightSkybox"); - schema.add_field(SchemaField::new("ID", FieldType::UInt32)); - schema.add_field(SchemaField::new("Name", FieldType::String)); - let rs = parse(&bytes, schema, "LightSkybox")?; - let mut m = HashMap::with_capacity(rs.records().len()); - for r in rs.records() { - if let (Some(id), Some(path)) = (u32_at(r, 0), str_at(&rs, r, 1)) { - m.insert(id, crate::models::model_path(&path)); - } - } - m - }; + // MONKEY (skybox): both layouts, keyed by the header's field count. + let skyboxes = skybox::load_skyboxes(chain, LIGHT_SKYBOX)?; Ok(LightCatalog { lights, int_bands, @@ -438,7 +430,7 @@ impl LightCatalog { pub fn ghost_skybox(&self, map: u32, pos: [f32; 3]) -> Option<&str> { let light = self.pick_light(map, pos)?; let id = self.light_params_skybox.get(&light.params[SLOT_DEATH])?; - self.skyboxes.get(id).map(String::as_str) + self.skyboxes.get(id).map(|d| d.path.as_str()) } /// The reference's area-light blend: from the map's global light, lerp toward each local diff --git a/crates/benilla-formats/src/light/skybox.rs b/crates/benilla-formats/src/light/skybox.rs new file mode 100644 index 00000000..70322687 --- /dev/null +++ b/crates/benilla-formats/src/light/skybox.rs @@ -0,0 +1,249 @@ +//! MONKEY (skybox): `LightSkybox.dbc` in both layouts and the zone skybox walk. +//! +//! The 1.12 table is `ID, Name` (8-byte records). The extended table the sky patch ships is the +//! modern column set with paths in place of FileDataIDs: `ID, Name, Flags, CelestialName` +//! (16-byte records). The layout is picked by the header's field count, so an unpatched data set +//! loads with flags 0 and no celestial model. +//! +//! [`LightCatalog::zone_skyboxes`] is the modern client's skybox collector +//! (`DayNightLightHolder::SkyBoxCollector::addSkyBox`) over the same global-then-farthest-first +//! walk as [`LightCatalog::sample_blended`]: the same model keeps its largest alpha, and every +//! other model collected so far is scaled by `1 − alpha`, so two skyboxes crossfade. A light whose +//! param names no skybox leaves the list alone, as in the modern client. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use super::{blend_alpha, weather_slot, LightCatalog, Submersion, FALLBACK_LIGHT_ID, SLOT_CLEAR}; +use crate::dbc::{parse, str_at, u32_at}; +use crate::Chain; + +/// Flag `0x1`: the model's sequence 0 plays across the game day, `time = duration × day fraction`. +pub const SKYBOX_FULL_DAY: u32 = 0x1; +/// Flag `0x2`: the procedural sky (dome, sun, moons, stars, clouds) keeps drawing under the model. +pub const SKYBOX_KEEP_CELESTIAL: u32 = 0x2; +/// Flag `0x4`: an alpha cone in the final fog colour draws over the model's horizon. +pub const SKYBOX_FOG_BLEND: u32 = 0x4; + +/// One `LightSkybox.dbc` row. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkyboxDef { + /// The model's chain path, spelled as a WMO MOSB skybox is, so one model never builds twice. + pub path: String, + /// The modern flag word; 0 in the 1.12 layout. + pub flags: u32, + /// The second model the modern client names as the celestial layer; `None` when empty. + pub celestial: Option, +} + +/// One weighted skybox the zone walk collected. +#[derive(Clone, Debug, PartialEq)] +pub struct ZoneSkybox { + pub id: u32, + pub weight: f32, +} + +/// Read `LightSkybox.dbc`, choosing the schema by the header's field count. +pub(super) fn load_skyboxes(chain: &mut Chain, file: &str) -> Result> { + let bytes = chain + .read_file(file) + .with_context(|| format!("reading {file}"))?; + let fields = bytes + .get(8..12) + .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + .unwrap_or(2); + let extended = fields >= 4; + let mut schema = Schema::new("LightSkybox"); + schema.add_field(SchemaField::new("ID", FieldType::UInt32)); + schema.add_field(SchemaField::new("Name", FieldType::String)); + if extended { + schema.add_field(SchemaField::new("Flags", FieldType::UInt32)); + schema.add_field(SchemaField::new("CelestialName", FieldType::String)); + // Columns past the four this client reads, if a later table carries them. + for i in 4..fields { + schema.add_field(SchemaField::new(format!("extra{i}"), FieldType::UInt32)); + } + } + let rs = parse(&bytes, schema, "LightSkybox")?; + let mut m = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let (Some(id), Some(name)) = (u32_at(r, 0), str_at(&rs, r, 1)) else { + continue; + }; + let (flags, celestial) = if extended { + ( + u32_at(r, 2).unwrap_or(0), + str_at(&rs, r, 3) + .filter(|c| !c.trim().is_empty()) + .map(|c| crate::models::model_path(&c)), + ) + } else { + (0, None) + }; + m.insert( + id, + SkyboxDef { + path: crate::models::model_path(&name), + flags, + celestial, + }, + ); + } + Ok(m) +} + +/// The modern collector's step: dedupe by model with the larger alpha, then scale every other +/// entry by `1 − alpha` (`addSkyBox` steps 1 and 4). +pub(super) fn collect(list: &mut Vec, id: u32, alpha: f32) { + if alpha <= 0.0 { + return; + } + let (at, alpha) = match list.iter_mut().position(|e| e.id == id) { + Some(i) => { + let a = list[i].weight.max(alpha); + list[i].weight = a; + (i, a) + } + None => { + list.push(ZoneSkybox { id, weight: alpha }); + (list.len() - 1, alpha) + } + }; + for (i, e) in list.iter_mut().enumerate() { + if i != at { + e.weight *= 1.0 - alpha; + } + } +} + +impl LightCatalog { + /// A `LightSkybox.dbc` row by id. + pub fn skybox_def(&self, id: u32) -> Option<&SkyboxDef> { + self.skyboxes.get(&id) + } + + /// The row whose model is `path`, for a MOSB skybox the table also names. + pub fn skybox_def_by_path(&self, path: &str) -> Option<&SkyboxDef> { + self.skyboxes + .values() + .find(|d| d.path.eq_ignore_ascii_case(path)) + } + + /// The skybox the slot's param names; a weather slot without one falls back to the clear + /// slot's, because the sky patch authors the clear slot only. + fn light_skybox(&self, params: &[u32; 5], slot: usize) -> Option { + let of = |p: u32| self.light_params_skybox.get(&p).copied(); + let param = match params[slot] { + 0 => params[SLOT_CLEAR], + p => p, + }; + of(param).or_else(|| (slot != SLOT_CLEAR).then(|| of(params[SLOT_CLEAR])).flatten()) + } + + /// The weighted skyboxes at `pos` for the living: the global (or fallback) light at alpha 1, + /// then each local sphere containing `pos` farthest first at its falloff alpha, through + /// [`collect`]. The slot is the lighting resolve's ([`weather_slot`]), ghost excluded: the + /// death sky is [`Self::ghost_skybox`]. + pub fn zone_skyboxes( + &self, + map: u32, + pos: [f32; 3], + stormy: bool, + submersion: Submersion, + ) -> Vec { + let mut out = Vec::new(); + if submersion.fixed_param().is_some() { + return out; + } + let slot = weather_slot(false, stormy, submersion.is_water()); + let map_has_no_light = !self.lights.iter().any(|l| l.map == map); + let base = self + .lights + .iter() + .find(|l| l.map == map && l.global) + .or_else(|| { + map_has_no_light + .then(|| self.lights.iter().find(|l| l.id == FALLBACK_LIGHT_ID)) + .flatten() + }); + if let Some(sky) = base.and_then(|l| self.light_skybox(&l.params, slot)) { + collect(&mut out, sky, 1.0); + } + let mut locals: Vec<(f32, &super::Light)> = self + .lights + .iter() + .filter(|l| l.map == map && !l.global) + .filter_map(|l| { + let d = (0..3) + .map(|i| (l.pos[i] - pos[i]).powi(2)) + .sum::() + .sqrt(); + (d <= l.falloff_end).then_some((d, l)) + }) + .collect(); + locals.sort_by(|a, b| b.0.total_cmp(&a.0)); + for (dist, l) in locals { + if let Some(sky) = self.light_skybox(&l.params, slot) { + collect( + &mut out, + sky, + blend_alpha(dist, l.falloff_start, l.falloff_end), + ); + } + } + out.retain(|e| e.weight > 0.0 && self.skyboxes.contains_key(&e.id)); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn w(list: &[ZoneSkybox], id: u32) -> f32 { + list.iter().find(|e| e.id == id).map_or(0.0, |e| e.weight) + } + + #[test] + fn a_second_model_crossfades_the_first() { + let mut l = Vec::new(); + collect(&mut l, 1, 1.0); + collect(&mut l, 2, 0.25); + assert!((w(&l, 1) - 0.75).abs() < 1e-6); + assert!((w(&l, 2) - 0.25).abs() < 1e-6); + } + + #[test] + fn the_same_model_keeps_its_largest_alpha() { + let mut l = Vec::new(); + collect(&mut l, 1, 0.5); + collect(&mut l, 2, 0.5); + collect(&mut l, 1, 0.8); + assert_eq!(l.len(), 2); + assert!((w(&l, 1) - 0.8).abs() < 1e-6); + assert!((w(&l, 2) - 0.5 * 0.2).abs() < 1e-6); + } + + #[test] + fn a_zero_alpha_leaves_the_list_alone() { + let mut l = Vec::new(); + collect(&mut l, 1, 1.0); + collect(&mut l, 2, 0.0); + assert_eq!(l, vec![ZoneSkybox { id: 1, weight: 1.0 }]); + } + + /// The installed chain loads in either layout. + #[test] + fn the_installed_table_loads() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let m = load_skyboxes(&mut chain, super::super::LIGHT_SKYBOX).expect("LightSkybox"); + assert_eq!( + m.get(&3).map(|d| d.path.as_str()), + Some(r"environments\stars\deathclouds.m2"), + "the ghost sky row" + ); + } +}