Compare commits

..

2 Commits

37 changed files with 10364 additions and 75 deletions
+5
View File
@@ -0,0 +1,5 @@
Status of terrain-aware flight (not committed):
- Done: sim/terrain.rs (look-ahead terrain following, dive only down a clear line, interceptor steer/loft/self-destruct, launch predicate), planner relief pricing + spawn above land (raid.rs), SAM launch check + why line (mod.rs, why.rs), Tally.scrubbed, world.probe, harness terrain fixes, 6 new tests in regress.rs. Build has no warnings; cargo test passes (43 + 4).
- Numbers (6 seeds, sim_run big-smart): hills:70:100:140 crashes 13 -> 3, hills:70:150:120 29 -> 2, default hills 2 -> 2 (all terminal-dive near-target by Kh101/Kalibr).
- Half-done / known: terminal dive of slow-turning cruise missiles still sometimes hits ground 7-20 blocks short of target; hills:70:150:120 tick max 38 ms / p95 2 ms (planner terrain phase + ground queries) not profiled; not tested in game.
- Next: tune dive geometry for Kh101/Kalibr, profile the planner's 1024-columns-per-tick height requests, optional in-game check.
+12 -4
View File
@@ -108,6 +108,8 @@ pub struct Report {
/// The block cache's counters (issue #43) and the drones that struck the land (issue #42).
pub world: String,
pub crashes: u32,
/// What went wrong for the air defence, as the raid report words it.
pub failures: String,
pub ground_batches: u64,
pub tick_p50: Duration,
pub tick_p95: Duration,
@@ -135,14 +137,16 @@ pub fn run(sc: &Scenario) -> Report {
sim.damage = false; // explosions would change the land the height function describes
for u in &sc.units {
let id = sim.id();
let mut s = Sam::new(id, u.kind, u.pos, u.heading);
// Systems stand on the land, whatever the layout says.
let pos = DVec3::new(u.pos.x, f64::from(sc.terrain.height(u.pos.x.floor() as i32, u.pos.z.floor() as i32)), u.pos.z);
let mut s = Sam::new(id, u.kind, pos, u.heading);
if u.empty {
s.empty = true;
s.missiles = 0;
}
sim.sams.push(s);
}
sim.targets = sc.objects.clone();
sim.targets = sc.objects.iter().map(|o| DVec3::new(o.x, f64::from(sc.terrain.height(o.x.floor() as i32, o.z.floor() as i32)), o.z)).collect();
sim.target_health = vec![100.0; sc.objects.len()];
if sc.ew {
sim.set_ew(true);
@@ -163,7 +167,8 @@ pub fn run(sc: &Scenario) -> Report {
if !ordered[i] && now >= spec.at && !sim.planning() && (i == 0 || ended[i - 1]) {
let order = RaidOrder { kinds: spec.kinds.clone(), approach: spec.approach, dist: spec.dist, aim: spec.aim };
launched_before[i] = sim.launched;
sim.order_raid(&order, &sc.objects, &mut out);
let objects = sim.targets.clone();
sim.order_raid(&order, &objects, &mut out);
ordered[i] = true;
}
}
@@ -226,6 +231,7 @@ pub fn run(sc: &Scenario) -> Report {
r.raids_ordered = ordered.iter().filter(|o| **o).count();
r.world = ground.world.text();
r.crashes = sim.crashes;
r.failures = sim.tally.text();
r.raids_ended = ended.iter().filter(|o| **o).count();
r.all_ended = r.raids_ended == sc.raids.len();
r.tick_total = times.iter().sum();
@@ -268,7 +274,8 @@ impl Report {
ground queries: {gq} in {gb} batches\n\
non-finite positions: {nan}\n\
stuck trucks: {stuck:?} (busy truck-ticks {busy})\n\
crashes into the land: {crashes}; {world}\n",
crashes into the land: {crashes}; {world}\n\
air-defence failures: {failures}\n",
name = self.name,
seed = self.seed,
units = self.units,
@@ -293,6 +300,7 @@ impl Report {
busy = self.truck_busy_ticks,
crashes = self.crashes,
world = self.world,
failures = if self.failures.is_empty() { "none" } else { &self.failures },
)
}
pub fn json(&self) -> String {
+159 -29
View File
@@ -14,6 +14,7 @@ pub mod fires;
mod raid;
pub mod record;
mod sight;
mod terrain;
mod ew;
mod lights;
mod logistics;
@@ -745,6 +746,23 @@ pub struct Drone {
sector: u8,
/// Sent by the smart enemy: it may be turned on systems that give themselves away.
smart: bool,
/// What the land ahead asks of its flight (see `terrain`).
nav: Nav,
}
/// A drone's terrain following: refreshed every few ticks, used every one.
#[derive(Clone, Copy, Debug)]
struct Nav {
/// The height it must be at now to clear the land ahead (unknown: -inf).
need: f64,
/// The height from which a straight dive on its target clears the land (unknown: -inf).
dive: f64,
}
impl Default for Nav {
fn default() -> Self {
Nav { need: f64::NEG_INFINITY, dive: f64::NEG_INFINITY }
}
}
impl Drone {
@@ -1073,6 +1091,10 @@ pub struct Sam {
pub ew_lock: bool,
/// Ticks till a gun fires its next illumination flare.
flare_cd: u32,
/// Shots the land ruled out, by target: when it was judged and why (`terrain::MASKED`, `terrain::PATH_BLOCKED`).
pub(super) terrain: HashMap<u32, (u64, u8)>,
/// Targets whose land hasn't been answered yet, and since when it has been waited for.
waits: HashMap<u32, u64>,
}
impl Sam {
@@ -1124,6 +1146,8 @@ impl Sam {
heat: 0.0,
ew_lock: false,
flare_cd: 0,
terrain: HashMap::new(),
waits: HashMap::new(),
}
}
pub fn alive(&self) -> bool {
@@ -1384,6 +1408,7 @@ impl Sim {
jammed: false,
sector: 0,
smart: false,
nav: Nav::default(),
});
self.launched += 1;
}
@@ -1433,7 +1458,7 @@ impl Sim {
/// One game tick.
pub fn step(&mut self, ground: &mut Ground, player: Option<DVec3>, out: &mut Out) {
self.tick += 1;
self.plan_step(out);
self.plan_step(Some(&mut *ground), out);
self.adapt();
self.release_queue();
self.step_drones(ground, player, out);
@@ -1443,7 +1468,7 @@ impl Sim {
self.step_ew(out);
self.update_sight(ground);
self.step_lights(out);
self.step_sams(out);
self.step_sams(ground, out);
self.step_missiles(ground, out);
self.step_fx(ground, out);
self.draw_targets(out);
@@ -1473,7 +1498,7 @@ impl Sim {
let spec = d.kind.spec();
let below = ground.at(d.pos, now);
let from = d.pos;
ground.ahead(d.pos, d.vel, 20.0, now);
ground.ahead(d.pos, d.vel, (120.0 / d.vel.length().max(0.05)).max(20.0), now);
// Near its aim it looks at the land there again: a platform or hilltop blown away
// meanwhile, it goes for what is left below, not for the air where it stood.
if d.flight != Flight::Falling
@@ -1596,9 +1621,6 @@ impl Sim {
}
continue;
}
if d.flight == Flight::Cruise && d.route.len() <= 1 && horizontal(d.target - d.pos).length() < spec.dive {
d.flight = Flight::Dive;
}
// 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;
@@ -1614,24 +1636,86 @@ impl Sim {
{
d.route.remove(0);
}
let goal = if d.flight == Flight::Dive {
d.target
} else {
// Cruise height over the target, and `clearance` over the land ahead.
let ahead = ground.at(d.pos + horizontal(d.vel).normalize_or_zero() * 20.0, now);
let floor = match (below, ahead) {
(Some(a), Some(b)) => a.max(b) + spec.clearance,
(Some(g), None) | (None, Some(g)) => g + spec.clearance,
(None, None) => d.target.y + spec.clearance,
};
let to = d.route.first().copied().unwrap_or(d.target);
// Under the radars: as low as the land lets it, all the way.
let height = if d.low { floor.max(d.target.y + 4.0) } else { (d.target.y + spec.cruise).max(floor) };
DVec3::new(to.x, height, to.z)
let climb = d.kind.climb();
// A dive begins far enough out to bring it down onto the target: a missile that turns slowly needs a long
// way to lose height it has over the aim.
let dive_at = {
let r = spec.speed * 1.35 / (spec.turn * 3.5).max(0.01);
let drop = (d.pos.y - d.target.y).clamp(0.0, r);
spec.dive.max(((2.0 * r * drop - drop * drop).sqrt() * 1.1).min(spec.dive * 2.5))
};
let way = d.route.first().copied().unwrap_or(d.target);
let to_target = horizontal(d.target - d.pos).length();
// What the land ahead asks: along its heading and towards its next point, several seconds out.
if d.age <= 1 || (now + u64::from(d.id)) % 3 == 0 {
let heading = horizontal(d.vel).normalize_or_zero();
let towards = horizontal(way - d.pos).normalize_or_zero();
let look = d.kind.look_len();
let mut lines = vec![(heading, look)];
let angle = heading.angle_between(towards);
if towards != DVec3::ZERO && angle > 0.1 {
let reach = look.min(horizontal(way - d.pos).length() + 30.0);
lines.push((towards, reach));
if angle > 0.4 {
lines.push(((heading + towards).normalize_or_zero(), reach));
}
}
d.nav.need = terrain::required_height(ground, d.pos, &lines, spec.clearance, &climb).unwrap_or(f64::NEG_INFINITY);
d.nav.dive = if to_target < dive_at * 3.0 {
terrain::dive_height(ground, d.pos, d.target, terrain::DIVE_MARGIN).unwrap_or(f64::NEG_INFINITY)
} else {
f64::NEG_INFINITY
};
}
// The dive into the target starts when the line down to it is clear of the land, else it comes in higher;
// a dive that finds the line blocked pulls out and climbs.
if d.flight == Flight::Cruise && d.route.len() <= 1 && to_target < dive_at && d.pos.y >= d.nav.dive - 1.0 && d.vel.y < 0.3 * spec.speed {
d.flight = Flight::Dive;
} else if d.flight == Flight::Dive && to_target > 40.0 && d.pos.y < d.nav.dive - 4.0 {
d.flight = Flight::Cruise;
}
let rate = if d.flight == Flight::Dive { spec.turn * 3.5 } else { spec.turn };
let old = d.vel.normalize_or(DVec3::NEG_Z);
let new = turn(old, goal - d.pos, rate);
let probe_len = (spec.speed * 10.0).clamp(50.0, 150.0);
let new = if d.flight == Flight::Dive {
let mut new = turn(old, d.target - d.pos, rate);
// Something solid across the dive, short of the target (a building the heights don't show): up and round.
if to_target > 40.0
&& d.age > 8
&& let world::Sweep::Hit { point, .. } = ground.world.probe(d.pos, d.pos + new * probe_len.min(to_target - 20.0), now)
&& point.distance(d.target) > 16.0
{
d.flight = Flight::Cruise;
d.nav.need = d.nav.need.max(point.y + spec.clearance);
new = turn(old, horizontal(way - d.pos).normalize_or(horizontal(old)) + DVec3::Y * climb.pitch.tan(), spec.turn);
}
new
} else {
let heading = horizontal(way - d.pos).normalize_or(horizontal(old).normalize_or(DVec3::NEG_Z));
// Cruise height over the target; as low as the land lets it when under the radars.
let base = if d.low { d.target.y + 4.0 } else { d.target.y + spec.cruise };
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 full = climb.pitch.tan();
let urgent = need.is_finite() && d.pos.y < need;
let slope = if urgent {
full
} else {
((height - d.pos.y) / (climb.radius * 1.5).min((to_target * 0.5).max(30.0)).max(30.0)).clamp(-0.35, full)
};
let mut new = terrain::fly_towards(old, heading, slope, rate, urgent);
// Something solid ahead that the heights don't show (trees, a building, an overhang): climb.
if d.age > 8
&& let world::Sweep::Hit { point, .. } =
ground.world.probe(d.pos - DVec3::Y * 4.0, d.pos - DVec3::Y * 4.0 + new * probe_len, now)
{
d.nav.need = d.nav.need.max(point.y + spec.clearance);
new = terrain::fly_towards(old, heading, full, rate, true);
}
terrain::clamp_pitch(new, -0.6, climb.pitch, heading)
};
let side = old.cross(new).y;
d.roll += ((side * 40.0).clamp(-0.8, 0.8) - d.roll) * 0.15;
let speed = if d.flight == Flight::Dive { spec.speed * 1.35 } else { spec.speed };
@@ -1733,6 +1817,7 @@ impl Sim {
jammed: false,
sector,
smart: false,
nav: Nav::default(),
});
}
for d in &self.drones {
@@ -1884,7 +1969,7 @@ impl Sim {
// ------------------------------------------------ air defence
fn step_sams(&mut self, out: &mut Out) {
fn step_sams(&mut self, ground: &mut Ground, out: &mut Out) {
if self.hold {
for i in 0..self.sams.len() {
if self.sams[i].alive() && !self.sams[i].moving() {
@@ -1923,6 +2008,11 @@ impl Sim {
sam.dish = sam.beam;
}
sam.jam = sam.jam.saturating_sub(1);
if self.tick % 64 == 0 {
let now = self.tick;
sam.terrain.retain(|_, (when, _)| now.saturating_sub(*when) < 4 * terrain::VERDICT_AGE);
sam.waits.retain(|_, since| now.saturating_sub(*since) < 4 * terrain::WAIT_TICKS);
}
if sam.reloading > 0 {
sam.reloading -= 1;
if sam.reloading == 0 {
@@ -1950,7 +2040,10 @@ impl Sim {
let (had_target, had_gun) = (self.sams[i].target, self.sams[i].gun_target);
let now = self.tick;
let (target, gun_target) = {
let tracks = &self.sams[i].tracks;
let me = &self.sams[i];
let tracks = &me.tracks;
// A shot the land ruled out a moment ago is not tried again at once.
let open = |id: u32| me.terrain_verdict(id, now).is_none_or(|b| b == 0);
// The conditions are `why`'s, shared with the diagnostic. A system knows its targets by their tracks:
// the forecast of the last measurement, never the true place.
let drone = |tr: &Track| drone_at.get(&tr.id).map(|&k| &self.drones[k]);
@@ -1958,7 +2051,7 @@ impl Sim {
let needed = why::needed;
let target = spec.missile.as_ref().map(|load| {
let ok = |tr: &Track, k: f64| {
drone(tr).is_some_and(|d| why::allowed(roles, tr, now, d) && why::in_reach(load.min, load.max, k, pos, tr.est(now)))
drone(tr).is_some_and(|d| why::allowed(roles, tr, now, d) && open(tr.id) && why::in_reach(load.min, load.max, k, pos, tr.est(now)))
};
let keep = had_target.filter(|t| tracks.iter().any(|tr| tr.id == *t && ok(tr, 1.1)));
keep.or_else(|| {
@@ -2133,9 +2226,38 @@ impl Sim {
// The missile turns after its boost, but the launcher must point near the target.
let t = sam.track(mt).filter(|_| drone_at.contains_key(&mt)).map(|tr| tr.est(now));
let aligned = why::missile_aligned(spec.family, sam.yaw, mount, t, aim_id, mt, aim_error);
if ready && aligned {
let n = load.magazine - sam.missiles;
let (muzzle, dir) = sam.muzzle(n);
let n = load.magazine - sam.missiles;
let (muzzle, dir) = sam.muzzle(n);
// The land: a target behind a hill, or a path the hill closes, is not fired at; the system looks for another.
let land = if ready && aligned {
match (sam.terrain_verdict(mt, now), sam.track(mt)) {
(Some(bits), _) => bits,
(None, Some(tr)) => {
let mut bits = terrain::launch_blocks(ground, load.kind, muzzle, sight::antenna(sam), tr.est(now), tr.vel);
if bits == terrain::WAIT {
// The land is being asked for: a moment's wait, not a long one (the game may not answer).
if now.saturating_sub(*sam.waits.entry(mt).or_insert(now)) > terrain::WAIT_TICKS {
bits = 0;
}
} else {
sam.waits.remove(&mt);
if bits != 0 {
sam.terrain.insert(mt, (now, bits));
}
}
bits
}
(None, None) => 0,
}
} else {
0
};
if land == terrain::WAIT {
// The land under the shot is not known yet: it waits.
} else if land != 0 {
retarget(&mut missile_on, Some(mt), None);
sam.target = None;
} else if ready && aligned {
launches.push((load.kind, muzzle, dir, mt, sam.id));
// Its missile is on it from now, so the systems after this one this tick see
// the target taken; this one's next launch looks for another.
@@ -2204,7 +2326,7 @@ impl Sim {
let now = self.tick;
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 = 0u32;
let (mut blocked, mut scrubbed) = (0u32, 0u32);
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();
for m in &mut self.missiles {
m.age += 1;
@@ -2223,7 +2345,14 @@ impl Sim {
let los = (t.pos - m.pos).normalize_or_zero();
let closing = (speed - t.vel.dot(los)).max(1.0);
let tgo = t.pos.distance(m.pos) / closing;
turn(dir, t.pos + t.vel * tgo - m.pos, spec.turn)
// 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, t.pos + t.vel * tgo);
if fail {
scrubbed += 1;
done.push((m.id, Some(t.id), false, m.sam));
continue;
}
turn(dir, want, spec.turn)
}
// After a cold launch it tips over towards the target while the motor burns.
Some(t) if lit && spec.cold > 0 => turn(dir, t.pos - m.pos, spec.turn * 0.5),
@@ -2303,6 +2432,7 @@ impl Sim {
t.expired += expired;
t.orphaned += orphaned;
t.blocked += blocked;
t.scrubbed += scrubbed;
});
for (mid, target, kill, sam) in done {
if let Some(i) = self.missiles.iter().position(|m| m.id == mid) {
+108 -6
View File
@@ -84,6 +84,12 @@ pub(super) struct Plan {
approach: Approach,
total: u32,
centre: DVec3,
/// The land under the grid's cells (NaN where the game has none to tell of), the cells still unanswered, and
/// the ticks the planner has waited for the answers. A smart raid is routed over the land, not round it.
heights: Vec<f32>,
unanswered: Vec<u32>,
waited: u32,
mapped: bool,
}
/// The raid in progress: the counts at its start, for the report.
@@ -123,6 +129,20 @@ impl Grid {
/// The cheapest way over `grid` from `from` to `to` by its prices (A*): points along it, `to` last.
pub(super) fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> (Vec<DVec3>, usize) {
plan_route_on(grid, prices, None, from, to)
}
/// The land a route crosses: its height under each cell (NaN unknown), the climb a kind holds for long
/// (rise a block), and the height above which the land forces a drone up from its low altitude.
pub(super) struct Relief<'a> {
pub heights: &'a [f32],
pub slope: f32,
pub floor: f32,
}
/// [`plan_route`] over relief: a climb steeper than the kind holds is dear (a cliff or a ridge it would have to
/// pull up over), and high land costs more than a valley, where it can fly low and stay under the radars.
pub(super) fn plan_route_on(grid: &Grid, prices: &[f32], relief: Option<&Relief>, from: DVec3, to: DVec3) -> (Vec<DVec3>, usize) {
let mut opened = 0;
let n = grid.size;
let at = |(i, j): (i32, i32)| (i * n + j) as usize;
@@ -151,7 +171,21 @@ pub(super) fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) ->
}
let len = if di != 0 && dj != 0 { std::f32::consts::SQRT_2 } else { 1.0 };
// The target's own cell is where it must go, whatever covers it.
let price = if nb == goal { len } else { len * prices[at(nb)] };
let mut price = if nb == goal { len } else { len * prices[at(nb)] };
if let Some(r) = relief
&& nb != goal
{
let (h0, h1) = (r.heights[at(c)], r.heights[at(nb)]);
if h1.is_finite() {
price += len * ((h1 - r.floor) / 30.0).clamp(0.0, 4.0);
if h0.is_finite() {
let slope = (h1 - h0) / (len * grid.cell as f32);
if slope > r.slope {
price += (slope - r.slope) * 20.0 * len;
}
}
}
}
let ng = g + price;
if ng < best[at(nb)] {
best[at(nb)] = ng;
@@ -250,15 +284,53 @@ impl Sim {
approach: order.approach,
total,
centre,
heights: Vec::new(),
unanswered: Vec::new(),
waited: 0,
mapped: !smart,
});
// A raid without routes is ready at once.
self.plan_step(out);
self.plan_step(None, out);
total
}
/// Works on the raid's plan a while (a tick's share), and launches it when it is ready.
pub(super) fn plan_step(&mut self, out: &mut Out) {
pub(super) fn plan_step(&mut self, ground: Option<&mut Ground>, out: &mut Out) {
let Some(mut plan) = self.plan.take() else { return };
// A smart raid first learns the land under its grid: asked for in bulk, a thousand columns a tick, for
// up to two seconds (what the game has no land for stays unknown and costs nothing).
if !plan.mapped {
let Some(ground) = ground else {
self.plan = Some(plan);
return;
};
let n = plan.grid.size;
if plan.heights.is_empty() {
plan.heights = vec![f32::NAN; (n * n) as usize];
plan.unanswered = (0..(n * n) as u32).collect();
}
let Plan { grid, heights, unanswered, .. } = &mut plan;
unanswered.retain(|&k| {
let (i, j) = (k as i32 / n, k as i32 % n);
let c = grid.centre((i, j), 0.0);
match ground.column(c.x.floor() as i32, c.z.floor() as i32, 1024) {
None => true,
Some(h) => {
if let Some(h) = h {
heights[k as usize] = h as f32;
}
false
}
}
});
plan.waited += 1;
if plan.unanswered.is_empty() || plan.waited >= 40 {
plan.mapped = true;
} else {
self.plan = Some(plan);
return;
}
}
let mut work = 0usize;
while plan.next < plan.items.len() && work < WORK_PER_TICK {
let i = plan.next;
@@ -268,17 +340,39 @@ impl Sim {
};
let spec = kind.spec();
let mut d = plan.items[i].d.clone();
// It starts over the land, not in it: a hill under the starting point lifts it.
let mut from = from;
if !plan.heights.is_empty() {
let (gi, gj) = plan.grid.index(from);
let g = plan.heights[(gi * plan.grid.size + gj) as usize];
let floor = f64::from(g) + if spec.ballistic.is_some() { 10.0 } else { spec.clearance + 10.0 };
if g.is_finite() && from.y < floor {
from.y = floor;
d.pos.y = floor;
if spec.ballistic.is_some() {
let flat = horizontal(aim - from);
let time = (flat.length() / spec.speed).max(30.0);
d.vel = flat / time + DVec3::Y * ((aim.y - from.y + 0.5 * GRAVITY * time * time) / time);
}
}
}
let mut path = from.distance(aim);
if let Some(key) = key {
let route = match plan.routes.get(&key) {
Some(r) => r.clone(),
None => {
if !plan.prices.contains_key(&kind) {
let (p, cost) = self.price_grid(&plan.grid, kind);
let heights = (!plan.heights.is_empty()).then_some(plan.heights.as_slice());
let (p, cost) = self.price_grid_on(&plan.grid, kind, heights);
work += cost;
plan.prices.insert(kind, p);
}
let (r, cost) = plan_route(&plan.grid, &plan.prices[&kind], from, aim);
let relief = (!plan.heights.is_empty()).then(|| Relief {
heights: &plan.heights,
slope: (kind.climb_pitch().tan() * 0.5) as f32,
floor: (aim.y + spec.clearance) as f32,
});
let (r, cost) = plan_route_on(&plan.grid, &plan.prices[&kind], relief.as_ref(), from, aim);
work += cost;
plan.routes.insert(key, r.clone());
r
@@ -357,6 +451,7 @@ impl Sim {
jammed: false,
sector: 0,
smart: false,
nav: Nav::default(),
}
}
@@ -404,6 +499,12 @@ impl Sim {
/// The price of each cell of `grid` for this kind flying low: 1, and much more where a
/// system sees it there (its radar's reach for it, the land) and can shoot it.
pub(super) fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec<f32>, usize) {
self.price_grid_on(grid, kind, None)
}
/// The same over known land: a cell's drone flies `height` over the land there, not over the system's feet,
/// so what a radar sees and what a hill hides follow the relief.
pub(super) fn price_grid_on(&self, grid: &Grid, kind: DroneKind, land: Option<&[f32]>) -> (Vec<f32>, usize) {
let mut cost = 0;
let spec = kind.spec();
let height = spec.clearance.max(6.0);
@@ -446,7 +547,8 @@ impl Sim {
for i in (ci - r).max(0)..=(ci + r).min(n - 1) {
for j in (cj - r).max(0)..=(cj + r).min(n - 1) {
cost += 1;
let p = grid.centre((i, j), s.pos.y + height);
let ground = land.map(|l| l[(i * n + j) as usize]).filter(|g| g.is_finite());
let p = grid.centre((i, j), ground.map_or(s.pos.y + height, |g| f64::from(g) + height));
let d = horizontal(p - s.pos).length();
if d > far {
continue;
+4
View File
@@ -65,6 +65,8 @@ pub struct Tally {
pub ammo_waits: u32,
/// Interceptors lost against the land or a building on their way.
pub blocked: u32,
/// Interceptors that blew up in the air short of land they could not climb over.
pub scrubbed: u32,
}
impl Tally {
@@ -76,6 +78,7 @@ impl Tally {
tracks_lost: self.tracks_lost.saturating_sub(start.tracks_lost),
ammo_waits: self.ammo_waits.saturating_sub(start.ammo_waits),
blocked: self.blocked.saturating_sub(start.blocked),
scrubbed: self.scrubbed.saturating_sub(start.scrubbed),
}
}
/// The non-zero counts as a line, or nothing.
@@ -87,6 +90,7 @@ impl Tally {
(self.tracks_lost, "потерь трека"),
(self.ammo_waits, "ожиданий БК"),
(self.blocked, "ракет разбились о препятствие"),
(self.scrubbed, "ракет самоуничтожились у рельефа"),
]
.into_iter()
.filter(|p| p.0 > 0)
+253 -2
View File
@@ -187,7 +187,8 @@ fn turret_waits_for_its_aim() {
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(0.0, 70.0, 0.0), 0.0));
// Straight behind the turret's heading, a slow target well inside the gun's reach.
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, 60.0), DVec3::ZERO, DVec3::new(0.0, 70.0, 900.0));
sim.drones[0].vel = DVec3::ZERO;
// (Pointing away from the gun, so that it doesn't start by turning back over it.)
sim.drones[0].vel = DVec3::Z * 0.01;
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut turned = 0;
for _ in 0..200 {
@@ -392,7 +393,9 @@ fn drone_flying_into_a_hill_explodes_at_its_face() {
let mut sim = Sim::new(3);
sim.damage = false;
let mut ground = Ground::default();
sim.add_drone(kind, DVec3::new(0.0, 80.0, 0.0), DVec3::X, DVec3::new(600.0, 80.0, 0.0));
// The aim is on the far side of the face, close enough that the dive is not called off for the hill (it is
// below, at the end of the dive, where flight planning leaves it alone).
sim.add_drone(kind, DVec3::new(0.0, 80.0, 0.0), DVec3::X, DVec3::new(80.0, 80.0, 0.0));
sim.drones[0].flight = Flight::Dive;
for _ in 0..300 {
if sim.drones.is_empty() {
@@ -547,3 +550,251 @@ fn fires_start_merge_and_are_limited() {
assert!(fl > 0.0 && sm > 0.0);
assert_eq!(fires::level(3000.0, 3000.0).0, 0.0);
}
// ---- flight over the land: strike drones follow it, launchers keep their missiles for shots the land allows,
// interceptors keep clear of it. A synthetic land (a height function) stands in for the game.
/// Land from a height function: blocks below it are solid, and the game's height map says the same.
struct Land<F: Fn(i32, i32) -> i32>(F);
impl<F: Fn(i32, i32) -> i32> world::WorldSource for Land<F> {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
Some(y < (self.0)(x, z))
}
}
/// What the game answers a tick after the sim asked: land heights, and the sections it needs.
fn answer_land<F: Fn(i32, i32) -> i32>(ground: &mut Ground, land: &Land<F>, now: u64) {
let columns = ground.take_wanted().into_iter().map(|(x, z)| (x, z, (land.0)(x, z))).collect();
ground.answer(columns, now);
let sections = ground.world.take_wanted(now);
ground.world.serve(land, sections, now);
}
/// A ridge across the way, `rise` blocks high over a flat at 64, `half` blocks of slope to each side, crest at x=300.
fn ridge(rise: i32, half: i32) -> Land<impl Fn(i32, i32) -> i32> {
ridge_at(300, rise, half)
}
fn ridge_at(crest: i32, rise: i32, half: i32) -> Land<impl Fn(i32, i32) -> i32> {
Land(move |x, _| 64 + (rise - (x - crest).abs() * rise / half).max(0))
}
/// Flies one drone of `kind` along +x over `land` from x=0 to its aim at x=750, to the end. Returns the sim.
fn fly_over<F: Fn(i32, i32) -> i32>(kind: DroneKind, land: &Land<F>, low: bool) -> Sim {
let mut sim = Sim::new(5);
sim.damage = false;
let mut ground = Ground::default();
let spec = kind.spec();
let height = if spec.cruise > 0.0 { spec.cruise } else { 25.0 };
sim.add_drone(kind, DVec3::new(0.0, 64.0 + height, 0.0), DVec3::X, DVec3::new(750.0, 64.0, 0.0));
sim.drones[0].low = low;
for _ in 0..4 {
sim.tick += 1;
answer_land(&mut ground, land, sim.tick);
}
let mut out = Out::default();
for _ in 0..4000 {
if sim.drones.is_empty() {
break;
}
sim.tick += 1;
sim.step_drones(&mut ground, None, &mut out);
answer_land(&mut ground, land, sim.tick);
}
sim
}
// Strike drones climb over a hill they could not have seen 20 blocks ahead.
#[test]
fn a_raid_crossing_a_hill_flies_over_it() {
for (rise, half) in [(60, 100), (60, 60), (90, 120)] {
let land = ridge(rise, half);
for kind in [DroneKind::Shahed, DroneKind::Geran3, DroneKind::Lancet, DroneKind::Gerbera, DroneKind::Kh101, DroneKind::Kalibr, DroneKind::Kh22, DroneKind::Zircon] {
for low in [false, true] {
let sim = fly_over(kind, &land, low);
assert!(sim.drones.is_empty(), "{kind:?}: still flying");
// It bursts at its aim, not on the hill 450 blocks short of it (where a crash or a decoy's end shows too).
let at = sim.blasts.first().expect("no blast").at;
assert!(at.x > 700.0, "{kind:?} low={low} burst at x={:.0} over a {rise}-block hill ({half} to the crest)", at.x);
}
}
}
}
// A low drone hugs the land: it does not fly at cruise height over the flat behind the ridge.
#[test]
fn a_low_drone_hugs_the_land_after_the_ridge() {
let land = ridge(60, 100);
let mut sim = Sim::new(5);
sim.damage = false;
let mut ground = Ground::default();
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, 0.0), DVec3::X, DVec3::new(900.0, 64.0, 0.0));
sim.drones[0].low = true;
let mut out = Out::default();
let mut highest_after = 0.0f64;
for _ in 0..4000 {
if sim.drones.is_empty() {
break;
}
sim.tick += 1;
sim.step_drones(&mut ground, None, &mut out);
answer_land(&mut ground, &land, sim.tick);
if let Some(d) = sim.drones.first()
&& d.pos.x > 560.0
&& d.pos.x < 800.0
{
highest_after = highest_after.max(d.pos.y);
}
}
assert_eq!(sim.crashes, 0);
assert!(highest_after < 64.0 + 40.0, "still {highest_after:.0} high far behind the ridge");
}
/// A target flying slowly along the ridge's far side at `x`, with a radar on the crest to see it.
fn behind_ridge(land: &Land<impl Fn(i32, i32) -> i32>, crest: i32, kind: SamKind, x: f64, ticks: u32) -> Sim {
let mut sim = Sim::new(9);
sim.damage = false;
let ground_y = (land.0)(0, 0) as f64;
let id = sim.id();
sim.sams.push(Sam::new(id, kind, DVec3::new(0.0, ground_y, 0.0), 0.0));
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(f64::from(crest), (land.0)(crest, 0) as f64, -60.0), 0.0));
let mut ground = Ground::default();
let mut out = Out::default();
for _ in 0..40 {
sim.tick += 1;
answer_land(&mut ground, land, sim.tick);
}
// It crawls along the far side of the ridge, 20 blocks over the land.
let there = (land.0)(x as i32, 0) as f64;
sim.add_drone(DroneKind::Gerbera, DVec3::new(x, there + 20.0, -100.0), DVec3::Z, DVec3::new(x, there, 3000.0));
for _ in 0..ticks {
sim.step(&mut ground, None, &mut out);
answer_land(&mut ground, land, sim.tick);
}
sim
}
// A short-range system behind a hill does not fire at a target the hill masks from it, whatever the network says.
#[test]
fn a_system_does_not_fire_at_a_target_masked_by_a_hill() {
// A short-range system 250 blocks short of the crest, the target 80 blocks beyond it.
type Boxed = Land<Box<dyn Fn(i32, i32) -> i32>>;
let hill: Boxed = Land(Box::new(|x, _| 64 + (60 - (x - 150).abs()).max(0)));
let flat: Boxed = Land(Box::new(|_, _| 64));
let shot = |land: &Boxed| {
let mut sim = Sim::new(9);
sim.damage = false;
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Tor, DVec3::new(100.0, 64.0, 0.0), 0.0));
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(150.0, (land.0)(150, 0) as f64, -60.0), 0.0));
let mut ground = Ground::default();
let mut out = Out::default();
for _ in 0..40 {
sim.tick += 1;
answer_land(&mut ground, land, sim.tick);
}
sim.add_drone(DroneKind::Gerbera, DVec3::new(200.0, 84.0, -60.0), DVec3::Z, DVec3::new(200.0, 64.0, 3000.0));
let mut masked = 0u32;
let mut seen = false;
let mut told = false;
for _ in 0..300 {
sim.step(&mut ground, None, &mut out);
answer_land(&mut ground, land, sim.tick);
seen |= sim.sams[0].seen.len() == 1;
masked += sim.sams[0].terrain.values().filter(|(_, b)| *b == terrain::MASKED).count() as u32;
// `why` names the reason while the verdict stands.
told |= sim.why(0).iter().any(|c| !c.ok && c.text.contains("за рельефом"));
}
(sim, seen, masked, told)
};
let (open, seen, _, _) = shot(&flat);
assert!(seen && open.sams[0].fired > 0, "control: with a clear line the Tor must fire (seen {seen}, fired {})", open.sams[0].fired);
let (shut, seen, masked, told) = shot(&hill);
assert!(seen, "the network must have shown the target to the Tor");
assert_eq!(shut.sams[0].fired, 0, "the Tor fired at a target behind the hill");
assert!(masked > 0, "no launch was judged and refused for the land");
assert_eq!(shut.tally.blocked + shut.tally.scrubbed, 0);
assert!(told, "`why` never named the land as the reason");
}
// A long-range system lofts over a low ridge (its interceptor does not hit it) but not over a mountain.
#[test]
fn a_long_range_system_lofts_over_a_ridge_or_waits() {
let low = ridge_at(150, 25, 60);
let sim = behind_ridge(&low, 150, SamKind::IrisT, 230.0, 300);
assert!(sim.sams[0].fired > 0, "an IRIS-T SLM must loft over a 25-block ridge");
assert_eq!(sim.tally.blocked + sim.tally.scrubbed, 0, "its interceptors hit the ridge");
let tall = ridge_at(150, 400, 100);
let sim = behind_ridge(&tall, 150, SamKind::IrisT, 230.0, 300);
assert_eq!(sim.sams[0].fired, 0, "no missile can climb a 400-block mountain at this range");
assert_eq!(sim.tally.blocked + sim.tally.scrubbed, 0);
assert!(sim.why(0).iter().any(|c| !c.ok && c.text.contains("рельеф")), "{:?}", sim.why(0).iter().map(|c| &c.text).collect::<Vec<_>>());
}
// Interceptors fly over rolling land at a target low over it, and none ends in the ground.
#[test]
fn an_interceptor_over_rolling_land_does_not_hit_the_ground() {
let rolling = Land(|x: i32, z: i32| 70 + (28.0 * ((f64::from(x) / 21.0).sin() + (f64::from(z) / 33.0).cos()) / 2.0).round() as i32);
for kind in [MissileKind::Pac3, MissileKind::M48n6, MissileKind::IrisT, MissileKind::M57e6, MissileKind::M9m338, MissileKind::P1sun] {
for dx in [160.0, 260.0, 380.0] {
let mut sim = Sim::new(4);
sim.damage = false;
let mut ground = Ground::default();
let mut out = Out::default();
for _ in 0..4 {
sim.tick += 1;
answer_land(&mut ground, &rolling, sim.tick);
}
// A Gerbera 20 blocks over the land at the far end, flying away from the launcher.
let at = DVec3::new(dx, (rolling.0)(dx as i32, 0) as f64 + 20.0, 0.0);
sim.add_drone(DroneKind::Gerbera, at, DVec3::X, DVec3::new(5000.0, 64.0, 0.0));
let (target, id) = (sim.drones[0].id, sim.id());
let from = DVec3::new(0.0, (rolling.0)(0, 0) as f64 + 3.0, 0.0);
sim.drones[0].engaged = 1;
sim.missiles.push(Missile { id, kind, pos: from, vel: DVec3::new(0.5, 0.8, 0.0).normalize() * 0.8, target, sam: 0, age: 0 });
for _ in 0..500 {
if sim.missiles.is_empty() {
break;
}
sim.tick += 1;
sim.step_missiles(&mut ground, &mut out);
answer_land(&mut ground, &rolling, sim.tick);
}
assert!(sim.missiles.is_empty(), "{kind:?} at {dx}: still flying");
assert_eq!(sim.tally.blocked, 0, "{kind:?} at {dx}: the interceptor hit the ground");
}
}
}
// An interceptor with no way over the land blows up in the air, and that is counted.
#[test]
fn an_interceptor_without_a_way_over_blows_up_high() {
let wall = ridge(300, 40);
let mut sim = Sim::new(4);
sim.damage = false;
let mut ground = Ground::default();
let mut out = Out::default();
for _ in 0..4 {
sim.tick += 1;
answer_land(&mut ground, &wall, sim.tick);
}
sim.add_drone(DroneKind::Gerbera, DVec3::new(420.0, 90.0, 0.0), DVec3::X, DVec3::new(5000.0, 64.0, 0.0));
let (target, id) = (sim.drones[0].id, sim.id());
sim.drones[0].engaged = 1;
sim.missiles.push(Missile { id, kind: MissileKind::M57e6, pos: DVec3::new(230.0, 70.0, 0.0), vel: DVec3::X * 3.0, target, sam: 0, age: 20 });
for _ in 0..200 {
if sim.missiles.is_empty() {
break;
}
sim.tick += 1;
sim.step_missiles(&mut ground, &mut out);
answer_land(&mut ground, &wall, sim.tick);
}
assert!(sim.missiles.is_empty());
assert_eq!((sim.tally.scrubbed, sim.tally.blocked), (1, 0), "{:?}", sim.tally);
assert_eq!(sim.drones[0].engaged, 0);
let at = sim.blasts.first().expect("no blast").at;
assert!(at.y > (wall.0)(at.x as i32, 0) as f64 + 3.0, "it burst in the ground at {at:?}");
}
+344
View File
@@ -0,0 +1,344 @@
//! 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.
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.
/// Unlike `at` it never asks again for a column it has, 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,
}
}
/// 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, _)) if y > -64 => Rise::At(f64::from(y)),
Some(_) => 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, _)) => Some((y > -64).then_some(f64::from(y))),
None => {
if 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()
}
/// 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> {
let mut need: Option<f64> = None;
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)));
}
}
}
need
}
/// 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) -> (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) {
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)
});
}
(raised, fail)
}
+13 -1
View File
@@ -308,7 +308,19 @@ impl Sim {
let aligned = missile_aligned(spec.family, sam.yaw, mount, Some(est), t.id, t.id, err);
let turn = if vertical { "вертикальный старт" } else { "доворот" };
v.push(check(aligned, format!("{turn}: ошибка наведения {:.0}°{}", err.to_degrees(), if aligned { "" } else { " — ещё доворачивается" })));
fires |= reach == 0 && free < need && mb == 0 && aligned && blocks == 0;
// The land: the verdict the launch decision gave this target (`terrain::launch_blocks`) a moment ago.
let land = sam.terrain_verdict(t.id, now).unwrap_or(0);
if land != 0 {
v.push(check(
false,
if land & terrain::MASKED != 0 {
"рельеф: цель за рельефом — ракета не стартует, ждём, пока цель выйдет из-за него"
} else {
"рельеф: траектория перекрыта рельефом — подняться над ним ракета не может"
},
));
}
fires |= reach == 0 && free < need && mb == 0 && aligned && blocks == 0 && land == 0;
}
if let Some(gun) = &spec.gun {
let on_it = guns_on.get(&t.id).copied().unwrap_or(0) - u32::from(sam.gun_target == Some(t.id));
+12 -1
View File
@@ -94,6 +94,8 @@ pub struct WorldCache {
/// Sections asked, and when.
asked: HashMap<Sec, u64>,
epoch: u32,
/// Sweeps made as a look ahead (flight planning) ask for sections after the urgent ones.
quiet: bool,
pub stats: WorldStats,
}
@@ -277,7 +279,7 @@ impl WorldCache {
let s = sec_of(x, y, z);
if *last != Some(s) {
*last = Some(s);
self.touch(s, true, now);
self.touch(s, !self.quiet, now);
}
match &self.secs.get(&s)?.fill {
Fill::Unknown => None,
@@ -301,6 +303,15 @@ impl WorldCache {
self.solid(&mut None, x, y, z, now) == Some(true)
}
/// A sweep made to plan a flight, not to move one: the sections it needs are asked after the urgent ones,
/// and a start inside a block is not a hit.
pub fn probe(&mut self, a: DVec3, b: DVec3, now: u64) -> Sweep {
self.quiet = true;
let r = self.sweep(a, b, true, now);
self.quiet = false;
r
}
/// The first solid block along `a` to `b`, by walking the blocks it
/// crosses; `skip_start` ignores the block it starts in (a launch from a
/// pad that is part of the land).
+36 -7
View File
@@ -19,9 +19,13 @@ Horizon действует правило clean-room. Исключение — P
| Что | Где |
|---|---|
| Весь HD-конвейер: цели, проходы, WGSL-шейдеры (строками), тайлы света, AO, тени, bloom, tonemap, TAA | `horizon/crates/horizon-client/src/gfx/hd.rs` |
| Объёмные облака (рэймарч слоя, тени облаков на землю) и объёмный туман (лучи света через тени каскадов), их цели и проходы; CPU-шум (2D, 3D Worley) | `horizon/crates/horizon-client/src/gfx/hd_volumes.rs`, `hd_noise.rs` |
| Атмосфера: сборка текстуры неба (WGSL), чтение неба/дымки/тона света в шейдерах, цвет солнца (`sun_transmittance`) | `horizon/crates/horizon-client/src/gfx/hd_sky.rs` (+ `THIRD_PARTY_PHOTON.md`) |
| Оркестровка HD: `Hd`, общие цели, буфер кадра, `resize`/`write`, сборка шейдеров (`assemble`: прелюдия + тело), контракты цвета/глубины/альф/времени (комментарий в начале файла) | `horizon/crates/horizon-client/src/gfx/hd/mod.rs` |
| Униформ кадра `HdFrame` (Rust) и сцена `HdScene`/`HdLight`/`HdSettings`, свет солнца/луны/облаков (`light_of`, `cloud_light`); общая WGSL-прелюдия (`HdFrame`, `FullOut`, `vs_full`) | `gfx/hd/frame.rs`, `gfx/hd/prelude.wgsl` |
| Тени (каскады, `Shadows`, `cascades()`); G-буфер (`GBuffer`, форматы) | `gfx/hd/shadows.rs`; `gfx/hd/gbuffer.rs` |
| AO (GTAO + блюр); освещение `fs_light` (+ тайлы источников: `tiles.rs`); вода (проход геометрии и композит) | `gfx/hd/ao.rs` + `ao.wgsl`, `ao_blur.wgsl`; `gfx/hd/lighting.rs` + `light.wgsl`, `gfx/hd/tiles.rs`; `gfx/hd/water.rs` + `water.wgsl` |
| TAA/TAAU и джиттер; bloom; tonemap (+ `Hd::finish`) | `gfx/hd/taa.rs` + `taa.wgsl`; `gfx/hd/bloom.rs` + `bloom.wgsl`; `gfx/hd/tonemap.rs` + `tonemap.wgsl` |
| Объёмные облака (рэймарч слоя, тени облаков на землю) и объёмный туман, их цели и проходы; CPU-шум (2D, 3D Worley) | `gfx/hd/volumes.rs` + `cloud_common.wgsl`, `cloud.wgsl`, `cloud_shadow.wgsl`, `fog.wgsl`; `gfx/hd/noise.rs` |
| Атмосфера: сборка текстуры неба (WGSL), чтение неба/дымки/тона света в шейдерах, цвет солнца (`sun_transmittance`) | `gfx/hd/sky.rs` + `sky.wgsl`, `sky_funcs.wgsl` (+ `THIRD_PARTY_PHOTON.md`) |
| Встраивание в кадр: G-буфер-проход, каскады теней, вызовы `hd.light` / `hd.finish`, джиттер, тайминги | `horizon/crates/horizon-client/src/gfx.rs` (`Renderer::render`, ищи `hd_on`, `AirDef fork:`) |
| G-буфер террейна: `fs_gbuffer`, `fs_shadow` | `horizon/crates/horizon-client/src/terrain.wgsl` |
| G-буфер сущностей и моделей мода: `fs_entity_gbuffer`, `fs_entity_shadow` | `horizon/crates/horizon-client/src/entity.wgsl` |
@@ -120,6 +124,28 @@ Bind groups кэшируются и пересоздаются только пр
Композит воды (`WATER`, group 0): 0 `HdFrame`, 1 `lit`, 2 глубина G-буфера, 3 цвет воды, 4 нормаль воды, 5 глубина воды, 6–7 тени, 8 лайтмап, 9 сэмплер, 10 текстура неба. Проход неба (`SKY_PASS`, group 0): только `HdFrame`.
## Контракты (цвет, глубина, альфа, время)
Полная таблица «проход: что читает → что пишет» — в начале `gfx/hd/mod.rs`, подробности — в начале файла каждого прохода.
- **Цвет.** От освещения до tonemap всё в «показанном» пространстве ванили (гамма, основные цвета Rec. 709), значения могут быть > 1
(`Rgba16Float`); tonemap не трогает ≤ 0.8. Исключения: текстура неба — линейная яркость Rec. 709 (марш в Rec. 2020; показывают
читатели через `sky_show`); albedo G-буфера — `Rgba8Unorm` в том же показанном пространстве.
- **Глубина.** Камера: reversed-Z, `Depth32Float`, 1 — близко, 0 — далеко/ничего (очистка в 0; глубина воды 0 — воды нет). Тени:
обычная Z ортопроекции света (0 — у света, 1 — далеко), сравнение `<=`.
- **Альфа.** albedo.a — эмиссия (свет добавляет `rgb·a·1.5`); `lit.a`: 1 — обычный пиксель, 0.5 — вода (в rgb дно без воздуха),
0.6 — прозрачное перед водой; `air.a` — пропускание воздуха; облака и туман: `a` — пропускание (`c·a + rgb`); вода: цвет.a —
свет блоков, нормаль.a — свет неба; история TAA: a — дистанция; AO: x — окклюзия, y — дистанция.
- **Координаты.** Позиции относительно камеры; для узоров (волны, шум облаков, ветер) мировая координата передаётся по модулю периода
(256, 65536). Мировая проекция с джиттером; `prev_view_proj` — с джиттером для `fs_taa` и без для `fs_taau` (выбирает `Hd::write`).
- **Время.** Три разных часа: счётчик кадров (`params.w`, mod 64, вращение шума), время воды (`water.y`, секунды стенных часов mod 600),
игровое время (`clouds.w`, секунды `HdScene.seconds` mod 300000, ветер облаков). Реплей, замораживающий игровое время, не
останавливает воду.
- **Истории.** Две цели TAA и две цели облаков принадлежат своим проходам, создаются в `resize`, сбрасываются при resize, телепорте
(> 64 блоков), выключенном TAA; один `Hd` обслуживает одну камеру, истории разных камер не смешиваются.
- **Размеры.** world — масштабированная цель мира (G-буфер, AO ½, свет, вода, облака/туман ½), out — окно (TAA, bloom, tonemap).
- **Bind groups** кэшируются по видам, которые читают; пересоздаются при resize и смене входного вида; нет роста в установившемся кадре.
## Как проверять
```bash
@@ -129,8 +155,10 @@ cargo test -p horizon-client --lib hd # naga-разбор всех HD-ш
cd ../airdef-mod && cargo horizon-mod build && cargo test # мод и его шейдер
```
WGSL-ошибки в `hd.rs` всплывают только в рантайме — поэтому каждый новый шейдер добавляй в naga-тест
`lighting_shader_parses`.
WGSL-ошибки всплывают только в рантайме — поэтому каждый новый шейдер добавляй в список `shaders()` в тестах
`gfx/hd/mod.rs`: `lighting_shader_parses` (naga-разбор каждого итогового шейдера), `every_wgsl_file_is_tested` (каждый `.wgsl`
входит в проверенный шейдер), `bindings_match_the_layouts` (привязки WGSL = `*_BINDINGS` в Rust: номера, виды, размерности),
`frame_layout_matches_the_prelude` (поля и размер `HdFrame` в Rust и в прелюдии).
Игра офскрин (только одна копия за раз; данные в `run-home/`, мир `Polygon`):
@@ -190,11 +218,12 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
| #35: ванильные облака (HD-облака выключены) красятся закатным цветом в собственном проходе (`hd::vanilla_cloud_tint` → цвет в `clouds::Input`), эвристика по глубине G-буфера убрана из `fs_light` | готово | в рабочем дереве форка |
| #37: воздух (ванильный туман, aerial, объёмный туман, лучи, ореолы) на воде считается до **поверхности** и кладётся один раз поверх готовой воды: `fs_light` пишет во вторую цель `air` (рассеяние + пропускание), `fs_water` применяет её после поглощения | готово | в рабочем дереве форка |
| #36: ванильная вода не рисует цвет в HD (`fs_translucent` при `Globals.hd_flags.x`, глубину оставляет); прозрачное перед водой (`d` ближе `wd`) не отключает HD-воду: вода считается на освещённом дне и добавляется к сцене с весом 0.7; преломление не берёт тексели с таким прозрачным (`textureGather` по альфе) | готово (проверены стекло; частицы и scale 0.67 не проверялись) | в рабочем дереве форка |
| #45: `gfx/hd.rs` разделён на модуль `gfx/hd/` (по файлу на проход: `shadows`, `gbuffer`, `ao`, `sky`, `volumes`, `lighting`+`tiles`, `water`, `taa`, `bloom`, `tonemap`, `frame`), WGSL вынесен в `.wgsl` (`include_str!`, общая прелюдия), у каждого прохода своя структура ресурсов/пайплайнов; контракты цвета, глубины, альф и времени записаны в `hd/mod.rs` и в начале файлов проходов; тесты привязок и размера `HdFrame`. Вид не менялся (сверка `tools/hd_bench.py`) | готово | в рабочем дереве форка |
| #39: веер восхода `vanilla_sunrise_weight` отсекается до цикла по 16 треугольникам выше горизонта (знак x стороны солнца и наклон ≤ α/3; проверено перебором 200 тыс. лучей без пропусков); `hd lighting` на рассвете 5.3 → 3.3 мс (M-серия, 2560×1440) | готово | в рабочем дереве форка |
## План (по порядку) и ТЗ
Каждый пункт — самостоятельная задача. Перед началом прочитай `hd.rs` целиком.
Каждый пункт — самостоятельная задача. Перед началом прочитай `gfx/hd/mod.rs` (контракты) и файл нужного прохода.
1. **TAAU — временной апскейлер** (поверх TAA) — СДЕЛАНО (патч 18, см. статус; масштаб — `HORIZON_HD_SCALE`, переопределяет «масштаб разрешения» настроек Horizon только для HD; поля в `HdSettings` нет). Осталось: LOD-bias текстур террейна, MetalFX. Исходное ТЗ: Мир и G-буфер рендерятся в `render_scale` (например 0.67),
TAA-проход восстанавливает полное разрешение: история в полном разрешении, текущий кадр семплируется с учётом
@@ -266,7 +295,7 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
- **Облака (24).** Слой 192.33…264.33 блока (`CLOUD_BASE/CLOUD_THICKNESS`), ветер 3 блока/с (`CLOUD_WIND`), шум — текстуры, сгенерированные на CPU при создании HD (`hd_noise.rs`: 2D 256² покрытие/форма, 3D 64³ Worley). Плотность — по `clouds_cumulus_density` Photon (порог покрытия × форма, «яйцо» по высоте, эрозия Worley, резкий верх), рассеяние — `clouds_cumulus_scattering` (октавы, пудра, фазы). Цвет света — `cloud_light` (Rust): пропускание атмосферы на высоте солнца; солнце светит облакам до −0.2 по высоте, дальше луна. Воздушная перспектива `exp(−d/3600)` к цвету дымки. История сбрасывается при resize, телепорте и без TAA. Опция ванильных облаков «выкл» → `frame.clouds` пуст → нет ни облаков, ни теней. Тень: `cloud_shadow` в `fs_light` (карта оптической толщины; гаснет при солнце ниже ~0.1–0.25 по высоте).
- **Туман (25).** `fs_fog`: 18 шагов, плотность `fog_sigma` (высотная дымка от y=62, масштаб 42; утренний туман — пока солнце на «утренней» стороне и низко; пятна из 3D-шума; дождь; затухание ниже y≈28–58, чтобы не светить в пещерах), свет — Photon-смесь двух HG, «вечернее свечение» ×1…3. Старые 14 шагов лучей остаются при `HORIZON_HD_VOLFOG=0`; ореолы вокруг источников мода — всегда.
- **Тайминги.** Метки Metal (`HORIZON_SHADER_PACK_TIMING=1`, Hills, 2560×1440): `hd clouds` ≈ 6 мс (завышено: метка ловит ожидание соседних проходов), `hd cloud shadows` 0.2–3 (шум), `hd fog` ≈ 1.3. По среднему кадру GPU «оба выкл → оба вкл»: +0.2…3.7 мс (замеры шумные: рядом шла чужая игра на том же GPU). Как удешевить: у облаков — длина шага (14) и потолок шагов (26) в `fs_cloud`, 3 шага света, 4 октавы, облака в четверть разрешения; у тумана — `FOG_STEPS`.
- **Разработка шейдеров.** `HORIZON_HD_SHADER_DIR=<папка>`: каждый HD-шейдер читается из `<папка>/<метка>.wgsl` (записывается из встроенного, если файла нет) — правки видны после перезапуска игры без пересборки; готовое переносится в константы вручную.
- **Разработка шейдеров.** `HORIZON_HD_SHADER_DIR=<папка>`: каждый HD-шейдер читается из `<папка>/<метка>.wgsl` (записывается из встроенного, если файла нет; это тело без прелюдии, она добавляется всегда) — правки видны после перезапуска игры без пересборки; готовое переносится в `gfx/hd/*.wgsl` вручную.
- **Слабые места.** Облака не отражаются в воде (SSR берёт небо без облаков); не закрывают далёкую землю выше слоя (только небо); карта теней облаков ±1200 блоков и почти не работает при солнце ниже ~10°; туман не знает рельефа (только высоту над y=62); нет перистых облаков; на быстром повороте камеры история облаков отбрасывается (кратковременный шум).
## Заметки по исправлениям #35–#41 (преемнику)
@@ -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/25] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 01/27] 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/25] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 02/27] 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/25] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 03/27] 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/25] Mod models: draw far models as scaled impostors past
Subject: [PATCH 04/27] 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/25] Mod cameras: render.set-views draws the world from
Subject: [PATCH 05/27] 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/25] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 06/27] 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/25] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 07/27] 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/25] Mod cameras: distant terrain tiles, fog measured from
Subject: [PATCH 08/27] 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/25] Mods: game.world-id, so mods keep state per world (API
Subject: [PATCH 09/27] 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/25] cargo-horizon-mod: trial runs behind feature full, so a
Subject: [PATCH 10/27] 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/25] HD lighting: G-buffer pass, per-pixel mod point lights
Subject: [PATCH 11/27] 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/25] HD lighting stage 2: sun and moon shadow cascades, the
Subject: [PATCH 12/27] 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/25] HD: bind groups cached until a resize or a new source,
Subject: [PATCH 13/27] 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/25] HD: lights culled per 16x16 tile (surface and halo
Subject: [PATCH 14/27] 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/25] HD: GPU time per pass in the F3 timing list,
Subject: [PATCH 15/27] 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/25] HD: shadows draw only sun-facing quads and skip buried
Subject: [PATCH 16/27] 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/25] Mods: model handles (render.model-id, model-generation,
Subject: [PATCH 17/27] 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/25] HD: temporal upscaling (HORIZON_HD_SCALE: world,
Subject: [PATCH 18/27] 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/25] HD water: its own pass (colour, normal, depth), the
Subject: [PATCH 19/27] 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/25] HD water: no vanilla texture on the surface (the biome
Subject: [PATCH 20/27] 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/25] HD sky and light: a physical atmosphere (Rayleigh, Mie,
Subject: [PATCH 21/27] 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/25] HD water: a darker blue, a little clearer, ripples a
Subject: [PATCH 22/27] 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/25] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
Subject: [PATCH 23/27] 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/25] Mods: world queries for simulations (render.query-rays,
Subject: [PATCH 24/27] 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/25] HD fixes: rain level from the world not from rain
Subject: [PATCH 25/27] 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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
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/27] 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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx/hd/cloud_common.wgsl | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
index 393ea564b..e2352f008 100644
--- a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
+++ b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
@@ -21,6 +21,8 @@ fn dampen(x: f32) -> f32 {
}
// Where a camera-relative point lies in the world's cloud pattern (the wind moves the pattern).
+// The camera's x, z come wrapped to 65536 blocks: every noise scale below must divide that (powers
+// of two), or the pattern jumps where the wrap happens (at x or z = 0).
fn cloud_xz(pos: vec3<f32>) -> vec2<f32> {
return vec2<f32>(pos.x + frame.cloud_cam.x, pos.z + frame.cloud_cam.y)
+ vec2<f32>(0.8, 0.6) * (CLOUD_WIND * frame.clouds.w);
@@ -33,8 +35,8 @@ fn cloud_density(pos: vec3<f32>, detail: f32) -> f32 {
let h = (pos.y - frame.clouds.x) / frame.clouds.y;
if h <= 0.0 || h >= 1.0 { return 0.0; }
let w = cloud_xz(pos);
- let cover = textureSampleLevel(noise2, repeat_s, w / 16000.0, 0.0).r;
- let shape = textureSampleLevel(noise2, repeat_s, w / 3000.0, 0.0).g;
+ let cover = textureSampleLevel(noise2, repeat_s, w / 16384.0, 0.0).r;
+ let shape = textureSampleLevel(noise2, repeat_s, w / 4096.0, 0.0).g;
let cu = mix(0.56, 0.80, cover) + frame.cloud_cam.z;
var d = linear_step(1.0 - cu, 1.0, shape);
// Carve an egg shape, thin out the bottom.
@@ -43,9 +45,9 @@ fn cloud_density(pos: vec3<f32>, detail: f32) -> f32 {
if d < 0.002 { return 0.0; }
if detail > 0.0 {
let p3 = vec3<f32>(w.x, pos.y, w.y);
- let warp = (textureSampleLevel(noise3, repeat_s, p3 / 1100.0, 0.0).b - 0.5) * 0.4;
- let w0 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 420.0 + vec3<f32>(warp), 0.0).r;
- let w1 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 90.0 + vec3<f32>(warp * 3.0), 0.0).g;
+ let warp = (textureSampleLevel(noise3, repeat_s, p3 / 1024.0, 0.0).b - 0.5) * 0.4;
+ let w0 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 512.0 + vec3<f32>(warp), 0.0).r;
+ let w1 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 64.0 + vec3<f32>(warp * 3.0), 0.0).g;
let fade = 0.20 * smoothstep(0.85, 1.0, 1.0 - h) - 0.35 * smoothstep(0.05, 0.5, h) + 0.6;
d -= 0.55 * w0 * w0 * dampen(1.0 - d);
d -= 0.45 * w1 * w1 * dampen(1.0 - d) * fade;
--
2.54.0 (Apple Git-157)