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 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-25 02:53:01 +01:00
co-authored by Claude Opus 5.5
parent da0f1a0d80
commit 77c3559791
3 changed files with 267 additions and 22 deletions
+5 -1
View File
@@ -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;
+13 -21
View File
@@ -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<u32, u32>,
/// `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<u32, String>,
/// 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<u32, SkyboxDef>,
}
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
+249
View File
@@ -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<String>,
}
/// 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<HashMap<u32, SkyboxDef>> {
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<ZoneSkybox>, 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<u32> {
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<ZoneSkybox> {
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::<f32>()
.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"
);
}
}