MONKEY (fog): LightFogBand.dbc reader + LightCatalog::blend_chain

benilla-formats/src/light/fog_band.rs reads our LightFogBand.dbc (the
LightFloatBand record, 13 rows per LightParams: height fog, curve blend,
sun-fog rgb/strength/angle, end-fog rgb/distance). Absent file = empty
catalog, no error. blend_chain exposes the area-light blend order so a
side table folds with the same law as the stock bands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-25 09:20:58 +01:00
co-authored by Claude Opus 5.5
parent 71c50a7f4d
commit 3d13fd331e
4 changed files with 276 additions and 0 deletions
+2
View File
@@ -128,6 +128,8 @@ pub use ground_effects::{
};
mod light;
pub use light::{Atmosphere, LightCatalog, Submersion, ZERO_KEY_COLOR, ZERO_KEY_SCALAR};
// MONKEY (fog): `LightFogBand.dbc`.
pub use light::{FogBand, FogBandCatalog, FOG_BANDS_PER_PARAM};
mod loading_screen;
pub use loading_screen::{load_loading_screens, LoadingScreenCatalog};
mod liquid;
+63
View File
@@ -11,9 +11,12 @@ use crate::dbc::{f32_at, parse, str_at, u32_at};
use crate::Chain;
mod atmosphere;
// MONKEY (fog): `LightFogBand.dbc`, the modern fog fields.
mod fog_band;
mod tables;
pub use atmosphere::Atmosphere;
pub use fog_band::{FogBand, FogBandCatalog, FOG_BANDS_PER_PARAM};
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,
@@ -509,6 +512,66 @@ impl LightCatalog {
acc
}
/// MONKEY (fog): the `LightParams` ids [`Self::sample_blended`] folds, in its order, each with its
/// lerp weight (the base first at 1.0). A side table keyed by `LightParams` (`LightFogBand.dbc`)
/// blends through this with the same law. Magma and slime give their fixed row alone.
pub fn blend_chain(
&self,
map: u32,
pos: [f32; 3],
stormy: bool,
submersion: Submersion,
ghost: bool,
) -> Vec<(u32, f32)> {
if let Some(p) = submersion.fixed_param() {
if self.has_param(p) {
return vec![(p, 1.0)];
}
}
let slot = weather_slot(ghost, stormy, submersion.is_water());
let param_of = |l: &Light| match l.params[slot] {
0 => l.params[SLOT_CLEAR],
p => p,
};
let mut chain = Vec::new();
let map_has_no_light = !self.lights.iter().any(|l| l.map == map);
if let Some(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()
})
{
let p = param_of(base);
if p >= 1 {
chain.push((p, 1.0));
}
}
let mut locals: Vec<(f32, &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 {
let p = param_of(l);
if p >= 1 {
chain.push((p, blend_alpha(dist, l.falloff_start, l.falloff_end)));
}
}
chain
}
/// Debug: the spheres near `pos`, nearest first (the blend applies them farthest first), then
/// the blended result against the `pick_light` sample and the water swatch endpoints.
pub fn debug_blend(&self, map: u32, pos: [f32; 3], time: u32) {
@@ -0,0 +1,201 @@
//! MONKEY (fog): `LightFogBand.dbc`, the modern fog fields 1.12 has no column for.
//!
//! The file is ours (DESIGN-Benilla-Graphics §2a). It mirrors `LightFloatBand.dbc` exactly: the
//! same 34-field, 136-byte band record (`ID, num, time[16], value[16]`, times in half-minutes on
//! the 2880 day), interpolated the same way across midnight. `LightParams` id `P` keys
//! [`FOG_BANDS_PER_PARAM`] rows, band `b` at id `(P − 1)·13 + b + 1`, the `LightFloatBand`
//! indexing with its own stride, so the stock `paramID·6 + k` tables stay untouched.
//!
//! | band | field | unit |
//! |---|---|---|
//! | 0 | height-fog density | extra extinction below the plane, × the distance fog's (0 = off) |
//! | 1 | height-fog height | WoW world Z, yards |
//! | 2 | height-fog falloff | 1/yd: how fast the extra density fades above the plane |
//! | 3 | curve blend | 0 = exponential (modern), 1 = the 1.12 linear ramp |
//! | 4-6 | sun-fog colour r, g, b | gamma 0..1 |
//! | 7 | sun-fog strength | 0..1 |
//! | 8 | sun-fog angle | cosine threshold of the lobe around the sun |
//! | 9-11 | end-fog colour r, g, b | gamma 0..1 |
//! | 12 | end-fog distance | yards (0 = the scene fog end) |
//!
//! A missing file, or a `LightParams` id without rows, is not an error: the caller falls back to
//! the colours it derives from the 1.12 bands.
use std::collections::HashMap;
use benilla_dbc::FieldType;
use super::tables::{band_schema, load_float_bands, sample_float, Band};
use crate::Chain;
const FOG_BAND: &str = "DBFilesClient\\LightFogBand.dbc";
/// Rows per `LightParams` id.
pub const FOG_BANDS_PER_PARAM: u32 = 13;
/// One `LightParams` id's fog fields at one time of day (see the module table).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FogBand {
pub height_density: f32,
/// WoW world Z, yards.
pub height: f32,
pub height_falloff: f32,
pub curve_blend: f32,
pub sun_rgb: [f32; 3],
pub sun_strength: f32,
pub sun_angle: f32,
pub end_rgb: [f32; 3],
/// Yards; 0 = the scene fog end.
pub end_distance: f32,
}
impl FogBand {
/// Componentwise `self + (o − self)·t`, the area and storm blend.
pub fn lerp(&self, o: &FogBand, t: f32) -> FogBand {
let l = |a: f32, b: f32| a + (b - a) * t;
let l3 = |a: [f32; 3], b: [f32; 3]| [l(a[0], b[0]), l(a[1], b[1]), l(a[2], b[2])];
FogBand {
height_density: l(self.height_density, o.height_density),
height: l(self.height, o.height),
height_falloff: l(self.height_falloff, o.height_falloff),
curve_blend: l(self.curve_blend, o.curve_blend),
sun_rgb: l3(self.sun_rgb, o.sun_rgb),
sun_strength: l(self.sun_strength, o.sun_strength),
sun_angle: l(self.sun_angle, o.sun_angle),
end_rgb: l3(self.end_rgb, o.end_rgb),
end_distance: l(self.end_distance, o.end_distance),
}
}
}
/// The parsed `LightFogBand.dbc`; empty when the file is absent.
#[derive(Default)]
pub struct FogBandCatalog {
bands: HashMap<u32, Band<f32>>,
}
impl FogBandCatalog {
/// Read `LightFogBand.dbc` off the chain. Absent or unreadable → an empty catalog (every
/// lookup `None`), which is the 1.12 data set.
pub fn load(chain: &mut Chain) -> Self {
let bands = load_float_bands(
chain,
FOG_BAND,
band_schema("LightFogBand", FieldType::Float32),
)
.unwrap_or_default();
FogBandCatalog { bands }
}
/// Whether the file carried any row.
pub fn is_empty(&self) -> bool {
self.bands.is_empty()
}
/// `LightParams` id `p` at `time` (half-minutes), or `None` when `p` has no rows. A single
/// missing band of a present id reads 0.
pub fn sample(&self, p: u32, time: u32) -> Option<FogBand> {
if p < 1 {
return None;
}
let base = (p - 1) * FOG_BANDS_PER_PARAM + 1;
let rows: Vec<Option<f32>> = (0..FOG_BANDS_PER_PARAM)
.map(|b| self.bands.get(&(base + b)).and_then(|band| sample_float(band, time)))
.collect();
if rows.iter().all(Option::is_none) {
return None;
}
let v = |b: usize| rows[b].unwrap_or(0.0);
Some(FogBand {
height_density: v(0),
height: v(1),
height_falloff: v(2),
curve_blend: v(3),
sun_rgb: [v(4), v(5), v(6)],
sun_strength: v(7),
sun_angle: v(8),
end_rgb: [v(9), v(10), v(11)],
end_distance: v(12),
})
}
/// The area/storm blend of [`super::LightCatalog::blend_chain`]: each id's sample, or
/// `fallback` for an id without rows, lerped in chain order. `None` when no id in the chain has
/// rows, so the caller keeps its own derivation untouched.
pub fn sample_chain(
&self,
chain: &[(u32, f32)],
time: u32,
fallback: &FogBand,
) -> Option<FogBand> {
let mut any = false;
let mut acc = *fallback;
for &(p, w) in chain {
let v = match self.sample(p, time) {
Some(v) => {
any = true;
v
}
None => *fallback,
};
acc = acc.lerp(&v, w);
}
any.then_some(acc)
}
/// Test/tool constructor: a catalog from `(row id, [(time, value)])` bands.
pub fn from_rows(rows: &[(u32, &[(u32, f32)])]) -> Self {
let mut bands = HashMap::new();
for (id, keys) in rows {
bands.insert(*id, Band::from_keys(keys));
}
FogBandCatalog { bands }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn neutral() -> FogBand {
FogBand {
height_density: 0.0,
height: 0.0,
height_falloff: 0.0,
curve_blend: 0.0,
sun_rgb: [0.0; 3],
sun_strength: 0.0,
sun_angle: 1.0,
end_rgb: [0.0; 3],
end_distance: 0.0,
}
}
#[test]
fn empty_catalog_is_absent() {
let c = FogBandCatalog::default();
assert!(c.is_empty());
assert_eq!(c.sample(12, 1440), None);
assert_eq!(c.sample_chain(&[(12, 1.0)], 1440, &neutral()), None);
}
#[test]
fn rows_land_at_the_param_stride_and_interpolate() {
// Param 12, band 7 (sun strength): 0 at 06:00, 1 at 18:00.
let id = 11 * FOG_BANDS_PER_PARAM + 7 + 1;
let c = FogBandCatalog::from_rows(&[(id, &[(720, 0.0), (2160, 1.0)])]);
let noon = c.sample(12, 1440).unwrap();
assert!((noon.sun_strength - 0.5).abs() < 1e-6);
assert_eq!(noon.height_density, 0.0);
assert_eq!(c.sample(13, 1440), None);
}
#[test]
fn chain_blends_rows_against_the_fallback() {
let id = 11 * FOG_BANDS_PER_PARAM + 7 + 1;
let c = FogBandCatalog::from_rows(&[(id, &[(0, 1.0)])]);
// Base 13 (no rows → fallback strength 0), then param 12 at weight 0.25.
let out = c.sample_chain(&[(13, 1.0), (12, 0.25)], 100, &neutral()).unwrap();
assert!((out.sun_strength - 0.25).abs() < 1e-6);
}
}
@@ -18,6 +18,16 @@ pub(super) struct Band<T> {
values: Vec<T>,
}
// MONKEY (fog): a band from `(time, value)` keys, for `fog_band`'s tests and tools.
impl<T: Copy> Band<T> {
pub(super) fn from_keys(keys: &[(u32, T)]) -> Self {
Band {
times: keys.iter().map(|k| k.0).collect(),
values: keys.iter().map(|k| k.1).collect(),
}
}
}
/// The shared band record: `ID, num, time[16], value[16]`, 34 fields, 136 bytes.
pub(super) fn band_schema(name: &str, value: FieldType) -> Schema {
let mut s = Schema::new(name);