Compare commits

...

2 Commits

Author SHA1 Message Date
chexit 1a693763fd 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)
build / mod (push) Successful in 8m25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:20:07 +03:00
chexit 557346548a Interceptors blow up only before blocks a probe confirms, not on a tree-top height; low flights hold height over unknown land
build / mod (push) Successful in 8m54s
release / windows (push) Successful in 22m27s
In a clean real game with 15 systems: interceptors scrubbed at relief 58 to 2, kills 33 to 46 of 82. Smart raids from 2500 blocks: crashes 10 to 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:03:31 +03:00
56 changed files with 1094 additions and 134 deletions
+47 -15
View File
@@ -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<String> {
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 {
+24 -26
View File
@@ -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<Line> {
/// 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<Line> {
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<Line> {
}
}
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<Line> {
} 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);
+21 -24
View File
@@ -147,35 +147,32 @@ pub fn draw(sim: &Sim, player: DVec3, yaw: f32) -> Vec<Shape> {
}
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));
}
+80 -11
View File
@@ -60,6 +60,7 @@ pub(super) struct Result {
pub crashed: Vec<(DroneKind, DVec3, f64)>,
/// Chunk tickets the sim asked for.
pub tickets: usize,
pub scrubs: Vec<(MissileKind, u32, f64, f64, DVec3, f64, f64)>,
}
pub(super) struct Setup {
@@ -82,17 +83,21 @@ pub(super) struct Setup {
pub dist: f64,
/// Systems and objects sunk this far into the land (a player who stood inside it).
pub bury: f64,
/// How steep the hills are (1 is the usual), and whether a forest's canopy stands on them (the game's height map shows the leaves).
pub steep: f64,
pub trees: bool,
}
/// Rolling hills: ridges of 20-35 blocks over a plain at 64, wavelengths of 90-250 blocks.
pub(super) fn hills(x: i32, z: i32) -> i32 {
let (x, z) = (f64::from(x), f64::from(z));
let n = (x / 53.0 + (z / 91.0).sin() * 1.7).sin() * 0.5 + (z / 41.0 - (x / 77.0).cos()).sin() * 0.35 + ((x + z) / 130.0).sin() * 0.4 + ((x - 2.0 * z) / 29.0).sin() * 0.12;
64 + (n * 28.0 + 14.0).max(-4.0) as i32
hills_k(x, z, 1.0)
}
fn ground_at(hilly: bool, x: f64, z: f64) -> f64 {
if hilly { f64::from(hills(x as i32, z as i32)) } else { 64.0 }
/// `hills` with its ridges `k` times as steep (higher and closer together).
pub(super) fn hills_k(x: i32, z: i32, k: f64) -> i32 {
let (x, z) = (f64::from(x) * k, f64::from(z) * k);
let n = (x / 53.0 + (z / 91.0).sin() * 1.7).sin() * 0.5 + (z / 41.0 - (x / 77.0).cos()).sin() * 0.35 + ((x + z) / 130.0).sin() * 0.4 + ((x - 2.0 * z) / 29.0).sin() * 0.12;
64 + (n * 28.0 * k + 14.0).max(-4.0) as i32
}
/// Mountains: broad ridges over the hills, nothing within about 300 blocks of the origin.
@@ -108,6 +113,8 @@ pub(super) struct Loader {
pub hilly: bool,
pub mountains: bool,
pub disc: Option<f64>,
steep: f64,
trees: bool,
asked: std::cell::RefCell<HashMap<(i32, i32), (u64, u64)>>,
pub tickets: usize,
}
@@ -116,10 +123,19 @@ const TICKET_LIFE: u64 = 400;
impl Loader {
pub fn new(s: &Setup) -> Self {
Loader { hilly: s.hilly, mountains: s.mountains, disc: s.disc, asked: Default::default(), tickets: 0 }
Loader { hilly: s.hilly, mountains: s.mountains, disc: s.disc, steep: s.steep, trees: s.trees, asked: Default::default(), tickets: 0 }
}
/// The land without the forest on it.
pub fn bare(&self, x: i32, z: i32) -> f64 {
f64::from(hills_k(x, z, self.steep) + if self.mountains { mountains(x, z) } else { 0 })
}
pub fn height(&self, x: i32, z: i32) -> i32 {
if self.hilly { hills(x, z) + if self.mountains { mountains(x, z) } else { 0 } } else { 64 }
if !self.hilly {
return 64;
}
// A forest: a quarter of the 3x3 patches carry a canopy 8 blocks over the ground (the game's height map shows the leaves).
let canopy = if self.trees && ((x.div_euclid(3).wrapping_mul(73856093) ^ z.div_euclid(3).wrapping_mul(19349663)) as u32 >> 3) % 4 == 0 { 8 } else { 0 };
hills_k(x, z, self.steep) + canopy + if self.mountains { mountains(x, z) } else { 0 }
}
fn loaded(&self, c: (i32, i32), now: u64) -> Option<bool> {
let Some(r) = self.disc else { return Some(true) };
@@ -174,13 +190,14 @@ impl world::WorldSource for Seen<'_> {
impl Setup {
pub fn new(systems: usize, smart: bool, seed: u64) -> Self {
Setup { systems, smart, seed, size: 36, radars: (systems / 10).max(1), objects: 36, tweak: |_| {}, hilly: false, bury: 0.0, disc: None, mountains: false, dist: 500.0 }
Setup { systems, smart, seed, size: 36, radars: (systems / 10).max(1), objects: 36, tweak: |_| {}, hilly: false, bury: 0.0, disc: None, mountains: false, dist: 500.0, steep: std::env::var("STEEP").ok().and_then(|v| v.parse().ok()).unwrap_or(1.0), trees: std::env::var("TREES").is_ok() }
}
}
/// The cluster: `objects` in a grid, systems of the mix scattered round them, radars among them.
pub(super) fn cluster(sim: &mut Sim, s: &Setup) {
let y = |x: f64, z: f64| if s.hilly { ground_at(true, x, z) - s.bury } else { 70.0 };
let l = Loader::new(s);
let y = |x: f64, z: f64| if s.hilly { l.bare(x as i32, z as i32) - s.bury } else { 70.0 };
let mut rng = Rng::new(s.seed.wrapping_mul(977) + 5);
let side = (s.objects as f64).sqrt().ceil() as usize;
for k in 0..s.objects {
@@ -294,6 +311,7 @@ pub(super) fn fly(s: &Setup) -> Result {
}
}
result.tickets = land.tickets;
result.scrubs = sim.scrubs.clone();
result.crashed = sim.crashed.iter().map(|c| (c.0, c.1, c.1.distance(c.2))).collect();
result.ticks = sim.tick - start;
result.launched = sim.launched;
@@ -445,6 +463,26 @@ fn far_raids_over_mountains_do_not_crash_into_them() {
assert!(crashed * 40 <= flown, "{crashed} of {flown} flew into the land");
}
/// Steep forested hills: an interceptor that is fired is one the land lets through. The launch check asks the same
/// of a shot as the flight does (the margin over the land, the loft), so few blow up short of a ridge.
#[test]
fn on_steep_forested_hills_few_interceptors_are_scrubbed() {
let (mut fired, mut scrubbed) = (0, 0);
for seed in 1..=3u64 {
let mut s = Setup::new(15, false, seed);
s.radars = 2;
s.objects = 4;
s.hilly = true;
s.steep = 1.6;
s.trees = true;
let r = fly(&s);
fired += r.missiles;
scrubbed += r.scrubbed;
}
eprintln!("scrubbed {scrubbed} of {fired}");
assert!(scrubbed * 100 <= fired * 10, "{scrubbed} of {fired} interceptors blew up short of a ridge");
}
/// What each of the new rules adds, one at a time (the raid of 36 and a heavier one of 80).
#[test]
#[ignore]
@@ -485,7 +523,7 @@ fn far_table() {
let dist: f64 = std::env::var("DIST").ok().and_then(|v| v.parse().ok()).unwrap_or(2500.0);
let mut by_kind: HashMap<DroneKind, (u32, u32)> = HashMap::new();
let (mut launched, mut kills, mut leaked, mut tickets) = (0, 0, 0, 0);
for seed in 1..=8u64 {
for seed in std::env::var("SEED").ok().and_then(|v| v.parse::<u64>().ok()).map_or(1..=8u64, |v| v..=v) {
let mut s = Setup::new(50, std::env::var("SMART").is_ok(), seed);
s.hilly = true;
s.mountains = true;
@@ -506,5 +544,36 @@ fn far_table() {
}
let mut v: Vec<_> = by_kind.into_iter().collect();
v.sort_by_key(|k| k.0 as u8);
eprintln!("disc {disc} dist {dist}: launched {launched} kills {kills} leaked {leaked} chunk tickets {tickets}; crashed by kind {:?}", v.iter().map(|k| (k.0, k.1.0)).collect::<Vec<_>>());
eprintln!("disc {disc} dist {dist}: launched {launched} kills {kills} leaked {leaked} chunk tickets {tickets}; crashed by kind {:?}", v.iter().map(|k| (k.0.clone(), k.1.0)).collect::<Vec<_>>());
}
/// Interceptors that blew up short of a ridge (`STEEP=1.6`, `SAMS=15`, `HILLS=0` for flat): by kind, and what the target was doing.
#[test]
#[ignore]
fn scrub_table() {
let n: usize = std::env::var("SAMS").ok().and_then(|v| v.parse().ok()).unwrap_or(15);
let hilly = std::env::var("HILLS").map_or(true, |v| v != "0");
let (mut fired, mut scrubbed, mut kills, mut launched, mut leaked) = (0, 0, 0, 0, 0);
let mut by: HashMap<String, (u32, u32)> = HashMap::new();
let (mut near, mut low) = (0, 0);
for seed in 1..=8u64 {
let mut s = Setup::new(n, false, seed);
s.radars = 2;
s.objects = 4;
s.hilly = hilly;
let r = fly(&s);
fired += r.missiles;
scrubbed += r.scrubbed;
kills += r.kills;
launched += r.launched;
leaked += r.leaked;
for c in &r.scrubs {
by.entry(format!("{:?}", c.0)).or_default().0 += 1;
near += u32::from(c.2 < 150.0);
low += u32::from(c.3 < 30.0);
}
}
let mut v: Vec<_> = by.into_iter().collect();
v.sort();
eprintln!("sams {n} hilly {hilly}: launched {launched} kills {kills} leaked {leaked} missiles {fired} scrubbed {scrubbed} (target within 150: {near}, target under 30 over land: {low}) by kind {:?}", v.iter().map(|k| (k.0.clone(), k.1.0)).collect::<Vec<_>>());
}
+1 -1
View File
@@ -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]
+6
View File
@@ -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) {
+110 -6
View File
@@ -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<u32>,
/// 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<f64> {
(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,11 +1577,16 @@ 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).
#[cfg(test)]
pub crashed: Vec<(DroneKind, DVec3, DVec3)>,
/// Interceptors that blew up short of a ridge: kind, age, distance to the target, the target's height over the land under it (balance measurements only).
#[cfg(test)]
pub scrubs: Vec<(MissileKind, u32, f64, f64, DVec3, f64, f64)>,
pub launched: u32,
/// Explosions damage the land (primed TNT through commands).
pub damage: bool,
@@ -1601,9 +1673,12 @@ impl Sim {
kills: 0,
hits: 0,
crashes: 0,
stalled: 0,
settling: Vec::new(),
#[cfg(test)]
crashed: Vec::new(),
#[cfg(test)]
scrubs: Vec::new(),
launched: 0,
damage: true,
seed: seed.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1,
@@ -1944,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;
@@ -2022,7 +2106,11 @@ impl Sim {
let own = below.map_or(f64::NEG_INFINITY, |g| g + spec.clearance);
let need = d.nav.need.max(own);
let safe = if need.is_finite() { need + 2.0 } else { d.target.y + spec.clearance };
let height = base.max(safe).max(d.nav.dive + 1.0);
let mut height = base.max(safe).max(d.nav.dive + 1.0);
// Blind: it does not come down over land nobody has told of; it holds the height it has.
if d.nav.blind && d.low && to_target > 300.0 {
height = height.max(d.pos.y);
}
let full = climb.pitch.tan();
// Blind (land ahead unknown): to the transit height at once, not at the gentle slope of a descent plan.
let urgent = (need.is_finite() && d.pos.y < need) || (d.nav.blind && d.pos.y < base - 2.0);
@@ -2153,6 +2241,7 @@ impl Sim {
nav: Nav::default(),
err: DVec3::ZERO,
hold: 0,
pace: Pace::default(),
});
}
for d in &self.drones {
@@ -2246,6 +2335,7 @@ impl Sim {
));
}
ground.forget(pos, 8);
ground.world.expect_blast(pos, 8.0, self.tick);
}
}
@@ -2771,6 +2861,8 @@ impl Sim {
let mut done: Vec<(u32, Option<u32>, bool, u32)> = Vec::new(); // (missile, target, kill, sam)
let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32);
let (mut blocked, mut scrubbed) = (0u32, 0u32);
#[cfg(test)]
let mut scrub_log = Vec::new();
let sams_fit: HashMap<u32, f64> = self.sams.iter().map(|s| (s.id, 1.0 - 0.3 * (1.0 - s.health.clamp(0.0, 1.0)))).collect();
// Where each launcher stands and how far its missiles reach: a hit past that is less likely (`why::reach_fit`).
let launchers: HashMap<u32, (DVec3, f64)> = self.sams.iter().filter_map(|s| Some((s.id, (s.pos, s.kind.spec().missile.as_ref()?.max)))).collect();
@@ -2831,9 +2923,19 @@ impl Sim {
let closing = (speed - vel.dot(los)).max(1.0);
let tgo = at.distance(m.pos) / closing;
// Over the land in the way: lofted as far as the missile can, else it blows up before the ridge.
let (want, fail) = terrain::steer(ground, m.kind, m.pos, speed, at + vel * tgo);
let (want, fail) = terrain::steer(ground, m.kind, m.pos, speed, at + vel * tgo, now);
if fail {
scrubbed += 1;
#[cfg(test)]
{
let over = target.and_then(|t| ground.at(t.pos, now).map(|g| t.pos.y - g)).unwrap_or(f64::NAN);
let dist = target.map_or(f64::NAN, |t| t.pos.distance(m.pos));
let mover = ground.at(m.pos, now).map_or(f64::NAN, |g| m.pos.y - g);
let from = launchers.get(&m.sam).map_or(0.0, |l| m.pos.distance(l.0));
if scrub_log.len() < 30 {
scrub_log.push((m.kind, m.age, dist, over, m.pos, mover, from));
}
}
done.push((m.id, target.map(|t| t.id), false, m.sam));
continue;
}
@@ -2923,6 +3025,8 @@ impl Sim {
}
}
}
#[cfg(test)]
self.scrubs.extend(scrub_log);
self.note(|t| {
t.missed += missed;
t.expired += expired;
+8 -2
View File
@@ -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);
+135
View File
@@ -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");
}
@@ -1669,3 +1673,134 @@ fn over_unknown_land_a_low_flight_holds_its_transit_height() {
assert!(blind > aim.y + spec.cruise - 8.0, "blind and low: {blind}");
assert!(seen < aim.y + 20.0, "over land it knows it keeps hugging it: {seen}");
}
/// The height map is a coarse upper bound (a forest's canopy shows as land): an interceptor under it, with the blocks
/// around it open, is not blown up for "no way over"; it is blown up only before blocks that are really there.
#[test]
fn an_interceptor_under_the_canopy_is_not_scrubbed_before_open_air() {
let blocks = sites::testland::Land(|_, _| 64);
let steer = |solid_to: i32| {
let mut ground = Ground::default();
let land = sites::testland::Land(move |_, _| solid_to);
let kind = MissileKind::M57e6;
let pos = DVec3::new(0.0, 66.0, 0.0);
let mut fail = false;
for t in 0..30 {
// The heights say 76 (the canopy); the blocks are solid only up to `solid_to`.
let cols = ground.take_wanted().into_iter().map(|(x, z)| (x, z, 76)).collect();
ground.answer(cols, t);
let secs = ground.world.take_wanted(t);
ground.world.serve(&land, secs, t);
fail = terrain::steer(&mut ground, kind, pos, kind.spec().speed, DVec3::new(0.0, 70.0, 200.0), t).1;
}
fail
};
let _ = blocks;
assert!(!steer(64), "open air under the canopy");
assert!(steer(80), "real blocks ahead");
}
/// A flight that is high over land nobody has told of does not come down towards the height of its aim.
#[test]
fn over_unknown_land_a_flight_does_not_descend() {
let aim = DVec3::new(0.0, 64.0, 3000.0);
let mut sim = Sim::new(4);
let mut d = sim.make_drone(DroneKind::Kh101, DVec3::new(0.0, 250.0, 0.0), DVec3::Z, aim);
d.low = true;
d.route = vec![aim];
sim.drones.push(d);
let (mut ground, mut out) = (Ground::default(), Out::default());
for _ in 0..150 {
sim.step(&mut ground, None, &mut out);
}
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));
}
+41 -1
View File
@@ -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<Track>,
/// 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<Track> = 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<u32, Track> {
let mut best: HashMap<u32, Track> = HashMap::new();
+6 -4
View File
@@ -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.
@@ -354,7 +354,7 @@ pub(super) const VERDICT_AGE: u64 = 20;
/// Which way an interceptor steers to keep clear of the land: `aim` is where it would go; the result is a direction
/// (unit) raised as far as its loft allows, and whether the land is in the way beyond that.
pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f64, aim: DVec3) -> (DVec3, bool) {
pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f64, aim: DVec3, now: u64) -> (DVec3, bool) {
let spec = kind.spec();
let want = (aim - pos).normalize_or(DVec3::Y);
let flat = horizontal(want);
@@ -372,10 +372,12 @@ pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f
// No way over, and the land close enough ahead that the next ticks will end in it: blow up before.
let mut fail = false;
if slope > cap + 0.02 && at < (speed * 6.0).max(40.0) {
// The heights are a coarse upper bound (the canopy of a forest, the roof of a building): it blows up only before
// blocks that are really there, not before what the height map says is higher than the missile.
fail = (1..=4).any(|k| {
let p = pos + raised * speed * f64::from(k);
ground.land(p).is_some_and(|g| g + 2.0 > p.y)
});
}) && matches!(ground.world.probe(pos, pos + raised * speed * 4.0, now), world::Sweep::Hit { .. });
}
(raised, fail)
}
+383
View File
@@ -0,0 +1,383 @@
//! Flights and the land. Strike drones follow the terrain with a look-ahead worth several seconds of flight
//! (what they can still climb is a question of their speed and turn rate), dive only down a clear line, and
//! interceptors keep a safety height over the land ahead, loft over a ridge when the line is blocked, and
//! blow up high when there is no way over. The launch predicates (`why`) ask the same questions of a
//! shot before it is fired, so a system doesn't spend missiles on targets behind a hill.
//!
//! The land comes from `Ground::land`: heights sampled on a coarse lattice, so many flights share columns and
//! the questions to the game stay few; what the heights miss (trees, buildings, overhangs) is caught by a
//! probe through the block cache.
use super::*;
/// Land heights are sampled at the centres of cells this wide.
pub(super) const LATTICE: i32 = 8;
/// Land questions waiting at once: flights ask for more than a tick can send, and wait.
const ASK_MAX: usize = 192;
/// What the game has said of the land at a place.
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum Rise {
/// Not yet: asked.
Pending,
/// There is no land to tell of (no chunk).
Void,
/// One above the highest solid block.
At(f64),
}
impl Ground {
/// One above the highest solid block at the lattice cell holding `p`, if known; asked for when it isn't.
/// Like `at` it asks again for a column that is old or was reported changed (#64), and it leaves room for the questions that matter more.
pub fn land(&mut self, p: DVec3) -> Option<f64> {
match self.rise(p) {
Rise::At(g) => Some(g),
_ => None,
}
}
/// For a planner: None while the game has not answered, Some(None) where it has no land to
/// tell of, else Some(Some(height)).
pub fn land_state(&mut self, p: DVec3) -> Option<Option<f64>> {
match self.rise(p) {
Rise::Pending => None,
Rise::Void => Some(None),
Rise::At(g) => Some(Some(g)),
}
}
/// The same, saying whether the game has not answered yet or has no land there to tell of.
pub(super) fn rise(&mut self, p: DVec3) -> Rise {
let key = (
(p.x.floor() as i32).div_euclid(LATTICE) * LATTICE + LATTICE / 2,
(p.z.floor() as i32).div_euclid(LATTICE) * LATTICE + LATTICE / 2,
);
match self.known.get(&key) {
Some(&(y, when)) => {
// Old or reported changed: still used, asked again with the questions to spare.
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 }
}
None => {
if self.wanted.len() < ASK_MAX && self.asked.insert(key) {
self.wanted.push(key);
}
Rise::Pending
}
}
}
/// The land's height at a column for the raid planner: `None` while the game hasn't answered, `Some(None)`
/// where it has no land to tell of. Asks, up to `cap` questions waiting.
pub(super) fn column(&mut self, x: i32, z: i32, cap: usize) -> Option<Option<f64>> {
match self.known.get(&(x, z)) {
Some(&(y, when)) => {
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)))
}
None => {
// A chunk the game is loading is asked about again in a few ticks, not every one.
let loading = self.loading.get(&(x, z)).is_some_and(|&t| self.now.saturating_sub(t) < LOAD_RECHECK);
if !loading && self.wanted.len() < cap && self.asked.insert((x, z)) {
self.wanted.push((x, z));
}
None
}
}
}
}
/// What a flight can do about a rise ahead.
#[derive(Clone, Copy, Debug)]
pub(super) struct Climb {
/// Radius of its tightest pull-up (blocks).
pub radius: f64,
/// The steepest climb it holds (radians).
pub pitch: f64,
}
impl Climb {
/// How much height it can have gained by the time it has gone `s` blocks on, starting level.
pub fn gain(&self, s: f64) -> f64 {
let arc = self.radius * self.pitch.sin();
if s <= arc {
self.radius - (self.radius * self.radius - s * s).max(0.0).sqrt()
} else {
self.radius * (1.0 - self.pitch.cos()) + (s - arc) * self.pitch.tan()
}
}
}
impl DroneKind {
/// The steepest climb it holds in cruise (radians): cruise missiles are the shallowest.
pub(super) fn climb_pitch(self) -> f64 {
match self {
DroneKind::Kh101 | DroneKind::Kalibr => 0.45,
DroneKind::Lancet => 0.7,
DroneKind::Geran3 | DroneKind::Kh22 => 0.6,
_ => 0.5,
}
}
pub(super) fn climb(self) -> Climb {
let s = self.spec();
Climb { radius: (s.speed / s.turn.max(0.01) * 1.15).max(12.0), pitch: self.climb_pitch() }
}
/// Blocks it looks ahead: eight seconds of flight, at least a pull-up and a climb over a hill.
pub(super) fn look_len(self) -> f64 {
let s = self.spec();
(s.speed * 160.0).clamp(160.0, 900.0)
}
}
/// A flight's new heading: it turns in yaw towards `heading` and in pitch towards a climb of `slope`, together
/// at most `rate` radians a tick, so a turn on to a new course doesn't dip or climb by itself (a great-circle
/// turn between nearly opposite headings can go either way). With `climb` set the pitch comes first and the
/// yaw gets what is left of the rate.
pub(super) fn fly_towards(old: DVec3, heading: DVec3, slope: f64, rate: f64, climb: bool) -> DVec3 {
let (old_yaw, old_pitch) = (old.x.atan2(old.z), old.y.clamp(-1.0, 1.0).asin());
let want_yaw = heading.x.atan2(heading.z);
let mut yaw = angle_diff(old_yaw, want_yaw);
let mut pitch = slope.atan() - old_pitch;
if climb {
pitch = pitch.clamp(-rate, rate);
yaw = yaw.clamp(-(rate * rate - pitch * pitch).max(0.0).sqrt(), (rate * rate - pitch * pitch).max(0.0).sqrt());
} else {
let total = yaw.hypot(pitch);
if total > rate {
yaw *= rate / total;
pitch *= rate / total;
}
}
let (yaw, pitch) = (old_yaw + yaw, old_pitch + pitch);
DVec3::new(yaw.sin() * pitch.cos(), pitch.sin(), yaw.cos() * pitch.cos())
}
/// How far over the land a dive's line passes.
pub(super) const DIVE_MARGIN: f64 = 8.0;
/// `v` with its pitch held between `lo` and `hi` (radians); `level` is the way it faces if it points straight up or down.
pub(super) fn clamp_pitch(v: DVec3, lo: f64, hi: f64, level: DVec3) -> DVec3 {
let pitch = v.y.clamp(-1.0, 1.0).asin();
if (lo..=hi).contains(&pitch) {
return v;
}
let pitch = pitch.clamp(lo, hi);
horizontal(v).normalize_or(level) * pitch.cos() + DVec3::Y * pitch.sin()
}
/// Chunks a tick the flights' look-ahead may ask the game to load (the rest of the budget is the launch-site search's).
const CHUNK_BUDGET: usize = 46;
/// Distances ahead where the land is sampled.
const REACH: [f64; 21] = [
0.0, 8.0, 16.0, 24.0, 32.0, 44.0, 56.0, 72.0, 90.0, 112.0, 136.0, 164.0, 196.0, 232.0, 272.0, 316.0, 366.0, 424.0, 500.0, 600.0,
720.0,
];
/// The height a flight must be at *now* to clear the land along each of `lines` (a unit horizontal direction and
/// how far out to look) by `clear`, given what it can climb: the greatest over the samples of the land's height plus clearance
/// less the height it can gain on the way. `None` when nothing is known.
pub(super) fn required_height(ground: &mut Ground, pos: DVec3, lines: &[(DVec3, f64)], clear: f64, climb: &Climb) -> (Option<f64>, bool) {
let mut need: Option<f64> = None;
// Land nobody has told of within the first half of the look-ahead: the flight is blind there.
let mut blind = false;
for &(dir, len) in lines {
for &s in REACH.iter().chain(&[900.0]) {
if s > len {
break;
}
if let Some(g) = ground.land(pos + dir * s) {
let h = g + clear - climb.gain(s);
need = Some(need.map_or(h, |n: f64| n.max(h)));
} else {
if s <= len * 0.5 {
blind = true;
}
// Land the game has not told of ahead: its chunk is asked for (it loads in a couple of seconds, a flight
// is that far from it), so the pull-up starts from the first known column and not at the ridge.
if ground.chunks_wanted() < CHUNK_BUDGET {
let p = pos + dir * s;
ground.want_chunk(((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4));
}
}
}
}
(need, blind)
}
/// The least height to start from, over here, so that the straight line down to `target` stays `margin` over the
/// land (the last stretch before the aim is the target's own business). `None` when the land isn't known.
pub(super) fn dive_height(ground: &mut Ground, pos: DVec3, target: DVec3, margin: f64) -> Option<f64> {
let flat = horizontal(target - pos);
let total = flat.length();
if total < 20.0 {
return None;
}
let dir = flat / total;
let mut need: Option<f64> = None;
let n = ((total - 14.0) / 12.0).clamp(1.0, 40.0) as i32;
for k in 0..=n {
let s = (total - 14.0) * f64::from(k) / f64::from(n);
if let Some(g) = ground.land(pos + dir * s) {
let f = s / total;
let y = (g + margin - target.y * f) / (1.0 - f);
need = Some(need.map_or(y, |m: f64| m.max(y)));
}
}
need
}
/// The slope (rise a block) a flight at `from` must hold to clear the land by `margin` on its way towards
/// `to`, with a pull-up of `radius` for the climb to begin early, ignoring the last `skip` blocks before `to`.
/// Returns the slope and how far away the land that asks for it is; `None` when nothing is in the way or known.
pub(super) fn rope(ground: &mut Ground, from: DVec3, to: DVec3, margin: f64, radius: f64, (near, reach, skip): (f64, f64, f64), pending: &mut bool) -> Option<(f64, f64)> {
let flat = horizontal(to - from);
let total = flat.length();
if total < 1.0 {
return None;
}
let dir = flat / total;
let end = (total - skip).min(reach);
let mut best: Option<(f64, f64)> = None;
let mut s = near;
while s <= end {
match ground.rise(from + dir * s) {
Rise::At(g) => {
let slope = (g + margin - from.y) / (s - radius * 0.6).max(6.0);
if best.is_none_or(|b| slope > b.0) {
best = Some((slope, s));
}
}
Rise::Pending => *pending = true,
Rise::Void => {}
}
s += if s < 64.0 { 8.0 } else { 16.0 };
}
best
}
// ---------------------------------------------------------------- interceptors
impl MissileKind {
/// The steepest climb it flies to get over land (radians): long-range missiles loft, short-range ones don't.
pub(super) fn loft(self) -> f64 {
match self {
MissileKind::M77n6 => 1.25,
MissileKind::M48n6 => 1.1,
MissileKind::Pac3 | MissileKind::IrisT | MissileKind::Amraam => 0.9,
MissileKind::M9m317 => 0.8,
MissileKind::P1sun => 0.5,
MissileKind::M57e6 | MissileKind::M9m338 => 0.3,
}
}
/// Whether it lofts over a ridge to a target behind it.
pub(super) fn lofts(self) -> bool {
self.loft() >= 0.5
}
}
/// How high above the land an interceptor keeps.
pub(super) const MISSILE_MARGIN: f64 = 8.0;
/// What keeps an interceptor from being launched at a target for the land in the way.
pub(super) const MASKED: u8 = 1;
pub(super) const PATH_BLOCKED: u8 = 2;
/// The land under the line of fire has been asked for and not answered: the shot waits a tick or two.
pub(super) const WAIT: u8 = 4;
/// Ticks a shot waits for the land to be answered before it is judged on what is known.
pub(super) const WAIT_TICKS: u64 = 30;
/// Whether a shot from `muzzle` (launcher at `antenna`) at a target forecast at `est` moving at `vel` may be
/// fired for the land: the intercept point must not be behind a hill from the launcher, unless the missile
/// lofts, and the path the missile would fly must be clear.
pub(super) fn launch_blocks(ground: &mut Ground, kind: MissileKind, muzzle: DVec3, antenna: DVec3, est: DVec3, vel: DVec3) -> u8 {
let spec = kind.spec();
// Where it meets the target: the forecast carried on for the flight time.
let mut at = est;
for _ in 0..2 {
let tgo = muzzle.distance(at) / (spec.speed * 0.8);
at = est + vel * tgo;
}
let flat = horizontal(at - antenna);
let total = flat.length();
if total < 40.0 {
return 0;
}
let dir = flat / total;
// The line of sight from the launcher: any rise of the land over the line to the intercept point masks it.
let mut masked = false;
let mut waiting = false;
let mut s = 12.0;
while s < total - 12.0 {
match ground.rise(antenna + dir * s) {
Rise::At(g) if g + 1.0 > antenna.y + (at.y - antenna.y) * s / total => {
masked = true;
break;
}
Rise::Pending => waiting = true,
_ => {}
}
s += 16.0;
}
if !masked && waiting {
return WAIT;
}
if !masked {
return 0;
}
if !kind.lofts() {
return MASKED;
}
// A lofted path: climbs at the slope the highest land in the way asks for, and comes down on the target.
let radius = spec.speed / spec.turn.max(0.05);
let mut pending = false;
let path = rope(ground, muzzle, at, MISSILE_MARGIN, radius, (32.0, total, 24.0), &mut pending);
match path {
Some((slope, _)) if slope > kind.loft().tan() => PATH_BLOCKED,
_ if pending => WAIT,
_ => 0,
}
}
impl Sam {
/// What the land said of a shot at `target` when it was last judged, if lately.
pub(super) fn terrain_verdict(&self, target: u32, now: u64) -> Option<u8> {
self.terrain.get(&target).filter(|(when, _)| now.saturating_sub(*when) <= VERDICT_AGE).map(|(_, bits)| *bits)
}
}
/// Ticks a launch verdict (the land in the way) is kept before the shot is judged again.
pub(super) const VERDICT_AGE: u64 = 20;
/// Which way an interceptor steers to keep clear of the land: `aim` is where it would go; the result is a direction
/// (unit) raised as far as its loft allows, and whether the land is in the way beyond that.
pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f64, aim: DVec3, now: u64) -> (DVec3, bool) {
let spec = kind.spec();
let want = (aim - pos).normalize_or(DVec3::Y);
let flat = horizontal(want);
let flat_len = flat.length();
let reach = (speed * 18.0).clamp(60.0, 320.0);
let radius = speed / spec.turn.max(0.05);
let Some((slope, at)) = rope(ground, pos, aim, MISSILE_MARGIN, radius, (8.0, reach, 20.0), &mut false) else { return (want, false) };
let wanted = if flat_len > 1e-6 { want.y / flat_len } else { f64::INFINITY };
if slope <= wanted {
return (want, false);
}
let cap = kind.loft().tan();
let dir = flat.normalize_or(DVec3::X);
let raised = (dir + DVec3::Y * slope.min(cap)).normalize();
// No way over, and the land close enough ahead that the next ticks will end in it: blow up before.
let mut fail = false;
if slope > cap + 0.02 && at < (speed * 6.0).max(40.0) {
// The heights are a coarse upper bound (the canopy of a forest, the roof of a building): it blows up only before
// blocks that are really there, not before what the height map says is higher than the missile.
fail = (1..=4).any(|k| {
let p = pos + raised * speed * f64::from(k);
ground.land(p).is_some_and(|g| g + 2.0 > p.y)
}) && matches!(ground.world.probe(pos, pos + raised * speed * 4.0, now), world::Sweep::Hit { .. });
}
(raised, fail)
}
+1 -1
View File
@@ -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 {
+80 -2
View File
@@ -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<Expected>,
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<Sec> = 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<DVec3> {
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;
@@ -1,7 +1,7 @@
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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;
@@ -1,7 +1,7 @@
From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
---
@@ -1,7 +1,7 @@
From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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
@@ -1,7 +1,7 @@
From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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
---
@@ -1,7 +1,7 @@
From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
---
@@ -1,7 +1,7 @@
From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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)
@@ -1,7 +1,7 @@
From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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
@@ -1,7 +1,7 @@
From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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
@@ -1,7 +1,7 @@
From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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
@@ -1,7 +1,7 @@
From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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
@@ -1,7 +1,7 @@
From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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)
@@ -1,7 +1,7 @@
From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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
@@ -1,7 +1,7 @@
From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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)
@@ -1,7 +1,7 @@
From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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
@@ -1,7 +1,7 @@
From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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
@@ -1,7 +1,7 @@
From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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)
@@ -1,7 +1,7 @@
From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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
@@ -1,7 +1,7 @@
From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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
@@ -1,7 +1,7 @@
From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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
@@ -1,7 +1,7 @@
From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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
@@ -1,7 +1,7 @@
From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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
@@ -1,7 +1,7 @@
From 27e24527550942695164dd3a769a42a659e797ca Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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);
@@ -1,7 +1,7 @@
From 615f5c3fa7c19a9895f8972a88333c6f250ad78a Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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
@@ -1,7 +1,7 @@
From 0254984fdcce53a4a0f2bff15f9be3b78c93842e Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 9645d3f1f47d8bb358d5630288bdd801bd874911 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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
@@ -1,7 +1,7 @@
From 22d6835507d5627b0fe4a749b082b025b35e0262 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 9be21a54fff055e78db245488c28a971a823fdba Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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)
@@ -1,7 +1,7 @@
From e84cdc21a9fabc4518c18edbeacb2df24c85b983 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 19f07ff4295766c7bee4bd1fbfc509b6a82d12d4 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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)
@@ -1,7 +1,7 @@
From 449edb87be5b5ddc61d0d2fbeef3a91316a97b62 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From ce3b535af682ff475e5256a05136f653a2e2608c Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
@@ -0,0 +1,110 @@
From a6dcb0b5f73640e0324c8bec3508e8e396e713bd Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@anthropic.com>
---
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:<address>` on a server; none on the title screen.
/// Mods keep their state apart by it.
world-id: func() -> option<string>;
+ /// 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<s64>;
/// 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<String>,
+ /// The open saved world's seed (`game.world-seed`, AirDef fork).
+ pub world_seed: Option<i64>,
}
/// 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<String> {
self.snapshot.world.clone()
}
+ fn world_seed(&mut self) -> Option<i64> {
+ self.snapshot.world_seed
+ }
fn raycast(&mut self, distance: f32) -> Option<Hit> {
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<player>` | None on the title screen. |
| `get-clock` | `func() -> option<clock>` | None on the title screen. |
| `world-id` | `func() -> option<string>` | Which world is open (AirDef fork): `world:<folder>` for a saved world, `server:<address>` on a server; none on the title screen. Mods keep their state apart by it. |
+| `world-seed` | `func() -> option<s64>` | 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<hit>` | 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)