From 0f5c4136f3bbd2ae3ca06e0b4985f86882a76eb0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 15:19:32 +0300 Subject: [PATCH] Replays: smoke rebuilt from the recording, recorded objects and searchlights, film cut in true order, export sessions guarded, gun camera above water; export keeps its last frames; hd_bench no longer excuses strong flicker (#76-#84, fork patch 35) Co-Authored-By: Claude Opus 5.5 --- airdef-mod/src/film.rs | 270 +++++++---- airdef-mod/src/lib.rs | 85 +++- airdef-mod/src/replay.rs | 41 +- airdef-mod/src/sim/record.rs | 85 ++++ airdef-mod/src/sim/regress.rs | 63 +++ airdef-mod/src/smoke.rs | 215 ++++++--- ...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 | 2 +- ...dules-gfx-hd-one-Rust-file-and-one-W.patch | 2 +- ...scales-that-divide-the-camera-s-6553.patch | 2 +- ...oke-and-missile-trails-with-clear-tu.patch | 2 +- ...ited-by-depth-so-terrain-and-vehicle.patch | 2 +- ...rails-drawn-at-least-as-wide-as-a-gr.patch | 2 +- ...main-view-API-level-15-renders-the-s.patch | 2 +- ...mods-render.set-spot-lights-API-leve.patch | 2 +- ...g-polish-cloud-panorama-cirrus-terra.patch | 2 +- ...through-glass-and-leaves-rain-ripple.patch | 2 +- ...y-frame-in-flight-reaches-the-writer.patch | 446 ++++++++++++++++++ tools/hd_bench.py | 42 +- tools/test_hd_bench.py | 89 ++++ 43 files changed, 1197 insertions(+), 207 deletions(-) create mode 100644 horizon-patches/0035-Film-export-every-frame-in-flight-reaches-the-writer.patch create mode 100644 tools/test_hd_bench.py diff --git a/airdef-mod/src/film.rs b/airdef-mod/src/film.rs index 9797c33..04c7671 100644 --- a/airdef-mod/src/film.rs +++ b/airdef-mod/src/film.rs @@ -243,21 +243,42 @@ fn placements Rig>(mk: F) -> Vec { vec![mk(1.0, 0.0), mk(-1.0, 0.0), mk(1.0, 28.0), mk(-1.0, 28.0)] } -/// The shots of event `i`. -fn plan_event(rec: &Recording, i: usize) -> Vec { +/// Shortest shot worth cutting to, in replay ticks. +const MIN_SHOT: f64 = 8.0; + +/// The replay tick a shot ends at. +fn shot_end(s: &Shot) -> f64 { + s.table.last().copied().unwrap_or(0.0) +} + +/// The shots of event `i`, with replay time never running back: no shot starts before `floor` (the +/// end of the one before it, or of the previous event's last), a stage the real intervals leave no +/// room for is merged into its neighbour or left out, and what happens in the story comes in the +/// order it happened (a launch before its kill; an interceptor not before its first point). +fn plan_event(rec: &Recording, i: usize, floor: f64) -> Vec { let e = rec.events[i]; let te = e.at as f64; let o = rec.origin; let end = rec.len as f64; let pos = e.pos; let mut shots: Vec = Vec::new(); - let mut add = |shot: Shot| shots.push(shot); + let cur = std::cell::Cell::new(floor); + let mut add = |shot: Shot| { + cur.set(shot_end(&shot)); + shots.push(shot); + }; + // The interval a stage may use: not before the cursor, and long enough to be a shot. + let span = |a: f64, b: f64| -> Option<(f64, f64)> { + let a = a.max(0.0).max(cur.get()); + (b - a >= MIN_SHOT).then_some((a, b)) + }; // The slow moment: half speed into it, a tenth through it, back to normal after. let slow = |lead: f64, after: f64| -> Vec<(f64, f64)> { vec![(te - lead, 0.6), (te - 4.0, 0.15), (te + 6.0, 0.15), (te + 22.0, 0.6), (te + after, 1.0)] }; let kill_close = |from: DVec3, aim_id: Option, head: &str, sub: String| -> Shot { - let a = (te - 10.0).max(0.0); + // Never before the story so far; held to at least a few ticks. + let a = (te - 10.0).max(0.0).max(cur.get()); let b = (te + 48.0).min(end).max(a + 4.0); let variants = placements(|side, lift| { let s = across(pos, from) * side; @@ -272,8 +293,7 @@ fn plan_event(rec: &Recording, i: usize) -> Vec { Shot::new(a, b, &slow(10.0, 48.0), variants, head, &sub, i) }; let aftermath = |from: DVec3, head: &str| -> Option { - let a = (te + 48.0).min(end); - let b = (te + 150.0).min(end); + let (a, b) = span((te + 48.0).min(end), (te + 150.0).min(end))?; if b - a < 20.0 { return None; } @@ -295,60 +315,78 @@ fn plan_event(rec: &Recording, i: usize) -> Vec { let mt = missile.and_then(|m| rec.track(m).map(|t| (m, t.first as f64))); match mt { Some((m, tl)) => { - // The hero: the launcher, three-quarters, a little before the launch. - let a = (tl - 60.0).max(0.0); - let b = tl + 6.0; - let launcher = sp + DVec3::Y * 4.0; - let variants = placements(|side, lift| { - let dir = (pos - sp).with_y(0.0).normalize_or(DVec3::X); - let s = across(pos, sp) * side; - let base = sp - dir * 38.0 + s * 34.0; - Rig { - eye: Eye::Dolly(base + DVec3::Y * (7.0 + lift), base + s * 6.0 - dir * 4.0 + DVec3::Y * (9.0 + lift)), - aim: Aim::Point(launcher), - fov: (46.0, 40.0), - } - }); - add(Shot::new(a, b.max(a + 20.0), &[(a, 1.0)], variants, &name, &sub, i)); - // The launch, from the side, the camera low and the interceptor climbing away. - let a2 = b.max(a + 20.0); - let b2 = (a2 + 44.0).min(te - 20.0).max(a2 + 10.0); - let variants = placements(|side, lift| { - let s = across(pos, sp) * side; - Rig { eye: Eye::Static(sp + s * 46.0 + DVec3::Y * (3.0 + lift * 0.3)), aim: Aim::Track(m, sp), fov: (42.0, 24.0) } - }); - add(Shot::new(a2, b2, &[(a2, 1.0)], variants, "Пуск", "", i)); - // The flight, from behind the interceptor with the target ahead; long flights run faster. - let a3 = b2; - let b3 = te - 32.0; - if b3 - a3 > 14.0 { - let sp3 = (((b3 - a3) / 6.0) / 20.0).clamp(1.0, 4.0); - let variants = placements(|side, lift| Rig { - eye: Eye::Behind { track: m, back: 15.0, up: 3.5 + lift * 0.2, side: 5.0 * side }, - aim: Aim::Track(drone, pos), - fov: (56.0, 56.0), + // The facts, in order: the launcher waits, the interceptor leaves it at `tl`, it + // flies, it meets the target at `te`. Each stage gets the time the real flight + // has for it; what has none is merged into the one before, or left out. + let tl = tl.min(te); + // The hero: the launcher, three-quarters, until the launch. + if let Some((a, b)) = span(tl - 60.0, tl) { + let launcher = sp + DVec3::Y * 4.0; + let variants = placements(|side, lift| { + let dir = (pos - sp).with_y(0.0).normalize_or(DVec3::X); + let s = across(pos, sp) * side; + let base = sp - dir * 38.0 + s * 34.0; + Rig { + eye: Eye::Dolly(base + DVec3::Y * (7.0 + lift), base + s * 6.0 - dir * 4.0 + DVec3::Y * (9.0 + lift)), + aim: Aim::Point(launcher), + fov: (46.0, 40.0), + } }); - add(Shot::new(a3, b3, &[(a3, sp3)], variants, "Сближение", "", i)); + add(Shot::new(a, b, &[(a, 1.0)], variants, &name, &sub, i)); + } + // The flight proper runs from the launch to ten ticks before the kill (where the + // close-up begins); under 6 ticks of it there is no room for more than the close-up. + let from = tl.max(cur.get()); + let ke = te - 10.0; + let room = ke - from; + if room >= 6.0 { + let launch_len = (room * 0.4).clamp(6.0, 44.0); + let mut b2 = from + launch_len; + let rest = ke - b2; + // The launch, from the side, the camera low and the interceptor climbing away. + let launch_at = |b2: f64| { + let variants = placements(|side, lift| { + let s = across(pos, sp) * side; + Rig { eye: Eye::Static(sp + s * 46.0 + DVec3::Y * (3.0 + lift * 0.3)), aim: Aim::Track(m, sp), fov: (42.0, 24.0) } + }); + Shot::new(from, b2, &[(from, 1.0)], variants, "Пуск", "", i) + }; + if rest < 14.0 { + // Too short to fly on camera: the launch shot runs to the close-up. + b2 = ke; + add(launch_at(b2)); + } else { + add(launch_at(b2)); + // The flight, from behind the interceptor with the target ahead; long flights run faster. + let b3 = if rest >= 36.0 { te - 32.0 } else { ke }; + let sp3 = (((b3 - b2) / 6.0) / 20.0).clamp(1.0, 4.0); + let variants = placements(|side, lift| Rig { + eye: Eye::Behind { track: m, back: 15.0, up: 3.5 + lift * 0.2, side: 5.0 * side }, + aim: Aim::Track(drone, pos), + fov: (56.0, 56.0), + }); + add(Shot::new(b2, b3, &[(b2, sp3)], variants, "Сближение", "", i)); + // Ahead of the target, looking back at the interceptor closing. + if rest >= 36.0 { + let variants = placements(|side, lift| Rig { + eye: Eye::Ahead { track: drone, ahead: 48.0, up: 6.0 + lift * 0.3, side: 14.0 * side }, + aim: Aim::Mid(m, drone, pos), + fov: (46.0, 38.0), + }); + add(Shot::new(b3, ke, &[(b3, 0.6)], variants, "", "", i)); + } + } } - // Ahead of the target, looking back at the interceptor closing. - let a4 = te - 32.0; - let b4 = te - 10.0; - let variants = placements(|side, lift| Rig { - eye: Eye::Ahead { track: drone, ahead: 48.0, up: 6.0 + lift * 0.3, side: 14.0 * side }, - aim: Aim::Mid(m, drone, pos), - fov: (46.0, 38.0), - }); - add(Shot::new(a4.max(0.0), b4.max(a4 + 6.0), &[(a4, 0.6)], variants, "", "", i)); } None => { - // A gun kill: the gun's view of the target. - let a = (te - 110.0).max(0.0); - let b = te - 10.0; - let variants = placements(|side, lift| { - let s = across(pos, sp) * side; - Rig { eye: Eye::Static(sp + s * 14.0 + DVec3::Y * (5.0 + lift * 0.3)), aim: Aim::Track(drone, pos), fov: (34.0, 18.0) } - }); - add(Shot::new(a, b.max(a + 12.0), &[(a, 1.0)], variants, &name, &sub, i)); + // A gun kill: the gun's view of the target (if the recording has room before it). + if let Some((a, b)) = span(te - 110.0, te - 10.0) { + let variants = placements(|side, lift| { + let s = across(pos, sp) * side; + Rig { eye: Eye::Static(sp + s * 14.0 + DVec3::Y * (5.0 + lift * 0.3)), aim: Aim::Track(drone, pos), fov: (34.0, 18.0) } + }); + add(Shot::new(a, b, &[(a, 1.0)], variants, &name, &sub, i)); + } } } let high = (pos.y - sp.y).max(0.0); @@ -360,20 +398,21 @@ fn plan_event(rec: &Recording, i: usize) -> Vec { EventKind::Hit { drone, .. } | EventKind::Crash { drone, .. } => { let from = rec.track(drone).and_then(|t| t.at(o, te - 60.0)).unwrap_or(o); let head = title(rec, i); - let a = (te - 150.0).max(0.0); - let b = te - 55.0; - let variants = placements(|side, lift| Rig { - eye: Eye::Behind { track: drone, back: 16.0, up: 4.0 + lift * 0.2, side: 6.0 * side }, - aim: Aim::Point(pos), - fov: (55.0, 55.0), - }); - add(Shot::new(a, b.max(a + 16.0), &[(a, 1.4)], variants, &head, "", i)); - let a2 = te - 55.0; - let variants = placements(|side, lift| { - let s = across(pos, from) * side; - Rig { eye: Eye::Static(pos + s * 80.0 + DVec3::Y * (3.0 + lift * 0.3)), aim: Aim::Track(drone, pos), fov: (40.0, 28.0) } - }); - add(Shot::new(a2.max(0.0), (te - 10.0).max(a2 + 8.0), &[(a2, 0.8)], variants, "", "", i)); + if let Some((a, b)) = span(te - 150.0, te - 55.0) { + let variants = placements(|side, lift| Rig { + eye: Eye::Behind { track: drone, back: 16.0, up: 4.0 + lift * 0.2, side: 6.0 * side }, + aim: Aim::Point(pos), + fov: (55.0, 55.0), + }); + add(Shot::new(a, b, &[(a, 1.4)], variants, &head, "", i)); + } + if let Some((a2, b2)) = span(te - 55.0, te - 10.0) { + let variants = placements(|side, lift| { + let s = across(pos, from) * side; + Rig { eye: Eye::Static(pos + s * 80.0 + DVec3::Y * (3.0 + lift * 0.3)), aim: Aim::Track(drone, pos), fov: (40.0, 28.0) } + }); + add(Shot::new(a2, b2, &[(a2, 0.8)], variants, "", "", i)); + } let sub = match e.kind { EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => { format!("сила взрыва: {}", ["хлопок", "средний", "сильный", "тяжёлый", "мощнейший"][(power as usize).min(4)]) @@ -387,12 +426,13 @@ fn plan_event(rec: &Recording, i: usize) -> Vec { } EventKind::SamLost { .. } => { let from = pos + DVec3::new(300.0, 0.0, 0.0); - let a = (te - 70.0).max(0.0); - let variants = placements(|side, lift| { - let s = across(pos, from) * side; - Rig { eye: Eye::Dolly(pos + s * 70.0 + DVec3::Y * (12.0 + lift), pos + s * 52.0 + DVec3::Y * (9.0 + lift)), aim: Aim::Point(pos + DVec3::Y * 3.0), fov: (50.0, 44.0) } - }); - add(Shot::new(a, (te - 10.0).max(a + 10.0), &[(a, 1.0)], variants, &title(rec, i), "", i)); + if let Some((a, b)) = span(te - 70.0, te - 10.0) { + let variants = placements(|side, lift| { + let s = across(pos, from) * side; + Rig { eye: Eye::Dolly(pos + s * 70.0 + DVec3::Y * (12.0 + lift), pos + s * 52.0 + DVec3::Y * (9.0 + lift)), aim: Aim::Point(pos + DVec3::Y * 3.0), fov: (50.0, 44.0) } + }); + add(Shot::new(a, b, &[(a, 1.0)], variants, &title(rec, i), "", i)); + } add(kill_close(from, None, "Комплекс уничтожен", String::new())); if let Some(s) = aftermath(from, "") { add(s); @@ -400,14 +440,14 @@ fn plan_event(rec: &Recording, i: usize) -> Vec { } EventKind::Split { .. } => { let ground = DVec3::new(pos.x, o.y, pos.z); - let a = (te - 40.0).max(0.0); - let b = (te + 170.0).min(end); + let a = (te - 40.0).max(0.0).max(cur.get()); + let b = (te + 170.0).min(end).max(a + 20.0); let variants = placements(|side, lift| Rig { eye: Eye::Dolly(ground + DVec3::new(170.0 * side, 8.0 + lift, 40.0), ground + DVec3::new(150.0 * side, 14.0 + lift, 20.0)), aim: Aim::Point(pos.lerp(ground, 0.4)), fov: (60.0, 52.0), }); - add(Shot::new(a, b.max(a + 20.0), &[(a, 1.0), (te - 4.0, 0.3), (te + 40.0, 1.0)], variants, &title(rec, i), "", i)); + add(Shot::new(a, b, &[(a, 1.0), (te - 4.0, 0.3), (te + 40.0, 1.0)], variants, &title(rec, i), "", i)); } } shots @@ -421,8 +461,14 @@ impl Film { return None; } let mut shots: Vec = Vec::new(); + // Replay time only moves on through the film: an event's shots start where the last ended. + let mut floor = 0.0; for i in events { - shots.extend(plan_event(rec, i)); + let planned = plan_event(rec, i, floor); + if let Some(last) = planned.last() { + floor = shot_end(last); + } + shots.extend(planned); } let mut start = 0.0; for s in &mut shots { @@ -573,3 +619,67 @@ impl Film { format!("{} моментов, {} планов, {:.0} с", events.len(), self.shots.len(), self.len) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sim::record::{Event, What}; + use crate::sim::{DroneKind, MissileKind, SamKind}; + + /// A recording with one interception: the interceptor leaves at `launch`, the kill is at `kill`. + fn intercept(len: u32, launch: u32, kill: u32, missile: bool) -> Recording { + let mut rec = Recording::blank(len); + let pos = DVec3::new(0.0, 100.0, 300.0); + rec.sams.push((5, SamKind::S400, DVec3::new(0.0, 64.0, 0.0))); + rec.straight(1, What::Drone(DroneKind::Geran3), 0, kill + 20, DVec3::new(0.0, 100.0, 900.0), pos); + if missile { + rec.straight(2, What::Missile(MissileKind::M48n6, 5), launch, kill, DVec3::new(0.0, 66.0, 0.0), pos); + } + rec.events.push(Event { + at: kill, + pos, + kind: EventKind::Kill { drone: 1, kind: DroneKind::Geran3, sam: 5, sam_kind: SamKind::S400, sam_index: 0, missile: missile.then_some(2) }, + }); + rec + } + + /// (head, start, end) of each shot in replay ticks. + fn spans(rec: &Recording, floor: f64) -> Vec<(String, f64, f64)> { + plan_event(rec, 0, floor).iter().map(|s| (s.head.clone(), s.table[0], shot_end(s))).collect() + } + + // #77: the shots follow the real intervals: replay time never runs back, the launch is not after + // the kill, nothing about the interceptor is shown before its first point. + #[test] + fn a_short_interception_is_cut_in_the_order_it_happened() { + // The issue's case (flight 100 to 105), then 1, 5, 15 and 100+ ticks, the kill near the + // start and near the end of the recording, and a gun kill. + let cases = [(100, 105, true, 400), (100, 101, true, 400), (100, 105, true, 120), (100, 115, true, 400), (100, 260, true, 400), (3, 8, true, 400), (0, 4, true, 30), (0, 300, true, 300), (250, 255, false, 400), (5, 12, false, 400)]; + for (launch, kill, missile, len) in cases { + let rec = intercept(len, launch, kill, missile); + let sp = spans(&rec, 0.0); + assert!(!sp.is_empty(), "{launch}->{kill}"); + let mut prev_end = 0.0; + for (head, a, b) in &sp { + assert!(b >= a, "{launch}->{kill} {head}: {a}..{b}"); + assert!(*a >= prev_end - 1e-9, "{launch}->{kill}: {head} starts at {a}, before the shot before it ended at {prev_end}: {sp:?}"); + prev_end = *b; + if head == "Пуск" || head == "Сближение" { + assert!(*a >= launch as f64 - 1e-9 && *b <= kill as f64, "{launch}->{kill}: {head} {a}..{b} outside the flight"); + } + } + // The launch comes before the close-up of the kill, which holds the kill itself. + let close = sp.iter().position(|s| s.0 == "Поражение цели").expect("close-up"); + assert!(sp[..close].iter().all(|s| s.2 <= kill as f64 + 1e-9), "{launch}->{kill}: {sp:?}"); + assert!(sp[close].2 >= kill as f64 || sp[close].2 >= len as f64 - 1.0, "{launch}->{kill}: {sp:?}"); + } + } + + #[test] + fn a_second_event_does_not_run_the_film_back() { + let rec = intercept(600, 100, 300, true); + // The previous event's last shot ended late: this one starts there. + let sp = spans(&rec, 180.0); + assert!(sp.iter().all(|s| s.1 >= 180.0 - 1e-9), "{sp:?}"); + } +} diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index a96d131..641ab9b 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -81,6 +81,8 @@ struct AirDef { export: Option, /// The effects preset a film replaced: (shader, hd). film_prev: Option<(bool, bool)>, + /// Counts the replay/film sessions started; an export belongs to the one it began with. + session: u64, /// The world drawn beyond the render distance (replays and cameras see far off). far: bool, /// The game's log of block changes (`render.world-log-start`): a replay shows the world as @@ -130,11 +132,22 @@ enum Stage { Finishing, } struct Export { + /// The session (`AirDef::session`) whose film this export records. + session: u64, opts: ExportOpts, stage: Stage, ticks: u32, } +/// Whether a replay or film may be started now: not while an export records (stop it first). +fn switch_allowed(exporting: bool) -> Result<(), String> { + if exporting { + Err("Экспорт идёт: дождитесь конца или /airdef replay stop".into()) + } else { + Ok(()) + } +} + /// A sound heard later: when, what, where, and how loud and high it was. struct PendingSound { due: u64, @@ -426,13 +439,18 @@ impl AirDef { /// The searchlights' spot lights (HD): a cone from the head along its real direction, as far /// as the beam runs free; at most 16, the renderer draws the nearest. fn spots(&self) -> Vec { - if self.shown().replay { - return Vec::new(); - } - self.sim - .sams - .iter() - .filter_map(|s| s.spot_beam()) + let shown = self.shown(); + // A replay lights with the recording's searchlights, where and as they pointed. + let beams: Vec = if shown.replay { + match self.replay.as_ref().and_then(|r| self.sim.replay(r.rec).map(|rec| rec.beams(f64::from(shown.now)))) { + Some(b) => b, + None => Vec::new(), + } + } else { + self.sim.sams.iter().filter_map(|s| s.spot_beam()).collect() + }; + beams + .into_iter() .take(16) .map(|b| SpotLight { id: b.id, @@ -536,13 +554,11 @@ impl AirDef { if !shown.replay { return (self.sim.tick as f64, self.smoke.segments(&self.sim)); } - let blasts = std::mem::replace(&mut self.sim.blasts, shown.blasts); - let fires = std::mem::take(&mut self.sim.fires.list); - let tick = std::mem::replace(&mut self.sim.tick, shown.now as u64); - let segments = smoke::Smoke::default().segments(&self.sim); - self.sim.blasts = blasts; - self.sim.fires.list = fires; - self.sim.tick = tick; + // The recording alone, as a function of its time: the live Sim is not touched. + let segments = match self.sim.replay(self.replay.as_ref().map_or(0, |r| r.rec)) { + Some(rec) => smoke::replay_smoke(rec, f64::from(shown.now)), + None => Vec::new(), + }; (f64::from(shown.now), segments) } @@ -962,6 +978,7 @@ impl AirDef { /// Plays the last recorded raid: "map", "full", "reel" or (with `event`) a clip. fn start_replay(&mut self, what: &str, event: Option) -> Result<(), String> { + switch_allowed(self.export.is_some())?; let n = self.sim.replays.len(); if n == 0 { return Err("Повторов пока нет: запишется первый же налёт (когда небо очистится)".into()); @@ -980,7 +997,11 @@ impl AirDef { _ => replay::Mode::Map, }; self.say_block_log(rec); - self.replay = Some(replay::Player::new(rec.number, mode, &self.sim)); + let player = replay::Player::new(rec.number, mode, &self.sim); + // A film (its quality and effects) or another replay ends properly before this one. + self.end_session(); + self.session += 1; + self.replay = Some(player); self.grid = false; input::claim_wheel(true); Ok(()) @@ -991,6 +1012,12 @@ impl AirDef { render::capture_stop(true); say("Экспорт отменён"); } + self.end_session(); + } + + /// Ends whatever replay or film shows, on every path: the film quality, the effects, the + /// clean HUD, the views and the time and camera overrides go back to the live game's. + fn end_session(&mut self) { render::set_film_quality(false); if let Some((shader, hd)) = self.film_prev.take() { self.set_effects(shader, hd); @@ -1007,6 +1034,9 @@ impl AirDef { /// Plays the director's film of the last raid at the best quality: HD effects on (restored after). fn start_film(&mut self, which: &str, export: Option<&ExportOpts>) -> Result<(), String> { + if export.is_none() { + switch_allowed(self.export.is_some())?; + } let n = self.sim.replays.len(); if n == 0 { return Err("Повторов пока нет: запишется первый же налёт (когда небо очистится)".into()); @@ -1025,6 +1055,9 @@ impl AirDef { player.clean = true; player.limit = o.seconds; } + // What was shown ends first (its effects are remembered below, not lost). + self.end_session(); + self.session += 1; self.replay = Some(player); self.grid = false; input::claim_wheel(true); @@ -1068,13 +1101,20 @@ impl AirDef { let which = o.which.clone(); self.start_film(&which, Some(&o))?; say(&format!("Экспорт «{}»: {} fps, {}x{}; планирую кадры…", o.name, o.fps, o.size.0, o.size.1)); - self.export = Some(Export { opts: o, stage: Stage::Planning, ticks: 0 }); + self.export = Some(Export { session: self.session, opts: o, stage: Stage::Planning, ticks: 0 }); Ok(()) } /// Drives an export: plan, start the recorder, wait for its first frame, report, stop at the end. fn export_step(&mut self, r: &mut replay::Player) { let Some(ex) = &mut self.export else { return }; + if ex.session != self.session { + // The film this export recorded was replaced: the recorder stops, nothing else drives it. + render::capture_stop(true); + self.export = None; + say("Экспорт отменён: показ заменён другим"); + return; + } ex.ticks += 1; let st = render::capture_status(); let failed = |s: &render::CaptureState| s.phase == render::CapturePhase::Failed; @@ -1349,6 +1389,7 @@ impl Mod for AirDef { time_view: false, export: None, film_prev: None, + session: 0, smoke: Default::default(), far: true, moving: None, @@ -1913,3 +1954,15 @@ impl Mod for AirDef { } horizon_mod_sdk::export_mod!(AirDef); + +#[cfg(test)] +mod tests { + use super::*; + + /// #83: while an export records no other replay or film replaces it; after it, they may. + #[test] + fn nothing_replaces_a_running_export() { + assert!(switch_allowed(true).is_err()); + assert!(switch_allowed(false).is_ok()); + } +} diff --git a/airdef-mod/src/replay.rs b/airdef-mod/src/replay.rs index 91961db..d474a1c 100644 --- a/airdef-mod/src/replay.rs +++ b/airdef-mod/src/replay.rs @@ -13,6 +13,17 @@ use crate::sim::{Class, DroneKind, Out, Sim}; use glam::DVec3; use horizon_mod_sdk::render::{CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape}; +/// Metres the camera keeps above the surface (land or water) it stands over. +const SURFACE_CLEAR: f64 = 2.5; + +/// `eye` lifted above the surface under it (`Ground::land_state`: unanswered or no land: unchanged). +fn above_surface(eye: DVec3, land: Option>) -> DVec3 { + match land { + Some(Some(h)) if eye.y < h + SURFACE_CLEAR => DVec3::new(eye.x, h + SURFACE_CLEAR, eye.z), + _ => eye, + } +} + /// Ghost models' ids, clear of the live ones. const GHOSTS: u32 = 0x5000_0000; /// The camera view's id (a new one each cut, so the game doesn't sweep between shots). @@ -419,7 +430,7 @@ impl Player { return; } sim.draw_ghosts(rec, self.t, GHOSTS, out); - self.direct(rec); + self.direct(rec, ground); self.sounds(rec, listener, out); } @@ -565,7 +576,7 @@ impl Player { } /// The event the camera films now: (eye, look, fov, shot). - fn shoot(&mut self, rec: &Recording, i: usize) -> (DVec3, DVec3, f32, u8) { + fn shoot(&mut self, rec: &Recording, i: usize, ground: &mut Ground) -> (DVec3, DVec3, f32, u8) { let e = rec.events[i]; let t = self.t; let te = e.at as f64; @@ -602,6 +613,10 @@ impl Player { None => { // A gun kill, from the gun. let at = rec.sam(sam).map_or(e.pos + DVec3::Y * 20.0, |s| s.1 + DVec3::Y * 4.0); + // A system on the sea floor (or a lake's) must not film from under the water: + // the game's ground height is the top of the highest block that blocks motion + // or holds a fluid, i.e. the water's surface there. + let at = above_surface(at, ground.land_state(at)); let dist = target.distance(at).max(1.0); let fov = (2.0 * (10.0 / dist).atan()).to_degrees().clamp(6.0, 60.0) as f32; (at, target, fov, 1) @@ -630,7 +645,7 @@ impl Player { } /// Chooses the shot and moves the camera; a new shot is a cut. - fn direct(&mut self, rec: &Recording) { + fn direct(&mut self, rec: &Recording, ground: &mut Ground) { let focus = match &self.mode { Mode::Clips { list, at, .. } => Some(list[*at].event), // The full replay follows what comes next: the next event within 6 s, else the drone @@ -644,7 +659,7 @@ impl Player { }; let (eye, look, fov, shot) = match focus { Some(i) => { - let s = self.shoot(rec, i); + let s = self.shoot(rec, i, ground); (s.0, s.1, s.2, (i, s.3)) } None => { @@ -957,3 +972,21 @@ impl Player { Mode::Clips { list: vec![clip(rec, i)], at: 0, reel: false } } } + +#[cfg(test)] +mod surface_tests { + use super::*; + + /// A gun standing on the sea floor films from above the water, not from under it. + #[test] + fn the_gun_camera_is_lifted_above_the_water() { + let eye = DVec3::new(10.0, 44.0, 5.0); // 4 above a Gepard on the floor at 40 + let lifted = above_surface(eye, Some(Some(62.0))); // the surface + assert!(lifted.y >= 62.0 + SURFACE_CLEAR - 1e-9); + assert_eq!((lifted.x, lifted.z), (10.0, 5.0)); + // On dry land, or with no answer yet, the camera stays where it was. + assert_eq!(above_surface(DVec3::new(0.0, 74.0, 0.0), Some(Some(70.0))), DVec3::new(0.0, 74.0, 0.0)); + assert_eq!(above_surface(eye, None), eye); + assert_eq!(above_surface(eye, Some(None)), eye); + } +} diff --git a/airdef-mod/src/sim/record.rs b/airdef-mod/src/sim/record.rs index a0e0da6..8e85a8e 100644 --- a/airdef-mod/src/sim/record.rs +++ b/airdef-mod/src/sim/record.rs @@ -18,6 +18,8 @@ pub struct SamKey { pitch: f32, /// The antenna's turn. dish: f32, + /// A searchlight's beam: how far it runs free (to 8 blocks), 0 when it is off. + beam: f32, } impl SamKey { @@ -30,6 +32,7 @@ impl SamKey { yaw: sam.yaw as f32, pitch: sam.pitch as f32, dish: sam.dish as f32, + beam: if sam.beam_on { ((sam.beam_free / 8.0).round() * 8.0).max(8.0) as f32 } else { 0.0 }, } } /// The same pose (to within what could be seen). @@ -40,6 +43,7 @@ impl SamKey { && near(self.yaw, o.yaw, 0.002) && near(self.pitch, o.pitch, 0.002) && near(self.dish, o.dish, 0.002) + && self.beam == o.beam } } @@ -79,6 +83,7 @@ impl SamTrack { yaw: turn(a.yaw, b.yaw, f), pitch: l(a.pitch, b.pitch), dish: turn(a.dish, b.dish, f), + beam: a.beam, }) } /// The system as it stood at `t`, as a ghost to draw with the ids from `id`. @@ -89,11 +94,54 @@ impl SamTrack { sam.yaw = k.yaw as f64; sam.pitch = k.pitch as f64; sam.dish = k.dish as f64; + sam.beam_on = k.beam > 0.0; + sam.beam_free = k.beam as f64; sam.health = if self.lost.is_some_and(|l| t >= l as f64) { 0.0 } else { 1.0 }; Some(sam) } } +#[cfg(test)] +impl Recording { + /// A recording of `len` ticks with nothing in it (for the planner's tests). + pub(crate) fn blank(len: u32) -> Self { + Recording { + number: 1, + origin: DVec3::ZERO, + len, + tracks: Vec::new(), + index: HashMap::new(), + events: Vec::new(), + tally: Tally::default(), + sams: Vec::new(), + objects: Vec::new(), + sam_tracks: Vec::new(), + sam_index: HashMap::new(), + ticks: Vec::new(), + times: Vec::new(), + quiet: 0, + } + } + /// A straight track from `from` to `to` over the ticks `first..=last` (every `STEP`). + pub(crate) fn straight(&mut self, id: u32, what: What, first: u32, last: u32, from: DVec3, to: DVec3) { + let n = ((last - first) / STEP).max(1) as usize; + let points = (0..=n).map(|k| { + let p = from.lerp(to, k as f64 / n as f64); + [p.x as f32, p.y as f32, p.z as f32] + }); + self.index.insert(id, self.tracks.len()); + self.tracks.push(Track { id, what, first, points: points.collect(), fell: None }); + } +} + +impl Recording { + /// The searchlights' beams at `t` as the recording had them (position and direction from the + /// recorded poses, none for a lost light), never the live state. + pub fn beams(&self, t: f64) -> Vec { + self.sam_tracks.iter().filter_map(|st| st.ghost(self.origin, t, st.id)?.spot_beam()).collect() + } +} + /// Ticks between a track's points. pub const STEP: u32 = 2; /// The longest recording (10 minutes) and how many are kept. @@ -244,6 +292,10 @@ impl Track { _ => DVec3::NEG_Z, } } + /// Its `i`-th point (every `STEP` ticks from `first`), if it has one. + pub fn sample(&self, origin: DVec3, i: usize) -> Option { + self.points.get(i).map(|q| origin + DVec3::new(q[0] as f64, q[1] as f64, q[2] as f64)) + } /// The tick of its last point. pub fn last(&self) -> u32 { self.first + (self.points.len().max(1) as u32 - 1) * STEP @@ -587,6 +639,13 @@ impl Sim { units::draw_sam_model(&ghost, &mut rng, out); } } + // The protected objects as marked in the raid (struck ones shrink), not the live ones. + for (i, p) in rec.objects.iter().enumerate() { + let struck = rec.events.iter().any(|e| e.at as f64 <= t && matches!(e.kind, EventKind::Hit { object: Some(o), .. } if o == i)); + let bob = (t * 0.1 + i as f64).sin() * 0.25; + let rot = DQuat::from_rotation_y(t * 0.08); + out.model(base + 0x00e0_0000 + i as u32 * PARTS, "airdef:target", *p + DVec3::Y * (1.6 + bob), rot, if struck { 0.4 } else { 0.8 }, true); + } // Fires: what the events left burning (the flames dim and the smoke thickens with time), // out when their life ends. for f in replay_fires(rec, t) { @@ -673,6 +732,32 @@ mod tests { assert!((pose(100.0).pos[0] - 4.0).abs() < 1e-6); } + // #76: a replay's searchlights point where the recording had them, from the recorded pose. + #[test] + fn a_replay_has_the_recorded_searchlight_beams() { + let mut r = recording(); + let mut light = Sam::new(9, SamKind::Light, DVec3::new(10.0, 64.0, 0.0), 0.0); + light.beam_on = true; + light.beam_free = 300.0; + light.yaw = 1.0; + light.pitch = 0.8; + r.note_sams(&[light], 0); + // Swung round and switched off later. + let mut later = Sam::new(9, SamKind::Light, DVec3::new(10.0, 64.0, 0.0), 0.0); + later.yaw = 2.0; + later.pitch = 0.8; + r.note_sams(&[later], 100); + assert!(r.beams(-1.0).is_empty(), "nothing before it was seen"); + let b = r.beams(0.0); + assert_eq!(b.len(), 1); + assert_eq!(b[0].id, 9); + assert!((b[0].free - 304.0).abs() < 1e-6 || (b[0].free - 296.0).abs() < 1e-6, "free {}", b[0].free); + assert!(b[0].dir.y > 0.5, "pointing up: {:?}", b[0].dir); + assert!(r.beams(100.0).is_empty(), "switched off"); + r.sam_tracks[0].lost = Some(50); + assert!(r.beams(10.0).len() == 1 && r.beams(60.0).is_empty(), "a lost light has no beam"); + } + #[test] fn a_system_first_seen_later_is_not_drawn_before() { let mut r = recording(); diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index d0cb50b..0afa4e7 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -654,6 +654,69 @@ fn replay_fires_and_blasts_follow_the_recording() { assert!(record::replay_blasts(&rec, 10.0 + a[0].life as f64).is_empty(), "burnt out"); } +// #78: a replay's smoke is rebuilt from the recording as a function of its time: trails behind +// what flew (clearing the fog), a column over the fire, billows that outlive the fireball; a seek +// shows what playing through shows, and the live Sim is not touched. +#[test] +fn replay_smoke_has_trails_and_fire_columns_and_is_a_function_of_time() { + use record::{Event, EventKind}; + let mut sim = Sim::new(1); + sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 2000.0)); + sim.add_drone(DroneKind::Geran3, DVec3::new(30.0, 80.0, 0.0), DVec3::Z, DVec3::new(30.0, 70.0, 2000.0)); + let mut g = Ground::default(); + let mut out = Out::default(); + for _ in 0..160 { + sim.step(&mut g, None, &mut out); + } + let mut rec = sim.rec.take().unwrap(); + assert_eq!(rec.tracks.len(), 2); + rec.events = vec![Event { at: 40, pos: DVec3::new(5.0, 70.0, 5.0), kind: EventKind::Hit { drone: 1, kind: DroneKind::Geran3, power: 2, object: None, sam: None } }]; + let live_tick = sim.tick; + let live_fires = sim.fires.list.len(); + let t = 130.0; + let a = crate::smoke::replay_smoke(&rec, t); + let trails = a.iter().filter(|s| s.clear > 0.0).count(); + let columns = a.iter().filter(|s| s.soot > 0.8).count(); + let billows = a.iter().filter(|s| s.soot == 0.55).count(); + assert!(trails > 0, "trails of both tracks: {trails}"); + assert!(columns > 0, "a column over the fire: {columns}"); + assert!(billows > 0, "the blast's billows"); + // Seeking away and back, or forward from nothing, gives the same smoke. + let _ = crate::smoke::replay_smoke(&rec, 10.0); + let _ = crate::smoke::replay_smoke(&rec, 155.0); + let b = crate::smoke::replay_smoke(&rec, t); + assert_eq!(a.iter().map(|s| (s.id, s.born as u64)).collect::>(), b.iter().map(|s| (s.id, s.born as u64)).collect::>()); + // Nothing from the future; before the raid, nothing. + assert!(a.iter().all(|s| s.born <= t)); + assert!(crate::smoke::replay_smoke(&rec, -5.0).is_empty()); + // The billows go on long after the short fireball is out. + let late = crate::smoke::replay_smoke(&rec, 400.0); + assert!(late.iter().any(|s| s.soot == 0.55)); + // The live state is not touched. + assert_eq!((sim.tick, sim.fires.list.len()), (live_tick, live_fires)); +} + +// #76: the protected objects of a replay are the recording's, not the live marks. +#[test] +fn a_replay_draws_the_recorded_objects_not_the_live_ones() { + let mut sim = Sim::new(1); + sim.targets.push(DVec3::new(0.0, 70.0, 0.0)); + sim.target_health.push(100.0); + 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(); + sim.step(&mut g, None, &mut out); + let rec = sim.rec.take().unwrap(); + assert_eq!(rec.objects.len(), 1); + // The live mark is moved away after the raid; the replay still shows it where it was. + sim.targets[0] = DVec3::new(900.0, 70.0, 900.0); + let mut shown = Out::default(); + sim.draw_ghosts(&rec, 0.0, 0x1000, &mut shown); + let marks: Vec<_> = shown.models.iter().filter(|m| m.name == "airdef:target").collect(); + assert_eq!(marks.len(), 1); + assert!(marks[0].at.x.abs() < 1.0 && marks[0].at.z.abs() < 1.0, "{:?}", marks[0].at); +} + fn sim_first(r: &record::Recording) -> u64 { r.tracks.first().map_or(0, |t| t.first as u64) } diff --git a/airdef-mod/src/smoke.rs b/airdef-mod/src/smoke.rs index 2cb7920..320d50b 100644 --- a/airdef-mod/src/smoke.rs +++ b/airdef-mod/src/smoke.rs @@ -47,6 +47,150 @@ fn seg(id: u32, a: DVec3, b: DVec3, born: u64, life: u32) -> SmokeSegment { } } + +/// The column over one fire up to `tick`: a puff every 15 ticks (90 for the weaker fires) that +/// rises, spreads and thins. A pure function of the tick. +fn fire_column(out: &mut Vec, fire_id: u32, at: DVec3, start: u64, size: f32, shown: bool, tick: u64, level: &dyn Fn(u64) -> (f32, f32)) { + // Only the strongest fires get a full column; the rest a thin puff now and then. + let step = if shown { 15 } else { 90 }; + let first = start.max(tick.saturating_sub(520)); + let mut k = first.div_ceil(step) * step; + while k <= tick && out.len() < MAX_TOTAL { + let (flame, smoke) = level(k); + if smoke > 0.05 && k >= start { + let id = 0x4000_0000 | (fire_id.wrapping_mul(131) ^ (k / step) as u32) & 0x3fff_ffff; + let at = at + DVec3::Y * 0.6; + let mut s = seg(id, at, at, k, 540); + s.radius_start = 1.0 * size; + s.radius_end = 8.0 * size + 2.0; + s.density = 2.6 * smoke * size.sqrt() * if shown { 1.0 } else { 0.5 }; + s.heat = flame * 0.6; + s.soot = 0.85; + s.rise = 2.6; + out.push(s); + } + k += step; + } +} + +/// Blasts: a billow of puffs going up, for 900 ticks after each. +fn blast_billows(out: &mut Vec, blasts: &[crate::sim::Blast], tick: u64) { + for b in blasts { + let age = tick.saturating_sub(b.start); + if age > 900 { + continue; + } + for j in 0..6u64 { + let born = b.start + j * 3; + if born > tick { + break; + } + let h = b.size as f64 * (0.2 + 0.5 * j as f64); + let at = b.at + DVec3::Y * h; + let mut s = seg(0x5000_0000 | (b.id.wrapping_mul(7) + j as u32) & 0x0fff_ffff, at, at, born, 800); + s.radius_start = b.size * 0.6; + s.radius_end = b.size * 3.2 + 4.0; + s.density = 2.4 / (1.0 + j as f32 * 0.25); + s.heat = if j < 3 { 1.0 } else { 0.4 }; + s.soot = 0.55; + s.rise = 4.0 - 0.3 * j as f32; + out.push(s); + } + } +} + +fn style_drone(s: &mut SmokeSegment, class: Class) { + let (r0, r1, dens, heat, soot, clear, life): (f32, f32, f32, f32, f32, f32, f32) = match class { + Class::Drone => (0.9, 5.0, 2.0, 0.0, 0.45, 3.0, 1200.0), + Class::Jet | Class::Cruise => (1.1, 7.0, 2.5, 0.15, 0.35, 4.0, 1300.0), + Class::Supersonic => (1.4, 9.0, 3.2, 0.5, 0.2, 5.0, 1300.0), + Class::Ballistic => (2.0, 13.0, 4.0, 0.7, 0.15, 7.0, 1500.0), + }; + s.radius_start = r0; + s.radius_end = r1; + s.density = dens; + s.heat = heat; + s.soot = soot; + s.clear = clear; + s.life = life; +} + +fn style_missile(s: &mut SmokeSegment) { + s.radius_start = 0.6; + s.radius_end = 5.0; + s.density = 3.0; + s.heat = 0.8; + s.soot = 0.05; + s.clear = 3.0; + s.life = 1000.0; +} + +/// Points between a replay trail segment's ends (a segment every `TRAIL_GRID * STEP` ticks). +const TRAIL_GRID: usize = 4; + +/// The smoke a recording shows at its tick `t`: trails behind everything that flew, columns over +/// the fires its events lit, billows over its blasts. A pure function of `t` (nothing is kept +/// between calls, so a seek, a pause or a cut shows exactly what playing through would), and it +/// touches no live state. Trail segments sit on a fixed grid of each track's points. +pub fn replay_smoke(rec: &crate::sim::record::Recording, t: f64) -> Vec { + use crate::sim::record::{STEP, What}; + let tick = t.max(0.0) as u64; + let mut out: Vec = Vec::new(); + let stride = (TRAIL_GRID as u32 * STEP) as f64; + for (n, tr) in rec.tracks.iter().enumerate() { + let (life, style): (f64, Box) = match tr.what { + What::Drone(kind) => { + let class = kind.spec().class; + let mut probe = seg(0, DVec3::ZERO, DVec3::ZERO, 0, 0); + style_drone(&mut probe, class); + (probe.life as f64, Box::new(move |s| style_drone(s, class))) + } + What::Missile(..) => (1000.0, Box::new(style_missile)), + }; + // The segments whose end point has been reached and whose life is not over. + let lo = (((t - life - tr.first as f64) / stride).floor().max(0.0)) as usize; + let hi = (((t - tr.first as f64) / stride).floor().max(0.0)) as usize; + for g in lo.max(1)..=hi { + let (Some(a), Some(b)) = (tr.sample(rec.origin, (g - 1) * TRAIL_GRID), tr.sample(rec.origin, g * TRAIL_GRID)) else { break }; + let born = tr.first as u64 + (g * TRAIL_GRID) as u64 * STEP as u64; + if born > tick || a.distance(b) >= 400.0 { + continue; + } + let mut s = seg(0x2000_0000 | ((n as u32).wrapping_mul(7919) ^ g as u32) & 0x0fff_ffff, a, b, born, 1200); + style(&mut s); + if (tick as f64) < born as f64 + s.life as f64 { + out.push(s); + } + } + } + if out.len() > MAX_TRAIL { + out.sort_by_key(|s| s.born as u64); + let cut = out.len() - MAX_TRAIL; + out.drain(..cut); + } + // Fires, the strongest dozen in full (as the live ones are). + let mut fires = crate::sim::record::replay_fires(rec, t); + fires.sort_by(|a, b| b.size.total_cmp(&a.size).then(a.start.cmp(&b.start))); + for (rank, f) in fires.iter().enumerate() { + let life = f.life as f64; + fire_column(&mut out, 0x1000_0000 | f.key as u32, f.at, f.start as u64, f.size, rank < crate::sim::fires::MAX, tick, &|k| crate::sim::fires::level(k.saturating_sub(f.start as u64) as f64, life)); + } + // Blasts keep their billows long after the fireball is out. + let blasts: Vec = rec + .events + .iter() + .enumerate() + .filter(|(_, e)| e.at as u64 <= tick) + .map(|(k, e)| { + let (size, life) = crate::sim::record::blast_spec(e.kind); + crate::sim::Blast { id: 0x4000_0000 | k as u32, at: e.pos, size, start: e.at as u64, life } + }) + .collect(); + blast_billows(&mut out, &blasts, tick); + out.truncate(MAX_TOTAL); + out +} + impl Smoke { fn trail(&mut self, key: (u8, u32), at: DVec3, tick: u64, style: impl Fn(&mut SmokeSegment)) { let Some(&(from, when)) = self.last.get(&key) else { @@ -76,32 +220,10 @@ impl Smoke { self.last_tick = tick; for d in &sim.drones { let class = d.kind.spec().class; - let (r0, r1, dens, heat, soot, clear, life): (f32, f32, f32, f32, f32, f32, f32) = match class { - Class::Drone => (0.9, 5.0, 2.0, 0.0, 0.45, 3.0, 1200.0), - Class::Jet | Class::Cruise => (1.1, 7.0, 2.5, 0.15, 0.35, 4.0, 1300.0), - Class::Supersonic => (1.4, 9.0, 3.2, 0.5, 0.2, 5.0, 1300.0), - Class::Ballistic => (2.0, 13.0, 4.0, 0.7, 0.15, 7.0, 1500.0), - }; - self.trail((0, d.id), d.pos, tick, |s| { - s.radius_start = r0; - s.radius_end = r1; - s.density = dens; - s.heat = heat; - s.soot = soot; - s.clear = clear; - s.life = life; - }); + self.trail((0, d.id), d.pos, tick, |s| style_drone(s, class)); } for m in &sim.missiles { - self.trail((1, m.id), m.pos, tick, |s| { - s.radius_start = 0.6; - s.radius_end = 5.0; - s.density = 3.0; - s.heat = 0.8; - s.soot = 0.05; - s.clear = 3.0; - s.life = 1000.0; - }); + self.trail((1, m.id), m.pos, tick, style_missile); } // Forget what is gone. if tick % 100 == 0 { @@ -129,50 +251,9 @@ impl Smoke { if f.quiet && f.size < 1.0 { continue; } - // Only the strongest fires get a full column; the rest a thin puff now and then. - let step = if f.shown { 15 } else { 90 }; - let first = f.start.max(tick.saturating_sub(520)); - let mut k = first.div_ceil(step) * step; - while k <= tick && out.len() < MAX_TOTAL { - let (flame, smoke) = f.level(k); - if smoke > 0.05 && k >= f.start { - let id = 0x4000_0000 | (f.id.wrapping_mul(131) ^ (k / step) as u32) & 0x3fff_ffff; - let at = f.at + DVec3::Y * 0.6; - let mut s = seg(id, at, at, k, 540); - s.radius_start = 1.0 * f.size; - s.radius_end = 8.0 * f.size + 2.0; - s.density = 2.6 * smoke * f.size.sqrt() * if f.shown { 1.0 } else { 0.5 }; - s.heat = flame * 0.6; - s.soot = 0.85; - s.rise = 2.6; - out.push(s); - } - k += step; - } - } - // Blasts: a billow of puffs going up. - for b in &sim.blasts { - let age = tick.saturating_sub(b.start); - if age > 900 { - continue; - } - for j in 0..6u64 { - let born = b.start + j * 3; - if born > tick { - break; - } - let h = b.size as f64 * (0.2 + 0.5 * j as f64); - let at = b.at + DVec3::Y * h; - let mut s = seg(0x5000_0000 | (b.id.wrapping_mul(7) + j as u32) & 0x0fff_ffff, at, at, born, 800); - s.radius_start = b.size * 0.6; - s.radius_end = b.size * 3.2 + 4.0; - s.density = 2.4 / (1.0 + j as f32 * 0.25); - s.heat = if j < 3 { 1.0 } else { 0.4 }; - s.soot = 0.55; - s.rise = 4.0 - 0.3 * j as f32; - out.push(s); - } + fire_column(&mut out, f.id, f.at, f.start, f.size, f.shown, tick, &|k| f.level(k)); } + blast_billows(&mut out, &sim.blasts, tick); out.truncate(MAX_TOTAL); out } 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 84ba3f0..3cc004f 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/34] Mod API (AirDef fork): world models, client particles, +Subject: [PATCH 01/35] 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 ad93fe8..e6b85a5 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/34] cargo-horizon-mod: report particles and sounds +Subject: [PATCH 02/35] 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 1375f76..a75465c 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/34] Mod API (AirDef fork): full-screen post pass for mods +Subject: [PATCH 03/35] 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 d2192b8..0bd624f 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/34] Mod models: draw far models as scaled impostors past +Subject: [PATCH 04/35] 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 8d5e970..aba161b 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/34] Mod cameras: render.set-views draws the world from +Subject: [PATCH 05/35] 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 0491340..22e6370 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/34] Mods: more fuel per tick and per event (AirDef plans +Subject: [PATCH 06/35] 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 f420210..ba9d89c 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/34] Mod cameras up to 1920x1080 (full-screen replays) +Subject: [PATCH 07/35] 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 47cc31a..cc48487 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/34] Mod cameras: distant terrain tiles, fog measured from +Subject: [PATCH 08/35] 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 5cfc420..63ff37c 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/34] Mods: game.world-id, so mods keep state per world (API +Subject: [PATCH 09/35] 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 490f3dc..94ba7cd 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/34] cargo-horizon-mod: trial runs behind feature full, so a +Subject: [PATCH 10/35] 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 37e160d..fe0cd30 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/34] HD lighting: G-buffer pass, per-pixel mod point lights +Subject: [PATCH 11/35] 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 72a85c9..ce9009a 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/34] HD lighting stage 2: sun and moon shadow cascades, the +Subject: [PATCH 12/35] 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 10c1f48..fc577c2 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/34] HD: bind groups cached until a resize or a new source, +Subject: [PATCH 13/35] 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 b251e06..d680c0e 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/34] HD: lights culled per 16x16 tile (surface and halo +Subject: [PATCH 14/35] 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 01d1836..435dbb1 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/34] HD: GPU time per pass in the F3 timing list, +Subject: [PATCH 15/35] 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 21da0d0..6aa16a8 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/34] HD: shadows draw only sun-facing quads and skip buried +Subject: [PATCH 16/35] 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 aa62ccf..33ea6b6 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/34] Mods: model handles (render.model-id, model-generation, +Subject: [PATCH 17/35] 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 d5312ff..cff6449 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/34] HD: temporal upscaling (HORIZON_HD_SCALE: world, +Subject: [PATCH 18/35] 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 84c6a24..c2f6b5d 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/34] HD water: its own pass (colour, normal, depth), the +Subject: [PATCH 19/35] 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 105714c..450cc1a 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/34] HD water: no vanilla texture on the surface (the biome +Subject: [PATCH 20/35] 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 ad35641..2863c28 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/34] HD sky and light: a physical atmosphere (Rayleigh, Mie, +Subject: [PATCH 21/35] 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 088cebd..8e7d0a2 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/34] HD water: a darker blue, a little clearer, ripples a +Subject: [PATCH 22/35] 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 34a5748..1e6f7e3 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/34] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun +Subject: [PATCH 23/35] 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 6908824..1208d3b 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/34] Mods: world queries for simulations (render.query-rays, +Subject: [PATCH 24/35] 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 index 3a0fde5..e8d07c9 100644 --- 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 @@ -1,7 +1,7 @@ From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 22:56:15 +0300 -Subject: [PATCH 25/34] HD fixes: rain level from the world not from rain +Subject: [PATCH 25/35] 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 diff --git a/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch b/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch index 0fcb8b5..9aa0081 100644 --- a/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch +++ b/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch @@ -1,7 +1,7 @@ From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 23:19:53 +0300 -Subject: [PATCH 26/34] HD split into modules (gfx/hd/: one Rust file and one +Subject: [PATCH 26/35] HD split into modules (gfx/hd/: one Rust file and one WGSL file per pass, a shared prelude), contracts for colour, depth, alpha, time and coordinates written down, tests that the frame layout and bindings match between Rust and WGSL (#45) diff --git a/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch b/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch index 8cc052d..6b21e06 100644 --- a/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch +++ b/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch @@ -1,7 +1,7 @@ From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 23:22:19 +0300 -Subject: [PATCH 27/34] HD clouds: noise scales that divide the camera's +Subject: [PATCH 27/35] HD clouds: noise scales that divide the camera's 65536-block wrap, so the clouds and their shadows no longer jump at x or z = 0 diff --git a/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch b/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch index be52fab..308bdc6 100644 --- a/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch +++ b/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch @@ -1,7 +1,7 @@ From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:17:11 +0300 -Subject: [PATCH 28/34] HD: volumetric smoke and missile trails with clear +Subject: [PATCH 28/35] HD: volumetric smoke and missile trails with clear tunnels through fog and cloud (render.set-smoke, API level 14; #54); sun speculars by material, wet surfaces and puddles in rain, wind in foliage (HORIZON_HD_SPECULAR=0, HORIZON_HD_WIND=0, HORIZON_HD_SMOKE=0); HD shaders diff --git a/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch b/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch index 75338e9..8d38559 100644 --- a/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch +++ b/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch @@ -1,7 +1,7 @@ From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:26:09 +0300 -Subject: [PATCH 29/34] HD clouds composited by depth so terrain and vehicles +Subject: [PATCH 29/35] HD clouds composited by depth so terrain and vehicles inside or behind the layer are hidden and those in front stay in front (#67); world-anchored noise everywhere (cloud detail by height above the layer, fog, smoke and puddle scales dividing the wrap) (#68); the replay's time is visual diff --git a/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch b/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch index 3a2345e..d52fa77 100644 --- a/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch +++ b/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch @@ -1,7 +1,7 @@ From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:44:45 +0300 -Subject: [PATCH 30/34] HD smoke: young trails drawn at least as wide as a grid +Subject: [PATCH 30/35] HD smoke: young trails drawn at least as wide as a grid cell (density scaled to keep the mass), gentler bulges, so a trail is a smooth ribbon and not a chain of puffs diff --git a/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch b/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch index 875cb4e..46228dd 100644 --- a/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch +++ b/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch @@ -1,7 +1,7 @@ From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:59:41 +0300 -Subject: [PATCH 31/34] Mods: render.set-main-view (API level 15) renders the +Subject: [PATCH 31/35] Mods: render.set-main-view (API level 15) renders the screen's world from a mod camera with the full pipeline, so replays get HD and the post effects; small mod views get the post pass with their own camera diff --git a/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch b/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch index 3cb7f9e..99d8e06 100644 --- a/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch +++ b/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch @@ -1,7 +1,7 @@ From 27e24527550942695164dd3a769a42a659e797ca Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 09:28:05 +0300 -Subject: [PATCH 32/34] Spot lights for mods (render.set-spot-lights, API level +Subject: [PATCH 32/35] Spot lights for mods (render.set-spot-lights, API level 16): surfaces lit in the cone, a volumetric beam stopped by depth (#60); film capture (render.capture-*, set-film-quality, API level 17): fixed-step clock, frames piped to ffmpeg or PNGs, offline audio, no chat in the frames (#58); diff --git a/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch b/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch index a581d47..db031d3 100644 --- a/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch +++ b/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch @@ -1,7 +1,7 @@ From 615f5c3fa7c19a9895f8972a88333c6f250ad78a Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 14:30:39 +0300 -Subject: [PATCH 33/34] HD clouds and fog polish: cloud panorama, cirrus, +Subject: [PATCH 33/35] HD clouds and fog polish: cloud panorama, cirrus, terrain-aware fog, far smoke cascade; replay world log and film clock; material overlays by sprite name diff --git a/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch b/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch index 74c77f9..24e7aa7 100644 --- a/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch +++ b/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch @@ -1,7 +1,7 @@ From 0254984fdcce53a4a0f2bff15f9be3b78c93842e Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 15:01:09 +0300 -Subject: [PATCH 34/34] HD: coloured sun through glass and leaves, rain ripples +Subject: [PATCH 34/35] HD: coloured sun through glass and leaves, rain ripples on water, film motion blur and depth of field Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0035-Film-export-every-frame-in-flight-reaches-the-writer.patch b/horizon-patches/0035-Film-export-every-frame-in-flight-reaches-the-writer.patch new file mode 100644 index 0000000..e7566a8 --- /dev/null +++ b/horizon-patches/0035-Film-export-every-frame-in-flight-reaches-the-writer.patch @@ -0,0 +1,446 @@ +From 9645d3f1f47d8bb358d5630288bdd801bd874911 Mon Sep 17 00:00:00 2001 +From: chexit +Date: Fri, 30 Oct 2026 15:19:32 +0300 +Subject: [PATCH 35/35] Film export: every frame in flight reaches the writer, + listener follows the main view, cloud wind on film frames, set-smoke reports + real activity + +Co-Authored-By: Claude Opus 5.5 +--- + crates/horizon-client/src/film_clock.rs | 32 +++-- + crates/horizon-client/src/game.rs | 25 +++- + crates/horizon-client/src/game/film.rs | 140 ++++++++++++++++--- + crates/horizon-client/src/game/mod_models.rs | 6 +- + crates/horizon-client/src/gfx.rs | 2 +- + crates/horizon-client/src/gfx/hd/mod.rs | 4 +- + crates/horizon-client/src/gfx/hd/smoke.rs | 24 ++++ + 7 files changed, 196 insertions(+), 37 deletions(-) + +diff --git a/crates/horizon-client/src/film_clock.rs b/crates/horizon-client/src/film_clock.rs +index 6396e4c1f..f001f0e78 100644 +--- a/crates/horizon-client/src/film_clock.rs ++++ b/crates/horizon-client/src/film_clock.rs +@@ -6,26 +6,34 @@ + use std::sync::Mutex; + use std::time::{Duration, Instant}; + +-static FILM: Mutex> = Mutex::new(None); ++/// While a film records: where its clock began (real time) and how much film time has passed. ++static FILM: Mutex> = Mutex::new(None); + +-/// The film's moment (frozen for the frame), or None to run on real time again. +-pub fn set(now: Option) { ++/// The film's moment (frozen for the frame) as (its origin, film time since it), or None to run on ++/// real time again. ++pub fn set(now: Option<(Instant, Duration)>) { + if let Ok(mut f) = FILM.lock() { + *f = now; + } + } + ++fn film() -> Option<(Instant, Duration)> { ++ FILM.lock().ok().and_then(|f| *f) ++} ++ + /// What the animations take as now. + pub fn now() -> Instant { +- FILM.lock() +- .ok() +- .and_then(|f| *f) +- .unwrap_or_else(Instant::now) ++ film().map_or_else(Instant::now, |(origin, t)| origin + t) + } + +-/// Time since `start` on this clock. ++/// Time since `start` on this clock. In a film that is the film's own time since it began ++/// whatever `start` was (an animation that began with the program would otherwise carry the ++/// real time the program ran before the film): two exports of one recording agree frame by frame. + pub fn since(start: Instant) -> Duration { +- now().saturating_duration_since(start) ++ match film() { ++ Some((_, t)) => t, ++ None => Instant::now().saturating_duration_since(start), ++ } + } + + #[cfg(test)] +@@ -35,10 +43,14 @@ mod tests { + #[test] + fn film_time_is_the_frames_not_the_wall() { + let origin = Instant::now(); +- set(Some(origin + Duration::from_millis(50))); ++ // Whatever the animation started at, a film second is a second of its animation. ++ let long_ago = origin.checked_sub(Duration::from_secs(1000)).unwrap_or(origin); ++ set(Some((origin, Duration::from_millis(50)))); + assert_eq!(since(origin), Duration::from_millis(50)); ++ assert_eq!(since(long_ago), Duration::from_millis(50)); + std::thread::sleep(Duration::from_millis(5)); + assert_eq!(since(origin), Duration::from_millis(50)); ++ assert_eq!(now(), origin + Duration::from_millis(50)); + set(None); + assert!(since(origin) >= Duration::from_millis(5)); + } +diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs +index 3f776fd3f..7a87e9ffb 100644 +--- a/crates/horizon-client/src/game.rs ++++ b/crates/horizon-client/src/game.rs +@@ -11584,6 +11584,8 @@ impl Game<'_> { + ), + }; + let (view_yaw, view_pitch) = self.shaken_view(view_yaw, view_pitch); ++ // AirDef fork: the position stays the player's (a mod places its sounds round it); the ++ // orientation is the main view's below, in every filming mode, preview and export alike. + self.audio + .set_listener([camera.x, camera.y, camera.z], view_yaw, view_pitch); + // AirDef fork: a mod's camera as the screen's own view (replays): the listener stays the +@@ -11598,11 +11600,10 @@ impl Game<'_> { + r.cut = true; + } + } +- if let Some(v) = &main_view +- && self.film.recording() +- { ++ if let Some(v) = &main_view { ++ let (yaw, pitch) = film::listener_orientation(Some((v.yaw, v.pitch)), (view_yaw, view_pitch)); + self.audio +- .set_listener([camera.x, camera.y, camera.z], v.yaw, v.pitch); ++ .set_listener([camera.x, camera.y, camera.z], yaw, pitch); + } + let (camera, underwater, view_yaw, view_pitch) = match &main_view { + Some(v) => (v.camera, false, v.yaw, v.pitch), +@@ -11701,6 +11702,9 @@ impl Game<'_> { + let debug_lines = horizon_client::gizmos::camera_relative(&lines, at, view); + let debug_lines_on_top = horizon_client::gizmos::camera_relative(&on_top, at, view); + let debug_crosshair = horizon_client::gizmos::camera_relative(&crosshair.on_top, at, view); ++ // AirDef fork: while a film records, the clouds' wind runs on the film's own frames, not the ++ // world's ticks (so two exports of one recording show the same sky at frame N). ++ let cloud_clock = self.film_cloud_clock(); + // LevelExtractor.extractBlockOutline: model translucency controls draw order. + let mut outline_after_terrain = false; + // GameRenderer.shouldRenderBlockOutline: not with the HUD hidden (F1), and only for a +@@ -11880,7 +11884,14 @@ impl Game<'_> { + .collect(); + let hd_lights = self.mod_lights(camera, alpha); + let hd_spots = self.mod_spots(camera, alpha); +- let (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha); ++ // The smoke volume is ray-marched inside the fog pass: it is drawn only where that runs (fog ++ // on, an Overworld sky, not under water). The mods are told exactly that, so a mod keeps its ++ // own smoke where HD would draw none. ++ let smoke_drawn = horizon_client::gfx::hd_smoke_possible() ++ && self.dim == Dim::Overworld ++ && self.settings.horizon.sky ++ && !underwater; ++ let (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha, smoke_drawn); + // AirDef fork: HD's fog reads the ground's height round the camera. + let hd_ground = |x: i32, z: i32| { + self.world +@@ -11969,8 +11980,8 @@ impl Game<'_> { + texture: &self.pack.clouds, + uv: [(white.u0 + white.u1) / 2.0, (white.v0 + white.v1) / 2.0], + camera, +- time: self.world.game_time, +- partial_tick: alpha, ++ time: cloud_clock.map_or(self.world.game_time, |c| c.0), ++ partial_tick: cloud_clock.map_or(alpha, |c| c.1), + range: self.settings.cloud_range, + fast: self.settings.clouds == "fast", + color: sky.cloud_color, +diff --git a/crates/horizon-client/src/game/film.rs b/crates/horizon-client/src/game/film.rs +index 2ae449fc5..1535eb281 100644 +--- a/crates/horizon-client/src/game/film.rs ++++ b/crates/horizon-client/src/game/film.rs +@@ -446,6 +446,40 @@ fn write_wav(raw: &Path, wav: &Path) -> Result { + Ok(true) + } + ++/// The frames left in flight at the end, in order, each to the writer exactly once. A readback ++/// or send error is returned (the film is then not passed off as complete). ++fn send_rest( ++ rest: Result, String>, ++ sink: Option<&mpsc::SyncSender>, ++ sent: &mut u32, ++) -> Result<(), String> { ++ let frames = rest.map_err(|e| format!("the last frames could not be read back: {e}"))?; ++ for f in frames { ++ let Some(tx) = sink else { ++ return Err("the writer is gone: the last frames were lost".into()); ++ }; ++ tx.send(Msg::Frame(f)) ++ .map_err(|_| "the writer stopped: the last frames were lost".to_string())?; ++ *sent += 1; ++ } ++ Ok(()) ++} ++ ++/// The cloud clock of frame number `stepped` of a film at `fps`: (whole game ticks, the fraction ++/// of a tick), from the film's own frame count alone. ++pub(super) fn film_cloud_time(stepped: u64, fps: u32) -> (i64, f64) { ++ let fps = u64::from(fps.max(1)); ++ let ticks = stepped * 20 / fps; ++ let rest = (stepped * 20 % fps) as f64 / fps as f64; ++ (ticks as i64, rest) ++} ++ ++/// The listener's orientation: the main view's (a replay or film camera) whenever there is one, ++/// else the player's own. Only the orientation: the position is the player's either way. ++pub(super) fn listener_orientation(main: Option<(f32, f32)>, own: (f32, f32)) -> (f32, f32) { ++ main.unwrap_or(own) ++} ++ + impl FilmState { + /// Whether frames are being recorded now. + pub(super) fn recording(&self) -> bool { +@@ -456,6 +490,12 @@ impl FilmState { + } + + impl Game<'_> { ++ /// The clouds' time for the frame being drawn while a film records, else None (the world's). ++ pub(super) fn film_cloud_clock(&self) -> Option<(i64, f64)> { ++ let film = self.film.film.as_ref().filter(|f| f.phase == Phase::Recording)?; ++ Some(film_cloud_time(film.stepped, film.req.fps)) ++ } ++ + /// Start of a frame: a mod's start or stop, the writer's end, and the time step to run the + /// frame by: `real` normally, exactly one frame of the film while it records. + pub(super) fn film_step(&mut self, real: Duration) -> Duration { +@@ -479,9 +519,10 @@ impl Game<'_> { + film.stepped += 1; + let at = |k: u64| (k * 1_000_000_000 + fps / 2) / fps; + // Animations on wall time follow the film's frames, not the machine's speed. +- horizon_client::film_clock::set(Some( +- film.origin + Duration::from_nanos(at(n + 1)), +- )); ++ horizon_client::film_clock::set(Some(( ++ film.origin, ++ Duration::from_nanos(at(n + 1)), ++ ))); + Duration::from_nanos(at(n + 1) - at(n)) + } + _ => real, +@@ -523,6 +564,7 @@ impl Game<'_> { + let cancelled = film.cancel; + let before = film.before; + let audio = film.req.audio; ++ let note = film.note.clone(); + self.film.film = None; + if audio { + self.audio.film_end(); +@@ -533,7 +575,9 @@ impl Game<'_> { + frames, + size, + file: o.file, +- message: if cancelled && !o.ok { ++ message: if !note.is_empty() && (cancelled || !o.ok) { ++ note ++ } else if cancelled && !o.ok { + "cancelled".into() + } else { + o.message +@@ -659,7 +703,7 @@ impl Game<'_> { + film.phase = Phase::Recording; + film.stepped = 0; + film.origin = std::time::Instant::now(); +- horizon_client::film_clock::set(Some(film.origin)); ++ horizon_client::film_clock::set(Some((film.origin, Duration::ZERO))); + if film.req.audio { + self.audio.film_begin(); + } +@@ -779,21 +823,17 @@ impl Game<'_> { + }; + } + Phase::Recording => { ++ let mut cancel = cancel; + if let Some(r) = self.renderer.as_mut() { + r.capture_next = false; +- let rest = if cancel { ++ if cancel { + r.capture_discard(); +- Ok(Vec::new()) + } else { +- r.capture_ready(0).and_then(|_| { +- // Everything in flight, waited for. +- r.capture_ready(0) +- }) +- }; +- if let (Ok(frames), Some(tx)) = (rest, &film.sink) { +- for f in frames { +- film.sent += 1; +- let _ = tx.send(Msg::Frame(f)); ++ // One wait for everything in flight; every frame of it goes on. ++ let rest = r.capture_ready(0).map_err(|e| e.to_string()); ++ if let Err(e) = send_rest(rest, film.sink.as_ref(), &mut film.sent) { ++ film.note = e; ++ cancel = true; + } + } + } +@@ -821,3 +861,71 @@ impl Game<'_> { + } + } + } ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ fn frame(n: u8) -> CapturedFrame { ++ CapturedFrame { ++ size: [1, 1], ++ bgra: true, ++ data: vec![n, 0, 0, 255], ++ } ++ } ++ ++ fn drain(rx: &mpsc::Receiver) -> Vec { ++ rx.try_iter() ++ .filter_map(|m| match m { ++ Msg::Frame(f) => Some(f.data[0]), ++ _ => None, ++ }) ++ .collect() ++ } ++ ++ /// #79: whatever is still in flight at the stop (0, 1 or 2 frames, ready or not) reaches the ++ /// writer once, in order; none is thrown away. ++ #[test] ++ fn the_frames_in_flight_at_the_stop_all_reach_the_writer() { ++ for pending in 0..=3u8 { ++ let (tx, rx) = mpsc::sync_channel(8); ++ let mut sent = 7; ++ let rest = (0..pending).map(|i| frame(10 + i)).collect(); ++ send_rest(Ok(rest), Some(&tx), &mut sent).unwrap(); ++ assert_eq!(sent, 7 + u32::from(pending)); ++ assert_eq!(drain(&rx), (0..pending).map(|i| 10 + i).collect::>()); ++ } ++ } ++ ++ /// #84: the main view's orientation is used with or without a capture. ++ #[test] ++ fn the_listener_follows_the_main_view_in_every_filming_mode() { ++ assert_eq!(listener_orientation(Some((90.0, 5.0)), (0.0, 0.0)), (90.0, 5.0)); ++ assert_eq!(listener_orientation(None, (12.0, -3.0)), (12.0, -3.0)); ++ } ++ ++ /// #80: the cloud wind of frame N depends on N and the frame rate only. ++ #[test] ++ fn the_cloud_clock_is_the_films_frames() { ++ // One second of film is 20 ticks at any frame rate; the frame in between is a fraction. ++ for fps in [24u32, 30, 60] { ++ assert_eq!(film_cloud_time(u64::from(fps), fps), (20, 0.0)); ++ assert_eq!(film_cloud_time(u64::from(fps) * 5, fps).0, 100); ++ } ++ let (t, p) = film_cloud_time(1, 60); ++ assert_eq!(t, 0); ++ assert!((p - 20.0 / 60.0).abs() < 1e-12); ++ // Pure: the same frame, the same sky. ++ assert_eq!(film_cloud_time(777, 30), film_cloud_time(777, 30)); ++ } ++ ++ #[test] ++ fn a_failed_readback_or_dead_writer_is_an_error_not_a_short_success() { ++ let (tx, rx) = mpsc::sync_channel(8); ++ let mut sent = 0; ++ assert!(send_rest(Err("gpu".into()), Some(&tx), &mut sent).is_err()); ++ drop(rx); ++ assert!(send_rest(Ok(vec![frame(1)]), Some(&tx), &mut sent).is_err()); ++ assert_eq!(sent, 0); ++ } ++} +diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs +index 78ee8448b..283acaa8a 100644 +--- a/crates/horizon-client/src/game/mod_models.rs ++++ b/crates/horizon-client/src/game/mod_models.rs +@@ -96,6 +96,7 @@ impl Game<'_> { + &mut self, + _camera: glam::DVec3, + _alpha: f64, ++ _drawn: bool, + ) -> (Vec, f32) { + (Vec::new(), 0.0) + } +@@ -310,8 +311,11 @@ impl Game<'_> { + &mut self, + camera: DVec3, + alpha: f64, ++ drawn: bool, + ) -> (Vec, f32) { +- let on = self.mod_hd().is_some() && horizon_client::gfx::hd_smoke_enabled(); ++ // What the mod is told is what is drawn: HD on, the smoke pass possible, and this frame's ++ // fog pass (an Overworld sky, above water) running. ++ let on = self.mod_hd().is_some() && drawn; + self.mods.mods.set_smoke_active(on); + let Some((before, now)) = self.mods.mods.smoke_clock().filter(|_| on) else { + return (Vec::new(), 0.0); +diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs +index a2a6e78b6..06fa09705 100644 +--- a/crates/horizon-client/src/gfx.rs ++++ b/crates/horizon-client/src/gfx.rs +@@ -87,7 +87,7 @@ mod native_post; + pub use hd::set_film_quality; + pub use hd::{ + HdLight, HdSettings, HdSmoke, HdSpot, MAX_SPOTS as HD_MAX_SPOTS, +- smoke_enabled as hd_smoke_enabled, spots_enabled as hd_spots_enabled, ++ smoke_enabled as hd_smoke_enabled, smoke_possible as hd_smoke_possible, spots_enabled as hd_spots_enabled, + }; + pub use mod_views::ModView; + mod native_sky; +diff --git a/crates/horizon-client/src/gfx/hd/mod.rs b/crates/horizon-client/src/gfx/hd/mod.rs +index d8c015a28..7744784d5 100644 +--- a/crates/horizon-client/src/gfx/hd/mod.rs ++++ b/crates/horizon-client/src/gfx/hd/mod.rs +@@ -97,7 +97,7 @@ pub use frame::{HdLight, HdScene, HdSettings, HdSpot, MAX_SPOTS, light_of, vanil + pub use gbuffer::{ALBEDO, LIGHT_LEVELS, NORMAL}; + pub use materials::{Table as MaterialTable, table as material_table, wind_enabled, wind_seconds}; + pub use shadows::{CASCADES, SHADOW_TINT, cascades, color_shadows_enabled}; +-pub use smoke::{HdSmoke, smoke_enabled}; ++pub use smoke::{HdSmoke, smoke_enabled, smoke_possible}; + pub use taa::{film_quality, jitter, jitter_count, scale_override, set_film_quality, taa_enabled}; + pub use volumes::clouds_enabled; + +@@ -746,7 +746,7 @@ impl Hd { + lights: &[HdLight], + clock: f32, + ) { +- if scene.sky && volumes::fog_enabled() && smoke_enabled() { ++ if scene.sky && smoke::smoke_possible() { + self.smoke.prepare(queue, scene, smoke, lights, clock); + } else { + self.smoke.frame = Default::default(); +diff --git a/crates/horizon-client/src/gfx/hd/smoke.rs b/crates/horizon-client/src/gfx/hd/smoke.rs +index 266f5fdef..34f3fd77c 100644 +--- a/crates/horizon-client/src/gfx/hd/smoke.rs ++++ b/crates/horizon-client/src/gfx/hd/smoke.rs +@@ -64,6 +64,30 @@ pub fn smoke_enabled() -> bool { + *ON.get_or_init(|| std::env::var("HORIZON_HD_SMOKE").map_or(true, |v| v != "0")) + } + ++/// Whether the smoke can be drawn at all: its volume is ray-marched in the fog pass, so it needs ++/// the volumetric fog as well as `HORIZON_HD_SMOKE`. This is what the mods are told. ++pub fn smoke_possible() -> bool { ++ smoke_possible_with(super::volumes::fog_enabled(), smoke_enabled()) ++} ++ ++fn smoke_possible_with(fog: bool, smoke: bool) -> bool { ++ fog && smoke ++} ++ ++#[cfg(test)] ++mod possible_tests { ++ use super::smoke_possible_with; ++ ++ /// #81: the answer to the mods is the real one: no fog pass, no smoke promised. ++ #[test] ++ fn smoke_is_promised_only_with_the_fog_pass() { ++ assert!(smoke_possible_with(true, true)); ++ assert!(!smoke_possible_with(false, true)); ++ assert!(!smoke_possible_with(true, false)); ++ assert!(!smoke_possible_with(false, false)); ++ } ++} ++ + /// A segment of smoke as the game hands it over for a frame, relative to the camera. + #[repr(C)] + #[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)] +-- +2.54.0 (Apple Git-157) + diff --git a/tools/hd_bench.py b/tools/hd_bench.py index 1b7a625..42b99dc 100644 --- a/tools/hd_bench.py +++ b/tools/hd_bench.py @@ -20,7 +20,11 @@ Поэтому кадры не сравниваются целиком: по кадрам одного прогона пиксель, у которого размах значений между кадрами больше --motion-level (16 из 255), считается движущимся (вода, облака, листва). Такие пиксели не участвуют в сравнении с базой (ни в одном из двух прогонов). Мерцание считается по остальным пикселям: -размах между кадрами больше --flicker-level (6 из 255) и не больше --motion-level. Для движущейся части +размах между кадрами больше --flicker-level (6 из 255) и не больше --motion-level. Сильное мерцание +по кадрам одного прогона от движения не отличить, поэтому «движение» само ограничено: доля непроверенных +пикселей (--max-animated), сумма «неустойчивых» (--max-flicker), пиксели, ставшие движущимися против +БАЗЫ (--max-new-animated: маска берётся из базы, не из проверяемого прогона), и доля проверяемых +пикселей (--min-static: ноль статичных — не «ноль ошибки», а провал). Для движущейся части выводится отдельная средняя разница, только для информации. Картинка `<сцена>_motion.png` показывает маску. Коды выхода: 0 — всё в порогах и бюджете; 1 — есть превышение; 2 — ошибка запуска или аргументов. @@ -337,19 +341,31 @@ def motion_range(rgbs): def flicker_stats(rng, w, h, n_frames, motion_level, flicker_level): + """По размаху каждого пикселя между кадрами одного прогона. `pct` — мерцание: размах в + (flicker_level, motion_level]; `animated_pct` — выше motion_level (движение: вода, облака, + листва; ИЛИ очень сильное мерцание — по одному прогону их не различить). `unsteady_pct` — сумма + обоих: все пиксели с размахом выше flicker_level, она не падает, когда мерцание усиливается и + переходит из одной корзины в другую. `unverified_pct` — доля пикселей, которые этот прогон + ничем не проверяет (то же, что animated_pct); `static_pct` — доля проверяемых.""" npx = w * h flick = sum(1 for r in rng if flicker_level < r <= motion_level) moving = sum(1 for r in rng if r > motion_level) return {"pct": 100.0 * flick / npx, "animated_pct": 100.0 * moving / npx, + "unsteady_pct": 100.0 * (flick + moving) / npx, + "unverified_pct": 100.0 * moving / npx, "static_pct": 100.0 * (npx - moving) / npx, "frames": n_frames, "size": [w, h]} def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_out=None, motion_out=None): - """Первый кадр текущего прогона против первого кадра базы. Движущиеся пиксели (в любом из двух - прогонов) в сравнении не участвуют: mean и pct — только по статичным; для движущихся — отдельная - средняя разница (информация, не порог).""" + """Первый кадр текущего прогона против первого кадра базы. Из сравнения исключаются только + пиксели, которые двигались уже в БАЗЕ (маска воспроизводимая, не зависит от того, что показал + проверяемый прогон): mean и pct — по остальным; для движущихся — отдельная средняя разница + (информация, не порог). Пиксель, ставший «движущимся» только в текущем прогоне (мерцание, + призраки TAA), из сравнения не исключается и считается в new_animated_pct. Доля проверяемых + пикселей — static_pct: при нуле mean не «0 ошибки», а отсутствие проверки (см. judge).""" npx = w * h - static = bytes(1 if (a <= motion_level and b <= motion_level) else 0 for a, b in zip(cur_rng, base_rng)) + static = bytes(1 if b <= motion_level else 0 for b in base_rng) + new_anim = sum(1 for a, b in zip(cur_rng, base_rng) if a > motion_level and b <= motion_level) d = bytes(map(abs, map(operator.sub, cur_rgb[0], base_rgb[0]))) dpix = _per_pixel_max(d) n_static = sum(static) @@ -358,6 +374,7 @@ def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_ over = sum(1 for p, s in zip(dpix, static) if s and p > DIFF_LEVEL) moving_mean = (sum(itertools.compress(d, _expand3(bytes(1 - s for s in static)))) / (3.0 * n_moving) if n_moving else 0.0) + cur_anim = sum(1 for a in cur_rng if a > motion_level) if diff_out: img = bytearray(min(255, v * DIFF_GAIN) for v in d) for i, s in enumerate(static): @@ -368,7 +385,9 @@ def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_ if motion_out: write_png_rgb(motion_out, w, h, bytes(_expand3(bytes(0 if s else 255 for s in static)))) return {"mean": mean, "pct": 100.0 * over / npx, "animated_pct": 100.0 * n_moving / npx, - "animated_mean": moving_mean, "size": [w, h]} + "animated_mean": moving_mean, "static_pct": 100.0 * n_static / npx, + "new_animated_pct": 100.0 * new_anim / npx, "cur_animated_pct": 100.0 * cur_anim / npx, + "size": [w, h]} # --- тайминги ----------------------------------------------------------------------------------- @@ -482,11 +501,17 @@ def judge(entry, args): fl = entry.get("flicker") if fl and args.max_flicker is not None and fl["pct"] > args.max_flicker: reasons.append(f"мерцание статичных пикселей {fl['pct']:.2f}% > {args.max_flicker:g}%") + if fl and fl["animated_pct"] > args.max_animated: + reasons.append(f"не проверено (размах > {args.motion_level}/255) {fl['animated_pct']:.1f}% кадра > {args.max_animated:g}%") base = entry.get("vs_baseline") if base: if base.get("error"): reasons.append(base["error"]) else: + if base["static_pct"] < args.min_static: + reasons.append(f"проверяемых пикселей {base['static_pct']:.1f}% < {args.min_static:g}%: сравнение с базой ничего не доказывает") + if base["new_animated_pct"] > args.max_new_animated: + reasons.append(f"новых движущихся пикселей против базы {base['new_animated_pct']:.2f}% > {args.max_new_animated:g}%") if base["mean"] > args.max_mean: reasons.append(f"разница с базой (статика) {base['mean']:.2f} > {args.max_mean:g}") if base["pct"] > args.max_pct: @@ -522,6 +547,8 @@ def write_report(out, entries, args, env_extra, size, baseline, meta): fl = f"{e['flicker']['pct']:.2f}" if e.get("flicker") else "-" mv = f"{e['flicker']['animated_pct']:.2f}" if e.get("flicker") else "-" b = e.get("vs_baseline") or {} + if "new_animated_pct" in b: # what the baseline comparison covers and what grew + mv += f" (проверено {b['static_pct']:.0f}%, новых {b['new_animated_pct']:.2f}%)" bm = f"{b['mean']:.2f}" if "mean" in b else "-" bp = f"{b['pct']:.2f}" if "pct" in b else "-" status = "OK" if not e["fail"] else "FAIL: " + "; ".join(e["fail"]) @@ -584,6 +611,9 @@ def parse_args(argv): p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы статичных пикселей с базой, 0..255") p.add_argument("--max-pct", type=float, default=5.0, help="порог статичных пикселей >20/255 с базой, %%") p.add_argument("--max-flicker", type=float, default=None, help="порог мерцания статичных пикселей, %%; без него мерцание только в отчёте (порог пока не откалиброван двумя одинаковыми прогонами)") + p.add_argument("--max-animated", type=float, default=60.0, help="порог доли кадра, которую прогон не проверяет (размах выше --motion-level), %%; не откалиброван") + p.add_argument("--max-new-animated", type=float, default=2.0, help="порог пикселей, статичных в базе и движущихся в прогоне, %%; не откалиброван") + p.add_argument("--min-static", type=float, default=10.0, help="наименьшая доля проверяемых (статичных в базе) пикселей, %%") p.add_argument("--no-freeze", action="store_true", help="не замораживать игровое время и погоду (живой замер производительности, не снимки)") p.add_argument("--no-pass-timing", action="store_true", help="не включать HORIZON_SHADER_PACK_TIMING") diff --git a/tools/test_hd_bench.py b/tools/test_hd_bench.py new file mode 100644 index 0000000..304f134 --- /dev/null +++ b/tools/test_hd_bench.py @@ -0,0 +1,89 @@ +"""Tests of tools/hd_bench.py's comparison (#82): `python3 -m unittest tools/test_hd_bench.py`.""" +import argparse +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import hd_bench as hb # noqa: E402 + + +def frame(w, h, v): + return bytes([v]) * (3 * w * h) + + +def args(**kw): + a = argparse.Namespace(budget_ms=18.0, max_flicker=1.0, max_animated=60.0, max_mean=3.0, + max_pct=5.0, max_new_animated=2.0, min_static=10.0, motion_level=hb.MOTION_LEVEL) + a.__dict__.update(kw) + return a + + +def judged(rgbs, base_rgbs, w=2, h=2): + rng = hb.motion_range(rgbs) + brng = hb.motion_range(base_rgbs) + entry = {"gpu_avg_ms": 1.0} + entry["flicker"] = hb.flicker_stats(rng, w, h, len(rgbs), hb.MOTION_LEVEL, hb.FLICKER_LEVEL) + entry["vs_baseline"] = hb.compare_runs(rgbs, rng, base_rgbs, brng, w, h, hb.MOTION_LEVEL) + return entry, hb.judge(entry, args()) + + +class FlickerIsNotAnimation(unittest.TestCase): + def test_the_issues_black_white_strobe_fails(self): + # The issue: a 2x2 frame, the baseline steadily black, the run alternating black and white. + entry, reasons = judged([frame(2, 2, 0), frame(2, 2, 255)] * 2, [frame(2, 2, 0)] * 3) + self.assertEqual(entry["flicker"]["pct"], 0.0) # the old blind spot is still in these two numbers ... + self.assertEqual(entry["flicker"]["animated_pct"], 100.0) + self.assertEqual(entry["flicker"]["unsteady_pct"], 100.0) + self.assertEqual(entry["vs_baseline"]["new_animated_pct"], 100.0) + self.assertTrue(reasons, "... but the verdict is a failure") + + def test_stronger_flicker_never_starts_passing(self): + w = h = 4 + base = [frame(w, h, 100)] * 3 + failed = [] + unsteady = [] + for amp in (8, 16, 32, 255): + lo, hi = 100 - min(amp // 2, 100), min(255, 100 + (amp + 1) // 2) + run = [frame(w, h, lo), frame(w, h, hi), frame(w, h, lo)] + entry, reasons = judged(run, base, w, h) + failed.append(bool(reasons)) + unsteady.append(entry["flicker"]["unsteady_pct"]) + self.assertEqual(failed, [True] * 4, failed) + self.assertEqual(unsteady, sorted(unsteady)) + + def test_a_fully_dynamic_frame_is_not_a_zero_error(self): + w = h = 2 + moving = [frame(w, h, 0), frame(w, h, 255)] + entry, reasons = judged(moving, moving) + self.assertEqual(entry["vs_baseline"]["static_pct"], 0.0) + self.assertTrue(any("проверяемых" in r for r in reasons), reasons) + + def test_water_and_foliage_that_also_move_in_the_baseline_pass(self): + w, h = 10, 10 + n = w * h + def mixed(a, b): # the left 20% animates (water), the rest is still + f = bytearray(frame(w, h, 90)) + for px in range(n // 5): + f[3 * px:3 * px + 3] = bytes([a]) * 3 + return bytes(f) + run = [mixed(40, 40), mixed(120, 40), mixed(60, 40)] + base = [mixed(30, 40), mixed(130, 40), mixed(70, 40)] + entry, reasons = judged(run, base, w, h) + self.assertEqual(entry["vs_baseline"]["new_animated_pct"], 0.0) + self.assertEqual(reasons, []) + + def test_the_mask_comes_from_the_baseline_not_from_the_run(self): + # Growth of the animated area against the baseline is counted and bounded. + w = h = 10 + base = [frame(w, h, 90)] * 3 + f = bytearray(frame(w, h, 90)) + f[0:30] = bytes([250]) * 30 # 10% of the pixels + run = [bytes(f), frame(w, h, 90), bytes(f)] + entry, reasons = judged(run, base, w, h) + self.assertAlmostEqual(entry["vs_baseline"]["new_animated_pct"], 10.0) + self.assertTrue(reasons) + + +if __name__ == "__main__": + unittest.main()