diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md
new file mode 100644
index 00000000..5550fdcc
--- /dev/null
+++ b/THIRD-PARTY.md
@@ -0,0 +1,8 @@
+# Third-party notices
+
+## WarcraftXL colour grading
+
+The colour-grading pass in `crates/benilla-app/src/post/grading.rs` and `grading.wgsl` is ported
+from WarcraftXL's `wxl-retail-grading` implementation, Copyright (C) 2026 WarcraftXL. The source
+implementation is licensed under GPL-3.0-or-later. The port preserves its 32³ LUT convention,
+strength control, and strip lookup mathematics while uploading the strip as a native 3D texture.
diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index e8b7b0fa..de717b2d 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -497,6 +497,7 @@
-- MONKEY (post): HDR excess + halo quality.
bloom = "Bloom",
sunShafts = "Sun Shafts",
+ colorGrading = "Colour Grading",
fogOff = "Off",
waterQuality = "Water Quality",
waterClassic = "Classic",
@@ -540,6 +541,8 @@
"Makes fire, lava, spells and lit windows carry a soft halo. Low uses a smaller blur chain; High is smoother. Off leaves the original image unchanged.",
SUN_SHAFTS =
"Draws sunlight through gaps in trees and buildings. Scene depth blocks the rays; they fade at night, off-screen and indoors.",
+ COLOR_GRADING =
+ "Applies a subtle zone-specific colour palette to the 3D world and blends between its day and night looks. The interface is never graded.",
VOLUMETRIC_FOG =
"Off disables volumetric fog. Low adds nearby haze and sun or moon shafts with fewer steps; High uses more steps for smoother shafts. Mist is heavier at dawn and in bad weather, and subtle indoors. Shafts require shadows; with shadows off only haze remains.",
WATER_QUALITY =
@@ -627,6 +630,7 @@
-- MONKEY (post): Russian HDR excess + halo quality row.
bloom = "Свечение",
sunShafts = "Солнечные лучи",
+ colorGrading = "Цветокоррекция",
-- MONKEY (volumetric fog): Russian atmospheric quality row.
volumetricFog = "Объёмный туман",
fogOff = "Выкл",
@@ -673,6 +677,8 @@
"Добавляет мягкое сияние огню, лаве, заклинаниям и освещённым окнам. Низкое качество использует короткое размытие, высокое — более плавное. Выключение сохраняет исходное изображение.",
SUN_SHAFTS =
"Показывает солнечные лучи сквозь деревья и здания. Рельеф и здания перекрывают свет; ночью и в помещениях эффект исчезает.",
+ COLOR_GRADING =
+ "Применяет мягкую цветовую палитру зоны и плавно меняет дневной вариант на ночной. Интерфейс не изменяется.",
-- MONKEY (volumetric fog): localised effect and cost explanation.
VOLUMETRIC_FOG =
"Выкл отключает объёмный туман. Низкое добавляет дымку вблизи и лучи солнца или луны с меньшим числом шагов; Высокое использует больше шагов для более плавных лучей. Туман гуще на рассвете и в непогоду, а в помещениях слабее. Для лучей нужны тени; без теней остаётся только дымка.",
@@ -811,7 +817,7 @@
-- three of eighteen shadow settings and no hint that the other fifteen existed.
AdvancedGraphics = {
-- MONKEY (volumetric fog): include in refresh, defaults and search.
- "RowLightingQuality", "RowWaterQuality", "RowVolumetricFog", "RowBloom", "RowSunShafts",
+ "RowLightingQuality", "RowWaterQuality", "RowVolumetricFog", "RowBloom", "RowSunShafts", "RowColorGrading",
"RowCharacterShadows", "RowWorldShadows", "RowShadowDistance",
"RowShadowResolution", "RowMoonShadows",
"RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft",
@@ -2645,11 +2651,22 @@
"BENILLA_TOOLTIP_SUN_SHAFTS")
+
+
+
+
+
+
+
+ OptionsRow_OnLoad(self, "colorGrading", BENILLA_ADVGFX.colorGrading,
+ "BENILLA_TOOLTIP_COLOR_GRADING")
+
+
-
-
+
+
OptionsRow_OnLoad(self, "characterShadows",
diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs
index d970a9fd..ccc57ff2 100644
--- a/crates/benilla-app/src/capture/scenarios.rs
+++ b/crates/benilla-app/src/capture/scenarios.rs
@@ -1379,6 +1379,40 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 0,
ui: None,
},
+ // MONKEY (post): lane-specific A/B and perf viewpoints. Kept at the end for merge isolation.
+ Scenario {
+ name: "post-lava-searing",
+ map: Some(MAP_AZEROTH),
+ eye: LAVA_RIVER_EYE,
+ look: LAVA_RIVER_LOOK,
+ minute: 0,
+ ui: None,
+ },
+ Scenario {
+ name: "post-stormwind-night",
+ map: Some(MAP_AZEROTH),
+ eye: [-8833.38, 628.63, 96.0],
+ look: [-8809.1, 672.3, 94.0],
+ minute: 0,
+ ui: None,
+ },
+ Scenario {
+ name: "post-sunshafts-noon",
+ map: Some(MAP_AZEROTH),
+ eye: WATER_EYE,
+ // Noon's 85-degree sun above the Goldshire trees (azimuth 45 degrees).
+ look: [-9508.5, -292.1, 369.7],
+ minute: 720,
+ ui: None,
+ },
+ Scenario {
+ name: "post-duskwood-grade",
+ map: Some(MAP_AZEROTH),
+ eye: [-10580.0, -1200.0, 45.0],
+ look: [-10540.0, -1160.0, 30.0],
+ minute: 1260,
+ ui: None,
+ },
];
/// The owner's reported vantage for the lava-glow report: `(-7048.8, -1000.6, 242.0)` facing
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index 2a4f80b5..7d7c5a25 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -924,6 +924,7 @@ pub(crate) const REGISTERED: &[Registered] = &[
// MONKEY (post): tier 0 leaves every emissive site and the frame byte-identical.
ours("bloom", "2", "benilla's own: HDR emissive bloom, 0 Off / 1 Low / 2 High"),
ours("sunShafts", "1", "benilla's own: depth-occluded screen-space sun shafts"),
+ ours("colorGrading", "1", "benilla's own: zone and day/night 32-cube colour grade"),
ours(
"waterQuality",
"1",
@@ -2014,6 +2015,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
// MONKEY (post): no HDR lift or post pass is the byte-identical baseline.
("bloom", "0"),
("sunShafts", "0"),
+ ("colorGrading", "0"),
("lavaLightGain", "0"),
("fireLightGain", "0"),
("nightGain", "1.0"),
@@ -2040,6 +2042,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
// MONKEY (post): quarter-resolution halo.
("bloom", "1"),
("sunShafts", "0"),
+ ("colorGrading", "0"),
("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
@@ -2069,6 +2072,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
// MONKEY (post): quarter-resolution halo.
("bloom", "1"),
("sunShafts", "0"),
+ ("colorGrading", "0"),
("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
@@ -2099,6 +2103,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
// MONKEY (post): half-resolution halo.
("bloom", "2"),
("sunShafts", "1"),
+ ("colorGrading", "1"),
("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
@@ -2913,13 +2918,14 @@ mod tests {
let shadows = VideoConfig::default();
let flag = |b: bool| if b { 1.0 } else { 0.0 };
// MONKEY (volumetric fog): include the atmospheric tier in this fixed-size default table.
- let lighting: [(&str, f32); 35] = [
+ let lighting: [(&str, f32); 36] = [
("waterQuality", shadows.water_quality as f32),
// MONKEY (volumetric fog): weld registry and renderer defaults.
("volumetricFog", shadows.volumetric_fog as f32),
// MONKEY (post): weld registry and renderer defaults.
("bloom", shadows.bloom as f32),
("sunShafts", flag(shadows.sun_shafts)),
+ ("colorGrading", flag(shadows.color_grading)),
("lavaLightGain", shadows.lava_light_gain),
// The two sun lanes and the cascade they share.
("worldShadows", flag(shadows.world_shadows)),
@@ -2972,10 +2978,10 @@ mod tests {
assert_eq!(d[name], want, "{name}: registered default left the knob");
}
// …and the census IS the row set. A name here that nothing registers would weld against a
- // row the client does not have; the length is the other half — 35 rows, 35 welds.
+ // row the client does not have; the length is the other half — 36 rows, 36 welds.
let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect();
// MONKEY (volumetric fog): the atmospheric tier joins the default-consumer weld.
- assert_eq!(welded.len(), 35, "the lighting lane welds 35 distinct rows");
+ assert_eq!(welded.len(), 36, "the lighting lane welds 36 distinct rows");
for name in &welded {
assert!(
REGISTERED.iter().any(|r| r.name == *name),
@@ -3154,6 +3160,7 @@ mod tests {
// MONKEY (post): the post lane's live tier.
"bloom" => video.bloom as f32,
"sunShafts" => video.sun_shafts as u32 as f32,
+ "colorGrading" => video.color_grading as u32 as f32,
"lavaLightGain" => video.lava_light_gain,
"fireLightGain" => video.fire_light_gain,
"moonShadowStrength" => video.moon_shadow_strength,
diff --git a/crates/benilla-app/src/post/grading.rs b/crates/benilla-app/src/post/grading.rs
new file mode 100644
index 00000000..5a4dc42a
--- /dev/null
+++ b/crates/benilla-app/src/post/grading.rs
@@ -0,0 +1,432 @@
+//! MONKEY (post): zone/day-night colour grading through a real 32³ GPU texture.
+//!
+//! Ported from WarcraftXL's `wxl-retail-grading` (Copyright (C) 2026 WarcraftXL, GPL-3.0-or-later).
+//! Its 1024×32 BLP strip/two-bilinear-tap cube lookup becomes the equivalent hardware-trilinear
+//! lookup after upload to a 3D texture. See `THIRD-PARTY.md`.
+
+use super::bloom::BloomLabel;
+use crate::video::VideoConfig;
+use benilla_assets::{AssetSet, LockRecover, WorldAssets};
+use benilla_world::{lighting::WorldTime, terrain_stream::CurrentArea, view::WorldCamera};
+use bevy::{
+ asset::RenderAssetUsages,
+ core_pipeline::{core_3d::graph::Core3d, FullscreenShader},
+ ecs::query::QueryItem,
+ image::Image,
+ prelude::*,
+ render::{
+ diagnostic::RecordDiagnostics,
+ extract_component::{ExtractComponent, ExtractComponentPlugin},
+ render_asset::RenderAssets,
+ render_graph::{
+ NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
+ },
+ render_resource::{binding_types::*, *},
+ renderer::{RenderContext, RenderDevice, RenderQueue},
+ texture::GpuImage,
+ view::ViewTarget,
+ RenderApp, RenderStartup,
+ },
+};
+use std::collections::{HashMap, HashSet};
+
+pub(super) struct GradingPlugin;
+
+#[derive(Resource)]
+struct GradeCatalog {
+ rows: benilla_formats::MonkeyZoneGrades,
+ luts: HashMap>,
+ identity: Handle,
+}
+
+#[derive(Component, Clone, PartialEq, ExtractComponent)]
+struct GradeView {
+ day: Handle,
+ night: Handle,
+ // x = authored strength, y = night blend.
+ control: Vec4,
+}
+
+#[derive(Clone, Copy, ShaderType)]
+struct GradeUniform {
+ control: Vec4,
+}
+
+impl Plugin for GradingPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_plugins(ExtractComponentPlugin::::default())
+ .add_systems(Last, update_views);
+ if !app.is_plugin_added::() {
+ return;
+ }
+ app.add_systems(Startup, load_catalog.after(AssetSet::Open));
+ let shader = app
+ .world_mut()
+ .resource_mut::>()
+ .add(Shader::from_wgsl(
+ include_str!("grading.wgsl"),
+ "post/grading.wgsl",
+ ));
+ let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
+ return;
+ };
+ render_app
+ .insert_resource(GradeShader(shader))
+ .add_systems(RenderStartup, init_pipeline)
+ .add_render_graph_node::>(Core3d, GradeLabel)
+ .add_render_graph_edges(
+ Core3d,
+ (
+ BloomLabel,
+ GradeLabel,
+ benilla_world::ffx_glow::FfxGlowLabel,
+ ),
+ );
+ }
+}
+
+fn load_catalog(
+ mut commands: Commands,
+ assets: Option>,
+ mut images: ResMut>,
+) {
+ let identity = images.add(volume_image(identity_cube()));
+ let Some(assets) = assets else {
+ commands.insert_resource(GradeCatalog {
+ rows: default(),
+ luts: default(),
+ identity,
+ });
+ return;
+ };
+ let mut chain = assets.chain.lock_recover();
+ let loose_root = benilla_formats::wow_data();
+ let loaded_rows = loose_root
+ .as_ref()
+ .map(|root| root.join("DBFilesClient").join("MonkeyZoneGrade.dbc"))
+ .filter(|path| path.is_file())
+ .map(|path| {
+ std::fs::read(path)
+ .map_err(anyhow::Error::from)
+ .and_then(|bytes| benilla_formats::parse_monkey_zone_grades(&bytes))
+ })
+ .unwrap_or_else(|| benilla_formats::load_monkey_zone_grades(&mut chain));
+ let rows = match loaded_rows {
+ Ok(rows) => rows,
+ Err(error) => {
+ info!("post grading: no MonkeyZoneGrade.dbc; identity fallback ({error:#})");
+ commands.insert_resource(GradeCatalog {
+ rows: default(),
+ luts: default(),
+ identity,
+ });
+ return;
+ }
+ };
+ let paths: HashSet = rows
+ .rows()
+ .flat_map(|row| [row.day_lut.clone(), row.night_lut.clone()])
+ .collect();
+ let mut luts = HashMap::new();
+ for path in paths {
+ let loose = loose_root
+ .as_ref()
+ .map(|root| root.join(path.replace('\\', "/")))
+ .filter(|path| path.is_file())
+ .map(std::fs::read)
+ .transpose()
+ .map_err(anyhow::Error::from);
+ let result = loose
+ .and_then(|bytes| match bytes {
+ Some(bytes) => Ok(bytes),
+ None => chain.read_file(&path.replace('/', "\\")),
+ })
+ .and_then(|bytes| benilla_formats::blp_bytes_to_mip_chain(&bytes));
+ match result.and_then(|blp| strip_to_cube(&blp)) {
+ Ok(cube) => {
+ luts.insert(path, images.add(volume_image(cube)));
+ }
+ Err(error) => warn!("post grading: {path} rejected; identity fallback: {error:#}"),
+ }
+ }
+ info!(
+ "post grading: {} area rows, {} resident LUTs",
+ rows.rows().count(),
+ luts.len()
+ );
+ commands.insert_resource(GradeCatalog {
+ rows,
+ luts,
+ identity,
+ });
+}
+
+fn strip_to_cube(blp: &benilla_formats::BlpMipChain) -> anyhow::Result> {
+ anyhow::ensure!(
+ blp.width == 1024 && blp.height == 32,
+ "expected 1024x32, got {}x{}",
+ blp.width,
+ blp.height
+ );
+ anyhow::ensure!(!blp.mips.is_empty(), "BLP has no base mip");
+ let strip = &blp.mips[0];
+ anyhow::ensure!(
+ strip.len() == 1024 * 32 * 4,
+ "base mip has {} bytes",
+ strip.len()
+ );
+ let mut cube = vec![0; 32 * 32 * 32 * 4];
+ for b in 0..32usize {
+ for g in 0..32usize {
+ for r in 0..32usize {
+ let source = (g * 1024 + b * 32 + r) * 4;
+ let destination = ((b * 32 + g) * 32 + r) * 4;
+ cube[destination..destination + 4].copy_from_slice(&strip[source..source + 4]);
+ }
+ }
+ }
+ Ok(cube)
+}
+
+fn identity_cube() -> Vec {
+ let mut cube = Vec::with_capacity(32 * 32 * 32 * 4);
+ for b in 0..32u32 {
+ for g in 0..32u32 {
+ for r in 0..32u32 {
+ cube.extend([r, g, b].map(|v| ((v * 255 + 15) / 31) as u8));
+ cube.push(255);
+ }
+ }
+ }
+ cube
+}
+
+fn volume_image(cube: Vec) -> Image {
+ Image::new(
+ Extent3d {
+ width: 32,
+ height: 32,
+ depth_or_array_layers: 32,
+ },
+ TextureDimension::D3,
+ cube,
+ TextureFormat::Rgba8Unorm,
+ RenderAssetUsages::RENDER_WORLD,
+ )
+}
+
+fn update_views(
+ mut commands: Commands,
+ video: Res,
+ catalog: Option>,
+ area: Res,
+ areas: Option>,
+ time: Res,
+ cameras: Query<(Entity, &Camera, Option<&GradeView>), With>,
+) {
+ let selected = catalog.as_ref().and_then(|catalog| {
+ let leaf = area.0?;
+ let zone = areas
+ .as_ref()
+ .and_then(|areas| areas.0.top_zone(leaf))
+ .unwrap_or(leaf);
+ let row = catalog
+ .rows
+ .for_area(leaf)
+ .or_else(|| catalog.rows.for_area(zone))?;
+ (row.strength > 0.0).then(|| GradeView {
+ day: catalog
+ .luts
+ .get(&row.day_lut)
+ .unwrap_or(&catalog.identity)
+ .clone(),
+ night: catalog
+ .luts
+ .get(&row.night_lut)
+ .unwrap_or(&catalog.identity)
+ .clone(),
+ control: Vec4::new(row.strength, night_weight(time.minute_f), 0.0, 0.0),
+ })
+ });
+ for (entity, camera, old) in &cameras {
+ let next = (video.color_grading && camera.is_active)
+ .then(|| selected.clone())
+ .flatten();
+ match next {
+ Some(next) if old != Some(&next) => {
+ commands.entity(entity).insert(next);
+ }
+ None if old.is_some() => {
+ commands.entity(entity).remove::();
+ }
+ _ => {}
+ }
+ }
+}
+
+fn night_weight(minute: f32) -> f32 {
+ let dawn = smoothstep(330.0, 450.0, minute);
+ let dusk = smoothstep(1110.0, 1230.0, minute);
+ 1.0 - dawn * (1.0 - dusk)
+}
+
+fn smoothstep(a: f32, b: f32, value: f32) -> f32 {
+ let t = ((value - a) / (b - a)).clamp(0.0, 1.0);
+ t * t * (3.0 - 2.0 * t)
+}
+
+#[derive(Resource)]
+struct GradeShader(Handle);
+
+#[derive(Resource)]
+struct GradePipeline {
+ layout: BindGroupLayoutDescriptor,
+ scene_sampler: Sampler,
+ lut_sampler: Sampler,
+ pipeline: CachedRenderPipelineId,
+}
+
+#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
+struct GradeLabel;
+
+fn init_pipeline(
+ mut commands: Commands,
+ device: Res,
+ cache: Res,
+ shader: Res,
+ fullscreen: Res,
+) {
+ let layout = BindGroupLayoutDescriptor::new(
+ "post_grading_layout",
+ &BindGroupLayoutEntries::sequential(
+ ShaderStages::FRAGMENT,
+ (
+ texture_2d(TextureSampleType::Float { filterable: true }),
+ sampler(SamplerBindingType::Filtering),
+ texture_3d(TextureSampleType::Float { filterable: true }),
+ texture_3d(TextureSampleType::Float { filterable: true }),
+ sampler(SamplerBindingType::Filtering),
+ uniform_buffer::(false),
+ ),
+ ),
+ );
+ let pipeline = cache.queue_render_pipeline(RenderPipelineDescriptor {
+ label: Some("post_grading".into()),
+ layout: vec![layout.clone()],
+ vertex: fullscreen.to_vertex_state(),
+ fragment: Some(FragmentState {
+ shader: shader.0.clone(),
+ shader_defs: vec![],
+ entry_point: Some("fragment".into()),
+ targets: vec![Some(ColorTargetState {
+ format: ViewTarget::TEXTURE_FORMAT_HDR,
+ blend: None,
+ write_mask: ColorWrites::ALL,
+ })],
+ }),
+ ..default()
+ });
+ let sampler = |filter| {
+ device.create_sampler(&SamplerDescriptor {
+ min_filter: filter,
+ mag_filter: filter,
+ address_mode_u: AddressMode::ClampToEdge,
+ address_mode_v: AddressMode::ClampToEdge,
+ address_mode_w: AddressMode::ClampToEdge,
+ ..default()
+ })
+ };
+ commands.insert_resource(GradePipeline {
+ layout,
+ scene_sampler: sampler(FilterMode::Nearest),
+ lut_sampler: sampler(FilterMode::Linear),
+ pipeline,
+ });
+}
+
+#[derive(Default)]
+struct GradeNode;
+
+impl ViewNode for GradeNode {
+ type ViewQuery = (&'static ViewTarget, &'static GradeView);
+
+ fn run<'w>(
+ &self,
+ _graph: &mut RenderGraphContext,
+ context: &mut RenderContext<'w>,
+ (target, grade): QueryItem<'w, '_, Self::ViewQuery>,
+ world: &'w World,
+ ) -> Result<(), NodeRunError> {
+ let settings = world.resource::();
+ let cache = world.resource::();
+ let Some(pipeline) = cache.get_render_pipeline(settings.pipeline) else {
+ return Ok(());
+ };
+ let images = world.resource::>();
+ let (Some(day), Some(night)) = (images.get(&grade.day), images.get(&grade.night)) else {
+ return Ok(());
+ };
+ let device = context.render_device();
+ let mut uniform = UniformBuffer::from(GradeUniform {
+ control: grade.control,
+ });
+ uniform.write_buffer(device, world.resource::());
+ let out = target.post_process_write();
+ let bind = device.create_bind_group(
+ "post_grading",
+ &cache.get_bind_group_layout(&settings.layout),
+ &BindGroupEntries::sequential((
+ out.source,
+ &settings.scene_sampler,
+ &day.texture_view,
+ &night.texture_view,
+ &settings.lut_sampler,
+ uniform.binding().unwrap(),
+ )),
+ );
+ let diagnostics = context.diagnostic_recorder();
+ let mut pass = context
+ .command_encoder()
+ .begin_render_pass(&RenderPassDescriptor {
+ label: Some("post_grading"),
+ color_attachments: &[Some(RenderPassColorAttachment {
+ view: out.destination,
+ depth_slice: None,
+ resolve_target: None,
+ ops: Operations::default(),
+ })],
+ depth_stencil_attachment: None,
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ });
+ let span = diagnostics.pass_span(&mut pass, "post_grading");
+ pass.set_pipeline(pipeline);
+ pass.set_bind_group(0, &bind, &[]);
+ pass.draw(0..3, 0..1);
+ span.end(&mut pass);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn identity_volume_has_exact_axes() {
+ let cube = identity_cube();
+ let at = |r: usize, g: usize, b: usize| &cube[((b * 32 + g) * 32 + r) * 4..][..4];
+ assert_eq!(at(0, 0, 0), &[0, 0, 0, 255]);
+ assert_eq!(at(31, 0, 0), &[255, 0, 0, 255]);
+ assert_eq!(at(0, 31, 0), &[0, 255, 0, 255]);
+ assert_eq!(at(0, 0, 31), &[0, 0, 255, 255]);
+ }
+
+ #[test]
+ fn day_night_blend_is_continuous() {
+ assert_eq!(night_weight(0.0), 1.0);
+ assert_eq!(night_weight(720.0), 0.0);
+ assert_eq!(night_weight(1439.0), 1.0);
+ assert!((night_weight(390.0) - 0.5).abs() < 1.0e-6);
+ }
+}
diff --git a/crates/benilla-app/src/post/grading.wgsl b/crates/benilla-app/src/post/grading.wgsl
new file mode 100644
index 00000000..fefdc9da
--- /dev/null
+++ b/crates/benilla-app/src/post/grading.wgsl
@@ -0,0 +1,35 @@
+// Colour grading ported from WarcraftXL wxl-retail-grading.
+// Copyright (C) 2026 WarcraftXL. GPL-3.0-or-later; see THIRD-PARTY.md.
+// MONKEY (post): real 32³ textures make the original 1024x32 strip's two bilinear taps plus
+// lerp(fract(b*31)) one hardware-trilinear lookup, for both the day and night cubes.
+
+#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput
+
+@group(0) @binding(0) var scene: texture_2d;
+@group(0) @binding(1) var scene_sampler: sampler;
+@group(0) @binding(2) var day_lut: texture_3d;
+@group(0) @binding(3) var night_lut: texture_3d;
+@group(0) @binding(4) var lut_sampler: sampler;
+
+struct Grade {
+ control: vec4,
+};
+@group(0) @binding(5) var grade: Grade;
+
+fn grade_one(lut: texture_3d, color: vec3) -> vec3 {
+ // Half a texel in from each face, with 31 intervals across the 32-texel cube.
+ let uvw = color * (31.0 / 32.0) + vec3(0.5 / 32.0);
+ return textureSampleLevel(lut, lut_sampler, uvw, 0.0).rgb;
+}
+
+@fragment
+fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 {
+ let source = textureSampleLevel(scene, scene_sampler, in.uv, 0.0);
+ let base = clamp(source.rgb, vec3(0.0), vec3(1.0));
+ let day = grade_one(day_lut, base);
+ let night = grade_one(night_lut, base);
+ let authored = mix(day, night, grade.control.y);
+ let graded = mix(base, authored, grade.control.x);
+ // Bloom already consumed the HDR signal; preserve its remaining excess for the FFX clamp.
+ return vec4(graded + max(source.rgb - vec3(1.0), vec3(0.0)), source.a);
+}
diff --git a/crates/benilla-app/src/post/mod.rs b/crates/benilla-app/src/post/mod.rs
index 3d61ee58..2952e5a6 100644
--- a/crates/benilla-app/src/post/mod.rs
+++ b/crates/benilla-app/src/post/mod.rs
@@ -1,6 +1,7 @@
//! MONKEY (post): optional world-camera effects ahead of FFXGlow's gamma clamp.
mod bloom;
+mod grading;
mod sun_shafts;
pub(crate) struct PostPlugin;
@@ -8,6 +9,7 @@ pub(crate) struct PostPlugin;
impl bevy::prelude::Plugin for PostPlugin {
fn build(&self, app: &mut bevy::prelude::App) {
app.add_plugins(bloom::BloomPlugin);
+ app.add_plugins(grading::GradingPlugin);
app.add_plugins(sun_shafts::SunShaftsPlugin);
}
}
diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs
index d1492045..e440d6ac 100644
--- a/crates/benilla-app/src/video.rs
+++ b/crates/benilla-app/src/video.rs
@@ -226,6 +226,8 @@ pub(crate) struct VideoConfig {
pub(crate) bloom: u8,
// MONKEY (post): depth-occluded radial sun shafts.
pub(crate) sun_shafts: bool,
+ // MONKEY (post): zone/day-night LUT grading at the world-to-UI boundary.
+ pub(crate) color_grading: bool,
/// Brightness of lava lighting its surroundings, 0..4; 0 disables the glow.
/// Published to `benilla_world::lighting::LavaLightGain` by `dynamic_interior::bridge`.
pub(crate) lava_light_gain: f32,
@@ -475,6 +477,7 @@ impl Default for VideoConfig {
bloom: 2,
// MONKEY (post): part of the shipped High graphics preset.
sun_shafts: true,
+ color_grading: true,
lava_light_gain: 1.0,
fire_flicker: 1.0,
display: if windowed_env() {
@@ -559,6 +562,7 @@ pub(crate) fn on_cvar(
"bloom" => cfg.bloom = v.clamp(0.0, 2.0) as u8,
// MONKEY (post): the shafts lane is binary.
"sunshafts" => cfg.sun_shafts = v != 0.0,
+ "colorgrading" => cfg.color_grading = v != 0.0,
"lavalightgain" => cfg.lava_light_gain = v.clamp(0.0, 4.0),
"worldshadows" => cfg.world_shadows = ev.flag(),
"charactershadows" => cfg.character_shadows = ev.flag(),
diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs
index ad87a31e..fb9d9216 100644
--- a/crates/benilla-formats/src/lib.rs
+++ b/crates/benilla-formats/src/lib.rs
@@ -29,6 +29,10 @@ mod camera_shakes;
mod cinematics;
mod creatures;
mod dbc;
+mod monkey_zone_grade;
+pub use monkey_zone_grade::{
+ load_monkey_zone_grades, parse_monkey_zone_grades, MonkeyZoneGrade, MonkeyZoneGrades,
+};
mod unit_blood;
pub use camera_shakes::{load_camera_shakes, CameraShake, CameraShakeCatalog, SpellShakeGroup};
pub use cinematics::{
diff --git a/crates/benilla-formats/src/monkey_zone_grade.rs b/crates/benilla-formats/src/monkey_zone_grade.rs
new file mode 100644
index 00000000..2c690d92
--- /dev/null
+++ b/crates/benilla-formats/src/monkey_zone_grade.rs
@@ -0,0 +1,69 @@
+//! MONKEY (post): `MonkeyZoneGrade.dbc`, the optional area-to-day/night LUT table.
+
+use anyhow::{Context, Result};
+use benilla_dbc::{FieldType, Schema, SchemaField};
+
+use crate::dbc::{f32_at, parse, str_at, u32_at};
+use crate::Chain;
+
+const TABLE: &str = "DBFilesClient\\MonkeyZoneGrade.dbc";
+
+/// One authored zone grade. Missing files and absent area rows are handled by the caller as the
+/// identity cube, so this type only represents complete rows.
+#[derive(Clone, Debug, PartialEq)]
+pub struct MonkeyZoneGrade {
+ pub id: u32,
+ pub area_id: u32,
+ pub day_lut: String,
+ pub night_lut: String,
+ pub strength: f32,
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct MonkeyZoneGrades {
+ rows: Vec,
+}
+
+impl MonkeyZoneGrades {
+ pub fn for_area(&self, area_id: u32) -> Option<&MonkeyZoneGrade> {
+ self.rows.iter().find(|row| row.area_id == area_id)
+ }
+
+ pub fn rows(&self) -> impl Iterator- {
+ self.rows.iter()
+ }
+}
+
+/// Read the five-column extension table through the same typed DBC parser as the stock tables.
+pub fn load_monkey_zone_grades(chain: &mut Chain) -> Result {
+ let bytes = chain
+ .read_file(TABLE)
+ .context("reading MonkeyZoneGrade.dbc")?;
+ parse_monkey_zone_grades(&bytes)
+}
+
+/// Parse an in-memory extension table through the typed DBC reader. This is used for the loose
+/// capture patch; the MPQ-backed path above and this path therefore share the exact schema.
+pub fn parse_monkey_zone_grades(bytes: &[u8]) -> Result {
+ let mut schema = Schema::new("MonkeyZoneGrade");
+ schema.add_field(SchemaField::new("ID", FieldType::UInt32));
+ schema.add_field(SchemaField::new("AreaID", FieldType::UInt32));
+ schema.add_field(SchemaField::new("DayLUT", FieldType::String));
+ schema.add_field(SchemaField::new("NightLUT", FieldType::String));
+ schema.add_field(SchemaField::new("Strength", FieldType::Float32));
+ let records = parse(bytes, schema, "MonkeyZoneGrade.dbc")?;
+ let rows = records
+ .records()
+ .iter()
+ .filter_map(|record| {
+ Some(MonkeyZoneGrade {
+ id: u32_at(record, 0)?,
+ area_id: u32_at(record, 1)?,
+ day_lut: str_at(&records, record, 2)?,
+ night_lut: str_at(&records, record, 3)?,
+ strength: f32_at(record, 4).unwrap_or(0.0).clamp(0.0, 1.0),
+ })
+ })
+ .collect();
+ Ok(MonkeyZoneGrades { rows })
+}