From 1a693763fdf98916a39b394bb512340cc84bac87 Mon Sep 17 00:00:00 2001 From: chexit Date: Sat, 31 Oct 2026 03:20:07 +0300 Subject: [PATCH] The player's picture and the enemy's intel apart from the truth (#93); world answers carry their context, worlds told apart by seed, a drone that can't reach its aim gives up (#90) Co-Authored-By: Claude Opus 5.5 --- airdef-mod/src/lib.rs | 62 +++++++--- airdef-mod/src/panel.rs | 50 ++++---- airdef-mod/src/radar.rs | 45 ++++--- airdef-mod/src/sim/doctrine.rs | 2 +- airdef-mod/src/sim/ew.rs | 6 + airdef-mod/src/sim/mod.rs | 89 +++++++++++++- airdef-mod/src/sim/raid.rs | 10 +- airdef-mod/src/sim/regress.rs | 93 +++++++++++++++ airdef-mod/src/sim/sight.rs | 42 ++++++- airdef-mod/src/sim/terrain.rs | 4 +- airdef-mod/src/sim/why.rs | 2 +- airdef-mod/src/sim/world.rs | 82 ++++++++++++- ...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 | 2 +- ...s-command.set-tree-and-set-suggestio.patch | 2 +- ...ound-sources-start-update-stop-with-.patch | 2 +- ...rom-a-far-point-so-the-view-bob-s-ey.patch | 2 +- ...1-direct-path-occlusion-ported-from-.patch | 2 +- ...s-through-the-server-thread-API-22-s.patch | 2 +- ...terrain-tiles-and-mouse-input-API-24.patch | 2 +- .../0042-game.world-seed-API-25.patch | 110 ++++++++++++++++++ 54 files changed, 560 insertions(+), 119 deletions(-) create mode 100644 horizon-patches/0042-game.world-seed-API-25.patch diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 4a98984..833d99c 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -827,9 +827,9 @@ impl AirDef { } fn draw_hud(&mut self, player: DVec3, yaw: f32) { - let air: Vec<&sim::Drone> = self.sim.drones.iter().filter(|d| d.alive()).collect(); + let pic = self.sim.air_picture(); // The command post's grid and replays cover the screen: the other panels wait under them. - if self.grid || self.replay.is_some() || (air.is_empty() && self.sim.sams.is_empty() && self.sim.missiles.is_empty()) { + if self.grid || self.replay.is_some() || (pic.tracks.is_empty() && self.sim.sams.is_empty() && self.sim.missiles.is_empty()) { if self.hud_shown { hud::clear(); render::clear_corner(); @@ -838,7 +838,7 @@ impl AirDef { return; } self.hud_shown = true; - hud::set_panel(Corner::TopRight, &panel::summary(&self.sim, &air, player)); + hud::set_panel(Corner::TopRight, &panel::summary(&self.sim, &pic, player)); render::set_corner(render::Corner::BottomRight, radar::SIZE, radar::SIZE + 12.0, &radar::draw(&self.sim, player, yaw)); } @@ -917,7 +917,23 @@ impl AirDef { } fn load(&mut self) { - self.load_from(&self.state_key()); + let key = self.state_key(); + // A save from before worlds were told apart by seed (kept under the folder name alone) goes to the + // world that opens first under that name and is then removed, so a later world of the same name + // starts empty. + if storage::get(&key).is_none() + && let Some(Some(id)) = &self.world + && let Some((base, _)) = id.split_once('#') + { + let old = state_key_of(Some(base)); + if storage::get(&old).is_some() { + self.load_from(&old); + let _ = storage::delete(&old); + self.dirty = true; + return; + } + } + self.load_from(&key); } fn load_from(&mut self, key: &str) { @@ -1022,6 +1038,28 @@ impl AirDef { } } +/// The storage key of a world's state: a hash of its identity (`world_identity`). +fn state_key_of(world: Option<&str>) -> String { + match world { + Some(w) => { + // FNV-1a: storage keys are short, lowercase and plain. + let h = w.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3)); + format!("state-{h:016x}") + } + None => "state".into(), + } +} + +/// Which world is open: its folder (`game.world-id`) and, for a saved world, its seed, so a new world +/// with the name of an old one has a layout of its own. +fn world_identity() -> Option { + let id = game::world_id()?; + Some(match game::world_seed() { + Some(seed) => format!("{id}#{seed:x}"), + None => id, + }) +} + impl AirDef { /// The EW stations: on if none is, else off. fn toggle_ew(&mut self) { @@ -1038,14 +1076,7 @@ impl AirDef { /// Where the open world's state is kept: a key of its own per world. fn state_key(&self) -> String { - match self.world.as_ref().and_then(|w| w.as_ref()) { - Some(w) => { - // FNV-1a: storage keys are short, lowercase and plain. - let h = w.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3)); - format!("state-{h:016x}") - } - None => "state".into(), - } + state_key_of(self.world.as_ref().and_then(|w| w.as_deref())) } /// Another world opened: the last one's state saved, a clean simulation with this one's loaded. @@ -1682,7 +1713,7 @@ impl Mod for AirDef { self.sim.engine_tick = tick; let Some(p) = player() else { return }; // Each world keeps its own systems, objects and settings: loaded when it opens. - let world = game::world_id(); + let world = world_identity(); if self.world.as_ref() != Some(&world) { self.open_world(world); } @@ -1732,10 +1763,11 @@ impl Mod for AirDef { } } let now = self.sim.tick; - self.ground.answer(render::ground(), now); - // The blocks themselves (issues #42, #43): what changed, and the sections asked for last tick. + // The blocks themselves (issues #42, #43): what changed, and the sections asked for last tick. First, so + // that the answers below are judged by the world they come in. let changes = render::world_changes(); self.ground.world.changed_rev(changes.epoch, changes.revision, changes.overflow, &changes.sections); + self.ground.answer(render::ground(), now); let sections = render::sections() .into_iter() .map(|s| sim::world::SectionAnswer { diff --git a/airdef-mod/src/panel.rs b/airdef-mod/src/panel.rs index 0a7dce4..6aec1df 100644 --- a/airdef-mod/src/panel.rs +++ b/airdef-mod/src/panel.rs @@ -1,6 +1,6 @@ //! The summary panel (top right): the alert, what is in the air, each //! air-defence system's state and the score. -use crate::sim::{Drone, DroneKind, Sim}; +use crate::sim::{AirPicture, DroneKind, Sim}; use glam::DVec3; use horizon_mod_sdk::hud::Line; @@ -23,18 +23,21 @@ fn kmh(v: DVec3) -> f64 { v.length() * 20.0 * 3.6 } -pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec { +/// The panel reads the air picture only (#93): what the network holds, with the age of a forecast. The sandbox's +/// `truth` adds one marked line of the real figures; it changes nothing else. +pub fn summary(sim: &Sim, pic: &AirPicture, player: DVec3) -> Vec { let mut lines = Vec::new(); let line = |text: String, color: u32| Line { text, color }; - if air.is_empty() { + let now = pic.now; + if pic.tracks.is_empty() { lines.push(line("Небо чистое".into(), GREEN)); } else { let blink = (sim.tick / 10) % 2 == 0; - lines.push(line(format!("ВОЗДУШНАЯ ТРЕВОГА — целей: {}", air.len()), if blink { RED } else { PINK })); - // By type. + lines.push(line(format!("ВОЗДУШНАЯ ТРЕВОГА — целей: {}", pic.tracks.len()), if blink { RED } else { PINK })); + // By type, as the radars classed them. let mut by_type = String::new(); for kind in DroneKind::ALL { - let n = air.iter().filter(|d| d.kind == kind).count(); + let n = pic.tracks.iter().filter(|t| t.kind == kind).count(); if n > 0 { if !by_type.is_empty() { by_type += ", "; @@ -43,29 +46,24 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec { } } lines.push(line(by_type, PINK)); - // The nearest threat to the player. - if let Some(d) = air.iter().min_by(|a, b| a.pos.distance(player).total_cmp(&b.pos.distance(player))) { + // The nearest track to the player: where it was last measured (a forecast says so, with its age). + if let Some(t) = pic.nearest(player) { + let e = t.est(now); + let mark = if t.forecast(now) { format!(" ~{}с", t.age(now) / 20) } else { String::new() }; lines.push(line( - format!( - "Ближ.: {} {} выс.{:.0} {:.0}км/ч", - d.kind.spec().name, - distance(d.pos.distance(player)), - d.pos.y - player.y, - kmh(d.vel) - ), + format!("Ближ.: {} {} выс.{:.0} {:.0}км/ч{mark}", t.kind.spec().name, distance(e.distance(player)), e.y - player.y, kmh(t.vel)), PINK, )); } } - if sim.planning() { - lines.push(line("Противник планирует налёт…".into(), YELLOW)); - } - if !sim.queue.is_empty() { - lines.push(line(format!("Ещё взлетает: {}", sim.queue.len()), PINK)); - } - let hidden = air.iter().filter(|d| !sim.sams.is_empty() && !sim.tracked(d.id)).count(); - if hidden > 0 { - lines.push(line(format!("Вне видимости РЛС: {hidden}"), GREY)); + if sim.truth { + let real = sim.drones.iter().filter(|d| d.alive()).count(); + let unseen = sim.drones.iter().filter(|d| d.alive() && pic.get(d.id).is_none()).count(); + let mut text = format!("ИСТИНА: в воздухе {real}, вне картины {unseen}, взлетает {}", sim.queue.len()); + if sim.planning() { + text += ", противник планирует налёт"; + } + lines.push(line(text, YELLOW)); } // The launch sites found, and the strike missiles (#101). if !sim.strikes.known.is_empty() || !sim.strikes.flights.is_empty() { @@ -103,8 +101,8 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec { } else { sam.status() }; - if let Some(t) = sam.gun_target.or(sam.target).and_then(|t| sim.drones.iter().find(|d| d.id == t)) { - text += &format!(" > {} {}", t.kind.spec().name, distance(t.pos.distance(sam.pos))); + if let Some(t) = sam.gun_target.or(sam.target).and_then(|t| pic.get(t)) { + text += &format!(" > {} {}", t.kind.spec().name, distance(t.est(now).distance(sam.pos))); } if sam.kills > 0 { text += &format!(" ({})", sam.kills); diff --git a/airdef-mod/src/radar.rs b/airdef-mod/src/radar.rs index 874d3ae..d47d8ff 100644 --- a/airdef-mod/src/radar.rs +++ b/airdef-mod/src/radar.rs @@ -147,35 +147,32 @@ pub fn draw(sim: &Sim, player: DVec3, yaw: f32) -> Vec { } let mut far = 0; // The air picture is what the systems' radars hold, as they last measured it (a track older than a second - // is a forecast, dimmer); with no system deployed, everything as it is. - let any = !sim.sams.is_empty(); - let picture = sim.picture(); - let now = sim.tick; - for d in sim.drones.iter().filter(|d| !any || !d.alive() || picture.contains_key(&d.id)) { - let track = picture.get(&d.id).filter(|_| any && d.alive()); - let (pos, vel) = track.map_or((d.pos, d.vel), |t| (t.est(now), t.vel)); + // is a forecast, dimmer); with no system deployed, the sky as it is. Nothing else is read (#93). + let pic = sim.air_picture(); + let now = pic.now; + for t in &pic.tracks { + let (pos, vel) = (t.est(now), t.vel); match scope(pos) { Some((x, y, bearing)) => { - if d.alive() { - // Fresh: bright and red; a forecast: orange, fading as it ages towards the track's drop. - let (a, color) = match track { - Some(t) if t.forecast(now) => ((1.0 - t.age(now) as f64 / t.ttl.max(1) as f64 * 0.7).max(0.3), 0xFFB040), - Some(_) => (1.0, 0xFF4040), - None => (fade(bearing), 0xFF4040), - }; - s.push(square(x, y, 1.8, alpha(color, a))); - // Its heading, a short tail. - let v = vel.normalize_or_zero(); - s.push(quad(pt(x, y), pt(x + 0.6, y), pt(x - v.x as f32 * 6.0 + 0.6, y - v.z as f32 * 6.0), pt(x - v.x as f32 * 6.0, y - v.z as f32 * 6.0), alpha(color, a * 0.6))); + // Fresh: bright and red; a forecast: orange, fading as it ages towards the track's drop. + let (a, color) = if pic.unaided { + (fade(bearing), 0xFF4040) + } else if t.forecast(now) { + ((1.0 - t.age(now) as f64 / t.ttl.max(1) as f64 * 0.7).max(0.3), 0xFFB040) } else { - s.push(square(x, y, 1.2, alpha(0x909090, fade(bearing)))); - } + (1.0, 0xFF4040) + }; + s.push(square(x, y, 1.8, alpha(color, a))); + // Its heading, a short tail. + let v = vel.normalize_or_zero(); + s.push(quad(pt(x, y), pt(x + 0.6, y), pt(x - v.x as f32 * 6.0 + 0.6, y - v.z as f32 * 6.0), pt(x - v.x as f32 * 6.0, y - v.z as f32 * 6.0), alpha(color, a * 0.6))); } - None if d.alive() => far += 1, - None => {} + None => far += 1, } - // The true place, when asked for: a white dot beside the track, to see how far the picture is off. - if sim.truth && d.alive() && any { + } + // The true places, when the sandbox asks for them: a white dot beside the track, to see how far the picture is off. + if sim.truth && !pic.unaided { + for d in sim.drones.iter().filter(|d| d.alive()) { if let Some((x, y, _)) = scope(d.pos) { s.push(square(x, y, 0.8, 0xFFFFFFFF)); } diff --git a/airdef-mod/src/sim/doctrine.rs b/airdef-mod/src/sim/doctrine.rs index 9b3cc40..358a861 100644 --- a/airdef-mod/src/sim/doctrine.rs +++ b/airdef-mod/src/sim/doctrine.rs @@ -162,7 +162,7 @@ mod tests { use super::*; fn track(pos: DVec3, vel: DVec3) -> Track { - Track { id: 1, pos, vel, tick: 0, own_tick: 0, ttl: 100, hits: 3, locked: false } + Track { id: 1, kind: DroneKind::Shahed, pos, vel, tick: 0, own_tick: 0, ttl: 100, hits: 3, locked: false } } #[test] diff --git a/airdef-mod/src/sim/ew.rs b/airdef-mod/src/sim/ew.rs index 8337a1a..580905f 100644 --- a/airdef-mod/src/sim/ew.rs +++ b/airdef-mod/src/sim/ew.rs @@ -145,6 +145,12 @@ impl Sim { out } + /// Whether the enemy has this unit in its intelligence: a radar emits, so it is known; the rest only once + /// they gave themselves away (a launch, jamming) and are in `known`. + pub(super) fn enemy_knows(s: &Sam, known: &[(u32, DVec3)]) -> bool { + s.alive() && (s.kind == SamKind::Radar || known.iter().any(|k| k.0 == s.id)) + } + /// During a smart raid: its drones not yet launched go for what just gave itself away (a jamming station /// first, then systems that fired), and drones whose system target is gone pick another. pub(super) fn adapt(&mut self) { diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index 137932c..2959c26 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -36,7 +36,7 @@ mod regress; mod sensor_tests; pub use ew::EW_RADIUS; pub use damage::{Comps, Part, PartState}; -pub use sight::{Ident, LowSensor, Track, RIM_RAYS}; +pub use sight::{AirPicture, Ident, LowSensor, Track, RIM_RAYS}; use damage::dice; use outcome::{Impact, Outcome}; pub use units::{Family, SamKind}; @@ -245,10 +245,19 @@ pub struct Ground { swept: u64, /// The tick of the last answer round: what the heights' ages are measured against. now: u64, + /// The world's epoch when the last questions went out: an answer to them is of that world or dimension, or of none. + asked_epoch: Option, /// The blocks themselves (issues #42, #43): what stops a flight. pub world: world::WorldCache, } +/// What `render.ground` answers where the chunk is not loaded (and never will be, unless asked for). +pub const NO_LAND: i32 = -64; +/// A height the game answered as a land height: none where it had no chunk. The one place that reads `NO_LAND`. +pub(crate) fn land_height(y: i32) -> Option { + (y > NO_LAND).then_some(f64::from(y)) +} + /// What `render.ground` answers (`i32::MIN`) for a column whose chunk it is loading for the mod. pub const PENDING: i32 = i32::MIN; /// Ticks before a column that was loading is asked about again. @@ -279,7 +288,7 @@ impl Ground { if (when == STALE || now.saturating_sub(when) > 200) && self.wanted.len() < 64 && self.asked.insert(key) { self.wanted.push(key); } - (y > -64).then_some(f64::from(y)) + land_height(y) } None => { if self.wanted.len() < 256 && self.asked.insert(key) { @@ -289,8 +298,18 @@ impl Ground { } } } + /// The game's answers to the questions of `take_wanted`. Each is a height (`land_height`), "no land" or + /// "loading"; one to a question of another world or dimension than the one open now is dropped (it is + /// asked again when needed), not taken as this one's. pub fn answer(&mut self, answers: Vec<(i32, i32, i32)>, now: u64) { self.now = now; + let answers = match self.asked_epoch.take() { + Some(e) if e != 0 && e != self.world.epoch() => { + self.world.stats.late_dropped += answers.len() as u64; + Vec::new() + } + _ => answers, + }; for (x, z, y) in answers { if y == PENDING { self.loading.insert((x, z), now); @@ -304,6 +323,12 @@ impl Ground { self.loading.retain(|_, t| now.saturating_sub(*t) < 400); } self.age_heights(); + // Blasts the game did not report in the blocks: the land there is looked at again. + for at in self.world.overdue_blasts(now) { + let how = if self.peek(at).is_some() { world::Settled::Unchanged } else { world::Settled::Unloaded }; + self.world.note_settled(how); + self.forget(at, 8); + } if self.known.len() > 300_000 && now.saturating_sub(self.swept) >= SWEEP_EVERY { self.swept = now; self.known.retain(|_, (_, when)| now.saturating_sub(*when) < 2000); @@ -312,6 +337,13 @@ impl Ground { /// Heights over blocks that changed (the block cache's notes of changes, chunk replacements and a new world) /// are marked old (`when` = STALE): still used, asked again within the budget. #64. fn age_heights(&mut self) { + // Another world or dimension: nothing known, asked or loading belongs to this one. + if std::mem::take(&mut self.world.epoch_reset) { + self.known.clear(); + self.loading.clear(); + self.wanted.clear(); + self.asked.clear(); + } let all = std::mem::take(&mut self.world.changed_all); let cols = std::mem::take(&mut self.world.changed_cols); if !all && cols.is_empty() { @@ -359,14 +391,14 @@ impl Ground { // What was answered as "no chunk" there is asked again: the chunk is being loaded now. for x in c.0 * 16..c.0 * 16 + 16 { for z in c.1 * 16..c.1 * 16 + 16 { - if self.known.get(&(x, z)).is_some_and(|v| v.0 <= -64) { + if self.known.get(&(x, z)).is_some_and(|v| land_height(v.0).is_none()) { self.known.remove(&(x, z)); } } } for x in (c.0 * 16..c.0 * 16 + 16).step_by(8) { for z in (c.1 * 16..c.1 * 16 + 16).step_by(8) { - if self.known.get(&(x + 4, z + 4)).is_some_and(|v| v.0 <= -64) { + if self.known.get(&(x + 4, z + 4)).is_some_and(|v| land_height(v.0).is_none()) { self.known.remove(&(x + 4, z + 4)); } } @@ -383,6 +415,7 @@ impl Ground { } pub fn take_wanted(&mut self) -> Vec<(i32, i32)> { self.asked.clear(); + self.asked_epoch = Some(self.world.epoch()); std::mem::take(&mut self.wanted) } /// The first solid block on the way from `a` to `b` (the real blocks, not the height of the land). @@ -926,6 +959,40 @@ pub struct Drone { err: DVec3, /// Ticks held at the edge of blocks the game has not told of (#61); reset by a clear step. hold: u16, + /// Its progress towards the aim: a flight that stops getting nearer runs out of fuel (see `Pace`). + pace: Pace, +} + +/// Ticks a cruise flight may go without getting nearer its aim or passing a route point before it is out of fuel: +/// long enough for a detour round a radar, far short of `MAX_FLIGHT`. It stops a drone circling an aim it +/// cannot turn onto, or one that holds for an answer that never comes, from loitering over the map for ever. +pub const STALL_TICKS: u32 = 3600; +/// How much nearer counts as getting nearer (blocks). +const STALL_GAIN: f64 = 10.0; + +#[derive(Clone, Copy, Debug)] +struct Pace { + /// The least distance to the aim so far, the age it was reached at, and the route points left then. + best: f64, + at: u32, + route: usize, +} + +impl Default for Pace { + fn default() -> Self { + Pace { best: f64::INFINITY, at: 0, route: usize::MAX } + } +} + +impl Pace { + /// One tick: true when the flight has gone `STALL_TICKS` without progress. + fn stalled(&mut self, age: u32, distance: f64, route: usize) -> bool { + if distance + STALL_GAIN < self.best || route < self.route { + *self = Pace { best: distance.min(self.best), at: age, route }; + return false; + } + age.saturating_sub(self.at) > STALL_TICKS + } } /// A drone's terrain following: refreshed every few ticks, used every one. @@ -1510,6 +1577,8 @@ pub struct Sim { pub hits: u32, /// Drones that struck the land or a building short of their aim. pub crashes: u32, + /// Cruise flights that ran out of fuel for making no headway (`STALL_TICKS`). + pub stalled: u32, /// Systems just deployed by hand and not yet checked for standing in the land: (id, until tick). settling: Vec<(u32, u64)>, /// What crashed, where, and its aim (balance measurements only). @@ -1604,6 +1673,7 @@ impl Sim { kills: 0, hits: 0, crashes: 0, + stalled: 0, settling: Vec::new(), #[cfg(test)] crashed: Vec::new(), @@ -1949,6 +2019,15 @@ impl Sim { } continue; } + // Out of fuel: no nearer its aim for a long time (it circles, or waits for what never comes): it falls + // where it is, a wreck like any other, and the raid is not held open by it. + if d.pace.stalled(d.age, d.pos.distance(d.target), d.route.len()) { + d.flight = Flight::Falling; + d.age = 0; + d.vel *= 0.35; + self.stalled += 1; + continue; + } // An interceptor coming: drones drop to the treetops (radars lose them sooner). if d.engaged > 0 && matches!(spec.class, Class::Drone | Class::Jet) { d.low = true; @@ -2162,6 +2241,7 @@ impl Sim { nav: Nav::default(), err: DVec3::ZERO, hold: 0, + pace: Pace::default(), }); } for d in &self.drones { @@ -2255,6 +2335,7 @@ impl Sim { )); } ground.forget(pos, 8); + ground.world.expect_blast(pos, 8.0, self.tick); } } diff --git a/airdef-mod/src/sim/raid.rs b/airdef-mod/src/sim/raid.rs index a0766b7..8ded552 100644 --- a/airdef-mod/src/sim/raid.rs +++ b/airdef-mod/src/sim/raid.rs @@ -512,6 +512,7 @@ impl Sim { nav: Nav::default(), err, hold: 0, + pace: Pace::default(), } } @@ -560,8 +561,11 @@ impl Sim { } // Smart: each kind at what it is good against; what gave itself away first (a jamming // station, a radar, a system that fired). + // The planner has only what the enemy knows (#93): the radars (they emit) and the units that gave + // themselves away; the rest of the player's layout is not in its hands. let known = self.known_units(); - let known_ew: Vec<&Sam> = alive.iter().copied().filter(|s| s.kind == SamKind::Ew && known.iter().any(|k| k.0 == s.id)).collect(); + let alive: Vec<&Sam> = alive.iter().copied().filter(|s| Self::enemy_knows(s, &known)).collect(); + let known_ew: Vec<&Sam> = alive.iter().copied().filter(|s| s.kind == SamKind::Ew).collect(); if matches!(kind, DroneKind::Lancet | DroneKind::Kh22) && !known_ew.is_empty() && roll < 0.6 { let s = known_ew[(r * known_ew.len() as f64) as usize % known_ew.len()]; return (s.pos, Some(s.id)); @@ -593,6 +597,7 @@ impl Sim { let mut cost = 0; let spec = kind.spec(); let height = spec.clearance.max(6.0); + let known_now = self.known_units(); let n = grid.size; let mut prices = vec![1.0f32; (n * n) as usize]; // Known jamming stations: drones that navigate by satellite keep away. @@ -610,7 +615,8 @@ impl Sim { } } } - for s in self.sams.iter().filter(|s| s.alive() && s.roles & spec.class.bit() != 0) { + // Only the systems the enemy knows of are priced in (#93): a hidden one is flown into. + for s in self.sams.iter().filter(|s| s.alive() && s.roles & spec.class.bit() != 0 && Self::enemy_knows(s, &known_now)) { let sp = s.kind.spec(); let (lo, hi) = sp.missile.as_ref().map_or((0.0, 0.0), |m| (m.min, m.max)); let gun = sp.gun.as_ref().map_or(0.0, |g| g.range); diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index 441fe8f..f21afd7 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -51,6 +51,10 @@ fn network_launcher_must_contribute_to_route_risk() { let id=sim.id();sim.sams.push(Sam::new(id,SamKind::P1sun,DVec3::new(0.,70.,0.),0.)); let id=sim.id();sim.sams.push(Sam::new(id,SamKind::Radar,DVec3::new(0.,70.,0.),0.)); let grid=Grid{min:DVec3::new(-160.,0.,-160.),cell:16.,size:20}; + // #93: the planner prices only what the enemy knows; the radar emits, the launcher is not known until it gives itself away. + let (prices,_)=sim.price_grid(&grid,DroneKind::Shahed); + assert!(prices.iter().all(|p|*p==1.),"an unknown launcher must not be priced"); + sim.intel.revealed.push((sim.sams[0].id,DVec3::new(0.,70.,0.),0)); let (prices,_)=sim.price_grid(&grid,DroneKind::Shahed); assert!(prices.iter().any(|p|*p>1.),"armed launcher with a network radar is invisible to route risk"); } @@ -1711,3 +1715,92 @@ fn over_unknown_land_a_flight_does_not_descend() { } assert!(sim.drones[0].pos.y > 245.0, "it came down over unknown land: {}", sim.drones[0].pos.y); } + +/// #90: a cruise flight that stops getting nearer its aim (a turn circle wider than the distance, a hold that +/// never ends) falls out of the sky after `STALL_TICKS`, so a raid is not held open by it. +#[test] +fn a_flight_that_makes_no_headway_runs_out_of_fuel() { + let mut pace = Pace::default(); + // Nearer and nearer: never stalled. + for age in 1..10_000u32 { + assert!(!pace.stalled(age, 5000.0 - f64::from(age) * 0.5, 1), "a flight under way stalled at {age}"); + } + // Circling at 40 blocks from the aim. + let mut pace = Pace::default(); + let mut ended = None; + for age in 1..10_000u32 { + if pace.stalled(age, 40.0 + f64::from(age % 7), 1) { + ended = Some(age); + break; + } + } + let ended = ended.expect("a circling flight never ran out of fuel"); + assert!((STALL_TICKS..STALL_TICKS + 20).contains(&ended), "{ended}"); + // Passing a route point is progress, however far off the aim the way goes. + let mut pace = Pace::default(); + for age in 1..10_000u32 { + assert!(!pace.stalled(age, 3000.0, 10 - (age / 1000) as usize), "route points passed, still stalled at {age}"); + } +} + +/// #90: the same in the simulation: a drone that gets no nearer its aim (here the aim keeps its distance, as one +/// does that it cannot turn onto) ends, falling, within the stall time, and the raid is not held open by it. +#[test] +fn a_drone_that_never_arrives_ends_the_raid() { + let mut sim = Sim::new(4); + let mut d = sim.make_drone(DroneKind::Kh101, DVec3::new(0.0, 400.0, 0.0), DVec3::X, DVec3::new(3000.0, 400.0, 0.0)); + d.low = false; + sim.drones.push(d); + let (mut ground, mut out) = (Ground::default(), Out::default()); + let mut ended = None; + for t in 0..STALL_TICKS + 400 { + if let Some(d) = sim.drones.first_mut() { + d.target = d.pos + DVec3::X * 3000.0; + } + sim.step(&mut ground, None, &mut out); + if !sim.drones.iter().any(|d| d.alive()) { + ended = Some(t); + break; + } + } + let ended = ended.expect("still airborne after the stall time"); + assert!(ended >= STALL_TICKS, "ended early: {ended}"); + assert_eq!(sim.stalled, 1); +} + +/// #90: an answer says which world it is of. Heights of another dimension are not this one's, and an answer to a +/// question asked before the world changed is dropped, not read as the new world's. +#[test] +fn heights_of_another_world_are_void_and_late_answers_are_dropped() { + let mut ground = Ground::default(); + ground.world.changed_rev(1, 0, false, &[]); + ground.answer(vec![(4, 4, 70), (12, 4, NO_LAND)], 1); + assert_eq!(ground.land_state(DVec3::new(4.5, 80.0, 4.5)), Some(Some(70.0))); + assert_eq!(ground.land_state(DVec3::new(12.5, 80.0, 4.5)), Some(None), "no land is an answer too"); + // A question goes out in world 1; the dimension changes before the answer comes. + let _ = ground.land(DVec3::new(100.5, 80.0, 100.5)); + assert_eq!(ground.take_wanted().len(), 1); + ground.world.changed_rev(2, 0, false, &[]); + ground.answer(vec![(100, 100, 55)], 2); + assert_eq!(ground.world.stats.late_dropped, 1); + assert_eq!(ground.land_state(DVec3::new(4.5, 80.0, 4.5)), None, "the old dimension's height is gone, asked again"); + assert_eq!(ground.land_state(DVec3::new(100.5, 80.0, 100.5)), None, "the late answer was not taken"); +} + +/// #90: a blast asked of the game in the blocks is confirmed by the game's own report of blocks changed there; one +/// that is not reported is told apart by whether the land was there. +#[test] +fn a_blast_in_the_blocks_waits_for_the_games_word() { + let mut ground = Ground::default(); + ground.answer(vec![(4, 4, 70)], 1); + let near = DVec3::new(4.5, 70.0, 4.5); + let far = DVec3::new(900.5, 70.0, 900.5); + ground.world.expect_blast(near, 8.0, 1); + ground.world.expect_blast(near + DVec3::X * 200.0, 8.0, 1); + ground.world.expect_blast(far, 8.0, 1); + ground.world.changed_rev(1, 1, false, &[(0, 4, 0)]); + assert_eq!(ground.world.stats.blasts_confirmed, 1); + ground.answer(vec![], 1 + world::CONFIRM_TICKS); + let s = ground.world.stats; + assert_eq!((s.blasts_asked, s.blasts_confirmed, s.blasts_unchanged, s.blasts_unloaded), (3, 1, 0, 2)); +} diff --git a/airdef-mod/src/sim/sight.rs b/airdef-mod/src/sim/sight.rs index 53f16e4..6c0238b 100644 --- a/airdef-mod/src/sim/sight.rs +++ b/airdef-mod/src/sim/sight.rs @@ -119,7 +119,7 @@ impl Ground { let hit = self.known.get(&k).or_else(|| { self.known.get(&(k.0.div_euclid(LAT) * LAT + LAT / 2, k.1.div_euclid(LAT) * LAT + LAT / 2)) }); - hit.and_then(|&(y, _)| (y > -64).then_some(f64::from(y))) + hit.and_then(|&(y, _)| land_height(y)) } } @@ -235,6 +235,8 @@ impl Ident { #[derive(Clone, Copy, Debug)] pub struct Track { pub id: u32, + /// What the radar made of it when it measured (the class the picture shows). + pub kind: DroneKind, /// Where it was and how it went when last measured (blocks, blocks a tick). pub pos: DVec3, pub vel: DVec3, @@ -248,6 +250,24 @@ pub struct Track { pub locked: bool, } +/// What the player's side knows of the air: the network's tracks as last measured, never the drones themselves. +pub struct AirPicture { + pub now: u64, + pub tracks: Vec, + /// No system is deployed: the tracks are the sky as it is. + pub unaided: bool, +} + +impl AirPicture { + pub fn get(&self, id: u32) -> Option<&Track> { + self.tracks.iter().find(|t| t.id == id) + } + /// The track nearest to a point, by its forecast place. + pub fn nearest(&self, to: DVec3) -> Option<&Track> { + self.tracks.iter().min_by(|a, b| a.est(self.now).distance(to).total_cmp(&b.est(self.now).distance(to))) + } +} + impl Track { pub fn age(&self, now: u64) -> u64 { now.saturating_sub(self.tick) @@ -396,6 +416,7 @@ impl Sim { let noise = DVec3::new(dice(seed, now, key, 1) - 0.5, (dice(seed, now, key, 2) - 0.5) * 0.5, dice(seed, now, key, 3) - 0.5) * 2.0 * sigma; fresh.push(Track { id: d.id, + kind: d.kind, pos: d.pos + noise, vel: d.vel, tick: now, @@ -447,6 +468,25 @@ impl Sim { self.sams.iter().any(|s| s.seen.contains(&id)) } + /// The air picture as the player's panel and radar read it (the only thing they read). + pub fn air_picture(&self) -> AirPicture { + let now = self.tick; + // With no system deployed there is no radar to be blind: the sky as it is (the same fallback the screens + // always had); a network's picture is only what it holds. + let unaided = self.sams.is_empty(); + let mut tracks: Vec = if unaided { + self.drones + .iter() + .filter(|d| d.alive()) + .map(|d| Track { id: d.id, kind: d.kind, pos: d.pos, vel: d.vel, tick: now, own_tick: now, ttl: 1, hits: 3, locked: false }) + .collect() + } else { + self.picture().into_values().collect() + }; + tracks.sort_by_key(|t| t.id); + AirPicture { now, tracks, unaided } + } + /// The freshest track of each drone any system holds: the air picture. pub fn picture(&self) -> HashMap { let mut best: HashMap = HashMap::new(); diff --git a/airdef-mod/src/sim/terrain.rs b/airdef-mod/src/sim/terrain.rs index f8af223..7d7e682 100644 --- a/airdef-mod/src/sim/terrain.rs +++ b/airdef-mod/src/sim/terrain.rs @@ -57,7 +57,7 @@ impl Ground { if self.due(when) && self.wanted.len() < ASK_MAX / 2 && self.asked.insert(key) { self.wanted.push(key); } - if y > -64 { Rise::At(f64::from(y)) } else { Rise::Void } + land_height(y).map_or(Rise::Void, Rise::At) } None => { if self.wanted.len() < ASK_MAX && self.asked.insert(key) { @@ -76,7 +76,7 @@ impl Ground { if self.due(when) && self.wanted.len() < cap / 2 && self.asked.insert((x, z)) { self.wanted.push((x, z)); } - Some((y > -64).then_some(f64::from(y))) + Some(land_height(y)) } None => { // A chunk the game is loading is asked about again in a few ticks, not every one. diff --git a/airdef-mod/src/sim/why.rs b/airdef-mod/src/sim/why.rs index 8f44d73..fc7c736 100644 --- a/airdef-mod/src/sim/why.rs +++ b/airdef-mod/src/sim/why.rs @@ -640,7 +640,7 @@ mod tests { .drones .iter() .filter(|d| d.alive()) - .map(|d| Track { id: d.id, pos: d.pos, vel: d.vel, tick: now, own_tick: now, ttl: 400, hits: 3, locked: false }) + .map(|d| Track { id: d.id, kind: d.kind, pos: d.pos, vel: d.vel, tick: now, own_tick: now, ttl: 400, hits: 3, locked: false }) .collect(); for r in sim.sams.iter_mut().filter(|s| s.kind == SamKind::Radar) { for t in &tracks { diff --git a/airdef-mod/src/sim/world.rs b/airdef-mod/src/sim/world.rs index d3c6adb..8589d1e 100644 --- a/airdef-mod/src/sim/world.rs +++ b/airdef-mod/src/sim/world.rs @@ -94,6 +94,13 @@ pub struct WorldStats { /// Sections dropped or re-read for a chunk's unload or replacement, or for age (#62). pub aged: u64, pub columns_changed: u64, + /// Blasts asked in the blocks, and what became of them (`Settled`). + pub blasts_asked: u64, + pub blasts_confirmed: u64, + pub blasts_unchanged: u64, + pub blasts_unloaded: u64, + /// Answers dropped for being to a question of another world or dimension. + pub late_dropped: u64, } /// What the game's exact ray said of a flight's step, once it answered (#63). @@ -182,9 +189,37 @@ pub struct WorldCache { /// Columns (section x, z) where blocks changed since the land heights last looked (#64); `all`: too many to say. pub(super) changed_cols: HashSet<(i32, i32)>, pub(super) changed_all: bool, + /// Another world or dimension opened since the land heights last looked: they are all void. + pub(super) epoch_reset: bool, + /// Blasts the mod asked the game to make in the blocks (`Out.commands`), waiting for the game's word that + /// blocks changed there. The game's damage (the simulation's own) and the geometry the game confirms + /// are two things: this is the second (#90). Singleplayer: the client's world is the authority. + expected: Vec, pub stats: WorldStats, } +/// A blast asked for: the sections it reaches (lowest, highest) and the tick it was asked. +struct Expected { + lo: Sec, + hi: Sec, + at: DVec3, + since: u64, +} + +/// Ticks the game has to report a block changed after a blast was asked for (the fuse is 0; commands run 64 a tick). +pub const CONFIRM_TICKS: u64 = 100; + +/// What became of a blast asked for in the blocks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Settled { + /// The game reported blocks changed in the sections it reaches. + Confirmed, + /// The chunk was there and nothing changed: nothing to break, a rule or the server's rights said no. + Unchanged, + /// There was no chunk to change. + Unloaded, +} + fn sec_of(x: i32, y: i32, z: i32) -> Sec { (x >> 4, y >> 4, z >> 4) } @@ -207,7 +242,7 @@ impl WorldCache { pub fn text(&self) -> String { let s = &self.stats; format!( - "мир: {} секций ({} КБ), запросов {}, неизвестно {}, лучей {} (ударов {}, неизв. {}), клеток {}", + "мир: {} секций ({} КБ), запросов {}, неизвестно {}, лучей {} (ударов {}, неизв. {}), клеток {}; взрывы в блоках: {} (подтверждено {}, без перемен {}, нет чанка {}), чужих ответов {}", self.len(), self.bytes() / 1024, s.sections_asked, @@ -215,7 +250,12 @@ impl WorldCache { s.sweeps, s.hits, s.unknown_sweeps, - s.cells + s.cells, + s.blasts_asked, + s.blasts_confirmed, + s.blasts_unchanged, + s.blasts_unloaded, + s.late_dropped ) } @@ -241,6 +281,8 @@ impl WorldCache { if epoch != self.epoch { self.rays = RayDesk { next: self.rays.next, ..RayDesk::default() }; if self.epoch != 0 { + self.epoch_reset = true; + self.expected.clear(); self.secs.clear(); self.urgent.clear(); self.ahead.clear(); @@ -264,6 +306,11 @@ impl WorldCache { let set: HashSet = sections.iter().copied().collect(); self.drop_rays(|b| set.contains(&sec_of(b[0], b[1], b[2]))); } + if !self.expected.is_empty() { + let before = self.expected.len(); + self.expected.retain(|e| !sections.iter().any(|s| (e.lo.0..=e.hi.0).contains(&s.0) && (e.lo.1..=e.hi.1).contains(&s.1) && (e.lo.2..=e.hi.2).contains(&s.2))); + self.stats.blasts_confirmed += (before - self.expected.len()) as u64; + } for s in sections { self.note_column(s.0, s.2); if let Some(e) = self.secs.get_mut(s) { @@ -274,6 +321,37 @@ impl WorldCache { self.stats.world_changes += sections.len() as u64; } + /// The game's epoch as last told: another world or dimension opening changes it. + pub fn epoch(&self) -> u32 { + self.epoch + } + + /// A blast of `radius` blocks was asked of the game at `at`: expect the game to report blocks changed there. + pub fn expect_blast(&mut self, at: DVec3, radius: f64, now: u64) { + let (lo, hi) = (at - DVec3::splat(radius), at + DVec3::splat(radius)); + let sec = |p: DVec3| sec_of(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32); + if self.expected.len() < 256 { + self.expected.push(Expected { lo: sec(lo), hi: sec(hi), at, since: now }); + } + self.stats.blasts_asked += 1; + } + + /// The blasts that the game did not report in `CONFIRM_TICKS`, taken: where each was. + pub fn overdue_blasts(&mut self, now: u64) -> Vec { + let (late, keep): (Vec<_>, Vec<_>) = std::mem::take(&mut self.expected).into_iter().partition(|e| now.saturating_sub(e.since) >= CONFIRM_TICKS); + self.expected = keep; + late.into_iter().map(|e| e.at).collect() + } + + /// What became of a blast that was not reported. + pub fn note_settled(&mut self, how: Settled) { + match how { + Settled::Confirmed => self.stats.blasts_confirmed += 1, + Settled::Unchanged => self.stats.blasts_unchanged += 1, + Settled::Unloaded => self.stats.blasts_unloaded += 1, + } + } + fn note_column(&mut self, sx: i32, sz: i32) { if self.changed_cols.len() >= 4096 { self.changed_all = true; 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 ebbd762..bb2073d 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/41] Mod API (AirDef fork): world models, client particles, +Subject: [PATCH 01/42] 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 359116a..c6ab2ca 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/41] cargo-horizon-mod: report particles and sounds +Subject: [PATCH 02/42] 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 fb9d075..92f1e6d 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/41] Mod API (AirDef fork): full-screen post pass for mods +Subject: [PATCH 03/42] 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 a7b11fd..8977476 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/41] Mod models: draw far models as scaled impostors past +Subject: [PATCH 04/42] 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 f54cdb1..4d9d621 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/41] Mod cameras: render.set-views draws the world from +Subject: [PATCH 05/42] 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 5c4e273..e6d368d 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/41] Mods: more fuel per tick and per event (AirDef plans +Subject: [PATCH 06/42] 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 0b9ccd4..58a9478 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/41] Mod cameras up to 1920x1080 (full-screen replays) +Subject: [PATCH 07/42] 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 857bb46..c26a7b5 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/41] Mod cameras: distant terrain tiles, fog measured from +Subject: [PATCH 08/42] 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 bd568eb..cc5fee9 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/41] Mods: game.world-id, so mods keep state per world (API +Subject: [PATCH 09/42] 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 7d9c73a..0c411c6 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/41] cargo-horizon-mod: trial runs behind feature full, so a +Subject: [PATCH 10/42] 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 bf1d4c7..f4f55c9 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/41] HD lighting: G-buffer pass, per-pixel mod point lights +Subject: [PATCH 11/42] 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 495e883..d9d5f87 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/41] HD lighting stage 2: sun and moon shadow cascades, the +Subject: [PATCH 12/42] 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 54f0e3e..905a883 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/41] HD: bind groups cached until a resize or a new source, +Subject: [PATCH 13/42] 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 6cc04d0..3ca0b62 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/41] HD: lights culled per 16x16 tile (surface and halo +Subject: [PATCH 14/42] 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 3b8f426..3d31fc0 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/41] HD: GPU time per pass in the F3 timing list, +Subject: [PATCH 15/42] 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 501299e..071b86e 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/41] HD: shadows draw only sun-facing quads and skip buried +Subject: [PATCH 16/42] 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 746a59f..e9523c7 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/41] Mods: model handles (render.model-id, model-generation, +Subject: [PATCH 17/42] 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 5b5e724..31ea9b5 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/41] HD: temporal upscaling (HORIZON_HD_SCALE: world, +Subject: [PATCH 18/42] 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 4f7d68c..efb492d 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/41] HD water: its own pass (colour, normal, depth), the +Subject: [PATCH 19/42] 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 471bc49..8f68f53 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/41] HD water: no vanilla texture on the surface (the biome +Subject: [PATCH 20/42] 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 6ac7af7..9993f59 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/41] HD sky and light: a physical atmosphere (Rayleigh, Mie, +Subject: [PATCH 21/42] 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 aaff9ec..2481ba2 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/41] HD water: a darker blue, a little clearer, ripples a +Subject: [PATCH 22/42] 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 a874bde..944ab63 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/41] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun +Subject: [PATCH 23/42] 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 e6ecb41..734ac86 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/41] Mods: world queries for simulations (render.query-rays, +Subject: [PATCH 24/42] 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 3ee76bf..fe5076e 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/41] HD fixes: rain level from the world not from rain +Subject: [PATCH 25/42] 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 430a6f4..a515ddd 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/41] HD split into modules (gfx/hd/: one Rust file and one +Subject: [PATCH 26/42] 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 e02c262..bd44503 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/41] HD clouds: noise scales that divide the camera's +Subject: [PATCH 27/42] 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 1467528..c16441d 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/41] HD: volumetric smoke and missile trails with clear +Subject: [PATCH 28/42] 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 1085e06..fe53665 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/41] HD clouds composited by depth so terrain and vehicles +Subject: [PATCH 29/42] 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 b871b95..05a1f0c 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/41] HD smoke: young trails drawn at least as wide as a grid +Subject: [PATCH 30/42] 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 2add094..8d0330a 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/41] Mods: render.set-main-view (API level 15) renders the +Subject: [PATCH 31/42] 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 cfb40c0..aeb6501 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/41] Spot lights for mods (render.set-spot-lights, API level +Subject: [PATCH 32/42] 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 f4af40a..b184dd0 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/41] HD clouds and fog polish: cloud panorama, cirrus, +Subject: [PATCH 33/42] 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 eef7ae5..f921d04 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/41] HD: coloured sun through glass and leaves, rain ripples +Subject: [PATCH 34/42] 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 index bb88d08..1fe1ca5 100644 --- 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 @@ -1,7 +1,7 @@ From 9645d3f1f47d8bb358d5630288bdd801bd874911 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 15:19:32 +0300 -Subject: [PATCH 35/41] Film export: every frame in flight reaches the writer, +Subject: [PATCH 35/42] Film export: every frame in flight reaches the writer, listener follows the main view, cloud wind on film frames, set-smoke reports real activity diff --git a/horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch b/horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch index d37f8f5..5c9a162 100644 --- a/horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch +++ b/horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch @@ -1,7 +1,7 @@ From 22d6835507d5627b0fe4a749b082b025b35e0262 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 15:43:11 +0300 -Subject: [PATCH 36/41] Mod command trees: command.set-tree and +Subject: [PATCH 36/42] Mod command trees: command.set-tree and set-suggestions, typed parse, colouring and completion from one tree (API 19) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0037-Persistent-mod-sound-sources-start-update-stop-with-.patch b/horizon-patches/0037-Persistent-mod-sound-sources-start-update-stop-with-.patch index 7706e81..9cf842c 100644 --- a/horizon-patches/0037-Persistent-mod-sound-sources-start-update-stop-with-.patch +++ b/horizon-patches/0037-Persistent-mod-sound-sources-start-update-stop-with-.patch @@ -1,7 +1,7 @@ From 9be21a54fff055e78db245488c28a971a823fdba Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 16:10:23 +0300 -Subject: [PATCH 37/41] Persistent mod sound sources: start, update, stop with +Subject: [PATCH 37/42] Persistent mod sound sources: start, update, stop with generations, click-free voices, loop sections, voice budget and virtual loops (API 20) diff --git a/horizon-patches/0038-HD-clouds-rays-from-a-far-point-so-the-view-bob-s-ey.patch b/horizon-patches/0038-HD-clouds-rays-from-a-far-point-so-the-view-bob-s-ey.patch index 8aeeb13..37181ae 100644 --- a/horizon-patches/0038-HD-clouds-rays-from-a-far-point-so-the-view-bob-s-ey.patch +++ b/horizon-patches/0038-HD-clouds-rays-from-a-far-point-so-the-view-bob-s-ey.patch @@ -1,7 +1,7 @@ From e84cdc21a9fabc4518c18edbeacb2df24c85b983 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 19:07:37 +0300 -Subject: [PATCH 38/41] HD clouds: rays from a far point, so the view bob's eye +Subject: [PATCH 38/42] HD clouds: rays from a far point, so the view bob's eye offset does not bend them Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0039-Acoustics-stage-1-direct-path-occlusion-ported-from-.patch b/horizon-patches/0039-Acoustics-stage-1-direct-path-occlusion-ported-from-.patch index a66923e..ac75331 100644 --- a/horizon-patches/0039-Acoustics-stage-1-direct-path-occlusion-ported-from-.patch +++ b/horizon-patches/0039-Acoustics-stage-1-direct-path-occlusion-ported-from-.patch @@ -1,7 +1,7 @@ From 19f07ff4295766c7bee4bd1fbfc509b6a82d12d4 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 22:09:23 +0300 -Subject: [PATCH 39/41] Acoustics stage 1: direct-path occlusion ported from +Subject: [PATCH 39/42] Acoustics stage 1: direct-path occlusion ported from Sound Physics Remastered 453654e (GPL-3.0, separate files), off by default; stereo output on surround devices; mixer sum in a fixed order (API 21) diff --git a/horizon-patches/0040-Mod-chunk-tickets-through-the-server-thread-API-22-s.patch b/horizon-patches/0040-Mod-chunk-tickets-through-the-server-thread-API-22-s.patch index 3b807d7..a23841b 100644 --- a/horizon-patches/0040-Mod-chunk-tickets-through-the-server-thread-API-22-s.patch +++ b/horizon-patches/0040-Mod-chunk-tickets-through-the-server-thread-API-22-s.patch @@ -1,7 +1,7 @@ From 449edb87be5b5ddc61d0d2fbeef3a91316a97b62 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 23:44:14 +0300 -Subject: [PATCH 40/41] Mod chunk tickets through the server thread (API 22), +Subject: [PATCH 40/42] Mod chunk tickets through the server thread (API 22), softer searchlights and light halos, acoustics stage 2: echo taps Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0041-Mod-canvas-with-terrain-tiles-and-mouse-input-API-24.patch b/horizon-patches/0041-Mod-canvas-with-terrain-tiles-and-mouse-input-API-24.patch index 4191a39..5a60562 100644 --- a/horizon-patches/0041-Mod-canvas-with-terrain-tiles-and-mouse-input-API-24.patch +++ b/horizon-patches/0041-Mod-canvas-with-terrain-tiles-and-mouse-input-API-24.patch @@ -1,7 +1,7 @@ From ce3b535af682ff475e5256a05136f653a2e2608c Mon Sep 17 00:00:00 2001 From: chexit Date: Sat, 31 Oct 2026 00:59:02 +0300 -Subject: [PATCH 41/41] Mod canvas with terrain tiles and mouse input (API 24), +Subject: [PATCH 41/42] Mod canvas with terrain tiles and mouse input (API 24), set-acoustics (API 23), acoustics stage 3: reverb Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0042-game.world-seed-API-25.patch b/horizon-patches/0042-game.world-seed-API-25.patch new file mode 100644 index 0000000..4842e2c --- /dev/null +++ b/horizon-patches/0042-game.world-seed-API-25.patch @@ -0,0 +1,110 @@ +From a6dcb0b5f73640e0324c8bec3508e8e396e713bd Mon Sep 17 00:00:00 2001 +From: chexit +Date: Sat, 31 Oct 2026 03:20:06 +0300 +Subject: [PATCH 42/42] game.world-seed (API 25) + +Co-Authored-By: Claude Opus 5.5 +--- + crates/horizon-client/src/game/mods.rs | 3 +++ + crates/horizon-mod-sdk/wit/horizon.wit | 4 ++++ + crates/horizon-mods/src/api_level.rs | 6 ++++-- + crates/horizon-mods/src/runtime.rs | 5 +++++ + docs/api/mods-reference.md | 1 + + 5 files changed, 17 insertions(+), 2 deletions(-) + +diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs +index 7f9714a18..e96744775 100644 +--- a/crates/horizon-client/src/game/mods.rs ++++ b/crates/horizon-client/src/game/mods.rs +@@ -599,6 +599,9 @@ impl Game<'_> { + .is_some() + .then(|| self.mods.world_id.clone()) + .flatten(), ++ world_seed: (self.player.is_some() ++ && self.mods.world_id.as_deref().is_some_and(|w| w.starts_with("world:"))) ++ .then_some(self.level.seed), + } + } + /// The first block along the player's view within +diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit +index 805c2a515..774cc5851 100644 +--- a/crates/horizon-mod-sdk/wit/horizon.wit ++++ b/crates/horizon-mod-sdk/wit/horizon.wit +@@ -51,6 +51,10 @@ interface game { + /// world, `server:
` on a server; none on the title screen. + /// Mods keep their state apart by it. + world-id: func() -> option; ++ /// The open saved world's seed (AirDef fork, API level 25): with the ++ /// folder name it tells a new world from an old one that had the same ++ /// name. None on the title screen and on a server. ++ world-seed: func() -> option; + /// A block along the player's view. + record hit { + /// The block hit. +diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs +index 579af9e25..3af36e7c1 100644 +--- a/crates/horizon-mods/src/api_level.rs ++++ b/crates/horizon-mods/src/api_level.rs +@@ -7,13 +7,13 @@ + //! version's level so the launcher can tell before it installs. + + /// The newest level this Horizon serves. +-pub const LEVEL: u32 = 24; ++pub const LEVEL: u32 = 25; + + /// Every function the game offers mods, as `interface.function`, with its + /// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new + /// function takes the next level and raises [`LEVEL`] (the `api_reference` + /// test fails until it is listed here). +-pub const FUNCTIONS: [(&str, u32); 90] = [ ++pub const FUNCTIONS: [(&str, u32); 91] = [ + ("log.write", 0), + ("game.get-player", 0), + ("game.get-clock", 0), +@@ -118,6 +118,8 @@ pub const FUNCTIONS: [(&str, u32); 90] = [ + ("render.set-canvas", 24), + ("render.canvas-events", 24), + ("render.canvas-open", 24), ++ // AirDef fork: the open world's seed, so a new world is not an old one of the same name. ++ ("game.world-seed", 25), + ]; + + /// What the game's program carries so the launcher can read its level +diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs +index 81286975f..910986549 100644 +--- a/crates/horizon-mods/src/runtime.rs ++++ b/crates/horizon-mods/src/runtime.rs +@@ -189,6 +189,8 @@ pub struct Snapshot { + pub creative: bool, + /// The open world (`game.world-id`, AirDef fork). + pub world: Option, ++ /// The open saved world's seed (`game.world-seed`, AirDef fork). ++ pub world_seed: Option, + } + + /// Capability `recipes`'s tables: `items` in the registry's order, which +@@ -1930,6 +1932,9 @@ impl game::Host for State { + fn world_id(&mut self) -> Option { + self.snapshot.world.clone() + } ++ fn world_seed(&mut self) -> Option { ++ self.snapshot.world_seed ++ } + fn raycast(&mut self, distance: f32) -> Option { + let (at, hit) = self.snapshot.view_hit.as_ref()?; + // NaN compares false: no hit. +diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md +index cf6e61440..3ab23b490 100644 +--- a/docs/api/mods-reference.md ++++ b/docs/api/mods-reference.md +@@ -126,6 +126,7 @@ A block along the player's view. + | `get-player` | `func() -> option` | None on the title screen. | + | `get-clock` | `func() -> option` | None on the title screen. | + | `world-id` | `func() -> option` | Which world is open (AirDef fork): `world:` for a saved world, `server:
` on a server; none on the title screen. Mods keep their state apart by it. | ++| `world-seed` | `func() -> option` | The open saved world's seed (AirDef fork, API level 25): with the folder name it tells a new world from an old one that had the same name. None on the title screen and on a server. | + | `raycast` | `func(distance: f32) -> option` | The first block along the player's view (fluids pass), up to `distance` blocks (at most 1024; past the loaded chunks, across the distant terrain when LOD Distance is on), as of this tick. None when nothing is hit, and on the title screen. | + + ## `chat` +-- +2.54.0 (Apple Git-157) +