diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 6172256fce..d79ba7d43f 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -1331,6 +1331,17 @@ impl Game<'_> { pub(super) fn native_zoom(&self, zoom: &mut Option) { let _ = zoom; } + /// Final world fog, after ordinary calculations and Horizon's fog option, before upload. + /// Title panoramas skip it. Invalid color or distance values retain the original whole fog. + pub(super) fn native_render_fog( + &self, + partial: f32, + camera: [f64; 3], + fog_type: horizon_client::fog::FogType, + fog: &mut horizon_client::fog::FogData, + ) { + let _ = (partial, camera, fog_type, fog); + } /// The camera shake native mods ask for in this frame, `partial` ticks /// past the last tick (0 to 1): add your pitch, yaw and roll in degrees /// and your offset in blocks, along the camera's right, up and back, to diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs index bc837ad9cb..345479106b 100644 --- a/crates/horizon-client/src/game/render_tick.rs +++ b/crates/horizon-client/src/game/render_tick.rs @@ -140,8 +140,43 @@ impl Game<'_> { if !self.settings.horizon.fog { horizon_client::fog::clear_distance_fog(&mut fog, &input); } + if self.title.is_none() { + fog = self.native_fog_now(self.clock.fraction() as f32, at, fog_type, fog); + } fog } + fn native_fog_now( + &self, + partial: f32, + camera: [f64; 3], + fog_type: FogType, + original: FogData, + ) -> FogData { + let mut fog = original; + self.native_render_fog(partial, camera, fog_type, &mut fog); + let distances = [ + fog.environmental_start, + fog.environmental_end, + fog.render_distance_start, + fog.render_distance_end, + fog.sky_end, + fog.cloud_end, + ]; + if fog + .color + .iter() + .all(|v| v.is_finite() && (0.0..=1.0).contains(v)) + && distances.iter().all(|v| v.is_finite()) + && fog.environmental_end > fog.environmental_start + && fog.render_distance_end > fog.render_distance_start + && fog.sky_end >= 0.0 + && fog.cloud_end >= 0.0 + { + fog + } else { + original + } + } /// `FogRenderer.computeFogColor`'s night vision brightenFactor: /// `GameRenderer.nightVisionScale`, but not while Darkness is on. fn fog_night_vision(&self) -> f32 { diff --git a/tests/test_render_fog_hook.py b/tests/test_render_fog_hook.py new file mode 100644 index 0000000000..81a8183362 --- /dev/null +++ b/tests/test_render_fog_hook.py @@ -0,0 +1,63 @@ +"""Exercise the actual final-fog validation/dispatch with a fake mod.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +from test_world_input_capture import method + +ROOT = Path(__file__).resolve().parents[1] + + +class RenderFogHookTests(unittest.TestCase): + @unittest.skipUnless(shutil.which("rustc"), "requires Rust") + def test_valid_parameters_and_atomic_invalid_fallback(self): + source = (ROOT / "crates/horizon-client/src/game/render_tick.rs").read_text(encoding="utf-8") + actual = method(source, "native_fog_now") + fixture = r""" +#[derive(Clone, Copy)] +enum FogType { Water } +#[derive(Clone, Copy, Debug, PartialEq)] +struct FogData { + color: [f32; 4], environmental_start: f32, environmental_end: f32, + render_distance_start: f32, render_distance_end: f32, sky_end: f32, cloud_end: f32, +} +struct Game { replacement: Option } +impl Game { + fn native_render_fog(&self, partial: f32, camera: [f64; 3], kind: FogType, fog: &mut FogData) { + assert_eq!((partial, camera), (0.5, [1.0, 2.0, 3.0])); + assert!(matches!(kind, FogType::Water)); + assert_eq!(fog.environmental_start, -8.0); + if let Some(value) = self.replacement { *fog = value; } + } + METHOD +} +#[test] +fn changed_unchanged_and_invalid() { + let original = FogData { color: [0.2, 0.3, 0.4, 1.0], environmental_start: -8.0, + environmental_end: 24.0, render_distance_start: 230.0, render_distance_end: 256.0, + sky_end: 24.0, cloud_end: 24.0 }; + let run = |replacement| Game { replacement }.native_fog_now(0.5, [1.0, 2.0, 3.0], FogType::Water, original); + assert_eq!(run(None), original); + let changed = FogData { color: [1.0, 0.0, 0.5, 0.5], environmental_start: -16.0, + environmental_end: 48.0, render_distance_start: 512.0, render_distance_end: 1024.0, + sky_end: 0.0, cloud_end: 80.0 }; + assert_eq!(run(Some(changed)), changed); + for invalid in [FogData { environmental_start: 48.0, ..changed }, + FogData { environmental_end: f32::NAN, ..changed }, + FogData { render_distance_end: 512.0, ..changed }, + FogData { render_distance_start: f32::NEG_INFINITY, ..changed }, + FogData { sky_end: -1.0, ..changed }, FogData { cloud_end: f32::INFINITY, ..changed }, + FogData { cloud_end: -1.0, ..changed }, + FogData { color: [f32::NAN, 0.0, 0.0, 1.0], ..changed }, + FogData { color: [0.0, 0.0, 0.0, 1.1], ..changed }] { + assert_eq!(run(Some(invalid)), original); + } +} +""".replace("METHOD", actual) + with tempfile.TemporaryDirectory() as folder: + src, exe = Path(folder) / "fog.rs", Path(folder) / "fog.exe" + src.write_text(fixture, encoding="utf-8") + subprocess.run(["rustc", "--edition=2024", "--test", str(src), "-o", str(exe)], check=True) + subprocess.run([str(exe)], check=True) diff --git a/tools/native-hook-fragments/render_fog.json b/tools/native-hook-fragments/render_fog.json new file mode 100644 index 0000000000..8b3d42dda4 --- /dev/null +++ b/tools/native-hook-fragments/render_fog.json @@ -0,0 +1,12 @@ +{ + "hooks": [ + { + "name": "render_fog", + "target": "horizon_client::game::native_hooks::Game<'_>::native_render_fog", + "neoforge": "ViewportEvent.RenderFog", + "side": "client" + } + ], + "rust_summary": "a final world fog hook (`native_render_fog`).", + "doc_summary": "`native_render_fog(partial, camera, fog_type, fog)` adjusts final FogData after normal environment/effect/render-distance calculation and Horizon's fog option, before GPU upload. Title panoramas skip it. The whole request is accepted only when color components are finite in 0..=1, all distances are finite, environmental and render-distance ends exceed their starts, and sky/cloud ends are nonnegative. Negative near planes remain valid (water/lava). Invalid requests retain the original whole fog. Bundles need \"hooks\": @API:render_fog@." +}