From e3148bf458cc7a7771b1fef14015707de90cc1eb Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 22:56:15 +0300 Subject: [PATCH] HD render fixes (#35-#39, #41) and replay time of day (#57) (fork patch 25); radar scan cycles with ageing tracks (#46), damage to individual system parts (#50), long-lasting fires (#52) Co-Authored-By: Claude Opus 5.5 --- airdef-mod/src/lib.rs | 37 +- airdef-mod/src/monitor.rs | 53 +- airdef-mod/src/radar.rs | 32 +- airdef-mod/src/replay.rs | 5 + airdef-mod/src/sim/damage.rs | 223 +++- airdef-mod/src/sim/fires.rs | 223 ++++ airdef-mod/src/sim/logistics.rs | 4 +- airdef-mod/src/sim/mod.rs | 141 ++- airdef-mod/src/sim/orders.rs | 10 +- airdef-mod/src/sim/record.rs | 60 ++ airdef-mod/src/sim/regress.rs | 46 + airdef-mod/src/sim/sensor_tests.rs | 228 ++++ airdef-mod/src/sim/sight.rs | 268 ++++- airdef-mod/src/sim/why.rs | 129 ++- docs/HD-RENDERER.md | 18 + ...ork-world-models-client-particles-so.patch | 2 +- ...izon-mod-report-particles-and-sounds.patch | 2 +- ...-fork-full-screen-post-pass-for-mods.patch | 2 +- ...far-models-as-scaled-impostors-past-.patch | 2 +- ...er.set-views-draws-the-world-from-ot.patch | 2 +- ...er-tick-and-per-event-AirDef-plans-r.patch | 2 +- ...-up-to-1920x1080-full-screen-replays.patch | 2 +- ...ant-terrain-tiles-fog-measured-from-.patch | 2 +- ...id-so-mods-keep-state-per-world-API-.patch | 2 +- ...d-trial-runs-behind-feature-full-so-.patch | 2 +- ...ffer-pass-per-pixel-mod-point-lights.patch | 2 +- ...e-2-sun-and-moon-shadow-cascades-the.patch | 2 +- ...ached-until-a-resize-or-a-new-source.patch | 2 +- ...-per-16x16-tile-surface-and-halo-lis.patch | 2 +- ...pass-in-the-F3-timing-list-half-reso.patch | 2 +- ...only-sun-facing-quads-and-skip-burie.patch | 2 +- ...es-render.model-id-model-generation-.patch | 2 +- ...aling-HORIZON_HD_SCALE-world-G-buffe.patch | 2 +- ...-pass-colour-normal-depth-the-floor-.patch | 2 +- ...lla-texture-on-the-surface-the-biome.patch | 2 +- ...-a-physical-atmosphere-Rayleigh-Mie-.patch | 2 +- ...r-blue-a-little-clearer-ripples-a-li.patch | 2 +- ...sunsets-Rec.-2020-atmosphere-sun-tin.patch | 2 +- ...es-for-simulations-render.query-rays.patch | 2 +- ...vel-from-the-world-not-from-rain-geo.patch | 992 ++++++++++++++++++ 40 files changed, 2374 insertions(+), 143 deletions(-) create mode 100644 airdef-mod/src/sim/fires.rs create mode 100644 airdef-mod/src/sim/sensor_tests.rs create mode 100644 horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index fff29fb..42e12a9 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -330,6 +330,16 @@ impl AirDef { let id = 0x6100_0000 | (b.id & 0x00ff_ffff); add(id, b.at + DVec3::Y * size as f64 * 0.5, (1.0, 0.58, 0.25), size * size * 18.0 * (glow + flash), size * 14.0); } + // Fires burn on for minutes: a flickering, wide glow that dies down with the flames. + for f in &self.sim.fires.list { + let (fl, sm) = f.level(tick); + if f.quiet { + continue; + } + let glow = fl.max(0.12 * sm * (1.0 - (tick.saturating_sub(f.start)) as f32 / f.life as f32)); + let id = 0x6200_0000 | (f.id & 0x00ff_ffff); + add(id, f.at + DVec3::Y * 1.2, (1.0, 0.5, 0.17), 70.0 * f.size * glow * flicker(f.id), 20.0 + 10.0 * f.size); + } for s in &self.sim.sams { if !s.alive() && s.burning() { add(s.id + 15, s.pos + DVec3::Y * 1.5, (1.0, 0.5, 0.18), 80.0 * flicker(s.id), 30.0); @@ -541,8 +551,11 @@ impl AirDef { } else { (s.health, s.cargo, s.missiles, s.bursts, s.empty, s.reloading) }; + // The parts' states (sensor, mount, launcher, gun, tracks, comms) close the line; older saves lack them. + let comps = if fresh { new.comps } else { s.comps }; + let parts = comps.0.iter().map(|v| format!("{v:.3}")).collect::>().join(" "); text += &format!( - "sam {} {} {} {} {} {} {} {} {} {} {} {} {dest}\n", + "sam {} {} {} {} {} {} {} {} {} {} {} {} {dest} {parts}\n", s.kind.key(), s.pos.x, s.pos.y, @@ -629,6 +642,13 @@ impl AirDef { (Some(x), Some(y), Some(z)) => Some(DVec3::new(x, y, z)), _ => None, }; + // The parts: absent in older saves, which load with every part whole. + for (k, v) in sam.comps.0.iter_mut().enumerate() { + if let Some(c) = num(16 + k) { + *v = c.clamp(0.0, 1.0); + } + } + sam.fit_cells(); self.sim.sams.push(sam); if let Some(to) = dest { let i = self.sim.sams.len() - 1; @@ -765,6 +785,7 @@ impl AirDef { fn stop_replay(&mut self) { self.replay = None; + render::set_time_override(None); input::claim_wheel(false); render::set_hud(&[]); render::set_views(&[]); @@ -959,7 +980,7 @@ impl AirDef { } } -const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | scenario save|load|list|delete | why [номер] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats"; +const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | scenario save|load|list|delete | why [номер] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | truth on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats"; impl Mod for AirDef { fn init() -> Result<(Self, Registration), String> { @@ -1083,6 +1104,7 @@ impl Mod for AirDef { let mut out = Out::default(); self.listener = DVec3::new(p.x, p.y + 1.6, p.z); self.sim.night = game::get_clock().is_some_and(|c| (13000..23000).contains(&(c.day_time % 24000))); + self.sim.day_time = game::get_clock().map(|c| c.day_time); self.sim.step(&mut self.ground, Some(DVec3::new(p.x, p.y, p.z)), &mut out); let wanted = self.ground.take_wanted(); if !wanted.is_empty() { @@ -1108,6 +1130,8 @@ impl Mod for AirDef { let size = render::gui_size(); render::set_hud(&r.hud(&self.sim, size)); render::set_views(&r.views(size)); + // The sky of the raid, not of now (None for a raid whose clock was unknown). + render::set_time_override(r.day_time(&self.sim)); self.monitor_shown = true; } else if self.grid { let stale = self.grid_ids.iter().any(|id| !self.sim.sams.iter().any(|s| s.id == *id)); @@ -1146,7 +1170,8 @@ impl Mod for AirDef { let at = s.dest.unwrap_or(s.pos); let place = (at.x * 10.0) as i64 as u64 ^ ((at.z * 10.0) as i64 as u64).rotate_left(21) ^ (if s.dest.is_some() { 0 } else { (s.heading * 100.0) as i64 as u64 }).rotate_left(42); let ammo = u64::from(s.missiles) | u64::from(s.bursts) << 16 | u64::from(s.cargo) << 32 | u64::from(s.empty) << 48 | u64::from(s.reloading > 0) << 49; - ((s.health * 1000.0) as u64).wrapping_mul(1_000_003) ^ place ^ ammo.rotate_left(7) + let parts = s.comps.0.iter().fold(0u64, |a, v| a.wrapping_mul(131).wrapping_add((v * 100.0) as u64)); + ((s.health * 1000.0) as u64).wrapping_mul(1_000_003) ^ place ^ ammo.rotate_left(7) ^ parts.rotate_left(13) }) .chain(self.sim.target_health.iter().map(|h| (h * 10.0) as u64)) .fold(self.sim.sams.len() as u64, |a, h| a.wrapping_mul(31).wrapping_add(h)); @@ -1409,7 +1434,7 @@ impl Mod for AirDef { ["repair"] => { let n = self.sim.repair(); self.dirty = true; - Some(format!("Отремонтировано комплексов: {n}; объекты восстановлены")) + Some(format!("Отремонтировано комплексов: {n} (корпус и все узлы: РЛС, привод, пусковая, орудие, ходовая, связь); объекты восстановлены")) } ["clear"] => { self.sim.clear(); @@ -1428,6 +1453,10 @@ impl Mod for AirDef { self.dirty = true; Some("Цели убраны".to_string()) } + ["truth", v @ ("on" | "off")] => { + self.sim.truth = *v == "on"; + Some(format!("Истинные данные на радаре: {} (боевую логику не меняют)", if self.sim.truth { "вкл" } else { "выкл" })) + } ["damage", v @ ("on" | "off")] => { self.sim.damage = *v == "on"; self.dirty = true; diff --git a/airdef-mod/src/monitor.rs b/airdef-mod/src/monitor.rs index 8927881..0642000 100644 --- a/airdef-mod/src/monitor.rs +++ b/airdef-mod/src/monitor.rs @@ -119,8 +119,10 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec spec.sensor, }; let scale = if reach > 0.0 { rr as f64 / (reach * 1.25) } else { 0.0 }; - let sweep = (tick as f32 * 0.12 + index as f32) % std::f32::consts::TAU; - for k in 0..6 { + // The beam is the radar's real one: targets are measured as it passes them. + let sweep = sam.beam() as f32; + let radar_up = sam.radar_up(); + for k in 0..if radar_up { 6 } else { 0 } { let a1 = sweep - k as f32 * 0.09; let a0 = a1 - 0.09; let p = |a: f32| (rx + a.sin() * rr, ry - a.cos() * rr); @@ -129,8 +131,14 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec reach * 1.25 { continue; @@ -146,10 +154,24 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec 0.0 && !radar_up { 0xFFFF5040 } else { 0xFF80FF90 })); // Its health under the radar: a bar, red when it is low. if sam.alive() && sam.health < 1.0 { @@ -184,6 +206,11 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec 0, }; s.push(Shape::View(ViewShape { view: sam.id, rect: Rect { x: cx, y: cy, w: cw, h: ch }, mode })); + // Worn or failed parts, over the picture: orange, red when one has failed. + if !sam.comps.whole() { + let failed = crate::sim::Part::ALL.iter().any(|p| sam.part_state(*p) == crate::sim::PartState::Failed); + s.push(text(fit(sam.parts_text(), cw * 0.55), cx + 3.0, cy + 3.0, if failed { 0xFFFF4040 } else { 0xFFFFB030 })); + } // Crosshair. s.push(fill(cx + cw / 2.0 - 0.5, cy + 3.0, 1.0, ch * 0.18, 0x90FFFFFF)); s.push(fill(cx + cw / 2.0 - 0.5, cy + ch * 0.82 - 3.0, 1.0, ch * 0.18, 0x90FFFFFF)); @@ -200,12 +227,22 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec 50.0 { s.push(text(fit(t.kind.spec().name.into(), cw - 6.0), cx + 3.0, cy + ch - 20.0, 0xFFFFFFFF)); } - let mut info = format!("{} в{:.0} {:.0}км/ч", distance(rel.length()), t.pos.y - sam.pos.y, t.vel.length() * 72.0); + let mut info = format!("{} в{:.0} {:.0}км/ч", distance(rel.length()), at.y - sam.pos.y, t.vel.length() * 72.0); + if let Some(tr) = track { + if tr.forecast(tick) { + info += &format!(" прогноз {:.1}с", tr.age(tick) as f64 / 20.0); + } else if cw > 200.0 { + info += &format!(" {}", tr.ident().name()); + } + } if cw > 220.0 { info += &format!(" П{bearing:03.0}"); } diff --git a/airdef-mod/src/radar.rs b/airdef-mod/src/radar.rs index d0d2ac0..9bc6c32 100644 --- a/airdef-mod/src/radar.rs +++ b/airdef-mod/src/radar.rs @@ -108,24 +108,40 @@ pub fn draw(sim: &Sim, player: DVec3, yaw: f32) -> Vec { } } let mut far = 0; - // The air picture is what the systems' radars hold; with none deployed, everything. + // The air picture is what the systems' radars hold, as they last measured it (a track older than a second + // is a forecast, dimmer); with no system deployed, everything as it is. let any = !sim.sams.is_empty(); - for d in sim.drones.iter().filter(|d| !any || !d.alive() || sim.tracked(d.id)) { - match scope(d.pos) { + let picture = sim.picture(); + let now = sim.tick; + for d in sim.drones.iter().filter(|d| !any || !d.alive() || picture.contains_key(&d.id)) { + let track = picture.get(&d.id).filter(|_| any && d.alive()); + let (pos, vel) = track.map_or((d.pos, d.vel), |t| (t.est(now), t.vel)); + match scope(pos) { Some((x, y, bearing)) => { - let a = fade(bearing); if d.alive() { - s.push(square(x, y, 1.8, alpha(0xFF4040, a))); + // Fresh: bright and red; a forecast: orange, fading as it ages towards the track's drop. + let (a, color) = match track { + Some(t) if t.forecast(now) => ((1.0 - t.age(now) as f64 / t.ttl.max(1) as f64 * 0.7).max(0.3), 0xFFB040), + Some(_) => (1.0, 0xFF4040), + None => (fade(bearing), 0xFF4040), + }; + s.push(square(x, y, 1.8, alpha(color, a))); // Its heading, a short tail. - let v = d.vel.normalize_or_zero(); - s.push(quad(pt(x, y), pt(x + 0.6, y), pt(x - v.x as f32 * 6.0 + 0.6, y - v.z as f32 * 6.0), pt(x - v.x as f32 * 6.0, y - v.z as f32 * 6.0), alpha(0xFF4040, a * 0.6))); + let v = vel.normalize_or_zero(); + s.push(quad(pt(x, y), pt(x + 0.6, y), pt(x - v.x as f32 * 6.0 + 0.6, y - v.z as f32 * 6.0), pt(x - v.x as f32 * 6.0, y - v.z as f32 * 6.0), alpha(color, a * 0.6))); } else { - s.push(square(x, y, 1.2, alpha(0x909090, a))); + s.push(square(x, y, 1.2, alpha(0x909090, fade(bearing)))); } } None if d.alive() => far += 1, None => {} } + // The true place, when asked for: a white dot beside the track, to see how far the picture is off. + if sim.truth && d.alive() && any { + if let Some((x, y, _)) = scope(d.pos) { + s.push(square(x, y, 0.8, 0xFFFFFFFF)); + } + } } // The player: a white arrow the way they look. // Minecraft's yaw 0 looks south (+z, down the scope). diff --git a/airdef-mod/src/replay.rs b/airdef-mod/src/replay.rs index 73bb2d3..9b31362 100644 --- a/airdef-mod/src/replay.rs +++ b/airdef-mod/src/replay.rs @@ -297,6 +297,11 @@ impl Player { self.fade = 6; } + /// The time of day the raid was flown in, as of the replay's moment now. + pub fn day_time(&self, sim: &Sim) -> Option { + sim.replay(self.rec)?.day_time(self.t) + } + /// One tick: time runs, the ghosts fly, the director films. Models and sounds go to `out`. pub fn tick(&mut self, sim: &Sim, listener: DVec3, out: &mut Out) { let Some(rec) = sim.replay(self.rec) else { diff --git a/airdef-mod/src/sim/damage.rs b/airdef-mod/src/sim/damage.rs index f1965ee..7a67d1d 100644 --- a/airdef-mod/src/sim/damage.rs +++ b/airdef-mod/src/sim/damage.rs @@ -8,6 +8,124 @@ use super::*; /// By blast power (as `explode` grades it): how far it reaches a vehicle, and how hard it hits one at the centre. const REACH: [f64; 5] = [4.0, 7.0, 12.0, 18.0, 26.0]; const HIT: [f64; 5] = [0.2, 0.45, 0.85, 1.25, 1.7]; +/// A unit's working parts, each 1 (whole) to 0 (failed); the hull is `Sam::health` (0 destroys it). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Part { + /// The radar (or optics): its reach and its scan. + Sensor, + /// The aiming drive of the launcher or turret. + Mount, + /// The launcher: cells, reload. + Launcher, + /// The gun: it jams. + Gun, + /// The running gear: it can't march when it fails. + Tracks, + /// The data link: no network when it fails. + Comms, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PartState { + Ok, + Damaged, + Failed, +} + +impl Part { + pub const ALL: [Part; 6] = [Part::Sensor, Part::Mount, Part::Launcher, Part::Gun, Part::Tracks, Part::Comms]; + pub fn name(self) -> &'static str { + match self { + Part::Sensor => "РЛС", + Part::Mount => "привод наведения", + Part::Launcher => "пусковая", + Part::Gun => "орудие", + Part::Tracks => "ходовая", + Part::Comms => "связь", + } + } + pub fn short(self) -> &'static str { + match self { + Part::Sensor => "РЛС", + Part::Mount => "Прив", + Part::Launcher => "ПУ", + Part::Gun => "Ор", + Part::Tracks => "Ход", + Part::Comms => "Св", + } + } + fn index(self) -> usize { + self as usize + } + /// Whether this kind of unit has the part at all. + pub fn on(self, kind: SamKind) -> bool { + let spec = kind.spec(); + match self { + Part::Sensor => spec.sensor > 0.0, + Part::Mount => kind.armed() && kind != SamKind::Light && (spec.missile.is_some() || spec.gun.is_some()), + Part::Launcher => spec.missile.is_some(), + Part::Gun => spec.gun.is_some(), + Part::Tracks => kind.mobile(), + Part::Comms => true, + } + } + /// Where the part is on the vehicle: right, up, back, blocks at scale 1 (the nose points to -back). + fn place(self, spec: &super::units::SamSpec) -> (f64, f64, f64) { + match self { + Part::Sensor => (0.0, spec.mast / spec.scale, 1.0), + Part::Mount => (0.0, spec.mount.0, -spec.mount.1), + Part::Launcher => (0.0, spec.mount.0, -spec.mount.1 + 0.4), + Part::Gun => (0.0, spec.mount.0, -spec.mount.1 - 1.0), + Part::Tracks => (0.0, 0.3, 0.0), + Part::Comms => (0.0, 1.6, 1.8), + } + } +} + +/// Below this a part is damaged (works worse), at 0 it has failed. +pub const WORN: f64 = 0.6; + +/// The state of every part: `Part::ALL`'s order. +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct Comps(pub [f64; 6]); + +impl Default for Comps { + fn default() -> Self { + Self([1.0; 6]) + } +} + +impl Comps { + pub fn get(&self, p: Part) -> f64 { + self.0[p.index()] + } + pub fn state(&self, p: Part) -> PartState { + let v = self.get(p); + if v <= 0.0 { + PartState::Failed + } else if v < WORN { + PartState::Damaged + } else { + PartState::Ok + } + } + pub fn whole(&self) -> bool { + self.0.iter().all(|&v| v >= 1.0) + } + /// Parts of this kind that are not whole, as "РЛС 40%, ходовая отказ". + pub fn describe(&self, kind: SamKind) -> String { + Part::ALL + .iter() + .filter(|p| p.on(kind) && self.get(**p) < 1.0) + .map(|p| match self.state(*p) { + PartState::Failed => format!("{} отказ", p.short()), + _ => format!("{} {:.0}%", p.short(), self.get(*p) * 100.0), + }) + .collect::>() + .join(", ") + } +} + /// Health points an object loses at the centre of a blast. const OBJECT_HIT: [f64; 5] = [2.0, 6.0, 14.0, 26.0, 40.0]; @@ -112,9 +230,39 @@ impl Sim { sam.health -= hit * k * k * if d < 1.0 { 1.5 } else { 1.0 }; if sam.health <= 0.0 { destroyed.push(i); - } else if before >= 0.5 && sam.health < 0.5 { + continue; + } + if before >= 0.5 && sam.health < 0.5 { out.messages.push(format!("{} №{} повреждён", sam.kind.name(), i + 1)); } + // The parts take the blast by their own distance from it: the side it came from decides which. + let spec = sam.kind.spec(); + let rot = DQuat::from_rotation_y(sam.heading); + for part in Part::ALL { + if !part.on(sam.kind) { + continue; + } + let (x, y, back) = part.place(spec); + let at = sam.pos + rot * DVec3::new(x, y, back) * spec.scale; + let size = if part == Part::Tracks { 2.5 } else { 1.0 }; + let dp = (at.distance(pos) - size).max(0.0); + if dp >= reach { + continue; + } + let kp = 1.0 - dp / reach; + let roll = dice(self.seed, self.tick, sam.id as u64, part as u64); + // The parts are softer than the hull: a blast the hull survives can still put one out. + let dmg = hit * kp * kp * (0.7 + roll) * 1.5 * if dp < 1.0 { 1.4 } else { 1.0 }; + let was = sam.comps.state(part); + let v = &mut sam.comps.0[part as usize]; + *v = (*v - dmg).max(0.0); + let now = sam.comps.state(part); + if now != was && now != PartState::Ok { + let word = if now == PartState::Failed { "отказал(а)" } else { "повреждён(а)" }; + out.messages.push(format!("{} №{}: {} {word}", sam.kind.name(), i + 1, part.name())); + } + } + sam.fit_cells(); } for i in destroyed { self.destroy(i, out); @@ -144,6 +292,9 @@ impl Sim { sam.target = None; sam.gun_target = None; sam.reloading = 0; + sam.tracks.clear(); + sam.seen.clear(); + sam.own.clear(); let spec = sam.kind.spec(); let missiles = sam.missiles; let bursts = sam.bursts; @@ -224,11 +375,76 @@ impl Sim { } +/// A roll from 0 to 1 fixed by the seed and three numbers: the same events roll the same in every run, +/// and the simulation's own random sequence is left alone. +pub(super) fn dice(seed: u64, a: u64, b: u64, c: u64) -> f64 { + let mut x = seed ^ a.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ b.wrapping_mul(0xBF58_476D_1CE4_E5B9) ^ c.wrapping_mul(0x94D0_49BB_1331_11EB); + x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + x ^= x >> 31; + (x >> 11) as f64 / (1u64 << 53) as f64 +} + impl Sam { /// Its wreck still burns. pub fn burning(&self) -> bool { self.burn > 0 } + /// A part's state, `Ok` for one this unit does not have. + pub fn part(&self, p: Part) -> f64 { + if p.on(self.kind) { self.comps.get(p) } else { 1.0 } + } + pub fn part_state(&self, p: Part) -> PartState { + if p.on(self.kind) { self.comps.state(p) } else { PartState::Ok } + } + pub fn parts_text(&self) -> String { + self.comps.describe(self.kind) + } + /// The aiming drive's share of its speed: a failed one doesn't turn at all. + pub(super) fn drive_fit(&self) -> f64 { + let h = self.part(Part::Mount); + if h <= 0.0 { 0.0 } else { 0.3 + 0.7 * h } + } + /// The radar's share of its reach; the scan is slower by the same. + pub(super) fn radar_fit(&self) -> f64 { + let h = self.part(Part::Sensor); + if h <= 0.0 { 0.0 } else { 0.4 + 0.6 * h } + } + /// The gun's share of its hit chance. + pub(super) fn gun_fit(&self) -> f64 { + 0.5 + 0.5 * self.part(Part::Gun) + } + /// Its launcher's cells still good: a damaged one holds fewer missiles. + pub(super) fn cells(&self) -> u32 { + let Some(load) = &self.kind.spec().missile else { return 0 }; + let h = self.part(Part::Launcher); + if h <= 0.0 { 0 } else { ((load.magazine as f64 * (0.4 + 0.6 * h)).ceil() as u32).clamp(1, load.magazine) } + } + /// Missiles above what the cells hold are lost with them. + pub fn fit_cells(&mut self) { + if self.kind.spec().missile.is_some() { + self.missiles = self.missiles.min(self.cells()); + } + } + /// Ticks a reload takes: longer with a damaged launcher. + pub(super) fn reload_time(&self) -> u32 { + let r = self.kind.spec().reload.max(1); + (r as f64 * (1.0 + 1.5 * (1.0 - self.part(Part::Launcher)))).round() as u32 + } + /// The gun fires (not jammed for good, not jammed now). + pub(super) fn gun_up(&self) -> bool { + self.part(Part::Gun) > 0.0 && self.jam == 0 + } + /// How far it can march: the running gear's share of the speed (0 can't). + pub(super) fn drive_speed(&self) -> f64 { + let h = self.part(Part::Tracks); + if h <= 0.0 { 0.0 } else { 0.35 + 0.65 * h } + } + /// The data link's reach, 0 when the link has failed. + pub(super) fn link_range(&self) -> f64 { + let h = self.part(Part::Comms); + if h <= 0.0 { 0.0 } else { super::sight::LINK * (0.3 + 0.7 * h) } + } } impl Sim { @@ -236,9 +452,11 @@ impl Sim { pub fn repair(&mut self) -> u32 { let mut n = 0; for sam in &mut self.sams { - if sam.health < 1.0 { + if sam.health < 1.0 || !sam.comps.whole() { n += 1; } + sam.comps = Comps::default(); + sam.jam = 0; let spec = sam.kind.spec(); sam.health = 1.0; sam.burn = 0; @@ -256,6 +474,7 @@ impl Sim { } // The wrecks' ammunition still to cook off goes with them. self.later.clear(); + self.fires.extinguish(); n } diff --git a/airdef-mod/src/sim/fires.rs b/airdef-mod/src/sim/fires.rs new file mode 100644 index 0000000..9102895 --- /dev/null +++ b/airdef-mod/src/sim/fires.rs @@ -0,0 +1,223 @@ +//! Long fires (#52): where a drone or a missile came down, where a warhead struck and on a +//! destroyed system a fire burns for minutes: it flares up, burns steadily, smokes thick and +//! heavy, dies down and smoulders. Visual first (flames, smoke columns, a flickering light, a +//! crackle); with damage on, a few real fire blocks on solid ground (never on water or in the +//! air), put back while it burns and taken away when it is out. At most `MAX` fires burn, the +//! oldest dying down first. A replay draws the same fires from its events (`replay_fire`). +use super::*; + +/// Fires burning at once. +pub const MAX: usize = 12; +/// A fire put out by the water it fell in lasts this long (steam only). +const STEAM: u32 = 160; +/// Ticks the oldest fire has left when a newer one pushes it out. +const FADE: u32 = 160; +/// Ticks between the fire blocks being set again. +const REFRESH: u64 = 80; + +pub struct Fire { + pub id: u32, + pub at: DVec3, + pub start: u64, + pub life: u32, + /// 0.6 (a crashed drone) to 3 (a cruise or ballistic warhead, a depot). + pub size: f32, + /// The system it burns on, if any: its own wreck fire does the flames for as long as it lasts. + host: Option, + /// The land under it found: `Some(true)` land, `Some(false)` water (steam only). + land: Option, + /// Asked about the ground since this tick. + asked: u64, + /// Fire blocks set (whole coordinates). + blocks: Vec<(i32, i32, i32)>, + /// The host's wreck is flaming: this fire leaves the flames to it. + pub quiet: bool, +} + +#[derive(Default)] +pub struct Fires { + pub list: Vec, + /// Commands to take fire blocks away, sent next tick. + cleanup: Vec, +} + +/// How long a fire of this power burns, in ticks: 2 to 5 minutes. +pub fn life_for(power: u32) -> u32 { + 2400 + 900 * power.min(4) +} + +/// The size of a fire of this power. +pub fn size_for(power: u32) -> f32 { + [0.6, 1.0, 1.6, 2.4, 3.0][power.min(4) as usize] +} + +/// How strong the flames (`.0`) and the smoke (`.1`) are, 0 to about 1, `age` ticks into a fire +/// that burns `life`: flaring up, steady flame, thick smoke while it dies down, then embers. +pub fn level(age: f64, life: f64) -> (f32, f32) { + let f = (age / life.max(1.0)).clamp(0.0, 1.0); + let flare = if age < 40.0 { 0.35 + 0.65 * age / 40.0 } else { 1.0 }; + let flame = if f < 0.45 { flare } else { flare * (1.0 - (f - 0.45) / 0.4).clamp(0.0, 1.0) }; + let smoke = if age < 100.0 { age / 100.0 } else if f < 0.8 { 1.0 } else { 1.0 - 0.85 * (f - 0.8) / 0.2 }; + (flame as f32, smoke.clamp(0.0, 1.0) as f32) +} + +impl Fires { + /// Fires burning now. + pub fn len(&self) -> usize { + self.list.len() + } + pub fn is_empty(&self) -> bool { + self.list.is_empty() + } + /// Puts every fire out; the fire blocks are taken away on the next step. + pub fn extinguish(&mut self) { + let list = std::mem::take(&mut self.list); + for f in list { + self.cleanup.extend(f.take_blocks()); + } + } +} + +impl Fire { + fn take_blocks(self) -> Vec { + self.blocks + .into_iter() + .map(|(x, y, z)| format!("fill {x} {y} {z} {x} {y} {z} minecraft:air replace minecraft:fire")) + .collect() + } + /// How strong it burns at tick `now`: (flames, smoke); water leaves steam only. + pub fn level(&self, now: u64) -> (f32, f32) { + let (fl, sm) = level(now.saturating_sub(self.start) as f64, self.life as f64); + if self.land == Some(false) { (0.0, sm * 0.5) } else { (fl, sm) } + } +} + +impl Sim { + /// Something came down or struck at `pos` (power 0 to 4): a fire starts there, or the fire + /// already burning close by grows. `host` is the system it burns on. + pub(super) fn ignite(&mut self, pos: DVec3, power: u32, host: Option) { + let now = self.tick; + let (size, life) = (size_for(power), life_for(power)); + if let Some(f) = self.fires.list.iter_mut().find(|f| f.at.distance(pos) < 6.0) { + // The same spot (a warhead and the wreck it made): one fire, the greater of the two. + f.size = f.size.max(size); + f.life = f.life.max((now - f.start) as u32 + life); + f.host = f.host.or(host); + return; + } + let id = self.id(); + self.fires.list.push(Fire { + id, + at: pos, + start: now, + life, + size, + host, + land: None, + asked: 0, + blocks: Vec::new(), + quiet: false, + }); + if self.fires.list.len() > MAX { + // The oldest dies down (and is gone) instead of burning on. + let old = self.fires.list.iter_mut().filter(|f| now.saturating_sub(f.start) as u32 + FADE < f.life).min_by_key(|f| f.start); + if let Some(f) = old { + f.life = (now - f.start) as u32 + FADE; + } + } + } + + /// Once a tick: finds the ground under each new fire, burns the old ones, ends the burnt out. + pub(super) fn step_fires(&mut self, ground: &mut Ground, out: &mut Out) { + let now = self.tick; + out.commands.append(&mut self.fires.cleanup); + let damage = self.damage; + let mut fires = std::mem::take(&mut self.fires.list); + for f in &mut fires { + let age = now.saturating_sub(f.start); + // Where the land is under it: it burns on the surface, not at the height of what fell. + if f.land.is_none() && age >= f.asked { + f.asked = age + 10; + if let Some(h) = ground.at(f.at, now) { + let (x, z) = (f.at.x.floor() as i32, f.at.z.floor() as i32); + let h = h as i32; + if ground.world.is_known_solid(x, h - 1, z, now) { + f.at.y = h as f64; + f.land = Some(true); + } else if ground.world.is_known_open(x, h - 1, z, now) { + // Water (or another fluid): the fire goes out in a cloud of steam. + f.at.y = h as f64; + f.land = Some(false); + f.life = f.life.min(age as u32 + STEAM); + } + } + // Not known for a while: it burns where it is, on the land. + if f.land.is_none() && age > 100 { + f.land = Some(true); + } + } + f.quiet = f.host.is_some_and(|id| self.sams.iter().any(|s| s.id == id && !s.alive() && s.burn > 0)); + let (fl, sm) = f.level(now); + let s = f.size; + if !f.quiet { + let top = f.at + DVec3::Y * 0.4; + let spread = 0.4 + 0.5 * s; + if fl > 0.04 && now % 2 == 0 { + out.particles("minecraft:flame", top, spread, DVec3::new(0.0, 0.05 + 0.05 * fl as f64, 0.0), 0.02, (3.0 * s * fl).ceil() as u32); + } + if sm > 0.04 && now % 3 == 0 { + out.particles("minecraft:large_smoke", top + DVec3::Y * 0.6, spread, DVec3::new(0.0, 0.1, 0.0), 0.03, (2.0 * s * sm).ceil() as u32); + } + // The tall column: a plume that rises far. + if sm > 0.3 && now % 6 == (f.id % 6) as u64 { + out.particles("minecraft:campfire_signal_smoke", top, 0.5 * s, DVec3::new(0.0, 0.09, 0.0), 0.01, 1 + (s * sm) as u32); + } + if fl > 0.5 && now % 20 == (f.id % 20) as u64 { + out.particles("minecraft:lava", top + DVec3::Y * 0.5, 0.4 * s, DVec3::new(0.0, 0.2, 0.0), 0.1, 2); + } + if now % 50 == (f.id % 50) as u64 && fl + sm * 0.3 > 0.1 { + out.sound("minecraft:block.fire.ambient", f.at, 0.5 + 1.2 * fl, 0.8 + 0.2 * s.min(1.0)); + } + } + // Real fire, only where the damage is real: a few blocks on the solid ground. + if damage && f.land == Some(true) && fl > 0.3 && now % REFRESH == (f.id as u64) % REFRESH && age >= 20 { + let (x, y, z) = (f.at.x.floor() as i32, f.at.y.floor() as i32, f.at.z.floor() as i32); + let n = if s >= 2.5 { 5 } else if s >= 1.5 { 3 } else { 1 }; + for k in 0..n { + let (dx, dz) = if k == 0 { (0, 0) } else { [(1, 0), (-1, 0), (0, 1), (0, -1)][k - 1] }; + let (bx, bz) = (x + dx, z + dz); + // Only where the land is as high as at the centre. + let level = ground.at(DVec3::new(bx as f64 + 0.5, 0.0, bz as f64 + 0.5), now).is_some_and(|h| h as i32 == y); + if level && ground.world.is_known_solid(bx, y - 1, bz, now) && ground.world.is_known_open(bx, y, bz, now) { + out.commands.push(format!("setblock {bx} {y} {bz} minecraft:fire keep")); + if !f.blocks.contains(&(bx, y, bz)) { + f.blocks.push((bx, y, bz)); + } + } + } + } + } + // The burnt out: their fire blocks go too. + let mut keep = Vec::with_capacity(fires.len()); + for f in fires { + if now.saturating_sub(f.start) < f.life as u64 { + keep.push(f); + } else { + out.commands.extend(f.take_blocks()); + } + } + // Fires lit this very tick were pushed to the list meanwhile (none are: ignite runs inside the step). + keep.append(&mut self.fires.list); + self.fires.list = keep; + } +} + +/// A fire for a replay: the event's power at age `age` ticks, as a glowing flame model's scale +/// (zero when it is out or not started) and a smoke strength. +pub fn replay_fire(power: u32, age: f64) -> (f32, f32) { + if age < 0.0 { + return (0.0, 0.0); + } + let (fl, sm) = level(age, life_for(power) as f64); + (fl * size_for(power), sm) +} diff --git a/airdef-mod/src/sim/logistics.rs b/airdef-mod/src/sim/logistics.rs index 94d2c15..1b29979 100644 --- a/airdef-mod/src/sim/logistics.rs +++ b/airdef-mod/src/sim/logistics.rs @@ -50,7 +50,7 @@ impl Sim { // The trucks: a job each, when idle. for t in 0..self.sams.len() { let truck = &self.sams[t]; - if truck.kind != SamKind::Tzm || !truck.alive() || truck.moving() || now % 10 != t as u64 % 10 { + if truck.kind != SamKind::Tzm || !truck.alive() || truck.moving() || truck.drive_speed() <= 0.0 || now % 10 != t as u64 % 10 { continue; } let at = truck.pos; @@ -116,7 +116,7 @@ impl Sim { fn reload_now(&mut self, i: usize, out: &mut Out) { let sam = &mut self.sams[i]; sam.empty = false; - sam.reloading = sam.kind.spec().reload.max(1); + sam.reloading = sam.reload_time(); out.sound("minecraft:block.piston.extend", sam.pos, 2.0, 0.6); } } diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index 2a6bac1..fd92837 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -10,6 +10,7 @@ use glam::{DQuat, DVec3}; use std::collections::HashMap; mod damage; +pub mod fires; mod raid; pub mod record; mod sight; @@ -22,7 +23,12 @@ mod why; pub mod world; #[cfg(test)] mod regress; +#[cfg(test)] +mod sensor_tests; pub use ew::EW_RADIUS; +pub use damage::{Comps, Part, PartState}; +pub use sight::{Ident, Track}; +use damage::dice; pub use units::{Family, SamKind}; use units::Muzzle; pub use raid::{Approach, Aim, RaidOrder}; @@ -747,9 +753,13 @@ impl Drone { } /// How much a system wants to shoot it: fast and heavy first, decoys look real. fn threat(&self, from: DVec3) -> f64 { + self.threat_at(from, self.pos) + } + /// The same for a target believed to be at `at` (a track's forecast). + fn threat_at(&self, from: DVec3, at: DVec3) -> f64 { let s = self.kind.spec(); let warhead = if s.decoy { 3.0 } else { s.warhead as f64 }; - (1.0 + warhead) * (1.0 + s.speed) / (1.0 + self.pos.distance(from) * 0.02) + (1.0 + warhead) * (1.0 + s.speed) / (1.0 + at.distance(from) * 0.02) } } @@ -1034,7 +1044,14 @@ pub struct Sam { pub health: f64, /// Ticks its wreck still burns. burn: u32, - /// The drones it has tracks of this tick: its own radar's and the network's. + /// Its working parts (the hull is `health`). + pub comps: Comps, + /// Ticks its gun stays jammed. + jam: u32, + /// Where its radar beam points (radians), and the tracks it holds: its own radar's and the network's. + pub(super) beam: f64, + pub tracks: Vec, + /// The ids of the tracks it holds this tick: its own radar's and the network's. pub seen: Vec, /// The drones its own radar (or optics) holds: guns need these. pub own: Vec, @@ -1087,6 +1104,10 @@ impl Sam { launched: 0, health: 1.0, burn: 0, + comps: Comps::default(), + jam: 0, + beam: id as f64 * 2.4, + tracks: Vec::new(), seen: Vec::new(), own: Vec::new(), kill_view: None, @@ -1108,12 +1129,16 @@ impl Sam { pub fn alive(&self) -> bool { self.health > 0.0 } + /// Where its radar beam points now (radians clockwise from north). + pub fn beam(&self) -> f64 { + self.beam + } /// How well it still works: turning, aiming, its radar's reach. /// Turns the mount towards `yaw` (and `pitch`, if it elevates) under its drive's limits; a damaged /// system has the less of both speed and acceleration. fn aim_towards(&mut self, yaw: f64, pitch: Option) { let d = self.kind.spec().drive(); - let fit = self.fitness(); + let fit = self.drive_fit(); self.yaw += drive(&mut self.yaw_vel, angle_diff(self.yaw, yaw), d.yaw.0 * fit, d.yaw.1 * fit); if let Some(pitch) = pitch { self.pitch += drive(&mut self.pitch_vel, pitch - self.pitch, d.pitch.0 * fit, d.pitch.1 * fit); @@ -1124,9 +1149,6 @@ impl Sam { self.yaw_vel = 0.0; self.pitch_vel = 0.0; } - fn fitness(&self) -> f64 { - 0.45 + 0.55 * self.health.clamp(0.0, 1.0) - } pub fn status(&self) -> String { let spec = self.kind.spec(); let mut text = spec.name.to_string(); @@ -1143,7 +1165,10 @@ impl Sam { let state = if self.ew_on { "ГЛУШИТ" } else if self.ew_lock { "остывает" } else { "готова" }; return format!("{text}: {state}, нагрев {:.0}%", self.heat * 100.0); } - SamKind::Radar => return format!("{text}: целей {}", self.own.len()), + SamKind::Radar => { + let broken = if self.comps.whole() { String::new() } else { format!(" [{}]", self.parts_text()) }; + return format!("{text}: целей {}{broken}", self.own.len()); + } SamKind::Light => return format!("{text}{}", if self.target.is_some() { ": в луче цель" } else { "" }), _ => {} } @@ -1153,6 +1178,9 @@ impl Sam { if self.health < 1.0 { text += &format!(" ({:.0}%)", self.health * 100.0); } + if !self.comps.whole() { + text += &format!(" [{}]", self.parts_text()); + } if self.reloading > 0 { text += &format!(" перезарядка {}с", self.reloading.div_ceil(20)); } else { @@ -1202,6 +1230,10 @@ pub struct Sim { pub launched: u32, /// Explosions damage the land (primed TNT through commands). pub damage: bool, + /// The seed: what falls to chance without the sequence (scan hits, damage to parts) is rolled from it. + seed: u64, + /// The air picture shows the true positions beside the tracks (a sandbox and analysis aid; changes no behaviour). + pub truth: bool, /// The shader draws fireballs: no glowing cubes. pub shader: bool, /// Air defence holds fire. @@ -1228,6 +1260,10 @@ pub struct Sim { intel: ew::Intel, /// Night: guns need light (the mod sets it from the game's clock). pub night: bool, + /// The long fires at crash and impact points (#52). + pub fires: fires::Fires, + /// The world clock's time of day in ticks (the mod sets it; recordings keep it). + pub day_time: Option, /// Illumination flares in the sky, and the drones lit this tick. pub flares: Vec, pub lit: Vec, @@ -1255,6 +1291,8 @@ impl Sim { crashes: 0, launched: 0, damage: true, + seed: seed.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1, + truth: false, shader: false, hold: false, horizons: HashMap::new(), @@ -1269,6 +1307,8 @@ impl Sim { tally: record::Tally::default(), intel: ew::Intel::default(), night: false, + fires: fires::Fires::default(), + day_time: None, flares: Vec::new(), lit: Vec::new(), } @@ -1387,6 +1427,7 @@ impl Sim { self.later.clear(); self.flares.clear(); self.lit.clear(); + self.fires.extinguish(); } /// One game tick. @@ -1407,6 +1448,7 @@ impl Sim { self.step_fx(ground, out); self.draw_targets(out); self.step_wrecks(out); + self.step_fires(ground, out); self.report_wave(out); self.record(); let now = self.tick; @@ -1871,18 +1913,20 @@ impl Sim { retarget(&mut guns_on, sam.gun_target.take(), None); continue; } - let fit = self.sams[i].fitness(); { let sam = &mut self.sams[i]; sam.cooldown = sam.cooldown.saturating_sub(1); sam.gun_cooldown = sam.gun_cooldown.saturating_sub(1); sam.flash = sam.flash.saturating_sub(1); sam.recoil *= 0.6; - sam.dish = (sam.dish + 0.15) % TAU; + if sam.radar_up() { + sam.dish = sam.beam; + } + sam.jam = sam.jam.saturating_sub(1); if sam.reloading > 0 { sam.reloading -= 1; if sam.reloading == 0 { - sam.missiles = spec.missile.as_ref().map_or(0, |m| m.magazine); + sam.missiles = sam.cells(); sam.bursts = spec.gun.as_ref().map_or(0, |g| g.bursts); out.sound("minecraft:block.piston.extend", sam.pos, 2.0, 0.6); } @@ -1904,23 +1948,27 @@ impl Sim { let pos = self.sams[i].pos; let roles = self.sams[i].roles; let (had_target, had_gun) = (self.sams[i].target, self.sams[i].gun_target); + let now = self.tick; let (target, gun_target) = { - let (seen, own) = (&self.sams[i].seen, &self.sams[i].own); - // The conditions are `why`'s, shared with the diagnostic. - let allowed = |d: &Drone| why::allowed(roles, seen, d); + let tracks = &self.sams[i].tracks; + // The conditions are `why`'s, shared with the diagnostic. A system knows its targets by their tracks: + // the forecast of the last measurement, never the true place. + let drone = |tr: &Track| drone_at.get(&tr.id).map(|&k| &self.drones[k]); let claimed = |d: &Drone| why::claimed(d, &missile_on, had_target); let needed = why::needed; let target = spec.missile.as_ref().map(|load| { - let in_reach = |d: &Drone, k: f64| why::in_reach(load.min, load.max, k, pos, d); - let keep = had_target.filter(|t| { - drone_at.get(t).is_some_and(|&k| allowed(&self.drones[k]) && in_reach(&self.drones[k], 1.1)) - }); + let ok = |tr: &Track, k: f64| { + drone(tr).is_some_and(|d| why::allowed(roles, tr, now, d) && why::in_reach(load.min, load.max, k, pos, tr.est(now))) + }; + let keep = had_target.filter(|t| tracks.iter().any(|tr| tr.id == *t && ok(tr, 1.1))); keep.or_else(|| { - self.drones + tracks .iter() - .filter(|d| allowed(d) && in_reach(d, 1.0) && claimed(d) < needed(d)) - .max_by(|a, b| a.threat(pos).total_cmp(&b.threat(pos))) - .map(|d| d.id) + .filter(|tr| ok(tr, 1.0)) + .filter_map(|tr| drone(tr).map(|d| (tr, d))) + .filter(|(_, d)| claimed(d) < needed(d)) + .max_by(|a, b| a.1.threat_at(pos, a.0.est(now)).total_cmp(&b.1.threat_at(pos, b.0.est(now)))) + .map(|(_, d)| d.id) }) }); // Guns: the nearest, spread over the targets the other guns and missiles aren't on. @@ -1928,12 +1976,13 @@ impl Sim { let on_it = |d: &Drone| { (guns_on.get(&d.id).copied().unwrap_or(0) - u32::from(had_gun == Some(d.id))) as f64 }; - self.drones + tracks .iter() - .filter(|d| why::gun_candidate(roles, seen, own, gun.track, pos, d)) - .map(|d| { + .filter_map(|tr| drone(tr).map(|d| (tr, d))) + .filter(|(tr, d)| why::gun_candidate(roles, tr, now, gun.track, pos, d)) + .map(|(tr, d)| { let busy = 1.0 + 2.0 * on_it(d) + if d.engaged > 0 { 1.5 } else { 0.0 }; - (d.pos.distance(pos) * busy, d.id) + (tr.est(now).distance(pos) * busy, d.id) }) .min_by(|a, b| a.0.total_cmp(&b.0)) .map(|(_, id)| id) @@ -1957,18 +2006,23 @@ impl Sim { } let sam = &mut self.sams[i]; // What the turret or launcher points at: guns first when a target is close. - let aim = sam.gun_target.or(sam.target).and_then(|t| drone_at.get(&t).map(|&k| &self.drones[k])); + let aim = sam + .gun_target + .or(sam.target) + .filter(|t| drone_at.contains_key(t)) + .and_then(|t| sam.track(t)) + .map(|tr| (tr.id, tr.est(now), tr.vel)); sam.launched = sam.launched.saturating_sub(1); let Some(aim) = aim else { // Out of missiles or of shells, and nothing to shoot: reload now (both, as a // combined system's crew would), not only once both are spent. - let spent = (spec.missile.is_some() && sam.missiles == 0) || (spec.gun.is_some() && sam.bursts == 0); + let spent = (spec.missile.is_some() && sam.missiles == 0 && sam.part(Part::Launcher) > 0.0) || (spec.gun.is_some() && sam.bursts == 0); if spent && sam.reloading == 0 && !sam.empty { if logistics { sam.empty = true; ammo_waits += 1; } else { - sam.reloading = spec.reload + sam.reloading = sam.reload_time() } } sam.state = if sam.reloading > 0 { @@ -1984,7 +2038,8 @@ impl Sim { }; let (mount, _) = sam.mount(); let lead_speed = spec.gun.as_ref().map_or(5.0, |g| g.shell); - let (lead, want_yaw, want_pitch) = why::aim_wants(mount, aim, lead_speed); + let (aim_id, aim_pos, aim_vel) = aim; + let (lead, want_yaw, want_pitch) = why::aim_wants(mount, aim_pos, aim_vel, lead_speed); let cells = matches!(spec.muzzle, Muzzle::Cells); match spec.family { // Vertical launch: no aiming (the Tor's turret turns its radar to the target). @@ -2007,7 +2062,7 @@ impl Sim { SamState::Fire } else if !vertical && aim_error > 0.15 { SamState::Slew - } else if aim.pos.distance(mount) < self::SamKind::range(sam.kind) { + } else if aim_pos.distance(mount) < self::SamKind::range(sam.kind) { SamState::Lock } else { SamState::Track @@ -2015,8 +2070,16 @@ impl Sim { // Guns. if let (Some(gun), Some(gt)) = (&spec.gun, sam.gun_target) { if let Some(t) = drone_at.get(>).map(|&k| &self.drones[k]) { - let dist = t.pos.distance(mount); - if why::gun_blocks(sam, aim.id, gt, aim_error, dist, gun.range) == 0 { + let est = sam.track(gt).map_or(t.pos, |tr| tr.est(now)); + let dist = est.distance(mount); + let mut blocks = why::gun_blocks(sam, aim_id, gt, aim_error, dist, gun.range, now); + // A damaged gun jams now and then, for a while: the burst it was about to fire is lost. + let wear = 1.0 - sam.part(Part::Gun); + if blocks == 0 && wear > 0.0 && dice(self.seed, now, sam.id as u64, 77) < wear * 0.1 { + sam.jam = 30 + (dice(self.seed, now, sam.id as u64, 78) * 90.0) as u32; + blocks = why::JAMMED; + } + if blocks == 0 { sam.gun_cooldown = 2; sam.bursts -= 1; sam.flash = 2; @@ -2026,7 +2089,7 @@ impl Sim { sam.empty = true; ammo_waits += 1; } else { - sam.reloading = spec.reload + sam.reloading = sam.reload_time() } } let (_, rot) = sam.mount(); @@ -2053,7 +2116,9 @@ impl Sim { } // At night a gun sees what is lit (a searchlight, a flare) and little else. let dark = if self.night && !self.lit.contains(&t.id) { lights::DARK_HITS } else { 1.0 }; - let p = gun.p * fit * dark / t.kind.spec().stealth * (1.0 - dist / (gun.range * 1.3)).max(0.35); + // The aim point is the track's forecast: the farther it is from the truth, the more shells miss. + let off = est.distance(t.pos); + let p = gun.p * sam.gun_fit() * dark / (t.kind.spec().stealth * (1.0 + (off / 8.0).powi(2))) * (1.0 - dist / (gun.range * 1.3)).max(0.35); if self.rng.chance(p) { gun_hits.push((gt, sam.id)); } else { @@ -2066,8 +2131,8 @@ impl Sim { if let (Some(load), Some(mt)) = (&spec.missile, sam.target) { let ready = why::missile_blocks(sam) == 0; // The missile turns after its boost, but the launcher must point near the target. - let t = drone_at.get(&mt).map(|&k| &self.drones[k]); - let aligned = why::missile_aligned(spec.family, sam.yaw, mount, t, aim.id, mt, aim_error); + let t = sam.track(mt).filter(|_| drone_at.contains_key(&mt)).map(|tr| tr.est(now)); + let aligned = why::missile_aligned(spec.family, sam.yaw, mount, t, aim_id, mt, aim_error); if ready && aligned { let n = load.magazine - sam.missiles; let (muzzle, dir) = sam.muzzle(n); @@ -2088,7 +2153,7 @@ impl Sim { sam.empty = true; ammo_waits += 1; } else { - sam.reloading = spec.reload + sam.reloading = sam.reload_time() } } } @@ -2140,7 +2205,7 @@ impl Sim { let mut done: Vec<(u32, Option, bool, u32)> = Vec::new(); // (missile, target, kill, sam) let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32); let mut blocked = 0u32; - let sams_fit: HashMap = self.sams.iter().map(|s| (s.id, s.fitness())).collect(); + let sams_fit: HashMap = self.sams.iter().map(|s| (s.id, 1.0 - 0.3 * (1.0 - s.health.clamp(0.0, 1.0)))).collect(); for m in &mut self.missiles { m.age += 1; let spec = m.kind.spec(); diff --git a/airdef-mod/src/sim/orders.rs b/airdef-mod/src/sim/orders.rs index 6d3321a..20d5c4d 100644 --- a/airdef-mod/src/sim/orders.rs +++ b/airdef-mod/src/sim/orders.rs @@ -36,10 +36,10 @@ impl Sam { } impl Sim { - /// Orders unit `i` to drive to `to`. False for one that can't (a depot, a wreck). + /// Orders unit `i` to drive to `to`. False for one that can't (a depot, a wreck, failed running gear). pub fn order_move(&mut self, i: usize, to: DVec3) -> bool { let Some(sam) = self.sams.get_mut(i) else { return false }; - if !sam.alive() || !sam.kind.mobile() { + if !sam.alive() || !sam.kind.mobile() || sam.drive_speed() <= 0.0 { return false; } sam.dest = Some(to); @@ -75,6 +75,10 @@ impl Sim { } } SamState::Moving => { + // The running gear failed on the way: it stops where it is and deploys there. + if sam.drive_speed() <= 0.0 { + sam.dest = None; + } let Some(to) = sam.dest else { sam.state = SamState::Deploying; sam.march = sam.kind.march_ticks(); @@ -93,7 +97,7 @@ impl Sim { sam.heading = slew(sam.heading, heading_of(flat), 0.06); let ahead = DQuat::from_rotation_y(sam.heading) * DVec3::NEG_Z; let turning = angle_diff(sam.heading, heading_of(flat)).abs(); - let step = SPEED * (1.0 - turning / std::f64::consts::PI).max(0.2); + let step = SPEED * sam.drive_speed() * (1.0 - turning / std::f64::consts::PI).max(0.2); let next = sam.pos + ahead * step; let y = ground.at(next, now).unwrap_or(sam.pos.y); sam.pos = DVec3::new(next.x, sam.pos.y + (y - sam.pos.y).clamp(-0.5, 0.5), next.z); diff --git a/airdef-mod/src/sim/record.rs b/airdef-mod/src/sim/record.rs index fc3305f..666a5f8 100644 --- a/airdef-mod/src/sim/record.rs +++ b/airdef-mod/src/sim/record.rs @@ -110,10 +110,16 @@ pub struct Recording { /// The systems and objects as they stood: (id, kind, where). pub sams: Vec<(u32, SamKind, DVec3)>, pub objects: Vec, + /// The world clock's time of day (ticks) every `TIME_STEP` ticks of the recording, from its + /// start: replays show the sky of the raid, not the current one. Empty when the clock was unknown. + times: Vec, /// Ticks the sky has been clear. quiet: u32, } +/// Ticks between the recording's clock samples. +const TIME_STEP: u32 = 20; + impl Track { /// Where it was at `t` (the recording's ticks, fractional), if it was. pub fn at(&self, origin: DVec3, t: f64) -> Option { @@ -169,6 +175,7 @@ impl Recording { tally: Tally::default(), sams: sim.sams.iter().map(|s| (s.id, s.kind, s.pos)).collect(), objects: sim.targets.clone(), + times: Vec::new(), quiet: 0, } } @@ -184,6 +191,19 @@ impl Recording { } } } + /// The time of day (ticks, 24000 a day) the world showed at the recording's tick `t`: the + /// clock as sampled, run on between samples at the pace it ran (a jump is a step), or None + /// when the clock was not known (the replay then shows the world's own time). + pub fn day_time(&self, t: f64) -> Option { + let k = (t.max(0.0) / TIME_STEP as f64).floor() as usize; + let i = k.min(self.times.len().checked_sub(1)?); + let a = self.times[i]; + let into = if i == k { t.max(0.0) - (i as u32 * TIME_STEP) as f64 } else { 0.0 }; + match self.times.get(i + 1) { + Some(&b) if b >= a && b - a <= 2 * TIME_STEP as u64 => Some(a + ((b - a) as f64 * into / TIME_STEP as f64) as u64), + _ => Some(a), + } + } pub fn track(&self, id: u32) -> Option<&Track> { self.index.get(&id).map(|&i| &self.tracks[i]) } @@ -211,6 +231,15 @@ impl Sim { /// Something worth a replay happened. pub(super) fn log(&mut self, pos: DVec3, kind: EventKind) { + match kind { + EventKind::Crash { power, .. } => self.ignite(pos, power, None), + EventKind::Hit { power, sam, .. } => { + let host = sam.and_then(|(_, i)| self.sams.get(i)).map(|s| s.id); + self.ignite(pos, power.max(1), host) + } + EventKind::SamLost { sam, .. } => self.ignite(pos, 2, Some(sam)), + _ => {} + } if let Some(r) = &mut self.rec { let at = r.len; if let EventKind::Kill { drone, .. } = kind @@ -236,6 +265,12 @@ impl Sim { self.rec = Some(Recording::new(self.recordings, self)); } let Some(r) = &mut self.rec else { return }; + if r.len % TIME_STEP == 0 + && let Some(dt) = self.day_time + && r.times.len() == (r.len / TIME_STEP) as usize + { + r.times.push(dt); + } if r.len % STEP == 0 { for d in &self.drones { r.point(d.id, What::Drone(d.kind), d.pos); @@ -310,6 +345,31 @@ impl Sim { } } } + // Fires: what the events left burning (the flames dim and the smoke thickens with time). + let mut lit: Vec<(DVec3, f64)> = Vec::new(); + for (k, e) in rec.events.iter().enumerate() { + let power = match e.kind { + EventKind::Crash { power, .. } => power, + EventKind::Hit { power, .. } => power.max(1), + EventKind::SamLost { .. } => 2, + _ => continue, + }; + let age = t - e.at as f64; + // One fire where several events met. + if lit.iter().any(|(p, at)| p.distance(e.pos) < 6.0 && *at <= e.at as f64) { + continue; + } + lit.push((e.pos, e.at as f64)); + let (flame, smoke) = fires::replay_fire(power, age); + let id = base + 0x00c0_0000 + k as u32 * PARTS; + if flame > 0.05 { + let wob = 1.0 + 0.15 * (age * 0.9 + k as f64).sin(); + out.model(id, "airdef:flame", e.pos + DVec3::Y * 0.5, DQuat::from_rotation_y(age * 0.07), (flame as f64 * 0.8 * wob).max(0.05), true); + } + if smoke > 0.1 && (age as u64) % 3 == 0 { + out.particles("minecraft:large_smoke", e.pos + DVec3::Y, 0.8, DVec3::new(0.0, 0.1, 0.0), 0.03, 1 + (smoke * 2.0) as u32); + } + } // Blasts: a fireball growing and fading for 1.5 s after each. for (k, e) in rec.events.iter().enumerate() { let age = t - e.at as f64; diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index 705d00c..9030f9b 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -501,3 +501,49 @@ fn wall_and_tunnel_fixtures_agree() { } assert!(ground.world.bytes() < 64 * 1024); } + +// #57: a recording keeps the world clock of the raid, with jumps as steps. +#[test] +fn recordings_keep_the_time_of_day() { + let mut sim = Sim::new(1); + sim.day_time = Some(18000); + sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 500.0)); + let mut g = Ground::default(); + let mut out = Out::default(); + for i in 0..100u64 { + sim.day_time = Some(if i < 50 { 18000 + i } else { 6000 + i }); + sim.step(&mut g, None, &mut out); + } + let r = sim.rec.as_ref().unwrap(); + assert_eq!(r.day_time(0.0), Some(18000 + sim_first(r))); + // The jump is a step, not a glide through the day. + for t in 0..r.len.min(100) { + let v = r.day_time(t as f64).unwrap(); + assert!((18000..18060).contains(&v) || (6000..6110).contains(&v), "{t}: {v}"); + } +} + +fn sim_first(r: &record::Recording) -> u64 { + r.tracks.first().map_or(0, |t| t.first as u64) +} + +// #52: a crash lights a fire; the oldest of too many dies down; water leaves steam only. +#[test] +fn fires_start_merge_and_are_limited() { + let mut sim = Sim::new(1); + sim.ignite(DVec3::new(0.0, 70.0, 0.0), 1, None); + sim.ignite(DVec3::new(2.0, 70.0, 0.0), 2, None); + assert_eq!(sim.fires.len(), 1, "one fire where two things fell"); + assert!(sim.fires.list[0].size >= fires::size_for(2)); + for i in 0..30 { + sim.tick += 10; + sim.ignite(DVec3::new(100.0 * (i + 1) as f64, 70.0, 0.0), 1, None); + } + let now = sim.tick; + // Past the limit the oldest are already dying down. + let healthy = sim.fires.list.iter().filter(|f| (now - f.start) + 160 < f.life as u64).count(); + assert!(healthy <= fires::MAX, "{healthy}"); + let (fl, sm) = fires::level(10.0, 3000.0); + assert!(fl > 0.0 && sm > 0.0); + assert_eq!(fires::level(3000.0, 3000.0).0, 0.0); +} diff --git a/airdef-mod/src/sim/sensor_tests.rs b/airdef-mod/src/sim/sensor_tests.rs new file mode 100644 index 0000000..aa56ef1 --- /dev/null +++ b/airdef-mod/src/sim/sensor_tests.rs @@ -0,0 +1,228 @@ +//! Radar scan cycles with ageing tracks (#46) and damage to a system's parts (#50). +use super::sight::{swept, FORECAST_AGE}; +use super::*; + +fn flat() -> (Ground, Out) { + (Ground::default(), Out::default()) +} + +fn radar_at(sim: &mut Sim, kind: SamKind, x: f64) -> usize { + let id = sim.id(); + sim.sams.push(Sam::new(id, kind, DVec3::new(x, 70.0, 0.0), 0.0)); + sim.sams.len() - 1 +} + +/// A Shahed flying east along z = 40, from x = `from`, high enough to be above any horizon of the systems. +fn drone_from(sim: &mut Sim, from: f64) -> u32 { + sim.add_drone(DroneKind::Shahed, DVec3::new(from, 95.0, 40.0), DVec3::X, DVec3::new(5000.0, 95.0, 40.0)); + sim.drones.last().unwrap().id +} + +// #46: the radar measures a target when the beam passes it; between passes its track stays where it was measured. +#[test] +fn a_radar_measures_once_a_turn_and_the_track_stays_put_between() { + let mut sim = Sim::new(5); + let (mut ground, mut out) = flat(); + let r = radar_at(&mut sim, SamKind::Radar, 0.0); + let id = drone_from(&mut sim, -150.0); + let (mut seen_at, mut last) = (Vec::new(), None::<(u64, DVec3)>); + let mut stale_gap = 0.0f64; + for _ in 0..700 { + sim.step(&mut ground, None, &mut out); + let Some(t) = sim.sams[r].track(id).copied() else { continue }; + match last { + Some((tick, pos)) if tick == t.tick => { + assert_eq!(pos, t.pos, "the track moved without a new measurement"); + let d = sim.drones.iter().find(|d| d.id == id).unwrap(); + stale_gap = stale_gap.max(d.pos.distance(t.pos)); + if t.age(sim.tick) > FORECAST_AGE { + assert!(t.forecast(sim.tick)); + } + } + _ => seen_at.push(t.tick), + } + last = Some((t.tick, t.pos)); + } + assert!(seen_at.len() >= 2, "measured {} times", seen_at.len()); + // A turn of the early-warning radar is 140 ticks (less relative to a target that moves round it): never every tick. + assert!(seen_at.windows(2).all(|w| w[1] - w[0] >= 60), "{seen_at:?}"); + assert!(stale_gap > 20.0, "the track never fell behind the target ({stale_gap})"); +} + +#[test] +fn a_beam_crossing_north_between_ticks_still_passes_a_target_there() { + let tau = std::f64::consts::TAU; + assert!(swept(0.01, tau - 0.05, 0.1)); + assert!(swept(tau - 0.02, tau - 0.05, 0.1)); + assert!(!swept(0.2, tau - 0.05, 0.1)); + assert!(!swept(tau - 0.1, tau - 0.05, 0.1)); +} + +#[test] +fn two_radars_have_their_own_phase_and_the_network_keeps_the_age() { + let mut sim = Sim::new(7); + let (mut ground, mut out) = flat(); + let a = radar_at(&mut sim, SamKind::Radar, 0.0); + let b = radar_at(&mut sim, SamKind::Radar, 500.0); + let n = radar_at(&mut sim, SamKind::P1sun, 250.0); + assert_ne!(sim.sams[a].beam, sim.sams[b].beam, "radars start at different phases"); + let id = drone_from(&mut sim, -50.0); + let mut relayed = 0; + for _ in 0..800 { + sim.step(&mut ground, None, &mut out); + let own = |s: usize| sim.sams[s].track(id).map_or(0, |t| t.own_tick); + let Some(t) = sim.sams[n].track(id) else { continue }; + relayed += 1; + assert_eq!(t.own_tick, 0, "a relayed measurement is not the receiver's own"); + assert!(t.tick <= own(a).max(own(b)), "the network made a track fresher than any measurement"); + if own(a).max(own(b)) > 0 { + assert_eq!(t.tick, own(a).max(own(b)), "the network holds the freshest measurement"); + } + } + assert!(relayed > 100, "the network never passed a track ({relayed})"); +} + +#[test] +fn a_lock_measures_every_tick_and_a_dead_radar_stops_the_feed() { + let mut sim = Sim::new(3); + let (mut ground, mut out) = flat(); + let p = radar_at(&mut sim, SamKind::Pantsir, 0.0); + let net = radar_at(&mut sim, SamKind::P1sun, 100.0); + let id = drone_from(&mut sim, -190.0); + let mut locked = 0; + for _ in 0..600 { + sim.step(&mut ground, None, &mut out); + if sim.sams[p].target == Some(id) + && let Some(t) = sim.sams[p].track(id) + && t.age(sim.tick) <= 1 + && t.locked + { + locked += 1; + } + } + assert!(locked > 5, "no fire-control lock ({locked})"); + // The radar is destroyed: the network's track is no longer renewed. + let newest = sim.sams[net].tracks.iter().map(|t| t.tick).max(); + sim.sams[p].health = 0.0; + let drones = sim.drones.iter().filter(|d| d.alive()).count(); + for _ in 0..40 { + sim.step(&mut ground, None, &mut out); + } + assert!(sim.sams[p].tracks.is_empty() && sim.sams[p].seen.is_empty()); + if drones > 0 { + assert!(sim.sams[net].tracks.iter().map(|t| t.tick).max() <= newest, "a dead radar still renewed a track"); + } +} + +#[test] +fn a_failed_radar_gives_no_own_tracks_but_the_network_still_feeds_it() { + let mut sim = Sim::new(9); + let (mut ground, mut out) = flat(); + let p = radar_at(&mut sim, SamKind::Patriot, 0.0); + let early = radar_at(&mut sim, SamKind::Radar, 120.0); + let id = drone_from(&mut sim, -150.0); + sim.sams[p].comps.0[Part::Sensor as usize] = 0.0; + for _ in 0..500 { + sim.step(&mut ground, None, &mut out); + } + assert!(!sim.sams[p].radar_up()); + assert!(sim.sams[p].own.is_empty(), "a failed radar measured something"); + assert!(sim.sams[p].track(id).is_some(), "the network should still feed the system"); + // Without its link it is blind. + sim.sams[p].comps.0[Part::Comms as usize] = 0.0; + sim.sams[p].tracks.clear(); + for _ in 0..200 { + sim.step(&mut ground, None, &mut out); + } + assert!(sim.sams[p].tracks.is_empty(), "no link, no network tracks"); + assert!(sim.sams[early].own.len() >= sim.sams[p].own.len()); +} + +// #50: a blast hurts the parts nearest to it. +#[test] +fn a_blast_from_behind_hurts_the_link_a_blast_from_the_front_the_drive() { + let at = DVec3::new(0.0, 70.0, 0.0); + let hurt = |dz: f64| { + let mut sim = Sim::new(4); + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Patriot, at, 0.0)); + let mut out = Out::default(); + sim.blast_damage(at + DVec3::new(0.0, 3.0, dz), 2, &mut out); + assert!(sim.sams[0].alive()); + let c = sim.sams[0].comps; + (1.0 - c.get(Part::Comms), 1.0 - c.get(Part::Mount), 1.0 - c.get(Part::Sensor)) + }; + let (back_comms, back_mount, _) = hurt(6.0); + let (front_comms, front_mount, _) = hurt(-6.0); + assert!(back_comms > back_mount, "behind: link {back_comms} drive {back_mount}"); + assert!(front_mount > front_comms, "front: link {front_comms} drive {front_mount}"); +} + +#[test] +fn a_close_blast_can_leave_a_firing_but_immobile_system() { + // Over many blasts at moderate range some leave the system able to fire and unable to drive. + let mut found = false; + for seed in 0..60 { + let mut sim = Sim::new(seed); + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Pantsir, DVec3::new(0.0, 70.0, 0.0), 0.0)); + let mut out = Out::default(); + sim.tick = seed; + sim.blast_damage(DVec3::new(0.0, 69.0, 6.0 + seed as f64 * 0.06), 3, &mut out); + let s = &sim.sams[0]; + if s.alive() && s.part(Part::Tracks) <= 0.0 && s.part(Part::Launcher) > 0.0 && s.part(Part::Gun) > 0.0 { + assert!(!sim.order_move(0, DVec3::new(300.0, 70.0, 0.0)), "failed running gear must refuse a march"); + assert!(why::missile_blocks(&sim.sams[0]) & why::LAUNCHER_DOWN == 0); + assert!(sim.why(0).iter().any(|c| c.text.contains("ходовая отказала")), "{:?}", sim.why(0).iter().map(|c| &c.text).collect::>()); + found = true; + break; + } + } + assert!(found, "no seed left it firing but immobile"); +} + +#[test] +fn failed_parts_block_what_they_serve_and_repair_restores_them() { + let mut sim = Sim::new(2); + let g = radar_at(&mut sim, SamKind::Gepard, 0.0); + let p = radar_at(&mut sim, SamKind::Patriot, 400.0); + sim.sams[g].comps.0[Part::Gun as usize] = 0.0; + assert!(!sim.sams[g].gun_up()); + assert!(why::gun_blocks(&sim.sams[g], 1, 1, 0.0, 10.0, 90.0, 0) & why::JAMMED != 0); + sim.sams[p].comps.0[Part::Launcher as usize] = 0.0; + assert!(why::missile_blocks(&sim.sams[p]) & why::LAUNCHER_DOWN != 0); + // A worn launcher: fewer cells, a longer reload. + sim.sams[p].comps.0[Part::Launcher as usize] = 0.3; + sim.sams[p].fit_cells(); + let full = Sam::new(0, SamKind::Patriot, DVec3::ZERO, 0.0); + assert!(sim.sams[p].cells() < full.cells()); + assert!(sim.sams[p].missiles <= sim.sams[p].cells()); + assert!(sim.sams[p].reload_time() > full.reload_time()); + // A worn mount turns slower, a failed one not at all. + sim.sams[p].comps.0[Part::Mount as usize] = 0.0; + assert_eq!(sim.sams[p].drive_fit(), 0.0); + sim.sams[p].comps.0[Part::Tracks as usize] = 0.0; + assert_eq!(sim.repair(), 2, "both damaged systems are counted"); + assert!(sim.sams.iter().all(|s| s.comps.whole() && s.cells() == s.kind.spec().missile.as_ref().map_or(0, |m| m.magazine))); + assert!(sim.later.is_empty(), "repair must not leave cook-offs behind"); + assert!(sim.order_move(p, DVec3::new(500.0, 70.0, 0.0))); +} + +#[test] +fn the_same_seed_rolls_the_same_scan_and_damage() { + let run = || { + let mut sim = Sim::new(11); + let (mut ground, mut out) = flat(); + let r = radar_at(&mut sim, SamKind::Radar, 0.0); + let id = drone_from(&mut sim, -150.0); + let mut ticks = Vec::new(); + for _ in 0..500 { + sim.step(&mut ground, None, &mut out); + if let Some(t) = sim.sams[r].track(id) { + ticks.push(t.tick); + } + } + ticks + }; + assert_eq!(run(), run()); +} diff --git a/airdef-mod/src/sim/sight.rs b/airdef-mod/src/sim/sight.rs index 1066e4d..7709427 100644 --- a/airdef-mod/src/sim/sight.rs +++ b/airdef-mod/src/sim/sight.rs @@ -3,6 +3,17 @@ //! (over the curve of the earth, scaled to the game's distances), and the land //! hides what flies behind hills and woods: each system keeps its horizon, the //! highest the land rises seen from its antenna, ray by ray round it. +//! +//! A radar does not know where everything is all the time. Its beam sweeps round +//! (a period by the kind of radar; an electronic one also revisits what it holds +//! more often), a target is measured when the beam passes it, with a chance that +//! falls with range and size, and the measurement is kept as a *track*: where it +//! was and how fast it went then, and when. Between measurements the track is +//! carried on by that velocity (a forecast, shown as one), and after three missed +//! scans it is dropped. A fire-control lock on a target the system is engaging is +//! a dedicated channel: it measures every tick while the target is in reach and in +//! sight. The network passes tracks with the age of their measurement; relaying +//! makes none of them fresher. use super::*; /// Rays round a radar, and blocks between the land's samples along them. @@ -10,6 +21,13 @@ const RAYS: usize = 48; const STEP: f64 = 16.0; /// How far units share their tracks (the network's data links). pub(super) const LINK: f64 = 1500.0; +/// A launcher fires on a track no older than this (ticks); a gun needs its own radar's, newer than `GUN_AGE`. +pub(super) const MISSILE_AGE: u64 = 120; +pub(super) const GUN_AGE: u64 = 3; +/// A gun takes a target its radar measured this recently (then the lock refreshes it every tick). +pub(super) const GUN_PICK_AGE: u64 = 40; +/// A track older than this is shown as a forecast. +pub const FORECAST_AGE: u64 = 20; /// Radio horizon: `HORIZON * (√mast + √height)` blocks. const HORIZON: f64 = 35.0; /// Anything this near is seen (and heard), whatever hides it. @@ -70,13 +88,144 @@ pub(super) fn reach(sam: &Sam, kind: DroneKind, height: f64) -> f64 { if spec.sensor <= 0.0 { return 0.0; } - let radar = spec.sensor * sam.fitness() * kind.rcs().powf(0.25); + let radar = spec.sensor * sam.radar_fit() * kind.rcs().powf(0.25); let horizon = HORIZON * (spec.mast.sqrt() + height.max(0.0).sqrt()); radar.min(horizon).max(NEAR) } +/// How a kind's radar looks round. +pub(super) struct Scan { + /// Ticks for the beam to go round. + pub period: u32, + /// An electronic radar: what it holds it revisits at once a third of the period, between the beam's passes. + pub agile: bool, +} + +impl SamKind { + pub(super) fn scan(self) -> Scan { + let (period, agile) = match self { + SamKind::Patriot => (30, true), + SamKind::S400 => (36, true), + SamKind::S500 => (24, true), + SamKind::IrisT | SamKind::Nasams => (40, true), + SamKind::Buk => (50, false), + SamKind::Tor | SamKind::Pantsir => (40, false), + SamKind::Gepard => (50, false), + // The early-warning radar: a big antenna turning slowly. + SamKind::Radar => (140, false), + _ => (40, false), + }; + Scan { period, agile } + } +} + +/// How well a track says what it is. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Ident { + /// One measurement: something is there. + Unknown, + /// Measured again: a drone or missile by how it flies. + Probable, + /// Held by a lock or by several measurements. + Confirmed, +} + +impl Ident { + pub fn name(self) -> &'static str { + match self { + Ident::Unknown => "неопознана", + Ident::Probable => "предположительно воздушная цель", + Ident::Confirmed => "подтверждена", + } + } +} + +/// What a system knows of one target: the last measurement, not the target itself. +#[derive(Clone, Copy, Debug)] +pub struct Track { + pub id: u32, + /// Where it was and how it went when last measured (blocks, blocks a tick). + pub pos: DVec3, + pub vel: DVec3, + /// When that was, and when this system's own radar last measured it (0: never, the network's only). + pub tick: u64, + pub own_tick: u64, + /// Ticks the track lives without a new measurement. + pub ttl: u32, + pub hits: u8, + /// The last own measurement came from a fire-control lock. + pub locked: bool, +} + +impl Track { + pub fn age(&self, now: u64) -> u64 { + now.saturating_sub(self.tick) + } + /// Where the target should be by now, carrying the last measurement on: the forecast, not a measurement. + pub fn est(&self, now: u64) -> DVec3 { + self.pos + self.vel * self.age(now) as f64 + } + pub fn forecast(&self, now: u64) -> bool { + self.age(now) > FORECAST_AGE + } + /// Own radar measured it within `age` ticks. + pub fn own_within(&self, now: u64, age: u64) -> bool { + self.own_tick > 0 && now.saturating_sub(self.own_tick) <= age + } + pub fn ident(&self) -> Ident { + match self.hits { + 0 | 1 => Ident::Unknown, + 2 => Ident::Probable, + _ => Ident::Confirmed, + } + } + /// How far the forecast may be off, blocks: grows with the age. + pub fn uncertainty(&self, now: u64) -> f64 { + 1.0 + self.age(now) as f64 * (0.05 + self.vel.length() * 0.1) + } +} + +impl Sam { + /// Ticks for its beam to go round (longer when the radar is damaged). + pub(super) fn scan_ticks(&self) -> f64 { + self.kind.scan().period as f64 / self.radar_fit().max(0.2) + } + /// Its own radar works: it has one, it is not failed and the unit is deployed. + pub fn radar_up(&self) -> bool { + self.alive() && !self.moving() && self.kind.spec().sensor > 0.0 && self.part(Part::Sensor) > 0.0 + } + pub fn track(&self, id: u32) -> Option<&Track> { + self.tracks.iter().find(|t| t.id == id) + } +} + +/// Whether a beam that moved from `from` by `w` radians this tick passed `bearing` (all angles taken round 2π, +/// so a beam crossing north between two ticks still passes a target there). +pub(super) fn swept(bearing: f64, from: f64, w: f64) -> bool { + (bearing - from).rem_euclid(TAU) < w +} + +/// A new measurement into a list of tracks (the index maps ids to places): a fresher one replaces an older. +fn merge(tracks: &mut Vec, index: &mut HashMap, t: Track) { + match index.get(&t.id) { + Some(&k) => { + let mine = &mut tracks[k]; + if t.tick > mine.tick { + let (own_tick, locked) = (mine.own_tick, if t.own_tick > 0 { t.locked } else { mine.locked }); + let hits = mine.hits.max(t.hits); + *mine = Track { own_tick: if t.own_tick > 0 { t.own_tick } else { own_tick }, hits, locked, ..t }; + } + } + None => { + index.insert(t.id, tracks.len()); + tracks.push(t); + } + } +} + impl Sim { - /// Rebuilds the systems' horizons in turn, and what each radar holds now. + /// Rebuilds the systems' horizons in turn, then lets each radar's beam pass the sky: new measurements + /// into the tracks, old tracks out, and the network's tracks shared. pub(super) fn update_sight(&mut self, ground: &mut Ground) { let now = self.tick; // One horizon every few ticks: the land answers come in over time. @@ -95,44 +244,108 @@ impl Sim { // Each drone's height over the land under it (or over the systems' ground, unknown). let heights: Vec> = self.drones.iter().map(|d| ground.at(d.pos, now).map(|g| d.pos.y - g)).collect(); + let drone_at: HashMap = self.drones.iter().enumerate().map(|(k, d)| (d.id, k)).collect(); + let seed = self.seed; + let mut index: HashMap = HashMap::new(); for sam in &mut self.sams { - sam.seen.clear(); - sam.own.clear(); - if !sam.alive() || sam.kind.spec().sensor <= 0.0 || sam.moving() { + // A track of a drone that is gone is dropped (the kill is seen), so is one not renewed for its time. + sam.tracks.retain(|t| now - t.tick <= t.ttl as u64 && drone_at.get(&t.id).is_some_and(|&k| self.drones[k].alive())); + if !sam.alive() || sam.moving() { + sam.tracks.clear(); continue; } + if !sam.radar_up() { + continue; + } + index.clear(); + index.extend(sam.tracks.iter().enumerate().map(|(k, t)| (t.id, k))); + let spec = sam.kind.spec(); + let scan = sam.kind.scan(); + let period = sam.scan_ticks(); + let w = TAU / period; + let (b0, ttl) = (sam.beam, ((period * 3.0) as u32).max(60)); + sam.beam = (b0 + w) % TAU; let at = antenna(sam); let horizon = self.horizons.get(&sam.id); + let mut fresh: Vec = Vec::new(); for (d, h) in self.drones.iter().zip(&heights) { if !d.alive() { continue; } - let dist = d.pos.distance(at); + let rel = d.pos - at; + let dist = rel.length(); + if dist > spec.sensor * 1.2 { + continue; + } + let locked = sam.target == Some(d.id) || sam.gun_target == Some(d.id); + let held = index.get(&d.id).map(|&k| sam.tracks[k]); + // The beam passes its bearing this tick (the angle wraps at 2π: the difference is taken round). + let in_beam = swept(rel.x.atan2(-rel.z), b0, w); + let revisit = scan.agile && held.is_some_and(|t| t.own_tick > 0 && now - t.own_tick >= (period / 3.0) as u64); + if !(in_beam || locked || revisit) { + continue; + } let height = h.unwrap_or(d.pos.y - sam.pos.y); - if dist > reach(sam, d.kind, height) { + let reach = reach(sam, d.kind, height); + if dist > reach || (dist > NEAR && horizon.is_some_and(|hz| hz.hides(at, d.pos))) { continue; } - if dist > NEAR && horizon.is_some_and(|hz| hz.hides(at, d.pos)) { + let key = (sam.id as u64) << 32 | d.id as u64; + // A target in the beam is measured with a chance that falls towards the edge of reach and + // with the target's stealth; one the radar already follows is lost only now and then. + let p = if locked || revisit { + 0.985 + } else { + ((0.97 - 0.5 * (dist / reach).powi(3)) / d.kind.spec().stealth.sqrt()).clamp(0.05, 0.97) + }; + if dist > NEAR && dice(seed, now, key, 0) >= p { continue; } - sam.own.push(d.id); + let sigma = if locked { 0.3 } else { 0.8 + dist * 0.004 }; + let noise = DVec3::new(dice(seed, now, key, 1) - 0.5, (dice(seed, now, key, 2) - 0.5) * 0.5, dice(seed, now, key, 3) - 0.5) * 2.0 * sigma; + fresh.push(Track { + id: d.id, + pos: d.pos + noise, + vel: d.vel, + tick: now, + own_tick: now, + ttl, + hits: held.map_or(1, |t| t.hits.saturating_add(1)), + locked, + }); + } + for t in fresh { + merge(&mut sam.tracks, &mut index, t); } } - // The network: every unit gets the tracks of the radars within data-link range. - let tracks: Vec<(DVec3, Vec)> = - self.sams.iter().filter(|s| !s.own.is_empty()).map(|s| (s.pos, s.own.clone())).collect(); - // Merged in the radars' order, each track once: a set marks what is in already. - let mut have: std::collections::HashSet = std::collections::HashSet::new(); + // The network: every unit gets the tracks the radars within data-link range measured themselves, + // with the time they were measured: a relayed track is never fresher than its measurement. + let shared: Vec<(u32, DVec3, f64, Vec)> = self + .sams + .iter() + .filter(|s| s.alive() && !s.moving() && s.link_range() > 0.0) + .map(|s| (s.id, s.pos, s.link_range(), s.tracks.iter().filter(|t| t.own_tick == t.tick).copied().collect::>())) + .filter(|s| !s.3.is_empty()) + .collect(); for sam in &mut self.sams { - sam.seen.clone_from(&sam.own); - if !sam.alive() || sam.moving() || !sam.kind.armed() { - continue; + if sam.alive() && !sam.moving() && sam.kind.armed() && sam.link_range() > 0.0 && !shared.is_empty() { + index.clear(); + index.extend(sam.tracks.iter().enumerate().map(|(k, t)| (t.id, k))); + let mine = sam.link_range(); + for (id, at, range, tracks) in &shared { + if *id != sam.id && at.distance(sam.pos) <= range.min(mine) { + for t in tracks { + merge(&mut sam.tracks, &mut index, Track { own_tick: 0, ..*t }); + } + } + } } - have.clear(); - have.extend(sam.own.iter().copied()); - for (at, ids) in &tracks { - if at.distance(sam.pos) <= LINK { - sam.seen.extend(ids.iter().copied().filter(|id| have.insert(*id))); + sam.seen.clear(); + sam.own.clear(); + for t in &sam.tracks { + sam.seen.push(t.id); + if t.own_within(now, t.ttl as u64) { + sam.own.push(t.id); } } } @@ -142,4 +355,15 @@ impl Sim { pub fn tracked(&self, id: u32) -> bool { self.sams.iter().any(|s| s.seen.contains(&id)) } + + /// The freshest track of each drone any system holds: the air picture. + pub fn picture(&self) -> HashMap { + let mut best: HashMap = HashMap::new(); + for t in self.sams.iter().flat_map(|s| s.tracks.iter()) { + if best.get(&t.id).is_none_or(|b| t.tick > b.tick) { + best.insert(t.id, *t); + } + } + best + } } diff --git a/airdef-mod/src/sim/why.rs b/airdef-mod/src/sim/why.rs index ec605dc..6f44a2b 100644 --- a/airdef-mod/src/sim/why.rs +++ b/airdef-mod/src/sim/why.rs @@ -11,29 +11,32 @@ use super::*; pub(super) const DEAD: u8 = 1; pub(super) const ROLE: u8 = 2; pub(super) const UNSEEN: u8 = 4; +/// Its track is older than a launcher fires on. +pub(super) const STALE: u8 = 8; -pub(super) fn target_blocks(roles: u8, seen: &[u32], d: &Drone) -> u8 { +pub(super) fn target_blocks(roles: u8, track: Option<&Track>, now: u64, d: &Drone) -> u8 { (if d.alive() { 0 } else { DEAD }) | (if roles & d.kind.spec().class.bit() != 0 { 0 } else { ROLE }) - | (if seen.contains(&d.id) { 0 } else { UNSEEN }) + | (if track.is_some() { 0 } else { UNSEEN }) + | (if track.is_some_and(|t| t.age(now) > sight::MISSILE_AGE) { STALE } else { 0 }) } -/// Alive, of a class the system engages, and on its picture (own radar or the network's). -pub(super) fn allowed(roles: u8, seen: &[u32], d: &Drone) -> bool { - target_blocks(roles, seen, d) == 0 +/// Alive, of a class the system engages, and on its picture (own radar or the network's), fresh enough. +pub(super) fn allowed(roles: u8, track: &Track, now: u64, d: &Drone) -> bool { + target_blocks(roles, Some(track), now, d) == 0 } pub(super) const TOO_CLOSE: u8 = 1; pub(super) const TOO_FAR: u8 = 2; /// Missile range: `min` to `max * k` (a target already held may stray to 1.1 × max). -pub(super) fn reach_blocks(min: f64, max: f64, k: f64, pos: DVec3, d: &Drone) -> u8 { - let dist = d.pos.distance(pos); +pub(super) fn reach_blocks(min: f64, max: f64, k: f64, pos: DVec3, at: DVec3) -> u8 { + let dist = at.distance(pos); (if dist >= min { 0 } else { TOO_CLOSE }) | (if dist < max * k { 0 } else { TOO_FAR }) } -pub(super) fn in_reach(min: f64, max: f64, k: f64, pos: DVec3, d: &Drone) -> bool { - reach_blocks(min, max, k, pos, d) == 0 +pub(super) fn in_reach(min: f64, max: f64, k: f64, pos: DVec3, at: DVec3) -> bool { + reach_blocks(min, max, k, pos, at) == 0 } /// Interceptors a target has or will have: in flight, and other systems' launchers on it. @@ -47,9 +50,10 @@ pub(super) fn needed(d: &Drone) -> u32 { if d.kind.spec().class == Class::Ballistic { 2 } else { 1 } } -/// A gun's candidates: held by the system's own radar and within the gun's tracking range. -pub(super) fn gun_candidate(roles: u8, seen: &[u32], own: &[u32], track: f64, pos: DVec3, d: &Drone) -> bool { - allowed(roles, seen, d) && own.contains(&d.id) && d.pos.distance(pos) < track +/// A gun's candidates: measured by the system's own radar a moment ago and within the gun's tracking range +/// (by the track's forecast, not the target's true place). +pub(super) fn gun_candidate(roles: u8, track: &Track, now: u64, gun_track: f64, pos: DVec3, d: &Drone) -> bool { + allowed(roles, track, now, d) && track.own_within(now, sight::GUN_PICK_AGE) && track.est(now).distance(pos) < gun_track } /// How many systems have each drone as their missile target and as their gun target. @@ -66,8 +70,8 @@ pub(super) fn engagement_counts(sams: &[Sam]) -> (HashMap, HashMap (DVec3, f64, f64) { - let lead = aim.pos + aim.vel * (aim.pos.distance(mount) / lead_speed); +pub(super) fn aim_wants(mount: DVec3, at: DVec3, vel: DVec3, lead_speed: f64) -> (DVec3, f64, f64) { + let lead = at + vel * (at.distance(mount) / lead_speed); let to = lead - mount; let want_yaw = heading_of(to); let want_pitch = (to.y / to.length().max(0.01)).asin().clamp(-0.1, 1.45); @@ -88,19 +92,22 @@ pub(super) fn aim_error(family: Family, yaw: f64, pitch: f64, want_yaw: f64, wan pub(super) const COOLDOWN: u8 = 1; pub(super) const RELOADING: u8 = 2; pub(super) const NO_ROUNDS: u8 = 4; +/// The launcher has failed. +pub(super) const LAUNCHER_DOWN: u8 = 8; /// What keeps a loaded launcher from firing now (aim aside). pub(super) fn missile_blocks(s: &Sam) -> u8 { - (if s.cooldown == 0 { 0 } else { COOLDOWN }) + (if s.part(Part::Launcher) > 0.0 { 0 } else { LAUNCHER_DOWN }) + | (if s.cooldown == 0 { 0 } else { COOLDOWN }) | (if s.reloading == 0 { 0 } else { RELOADING }) | (if s.missiles > 0 { 0 } else { NO_ROUNDS }) } /// The launcher points near enough: a trainable one only by its training, a turret by its /// whole aim at this target, the vertical ones need nothing. -pub(super) fn missile_aligned(family: Family, yaw: f64, mount: DVec3, t: Option<&Drone>, aim_id: u32, mt: u32, aim_error: f64) -> bool { +pub(super) fn missile_aligned(family: Family, yaw: f64, mount: DVec3, t: Option, aim_id: u32, mt: u32, aim_error: f64) -> bool { match family { - Family::Trainable => t.is_some_and(|t| angle_diff(yaw, heading_of(t.pos - mount)).abs() < 0.15), + Family::Trainable => t.is_some_and(|t| angle_diff(yaw, heading_of(t - mount)).abs() < 0.15), Family::Turret => aim_id == mt && aim_error < 0.15, _ => true, } @@ -109,11 +116,17 @@ pub(super) fn missile_aligned(family: Family, yaw: f64, mount: DVec3, t: Option< pub(super) const NOT_AIMED: u8 = 8; pub(super) const AIM_ERROR: u8 = 16; pub(super) const OUT_OF_RANGE: u8 = 32; +/// The gun is jammed (for a while, or failed). +pub(super) const JAMMED: u8 = 64; +/// Its radar has no fresh measurement of the target. +pub(super) const NO_LOCK: u8 = 128; /// What keeps a gun from firing now: the turret on another target, not yet laid, out of range, /// and the gun's own cooldown, reload and shells (`COOLDOWN`, `RELOADING`, `NO_ROUNDS` bits). -pub(super) fn gun_blocks(s: &Sam, aim_id: u32, gt: u32, aim_error: f64, dist: f64, range: f64) -> u8 { +pub(super) fn gun_blocks(s: &Sam, aim_id: u32, gt: u32, aim_error: f64, dist: f64, range: f64, now: u64) -> u8 { (if aim_id == gt { 0 } else { NOT_AIMED }) + | (if s.gun_up() { 0 } else { JAMMED }) + | (if s.track(gt).is_some_and(|t| t.own_within(now, sight::GUN_AGE)) { 0 } else { NO_LOCK }) | (if aim_error < 0.1 { 0 } else { AIM_ERROR }) | (if dist < range { 0 } else { OUT_OF_RANGE }) | (if s.gun_cooldown == 0 { 0 } else { COOLDOWN }) @@ -160,9 +173,38 @@ impl Sim { let logistics = self.logistics(); let (missile_on, guns_on) = engagement_counts(&self.sams); let alive = self.drones.iter().filter(|d| d.alive()).count(); + let now = self.tick; let seen = self.drones.iter().filter(|d| d.alive() && sam.seen.contains(&d.id)).count(); let own = self.drones.iter().filter(|d| d.alive() && sam.own.contains(&d.id)).count(); v.push(check(alive > 0, format!("в небе {alive}, на картине {seen} (своя РЛС {own}, остальные по сети)"))); + // The parts: what is worn or failed and what it costs. + if !sam.comps.whole() { + v.push(check(false, format!("узлы: {}", sam.parts_text()))); + } + for p in Part::ALL { + if !p.on(sam.kind) || sam.part_state(p) == PartState::Ok { + continue; + } + let failed = sam.part_state(p) == PartState::Failed; + let effect = match (p, failed) { + (Part::Sensor, true) => "РЛС отказала: своих измерений нет, стволы не стреляют, ракеты — по данным сети".to_string(), + (Part::Sensor, false) => format!("РЛС повреждена: дальность {:.0}%, обзор вдвое реже при худшем", sam.radar_fit() * 100.0), + (Part::Mount, true) => format!("привод наведения заклинил: стоит на {:.0}°, не доворачивается", sam.yaw.to_degrees().rem_euclid(360.0)), + (Part::Mount, false) => format!("привод наведения повреждён: поворот {:.0}% от нормы", sam.drive_fit() * 100.0), + (Part::Launcher, true) => "пусковая отказала: пуски невозможны".to_string(), + (Part::Launcher, false) => format!("пусковая повреждена: {} ячеек из {}, перезарядка {:.0} с", sam.cells(), spec.missile.as_ref().map_or(0, |m| m.magazine), sam.reload_time() as f64 / 20.0), + (Part::Gun, true) => "орудие заклинило: не стреляет".to_string(), + (Part::Gun, false) => format!("орудие повреждено: меткость {:.0}%, заклинивает", sam.gun_fit() * 100.0), + (Part::Tracks, true) => "ходовая отказала: на марш не выйдет".to_string(), + (Part::Tracks, false) => format!("ходовая повреждена: скорость марша {:.0}%", sam.drive_speed() * 100.0), + (Part::Comms, true) => "связь отказала: сети нет, цели только свои".to_string(), + (Part::Comms, false) => format!("связь повреждена: линия {:.0} м", sam.link_range()), + }; + v.push(check(false, effect)); + } + if sam.jam > 0 { + v.push(check(false, format!("орудие заклинило, ещё {:.1} с", sam.jam as f64 / 20.0))); + } let drone_at = |id: u32| self.drones.iter().find(|d| d.id == id); let subject = sam.target.or(sam.gun_target).and_then(drone_at).or_else(|| { self.drones @@ -175,24 +217,42 @@ impl Sim { return v; }; let held = sam.target == Some(t.id) || sam.gun_target == Some(t.id); - let dist = t.pos.distance(sam.pos); + let dist = sam.track(t.id).map_or(t.pos, |tr| tr.est(self.tick)).distance(sam.pos); v.push(check( true, format!("цель: {} {}, {}", t.kind.spec().name, if held { "(сопровождается)" } else { "(самая опасная)" }, crate::panel::distance(dist)), )); - let blocks = target_blocks(sam.roles, &sam.seen, t); + let track = sam.track(t.id); + let blocks = target_blocks(sam.roles, track, now, t); + // Where the system believes the target is: its track's forecast (the true place only when it has no track). + let est = track.map_or(t.pos, |tr| tr.est(now)); + let evel = track.map_or(t.vel, |tr| tr.vel); v.push(check( blocks & ROLE == 0, format!("роль: {} {} [{}]", t.kind.spec().class.name(), if blocks & ROLE == 0 { "в ролях" } else { "не в ролях" }, roles_text(sam.roles)), )); - let detect = match (sam.own.contains(&t.id), sam.seen.contains(&t.id)) { - (true, _) => "обнаружена своей РЛС", - (false, true) => "видна только по сети (стволы не стреляют)", - _ => "не обнаружена ни своей РЛС, ни сетью", + let detect = match (track, sam.own.contains(&t.id)) { + (Some(tr), own) => { + let kind = if own { "своей РЛС" } else { "по сети (стволы не стреляют)" }; + let age = tr.age(now) as f64 / 20.0; + let lock = if tr.own_within(now, sight::GUN_AGE) && tr.locked { ", захват" } else { "" }; + let how = if tr.forecast(now) { format!("прогноз ±{:.0} м", tr.uncertainty(now)) } else { "измерение".to_string() }; + format!("трек {kind}: {}, возраст {age:.1} с, {how}{lock}, {}", tr.ident().name(), if blocks & STALE != 0 { "устарел для пуска" } else { "годен для пуска" }) + } + _ => "не обнаружена ни своей РЛС, ни сетью".to_string(), }; - v.push(check(blocks & UNSEEN == 0, detect)); - let fit = sam.fitness(); - v.push(check(fit >= 0.9, format!("состояние {:.0}%: поворот и точность {:.0}%", sam.health * 100.0, fit * 100.0))); + v.push(check(blocks & (UNSEEN | STALE) == 0, detect)); + if sam.kind.spec().sensor > 0.0 { + let period = sam.scan_ticks(); + v.push(check( + sam.radar_up(), + if sam.radar_up() { + format!("обзор: оборот {:.1} с{}, луч на {:.0}°", period / 20.0, if sam.kind.scan().agile { ", электронный, цели в треке чаще" } else { "" }, sam.beam.to_degrees()) + } else { + "обзор: РЛС не работает".to_string() + }, + )); + } // Ammunition and its supply. let mut rounds = String::new(); if let Some(load) = &spec.missile { @@ -211,12 +271,12 @@ impl Sim { v.push(check(sam.reloading == 0 && !sam.empty && (sam.missiles > 0 || sam.bursts > 0), format!("{rounds}— {supply}"))); let mount = sam.mount().0; let lead_speed = spec.gun.as_ref().map_or(5.0, |g| g.shell); - let (_, want_yaw, want_pitch) = aim_wants(mount, t, lead_speed); + let (_, want_yaw, want_pitch) = aim_wants(mount, est, evel, lead_speed); let err = aim_error(spec.family, sam.yaw, sam.pitch, want_yaw, want_pitch); let vertical = spec.family == Family::Vertical; let mut fires = false; if let Some(load) = &spec.missile { - let reach = reach_blocks(load.min, load.max, 1.0, sam.pos, t); + let reach = reach_blocks(load.min, load.max, 1.0, sam.pos, est); v.push(check( reach == 0, format!( @@ -241,15 +301,18 @@ impl Sim { if mb & NO_ROUNDS != 0 { why.push("нет ракет".to_string()); } + if mb & LAUNCHER_DOWN != 0 { + why.push("пусковая отказала".to_string()); + } v.push(check(mb == 0, if mb == 0 { "пусковая готова".to_string() } else { format!("пусковая не готова: {}", why.join(", ")) })); - let aligned = missile_aligned(spec.family, sam.yaw, mount, Some(t), t.id, t.id, err); + let aligned = missile_aligned(spec.family, sam.yaw, mount, Some(est), t.id, t.id, err); let turn = if vertical { "вертикальный старт" } else { "доворот" }; v.push(check(aligned, format!("{turn}: ошибка наведения {:.0}°{}", err.to_degrees(), if aligned { "" } else { " — ещё доворачивается" }))); fires |= reach == 0 && free < need && mb == 0 && aligned && blocks == 0; } if let Some(gun) = &spec.gun { let on_it = guns_on.get(&t.id).copied().unwrap_or(0) - u32::from(sam.gun_target == Some(t.id)); - let cand = gun_candidate(sam.roles, &sam.seen, &sam.own, gun.track, sam.pos, t); + let cand = track.is_some_and(|tr| gun_candidate(sam.roles, tr, now, gun.track, sam.pos, t)); v.push(check( cand, format!( @@ -259,7 +322,7 @@ impl Sim { if cand { "" } else { " — вне сопровождения (нужна своя РЛС)" } ), )); - let gb = gun_blocks(sam, t.id, t.id, err, mount.distance(t.pos), gun.range); + let gb = gun_blocks(sam, t.id, t.id, err, mount.distance(est), gun.range, now); let mut why = Vec::new(); for (bit, name) in [ (AIM_ERROR, format!("орудие ещё наводится ({:.0}°)", err.to_degrees())), @@ -267,6 +330,8 @@ impl Sim { (COOLDOWN, "пауза между очередями".to_string()), (RELOADING, "перезарядка".to_string()), (NO_ROUNDS, "нет снарядов".to_string()), + (JAMMED, "орудие заклинило".to_string()), + (NO_LOCK, "нет свежего захвата своей РЛС".to_string()), ] { if gb & bit != 0 { why.push(name); diff --git a/docs/HD-RENDERER.md b/docs/HD-RENDERER.md index 7871709..3036092 100644 --- a/docs/HD-RENDERER.md +++ b/docs/HD-RENDERER.md @@ -155,6 +155,11 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс текстурах и анимация воды), а при ошибке теней было 8.5%. Сравни с `HORIZON_HD_TAA=0` / `HORIZON_HD_AO=0` / `HORIZON_HD_SHADOW_FAST=0`, чтобы найти виновника. +Графический полигон: `python3 tools/hd_bench.py` (`--list` — сцены; `--only polygon_noon` — одна сцена; `--env +HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home-bench/out/before` — diff ×6 и +пороги; `--budget-ms 18` — GPU-бюджет). Сам копирует `Polygon`/`Hills` в `run-home-bench/home`, сокет +`run-home-bench/c.sock`, одна игра за раз; отчёт `report.md` с таблицей, проходами GPU и кадрами. + Отладка: временно выводи промежуточные величины цветом в `fs_light` (так нашли пустую карту теней и неверное направление солнца). Помни: солнце в мире идёт по оси X (`(-sin a, cos a, 0)`), а `Globals.sun` неба — в своей плоскости YZ. @@ -180,6 +185,12 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс | Объёмные облака (рэймарч, мультирассеяние Photon, тени на землю, TAA-история, ванильные скрыты) | готово | патч 24 | | Объёмный туман (лучи через тени каскадов, высотная дымка, утренний туман, полразрешения) | готово | патч 25 | | API моделей по хэндлам (#30, уровень API 11: `model-id`, `model-generation`, `set-models-by-id`) | готово | патч 17 | +| #41: пасмурность неба и сила солнца берутся из мирового уровня дождя (`Sky.rain`, ставит `apply_weather`), а не из наличия геометрии осадков | готово | в рабочем дереве форка | +| #38: TAA без апскейла ищет историю по матрице прошлого кадра **с джиттером** (`HdScene.prev_view_proj_jittered`); `fs_taau` по-прежнему берёт матрицу без джиттера | готово | в рабочем дереве форка | +| #35: ванильные облака (HD-облака выключены) красятся закатным цветом в собственном проходе (`hd::vanilla_cloud_tint` → цвет в `clouds::Input`), эвристика по глубине G-буфера убрана из `fs_light` | готово | в рабочем дереве форка | +| #37: воздух (ванильный туман, aerial, объёмный туман, лучи, ореолы) на воде считается до **поверхности** и кладётся один раз поверх готовой воды: `fs_light` пишет во вторую цель `air` (рассеяние + пропускание), `fs_water` применяет её после поглощения | готово | в рабочем дереве форка | +| #36: ванильная вода не рисует цвет в HD (`fs_translucent` при `Globals.hd_flags.x`, глубину оставляет); прозрачное перед водой (`d` ближе `wd`) не отключает HD-воду: вода считается на освещённом дне и добавляется к сцене с весом 0.7; преломление не берёт тексели с таким прозрачным (`textureGather` по альфе) | готово (проверены стекло; частицы и scale 0.67 не проверялись) | в рабочем дереве форка | +| #39: веер восхода `vanilla_sunrise_weight` отсекается до цикла по 16 треугольникам выше горизонта (знак x стороны солнца и наклон ≤ α/3; проверено перебором 200 тыс. лучей без пропусков); `hd lighting` на рассвете 5.3 → 3.3 мс (M-серия, 2560×1440) | готово | в рабочем дереве форка | ## План (по порядку) и ТЗ @@ -257,3 +268,10 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс - **Тайминги.** Метки Metal (`HORIZON_SHADER_PACK_TIMING=1`, Hills, 2560×1440): `hd clouds` ≈ 6 мс (завышено: метка ловит ожидание соседних проходов), `hd cloud shadows` 0.2–3 (шум), `hd fog` ≈ 1.3. По среднему кадру GPU «оба выкл → оба вкл»: +0.2…3.7 мс (замеры шумные: рядом шла чужая игра на том же GPU). Как удешевить: у облаков — длина шага (14) и потолок шагов (26) в `fs_cloud`, 3 шага света, 4 октавы, облака в четверть разрешения; у тумана — `FOG_STEPS`. - **Разработка шейдеров.** `HORIZON_HD_SHADER_DIR=<папка>`: каждый HD-шейдер читается из `<папка>/<метка>.wgsl` (записывается из встроенного, если файла нет) — правки видны после перезапуска игры без пересборки; готовое переносится в константы вручную. - **Слабые места.** Облака не отражаются в воде (SSR берёт небо без облаков); не закрывают далёкую землю выше слоя (только небо); карта теней облаков ±1200 блоков и почти не работает при солнце ниже ~10°; туман не знает рельефа (только высоту над y=62); нет перистых облаков; на быстром повороте камеры история облаков отбрасывается (кратковременный шум). + +## Заметки по исправлениям #35–#41 (преемнику) + +- **Цели света.** `fs_light` пишет две цели: `lit` и `air` (`Rgba16Float`). Альфа `lit`: 1 — обычный пиксель, 0.5 — вода (в `lit` дно без воздуха, в `air` рассеяние воздуха до поверхности и пропускание в альфе), 0.6 — прозрачное перед водой (в `lit` сцена как нарисована, в `air.rgb` освещённое дно). Привязка 11 композита воды — `air`. +- **Ванильная вода в HD.** Пока `hd.water_active()`, `fs_translucent` не рисует цвет воды (альфа 0), но пишет её глубину; флаг — `Globals.hd_flags.x` (поле добавлено в Rust-`Globals`, `terrain.wgsl`, `entity.wgsl`; пишется после `hd.write` и в `hd.main_globals`). Вода без дна (`g = 0`) считается водой толщиной 48. +- **Не сделано.** Полупрозрачное перед водой берёт вес 0.7 на «вклад воды» (приближение: настоящий порядок «вода → прозрачное» потребовал бы разделить проход сцены). Частицы перед водой, `HORIZON_HD_SCALE=0.67`, поиск мерцания на рябь после этих правок не прогонялись. +- **`HORIZON_HD_TAA=0`.** На Polygon в полдень (`tp @s 40 76 40 135 15`) HD с выключенным TAA выглядит так же (дымка, небо, тени на месте), GPU 16.5 мс против 18.1 мс с TAA (минус ≈ проход TAA); потери вида и падения до 4.8 мс не воспроизвелись. В коде `HORIZON_HD_TAA` читают только `taa_enabled()` (джиттер, проход `taa`, история), остальные проходы от него не зависят. Если проявится снова — проверь, что до шота прошёл `/airdef shader hd` (при неготовой команде кадр без HD ~5 мс). diff --git a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch b/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch index d90577b..7c0bf8d 100644 --- a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch +++ b/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch @@ -1,7 +1,7 @@ From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:37:45 +0300 -Subject: [PATCH 01/24] Mod API (AirDef fork): world models, client particles, +Subject: [PATCH 01/25] Mod API (AirDef fork): world models, client particles, sounds, ground heights render.set-models draws block models with an interpolated pose per id; diff --git a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch index 5d2b4a6..96483c0 100644 --- a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch +++ b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch @@ -1,7 +1,7 @@ From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:39:45 +0300 -Subject: [PATCH 02/24] cargo-horizon-mod: report particles and sounds +Subject: [PATCH 02/25] cargo-horizon-mod: report particles and sounds Co-Authored-By: Claude Opus 5.5 --- diff --git a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch b/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch index 290a0c4..aa0f577 100644 --- a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch +++ b/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch @@ -1,7 +1,7 @@ From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 13:33:10 +0300 -Subject: [PATCH 03/24] Mod API (AirDef fork): full-screen post pass for mods +Subject: [PATCH 03/25] Mod API (AirDef fork): full-screen post pass for mods render.set-post compiles a mod's WGSL after a prelude (camera matrices, time, 256 vec4 of params, the world's colour and depth) and runs it after diff --git a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch index 1de27a2..3f8b7e0 100644 --- a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch +++ b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch @@ -1,7 +1,7 @@ From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:09:30 +0300 -Subject: [PATCH 04/24] Mod models: draw far models as scaled impostors past +Subject: [PATCH 04/25] Mod models: draw far models as scaled impostors past the far plane, cull at 4096 --- diff --git a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch index fb71040..ed566c5 100644 --- a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch +++ b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch @@ -1,7 +1,7 @@ From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:31:00 +0300 -Subject: [PATCH 05/24] Mod cameras: render.set-views draws the world from +Subject: [PATCH 05/25] Mod cameras: render.set-views draws the world from other points into pictures that HUD view shapes show (API level 9) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch b/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch index 9e06aa5..6994a4e 100644 --- a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch +++ b/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch @@ -1,7 +1,7 @@ From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:08:21 +0300 -Subject: [PATCH 06/24] Mods: more fuel per tick and per event (AirDef plans +Subject: [PATCH 06/25] Mods: more fuel per tick and per event (AirDef plans raids) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch b/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch index e217139..26234ff 100644 --- a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch +++ b/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch @@ -1,7 +1,7 @@ From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:25:29 +0300 -Subject: [PATCH 07/24] Mod cameras up to 1920x1080 (full-screen replays) +Subject: [PATCH 07/25] Mod cameras up to 1920x1080 (full-screen replays) Co-Authored-By: Claude Opus 5.5 --- diff --git a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch b/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch index 141bf50..f253d67 100644 --- a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch +++ b/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch @@ -1,7 +1,7 @@ From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 11:15:11 +0300 -Subject: [PATCH 08/24] Mod cameras: distant terrain tiles, fog measured from +Subject: [PATCH 08/25] Mod cameras: distant terrain tiles, fog measured from the camera, no impostors while one is shown Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch b/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch index 57b0677..3461de0 100644 --- a/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch +++ b/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch @@ -1,7 +1,7 @@ From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 11:25:45 +0300 -Subject: [PATCH 09/24] Mods: game.world-id, so mods keep state per world (API +Subject: [PATCH 09/25] Mods: game.world-id, so mods keep state per world (API level 9) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch b/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch index b49cf28..c2c204c 100644 --- a/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch +++ b/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch @@ -1,7 +1,7 @@ From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 12:01:08 +0300 -Subject: [PATCH 10/24] cargo-horizon-mod: trial runs behind feature full, so a +Subject: [PATCH 10/25] cargo-horizon-mod: trial runs behind feature full, so a packing-only tool skips wasmtime Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch b/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch index 5bbea76..4af1037 100644 --- a/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch +++ b/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch @@ -1,7 +1,7 @@ From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:18:43 +0300 -Subject: [PATCH 11/24] HD lighting: G-buffer pass, per-pixel mod point lights +Subject: [PATCH 11/25] HD lighting: G-buffer pass, per-pixel mod point lights in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API level 10) diff --git a/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch b/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch index 102b68d..f961a16 100644 --- a/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch +++ b/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch @@ -1,7 +1,7 @@ From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:59:43 +0300 -Subject: [PATCH 12/24] HD lighting stage 2: sun and moon shadow cascades, the +Subject: [PATCH 12/25] HD lighting stage 2: sun and moon shadow cascades, the world relit (sun by face angle and shadow, sky and block light through the lightmap), light shafts and halos, fog over it again, a light colour grade diff --git a/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch b/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch index df745bc..51fd635 100644 --- a/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch +++ b/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch @@ -1,7 +1,7 @@ From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:19:53 +0300 -Subject: [PATCH 13/24] HD: bind groups cached until a resize or a new source, +Subject: [PATCH 13/25] HD: bind groups cached until a resize or a new source, shadow passes draw only the entities that cast shadows Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch b/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch index 80d0b3d..b2c6412 100644 --- a/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch +++ b/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch @@ -1,7 +1,7 @@ From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:25:44 +0300 -Subject: [PATCH 14/24] HD: lights culled per 16x16 tile (surface and halo +Subject: [PATCH 14/25] HD: lights culled per 16x16 tile (surface and halo lists built on the CPU), a naga test for the lighting shaders Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch b/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch index 21c070b..55d8e7e 100644 --- a/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch +++ b/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch @@ -1,7 +1,7 @@ From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:43:36 +0300 -Subject: [PATCH 15/24] HD: GPU time per pass in the F3 timing list, +Subject: [PATCH 15/25] HD: GPU time per pass in the F3 timing list, half-resolution GTAO with a depth-aware blur Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch b/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch index 6364e32..755022f 100644 --- a/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch +++ b/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch @@ -1,7 +1,7 @@ From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:02:02 +0300 -Subject: [PATCH 16/24] HD: shadows draw only sun-facing quads and skip buried +Subject: [PATCH 16/25] HD: shadows draw only sun-facing quads and skip buried sections, the far cascade every second frame; TAA (Halton jitter, reprojection by depth, YCoCg variance clipping) before bloom diff --git a/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch b/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch index a7c605e..990cea8 100644 --- a/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch +++ b/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch @@ -1,7 +1,7 @@ From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:02:02 +0300 -Subject: [PATCH 17/24] Mods: model handles (render.model-id, model-generation, +Subject: [PATCH 17/25] Mods: model handles (render.model-id, model-generation, set-models-by-id; API level 11) so mods send no model names each tick; clear-post and set-lighting documented diff --git a/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch b/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch index 204f3c8..2a832fc 100644 --- a/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch +++ b/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch @@ -1,7 +1,7 @@ From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:19:57 +0300 -Subject: [PATCH 18/24] HD: temporal upscaling (HORIZON_HD_SCALE: world, +Subject: [PATCH 18/25] HD: temporal upscaling (HORIZON_HD_SCALE: world, G-buffer and lighting at a lower size, rebuilt to the window in the TAA pass); fix flickering shadows near the player (the near cascade lost its sections on frames the far one was held, the held far matrix ignored the diff --git a/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch b/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch index d0330ae..f6138a3 100644 --- a/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch +++ b/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch @@ -1,7 +1,7 @@ From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:47:52 +0300 -Subject: [PATCH 19/24] HD water: its own pass (colour, normal, depth), the +Subject: [PATCH 19/25] HD water: its own pass (colour, normal, depth), the floor under it lit, a composite with world-space waves, refraction with depth absorption in the biome colour, screen-space reflections, Fresnel and a shadowed sun glint (HORIZON_HD_WATER=0 to switch off) diff --git a/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch b/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch index fb29ce9..3044c26 100644 --- a/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch +++ b/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch @@ -1,7 +1,7 @@ From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:56:04 +0300 -Subject: [PATCH 20/24] HD water: no vanilla texture on the surface (the biome +Subject: [PATCH 20/25] HD water: no vanilla texture on the surface (the biome colour only), a darker, deeper colour Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch b/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch index 89eb17d..655097c 100644 --- a/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch +++ b/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch @@ -1,7 +1,7 @@ From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 18:38:19 +0300 -Subject: [PATCH 21/24] HD sky and light: a physical atmosphere (Rayleigh, Mie, +Subject: [PATCH 21/25] HD sky and light: a physical atmosphere (Rayleigh, Mie, ozone; constants and transmittance after Photon, MIT) for the sky, haze, fog and water reflections; warmer sunlight from the atmosphere's transmittance, a gentle sky-coloured ambient, soft PCSS-lite shadows near the player, a haze diff --git a/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch b/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch index 2601a5f..2941e5f 100644 --- a/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch +++ b/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch @@ -1,7 +1,7 @@ From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 19:02:16 +0300 -Subject: [PATCH 22/24] HD water: a darker blue, a little clearer, ripples a +Subject: [PATCH 22/25] HD water: a darker blue, a little clearer, ripples a little higher Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch b/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch index e20f24e..70d5c32 100644 --- a/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch +++ b/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch @@ -1,7 +1,7 @@ From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 21:41:48 +0300 -Subject: [PATCH 23/24] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun +Subject: [PATCH 23/25] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun tint, blue hour; MIT), volumetric clouds with ground shadows instead of vanilla's in HD, volumetric fog with sun shafts and morning mist (HORIZON_HD_CLOUDS=0, HORIZON_HD_VOLFOG=0) diff --git a/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch b/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch index 777c346..3a8c068 100644 --- a/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch +++ b/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch @@ -1,7 +1,7 @@ From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 21:43:22 +0300 -Subject: [PATCH 24/24] Mods: world queries for simulations (render.query-rays, +Subject: [PATCH 24/25] Mods: world queries for simulations (render.query-rays, query-sections, world-changes; API level 12): block-exact rays and section occupancy by collision, sight or sound, answered next tick within budgets, and a log of changed sections diff --git a/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch b/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch new file mode 100644 index 0000000..f79755d --- /dev/null +++ b/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch @@ -0,0 +1,992 @@ +From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001 +From: chexit +Date: Thu, 29 Oct 2026 22:56:15 +0300 +Subject: [PATCH 25/25] HD fixes: rain level from the world not from rain + geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint + without a depth heuristic (#35), air fog once over water (#37), translucent + things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick + air capped; mods: render.set-time-override (API level 13) so replays show + their recorded time of day (#57) + +Co-Authored-By: Claude Opus 5.5 +--- + crates/horizon-client/src/entity.wgsl | 2 + + crates/horizon-client/src/environment.rs | 5 + + crates/horizon-client/src/game.rs | 18 +- + crates/horizon-client/src/gfx.rs | 52 ++- + crates/horizon-client/src/gfx/hd.rs | 411 ++++++++++++++++------- + crates/horizon-client/src/gfx/hd_sky.rs | 8 + + crates/horizon-client/src/terrain.wgsl | 8 + + crates/horizon-mod-sdk/wit/horizon.wit | 5 + + crates/horizon-mods/src/api_level.rs | 6 +- + crates/horizon-mods/src/runtime.rs | 12 + + docs/api/mods-reference.md | 1 + + 11 files changed, 387 insertions(+), 141 deletions(-) + +diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl +index 61bc1f92a..b3fc9bc82 100644 +--- a/crates/horizon-client/src/entity.wgsl ++++ b/crates/horizon-client/src/entity.wgsl +@@ -20,6 +20,8 @@ struct Globals { + crumbling_sprite: vec4, + // Selection outline parameters (read by terrain.wgsl). + outline_params: vec4, ++ // AirDef fork. x: the HD water draws the water (fs_translucent leaves vanilla's out). ++ hd_flags: vec4, + // Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs). + dynamic_lights: array, 16>, + } +diff --git a/crates/horizon-client/src/environment.rs b/crates/horizon-client/src/environment.rs +index 20855b749..9b8acf13f 100644 +--- a/crates/horizon-client/src/environment.rs ++++ b/crates/horizon-client/src/environment.rs +@@ -141,6 +141,9 @@ pub struct Sky { + pub fog_end: f32, + /// `visual/cloud_fog_end_distance`. + pub cloud_fog_end: f32, ++ /// AirDef fork: the world's rain level (0..1), set by `apply_weather`. The HD sky reads this ++ /// rather than the presence of rain geometry near the camera. ++ pub rain: f32, + } + impl Environment { + pub fn load(root: &Path) -> Result { +@@ -305,6 +308,7 @@ impl Environment { + .base + .get("minecraft:visual/fog_end_distance") + .map_or(1024.0, |v| v[0]), ++ rain: 0.0, + cloud_fog_end: self + .base + .get("minecraft:visual/cloud_fog_end_distance") +@@ -315,6 +319,7 @@ impl Environment { + /// WeatherAttributes: rain then thunder layers lerped by their levels + /// (rain counts only the part not covered by thunder). + pub fn apply_weather(sky: &mut Sky, rain: f32, thunder: f32) { ++ sky.rain = rain.clamp(0.0, 1.0); + let grey = |c: [f32; 3]| c[0] * 0.3 + c[1] * 0.59 + c[2] * 0.11; + let lerp3 = |t: f32, a: [f32; 3], b: [f32; 3]| -> [f32; 3] { + std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t) +diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs +index 366be39c4..fad8de187 100644 +--- a/crates/horizon-client/src/game.rs ++++ b/crates/horizon-client/src/game.rs +@@ -8370,6 +8370,14 @@ impl Game<'_> { + .collect() + } + ++ /// The time of day the player sees: a mod's override (a replay's recorded time), else the world's. ++ fn shown_day_time(&self) -> i64 { ++ self.mods ++ .mods ++ .time_override() ++ .map_or(self.world.day_time, |t| t as i64) ++ } ++ + fn sky_darken(&self) -> i32 { + if self.dim == Dim::Nether { + return 11; +@@ -8378,12 +8386,12 @@ impl Game<'_> { + let sky = self + .res + .end_environment() +- .sample(self.world.day_time, None, None); ++ .sample(self.shown_day_time(), None, None); + return (15.0 - 15.0 * sky.sky_light_level).round() as i32; + } + let (rain, thunder) = self.world.weather.levels(1.0); + // Sampled once per (time, weather): every mob asks each tick. +- let key = (self.world.day_time, rain.to_bits(), thunder.to_bits()); ++ let key = (self.shown_day_time(), rain.to_bits(), thunder.to_bits()); + if let Some((k, darken)) = self.sky_darken_memo.get() + && k == key + { +@@ -8392,7 +8400,7 @@ impl Game<'_> { + let mut sky = self + .res + .environment() +- .sample(self.world.day_time, None, None); ++ .sample(self.shown_day_time(), None, None); + horizon_client::environment::apply_weather(&mut sky, rain, thunder); + // Level.updateSkyBrightness: darkening from the sky light level. + let darken = (15.0 - 15.0 * sky.sky_light_level).round() as i32; +@@ -8898,7 +8906,7 @@ impl Game<'_> { + "first_quarter", + "waxing_gibbous", + ]; +- let phase = phases[(self.world.day_time / 24000).rem_euclid(8) as usize]; ++ let phase = phases[(self.shown_day_time() / 24000).rem_euclid(8) as usize]; + body( + &format!("environment/celestial/moon/{phase}"), + sky.moon_angle, +@@ -11608,7 +11616,7 @@ impl Game<'_> { + ); + // visual/water_fog_color: no dimension sets it, its default is #050533. + let water_fog = blend(|b| Some(b.water_fog), [0.0; 3]); +- let mut sky = environment.sample(self.world.day_time, Some(biome_sky), Some(biome_fog)); ++ let mut sky = environment.sample(self.shown_day_time(), Some(biome_sky), Some(biome_fog)); + let (rain, thunder) = if self.dim == Dim::Overworld { + self.world.weather.levels(alpha as f32) + } else { +diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs +index 072090e33..a8031c1f6 100644 +--- a/crates/horizon-client/src/gfx.rs ++++ b/crates/horizon-client/src/gfx.rs +@@ -194,6 +194,9 @@ struct Globals { + crumbling_sprite: [f32; 4], + /// Framebuffer size, selection stroke width, perspective layering scale. + outline_params: [f32; 4], ++ /// AirDef fork. x: 1 while the HD water draws the water (fs_translucent then leaves vanilla's ++ /// water out of the colour and keeps its depth). ++ hd_flags: [f32; 4], + /// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`). + dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX], + } +@@ -578,7 +581,7 @@ pub struct Renderer { + hd_far: Option<(Mat4, glam::DVec3, Vec3, u32)>, + /// HD's TAA: frames jittered so far, and the last one's unjittered view-projection and camera. + taa_frame: u32, +- taa_prev: Option<(Mat4, glam::DVec3)>, ++ taa_prev: Option<(Mat4, glam::DVec3, Mat4)>, + hd: Option, + mod_params: (Vec, f32), + /// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`). +@@ -1207,17 +1210,15 @@ impl Renderer { + if sprite_arrays > 1 { + log::info!("Horizon: {layers} block textures in {sprite_arrays} arrays"); + } ++ // AirDef fork, HD water: the sprite layers terrain.wgsl's fs_water takes for water (and ++ // fs_translucent clears while the HD water draws it instead). + let sprite_constants = [ + ("SPRITE_ARRAYS", f64::from(sprite_arrays)), + ("SPRITE_SPLIT", f64::from(split)), +- ]; +- // AirDef fork, HD water: the sprite layers terrain.wgsl's fs_water takes for water. +- let water_constants = [ +- ("SPRITE_ARRAYS", f64::from(sprite_arrays)), +- ("SPRITE_SPLIT", f64::from(split)), + ("WATER_STILL", f64::from(water_layers[0])), + ("WATER_FLOW", f64::from(water_layers[1])), + ]; ++ let water_constants = sprite_constants; + let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Horizon terrain bindings"), + entries: &[ +@@ -2823,7 +2824,7 @@ impl Renderer { + mip_levels, + mipmaps: mip_levels - 1, + terrain_layout: bindings, +- terrain_sprites: sprite_constants.map(|(_, v)| v), ++ terrain_sprites: [f64::from(sprite_arrays), f64::from(split)], + sprite_sampler, + lightmap_view, + lightmap_sampler, +@@ -4049,7 +4050,7 @@ impl Renderer { + }; + let taa_prev = std::mem::replace( + &mut self.taa_prev, +- taa.then_some((unjittered, frame.camera)), ++ taa.then_some((unjittered, frame.camera, view_proj)), + ); + if frame.title { + self.panorama.prepare( +@@ -4135,6 +4136,7 @@ impl Renderer { + frame.glint_ticks, + ], + outline_params: outline_params(self.config.width, self.config.height), ++ hd_flags: [0.0; 4], + dynamic_lights: { + let mut lights = [[0.0; 4]; crate::dynamic_light::MAX]; + for (to, from) in lights.iter_mut().zip(frame.dynamic_lights) { +@@ -4260,11 +4262,14 @@ impl Renderer { + taa: taa && taa_prev.is_some(), + jitter_px, + prev_view_proj: taa_prev.map_or(unjittered, |p| p.0), ++ prev_view_proj_jittered: taa_prev.map_or(view_proj, |p| p.2), + camera_delta: taa_prev.map_or(Vec3::ZERO, |p| (frame.camera - p.1).as_vec3()), + // The world's sun: vanilla turns the sky -90° about Y, so it crosses from east to west. + sun: Vec3::new(-sun_angle.sin(), sun_angle.cos(), 0.0), + dark: 1.0 - frame.sky.sky_light_factor, +- rain: if frame.weather.is_empty() { 0.0 } else { 0.7 }, ++ // The world's rain level (fades in and out with the weather), not the rain geometry ++ // near the camera: hiding the particles or standing under a roof leaves the sky alone. ++ rain: frame.sky.rain.clamp(0.0, 1.0) * 0.7, + fog_color, + fog, + sky: frame.skybox && !frame.end_sky && !frame.nether_lighting && !frame.underwater, +@@ -4827,7 +4832,20 @@ impl Renderer { + + shadow_quads + + translucent_particle_quads + + translucent_item_quads; +- let cloud_quads = if let Some(input) = &frame.clouds { ++ // AirDef fork: vanilla's clouds (HD's volumetric ones off) take the low sun's colour in their ++ // own colour, so a mountain or glass behind them does not change it. ++ let cloud_input = frame.clouds.as_ref().map(|c| { ++ if !hd_wanted || !hd_scene.sky || hd::clouds_enabled() { ++ return crate::clouds::Input { ..*c }; ++ } ++ let [r, g, b, a] = c.color; ++ let [tr, tg, tb] = hd::vanilla_cloud_tint(&hd_scene); ++ crate::clouds::Input { ++ color: [r * tr, g * tg, b * tb, a], ++ ..*c ++ } ++ }); ++ let cloud_quads = if let Some(input) = &cloud_input { + let (rebuilt, uniform) = + crate::spans::time("clouds", || self.cloud_mesh.prepare(input)); + let vertices = self.cloud_mesh.vertices.len() as u64; +@@ -5154,6 +5172,15 @@ impl Renderer { + if let Some(settings) = frame.hd { + hd.write(&self.queue, &hd_scene, frame.hd_lights, settings); + } ++ // vanilla's translucent water leaves the colour while the HD water draws it. ++ let hd_flags_at = std::mem::offset_of!(Globals, hd_flags) as u64; ++ let hd_flags: [f32; 4] = [if hd.water_active() { 1.0 } else { 0.0 }, 0.0, 0.0, 0.0]; ++ self.uploads.buffer( ++ &mut encoder, ++ &self.globals, ++ hd_flags_at, ++ bytemuck::bytes_of(&hd_flags), ++ ); + hd.set_timing(device, &self.queue, self.pass_timing); + hd.timing_begin(device, &self.queue); + // The sun's (or moon's) shadow cascades: the world's globals swapped for the light's. +@@ -5184,6 +5211,11 @@ impl Renderer { + } + self.queue + .write_buffer(&hd.main_globals, 0, bytemuck::bytes_of(&globals)); ++ self.queue.write_buffer( ++ &hd.main_globals, ++ hd_flags_at, ++ bytemuck::bytes_of(&hd_flags), ++ ); + // Facing the light only: the nearest surface to it is always one that does. + let toward = (hd_shadow_fast() && self.sides_on).then(|| shadow_dir * 1.0e5); + for (c, cascade) in hd_cascades.iter().enumerate() { +diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs +index 3b9daabf4..e84a618a9 100644 +--- a/crates/horizon-client/src/gfx/hd.rs ++++ b/crates/horizon-client/src/gfx/hd.rs +@@ -133,6 +133,10 @@ pub struct HdScene { + pub view_proj: Mat4, + /// Last frame's view-projection without jitter, and the camera's move since (TAA). + pub prev_view_proj: Mat4, ++ /// The same frame's matrix as it was drawn, jitter included. History kept on the jittered ++ /// pixel grid (`fs_taa`, scale 1) is looked up with this; `fs_taau` resolves the history on the ++ /// unjittered output grid and uses `prev_view_proj`. ++ pub prev_view_proj_jittered: Mat4, + pub camera_delta: glam::Vec3, + /// This frame is anti-aliased over time (the projection is jittered). + pub taa: bool, +@@ -403,31 +407,104 @@ fn fog_at(px: vec2, dist: f32) -> vec4 { + return sum / total; + } + ++struct LightOut { ++ // The lit colour; alpha 0.5 marks water (the floor under it, lit without any air), 0.6 an ++ // object in front of the water (the scene as drawn). ++ @location(0) lit: vec4, ++ // Water: the air between the camera and the surface (scattered light, transmittance); ++ // in front of the water: the floor lit (rgb); else nothing. ++ @location(1) air: vec4, ++} ++ ++// An opaque surface lit: the ambient, the sun and the mods' lights over its albedo. ++fn shade_surface(px: vec2, p: vec3, water: bool) -> vec3 { ++ let sun = frame.sun.xyz; ++ let sun_on = frame.sun.w; ++ let atmosphere = frame.sky_sun.w > 0.5; ++ let count = u32(frame.params.x); ++ let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u; ++ let surface_start = tiles[tile]; ++ let surface_all = tiles[tile + 1u] == 0xffffffffu; ++ let surface_count = select(tiles[tile + 1u], count, surface_all); ++ let n = normalize(textureLoad(normal_tex, px, 0).xyz); ++ let albedo = textureLoad(albedo_tex, px, 0); ++ let lm = textureLoad(light_tex, px, 0).xy; ++ // Vanilla's light (sky and blocks through the lightmap), shaded where the sun can't reach. ++ let base = textureSampleLevel(lightmap, linear, clamp(vec2(lm.y, lm.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2(0.5 / 16.0), vec2(15.5 / 16.0)), 0.0).rgb; ++ let block = textureSampleLevel(lightmap, linear, vec2(lm.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb; ++ let sky_reach = smoothstep(0.35, 0.95, lm.x); ++ var s = 1.0; ++ let ndl = dot(n, sun); ++ if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow_taps(p, n, select(1, 0, water)) * cloud_shadow(p); } else { s = 0.0; } ++ // Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds ++ // direct light by the face's angle. ++ let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach; ++ // Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on ++ // the mods' lights by half, not on the sun or on emission. ++ var occlusion = 1.0; ++ // (The water dims and blurs the floor under it: no occlusion there.) ++ if frame.ambient.w > 0.0 && !water { occlusion = ao_at(px, length(p)); } ++ let ao_ambient = mix(1.0, occlusion, frame.ambient.w); ++ let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5); ++ // The sky's light is bluish from above and carries the horizon's colour from the sides; ++ // faces turned up take more of it than the ones turned down. ++ var sky_tint = vec3(1.0); ++ if atmosphere { ++ let reach = smoothstep(0.25, 0.9, lm.x); ++ let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); ++ let shape = mix(0.66, 1.1, smoothstep(0.1, 1.0, up)); ++ let hue = mix(vec3(1.0), sky_light_tint(n), 0.4 * (1.0 - 0.5 * frame.sun_color.a)); ++ sky_tint = mix(vec3(1.0), hue * shape, reach); ++ } ++ var light = max(base * sky_tint * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct; ++ // The mods' lights. ++ for (var k = 0u; k < surface_count; k++) { ++ let l = lights[select(tiles[surface_start + k], k, surface_all)]; ++ let to = l.pos.xyz - p; ++ let dist = length(to); ++ if dist >= l.pos.w { continue; } ++ let window = pow(clamp(1.0 - pow(dist / l.pos.w, 4.0), 0.0, 1.0), 2.0); ++ let falloff = l.color.w / (dist * dist + 1.0) * window; ++ let k = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0); ++ light += l.color.rgb * falloff * k * ao_lights; ++ } ++ return albedo.rgb * light + albedo.rgb * albedo.a * 1.5; ++} ++ + @fragment +-fn fs_light(in: FullOut) -> @location(0) vec4 { ++fn fs_light(in: FullOut) -> LightOut { + let px = vec2(in.position.xy); + let scene = textureLoad(scene_color, px, 0).rgb; + let d = textureLoad(scene_depth, px, 0); + let g = textureLoad(gbuffer_depth, px, 0); +- // Water in front of the opaque world with nothing translucent over it: here the floor is lit +- // and the water composite lays the surface over it (it knows the pixel by the alpha 0.5). +- var water = false; +- if frame.water.x > 0.5 && g > 0.0 { +- let wd = textureLoad(water_depth, px, 0); +- water = wd > 0.0 && wd >= g && d - wd <= d * 0.002; +- } +- let dd = select(d, g, water); ++ // Water over the opaque world (or over nothing): the water composite lays the surface over ++ // the floor, which is lit here with no air. Something translucent in front of the water ++ // (`front`: nearer than its surface) keeps the scene as drawn; the floor goes beside it. ++ var wd = 0.0; ++ var water_here = false; ++ if frame.water.x > 0.5 { ++ wd = textureLoad(water_depth, px, 0); ++ water_here = wd > 0.0 && (g <= 0.0 || wd >= g); ++ } ++ let front = water_here && g > 0.0 && d - wd > d * 0.002; ++ let water = water_here && !front; ++ let has_floor = water && g > 0.0; ++ let dd = select(d, g, has_floor); + let ndc = vec4(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(dd, 0.000001), 1.0); + let h = frame.inverse_view_proj * ndc; + let p = h.xyz / h.w; + let view = normalize(p); ++ // Where the air ends: the water's surface over water, else the pixel itself. ++ var ps = p; ++ if water { ++ let hf = frame.inverse_view_proj * vec4(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(wd, 0.000001), 1.0); ++ ps = hf.xyz / hf.w; ++ } ++ let view_s = normalize(ps); + let sun = frame.sun.xyz; + let sun_on = frame.sun.w; + let count = u32(frame.params.x); + let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u; +- let surface_start = tiles[tile]; +- let surface_all = tiles[tile + 1u] == 0xffffffffu; +- let surface_count = select(tiles[tile + 1u], count, surface_all); + let halo_start = tiles[tile + 2u]; + let halo_all = tiles[tile + 3u] == 0xffffffffu; + let halo_count = select(tiles[tile + 3u], count, halo_all); +@@ -455,113 +532,81 @@ fn fs_light(in: FullOut) -> @location(0) vec4 { + c = c * cl.a + cl.rgb; + } + } +- // Clouds (vanilla's, drawn into the world with depth but not into the G-buffer): lit by the low +- // sun's colour at dusk and dawn instead of vanilla's flat white. +- if atmosphere && frame.clouds.z < 0.5 && !water && d > 0.0 && g <= 0.0 && p.y > 8.0 && dot(p, p) > 2304.0 { +- let low = 1.0 - smoothstep(0.05, 0.5, frame.sky_sun.y); +- c = scene * mix(vec3(1.0), mix(frame.sun_color.rgb, vec3(1.0), 0.45) * 1.1, low * 0.7 * (1.0 - frame.sky_extra.z)); +- } +- let surface = water || (d > 0.0 && abs(d - g) <= max(d, g) * 0.002); ++ // (Vanilla's clouds, when the volumetric ones are off, take the low sun's colour in their own ++ // pass: `vanilla_cloud_tint`, applied to their colour on the CPU.) ++ let surface = has_floor || (!water_here && d > 0.0 && abs(d - g) <= max(d, g) * 0.002); + if surface { +- let n = normalize(textureLoad(normal_tex, px, 0).xyz); +- let albedo = textureLoad(albedo_tex, px, 0); +- let lm = textureLoad(light_tex, px, 0).xy; +- // Vanilla's light (sky and blocks through the lightmap), shaded where the sun can't reach. +- let base = textureSampleLevel(lightmap, linear, clamp(vec2(lm.y, lm.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2(0.5 / 16.0), vec2(15.5 / 16.0)), 0.0).rgb; +- let block = textureSampleLevel(lightmap, linear, vec2(lm.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb; +- let sky_reach = smoothstep(0.35, 0.95, lm.x); +- var s = 1.0; +- let ndl = dot(n, sun); +- if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow_taps(p, n, select(1, 0, water)) * cloud_shadow(p); } else { s = 0.0; } +- // Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds +- // direct light by the face's angle. +- let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach; +- // Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on +- // the mods' lights by half, not on the sun or on emission. +- var occlusion = 1.0; +- // (The water dims and blurs the floor under it: no occlusion there.) +- if frame.ambient.w > 0.0 && !water { occlusion = ao_at(px, length(p)); } +- let ao_ambient = mix(1.0, occlusion, frame.ambient.w); +- let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5); +- // The sky's light is bluish from above and carries the horizon's colour from the sides; +- // faces turned up take more of it than the ones turned down. +- var sky_tint = vec3(1.0); +- if atmosphere { +- let reach = smoothstep(0.25, 0.9, lm.x); +- let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); +- let shape = mix(0.66, 1.1, smoothstep(0.1, 1.0, up)); +- let hue = mix(vec3(1.0), sky_light_tint(n), 0.4 * (1.0 - 0.5 * frame.sun_color.a)); +- sky_tint = mix(vec3(1.0), hue * shape, reach); +- } +- var light = max(base * sky_tint * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct; +- // The mods' lights. +- for (var k = 0u; k < surface_count; k++) { +- let l = lights[select(tiles[surface_start + k], k, surface_all)]; +- let to = l.pos.xyz - p; +- let dist = length(to); +- if dist >= l.pos.w { continue; } +- let window = pow(clamp(1.0 - pow(dist / l.pos.w, 4.0), 0.0, 1.0), 2.0); +- let falloff = l.color.w / (dist * dist + 1.0) * window; +- let k = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0); +- light += l.color.rgb * falloff * k * ao_lights; +- } +- c = albedo.rgb * light + albedo.rgb * albedo.a * 1.5; +- // Vanilla's fog over it again. +- let sph = length(p); +- let cyl = max(length(p.xz), abs(p.y)); +- let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w)); +- let haze = haze_colour(view); +- c = mix(c, haze, f * frame.fog_color.a); +- // Aerial perspective: far things take the horizon's colour a little before the fog does. +- if atmosphere && frame.fog.x > 1.0e6 { +- c = mix(c, haze, (1.0 - exp(-sph / 700.0)) * 0.5); ++ c = shade_surface(px, p, water); ++ // Vanilla's fog over it again (the water composite does it at the surface). ++ if !water { ++ let sph = length(p); ++ let cyl = max(length(p.xz), abs(p.y)); ++ let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w)); ++ let haze = haze_colour(view); ++ c = mix(c, haze, f * frame.fog_color.a); ++ // Aerial perspective: far things take the horizon's colour a little before the fog does. ++ if atmosphere && frame.fog.x > 1.0e6 { ++ c = mix(c, haze, (1.0 - exp(-sph / 700.0)) * 0.5); ++ } + } + } +- // The volumetric fog: the light the air scatters towards the camera (shafts through the trees, +- // mist, haze), and how much of the pixel it hides. +- let far = select(min(length(p), frame.shadow.z), frame.shadow.z, d <= 0.0); ++ // What lies behind a translucent thing in front of the water: the floor, lit. ++ var floor_lit = vec3(0.0); ++ if front { ++ let hg = frame.inverse_view_proj * vec4(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(g, 0.000001), 1.0); ++ floor_lit = shade_surface(px, hg.xyz / hg.w, true); ++ } ++ // The air between the camera and the pixel (the water's surface over water): the volumetric ++ // fog (what it hides, what it scatters), the sun's rays between the shadows, and the halos ++ // round the mods' lights. Over water they go to the composite, which lays them over the ++ // finished water; elsewhere over the pixel here. ++ var air_t = 1.0; ++ var air_s = vec3(0.0); ++ let far = select(min(length(ps), frame.shadow.z), frame.shadow.z, d <= 0.0); + let density = frame.ambient.z; + let volumetric = frame.vfog.x > 0.5 && atmosphere; + if volumetric { + var fog_len = 192.0; +- if d > 0.0 { +- var pf = p; +- if water { +- let hf = frame.inverse_view_proj * vec4(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(d, 0.000001), 1.0); +- pf = hf.xyz / hf.w; +- } +- fog_len = min(length(pf), 192.0); +- } ++ if d > 0.0 { fog_len = min(length(ps), 192.0); } + let fg = fog_at(px, fog_len); +- c = c * fg.a + fg.rgb; ++ air_t = fg.a; ++ air_s = fg.rgb; + } +- // Light in the air (without the volumetric fog): the sun's rays between the shadows; always the +- // halos round the mods' lights. + if !volumetric && sun_on > 0.0 && density > 0.0 { + let steps = 14; + let jitter = ign(in.position.xy); + var acc = 0.0; + for (var i = 0; i < steps; i++) { + let t = (f32(i) + jitter) / f32(steps) * far; +- acc += lit_at(view * t); ++ acc += lit_at(view_s * t); + } + // Henyey-Greenstein, forward scattering: brightest looking towards the sun. +- let cos_t = dot(view, sun); ++ let cos_t = dot(view_s, sun); + let gk = 0.6; + let phase = (1.0 - gk * gk) / pow(1.0 + gk * gk - 2.0 * gk * cos_t, 1.5) * 0.08; +- c += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density)); ++ air_s += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density)); + } + for (var k = 0u; k < halo_count; k++) { + let l = lights[select(tiles[halo_start + k], k, halo_all)]; + // Nearest point of the view ray to the light, and the glow along the ray in front of it. +- let t0 = dot(l.pos.xyz, view); +- let hh = length(l.pos.xyz - view * t0) + 0.5; ++ let t0 = dot(l.pos.xyz, view_s); ++ let hh = length(l.pos.xyz - view_s * t0) + 0.5; + if hh > l.pos.w * 0.5 { continue; } + let a0 = atan((0.0 - t0) / hh); + let a1 = atan((far - t0) / hh); +- c += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + density * 300.0); ++ // Thicker air glows more round a light, but only so far: the mist at dusk and many fires ++ // at once would otherwise wash the picture out. ++ air_s += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + min(density * 300.0, 0.9)); ++ } ++ var out: LightOut; ++ if water { ++ out.lit = vec4(c, 0.5); ++ out.air = vec4(air_s, air_t); ++ } else { ++ out.lit = vec4(c * air_t + air_s, select(1.0, 0.6, front)); ++ out.air = vec4(floor_lit, 0.0); + } +- return vec4(c, select(1.0, 0.5, water)); ++ return out; + } + "#; + +@@ -580,6 +625,9 @@ const REFLECT_RANGE: f32 = 96.0; + @group(0) @binding(8) var lightmap: texture_2d; + @group(0) @binding(9) var linear: sampler; + //SKY10 ++// What the lighting left beside the lit world: over water the air in front of the surface (scattered ++// light, transmittance), in front of the water the lit floor. ++@group(0) @binding(11) var air_tex: texture_2d; + + fn ign(px: vec2) -> f32 { + let p = px + vec2(5.588238 * frame.params.w); +@@ -761,6 +809,9 @@ fn fs_water(in: FullOut) -> @location(0) vec4 { + let px = vec2(in.position.xy); + let base = textureLoad(lit_tex, px, 0); + if base.a > 0.75 { return vec4(base.rgb, 1.0); } ++ // Alpha 0.6: something translucent stands in front of the water here (base is the scene). ++ let front = base.a > 0.55; ++ let aux = textureLoad(air_tex, px, 0); + let wd = textureLoad(water_depth, px, 0); + let g = textureLoad(gbuffer_depth, px, 0); + let tint = textureLoad(water_tint, px, 0); +@@ -792,23 +843,35 @@ fn fs_water(in: FullOut) -> @location(0) vec4 { + let volume_light = max(ambient * frame.ambient.x, block * frame.ambient.y) + frame.sun_color.rgb * sun_on * sunlit * 0.8; + + // Refraction: where the ray through the waves meets the world, against where the flat surface +- // would send it (so still water looks as vanilla's and only the waves distort). +- let floor0 = position_at(in.uv, g); +- let depth0 = max(length(floor0) - dist, 0.0); +- let bent = refract(-v, n, 0.75) - refract(-v, geometric, 0.75); ++ // would send it (so still water looks as vanilla's and only the waves distort). Not where ++ // something translucent is in front of the water, nor onto texels that hold such a thing. ++ let has_floor = g > 0.0; + var uv_floor = in.uv; + var floor_z = g; +- let moved = project(floor0 + bent * min(depth0, 24.0)); +- if moved.z > 0.0 && on_screen(moved.xy) { +- let z = textureLoad(gbuffer_depth, texel(moved.xy), 0); +- // Only what lies behind the water: not the bank or an entity in front of it. +- if z > 0.0 && z <= wd { +- uv_floor = moved.xy; +- floor_z = z; ++ if has_floor && !front { ++ let floor0 = position_at(in.uv, g); ++ let depth0 = max(length(floor0) - dist, 0.0); ++ let bent = refract(-v, n, 0.75) - refract(-v, geometric, 0.75); ++ let moved = project(floor0 + bent * min(depth0, 24.0)); ++ if moved.z > 0.0 && on_screen(moved.xy) { ++ let z = textureLoad(gbuffer_depth, texel(moved.xy), 0); ++ // The four texels the filter blends: none may be a pixel with a translucent thing ++ // in front of the water. ++ let marks = textureGather(3, lit_tex, linear, moved.xy); ++ let tainted = any((marks > vec4(0.55)) & (marks < vec4(0.75))); ++ // Only what lies behind the water: not the bank or an entity in front of it. ++ if z > 0.0 && z <= wd && !tainted { ++ uv_floor = moved.xy; ++ floor_z = z; ++ } + } + } +- let thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0); +- let behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb; ++ var thickness = 48.0; ++ if has_floor { ++ thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0); ++ } ++ var behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb; ++ if front { behind = aux.rgb; } + // A deep, dark water: the biome's colour darkened and a little greyed, so it reads as + // depth rather than as bright paint. + let biome = clamp(tint.rgb, vec3(0.03), vec3(1.0)); +@@ -840,6 +903,13 @@ fn fs_water(in: FullOut) -> @location(0) vec4 { + if frame.sky_sun.w > 0.5 && frame.fog.x > 1.0e6 { + c = mix(c, haze, (1.0 - exp(-dist / 700.0)) * 0.5); + } ++ if front { ++ // The scene as drawn, with the water's change to the floor laid under the translucent thing. ++ c = base.rgb + (c - aux.rgb) * 0.7; ++ } else { ++ // The air between the camera and the surface: volumetric fog, rays, halos. ++ c = c * aux.a + aux.rgb; ++ } + return vec4(max(c, vec3(0.0)), 1.0); + } + "#; +@@ -1413,6 +1483,8 @@ pub struct Hd { + water_normal: wgpu::TextureView, + water_depth: wgpu::TextureView, + lit_water: wgpu::TextureView, ++ /// The lighting's second target: the air over water, the floor beside translucent things. ++ air: wgpu::TextureView, + water_layout: wgpu::BindGroupLayout, + water_pipeline: wgpu::RenderPipeline, + water_group: Option<((wgpu::TextureView, wgpu::Sampler), wgpu::BindGroup)>, +@@ -1849,6 +1921,7 @@ impl Hd { + FLOAT, + SAMPLER, + FLOAT, ++ UNFILTERED, + ]), + }); + let sky_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { +@@ -1961,15 +2034,39 @@ impl Hd { + }, + alpha: wgpu::BlendComponent::REPLACE, + }; +- let light_pipeline = fullscreen( +- device, +- "HD lighting", +- &light_layout, +- &light, +- "fs_light", +- HDR, +- None, +- ); ++ let light_pipeline = { ++ let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { ++ label: Some("HD lighting"), ++ bind_group_layouts: &[Some(&light_layout)], ++ ..Default::default() ++ }); ++ let target = Some(wgpu::ColorTargetState { ++ format: HDR, ++ blend: None, ++ write_mask: wgpu::ColorWrites::ALL, ++ }); ++ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { ++ label: Some("HD lighting"), ++ layout: Some(&layout), ++ vertex: wgpu::VertexState { ++ module: &light, ++ entry_point: Some("vs_full"), ++ compilation_options: Default::default(), ++ buffers: &[], ++ }, ++ fragment: Some(wgpu::FragmentState { ++ module: &light, ++ entry_point: Some("fs_light"), ++ compilation_options: Default::default(), ++ targets: &[target.clone(), target], ++ }), ++ primitive: Default::default(), ++ depth_stencil: None, ++ multisample: Default::default(), ++ multiview_mask: None, ++ cache: None, ++ }) ++ }; + let prefilter = fullscreen( + device, + "HD bloom prefilter", +@@ -2108,6 +2205,7 @@ impl Hd { + ) + .view, + lit: target(device, "HD lit", [1, 1], HDR).view, ++ air: target(device, "HD air", [1, 1], HDR).view, + fx: target(device, "HD post", [1, 1], HDR).view, + water_tint: target(device, "HD water colour", [1, 1], WATER_TINT).view, + water_normal: target(device, "HD water normal", [1, 1], WATER_NORMAL).view, +@@ -2199,6 +2297,7 @@ impl Hd { + self.levels = target(device, "HD light levels", size, LIGHT_LEVELS).view; + self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view; + self.lit = target(device, "HD lit", size, HDR).view; ++ self.air = target(device, "HD air", size, HDR).view; + self.fx = target(device, "HD post", size, HDR).view; + self.water_tint = target(device, "HD water colour", size, WATER_TINT).view; + self.water_normal = target(device, "HD water normal", size, WATER_NORMAL).view; +@@ -2322,7 +2421,12 @@ impl Hd { + 0.0 + }; + let frame = HdFrame { +- prev_view_proj: scene.prev_view_proj.to_cols_array_2d(), ++ prev_view_proj: if self.upscaling() { ++ scene.prev_view_proj ++ } else { ++ scene.prev_view_proj_jittered ++ } ++ .to_cols_array_2d(), + taa: [ + scene.camera_delta.x, + scene.camera_delta.y, +@@ -2685,15 +2789,28 @@ impl Hd { + return; + }; + self.water_composited = false; +- Self::draw( +- encoder, +- "HD lighting", +- &self.lit, +- false, +- &self.light_pipeline, +- group, +- stamp(&mut self.timing, "hd lighting"), +- ); ++ let clear = |view| { ++ Some(wgpu::RenderPassColorAttachment { ++ view, ++ resolve_target: None, ++ depth_slice: None, ++ ops: wgpu::Operations { ++ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), ++ store: wgpu::StoreOp::Store, ++ }, ++ }) ++ }; ++ { ++ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { ++ label: Some("HD lighting"), ++ timestamp_writes: stamp(&mut self.timing, "hd lighting"), ++ color_attachments: &[clear(&self.lit), clear(&self.air)], ++ ..Default::default() ++ }); ++ pass.set_pipeline(&self.light_pipeline); ++ pass.set_bind_group(0, group, &[]); ++ pass.draw(0..3, 0..1); ++ } + } + + /// A bind group of consecutive bindings 0.., counted (`groups_last_frame`). +@@ -2987,6 +3104,7 @@ impl Hd { + wgpu::BindingResource::TextureView(lightmap.0), + wgpu::BindingResource::Sampler(&self.sampler), + wgpu::BindingResource::TextureView(&self.sky_lut), ++ wgpu::BindingResource::TextureView(&self.air), + ], + ); + self.water_group = Some((key, group)); +@@ -3098,6 +3216,20 @@ pub fn light_of(scene: &HdScene) -> (glam::Vec3, f32, glam::Vec3) { + } + } + ++/// The factor on vanilla's cloud colour that lights the clouds with the low sun's colour at dusk ++/// and dawn instead of a flat white. It is the clouds' own colour (the cloud pass gets it), not a ++/// guess from the depth buffer, so what stands behind a cloud cannot change it. ++pub fn vanilla_cloud_tint(scene: &HdScene) -> [f32; 3] { ++ let sun = scene.sun.normalize_or(glam::Vec3::Y); ++ let low = 1.0 - smooth(0.05, 0.5, sun.y); ++ let overcast = (scene.rain / 0.7).clamp(0.0, 1.0) * 0.85; ++ let (_, _, color) = light_of(scene); ++ let warm = color.lerp(glam::Vec3::ONE, 0.45) * 1.1; ++ let t = low * 0.7 * (1.0 - overcast); ++ let tint = glam::Vec3::ONE.lerp(warm, t); ++ [tint.x, tint.y, tint.z] ++} ++ + /// The x of the sun's direction: positive in the morning, negative in the evening. + fn sun_dir_x(scene: &HdScene) -> f32 { + scene.sun.normalize_or(glam::Vec3::Y).x +@@ -3201,6 +3333,37 @@ mod tests { + assert_eq!(seen.len(), 16); + } + ++ #[test] ++ fn taa_history_lookup_follows_the_jittered_grid() { ++ // A static point under two frames' different Halton jitters: fs_taa finds this frame's ++ // point in the history by the previous frame's matrix *as drawn*, which lands on the pixel ++ // the previous frame wrote it to; the unjittered one is off by that frame's jitter. ++ let size = [1920u32, 1080]; ++ let unjittered = Mat4::perspective_rh(1.0, 16.0 / 9.0, 0.05, 1000.0); ++ let to_uv = |m: Mat4, p: glam::Vec3| { ++ let c = m * p.extend(1.0); ++ [c.x / c.w * 0.5 + 0.5, 0.5 - c.y / c.w * 0.5] ++ }; ++ let drawn = |frame: u32| { ++ let j = jitter(frame, size, 16); ++ Mat4::from_translation(glam::Vec3::new(j[0], j[1], 0.0)) * unjittered ++ }; ++ let p = glam::Vec3::new(0.0, 0.0, -20.0); ++ for frame in 1..16 { ++ let prev = drawn(frame - 1); ++ // The pixel (in uv) the previous frame drew the point at: what the history holds there. ++ let wrote = to_uv(prev, p); ++ let old = to_uv(unjittered, p); ++ let err = [ ++ (old[0] - wrote[0]).abs() * size[0] as f32, ++ (old[1] - wrote[1]).abs() * size[1] as f32, ++ ]; ++ let pj = jitter(frame - 1, size, 16); ++ assert!((err[0] - pj[0].abs() * size[0] as f32 * 0.5).abs() < 1e-3); ++ assert!((err[1] - pj[1].abs() * size[1] as f32 * 0.5).abs() < 1e-3); ++ } ++ } ++ + #[test] + fn jitter_covers_the_upscale_ratio() { + assert_eq!(jitter_count(1.0), 16); +diff --git a/crates/horizon-client/src/gfx/hd_sky.rs b/crates/horizon-client/src/gfx/hd_sky.rs +index ec55de4c8..dbe8c8853 100644 +--- a/crates/horizon-client/src/gfx/hd_sky.rs ++++ b/crates/horizon-client/src/gfx/hd_sky.rs +@@ -283,6 +283,14 @@ fn vanilla_sunrise_weight(d: vec3) -> f32 { + let theta = radians(flip + 90.0); + let c = cos(theta); + let s = sin(theta); ++ // Above the horizon the fan reaches only the sun's side (x of the same sign as the apex's, -s) ++ // and rises at most alpha/3 per block of that distance (its rim stands 40*alpha high over a ++ // 120-block radius and the points above the horizon lie at least 120 * the share of the rim ++ // from the axis); anything else cannot hit it, so most of the sky skips the 16 triangles. ++ if d.y > 0.0 { ++ let side = -s * d.x; ++ if side <= 0.0 || d.y > alpha * (1.0 / 3.0) * side * 1.02 + 0.001 { return 0.0; } ++ } + var best = 0.0; + let apex = vanilla_rotate_x90(vec3(-100.0 * s, 100.0 * c, 0.0)); + for (var i = 0; i < 16; i++) { +diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl +index 0a2a6cdba..cb098a64a 100644 +--- a/crates/horizon-client/src/terrain.wgsl ++++ b/crates/horizon-client/src/terrain.wgsl +@@ -22,6 +22,8 @@ struct Globals { + // Atlas rect of the current destroy stage (read by entity.wgsl). + crumbling_sprite: vec4, + outline_params: vec4, ++ // AirDef fork. x: the HD water draws the water (fs_translucent leaves vanilla's out). ++ hd_flags: vec4, + // Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs). + dynamic_lights: array, 16>, + } +@@ -211,6 +213,12 @@ struct WaterOut { + @fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4 { + let color = sprite(in.uv, in.layer & 0x7fffu) * in.color; + if color.a < 0.01 { discard; } ++ // The HD water composite draws the water itself: vanilla's stays out of the colour (alpha 0 ++ // blends to nothing) but keeps its depth, which the lighting reads. ++ let layer = in.layer & 0x7fffu; ++ if globals.hd_flags.x > 0.5 && (layer == WATER_STILL || layer == WATER_FLOW) { ++ return vec4(0.0); ++ } + return apply_fog(color, in.spherical, in.cylindrical); + } + +diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit +index 8411ac9aa..5fa5e8a27 100644 +--- a/crates/horizon-mod-sdk/wit/horizon.wit ++++ b/crates/horizon-mod-sdk/wit/horizon.wit +@@ -694,6 +694,11 @@ interface render { + /// Switches the HD lighting on or off for this mod's world, with its + /// exposure and bloom; off, the world is drawn as the game draws it. + set-lighting: func(lighting: lighting); ++ /// Shows the world at this time of day (AirDef fork): the sky, the sun ++ /// and moon, the sky light and the fog, as `day-time` ticks (24000 a day, ++ /// as `clock`). Only what the player sees changes: the world's own clock, ++ /// its saves and its rules do not. None shows the world's own time. ++ set-time-override: func(day-time: option); + } + + /// What the game calls. Only the hooks `init` asks for are ever called. +diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs +index 8cf350044..bc1ade067 100644 +--- a/crates/horizon-mods/src/api_level.rs ++++ b/crates/horizon-mods/src/api_level.rs +@@ -7,13 +7,13 @@ + //! version's level so the launcher can tell before it installs. + + /// The newest level this Horizon serves. +-pub const LEVEL: u32 = 12; ++pub const LEVEL: u32 = 13; + + /// Every function the game offers mods, as `interface.function`, with its + /// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new + /// function takes the next level and raises [`LEVEL`] (the `api_reference` + /// test fails until it is listed here). +-pub const FUNCTIONS: [(&str, u32); 66] = [ ++pub const FUNCTIONS: [(&str, u32); 67] = [ + ("log.write", 0), + ("game.get-player", 0), + ("game.get-clock", 0), +@@ -82,6 +82,8 @@ pub const FUNCTIONS: [(&str, u32); 66] = [ + ("render.query-sections", 12), + ("render.sections", 12), + ("render.world-changes", 12), ++ // AirDef fork: a replay shows its recorded time of day. ++ ("render.set-time-override", 13), + ]; + + /// What the game's program carries so the launcher can read its level +diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs +index 2189a99e1..fb6180f1c 100644 +--- a/crates/horizon-mods/src/runtime.rs ++++ b/crates/horizon-mods/src/runtime.rs +@@ -507,6 +507,8 @@ struct State { + /// `render.set-lights` and `render.set-lighting` (AirDef fork). + lights: Vec, + lighting: Option, ++ /// `render.set-time-override` (AirDef fork). ++ time_override: Option, + input: InputState, + browser: BrowserState, + /// Capability `storage`'s folder; made on first `set`. +@@ -749,6 +751,7 @@ impl Mods { + views: Vec::new(), + lights: Vec::new(), + lighting: None, ++ time_override: None, + input: InputState::default(), + browser: BrowserState::default(), + data_dir, +@@ -1334,6 +1337,12 @@ impl Mods { + .iter() + .find_map(|e| e.run.as_ref()?.store.data().lighting) + } ++ /// The time of day the first running mod asked to show, if any. ++ pub fn time_override(&self) -> Option { ++ self.entries ++ .iter() ++ .find_map(|e| e.run.as_ref()?.store.data().time_override) ++ } + /// The columns each running mod asked the ground height of, taken. + pub fn take_ground_queries(&mut self) -> Vec<(usize, Vec<(i32, i32)>)> { + self.entries +@@ -2237,6 +2246,9 @@ impl render::Host for State { + }; + self.lighting = Some(lighting); + } ++ fn set_time_override(&mut self, day_time: Option) { ++ self.time_override = day_time.map(|t| t.min(1 << 40)); ++ } + fn query_ground(&mut self, columns: Vec<(i32, i32)>) { + let room = 512usize.saturating_sub(self.ground_queries.len()); + self.ground_queries.extend(columns.into_iter().take(room)); +diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md +index 2afb5bf62..b58f863c2 100644 +--- a/docs/api/mods-reference.md ++++ b/docs/api/mods-reference.md +@@ -646,6 +646,7 @@ The HD lighting (AirDef fork): the world lit per pixel by the mods' lights, in h + | `set-views` | `func(views: list)` | Replaces the mod's cameras: up to 8. A `view` shape shows one; the game renders up to three a frame, in turn. | + | `set-lights` | `func(lights: list)` | Replaces the mod's lights: up to 512, interpolated per id between the last two calls. | + | `set-lighting` | `func(lighting: lighting)` | Switches the HD lighting on or off for this mod's world, with its exposure and bloom; off, the world is drawn as the game draws it. | ++| `set-time-override` | `func(day-time: option)` | Shows the world at this time of day (AirDef fork): the sky, the sun and moon, the sky light and the fog, as `day-time` ticks (24000 a day, as `clock`). Only what the player sees changes: the world's own clock, its saves and its rules do not. None shows the world's own time. | + + ## `events` + +-- +2.54.0 (Apple Git-157) +