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:
co-authored by
Claude Opus 5.5
parent
da0f1a0d80
commit
77c3559791
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user