diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs index a901db3a..228e54f3 100644 --- a/crates/benilla-assets/src/materials.rs +++ b/crates/benilla-assets/src/materials.rs @@ -473,6 +473,11 @@ pub struct LiquidExt { /// `y` = frame count, `z` = scroll flag, `w` = clock enable. #[uniform(102)] pub anim: Vec4, + /// `rgb` multiplies the magma/slime body before fog; `w` unread. White on everything 1.12 + /// draws its own way — see `benilla_world::liquid::surface::MagmaTone`, the one deliberate + /// departure from the shipped palette. + #[uniform(102)] + pub tint: Vec4, /// GFX (planar water): the mirrored view of the world /// (`benilla_world::liquid::reflect`), filtered, at half the viewport. Sampled only where the /// screen-space march missed and only when `water.mirror.x` says the target was rendered for diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl index 9b03ade0..487c5dff 100644 --- a/crates/benilla-assets/src/shaders/liquid.wgsl +++ b/crates/benilla-assets/src/shaders/liquid.wgsl @@ -48,6 +48,8 @@ struct LiquidParams { // y = frame count; z = scroll flag (WMO magma/slime only, liquid nibbles 6/7); w = clock // enable (0 on a deterministic run). anim: vec4, + // rgb multiplies the magma/slime body before fog; white everywhere 1.12 is left alone. + tint: vec4, }; @group(#{MATERIAL_BIND_GROUP}) @binding(102) var w: LiquidParams; @@ -243,10 +245,14 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { // Magma/slime: the sheet is the opaque body, unmodulated (the ADT vertex has no colour, the WMO // one is `0xffffffff`) and unlit (lighting off on both paths), but fogged. if (w.kind.x > 0.5) { + // The per-zone palette, applied to the body before fog and before the emissive boost, so + // a tinted pool's glow is its own colour and not the sheet's. White leaves the sheet + // exactly as the reference draws it. + let body = detail.rgb * w.tint.rgb; // MONKEY (post): distant magma keeps some authored body and crosses 1.0 for bloom. - let fogged = apply_fog(detail.rgb, in.world_position.xyz, in.room_fog); + let fogged = apply_fog(body, in.world_position.xyz, in.room_fog); let hot = emissive_hook::emissive_boost( - detail.rgb, emissive_hook::EMISSIVE_MAGMA, wow_light.light_diffuse.w, 1.0); + body, emissive_hook::EMISSIVE_MAGMA, wow_light.light_diffuse.w, 1.0); return vec4(mix(fogged, hot, emissive_hook::magma_fog_resist(wow_light.light_diffuse.w)), 1.0); } diff --git a/crates/benilla-world/src/liquid/surface.rs b/crates/benilla-world/src/liquid/surface.rs index e6c3a4a3..c6eee71d 100644 --- a/crates/benilla-world/src/liquid/surface.rs +++ b/crates/benilla-world/src/liquid/surface.rs @@ -78,14 +78,70 @@ impl LiquidPath { } } +/// Which palette a magma pool draws its body with: 1.12's own everywhere, bar the buildings +/// named here. +/// +/// **This is the one deliberate departure from the shipped art in the liquid renderer.** The +/// `XTextures\\lava` frames are a saturated red — mean RGB (176, 23, 0), maxima (255, 134, 0) on +/// frame 1 — and outdoors the zone's own fog carries them: Burning Steppes is a red haze and the +/// lava sits inside it. A city interior has no such haze, so the same sheet reads as a flat +/// neon slab against brown rock, and next to the Great Forge's own molten pour, which is a +/// near-white WMO material, it reads wrong twice over. grom, 2026-11-09: *"this red looks nice +/// in the burning steps but terrible in Ironforge in contrast to the lava pouring from the +/// forge [...] still keep the kinda colouring and tone of a per zone lava"*. +/// +/// Keyed on `MOHD.wmoID` (the `WMOAreaTable` key), so it names a building rather than a box of +/// world coordinates: it survives a placement moving and it cannot catch a neighbour's pool. +/// ADT magma never reaches it — open-world lava has no WMO — which is what leaves the Steppes +/// alone. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)] +pub(crate) enum MagmaTone { + /// The shipped frames, untouched. Every liquid in the world but the list below. + #[default] + AsShipped, + /// Ironforge (`wmoID` 208). Red down, green up: the body goes deeper and crustier and the + /// pour above it becomes the brightest thing in the room, which is what a forge should look + /// like. Measured over the `ironforge-lava` frame, mean RGB (202, 23, 0) -> (166, 37, 0). + Ironforge, +} + +impl MagmaTone { + /// The building's palette, by its `MOHD.wmoID`. + fn for_wmo(wmo_id: u32) -> Self { + match wmo_id { + 208 => Self::Ironforge, + _ => Self::AsShipped, + } + } + + /// The multiplier on the body, `rgb` in the shader's gamma space. + fn tint(self) -> Vec4 { + match self { + Self::AsShipped => Vec4::ONE, + Self::Ironforge => Vec4::new(0.82, 1.6, 1.0, 1.0), + } + } + + /// Every palette a material is built for. Only magma takes more than the shipped one. + fn all_for(kind: LiquidKind) -> &'static [Self] { + if kind == LiquidKind::Magma { + &[Self::AsShipped, Self::Ironforge] + } else { + &[Self::AsShipped] + } + } +} + /// Which shared material a surface takes. The scroll is per nibble and per path, which -/// [`LiquidKind`] collapses (2 and 6 are both `Magma`), so it keys the material too. +/// [`LiquidKind`] collapses (2 and 6 are both `Magma`), so it keys the material too; so does the +/// palette, which is per building. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] struct LiquidKey { kind: LiquidKind, path: LiquidPath, /// This surface takes the animated stage-0 texture matrix ([`scrolls`]). scroll: bool, + tone: MagmaTone, } /// Does a WMO MLIQ surface of this type nibble take the reference's animated stage-0 texture @@ -110,9 +166,15 @@ impl LiquidAssets { kind: LiquidKind, path: LiquidPath, scroll: bool, + tone: MagmaTone, ) -> Option> { self.materials - .get(&LiquidKey { kind, path, scroll }) + .get(&LiquidKey { + kind, + path, + scroll, + tone, + }) .map(|e| e.material.clone()) } } @@ -154,7 +216,8 @@ pub(crate) fn spawn_liquids<'a>( for lq in liquids { // The scene fog (the ADT passes submit none and draw under the scene submit `0x66ff20`), // and never a scroll, though ADT magma is nibble 6: its kernel zeroes the v-translate. - let Some(material) = liquid.material(lq.kind, LiquidPath::Adt, false) else { + let Some(material) = liquid.material(lq.kind, LiquidPath::Adt, false, MagmaTone::AsShipped) + else { continue; // this kind's frames failed to load (warned at setup) }; let info = wet_footprint(lq, &Transform::IDENTITY, LiquidSource::AdtChunk); @@ -253,6 +316,8 @@ pub(crate) fn spawn_wmo_liquids<'a>( transform: Transform, interior: bool, pool: WmoPool, + // The owning root's `MOHD.wmoID`, which picks the magma palette (`MagmaTone`). + wmo_id: u32, // The owning root's MOMT `diffColor` table: MOMT lives in the root, not the group file. material_diff_color: &[[f32; 3]], entities: &mut Vec, @@ -261,6 +326,7 @@ pub(crate) fn spawn_wmo_liquids<'a>( return; }; let path = LiquidPath::wmo(interior); + let tone = MagmaTone::for_wmo(wmo_id); for lq in liquids { // MONKEY (water): opt-in evidence for WMO-liquid classification and the raw MLIQ opacity // contract. The byte is not a depth; logging it beside the group and world centre makes a @@ -302,7 +368,7 @@ pub(crate) fn spawn_wmo_liquids<'a>( .copied() }) .flatten(); - let Some(material) = liquid.material(lq.kind, path, scroll) else { + let Some(material) = liquid.material(lq.kind, path, scroll, tone) else { continue; // this kind's frames failed to load (warned at setup) }; if scroll { @@ -415,13 +481,14 @@ pub(super) fn setup_liquid( } else { &[false] }; - for (path, &scroll) in [ + for ((path, &scroll), &tone) in [ LiquidPath::Adt, LiquidPath::WmoExterior, LiquidPath::WmoInterior, ] .into_iter() .flat_map(|p| scroll_lanes.iter().map(move |s| (p, s))) + .flat_map(|ps| MagmaTone::all_for(kind).iter().map(move |t| (ps, t))) { let material = materials.add(ExtendedMaterial { base: StandardMaterial { @@ -464,6 +531,8 @@ pub(super) fn setup_liquid( if scroll { 1.0 } else { 0.0 }, if deterministic { 0.0 } else { 1.0 }, ), + // The per-building magma palette; `Vec4::ONE` for everything else. + tint: tone.tint(), // MONKEY (enhanced water): the module's uniform; the sky rows and the // quality follow at runtime (`liquid/scene_depth.rs`). water: benilla_assets::WaterUniform { @@ -498,9 +567,15 @@ pub(super) fn setup_liquid( light_buf: world_assets.shared_light.clone(), }, }); - assets - .materials - .insert(LiquidKey { kind, path, scroll }, LiquidEntry { material }); + assets.materials.insert( + LiquidKey { + kind, + path, + scroll, + tone, + }, + LiquidEntry { material }, + ); } } // Frame sets, not materials. @@ -508,7 +583,11 @@ pub(super) fn setup_liquid( "liquid: loaded {} water frame set(s)", FRAME_SETS .iter() - .filter(|(k, ..)| assets.material(*k, LiquidPath::Adt, false).is_some()) + .filter(|(k, ..)| { + assets + .material(*k, LiquidPath::Adt, false, MagmaTone::AsShipped) + .is_some() + }) .count() ); commands.insert_resource(assets); @@ -621,6 +700,7 @@ mod tests { kind: LiquidKind::Still, path: LiquidPath::Adt, scroll: false, + tone: MagmaTone::AsShipped, }, LiquidEntry { material: Handle::default(), @@ -682,6 +762,46 @@ mod real_data { use benilla_formats::{parse_wmo_root, wmo_group_liquid_mesh}; use std::collections::HashMap; + /// The magma palette's key is a real `MOHD.wmoID`, read back off the shipped root. + /// + /// `MagmaTone::for_wmo` names 208 and nothing else. A constant like that rots in silence -- + /// the pool would simply stop being tinted and nobody would know which end moved -- so it is + /// pinned against the file it came from. + #[test] + fn ironforges_magma_palette_is_keyed_on_its_own_wmo_id() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let bytes = chain + .read_file("world\\wmo\\khazmodan\\cities\\ironforge\\ironforge.wmo") + .expect("root readable"); + let wmo_id = benilla_formats::wmo_root_id(&bytes); + assert_eq!( + wmo_id, 208, + "Ironforge's MOHD.wmoID moved; MagmaTone::for_wmo names the old one" + ); + assert_eq!( + super::MagmaTone::for_wmo(wmo_id), + super::MagmaTone::Ironforge, + ); + // Every other building in the city block keeps the shipped palette. + for other in [0, 1, 207, 209, 10047] { + assert_eq!( + super::MagmaTone::for_wmo(other), + super::MagmaTone::AsShipped, + "wmoID {other} must take the shipped frames", + ); + } + assert_eq!( + super::MagmaTone::AsShipped.tint(), + bevy::prelude::Vec4::ONE, + "the shipped palette must be a no-op multiply, or every pool in the world moves", + ); + // Only magma is built in more than one palette: a second water material per tone would + // be a draw call's worth of nothing. + assert_eq!(super::MagmaTone::all_for(LiquidKind::Magma).len(), 2); + assert_eq!(super::MagmaTone::all_for(LiquidKind::Still).len(), 1); + } + /// The scroll gate over the shipped pools, where both behaviours share buildings: Ironforge has /// 9 static nibble-2 and 2 scrolling nibble-6 magma groups, Blackrock is nibble 6 throughout, /// Undercity's 38 slime canals are nibble 3 and still, and Stratholme's are nibble 7 and flow. diff --git a/crates/benilla-world/src/terrain_stream/spawn/mod.rs b/crates/benilla-world/src/terrain_stream/spawn/mod.rs index f3bd09c5..7c495526 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/mod.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/mod.rs @@ -513,6 +513,8 @@ pub(super) fn spawn_loaded_placements( &p.transform, m.group_bounds.get(gi), ), + // The building's own id, which picks its magma palette. + m.wmo_id, // The root's MOMT diffColor table: an interior pool's body colour. &m.material_diff_color, &mut ents,