From 77c3559791d687297e9a0e6c817def35eb3b3bb9 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 02:53:01 +0100 Subject: [PATCH 1/7] 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" + ); + } +} From 47f1769b6d8ffffb3a729ec001e9df9023067a60 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 03:23:34 +0100 Subject: [PATCH 2/7] MONKEY (skybox): weighted zone skybox lane, flags, animated skybox rig CameraSkybox becomes a weighted layer list (ghost > WMO > zone, modern crossfade), flags 0x1 day cycle / 0x2 keep celestial / 0x4 fog cone, the celestial model as a second layer, and a CPU rig + mat-anim rows so skybox bones, texture transforms, colour and alpha tracks play. Co-Authored-By: Claude Opus 5.5 --- crates/benilla-world/src/lib.rs | 2 + .../benilla-world/src/model_render/batch.rs | 46 +- crates/benilla-world/src/skybox.rs | 845 ++++++++++++++---- crates/benilla-world/src/skybox_anim.rs | 527 +++++++++++ crates/benilla-world/src/world_census.rs | 5 +- 5 files changed, 1260 insertions(+), 165 deletions(-) create mode 100644 crates/benilla-world/src/skybox_anim.rs diff --git a/crates/benilla-world/src/lib.rs b/crates/benilla-world/src/lib.rs index 02aef8e8..38281004 100644 --- a/crates/benilla-world/src/lib.rs +++ b/crates/benilla-world/src/lib.rs @@ -146,6 +146,8 @@ mod shaders; pub mod sky; pub mod sky_order; pub mod skybox; +// MONKEY (skybox): the skybox rig and material loops. +mod skybox_anim; pub mod static_gx; pub mod static_merge; pub mod straddle; diff --git a/crates/benilla-world/src/model_render/batch.rs b/crates/benilla-world/src/model_render/batch.rs index 9adc718e..eeeded74 100644 --- a/crates/benilla-world/src/model_render/batch.rs +++ b/crates/benilla-world/src/model_render/batch.rs @@ -110,11 +110,16 @@ impl M2BatchMaterials<'_> { /// (`[CM2Model+0x180]`, from the interior crossfade `[0xce9bdc]`) scales every batch's alpha, /// and `0 < A < 1` promotes a batch to SRC_ALPHA blending (`0x70c190`): `fade_blend` is that /// twin, or `steady` when the batch already blends. + /// + /// MONKEY (skybox): `uv` and `tint` are the batch's texture-transform and colour loops, the + /// material's seed and identity; [`crate::skybox_anim`] drives their rows. pub fn skybox( &mut self, sub: &benilla_formats::RenderSubmesh, texture: Option>, order: u16, + uv: Option<&std::sync::Arc>, + tint: Option<&std::sync::Arc>, ) -> Option { let light = self.light.as_ref()?.0.clone(); let torch = self.torch.binds()?; @@ -136,10 +141,9 @@ impl M2BatchMaterials<'_> { sub.env_map, ShadeSel::Lit, // unread: every shipped skybox batch is UNLIT (0x01) order, - // No texture-transform or colour loop: neither shipped skybox authors one. Wiring - // one needs the `M2Model` lane's `Arc`, which the material key identifies it by. - None, - None, + // MONKEY (skybox): the loops' `Arc`s, which the material key identifies them by. + uv, + tint, None, // M2: no MOBA class, no SIDN, no WINDOW None, false, @@ -161,6 +165,40 @@ impl M2BatchMaterials<'_> { Some(SkyboxBatch { steady, fade_blend }) } + /// MONKEY (skybox): the flag `0x4` fog cone's material: an untextured, unlit, two-sided blend + /// on the sky lane, its colour the vertex colour times the tint row the skybox lane writes. + pub fn sky_fog_cone(&mut self, order: u16) -> Option> { + let light = self.light.as_ref()?.0.clone(); + let torch = self.torch.binds()?; + Some(model_material( + &mut self.cache.0, + &mut self.materials, + None, + ModelBlend::Blend, + true, + false, + false, + true, // unlit + false, + false, + true, // never writes depth + false, // …and always tests it + benilla_formats::FogPolicy::Off, + false, + ShadeSel::Lit, + order, + None, + None, + None, + None, + false, + true, // the sky lane + &light, + &torch, + None, + )) + } + /// One steady material lit by a render target's own light buffer (a portrait booth, the model /// pane, char-select), which [`super::MatKey::light`] keys apart from the world. `rig` lights /// by the scene's authored M2 rig ([`ShadeSel::Rig`]) and plays the UV animation. `order` must diff --git a/crates/benilla-world/src/skybox.rs b/crates/benilla-world/src/skybox.rs index d4995c09..c7ab7add 100644 --- a/crates/benilla-world/src/skybox.rs +++ b/crates/benilla-world/src/skybox.rs @@ -14,31 +14,88 @@ //! both moons, gradient band and cloud dome; below it they draw under the sky. The glare quads //! draw on their own path (`0x483740` → `0x6d48c0` → `0x7e57e0`), and the fog, ambient and //! diffuse are untouched. +//! +//! MONKEY (skybox): a third slot, the living player's zone skybox, behind the cvar +//! `zoneSkyboxes` ([`ZoneSkyboxes`]). The 1.12 engine never draws a `LightParams` skybox for the +//! living; the modern client does, weighted by the Light sphere falloff +//! ([`benilla_formats::LightCatalog::zone_skyboxes`]). The backdrop is a weighted list +//! ([`CameraSkybox`]): ghost > WMO > zone, the WMO sky crossfading the zone list by the modern +//! collector's rule. `LightSkybox.dbc` flags from the extended table: `0x1` plays sequence 0 over +//! the game day, `0x2` keeps the celestial pass, `0x4` draws a fog-colour cone over the horizon; +//! a celestial model draws as a layer under its main model. Every batch animates +//! ([`crate::skybox_anim`]): bones, texture transforms, colour and alpha tracks. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use bevy::asset::RenderAssetUsages; +use bevy::camera::visibility::NoFrustumCulling; +use bevy::math::Affine3A; use bevy::mesh::{Indices, MeshTag, PrimitiveTopology}; use bevy::prelude::*; use crate::model_render::M2BatchMaterials; +use crate::skybox_anim::{SkyMatLane, SkyRig}; use crate::view::WorldCamera; use benilla_assets::coords::{bevy_to_wow, wow_to_bevy}; use benilla_assets::materials::WowModelMaterial; use benilla_assets::WmoModel; use benilla_assets::{LockRecover, WorldAssets}; +use benilla_formats::{SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY, SKYBOX_KEEP_CELESTIAL}; /// The MOGP/MOGI group flag asking for the root's MOSB sky; the loader keeps the MOGP copy in /// `WmoGroupNav::flags`. const SHOW_SKYBOX: u32 = 0x40000; -/// The skybox model this frame asks for; `None` leaves the [`crate::sky`] gradient dome. -#[derive(Resource, Default, PartialEq, Eq)] -pub struct CameraSkybox(pub Option); +/// MONKEY (skybox): batch-order base of a main model's batches; a celestial layer takes `1..`, so +/// it draws first, under the main model, and the fog cone takes [`FOG_CONE_ORDER`], last. +const MAIN_ORDER_BASE: u16 = 24; +/// MONKEY (skybox): the fog cone's batch order, the band's last distinct step. +const FOG_CONE_ORDER: u16 = 56; + +/// MONKEY (skybox): cvar `zoneSkyboxes` (0/1, default 0): draw the living player's zone skybox +/// from `LightParams`. Off leaves the reference's two slots only. +#[derive(Resource, Default, Clone, Copy, PartialEq, Eq, Debug)] +pub struct ZoneSkyboxes(pub bool); + +/// One skybox model the frame asks for, at its weight. +#[derive(Clone, Debug, PartialEq)] +pub struct SkyboxLayer { + /// The model path, as the slot named it. + pub path: String, + /// The slot weight, `[0, 1]`; 0 fills the slot without drawing (`0x6d4afe`). + pub weight: f32, + /// `LightSkybox.dbc` flags, 0 without a row or in the 1.12 table. + pub flags: u32, + /// The celestial layer of another layer's row, drawn under it. + pub celestial: bool, +} + +/// The skybox models this frame asks for; empty leaves the [`crate::sky`] gradient dome. +#[derive(Resource, Default, PartialEq)] +pub struct CameraSkybox(pub Vec); + +impl CameraSkybox { + /// The heaviest main layer, the one a readout names. + pub fn primary(&self) -> Option<&SkyboxLayer> { + self.0 + .iter() + .filter(|l| !l.celestial) + .fold(None, |best: Option<&SkyboxLayer>, l| match best { + Some(b) if b.weight >= l.weight => Some(b), + _ => Some(l), + }) + } + + fn layer(&self, path: &str) -> Option<&SkyboxLayer> { + self.0.iter().find(|l| l.path == path) + } +} /// The skybox slot's weight this frame: the interior crossfade [`crate::lighting::WmoCrossfade`] /// (±0.25/s, `[0x8115b0]`) for a WMO sky, 1.0 for the ghost sky (`0x6d2260`), and 0 when none -/// resolves. +/// resolves. MONKEY (skybox): the heaviest layer that does not keep the celestial pass (flag +/// `0x2`), so a combining zone sky never stands the procedural sky down. #[derive(Resource, Default, PartialEq)] pub struct SkyboxWeight(pub f32); @@ -60,22 +117,55 @@ struct SkyboxPart { /// The blend-promotion twin for `0 < weight < 1` (`0x811fe0`); `steady` itself when the batch /// already blends. fade_blend: Handle, + /// MONKEY (skybox): the batch's colour-alpha × transparency loops (`0x707680`). + alpha: Option, + /// MONKEY (skybox): the last sampled alpha factor. + anim_alpha: f32, + /// MONKEY (skybox): the batch's texture-transform and colour rows. + lane: SkyMatLane, + /// MONKEY (skybox): how the rig moves the batch. + pose: PartPose, } -/// A batch wholly weighted to one parentless rotation-only bone, so it spins rigidly about the -/// bone's pivot ([`benilla_formats::BoneSpin`]) with no joint children, which would lag the -/// post-propagation anchor a frame. Only `CavernsOfTimeSky.m2`'s four asteroid belts spin. -#[derive(Component)] -struct SkyboxSpin { - /// The bone's pivot, in the same (Bevy) space the batch's vertices were baked into. - pivot: Vec3, - spin: benilla_formats::BoneSpin, +/// MONKEY (skybox): how a batch follows its model's rig. +enum PartPose { + /// No moving bone under the batch. + Static, + /// Wholly weighted to one bone: the bone's matrix is the batch's transform. + Rigid(u16), + /// Spread over moving bones: skinned on the CPU into its mesh. + Skinned { + mesh: Handle, + base: Vec, + joints: Vec<[u16; 4]>, + weights: Vec<[f32; 4]>, + }, } +/// MONKEY (skybox): the batch's model-space pose this frame, composed with the eye in +/// [`follow_camera`]. +#[derive(Component, Default)] +struct SkyboxLocal(Affine3A); + /// Skybox paths built this session, failed loads included, so a failure is not retried per frame. #[derive(Resource, Default)] struct BuiltSkyboxes(HashSet); +/// MONKEY (skybox): each built model's rig, by path. +#[derive(Resource, Default)] +struct SkyRigs(HashMap>); + +/// MONKEY (skybox): the flag `0x4` fog cone: its entity and its tint row. +#[derive(Resource, Default)] +struct FogCone { + entity: Option, + slot: Option, +} + +/// MONKEY (skybox): marks the fog cone's entity. +#[derive(Component)] +struct FogConePart; + /// [`CameraSkybox`] is settled after this set; the dome's gate runs after it, so the two backdrops /// agree within a frame. #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -89,9 +179,17 @@ impl Plugin for SkyboxPlugin { app.init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() .add_systems( Update, - (resolve_camera_skybox, build_skybox, apply_skybox_visibility) + ( + resolve_camera_skybox, + build_skybox, + animate_skyboxes, + apply_skybox_visibility, + ) .chain() // After the PVS pass, whose flood this reads the same frame. .after(crate::wmo_portal::WmoPvsSet) @@ -108,9 +206,49 @@ impl Plugin for SkyboxPlugin { } } +/// MONKEY (skybox): a path as `model_path` spells it, for comparing a MOSB name with a DBC one. +fn norm(path: &str) -> String { + let lower = path.to_ascii_lowercase(); + match lower + .strip_suffix(".mdx") + .or_else(|| lower.strip_suffix(".mdl")) + { + Some(stem) => format!("{stem}.m2"), + None => lower, + } +} + +/// MONKEY (skybox): the modern collector's step on layers (`addSkyBox`): the same model keeps the +/// larger weight and every other layer is scaled by `1 − weight`. +fn collect_layer(layers: &mut Vec, path: &str, weight: f32, flags: u32) { + let key = norm(path); + let at = match layers.iter().position(|l| norm(&l.path) == key) { + Some(i) => { + layers[i].weight = layers[i].weight.max(weight); + i + } + None => { + layers.push(SkyboxLayer { + path: path.to_owned(), + weight, + flags, + celestial: false, + }); + layers.len() - 1 + } + }; + let w = layers[at].weight; + for (i, l) in layers.iter_mut().enumerate() { + if i != at { + l.weight *= 1.0 - w; + } + } +} + /// Resolve the wanted skybox and its weight. A WMO sky needs a group of the placement's flood PVS, /// not the camera's own group, to carry [`SHOW_SKYBOX`] (`0x6b42e0`, `ebx` the group visited) and /// its root to name a MOSB; the weight rides the down-ray claim's crossfade, a separate resolver. +#[allow(clippy::too_many_arguments)] fn resolve_camera_skybox( instances: Query<&crate::wmo_portal::WmoPortalInstance>, wmos: Res>, @@ -119,71 +257,158 @@ fn resolve_camera_skybox( current_map: Option>, cam: Query<&GlobalTransform, With>, crossfade: Res, + zone: Res, + weather: Option>, + eye_liquid: crate::liquid::EyeLiquid, mut want: ResMut, mut weight: ResMut, ) { + let pos = cam.single().ok().map(|t| bevy_to_wow(t.translation())); + let map = current_map.as_ref().map_or(0, |m| m.0); + let catalog = sampler.as_ref().map(|s| &s.0); + let flags_of = |path: &str| { + catalog + .and_then(|c| c.skybox_def_by_path(&norm(path))) + .map_or(0, |d| d.flags) + }; + let mut layers: Vec = Vec::new(); + // The ghost sky first, as its slot skips the WMO one. Resolved from the atmosphere's map and // camera position, off the same ghost flag (`PLAYER_FLAGS` 0x10), so the two switch together. - if viewer.ghost { - let ghost_sky = sampler.as_ref().and_then(|s| { - let pos = bevy_to_wow(cam.single().ok()?.translation()); - let map = current_map.as_ref().map_or(0, |m| m.0); - s.0.ghost_skybox(map, pos) + let ghost_sky = viewer + .ghost + .then(|| catalog.zip(pos).and_then(|(c, p)| c.ghost_skybox(map, p))) + .flatten(); + if let Some(sky) = ghost_sky { + // The DBC slot's weight is 1.0 whenever filled (`0x6d26cb`/`0x6d26d0`): it pops in. + layers.push(SkyboxLayer { + path: sky.to_owned(), + weight: 1.0, + flags: flags_of(sky), + celestial: false, }); - if let Some(sky) = ghost_sky { - if want.0.as_deref() != Some(sky) { - want.0 = Some(sky.to_owned()); + } else { + // MONKEY (skybox): the zone list, under the WMO slot. Hidden submerged, as the celestial + // pass is; a storm lerps the storm slot's list over the clear one, as the atmosphere does. + if zone.0 && !eye_liquid.submersion().any() { + if let (Some(c), Some(p)) = (catalog, pos) { + let storm = weather + .as_ref() + .map_or(0.0, |w| crate::weather::storm_blend(w.sky_density)); + let mut acc: Vec<(u32, f32)> = c + .zone_skyboxes(map, p, false, benilla_formats::Submersion::Dry) + .into_iter() + .map(|e| (e.id, e.weight * (1.0 - storm))) + .collect(); + if storm > 0.0 { + for e in c.zone_skyboxes(map, p, true, benilla_formats::Submersion::Dry) { + match acc.iter_mut().find(|a| a.0 == e.id) { + Some(a) => a.1 += e.weight * storm, + None => acc.push((e.id, e.weight * storm)), + } + } + } + for (id, w) in acc { + let Some(def) = c.skybox_def(id) else { + continue; + }; + if w <= 0.0 { + continue; + } + match layers.iter_mut().find(|l| norm(&l.path) == def.path) { + Some(l) => l.weight = l.weight.max(w), + None => layers.push(SkyboxLayer { + path: def.path.clone(), + weight: w, + flags: def.flags, + celestial: false, + }), + } + } + } + } + // `min()`, not the first match: query order is unstable across frames, and two + // overlapping Caverns of Time shells both qualify. + let resolved = instances + .iter() + .filter_map(|inst| { + let model = wmos.get(&inst.handle)?; + // The MOSB test first: 810 of the game's 815 WMO roots name no skybox. + let sky = model.skybox.as_deref()?; + model + .group_nav + .iter() + .enumerate() + .any(|(i, nav)| { + // Fail closed, unlike the cull: a lookup miss here would paint a sky over + // the whole world. + nav.flags & SHOW_SKYBOX != 0 + && inst.visible.get(i).copied().unwrap_or(false) + }) + .then(|| sky.to_owned()) + }) + .min(); + // The WMO slot's weight is the interior crossfade `[0xce9bdc]`; a name seen through a + // doorway at weight 0 fills the slot, which `0x6d4afe` declines to draw. + if let Some(sky) = resolved { + let t = crossfade.t(); + let flags = flags_of(&sky); + if t > 0.0 { + collect_layer(&mut layers, &sky, t, flags); + } else if !layers.iter().any(|l| norm(&l.path) == norm(&sky)) { + layers.push(SkyboxLayer { + path: sky, + weight: 0.0, + flags, + celestial: false, + }); } - // The DBC slot's weight is 1.0 whenever filled (`0x6d26cb`/`0x6d26d0`): it pops in. - weight.set_if_neq(SkyboxWeight(1.0)); - return; } } - // `min()`, not the first match: query order is unstable across frames, and two overlapping - // Caverns of Time shells both qualify. - let resolved = instances + // MONKEY (skybox): each row's celestial model, a layer at its main model's weight and flags. + let celestial: Vec = layers .iter() - .filter_map(|inst| { - let model = wmos.get(&inst.handle)?; - // The MOSB test first: 810 of the game's 815 WMO roots name no skybox. - let sky = model.skybox.as_deref()?; - model - .group_nav - .iter() - .enumerate() - .any(|(i, nav)| { - // Fail closed, unlike the cull: a lookup miss here would paint a sky over the - // whole world. - nav.flags & SHOW_SKYBOX != 0 && inst.visible.get(i).copied().unwrap_or(false) - }) - .then(|| sky.to_owned()) + .filter_map(|l| { + let path = catalog? + .skybox_def_by_path(&norm(&l.path))? + .celestial + .clone()?; + Some(SkyboxLayer { + path, + weight: l.weight, + flags: l.flags, + celestial: true, + }) }) - .min(); - if want.0 != resolved { - want.0 = resolved; + .collect(); + layers.extend(celestial); + if want.0 != layers { + want.0 = layers; } - // The WMO slot's weight is the interior crossfade `[0xce9bdc]`; a name seen through a doorway - // at weight 0 fills the slot, which `0x6d4afe` declines to draw. - weight.set_if_neq(SkyboxWeight(match want.0 { - Some(_) => crossfade.t(), - None => 0.0, - })); + let stand_down = want + .0 + .iter() + .filter(|l| l.flags & SKYBOX_KEEP_CELESTIAL == 0) + .map(|l| l.weight) + .fold(0.0, f32::max); + weight.set_if_neq(SkyboxWeight(stand_down)); } -/// Build the wanted skybox on first request; the models are small and few, so it stays built. +/// Build the wanted skyboxes on first request; the models are small and few, so they stay built. +#[allow(clippy::too_many_arguments)] fn build_skybox( mut commands: Commands, want: Res, mut built: ResMut, + mut rigs: ResMut, + mut cone: ResMut, world_assets: Option>, mut meshes: ResMut>, mut images: ResMut>, + mut table: ResMut, mut mats: M2BatchMaterials, ) { - let Some(path) = want.0.as_deref() else { - return; - }; - if built.0.contains(path) { + if want.0.is_empty() { return; } // Before the shared light buffer exists: retry, without latching `built`. @@ -193,84 +418,213 @@ fn build_skybox( let Some(mut world_assets) = world_assets else { return; // assetless run: the gradient dome stays the backdrop }; - // The model's rigid spins by bone; none under a deterministic run, which keeps bind poses. - let spins = if crate::dev_state::deterministic_run() { - Default::default() - } else { - benilla_formats::load_m2_bone_spins(&mut world_assets.chain.lock_recover(), path) - .unwrap_or_default() - }; - let subs = benilla_formats::load_m2_mesh(&mut world_assets.chain.lock_recover(), path); - let subs = match subs { - Ok(subs) if !subs.is_empty() => subs, - Ok(_) => { - warn!("skybox '{path}' has no render batches — keeping the gradient dome"); - built.0.insert(path.to_string()); - return; + if cone.entity.is_none() && want.0.iter().any(|l| l.flags & SKYBOX_FOG_BLEND != 0) { + build_fog_cone(&mut commands, &mut cone, &mut meshes, &mut table, &mut mats); + } + for layer in &want.0 { + let path = layer.path.as_str(); + if built.0.contains(path) { + continue; } - Err(e) => { - warn!("skybox '{path}' failed to load, keeping the gradient dome: {e:#}"); - built.0.insert(path.to_string()); - return; - } - }; - for (i, sub) in subs.iter().enumerate() { - let mut mesh = Mesh::new( - PrimitiveTopology::TriangleList, - RenderAssetUsages::default(), - ); - let positions: Vec<[f32; 3]> = sub - .positions - .iter() - .map(|p| wow_to_bevy(*p).to_array()) - .collect(); - mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); - // Unread by the unlit sky, but the shared shader's vertex layout needs it. - mesh.insert_attribute( - Mesh::ATTRIBUTE_NORMAL, - sub.normals - .iter() - .map(|n| wow_to_bevy(*n).to_array()) - .collect::>(), + // MONKEY (skybox): the rig, off the same bytes the batches come from. + let bytes = world_assets + .chain + .lock_recover() + .read_file(&norm(path)) + .ok(); + let rig = Arc::new( + bytes + .as_deref() + .map(SkyRig::from_bytes) + .unwrap_or_default(), ); - mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, sub.uvs.clone()); - mesh.insert_indices(Indices::U32(sub.indices.clone())); - // The batch's authored address mode: Caverns of Time's belts wrap their UVs. - let texture = sub - .texture - .as_deref() - .and_then(|t| world_assets.texture(t, (sub.wrap_x, sub.wrap_y), &mut images)); - // `i + 1`, the authored batch order (0 is unordered): every batch shares one sort distance. - let Some(pair) = mats.skybox(sub, texture, u16::try_from(i + 1).unwrap_or(0)) else { - return; // light buffer vanished mid-build; `built` is unlatched, so we retry + let subs = benilla_formats::load_m2_mesh(&mut world_assets.chain.lock_recover(), path); + let subs = match subs { + Ok(subs) if !subs.is_empty() => subs, + Ok(_) => { + warn!("skybox '{path}' has no render batches — keeping the gradient dome"); + built.0.insert(path.to_string()); + continue; + } + Err(e) => { + warn!("skybox '{path}' failed to load, keeping the gradient dome: {e:#}"); + built.0.insert(path.to_string()); + continue; + } }; - let part = commands - .spawn(( - Mesh3d(meshes.add(mesh)), - MeshMaterial3d(pair.steady.clone()), - Transform::default(), - Visibility::Hidden, // `apply_skybox_visibility` turns on exactly the wanted one - // The crossfade's alpha (bits 0..=5), written only by `apply_skybox_visibility`. - MeshTag(crate::mesh_tag::spawn_tag(0, 1.0)), - SkyboxPart { - path: path.to_string(), - steady: pair.steady, - fade_blend: pair.fade_blend, + let base = if layer.celestial { 0 } else { MAIN_ORDER_BASE }; + for (i, sub) in subs.iter().enumerate() { + let mut mesh = Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ); + let positions: Vec = sub.positions.iter().map(|p| wow_to_bevy(*p)).collect(); + mesh.insert_attribute( + Mesh::ATTRIBUTE_POSITION, + positions.iter().map(|p| p.to_array()).collect::>(), + ); + // Unread by the unlit sky, but the shared shader's vertex layout needs it. + mesh.insert_attribute( + Mesh::ATTRIBUTE_NORMAL, + sub.normals + .iter() + .map(|n| wow_to_bevy(*n).to_array()) + .collect::>(), + ); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, sub.uvs.clone()); + // MONKEY (skybox): the constant M2Color, when the batch has one that is not white. + if sub.vertex_colors.len() == sub.positions.len() + && sub + .vertex_colors + .iter() + .any(|c| c.iter().any(|v| (v - 1.0).abs() > 1e-3)) + { + mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, sub.vertex_colors.clone()); + } + mesh.insert_indices(Indices::U32(sub.indices.clone())); + // The batch's authored address mode: Caverns of Time's belts wrap their UVs. + let texture = sub + .texture + .as_deref() + .and_then(|t| world_assets.texture(t, (sub.wrap_x, sub.wrap_y), &mut images)); + // MONKEY (skybox): the texture transform and colour loops as material identities. + let uv = sub.uv_anim.clone().map(Arc::new); + let tint = sub.rgb_anim.clone().map(Arc::new); + // The authored batch order after the layer's base (0 is unordered): every batch shares + // one sort distance. + let order = base + u16::try_from(i + 1).unwrap_or(0); + let Some(pair) = mats.skybox(sub, texture, order, uv.as_ref(), tint.as_ref()) else { + return; // light buffer vanished mid-build; `built` is unlatched, so we retry + }; + let lane = SkyMatLane::register( + sub, + uv, + tint, + &mut table, + mats.materials(), + &[pair.steady.id(), pair.fade_blend.id()], + ); + let mesh = meshes.add(mesh); + let pose = match sole_bone(sub) { + Some(b) if rig.bone_moves(b) => PartPose::Rigid(b), + Some(_) => PartPose::Static, + None if rig.animates() && !sub.joints.is_empty() => PartPose::Skinned { + mesh: mesh.clone(), + base: positions, + joints: sub.joints.clone(), + weights: sub.weights.clone(), }, - )) - .id(); - if let Some(spin) = sole_bone(sub).and_then(|b| spins.get(&b)) { - commands.entity(part).insert(SkyboxSpin { - pivot: wow_to_bevy(spin.pivot), - spin: spin.clone(), - }); + None => PartPose::Static, + }; + let skinned = matches!(pose, PartPose::Skinned { .. }); + let part = commands + .spawn(( + Mesh3d(mesh), + MeshMaterial3d(pair.steady.clone()), + Transform::default(), + Visibility::Hidden, // `apply_skybox_visibility` turns on exactly the wanted one + // The crossfade's alpha (bits 0..=5), written only by `apply_skybox_visibility`. + MeshTag(crate::mesh_tag::spawn_tag(0, 1.0)), + SkyboxLocal::default(), + SkyboxPart { + path: path.to_string(), + steady: pair.steady, + fade_blend: pair.fade_blend, + alpha: sub.alpha_anim.clone(), + anim_alpha: 1.0, + lane, + pose, + }, + )) + .id(); + if skinned { + // The mesh moves under its bounds; the shell surrounds the eye anyway. + commands.entity(part).insert(NoFrustumCulling); + } } + rigs.0.insert(path.to_string(), rig); + built.0.insert(path.to_string()); } - built.0.insert(path.to_string()); } -/// The bone every vertex of this batch is wholly weighted to, if any: the batch's half of -/// [`benilla_formats::BoneSpin`]'s rigid condition. +/// MONKEY (skybox): the flag `0x4` cone, the modern client's sky-cone draw in the final fog colour +/// (`skyMesh0x4Sky`): a band around the eye, opaque at and below the horizon, fading out by 24° +/// of elevation. White vertices carry the alpha; the fog colour rides its tint row. +fn build_fog_cone( + commands: &mut Commands, + cone: &mut FogCone, + meshes: &mut Assets, + table: &mut crate::mat_anim_table::MatAnimTable, + mats: &mut M2BatchMaterials, +) { + const SEGMENTS: usize = 32; + const RADIUS: f32 = 40.0; + // (elevation degrees, alpha), bottom to top. + const RINGS: [(f32, f32); 7] = [ + (-60.0, 1.0), + (0.0, 1.0), + (4.0, 0.85), + (8.0, 0.6), + (12.0, 0.35), + (18.0, 0.12), + (24.0, 0.0), + ]; + let mut positions = Vec::new(); + let mut colors = Vec::new(); + for (elev, alpha) in RINGS { + let (se, ce) = elev.to_radians().sin_cos(); + for s in 0..=SEGMENTS { + let az = s as f32 / SEGMENTS as f32 * std::f32::consts::TAU; + positions.push([RADIUS * ce * az.cos(), RADIUS * se, RADIUS * ce * az.sin()]); + colors.push([1.0, 1.0, 1.0, alpha]); + } + } + let row = SEGMENTS as u32 + 1; + let mut indices = Vec::new(); + for r in 0..(RINGS.len() as u32 - 1) { + for s in 0..SEGMENTS as u32 { + let a = r * row + s; + let b = a + row; + indices.extend_from_slice(&[a, b, a + 1, a + 1, b, b + 1]); + } + } + let n = positions.len(); + let mut mesh = Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ); + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); + mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; n]); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, vec![[0.0, 0.0]; n]); + mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors); + mesh.insert_indices(Indices::U32(indices)); + let Some(material) = mats.sky_fog_cone(FOG_CONE_ORDER) else { + return; + }; + let slot = table.alloc(); + if let Some(slot) = slot { + crate::model_render::lazy::with_material_mut(mats.materials(), material.id(), |m| { + m.extension.anim_slots.y = f32::from(slot); + }); + } + let entity = commands + .spawn(( + Mesh3d(meshes.add(mesh)), + MeshMaterial3d(material), + Transform::default(), + Visibility::Hidden, + MeshTag(crate::mesh_tag::spawn_tag(0, 1.0)), + SkyboxLocal::default(), + FogConePart, + NoFrustumCulling, + )) + .id(); + cone.entity = Some(entity); + cone.slot = slot; +} + +/// The bone every vertex of this batch is wholly weighted to, if any: a rigid batch takes the +/// bone's matrix whole, with no skinning. fn sole_bone(sub: &benilla_formats::RenderSubmesh) -> Option { let bone = sub.joints.first()?[0]; (sub.weights.len() == sub.joints.len() @@ -283,22 +637,145 @@ fn sole_bone(sub: &benilla_formats::RenderSubmesh) -> Option { .then_some(bone) } -/// Show the wanted skybox at the slot weight and hide every other, the sole `Visibility`, material -/// and `MeshTag` writer for these entities. As the reference: hidden at weight 0 (`0x6d4afe`), the -/// weight in every batch's alpha (`0x710cb0` → `[CM2Model+0x180]`), and below 1 the batch -/// promoted to SRC_ALPHA blending whatever its mode (`0x811fe0`). +/// MONKEY (skybox): one model's clock and pose this frame. +struct ModelClock { + band_t: f32, + gseq: f64, + live: bool, + pose: Vec, +} + +/// MONKEY (skybox): pose every shown skybox and run its material loops on the model's clock: +/// sequence 0 at `duration × day fraction` under flag `0x1`, else the scene clock. A capture keeps +/// the bind pose and the seeds, as the shared lanes freeze, unless the day drives the clock. +#[allow(clippy::too_many_arguments)] +fn animate_skyboxes( + time: Res