Compare commits

...

7 Commits

Author SHA1 Message Date
chexit f43fa5ee66 Clouds occlude by depth, world-anchored noise, visual-only replay time (fork patch 29; #67, #68, #70)
build / mod (push) Successful in 3m29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 03:26:10 +03:00
chexit 336ae9f7cc gitignore the smoke agent's test home
build / mod (push) Successful in 3m22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 03:17:21 +03:00
chexit 5f8bff31b9 Smoke and trails from the mod (#54), materials, wetness and wind in HD (fork patch 28); collisions and guidance: unknown is not clear (#61), cache invalidation and ageing (#62, #64), exact rays for partial blocks (#63), raids use the accuracy profiles (#65), interceptors guide on tracks and seekers (#66), fires and their effects bounded (#69)
build / mod (push) Successful in 3m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 03:17:11 +03:00
chexit 3ced60e84f hd_bench: time and weather frozen per shot, several stills per shot, moving pixels (water, clouds, leaves) kept out of the baseline score, warm-up, engine hash in the report (#71)
build / mod (push) Successful in 3m18s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:54:01 +03:00
chexit 6f3fc644bc Terrain-aware flight: drones look 8 s ahead and climb in time, the planner prices terrain, SAMs hold fire at targets masked by terrain (long-range ones loft over ridges), interceptors keep clear of the land or self-destruct in the air
build / mod (push) Successful in 3m24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:39:44 +03:00
chexit 9075717872 HD split into modules (#45, fork patch 26); clouds no longer jump at x or z = 0 (patch 27)
build / mod (push) Successful in 3m21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:22:19 +03:00
chexit e3148bf458 HD render fixes (#35-#39, #41) and replay time of day (#57) (fork patch 25); radar scan cycles with ageing tracks (#46), damage to individual system parts (#50), long-lasting fires (#52)
build / mod (push) Successful in 3m22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:56:15 +03:00
54 changed files with 16836 additions and 344 deletions
+1
View File
@@ -6,3 +6,4 @@ __pycache__/
/out/
.DS_Store
run-home-bench/
run-home-smoke/
+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 {
+3 -1
View File
@@ -201,7 +201,9 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
let t1 = min(span.y, wall);
if t1 <= t0 { continue; }
let heat = 1.0 - smoothstep(0.05, 0.55, k);
let fade = 1.0 - smoothstep(0.65, 1.0, k);
// With the HD renderer's volumetric smoke (params[0].w = 2) only the fireball is drawn here.
var fade = 1.0 - smoothstep(0.65, 1.0, k);
if frame.params[0].w > 1.5 { fade = 1.0 - smoothstep(0.08, 0.28, k); }
// Fewer steps for a fireball that is small on screen.
let on_screen = radius / max(length(centre), 1.0);
let steps = i32(clamp(on_screen * 40.0, 6.0, 14.0));
+68 -6
View File
@@ -16,6 +16,7 @@ mod monitor;
mod panel;
mod radar;
mod replay;
mod smoke;
pub mod sim;
mod scenario;
mod wave;
@@ -54,6 +55,7 @@ const ITEMS: [&str; 29] = [
];
struct AirDef {
smoke: smoke::Smoke,
sim: Sim,
ground: Ground,
dirty: bool,
@@ -330,6 +332,16 @@ impl AirDef {
let id = 0x6100_0000 | (b.id & 0x00ff_ffff);
add(id, b.at + DVec3::Y * size as f64 * 0.5, (1.0, 0.58, 0.25), size * size * 18.0 * (glow + flash), size * 14.0);
}
// Fires burn on for minutes: a flickering, wide glow that dies down with the flames.
for f in self.sim.fires.list.iter().filter(|f| f.shown) {
let (fl, sm) = f.level(tick);
if f.quiet {
continue;
}
let glow = fl.max(0.12 * sm * (1.0 - (tick.saturating_sub(f.start)) as f32 / f.life as f32));
let id = 0x6200_0000 | (f.id & 0x00ff_ffff);
add(id, f.at + DVec3::Y * 1.2, (1.0, 0.5, 0.17), 70.0 * f.size * glow * flicker(f.id), 20.0 + 10.0 * f.size);
}
for s in &self.sim.sams {
if !s.alive() && s.burning() {
add(s.id + 15, s.pos + DVec3::Y * 1.5, (1.0, 0.5, 0.18), 80.0 * flicker(s.id), 30.0);
@@ -371,7 +383,7 @@ impl AirDef {
fn shader_params(&self, out: &Out) -> Vec<f32> {
let mut p = vec![0.0f32; 4];
p[0] = self.sim.tick as f32;
p[3] = if self.hd { 1.0 } else { 0.0 };
p[3] = if self.smoke.hd { 2.0 } else if self.hd { 1.0 } else { 0.0 };
let mut blasts: Vec<&sim::Blast> = self.sim.blasts.iter().rev().take(48).collect();
// The shader composites the volumes front to back with one shared transmittance, so
// the nearest to the player goes first. This is exact for volumes that do not
@@ -449,14 +461,20 @@ impl AirDef {
}
if self.hd {
render::set_lights(&self.lights(&out));
self.smoke.hd = render::set_smoke(self.sim.tick as f64, &self.smoke.segments(&self.sim));
} else {
self.smoke.hd = false;
}
if !out.models.is_empty() || self.sim.tick % 20 == 0 {
self.send_models(&out.models);
}
if !out.particles.is_empty() {
let hd_smoke = self.smoke.hd;
let spawns: Vec<ParticleSpawn> = out
.particles
.into_iter()
// The long smoke is the renderer's volume then.
.filter(|p| !(hd_smoke && matches!(p.kind, "minecraft:campfire_cosy_smoke" | "minecraft:campfire_signal_smoke" | "minecraft:large_smoke")))
.take(512)
.map(|p| ParticleSpawn {
particle: p.kind.to_string(),
@@ -541,8 +559,11 @@ impl AirDef {
} else {
(s.health, s.cargo, s.missiles, s.bursts, s.empty, s.reloading)
};
// The parts' states (sensor, mount, launcher, gun, tracks, comms) close the line; older saves lack them.
let comps = if fresh { new.comps } else { s.comps };
let parts = comps.0.iter().map(|v| format!("{v:.3}")).collect::<Vec<_>>().join(" ");
text += &format!(
"sam {} {} {} {} {} {} {} {} {} {} {} {} {dest}\n",
"sam {} {} {} {} {} {} {} {} {} {} {} {} {dest} {parts}\n",
s.kind.key(),
s.pos.x,
s.pos.y,
@@ -629,6 +650,13 @@ impl AirDef {
(Some(x), Some(y), Some(z)) => Some(DVec3::new(x, y, z)),
_ => None,
};
// The parts: absent in older saves, which load with every part whole.
for (k, v) in sam.comps.0.iter_mut().enumerate() {
if let Some(c) = num(16 + k) {
*v = c.clamp(0.0, 1.0);
}
}
sam.fit_cells();
self.sim.sams.push(sam);
if let Some(to) = dest {
let i = self.sim.sams.len() - 1;
@@ -765,6 +793,7 @@ impl AirDef {
fn stop_replay(&mut self) {
self.replay = None;
render::set_time_override(None);
input::claim_wheel(false);
render::set_hud(&[]);
render::set_views(&[]);
@@ -959,7 +988,7 @@ impl AirDef {
}
}
const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | scenario save|load|list|delete | why [номер] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats";
const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | scenario save|load|list|delete | why [номер] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | truth on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats";
impl Mod for AirDef {
fn init() -> Result<(Self, Registration), String> {
@@ -976,6 +1005,7 @@ impl Mod for AirDef {
grid_ids: Vec::new(),
wave: wave::WaveConfig::default(),
replay: None,
smoke: Default::default(),
far: true,
moving: None,
hd: false,
@@ -1063,7 +1093,7 @@ impl Mod for AirDef {
self.ground.answer(render::ground(), now);
// The blocks themselves (issues #42, #43): what changed, and the sections asked for last tick.
let changes = render::world_changes();
self.ground.world.changed(changes.epoch, changes.overflow, &changes.sections);
self.ground.world.changed_rev(changes.epoch, changes.revision, changes.overflow, &changes.sections);
let sections = render::sections()
.into_iter()
.map(|s| sim::world::SectionAnswer {
@@ -1080,14 +1110,39 @@ impl Mod for AirDef {
})
.collect();
self.ground.world.answer(sections, now);
// Exact collision shapes of blocks a flight met (#63): the answers to last tick's rays.
let replies: Vec<sim::world::RayReply> = render::rays()
.into_iter()
.map(|r| sim::world::RayReply {
id: r.id,
clear: matches!(r.verdict, render::Verdict::Clear),
hit: matches!(r.verdict, render::Verdict::Hit),
fraction: r.fraction,
normal: [r.normal.0, r.normal.1, r.normal.2],
block: [r.block.0, r.block.1, r.block.2],
})
.collect();
self.ground.world.answer_rays(&replies);
let mut out = Out::default();
self.listener = DVec3::new(p.x, p.y + 1.6, p.z);
self.sim.night = game::get_clock().is_some_and(|c| (13000..23000).contains(&(c.day_time % 24000)));
self.sim.day_time = game::get_clock().map(|c| c.day_time);
self.sim.step(&mut self.ground, Some(DVec3::new(p.x, p.y, p.z)), &mut out);
self.smoke.update(&self.sim);
let wanted = self.ground.take_wanted();
if !wanted.is_empty() {
render::query_ground(&wanted);
}
let rays: Vec<render::Ray> = self
.ground
.world
.take_rays(now)
.into_iter()
.map(|(id, a, b)| render::Ray { id, start: (a.x, a.y, a.z), end: (b.x, b.y, b.z), policy: render::Policy::Collision })
.collect();
if !rays.is_empty() {
render::query_rays(&rays);
}
let sections = self.ground.world.take_wanted(now);
if !sections.is_empty() {
render::query_sections(render::Policy::Collision, &sections);
@@ -1108,6 +1163,8 @@ impl Mod for AirDef {
let size = render::gui_size();
render::set_hud(&r.hud(&self.sim, size));
render::set_views(&r.views(size));
// The sky of the raid, not of now (None for a raid whose clock was unknown).
render::set_time_override(r.day_time(&self.sim));
self.monitor_shown = true;
} else if self.grid {
let stale = self.grid_ids.iter().any(|id| !self.sim.sams.iter().any(|s| s.id == *id));
@@ -1146,7 +1203,8 @@ impl Mod for AirDef {
let at = s.dest.unwrap_or(s.pos);
let place = (at.x * 10.0) as i64 as u64 ^ ((at.z * 10.0) as i64 as u64).rotate_left(21) ^ (if s.dest.is_some() { 0 } else { (s.heading * 100.0) as i64 as u64 }).rotate_left(42);
let ammo = u64::from(s.missiles) | u64::from(s.bursts) << 16 | u64::from(s.cargo) << 32 | u64::from(s.empty) << 48 | u64::from(s.reloading > 0) << 49;
((s.health * 1000.0) as u64).wrapping_mul(1_000_003) ^ place ^ ammo.rotate_left(7)
let parts = s.comps.0.iter().fold(0u64, |a, v| a.wrapping_mul(131).wrapping_add((v * 100.0) as u64));
((s.health * 1000.0) as u64).wrapping_mul(1_000_003) ^ place ^ ammo.rotate_left(7) ^ parts.rotate_left(13)
})
.chain(self.sim.target_health.iter().map(|h| (h * 10.0) as u64))
.fold(self.sim.sams.len() as u64, |a, h| a.wrapping_mul(31).wrapping_add(h));
@@ -1409,7 +1467,7 @@ impl Mod for AirDef {
["repair"] => {
let n = self.sim.repair();
self.dirty = true;
Some(format!("Отремонтировано комплексов: {n}; объекты восстановлены"))
Some(format!("Отремонтировано комплексов: {n} (корпус и все узлы: РЛС, привод, пусковая, орудие, ходовая, связь); объекты восстановлены"))
}
["clear"] => {
self.sim.clear();
@@ -1428,6 +1486,10 @@ impl Mod for AirDef {
self.dirty = true;
Some("Цели убраны".to_string())
}
["truth", v @ ("on" | "off")] => {
self.sim.truth = *v == "on";
Some(format!("Истинные данные на радаре: {} (боевую логику не меняют)", if self.sim.truth { "вкл" } else { "выкл" }))
}
["damage", v @ ("on" | "off")] => {
self.sim.damage = *v == "on";
self.dirty = true;
+45 -8
View File
@@ -119,8 +119,10 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
_ => spec.sensor,
};
let scale = if reach > 0.0 { rr as f64 / (reach * 1.25) } else { 0.0 };
let sweep = (tick as f32 * 0.12 + index as f32) % std::f32::consts::TAU;
for k in 0..6 {
// The beam is the radar's real one: targets are measured as it passes them.
let sweep = sam.beam() as f32;
let radar_up = sam.radar_up();
for k in 0..if radar_up { 6 } else { 0 } {
let a1 = sweep - k as f32 * 0.09;
let a0 = a1 - 0.09;
let p = |a: f32| (rx + a.sin() * rr, ry - a.cos() * rr);
@@ -129,8 +131,14 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
let target = sim.watched(sam);
let mut seen = 0;
// Only what its own radar holds.
for d in sim.drones.iter().filter(|d| d.alive() && sam.seen.contains(&d.id)) {
let rel = d.pos - sam.pos;
for (d, tr) in sim.drones.iter().filter(|d| d.alive()).filter_map(|d| sam.track(d.id).map(|t| (d, t))) {
// Where the system believes it is: the last measurement carried on; a forecast is drawn hollow.
let est = tr.est(tick);
if sim.truth {
let r = d.pos - sam.pos;
s.push(fill(rx + r.x as f32 * scale as f32 - 0.5, ry + r.z as f32 * scale as f32 - 0.5, 1.0, 1.0, 0xFFFFFFFF));
}
let rel = est - sam.pos;
let flat = (rel.x * rel.x + rel.z * rel.z).sqrt();
if reach <= 0.0 || flat > reach * 1.25 {
continue;
@@ -146,10 +154,24 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
} else {
0xFF808080
};
s.push(fill(bx - 1.5, by - 1.5, 3.0, 3.0, color));
if tr.forecast(tick) {
s.push(fill(bx - 1.5, by - 1.5, 3.0, 1.0, color));
s.push(fill(bx - 1.5, by + 0.5, 3.0, 1.0, color));
s.push(fill(bx - 1.5, by - 0.5, 1.0, 1.0, color));
s.push(fill(bx + 0.5, by - 0.5, 1.0, 1.0, color));
} else {
s.push(fill(bx - 1.5, by - 1.5, 3.0, 3.0, color));
}
}
s.push(fill(rx - 1.5, ry - 1.5, 3.0, 3.0, 0xFF40A0FF));
s.push(text(fit(format!("РЛС {} · {}", distance(reach), seen), rr * 2.0 + 4.0), x + 4.0, y + h - 10.0, 0xFF80FF90));
let radar_text = if spec.sensor <= 0.0 {
format!("сеть · {seen}")
} else if radar_up {
format!("РЛС {} · {}", distance(reach), seen)
} else {
"РЛС ОТКАЗ".to_string()
};
s.push(text(fit(radar_text, rr * 2.0 + 4.0), x + 4.0, y + h - 10.0, if spec.sensor > 0.0 && !radar_up { 0xFFFF5040 } else { 0xFF80FF90 }));
// Its health under the radar: a bar, red when it is low.
if sam.alive() && sam.health < 1.0 {
@@ -184,6 +206,11 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
(false, false) => 0,
};
s.push(Shape::View(ViewShape { view: sam.id, rect: Rect { x: cx, y: cy, w: cw, h: ch }, mode }));
// Worn or failed parts, over the picture: orange, red when one has failed.
if !sam.comps.whole() {
let failed = crate::sim::Part::ALL.iter().any(|p| sam.part_state(*p) == crate::sim::PartState::Failed);
s.push(text(fit(sam.parts_text(), cw * 0.55), cx + 3.0, cy + 3.0, if failed { 0xFFFF4040 } else { 0xFFFFB030 }));
}
// Crosshair.
s.push(fill(cx + cw / 2.0 - 0.5, cy + 3.0, 1.0, ch * 0.18, 0x90FFFFFF));
s.push(fill(cx + cw / 2.0 - 0.5, cy + ch * 0.82 - 3.0, 1.0, ch * 0.18, 0x90FFFFFF));
@@ -200,12 +227,22 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
if !t.alive() || sim.showing_kill(sam) {
s.push(text("ПОРАЖЕНА", cx + cw / 2.0 - 24.0, cy + ch / 2.0 + bh / 2.0 + 2.0, 0xFF60FF60));
}
let rel = t.pos - sam.pos;
// The system knows the target by its track: the forecast of the last measurement.
let track = sam.track(t.id);
let at = track.map_or(t.pos, |tr| tr.est(tick));
let rel = at - sam.pos;
let bearing = (heading_of(rel).to_degrees() + 180.0).rem_euclid(360.0);
if ch > 50.0 {
s.push(text(fit(t.kind.spec().name.into(), cw - 6.0), cx + 3.0, cy + ch - 20.0, 0xFFFFFFFF));
}
let mut info = format!("{} в{:.0} {:.0}км/ч", distance(rel.length()), t.pos.y - sam.pos.y, t.vel.length() * 72.0);
let mut info = format!("{} в{:.0} {:.0}км/ч", distance(rel.length()), at.y - sam.pos.y, t.vel.length() * 72.0);
if let Some(tr) = track {
if tr.forecast(tick) {
info += &format!(" прогноз {:.1}с", tr.age(tick) as f64 / 20.0);
} else if cw > 200.0 {
info += &format!(" {}", tr.ident().name());
}
}
if cw > 220.0 {
info += &format!(" П{bearing:03.0}");
}
+24 -8
View File
@@ -108,24 +108,40 @@ pub fn draw(sim: &Sim, player: DVec3, yaw: f32) -> Vec<Shape> {
}
}
let mut far = 0;
// The air picture is what the systems' radars hold; with none deployed, everything.
// The air picture is what the systems' radars hold, as they last measured it (a track older than a second
// is a forecast, dimmer); with no system deployed, everything as it is.
let any = !sim.sams.is_empty();
for d in sim.drones.iter().filter(|d| !any || !d.alive() || sim.tracked(d.id)) {
match scope(d.pos) {
let picture = sim.picture();
let now = sim.tick;
for d in sim.drones.iter().filter(|d| !any || !d.alive() || picture.contains_key(&d.id)) {
let track = picture.get(&d.id).filter(|_| any && d.alive());
let (pos, vel) = track.map_or((d.pos, d.vel), |t| (t.est(now), t.vel));
match scope(pos) {
Some((x, y, bearing)) => {
let a = fade(bearing);
if d.alive() {
s.push(square(x, y, 1.8, alpha(0xFF4040, a)));
// Fresh: bright and red; a forecast: orange, fading as it ages towards the track's drop.
let (a, color) = match track {
Some(t) if t.forecast(now) => ((1.0 - t.age(now) as f64 / t.ttl.max(1) as f64 * 0.7).max(0.3), 0xFFB040),
Some(_) => (1.0, 0xFF4040),
None => (fade(bearing), 0xFF4040),
};
s.push(square(x, y, 1.8, alpha(color, a)));
// Its heading, a short tail.
let v = d.vel.normalize_or_zero();
s.push(quad(pt(x, y), pt(x + 0.6, y), pt(x - v.x as f32 * 6.0 + 0.6, y - v.z as f32 * 6.0), pt(x - v.x as f32 * 6.0, y - v.z as f32 * 6.0), alpha(0xFF4040, a * 0.6)));
let v = vel.normalize_or_zero();
s.push(quad(pt(x, y), pt(x + 0.6, y), pt(x - v.x as f32 * 6.0 + 0.6, y - v.z as f32 * 6.0), pt(x - v.x as f32 * 6.0, y - v.z as f32 * 6.0), alpha(color, a * 0.6)));
} else {
s.push(square(x, y, 1.2, alpha(0x909090, a)));
s.push(square(x, y, 1.2, alpha(0x909090, fade(bearing))));
}
}
None if d.alive() => far += 1,
None => {}
}
// The true place, when asked for: a white dot beside the track, to see how far the picture is off.
if sim.truth && d.alive() && any {
if let Some((x, y, _)) = scope(d.pos) {
s.push(square(x, y, 0.8, 0xFFFFFFFF));
}
}
}
// The player: a white arrow the way they look.
// Minecraft's yaw 0 looks south (+z, down the scope).
+5
View File
@@ -297,6 +297,11 @@ impl Player {
self.fade = 6;
}
/// The time of day the raid was flown in, as of the replay's moment now.
pub fn day_time(&self, sim: &Sim) -> Option<u64> {
sim.replay(self.rec)?.day_time(self.t)
}
/// One tick: time runs, the ghosts fly, the director films. Models and sounds go to `out`.
pub fn tick(&mut self, sim: &Sim, listener: DVec3, out: &mut Out) {
let Some(rec) = sim.replay(self.rec) else {
+221 -2
View File
@@ -8,6 +8,124 @@ use super::*;
/// By blast power (as `explode` grades it): how far it reaches a vehicle, and how hard it hits one at the centre.
const REACH: [f64; 5] = [4.0, 7.0, 12.0, 18.0, 26.0];
const HIT: [f64; 5] = [0.2, 0.45, 0.85, 1.25, 1.7];
/// A unit's working parts, each 1 (whole) to 0 (failed); the hull is `Sam::health` (0 destroys it).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Part {
/// The radar (or optics): its reach and its scan.
Sensor,
/// The aiming drive of the launcher or turret.
Mount,
/// The launcher: cells, reload.
Launcher,
/// The gun: it jams.
Gun,
/// The running gear: it can't march when it fails.
Tracks,
/// The data link: no network when it fails.
Comms,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PartState {
Ok,
Damaged,
Failed,
}
impl Part {
pub const ALL: [Part; 6] = [Part::Sensor, Part::Mount, Part::Launcher, Part::Gun, Part::Tracks, Part::Comms];
pub fn name(self) -> &'static str {
match self {
Part::Sensor => "РЛС",
Part::Mount => "привод наведения",
Part::Launcher => "пусковая",
Part::Gun => "орудие",
Part::Tracks => "ходовая",
Part::Comms => "связь",
}
}
pub fn short(self) -> &'static str {
match self {
Part::Sensor => "РЛС",
Part::Mount => "Прив",
Part::Launcher => "ПУ",
Part::Gun => "Ор",
Part::Tracks => "Ход",
Part::Comms => "Св",
}
}
fn index(self) -> usize {
self as usize
}
/// Whether this kind of unit has the part at all.
pub fn on(self, kind: SamKind) -> bool {
let spec = kind.spec();
match self {
Part::Sensor => spec.sensor > 0.0,
Part::Mount => kind.armed() && kind != SamKind::Light && (spec.missile.is_some() || spec.gun.is_some()),
Part::Launcher => spec.missile.is_some(),
Part::Gun => spec.gun.is_some(),
Part::Tracks => kind.mobile(),
Part::Comms => true,
}
}
/// Where the part is on the vehicle: right, up, back, blocks at scale 1 (the nose points to -back).
fn place(self, spec: &super::units::SamSpec) -> (f64, f64, f64) {
match self {
Part::Sensor => (0.0, spec.mast / spec.scale, 1.0),
Part::Mount => (0.0, spec.mount.0, -spec.mount.1),
Part::Launcher => (0.0, spec.mount.0, -spec.mount.1 + 0.4),
Part::Gun => (0.0, spec.mount.0, -spec.mount.1 - 1.0),
Part::Tracks => (0.0, 0.3, 0.0),
Part::Comms => (0.0, 1.6, 1.8),
}
}
}
/// Below this a part is damaged (works worse), at 0 it has failed.
pub const WORN: f64 = 0.6;
/// The state of every part: `Part::ALL`'s order.
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct Comps(pub [f64; 6]);
impl Default for Comps {
fn default() -> Self {
Self([1.0; 6])
}
}
impl Comps {
pub fn get(&self, p: Part) -> f64 {
self.0[p.index()]
}
pub fn state(&self, p: Part) -> PartState {
let v = self.get(p);
if v <= 0.0 {
PartState::Failed
} else if v < WORN {
PartState::Damaged
} else {
PartState::Ok
}
}
pub fn whole(&self) -> bool {
self.0.iter().all(|&v| v >= 1.0)
}
/// Parts of this kind that are not whole, as "РЛС 40%, ходовая отказ".
pub fn describe(&self, kind: SamKind) -> String {
Part::ALL
.iter()
.filter(|p| p.on(kind) && self.get(**p) < 1.0)
.map(|p| match self.state(*p) {
PartState::Failed => format!("{} отказ", p.short()),
_ => format!("{} {:.0}%", p.short(), self.get(*p) * 100.0),
})
.collect::<Vec<_>>()
.join(", ")
}
}
/// Health points an object loses at the centre of a blast.
const OBJECT_HIT: [f64; 5] = [2.0, 6.0, 14.0, 26.0, 40.0];
@@ -112,9 +230,39 @@ impl Sim {
sam.health -= hit * k * k * if d < 1.0 { 1.5 } else { 1.0 };
if sam.health <= 0.0 {
destroyed.push(i);
} else if before >= 0.5 && sam.health < 0.5 {
continue;
}
if before >= 0.5 && sam.health < 0.5 {
out.messages.push(format!("{} №{} повреждён", sam.kind.name(), i + 1));
}
// The parts take the blast by their own distance from it: the side it came from decides which.
let spec = sam.kind.spec();
let rot = DQuat::from_rotation_y(sam.heading);
for part in Part::ALL {
if !part.on(sam.kind) {
continue;
}
let (x, y, back) = part.place(spec);
let at = sam.pos + rot * DVec3::new(x, y, back) * spec.scale;
let size = if part == Part::Tracks { 2.5 } else { 1.0 };
let dp = (at.distance(pos) - size).max(0.0);
if dp >= reach {
continue;
}
let kp = 1.0 - dp / reach;
let roll = dice(self.seed, self.tick, sam.id as u64, part as u64);
// The parts are softer than the hull: a blast the hull survives can still put one out.
let dmg = hit * kp * kp * (0.7 + roll) * 1.5 * if dp < 1.0 { 1.4 } else { 1.0 };
let was = sam.comps.state(part);
let v = &mut sam.comps.0[part as usize];
*v = (*v - dmg).max(0.0);
let now = sam.comps.state(part);
if now != was && now != PartState::Ok {
let word = if now == PartState::Failed { "отказал(а)" } else { "повреждён(а)" };
out.messages.push(format!("{} №{}: {} {word}", sam.kind.name(), i + 1, part.name()));
}
}
sam.fit_cells();
}
for i in destroyed {
self.destroy(i, out);
@@ -144,6 +292,9 @@ impl Sim {
sam.target = None;
sam.gun_target = None;
sam.reloading = 0;
sam.tracks.clear();
sam.seen.clear();
sam.own.clear();
let spec = sam.kind.spec();
let missiles = sam.missiles;
let bursts = sam.bursts;
@@ -224,11 +375,76 @@ impl Sim {
}
/// A roll from 0 to 1 fixed by the seed and three numbers: the same events roll the same in every run,
/// and the simulation's own random sequence is left alone.
pub(super) fn dice(seed: u64, a: u64, b: u64, c: u64) -> f64 {
let mut x = seed ^ a.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ b.wrapping_mul(0xBF58_476D_1CE4_E5B9) ^ c.wrapping_mul(0x94D0_49BB_1331_11EB);
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
x ^= x >> 31;
(x >> 11) as f64 / (1u64 << 53) as f64
}
impl Sam {
/// Its wreck still burns.
pub fn burning(&self) -> bool {
self.burn > 0
}
/// A part's state, `Ok` for one this unit does not have.
pub fn part(&self, p: Part) -> f64 {
if p.on(self.kind) { self.comps.get(p) } else { 1.0 }
}
pub fn part_state(&self, p: Part) -> PartState {
if p.on(self.kind) { self.comps.state(p) } else { PartState::Ok }
}
pub fn parts_text(&self) -> String {
self.comps.describe(self.kind)
}
/// The aiming drive's share of its speed: a failed one doesn't turn at all.
pub(super) fn drive_fit(&self) -> f64 {
let h = self.part(Part::Mount);
if h <= 0.0 { 0.0 } else { 0.3 + 0.7 * h }
}
/// The radar's share of its reach; the scan is slower by the same.
pub(super) fn radar_fit(&self) -> f64 {
let h = self.part(Part::Sensor);
if h <= 0.0 { 0.0 } else { 0.4 + 0.6 * h }
}
/// The gun's share of its hit chance.
pub(super) fn gun_fit(&self) -> f64 {
0.5 + 0.5 * self.part(Part::Gun)
}
/// Its launcher's cells still good: a damaged one holds fewer missiles.
pub(super) fn cells(&self) -> u32 {
let Some(load) = &self.kind.spec().missile else { return 0 };
let h = self.part(Part::Launcher);
if h <= 0.0 { 0 } else { ((load.magazine as f64 * (0.4 + 0.6 * h)).ceil() as u32).clamp(1, load.magazine) }
}
/// Missiles above what the cells hold are lost with them.
pub fn fit_cells(&mut self) {
if self.kind.spec().missile.is_some() {
self.missiles = self.missiles.min(self.cells());
}
}
/// Ticks a reload takes: longer with a damaged launcher.
pub(super) fn reload_time(&self) -> u32 {
let r = self.kind.spec().reload.max(1);
(r as f64 * (1.0 + 1.5 * (1.0 - self.part(Part::Launcher)))).round() as u32
}
/// The gun fires (not jammed for good, not jammed now).
pub(super) fn gun_up(&self) -> bool {
self.part(Part::Gun) > 0.0 && self.jam == 0
}
/// How far it can march: the running gear's share of the speed (0 can't).
pub(super) fn drive_speed(&self) -> f64 {
let h = self.part(Part::Tracks);
if h <= 0.0 { 0.0 } else { 0.35 + 0.65 * h }
}
/// The data link's reach, 0 when the link has failed.
pub(super) fn link_range(&self) -> f64 {
let h = self.part(Part::Comms);
if h <= 0.0 { 0.0 } else { super::sight::LINK * (0.3 + 0.7 * h) }
}
}
impl Sim {
@@ -236,9 +452,11 @@ impl Sim {
pub fn repair(&mut self) -> u32 {
let mut n = 0;
for sam in &mut self.sams {
if sam.health < 1.0 {
if sam.health < 1.0 || !sam.comps.whole() {
n += 1;
}
sam.comps = Comps::default();
sam.jam = 0;
let spec = sam.kind.spec();
sam.health = 1.0;
sam.burn = 0;
@@ -256,6 +474,7 @@ impl Sim {
}
// The wrecks' ammunition still to cook off goes with them.
self.later.clear();
self.fires.extinguish();
n
}
+4 -4
View File
@@ -169,22 +169,22 @@ impl Sim {
continue;
}
// Only those not yet sent at a unit.
if self.sams.iter().any(|s| s.pos.distance(d.target) < 6.0) {
if self.sams.iter().any(|s| s.pos.distance(d.target - d.err) < 6.0) {
continue;
}
let to = if !ew.is_empty() { pick(n, &ew) } else { pick(n, &known) };
d.target = to;
d.target = to + d.err;
d.route.clear();
retargeted.push((true, qi));
n += 1;
}
// In flight: a target system destroyed — the next known one, or keep going.
for (di, d) in self.drones.iter_mut().enumerate().filter(|(_, d)| d.alive() && d.smart) {
let aimed_at_wreck = self.sams.iter().any(|s| !s.alive() && s.pos.distance(d.target) < 6.0);
let aimed_at_wreck = self.sams.iter().any(|s| !s.alive() && s.pos.distance(d.target - d.err) < 6.0);
if aimed_at_wreck {
let next = known.iter().min_by(|a, b| a.1.distance(d.pos).total_cmp(&b.1.distance(d.pos)));
if let Some((_, at)) = next {
d.target = *at;
d.target = *at + d.err;
d.route.clear();
retargeted.push((false, di));
}
+259
View File
@@ -0,0 +1,259 @@
//! Long fires (#52): where a drone or a missile came down, where a warhead struck and on a
//! destroyed system a fire burns for minutes: it flares up, burns steadily, smokes thick and
//! heavy, dies down and smoulders. Visual first (flames, smoke columns, a flickering light, a
//! crackle); with damage on, a few real fire blocks on solid ground (never on water or in the
//! air), put back while it burns and taken away when it is out. At most `MAX` fires burn, the
//! oldest dying down first. A replay draws the same fires from its events (`replay_fire`).
use super::*;
/// Fires drawn at full strength at once (flames, smoke columns, lights, fire blocks): the strongest `MAX`.
pub const MAX: usize = 12;
/// Fires kept at all, burning places the player may come back to: past it the weakest are put out (#69).
/// The rest of them, beyond `MAX`, only smoulder (a thin smoke, now and then), with no light.
pub const LIST_MAX: usize = 32;
/// A fire put out by the water it fell in lasts this long (steam only).
const STEAM: u32 = 160;
/// Ticks the oldest fire has left when a newer one pushes it out.
const FADE: u32 = 160;
/// Ticks between the fire blocks being set again.
const REFRESH: u64 = 80;
pub struct Fire {
pub id: u32,
pub at: DVec3,
pub start: u64,
pub life: u32,
/// 0.6 (a crashed drone) to 3 (a cruise or ballistic warhead, a depot).
pub size: f32,
/// The system it burns on, if any: its own wreck fire does the flames for as long as it lasts.
host: Option<u32>,
/// The land under it found: `Some(true)` land, `Some(false)` water (steam only).
land: Option<bool>,
/// Asked about the ground since this tick.
asked: u64,
/// Fire blocks set (whole coordinates).
blocks: Vec<(i32, i32, i32)>,
/// The host's wreck is flaming: this fire leaves the flames to it.
pub quiet: bool,
/// Among the `MAX` strongest this tick: the only ones drawn in full and lit (#69).
pub shown: bool,
}
#[derive(Default)]
pub struct Fires {
pub list: Vec<Fire>,
/// Commands to take fire blocks away, sent next tick.
cleanup: Vec<String>,
}
/// How long a fire of this power burns, in ticks: 2 to 5 minutes.
pub fn life_for(power: u32) -> u32 {
2400 + 900 * power.min(4)
}
/// The size of a fire of this power.
pub fn size_for(power: u32) -> f32 {
[0.6, 1.0, 1.6, 2.4, 3.0][power.min(4) as usize]
}
/// How strong the flames (`.0`) and the smoke (`.1`) are, 0 to about 1, `age` ticks into a fire
/// that burns `life`: flaring up, steady flame, thick smoke while it dies down, then embers.
pub fn level(age: f64, life: f64) -> (f32, f32) {
let f = (age / life.max(1.0)).clamp(0.0, 1.0);
let flare = if age < 40.0 { 0.35 + 0.65 * age / 40.0 } else { 1.0 };
let flame = if f < 0.45 { flare } else { flare * (1.0 - (f - 0.45) / 0.4).clamp(0.0, 1.0) };
let smoke = if age < 100.0 { age / 100.0 } else if f < 0.8 { 1.0 } else { 1.0 - 0.85 * (f - 0.8) / 0.2 };
(flame as f32, smoke.clamp(0.0, 1.0) as f32)
}
impl Fires {
/// Fires burning now.
pub fn len(&self) -> usize {
self.list.len()
}
pub fn is_empty(&self) -> bool {
self.list.is_empty()
}
/// Puts every fire out; the fire blocks are taken away on the next step.
pub fn extinguish(&mut self) {
let list = std::mem::take(&mut self.list);
for f in list {
self.cleanup.extend(f.take_blocks());
}
}
}
impl Fire {
fn take_blocks(self) -> Vec<String> {
self.blocks
.into_iter()
.map(|(x, y, z)| format!("fill {x} {y} {z} {x} {y} {z} minecraft:air replace minecraft:fire"))
.collect()
}
/// How strong it burns at tick `now`: (flames, smoke); water leaves steam only.
pub fn level(&self, now: u64) -> (f32, f32) {
let (fl, sm) = level(now.saturating_sub(self.start) as f64, self.life as f64);
if self.land == Some(false) { (0.0, sm * 0.5) } else { (fl, sm) }
}
}
impl Sim {
/// Something came down or struck at `pos` (power 0 to 4): a fire starts there, or the fire
/// already burning close by grows. `host` is the system it burns on.
pub(super) fn ignite(&mut self, pos: DVec3, power: u32, host: Option<u32>) {
let now = self.tick;
let (size, life) = (size_for(power), life_for(power));
if let Some(f) = self.fires.list.iter_mut().find(|f| f.at.distance(pos) < 6.0) {
// The same spot (a warhead and the wreck it made): one fire, the greater of the two.
f.size = f.size.max(size);
f.life = f.life.max((now - f.start) as u32 + life);
f.host = f.host.or(host);
return;
}
let id = self.id();
self.fires.list.push(Fire {
id,
at: pos,
start: now,
life,
size,
host,
land: None,
asked: 0,
blocks: Vec::new(),
quiet: false,
shown: true,
});
// A hard bound on the places kept (#69): the weakest, soonest out, goes now and its fire blocks with it.
while self.fires.list.len() > LIST_MAX {
let weakest = (0..self.fires.list.len())
.min_by(|&a, &b| {
let (fa, fb) = (&self.fires.list[a], &self.fires.list[b]);
let w = |f: &Fire| f.size as f64 * (f.life as f64 - now.saturating_sub(f.start) as f64).max(0.0);
w(fa).total_cmp(&w(fb)).then(fa.id.cmp(&fb.id))
})
.unwrap_or(0);
let gone = self.fires.list.swap_remove(weakest);
self.fires.cleanup.extend(gone.take_blocks());
}
if self.fires.list.len() > MAX {
// The oldest dies down (and is gone) instead of burning on.
let old = self.fires.list.iter_mut().filter(|f| now.saturating_sub(f.start) as u32 + FADE < f.life).min_by_key(|f| f.start);
if let Some(f) = old {
f.life = (now - f.start) as u32 + FADE;
}
}
}
/// Once a tick: finds the ground under each new fire, burns the old ones, ends the burnt out.
pub(super) fn step_fires(&mut self, ground: &mut Ground, out: &mut Out) {
let now = self.tick;
out.commands.append(&mut self.fires.cleanup);
let damage = self.damage;
let mut fires = std::mem::take(&mut self.fires.list);
// The strongest `MAX` are drawn in full and lit; the rest smoulder (#69).
let mut rank: Vec<(f32, u32, usize)> = fires
.iter()
.enumerate()
.map(|(i, f)| {
let (fl, sm) = f.level(now);
(f.size * (fl + 0.3 * sm), f.id, i)
})
.collect();
rank.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1)));
for (k, &(_, _, i)) in rank.iter().enumerate() {
fires[i].shown = k < MAX;
}
for f in &mut fires {
let age = now.saturating_sub(f.start);
// Where the land is under it: it burns on the surface, not at the height of what fell.
if f.land.is_none() && age >= f.asked {
f.asked = age + 10;
if let Some(h) = ground.at(f.at, now) {
let (x, z) = (f.at.x.floor() as i32, f.at.z.floor() as i32);
let h = h as i32;
if ground.world.is_known_solid(x, h - 1, z, now) {
f.at.y = h as f64;
f.land = Some(true);
} else if ground.world.is_known_open(x, h - 1, z, now) {
// Water (or another fluid): the fire goes out in a cloud of steam.
f.at.y = h as f64;
f.land = Some(false);
f.life = f.life.min(age as u32 + STEAM);
}
}
// Not known for a while: it burns where it is, on the land.
if f.land.is_none() && age > 100 {
f.land = Some(true);
}
}
f.quiet = f.host.is_some_and(|id| self.sams.iter().any(|s| s.id == id && !s.alive() && s.burn > 0));
let (fl, sm) = f.level(now);
let s = f.size;
if !f.quiet && !f.shown {
// Beyond the budget: a thin smoke now and then, no flames, no light, no new fire blocks.
if sm > 0.04 && now % 24 == (f.id % 24) as u64 {
out.particles("minecraft:large_smoke", f.at + DVec3::Y, 0.4 + 0.3 * f.size, DVec3::new(0.0, 0.1, 0.0), 0.03, 1);
}
} else if !f.quiet {
let top = f.at + DVec3::Y * 0.4;
let spread = 0.4 + 0.5 * s;
if fl > 0.04 && now % 2 == 0 {
out.particles("minecraft:flame", top, spread, DVec3::new(0.0, 0.05 + 0.05 * fl as f64, 0.0), 0.02, (3.0 * s * fl).ceil() as u32);
}
if sm > 0.04 && now % 3 == 0 {
out.particles("minecraft:large_smoke", top + DVec3::Y * 0.6, spread, DVec3::new(0.0, 0.1, 0.0), 0.03, (2.0 * s * sm).ceil() as u32);
}
// The tall column: a plume that rises far.
if sm > 0.3 && now % 6 == (f.id % 6) as u64 {
out.particles("minecraft:campfire_signal_smoke", top, 0.5 * s, DVec3::new(0.0, 0.09, 0.0), 0.01, 1 + (s * sm) as u32);
}
if fl > 0.5 && now % 20 == (f.id % 20) as u64 {
out.particles("minecraft:lava", top + DVec3::Y * 0.5, 0.4 * s, DVec3::new(0.0, 0.2, 0.0), 0.1, 2);
}
if now % 50 == (f.id % 50) as u64 && fl + sm * 0.3 > 0.1 {
out.sound("minecraft:block.fire.ambient", f.at, 0.5 + 1.2 * fl, 0.8 + 0.2 * s.min(1.0));
}
}
// Real fire, only where the damage is real: a few blocks on the solid ground.
if damage && f.shown && f.land == Some(true) && fl > 0.3 && now % REFRESH == (f.id as u64) % REFRESH && age >= 20 {
let (x, y, z) = (f.at.x.floor() as i32, f.at.y.floor() as i32, f.at.z.floor() as i32);
let n = if s >= 2.5 { 5 } else if s >= 1.5 { 3 } else { 1 };
for k in 0..n {
let (dx, dz) = if k == 0 { (0, 0) } else { [(1, 0), (-1, 0), (0, 1), (0, -1)][k - 1] };
let (bx, bz) = (x + dx, z + dz);
// Only where the land is as high as at the centre.
let level = ground.at(DVec3::new(bx as f64 + 0.5, 0.0, bz as f64 + 0.5), now).is_some_and(|h| h as i32 == y);
if level && ground.world.is_known_solid(bx, y - 1, bz, now) && ground.world.is_known_open(bx, y, bz, now) {
out.commands.push(format!("setblock {bx} {y} {bz} minecraft:fire keep"));
if !f.blocks.contains(&(bx, y, bz)) {
f.blocks.push((bx, y, bz));
}
}
}
}
}
// The burnt out: their fire blocks go too.
let mut keep = Vec::with_capacity(fires.len());
for f in fires {
if now.saturating_sub(f.start) < f.life as u64 {
keep.push(f);
} else {
out.commands.extend(f.take_blocks());
}
}
// Fires lit this very tick were pushed to the list meanwhile (none are: ignite runs inside the step).
keep.append(&mut self.fires.list);
self.fires.list = keep;
}
}
/// A fire for a replay: the event's power at age `age` ticks, as a glowing flame model's scale
/// (zero when it is out or not started) and a smoke strength.
pub fn replay_fire(power: u32, age: f64) -> (f32, f32) {
if age < 0.0 {
return (0.0, 0.0);
}
let (fl, sm) = level(age, life_for(power) as f64);
(fl * size_for(power), sm)
}
+2 -2
View File
@@ -50,7 +50,7 @@ impl Sim {
// The trucks: a job each, when idle.
for t in 0..self.sams.len() {
let truck = &self.sams[t];
if truck.kind != SamKind::Tzm || !truck.alive() || truck.moving() || now % 10 != t as u64 % 10 {
if truck.kind != SamKind::Tzm || !truck.alive() || truck.moving() || truck.drive_speed() <= 0.0 || now % 10 != t as u64 % 10 {
continue;
}
let at = truck.pos;
@@ -116,7 +116,7 @@ impl Sim {
fn reload_now(&mut self, i: usize, out: &mut Out) {
let sam = &mut self.sams[i];
sam.empty = false;
sam.reloading = sam.kind.spec().reload.max(1);
sam.reloading = sam.reload_time();
out.sound("minecraft:block.piston.extend", sam.pos, 2.0, 0.6);
}
}
+486 -107
View File
@@ -10,9 +10,11 @@ use glam::{DQuat, DVec3};
use std::collections::HashMap;
mod damage;
pub mod fires;
mod raid;
pub mod record;
mod sight;
mod terrain;
mod ew;
mod lights;
mod logistics;
@@ -22,7 +24,12 @@ mod why;
pub mod world;
#[cfg(test)]
mod regress;
#[cfg(test)]
mod sensor_tests;
pub use ew::EW_RADIUS;
pub use damage::{Comps, Part, PartState};
pub use sight::{Ident, Track};
use damage::dice;
pub use units::{Family, SamKind};
use units::Muzzle;
pub use raid::{Approach, Aim, RaidOrder};
@@ -212,10 +219,19 @@ pub struct Ground {
asked: std::collections::HashSet<(i32, i32)>,
/// The tick of the last full clean-up (a big cache would otherwise be scanned on every answer).
swept: u64,
/// The tick of the last answer round: what the heights' ages are measured against.
now: u64,
/// The blocks themselves (issues #42, #43): what stops a flight.
pub world: world::WorldCache,
}
/// The `when` of a height reported changed: due to be asked again.
const STALE: u64 = u64::MAX;
/// Ticks after which a land height used by the flights' look-ahead is asked again (#64), changes told of or not.
const HEIGHT_TTL: u64 = 1200;
/// Ticks a flight waits for an unanswered section, and for a chunk the game doesn't have while low over the land (#61).
pub const HOLD_PENDING: u16 = 45;
pub const HOLD_NO_CHUNK: u16 = 240;
/// Ticks between full clean-ups of a big ground cache.
const SWEEP_EVERY: u64 = 200;
@@ -228,7 +244,7 @@ impl Ground {
Some(&(y, when)) => {
// Refreshes of known columns take at most a quarter of the questions: the rest
// stay free for columns never seen, or those would starve.
if now.saturating_sub(when) > 200 && self.wanted.len() < 64 && self.asked.insert(key) {
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))
@@ -242,14 +258,54 @@ impl Ground {
}
}
pub fn answer(&mut self, answers: Vec<(i32, i32, i32)>, now: u64) {
self.now = now;
for (x, z, y) in answers {
self.known.insert((x, z), (y, now));
}
self.age_heights();
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);
}
}
/// 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) {
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() {
return;
}
if all || cols.len() > 24 {
for (&(x, z), v) in self.known.iter_mut() {
if all || cols.contains(&(x >> 4, z >> 4)) {
v.1 = STALE;
}
}
} else {
for &(sx, sz) in &cols {
for x in sx * 16..sx * 16 + 16 {
for z in sz * 16..sz * 16 + 16 {
if let Some(v) = self.known.get_mut(&(x, z)) {
v.1 = STALE;
}
}
}
// The lattice columns of the flights' look-ahead (see `terrain`).
for x in (sx * 16..sx * 16 + 16).step_by(8) {
for z in (sz * 16..sz * 16 + 16).step_by(8) {
if let Some(v) = self.known.get_mut(&(x + 4, z + 4)) {
v.1 = STALE;
}
}
}
}
}
}
/// Whether a known height is due to be asked again: reported old, or too old to trust.
fn due(&self, when: u64) -> bool {
when == STALE || self.now.saturating_sub(when) > HEIGHT_TTL
}
pub fn take_wanted(&mut self) -> Vec<(i32, i32)> {
self.asked.clear();
std::mem::take(&mut self.wanted)
@@ -258,6 +314,48 @@ impl Ground {
pub fn sweep(&mut self, a: DVec3, b: DVec3, skip_start: bool, now: u64) -> world::Sweep {
self.world.sweep(a, b, skip_start, now)
}
/// The step of flight `key` from `a` to `b`: the first solid block by the occupancy, then (#63) by the
/// block's exact collision shape, which the game answers a tick or two later (see `WorldCache::narrow`).
pub fn trace(&mut self, key: u32, a: DVec3, b: DVec3, skip_start: bool, now: u64) -> world::Sweep {
let s = self.world.sweep(a, b, skip_start, now);
self.world.narrow(key, s, a, b, now)
}
/// What to do with a step that ended in blocks the game hasn't told of (#61): `Some(t)` cuts the step at `t`
/// of the way (it holds there, the section is asked for urgently), `None` lets it go on. Unread is never
/// taken for clear: a section not answered yet holds a flight for up to `HOLD_PENDING` ticks, a chunk the
/// game doesn't have holds one that is low over the land (or inside it) for `HOLD_NO_CHUNK`; after that, and
/// high over the land, it goes on (the game has nothing there to hit) until a step is clear again.
pub fn unread(&mut self, sweep: &world::Sweep, hold: &mut u16, low: bool) -> Option<f64> {
match *sweep {
world::Sweep::Unknown { t, pending } => {
let limit = if pending {
HOLD_PENDING
} else if low {
HOLD_NO_CHUNK
} else {
0
};
if *hold < limit {
*hold += 1;
self.world.stats.held += 1;
Some((t - 1e-6).max(0.0))
} else {
self.world.stats.passed_unknown += 1;
None
}
}
world::Sweep::Clear => {
*hold = 0;
None
}
world::Sweep::Hit { .. } => None,
// The exact shape is being asked for: the flight holds at the face (a bounded wait, see `narrow`).
world::Sweep::Wait { t } => {
self.world.stats.held += 1;
Some((t - 1e-6).max(0.0))
}
}
}
/// Has the sections along a flight read before it gets there.
pub fn ahead(&mut self, pos: DVec3, vel: DVec3, ticks: f64, now: u64) {
self.world.look_ahead(pos, vel, ticks, now);
@@ -739,6 +837,28 @@ 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,
/// Its navigation error (#65): `target` is the nominal aim plus this, kept apart so a new aim (a retarget)
/// keeps the error this airframe was born with instead of becoming exact.
err: DVec3,
/// Ticks held at the edge of blocks the game has not told of (#61); reset by a clear step.
hold: u16,
}
/// 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 {
@@ -747,9 +867,13 @@ impl Drone {
}
/// How much a system wants to shoot it: fast and heavy first, decoys look real.
fn threat(&self, from: DVec3) -> f64 {
self.threat_at(from, self.pos)
}
/// The same for a target believed to be at `at` (a track's forecast).
fn threat_at(&self, from: DVec3, at: DVec3) -> f64 {
let s = self.kind.spec();
let warhead = if s.decoy { 3.0 } else { s.warhead as f64 };
(1.0 + warhead) * (1.0 + s.speed) / (1.0 + self.pos.distance(from) * 0.02)
(1.0 + warhead) * (1.0 + s.speed) / (1.0 + at.distance(from) * 0.02)
}
}
@@ -942,6 +1066,50 @@ pub struct Missile {
/// The system that fired it.
sam: u32,
age: u32,
/// Ticks held at the edge of blocks the game has not told of (#61).
hold: u16,
/// What it knows of its target (#66): the last fix, from the firing system's track (the data link)
/// or from its own seeker. It steers on this, never on the target's true position.
fix: Option<Fix>,
/// The tick its seeker last held the target (0: not yet).
seen: u64,
}
/// A missile's knowledge of its target: where it was and how it went when measured, and when.
#[derive(Clone, Copy, Debug)]
pub struct Fix {
pub pos: DVec3,
pub vel: DVec3,
pub tick: u64,
/// Measured by its own seeker (else by the firing system's radar, passed on by the link).
pub seeker: bool,
}
impl Fix {
/// Where the target should be by now, carrying the measurement on.
pub fn est(&self, now: u64) -> DVec3 {
self.pos + self.vel * now.saturating_sub(self.tick).min(MAX_COAST) as f64
}
}
/// Ticks a fix is carried on by its velocity; a missile with nothing newer than this has lost its target.
pub const MAX_COAST: u64 = 240;
/// Ticks the seeker keeps a target it lost sight of (it looks again round the last place).
const REACQUIRE: u64 = 12;
impl MissileKind {
/// How far its own seeker holds a target of average size (blocks); 0: command guidance only.
pub fn seeker(self) -> f64 {
match self {
MissileKind::M57e6 => 0.0,
MissileKind::Pac3 => 110.0,
MissileKind::M48n6 | MissileKind::M77n6 => 120.0,
MissileKind::IrisT | MissileKind::M9m317 => 90.0,
MissileKind::Amraam => 130.0,
MissileKind::M9m338 => 70.0,
MissileKind::P1sun => 60.0,
}
}
}
// ---------------------------------------------------------------- air defence
@@ -1034,7 +1202,14 @@ pub struct Sam {
pub health: f64,
/// Ticks its wreck still burns.
burn: u32,
/// The drones it has tracks of this tick: its own radar's and the network's.
/// Its working parts (the hull is `health`).
pub comps: Comps,
/// Ticks its gun stays jammed.
jam: u32,
/// Where its radar beam points (radians), and the tracks it holds: its own radar's and the network's.
pub(super) beam: f64,
pub tracks: Vec<Track>,
/// The ids of the tracks it holds this tick: its own radar's and the network's.
pub seen: Vec<u32>,
/// The drones its own radar (or optics) holds: guns need these.
pub own: Vec<u32>,
@@ -1056,6 +1231,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 {
@@ -1087,6 +1266,10 @@ impl Sam {
launched: 0,
health: 1.0,
burn: 0,
comps: Comps::default(),
jam: 0,
beam: id as f64 * 2.4,
tracks: Vec::new(),
seen: Vec::new(),
own: Vec::new(),
kill_view: None,
@@ -1103,17 +1286,23 @@ impl Sam {
heat: 0.0,
ew_lock: false,
flare_cd: 0,
terrain: HashMap::new(),
waits: HashMap::new(),
}
}
pub fn alive(&self) -> bool {
self.health > 0.0
}
/// Where its radar beam points now (radians clockwise from north).
pub fn beam(&self) -> f64 {
self.beam
}
/// How well it still works: turning, aiming, its radar's reach.
/// Turns the mount towards `yaw` (and `pitch`, if it elevates) under its drive's limits; a damaged
/// system has the less of both speed and acceleration.
fn aim_towards(&mut self, yaw: f64, pitch: Option<f64>) {
let d = self.kind.spec().drive();
let fit = self.fitness();
let fit = self.drive_fit();
self.yaw += drive(&mut self.yaw_vel, angle_diff(self.yaw, yaw), d.yaw.0 * fit, d.yaw.1 * fit);
if let Some(pitch) = pitch {
self.pitch += drive(&mut self.pitch_vel, pitch - self.pitch, d.pitch.0 * fit, d.pitch.1 * fit);
@@ -1124,9 +1313,6 @@ impl Sam {
self.yaw_vel = 0.0;
self.pitch_vel = 0.0;
}
fn fitness(&self) -> f64 {
0.45 + 0.55 * self.health.clamp(0.0, 1.0)
}
pub fn status(&self) -> String {
let spec = self.kind.spec();
let mut text = spec.name.to_string();
@@ -1143,7 +1329,10 @@ impl Sam {
let state = if self.ew_on { "ГЛУШИТ" } else if self.ew_lock { "остывает" } else { "готова" };
return format!("{text}: {state}, нагрев {:.0}%", self.heat * 100.0);
}
SamKind::Radar => return format!("{text}: целей {}", self.own.len()),
SamKind::Radar => {
let broken = if self.comps.whole() { String::new() } else { format!(" [{}]", self.parts_text()) };
return format!("{text}: целей {}{broken}", self.own.len());
}
SamKind::Light => return format!("{text}{}", if self.target.is_some() { ": в луче цель" } else { "" }),
_ => {}
}
@@ -1153,6 +1342,9 @@ impl Sam {
if self.health < 1.0 {
text += &format!(" ({:.0}%)", self.health * 100.0);
}
if !self.comps.whole() {
text += &format!(" [{}]", self.parts_text());
}
if self.reloading > 0 {
text += &format!(" перезарядка {}с", self.reloading.div_ceil(20));
} else {
@@ -1202,6 +1394,10 @@ pub struct Sim {
pub launched: u32,
/// Explosions damage the land (primed TNT through commands).
pub damage: bool,
/// The seed: what falls to chance without the sequence (scan hits, damage to parts) is rolled from it.
seed: u64,
/// The air picture shows the true positions beside the tracks (a sandbox and analysis aid; changes no behaviour).
pub truth: bool,
/// The shader draws fireballs: no glowing cubes.
pub shader: bool,
/// Air defence holds fire.
@@ -1228,6 +1424,10 @@ pub struct Sim {
intel: ew::Intel,
/// Night: guns need light (the mod sets it from the game's clock).
pub night: bool,
/// The long fires at crash and impact points (#52).
pub fires: fires::Fires,
/// The world clock's time of day in ticks (the mod sets it; recordings keep it).
pub day_time: Option<u64>,
/// Illumination flares in the sky, and the drones lit this tick.
pub flares: Vec<lights::Flare>,
pub lit: Vec<u32>,
@@ -1255,6 +1455,8 @@ impl Sim {
crashes: 0,
launched: 0,
damage: true,
seed: seed.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1,
truth: false,
shader: false,
hold: false,
horizons: HashMap::new(),
@@ -1269,6 +1471,8 @@ impl Sim {
tally: record::Tally::default(),
intel: ew::Intel::default(),
night: false,
fires: fires::Fires::default(),
day_time: None,
flares: Vec::new(),
lit: Vec::new(),
}
@@ -1315,36 +1519,9 @@ impl Sim {
}
fn add_drone(&mut self, kind: DroneKind, from: DVec3, dir: DVec3, target: DVec3) {
let id = self.id();
let spec = kind.spec();
// Its navigation error: it flies to, and strikes at, a point off the aim it was given.
let target = target + self.aim_error(kind);
let (vel, flight) = if spec.ballistic.is_some() {
// An arc from `from` to `target` at the kind's horizontal speed.
let flat = horizontal(target - from);
let time = (flat.length() / spec.speed).max(30.0);
let up = (target.y - from.y + 0.5 * GRAVITY * time * time) / time;
(flat / time + DVec3::Y * up, Flight::Ballistic)
} else {
(dir.normalize_or(DVec3::NEG_Z) * spec.speed, Flight::Cruise)
};
self.drones.push(Drone {
id,
kind,
pos: from,
vel,
target,
flight,
roll: 0.0,
spin: 0.0,
age: 0,
engaged: 0,
route: Vec::new(),
low: false,
jammed: false,
sector: 0,
smart: false,
});
// Its navigation error is drawn in `make_drone`, the one path of manual and raid launches (#65).
let d = self.make_drone(kind, from, dir, target);
self.drones.push(d);
self.launched += 1;
}
@@ -1387,12 +1564,13 @@ impl Sim {
self.later.clear();
self.flares.clear();
self.lit.clear();
self.fires.extinguish();
}
/// 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);
@@ -1402,11 +1580,12 @@ 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);
self.step_wrecks(out);
self.step_fires(ground, out);
self.report_wave(out);
self.record();
let now = self.tick;
@@ -1431,7 +1610,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
@@ -1446,9 +1625,11 @@ impl Sim {
d.vel *= 0.985;
d.spin += 0.35;
d.pos += d.vel;
let sweep = ground.sweep(from, d.pos, d.age <= 3, now);
let sweep = ground.trace(d.id, from, d.pos, d.age <= 3, now);
if let Some((_, p)) = sweep.hit() {
d.pos = p;
} else if let Some(t) = ground.unread(&sweep, &mut d.hold, below.is_some_and(|g| from.y < g + 12.0)) {
d.pos = from.lerp(d.pos, t);
}
if self.rng.chance(0.5) {
out.particles("minecraft:large_smoke", d.pos, 0.2, DVec3::new(0.0, 0.05, 0.0), 0.02, 1);
@@ -1477,9 +1658,11 @@ impl Sim {
d.vel = turn(d.vel, d.target - d.pos, spec.turn) * sp;
}
d.pos += d.vel;
let sweep = ground.sweep(from, d.pos, d.age <= 3, now);
let sweep = ground.trace(d.id, from, d.pos, d.age <= 3, now);
if let Some((_, p)) = sweep.hit() {
d.pos = p;
} else if let Some(t) = ground.unread(&sweep, &mut d.hold, below.is_some_and(|g| from.y < g + 12.0)) {
d.pos = from.lerp(d.pos, t);
}
let speed = d.vel.length();
let landed = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now);
@@ -1554,9 +1737,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;
@@ -1572,32 +1752,97 @@ 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 };
d.vel = new * speed;
d.pos += d.vel;
let sweep = ground.sweep(from, d.pos, d.age <= 3, now);
let sweep = ground.trace(d.id, from, d.pos, d.age <= 3, now);
if let Some((_, p)) = sweep.hit() {
d.pos = p;
} else if let Some(t) = ground.unread(&sweep, &mut d.hold, below.is_some_and(|g| from.y < g + 12.0)) {
// Not told of what lies ahead: it doesn't fly into it unseen (a held flight waits, a tick or two).
d.pos = from.lerp(d.pos, t);
}
let at_target = d.pos.distance(d.target) < speed.max(1.5);
let in_ground = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now);
@@ -1691,6 +1936,9 @@ impl Sim {
jammed: false,
sector,
smart: false,
nav: Nav::default(),
err: DVec3::ZERO,
hold: 0,
});
}
for d in &self.drones {
@@ -1842,7 +2090,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() {
@@ -1871,18 +2119,25 @@ impl Sim {
retarget(&mut guns_on, sam.gun_target.take(), None);
continue;
}
let fit = self.sams[i].fitness();
{
let sam = &mut self.sams[i];
sam.cooldown = sam.cooldown.saturating_sub(1);
sam.gun_cooldown = sam.gun_cooldown.saturating_sub(1);
sam.flash = sam.flash.saturating_sub(1);
sam.recoil *= 0.6;
sam.dish = (sam.dish + 0.15) % TAU;
if sam.radar_up() {
sam.dish = sam.beam;
}
sam.jam = sam.jam.saturating_sub(1);
if 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 {
sam.missiles = spec.missile.as_ref().map_or(0, |m| m.magazine);
sam.missiles = sam.cells();
sam.bursts = spec.gun.as_ref().map_or(0, |g| g.bursts);
out.sound("minecraft:block.piston.extend", sam.pos, 2.0, 0.6);
}
@@ -1904,23 +2159,30 @@ impl Sim {
let pos = self.sams[i].pos;
let roles = self.sams[i].roles;
let (had_target, had_gun) = (self.sams[i].target, self.sams[i].gun_target);
let now = self.tick;
let (target, gun_target) = {
let (seen, own) = (&self.sams[i].seen, &self.sams[i].own);
// The conditions are `why`'s, shared with the diagnostic.
let allowed = |d: &Drone| why::allowed(roles, seen, d);
let 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]);
let claimed = |d: &Drone| why::claimed(d, &missile_on, had_target);
let needed = why::needed;
let target = spec.missile.as_ref().map(|load| {
let in_reach = |d: &Drone, k: f64| why::in_reach(load.min, load.max, k, pos, d);
let keep = had_target.filter(|t| {
drone_at.get(t).is_some_and(|&k| allowed(&self.drones[k]) && in_reach(&self.drones[k], 1.1))
});
let ok = |tr: &Track, k: f64| {
drone(tr).is_some_and(|d| why::allowed(roles, tr, now, d) && 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(|| {
self.drones
tracks
.iter()
.filter(|d| allowed(d) && in_reach(d, 1.0) && claimed(d) < needed(d))
.max_by(|a, b| a.threat(pos).total_cmp(&b.threat(pos)))
.map(|d| d.id)
.filter(|tr| ok(tr, 1.0))
.filter_map(|tr| drone(tr).map(|d| (tr, d)))
.filter(|(_, d)| claimed(d) < needed(d))
.max_by(|a, b| a.1.threat_at(pos, a.0.est(now)).total_cmp(&b.1.threat_at(pos, b.0.est(now))))
.map(|(_, d)| d.id)
})
});
// Guns: the nearest, spread over the targets the other guns and missiles aren't on.
@@ -1928,12 +2190,13 @@ impl Sim {
let on_it = |d: &Drone| {
(guns_on.get(&d.id).copied().unwrap_or(0) - u32::from(had_gun == Some(d.id))) as f64
};
self.drones
tracks
.iter()
.filter(|d| why::gun_candidate(roles, seen, own, gun.track, pos, d))
.map(|d| {
.filter_map(|tr| drone(tr).map(|d| (tr, d)))
.filter(|(tr, d)| why::gun_candidate(roles, tr, now, gun.track, pos, d))
.map(|(tr, d)| {
let busy = 1.0 + 2.0 * on_it(d) + if d.engaged > 0 { 1.5 } else { 0.0 };
(d.pos.distance(pos) * busy, d.id)
(tr.est(now).distance(pos) * busy, d.id)
})
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, id)| id)
@@ -1957,18 +2220,23 @@ impl Sim {
}
let sam = &mut self.sams[i];
// What the turret or launcher points at: guns first when a target is close.
let aim = sam.gun_target.or(sam.target).and_then(|t| drone_at.get(&t).map(|&k| &self.drones[k]));
let aim = sam
.gun_target
.or(sam.target)
.filter(|t| drone_at.contains_key(t))
.and_then(|t| sam.track(t))
.map(|tr| (tr.id, tr.est(now), tr.vel));
sam.launched = sam.launched.saturating_sub(1);
let Some(aim) = aim else {
// Out of missiles or of shells, and nothing to shoot: reload now (both, as a
// combined system's crew would), not only once both are spent.
let spent = (spec.missile.is_some() && sam.missiles == 0) || (spec.gun.is_some() && sam.bursts == 0);
let spent = (spec.missile.is_some() && sam.missiles == 0 && sam.part(Part::Launcher) > 0.0) || (spec.gun.is_some() && sam.bursts == 0);
if spent && sam.reloading == 0 && !sam.empty {
if logistics {
sam.empty = true;
ammo_waits += 1;
} else {
sam.reloading = spec.reload
sam.reloading = sam.reload_time()
}
}
sam.state = if sam.reloading > 0 {
@@ -1984,7 +2252,8 @@ impl Sim {
};
let (mount, _) = sam.mount();
let lead_speed = spec.gun.as_ref().map_or(5.0, |g| g.shell);
let (lead, want_yaw, want_pitch) = why::aim_wants(mount, aim, lead_speed);
let (aim_id, aim_pos, aim_vel) = aim;
let (lead, want_yaw, want_pitch) = why::aim_wants(mount, aim_pos, aim_vel, lead_speed);
let cells = matches!(spec.muzzle, Muzzle::Cells);
match spec.family {
// Vertical launch: no aiming (the Tor's turret turns its radar to the target).
@@ -2007,7 +2276,7 @@ impl Sim {
SamState::Fire
} else if !vertical && aim_error > 0.15 {
SamState::Slew
} else if aim.pos.distance(mount) < self::SamKind::range(sam.kind) {
} else if aim_pos.distance(mount) < self::SamKind::range(sam.kind) {
SamState::Lock
} else {
SamState::Track
@@ -2015,8 +2284,16 @@ impl Sim {
// Guns.
if let (Some(gun), Some(gt)) = (&spec.gun, sam.gun_target) {
if let Some(t) = drone_at.get(&gt).map(|&k| &self.drones[k]) {
let dist = t.pos.distance(mount);
if why::gun_blocks(sam, aim.id, gt, aim_error, dist, gun.range) == 0 {
let est = sam.track(gt).map_or(t.pos, |tr| tr.est(now));
let dist = est.distance(mount);
let mut blocks = why::gun_blocks(sam, aim_id, gt, aim_error, dist, gun.range, now);
// A damaged gun jams now and then, for a while: the burst it was about to fire is lost.
let wear = 1.0 - sam.part(Part::Gun);
if blocks == 0 && wear > 0.0 && dice(self.seed, now, sam.id as u64, 77) < wear * 0.1 {
sam.jam = 30 + (dice(self.seed, now, sam.id as u64, 78) * 90.0) as u32;
blocks = why::JAMMED;
}
if blocks == 0 {
sam.gun_cooldown = 2;
sam.bursts -= 1;
sam.flash = 2;
@@ -2026,7 +2303,7 @@ impl Sim {
sam.empty = true;
ammo_waits += 1;
} else {
sam.reloading = spec.reload
sam.reloading = sam.reload_time()
}
}
let (_, rot) = sam.mount();
@@ -2053,7 +2330,9 @@ impl Sim {
}
// At night a gun sees what is lit (a searchlight, a flare) and little else.
let dark = if self.night && !self.lit.contains(&t.id) { lights::DARK_HITS } else { 1.0 };
let p = gun.p * fit * dark / t.kind.spec().stealth * (1.0 - dist / (gun.range * 1.3)).max(0.35);
// The aim point is the track's forecast: the farther it is from the truth, the more shells miss.
let off = est.distance(t.pos);
let p = gun.p * sam.gun_fit() * dark / (t.kind.spec().stealth * (1.0 + (off / 8.0).powi(2))) * (1.0 - dist / (gun.range * 1.3)).max(0.35);
if self.rng.chance(p) {
gun_hits.push((gt, sam.id));
} else {
@@ -2066,11 +2345,40 @@ impl Sim {
if let (Some(load), Some(mt)) = (&spec.missile, sam.target) {
let ready = why::missile_blocks(sam) == 0;
// The missile turns after its boost, but the launcher must point near the target.
let t = drone_at.get(&mt).map(|&k| &self.drones[k]);
let aligned = why::missile_aligned(spec.family, sam.yaw, mount, t, aim.id, mt, aim_error);
if ready && aligned {
let n = load.magazine - sam.missiles;
let (muzzle, dir) = sam.muzzle(n);
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);
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.
@@ -2088,7 +2396,7 @@ impl Sim {
sam.empty = true;
ammo_waits += 1;
} else {
sam.reloading = spec.reload
sam.reloading = sam.reload_time()
}
}
}
@@ -2103,10 +2411,17 @@ impl Sim {
if let Some(s) = self.sams.iter().find(|s| s.id == sam) {
self.intel.revealed.push((sam, s.pos, self.tick));
}
// It leaves with the track the system fired on (#66).
let fix = self
.sams
.iter()
.find(|s| s.id == sam)
.and_then(|s| s.track(target))
.map(|tr| Fix { pos: tr.pos, vel: tr.vel, tick: tr.tick, seeker: false });
let spec = kind.spec();
let id = self.id();
let v0 = if spec.cold > 0 { 0.7 } else { 0.8 };
self.missiles.push(Missile { id, kind, pos: muzzle, vel: dir * v0, target, sam, age: 0 });
self.missiles.push(Missile { id, kind, pos: muzzle, vel: dir * v0, target, sam, age: 0, hold: 0, fix, seen: 0 });
if spec.cold > 0 {
// Thrown out by gas: a puff, a thump; the motor lights later.
out.particles("minecraft:cloud", muzzle, 0.6, dir * 0.05, 0.05, 30);
@@ -2139,8 +2454,18 @@ 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 sams_fit: HashMap<u32, f64> = self.sams.iter().map(|s| (s.id, s.fitness())).collect();
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();
// The data link (#66): the track the firing system holds of each missile's target, if it is up.
let links: HashMap<(u32, u32), Track> = self
.missiles
.iter()
.filter_map(|m| {
let s = self.sams.iter().find(|s| s.id == m.sam)?;
(s.alive() && !s.moving()).then_some(())?;
Some(((m.sam, m.target), *s.track(m.target)?))
})
.collect();
for m in &mut self.missiles {
m.age += 1;
let spec = m.kind.spec();
@@ -2152,16 +2477,52 @@ impl Sim {
(m.vel.length() + spec.accel).min(spec.speed)
};
let dir = m.vel.normalize_or(DVec3::Y);
let new = match target {
Some(t) if m.age >= spec.boost => {
// What it knows: the link's newest track, and its own seeker once the target is in its reach and in view.
if let Some(tr) = links.get(&(m.sam, m.target))
&& m.fix.is_none_or(|f| tr.tick > f.tick && !(f.seeker && now.saturating_sub(f.tick) <= REACQUIRE))
{
m.fix = Some(Fix { pos: tr.pos, vel: tr.vel, tick: tr.tick, seeker: false });
}
if let Some(t) = target
&& lit
&& m.age >= spec.boost
&& m.kind.seeker() > 0.0
{
let range = m.kind.seeker() * t.kind.rcs().powf(0.25).clamp(0.6, 1.6);
let to = t.pos - m.pos;
let in_view = to.length() < range && to.normalize_or_zero().dot(dir) > 0.5;
if in_view && !matches!(ground.world.probe(m.pos, t.pos, now), world::Sweep::Hit { .. }) {
m.seen = now;
}
if m.seen > 0 && now.saturating_sub(m.seen) <= REACQUIRE {
// The seeker measures the target (the track of its own, newer than any link's).
m.fix = Some(Fix { pos: if m.seen == now { t.pos } else { m.fix.map_or(t.pos, |f| f.pos) }, vel: t.vel, tick: if m.seen == now { now } else { m.fix.map_or(now, |f| f.tick) }, seeker: true });
}
}
// Nothing new for too long and no seeker on it: the interceptor has lost its target.
if target.is_some() && m.fix.is_none_or(|f| now.saturating_sub(f.tick) > MAX_COAST) && m.age > spec.boost + 20 {
expired += 1;
done.push((m.id, None, false, m.sam));
continue;
}
let aim = m.fix.map(|f| (f.est(now), f.vel));
let new = match aim {
Some((at, vel)) if m.age >= spec.boost => {
// Time to go by the closing speed: head-on targets come to meet it.
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)
let los = (at - m.pos).normalize_or_zero();
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);
if fail {
scrubbed += 1;
done.push((m.id, target.map(|t| 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),
Some((at, _)) if lit && spec.cold > 0 => turn(dir, at - m.pos, spec.turn * 0.5),
_ => dir,
};
let before = m.pos;
@@ -2169,7 +2530,24 @@ impl Sim {
m.pos += m.vel;
// The first block on the way this tick; a fuse that goes off earlier along the way still counts.
ground.ahead(m.pos, m.vel, 10.0, now);
let world = ground.sweep(before, m.pos, m.age <= 12, now).hit();
let sweep = ground.trace(m.id | 0x8000_0000, before, m.pos, m.age <= 12, now);
let mut world = sweep.hit();
// Blocks not told of (#61): the interceptor holds at the edge of what is read, and no fuse is
// counted beyond it; low over a chunk the game doesn't have, it is lost there as against a wall.
if world.is_none()
&& let world::Sweep::Unknown { t, pending } = sweep
{
let low = ground.at(m.pos, now).is_some_and(|g| before.y < g + 12.0);
if !pending && low {
world = Some((t, before.lerp(m.pos, t)));
} else if let Some(t) = ground.unread(&sweep, &mut m.hold, low || pending) {
m.pos = before.lerp(m.pos, t);
}
} else if let world::Sweep::Wait { t } = sweep {
m.pos = before.lerp(m.pos, (t - 1e-6).max(0.0));
} else if matches!(sweep, world::Sweep::Clear) {
m.hold = 0;
}
if let Some(t) = target {
// Closest approach this tick, both moving (fast targets cross a fuse in a tick).
let rel0 = (t.pos - t.vel) - before;
@@ -2238,6 +2616,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) {
+7 -3
View File
@@ -36,10 +36,10 @@ impl Sam {
}
impl Sim {
/// Orders unit `i` to drive to `to`. False for one that can't (a depot, a wreck).
/// Orders unit `i` to drive to `to`. False for one that can't (a depot, a wreck, failed running gear).
pub fn order_move(&mut self, i: usize, to: DVec3) -> bool {
let Some(sam) = self.sams.get_mut(i) else { return false };
if !sam.alive() || !sam.kind.mobile() {
if !sam.alive() || !sam.kind.mobile() || sam.drive_speed() <= 0.0 {
return false;
}
sam.dest = Some(to);
@@ -75,6 +75,10 @@ impl Sim {
}
}
SamState::Moving => {
// The running gear failed on the way: it stops where it is and deploys there.
if sam.drive_speed() <= 0.0 {
sam.dest = None;
}
let Some(to) = sam.dest else {
sam.state = SamState::Deploying;
sam.march = sam.kind.march_ticks();
@@ -93,7 +97,7 @@ impl Sim {
sam.heading = slew(sam.heading, heading_of(flat), 0.06);
let ahead = DQuat::from_rotation_y(sam.heading) * DVec3::NEG_Z;
let turning = angle_diff(sam.heading, heading_of(flat)).abs();
let step = SPEED * (1.0 - turning / std::f64::consts::PI).max(0.2);
let step = SPEED * sam.drive_speed() * (1.0 - turning / std::f64::consts::PI).max(0.2);
let next = sam.pos + ahead * step;
let y = ground.at(next, now).unwrap_or(sam.pos.y);
sam.pos = DVec3::new(next.x, sam.pos.y + (y - sam.pos.y).clamp(-0.5, 0.5), next.z);
+115 -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,40 @@ 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 aimed = aim + d.err;
let flat = horizontal(aimed - from);
let time = (flat.length() / spec.speed).max(30.0);
d.vel = flat / time + DVec3::Y * ((aimed.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
@@ -333,6 +428,10 @@ impl Sim {
pub(super) fn make_drone(&mut self, kind: DroneKind, from: DVec3, dir: DVec3, target: DVec3) -> Drone {
let id = self.id();
let spec = kind.spec();
// Its navigation error (CEP and a rare gross fault, #59/#65): the same for a manual launch and a raid.
// The raid's own ±4 spread of aim points is the tactical dispersal and stays on top of it.
let err = self.aim_error(kind);
let target = target + err;
let (vel, flight) = if spec.ballistic.is_some() {
let flat = horizontal(target - from);
let time = (flat.length() / spec.speed).max(30.0);
@@ -357,6 +456,9 @@ impl Sim {
jammed: false,
sector: 0,
smart: false,
nav: Nav::default(),
err,
hold: 0,
}
}
@@ -404,6 +506,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 +554,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;
+64
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)
@@ -110,10 +114,16 @@ pub struct Recording {
/// The systems and objects as they stood: (id, kind, where).
pub sams: Vec<(u32, SamKind, DVec3)>,
pub objects: Vec<DVec3>,
/// The world clock's time of day (ticks) every `TIME_STEP` ticks of the recording, from its
/// start: replays show the sky of the raid, not the current one. Empty when the clock was unknown.
times: Vec<u64>,
/// Ticks the sky has been clear.
quiet: u32,
}
/// Ticks between the recording's clock samples.
const TIME_STEP: u32 = 20;
impl Track {
/// Where it was at `t` (the recording's ticks, fractional), if it was.
pub fn at(&self, origin: DVec3, t: f64) -> Option<DVec3> {
@@ -169,6 +179,7 @@ impl Recording {
tally: Tally::default(),
sams: sim.sams.iter().map(|s| (s.id, s.kind, s.pos)).collect(),
objects: sim.targets.clone(),
times: Vec::new(),
quiet: 0,
}
}
@@ -184,6 +195,19 @@ impl Recording {
}
}
}
/// The time of day (ticks, 24000 a day) the world showed at the recording's tick `t`: the
/// clock as sampled, run on between samples at the pace it ran (a jump is a step), or None
/// when the clock was not known (the replay then shows the world's own time).
pub fn day_time(&self, t: f64) -> Option<u64> {
let k = (t.max(0.0) / TIME_STEP as f64).floor() as usize;
let i = k.min(self.times.len().checked_sub(1)?);
let a = self.times[i];
let into = if i == k { t.max(0.0) - (i as u32 * TIME_STEP) as f64 } else { 0.0 };
match self.times.get(i + 1) {
Some(&b) if b >= a && b - a <= 2 * TIME_STEP as u64 => Some(a + ((b - a) as f64 * into / TIME_STEP as f64) as u64),
_ => Some(a),
}
}
pub fn track(&self, id: u32) -> Option<&Track> {
self.index.get(&id).map(|&i| &self.tracks[i])
}
@@ -211,6 +235,15 @@ impl Sim {
/// Something worth a replay happened.
pub(super) fn log(&mut self, pos: DVec3, kind: EventKind) {
match kind {
EventKind::Crash { power, .. } => self.ignite(pos, power, None),
EventKind::Hit { power, sam, .. } => {
let host = sam.and_then(|(_, i)| self.sams.get(i)).map(|s| s.id);
self.ignite(pos, power.max(1), host)
}
EventKind::SamLost { sam, .. } => self.ignite(pos, 2, Some(sam)),
_ => {}
}
if let Some(r) = &mut self.rec {
let at = r.len;
if let EventKind::Kill { drone, .. } = kind
@@ -236,6 +269,12 @@ impl Sim {
self.rec = Some(Recording::new(self.recordings, self));
}
let Some(r) = &mut self.rec else { return };
if r.len % TIME_STEP == 0
&& let Some(dt) = self.day_time
&& r.times.len() == (r.len / TIME_STEP) as usize
{
r.times.push(dt);
}
if r.len % STEP == 0 {
for d in &self.drones {
r.point(d.id, What::Drone(d.kind), d.pos);
@@ -310,6 +349,31 @@ impl Sim {
}
}
}
// Fires: what the events left burning (the flames dim and the smoke thickens with time).
let mut lit: Vec<(DVec3, f64)> = Vec::new();
for (k, e) in rec.events.iter().enumerate() {
let power = match e.kind {
EventKind::Crash { power, .. } => power,
EventKind::Hit { power, .. } => power.max(1),
EventKind::SamLost { .. } => 2,
_ => continue,
};
let age = t - e.at as f64;
// One fire where several events met.
if lit.iter().any(|(p, at)| p.distance(e.pos) < 6.0 && *at <= e.at as f64) {
continue;
}
lit.push((e.pos, e.at as f64));
let (flame, smoke) = fires::replay_fire(power, age);
let id = base + 0x00c0_0000 + k as u32 * PARTS;
if flame > 0.05 {
let wob = 1.0 + 0.15 * (age * 0.9 + k as f64).sin();
out.model(id, "airdef:flame", e.pos + DVec3::Y * 0.5, DQuat::from_rotation_y(age * 0.07), (flame as f64 * 0.8 * wob).max(0.05), true);
}
if smoke > 0.1 && (age as u64) % 3 == 0 {
out.particles("minecraft:large_smoke", e.pos + DVec3::Y, 0.8, DVec3::new(0.0, 0.1, 0.0), 0.03, 1 + (smoke * 2.0) as u32);
}
}
// Blasts: a fireball growing and fading for 1.5 s after each.
for (k, e) in rec.events.iter().enumerate() {
let age = t - e.at as f64;
+715 -8
View File
@@ -137,7 +137,7 @@ fn retargeted_smart_drone_gets_a_route_round_the_defences() {
sim.tick = 40;
sim.adapt();
let d = &sim.drones[0];
assert!(d.target.distance(DVec3::new(0.0, 70.0, 1000.0)) < 1.0, "not sent at the unit");
assert!((d.target - d.err).distance(DVec3::new(0.0, 70.0, 1000.0)) < 1.0, "not sent at the unit");
assert!(d.route.len() > 1, "no route after the new target: it flies straight through the defences");
}
@@ -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 {
@@ -374,8 +375,12 @@ fn fast_drone_must_not_cross_a_one_block_wall() {
sim.add_drone(DroneKind::Zircon, DVec3::new(0.0, 100.0, 0.0), DVec3::X, DVec3::new(1000.0, 100.0, 0.0));
sim.drones[0].flight = Flight::Dive;
warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(30.0, 100.0, 0.0));
fly(&mut sim, &mut ground, &src);
assert!(sim.drones.is_empty(), "crossed the wall in one tick");
// The wall's exact shape is asked of the game (#63): a tick or two, the drone holds at its face meanwhile.
for _ in 0..4 {
fly(&mut sim, &mut ground, &src);
assert!(sim.drones.first().is_none_or(|d| d.pos.x < 3.5), "crossed the wall");
}
assert!(sim.drones.is_empty(), "stuck at the wall");
let at = sim.blasts.first().expect("no blast").at;
assert!((at.x - 3.0).abs() < 0.05 && (at.y - 100.0).abs() < 1.5, "burst at {at:?}, not at the wall face x=3");
assert_eq!((sim.crashes, sim.hits), (1, 0), "a wall in the way is a crash, not a hit on the aim");
@@ -392,7 +397,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() {
@@ -419,10 +426,14 @@ fn missile_cannot_pass_through_a_wall() {
sim.drones[0].vel = DVec3::ZERO;
sim.drones[0].engaged = 1;
let (target, id) = (sim.drones[0].id, sim.id());
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::X * 3.0, target, sam: 0, age: 20 });
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::X * 3.0, target, sam: 0, age: 20, hold: 0, fix: None, seen: 0 });
warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(30.0, 100.0, 0.0));
sim.tick = 1;
sim.step_missiles(&mut ground, &mut Out::default());
for _ in 0..4 {
sim.tick += 1;
sim.step_missiles(&mut ground, &mut Out::default());
let wanted = ground.world.take_wanted(sim.tick);
ground.world.serve(&src, wanted, sim.tick);
}
assert!(sim.missiles.is_empty(), "the missile is still flying");
assert_eq!(sim.kills, 0, "a kill through the wall");
assert!(sim.drones[0].alive(), "the target died behind the wall");
@@ -501,3 +512,699 @@ fn wall_and_tunnel_fixtures_agree() {
}
assert!(ground.world.bytes() < 64 * 1024);
}
// #57: a recording keeps the world clock of the raid, with jumps as steps.
#[test]
fn recordings_keep_the_time_of_day() {
let mut sim = Sim::new(1);
sim.day_time = Some(18000);
sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 500.0));
let mut g = Ground::default();
let mut out = Out::default();
for i in 0..100u64 {
sim.day_time = Some(if i < 50 { 18000 + i } else { 6000 + i });
sim.step(&mut g, None, &mut out);
}
let r = sim.rec.as_ref().unwrap();
assert_eq!(r.day_time(0.0), Some(18000 + sim_first(r)));
// The jump is a step, not a glide through the day.
for t in 0..r.len.min(100) {
let v = r.day_time(t as f64).unwrap();
assert!((18000..18060).contains(&v) || (6000..6110).contains(&v), "{t}: {v}");
}
}
fn sim_first(r: &record::Recording) -> u64 {
r.tracks.first().map_or(0, |t| t.first as u64)
}
// #52: a crash lights a fire; the oldest of too many dies down; water leaves steam only.
#[test]
fn fires_start_merge_and_are_limited() {
let mut sim = Sim::new(1);
sim.ignite(DVec3::new(0.0, 70.0, 0.0), 1, None);
sim.ignite(DVec3::new(2.0, 70.0, 0.0), 2, None);
assert_eq!(sim.fires.len(), 1, "one fire where two things fell");
assert!(sim.fires.list[0].size >= fires::size_for(2));
for i in 0..30 {
sim.tick += 10;
sim.ignite(DVec3::new(100.0 * (i + 1) as f64, 70.0, 0.0), 1, None);
}
let now = sim.tick;
// Past the limit the oldest are already dying down.
let healthy = sim.fires.list.iter().filter(|f| (now - f.start) + 160 < f.life as u64).count();
assert!(healthy <= fires::MAX, "{healthy}");
let (fl, sm) = fires::level(10.0, 3000.0);
assert!(fl > 0.0 && sm > 0.0);
assert_eq!(fires::level(3000.0, 3000.0).0, 0.0);
}
// ---- 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::Shahed, 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);
// The track is fresh only for a while (the target creeps out of reach): the reason is read at several moments.
let mut told = false;
for ticks in (150..=300).step_by(10) {
let sim = behind_ridge(&tall, 150, SamKind::IrisT, 230.0, ticks);
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);
told |= sim.why(0).iter().any(|c| !c.ok && c.text.contains("рельеф"));
}
assert!(told, "`why` never named the land as the reason");
}
// 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, hold: 0, fix: Some(fix_of(&sim.drones[0], sim.tick)), seen: 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");
}
}
}
/// The fix a link would have given on a target (a test stand-in for the firing system's track; these tests keep their targets still).
fn fix_of(d: &Drone, tick: u64) -> Fix {
Fix { pos: d.pos, vel: DVec3::ZERO, tick, seeker: false }
}
// 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, hold: 0, fix: Some(fix_of(&sim.drones[0], sim.tick)), seen: 0 });
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:?}");
}
// ---- #61-#66, #69 (review of 6f3fc64): unread blocks, cache lifetime, exact shapes, heights, accuracy, guidance, fires
/// Blocks with finer shapes: `shape` gives the boxes of a block (none: from `solid`).
struct Shaped<S: Fn(i32, i32, i32) -> Option<Vec<[f64; 6]>>>(S);
impl<S: Fn(i32, i32, i32) -> Option<Vec<[f64; 6]>>> world::WorldSource for Shaped<S> {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
(self.0)(x, y, z).map(|b| !b.is_empty())
}
fn boxes(&self, x: i32, y: i32, z: i32) -> Vec<[f64; 6]> {
(self.0)(x, y, z).unwrap_or_default()
}
}
// #61: a flight on a cold cache does not cross the wall before the game has answered
#[test]
fn cold_cache_never_means_clear_for_a_drone() {
let src = Blocks(|x, y, z| Some(y < 64 || (x == 3 && (64..141).contains(&y) && z.abs() < 50)));
let mut sim = Sim::new(1);
sim.damage = false;
let mut ground = Ground::default();
sim.add_drone(DroneKind::Zircon, DVec3::new(0.0, 100.0, 0.0), DVec3::X, DVec3::new(1000.0, 100.0, 0.0));
sim.drones[0].flight = Flight::Dive;
// No warm-up: nothing is read. One tick: the drone has not gone through, and asks for the sections.
sim.tick += 1;
sim.step_drones(&mut ground, None, &mut Out::default());
assert!(sim.drones[0].pos.x < 3.5, "crossed the wall unread: x = {}", sim.drones[0].pos.x);
assert!(ground.world.stats.held > 0);
let mut hit_wall = false;
for _ in 0..8 {
let wanted = ground.world.take_wanted(sim.tick);
ground.world.serve(&src, wanted, sim.tick);
sim.tick += 1;
sim.step_drones(&mut ground, None, &mut Out::default());
assert!(sim.drones.first().is_none_or(|d| d.pos.x < 3.5), "crossed the wall");
hit_wall |= sim.drones.is_empty();
}
assert!(hit_wall && sim.crashes == 1, "the wall was not found once read: crashes {}", sim.crashes);
}
// #61: nor does an interceptor fuse past the unread part of its way
#[test]
fn cold_cache_does_not_fuse_through_a_wall() {
let src = Blocks(|x, y, z| Some(y < 64 || (x == 3 && (64..141).contains(&y) && z.abs() < 50)));
let mut sim = Sim::new(1);
let mut ground = Ground::default();
sim.add_drone(DroneKind::Shahed, DVec3::new(8.5, 100.0, 0.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 900.0));
sim.drones[0].vel = DVec3::ZERO;
sim.drones[0].engaged = 1;
let (target, id) = (sim.drones[0].id, sim.id());
let fix = Some(fix_of(&sim.drones[0], 0));
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::X * 3.0, target, sam: 0, age: 20, hold: 0, fix, seen: 0 });
sim.tick = 1;
sim.step_missiles(&mut ground, &mut Out::default());
assert_eq!(sim.missiles.len(), 1, "the missile went on (held) at the edge of what is read");
assert!(sim.missiles[0].pos.x < 3.5 && sim.kills == 0 && sim.tally.missed == 0, "fused unread");
for _ in 0..6 {
let wanted = ground.world.take_wanted(sim.tick);
ground.world.serve(&src, wanted, sim.tick);
sim.tick += 1;
sim.step_missiles(&mut ground, &mut Out::default());
}
assert!(sim.missiles.is_empty() && sim.kills == 0 && sim.tally.blocked == 1, "{:?}", sim.tally);
}
// #61: a chunk the game does not have holds a low flight for a bounded time, then it goes on; a section
// that was queued as a look-ahead moves up when a step needs it, even with the queue full.
#[test]
fn unknown_policy_is_bounded_and_urgent_sections_jump_the_queue() {
let none = Blocks(|_, _, _| None);
let mut ground = Ground::default();
// Ground known: the flight is 5 blocks over it, so it is low.
ground.answer((-40..400).map(|x| (x, 0, 64)).collect(), 0);
let mut sim = Sim::new(1);
sim.damage = false;
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 69.0, 0.0), DVec3::X, DVec3::new(5000.0, 69.0, 0.0));
let mut held = 0;
for _ in 0..900 {
let before = sim.drones[0].pos;
sim.tick += 1;
sim.step_drones(&mut ground, None, &mut Out::default());
let wanted = ground.world.take_wanted(sim.tick);
ground.world.serve(&none, wanted, sim.tick);
held += (sim.drones[0].pos.distance(before) < 1e-3) as u32;
}
assert!(held > 20, "an unloaded chunk was no obstacle at all ({held})");
assert!(sim.drones[0].pos.x > 100.0, "held for ever at x = {}", sim.drones[0].pos.x);
// Urgent over ahead.
let mut w = world::WorldCache::default();
for i in 0..600 {
w.look_ahead(DVec3::new(f64::from(i) * 16.0, 70.0, 0.0), DVec3::X, 1.0, 1);
}
w.sweep(DVec3::new(500.5, 70.5, 0.5), DVec3::new(501.5, 70.5, 0.5), false, 1);
let first = w.take_wanted(1);
assert!(first.contains(&(31, 4, 0)), "the section a step needs waited behind look-aheads");
}
// #62: a chunk's unload or replacement voids what was known; light-only changes cost nothing
#[test]
fn chunk_replacement_and_age_invalidate_known_sections() {
let mut w = world::WorldCache::default();
let (a, b) = (DVec3::new(0.5, 70.5, 0.5), DVec3::new(30.5, 70.5, 0.5));
let empty = Blocks(|_, _, _| Some(false));
let wall = Blocks(|x, _, _| Some(x == 20));
w.changed_rev(1, 10, false, &[]);
for t in 1..4 {
w.sweep(a, b, false, t);
let q = w.take_wanted(t);
w.serve(&empty, q, t);
}
assert_eq!(w.sweep(a, b, false, 5), world::Sweep::Clear);
// Light only: the counter does not move, nothing happens.
let n = w.stats.invalidated;
w.changed_rev(1, 10, false, &[]);
assert_eq!(w.stats.invalidated, n, "a light update invalidated the cache");
// The chunk is replaced by a packet with a wall: the game says which column (unload/replace note).
w.columns_changed(&[(0, 0), (1, 0)]);
assert!(matches!(w.sweep(a, b, false, 6), world::Sweep::Unknown { pending: true, .. }), "an unloaded chunk still answers from memory");
for t in 7..10 {
w.sweep(a, b, false, t);
let q = w.take_wanted(t);
w.serve(&wall, q, t);
}
assert!(matches!(w.sweep(a, b, false, 10), world::Sweep::Hit { block: [20, 70, 0], .. }));
// A replacement nobody listed: the counter moved with no section named; everything read is read again.
w.changed_rev(1, 11, false, &[]);
assert!(matches!(w.sweep(a, b, false, 11), world::Sweep::Hit { .. }), "old data is used until the answer");
let q = w.take_wanted(11);
assert!(!q.is_empty());
w.serve(&empty, q, 12);
assert_eq!(w.sweep(a, b, false, 13), world::Sweep::Clear);
// And a section in use is read again after a while, told of or not.
for t in (14..14 + 1300).step_by(100) {
w.sweep(a, b, false, t);
}
assert!(w.stats.aged > 0 && !w.take_wanted(1400).is_empty(), "never read again");
// Another world, as ever.
w.changed_rev(2, 0, false, &[]);
assert!(w.is_empty());
}
// #63: a lower slab does not stop what flies over its free half; a full wall and the slab's own half do
#[test]
fn partial_blocks_use_exact_shapes() {
let slab = Shaped(|x, y, z| {
if y < 64 {
Some(vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]])
} else if (x, y, z) == (10, 70, 0) {
Some(vec![[0.0, 0.0, 0.0, 1.0, 0.5, 1.0]])
} else if x == 20 && z == 0 {
Some(vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]])
} else {
Some(vec![])
}
});
let mut g = Ground::default();
let run = |g: &mut Ground, a: DVec3, b: DVec3| {
let mut r = g.trace(7, a, b, false, 1);
for t in 1..8 {
let q = g.world.take_wanted(t);
g.world.serve(&slab, q, t);
if !matches!(r, world::Sweep::Wait { .. } | world::Sweep::Unknown { .. }) {
break;
}
r = g.trace(7, a, b, false, t + 1);
}
r
};
// The free upper half: clear (and the wall at x=20 is further on than the segment).
let r = run(&mut g, DVec3::new(9.5, 70.75, 0.5), DVec3::new(11.5, 70.75, 0.5));
assert_eq!(r, world::Sweep::Clear, "the free half of a slab was an obstacle");
// Through the slab's own half: a hit where its shape starts.
let mut g = Ground::default();
let r = run(&mut g, DVec3::new(9.5, 70.25, 0.5), DVec3::new(11.5, 70.25, 0.5));
let world::Sweep::Hit { point, normal, block, .. } = r else { panic!("{r:?}") };
assert!((point.x - 10.0).abs() < 1e-9 && normal == [-1, 0, 0] && block == [10, 70, 0], "{r:?}");
// A full-size wall is a hit at its face, like the section's cube.
let mut g = Ground::default();
let r = run(&mut g, DVec3::new(18.5, 70.5, 0.5), DVec3::new(22.5, 70.5, 0.5));
assert!(matches!(r, world::Sweep::Hit { block: [20, 70, 0], normal: [-1, 0, 0], .. }), "{r:?}");
// The flight holds at the face meanwhile (a pending answer is a wait, not a pass).
let mut g = Ground::default();
let w = world::WorldCache::default();
drop(w);
for t in 0..4 {
let q = g.world.take_wanted(t);
g.world.serve(&slab, q, t);
g.world.sweep(DVec3::new(18.5, 70.5, 0.5), DVec3::new(22.5, 70.5, 0.5), false, t);
}
let first = g.trace(9, DVec3::new(18.5, 70.5, 0.5), DVec3::new(22.5, 70.5, 0.5), false, 5);
assert!(matches!(first, world::Sweep::Wait { .. }), "{first:?}");
// Never answered: after the wait the solid cube of the sections stands.
let last = g.trace(9, DVec3::new(18.5, 70.5, 0.5), DVec3::new(22.5, 70.5, 0.5), false, 5 + 31);
assert!(matches!(last, world::Sweep::Hit { .. }), "{last:?}");
}
// #64: heights known are old when blocks change, a chunk is replaced, or time passes; they are asked again
#[test]
fn land_heights_follow_world_changes_and_age() {
let mut g = Ground::default();
g.answer(vec![(4, 4, 150)], 0);
assert_eq!(g.land(DVec3::new(4.0, 70.0, 4.0)), Some(150.0));
assert!(g.take_wanted().is_empty(), "a fresh height was asked again");
// The mountain is blown away: the game says which section changed.
g.world.changed(1, false, &[(0, 9, 0)]);
g.answer(vec![], 10);
assert_eq!(g.land(DVec3::new(4.0, 70.0, 4.0)), Some(150.0), "the old height is used until the new one comes");
assert!(g.take_wanted().contains(&(4, 4)), "a changed column was not asked again");
g.answer(vec![(4, 4, 70)], 11);
assert_eq!(g.land(DVec3::new(4.0, 70.0, 4.0)), Some(70.0));
// Another column is not touched by that.
g.answer(vec![(100, 100, 90)], 12);
g.world.changed(1, false, &[(0, 9, 0)]);
g.answer(vec![], 13);
assert_eq!(g.land(DVec3::new(100.0, 70.0, 100.0)), Some(90.0));
assert!(!g.take_wanted().contains(&(100, 100)));
// Time alone: the issue's case, 500 seconds on.
g.answer(vec![], 10_000);
assert_eq!(g.land(DVec3::new(4.0, 70.0, 4.0)), Some(70.0));
assert!(g.take_wanted().contains(&(4, 4)), "never asked again after 500 s");
// A planner column too.
let mut g = Ground::default();
g.answer(vec![(20, 20, 80)], 0);
g.answer(vec![], 5000);
assert_eq!(g.column(20, 20, 1000), Some(Some(80.0)));
assert!(g.take_wanted().contains(&(20, 20)));
// A chunk replaced.
let mut g = Ground::default();
g.answer(vec![(4, 4, 150), (8, 8, 150)], 0);
g.world.columns_changed(&[(0, 0)]);
g.answer(vec![], 1);
g.land(DVec3::new(4.0, 70.0, 4.0));
assert!(g.take_wanted().contains(&(4, 4)));
}
// #65: manual launches and raids draw their navigation error the same way, and a retarget keeps it
#[test]
fn raids_have_the_same_strike_accuracy_as_manual_launches() {
for kind in [DroneKind::Kh101, DroneKind::Shahed, DroneKind::Zircon] {
let (cep, gross) = kind.accuracy();
let aim = DVec3::new(0.0, 70.0, 1000.0);
let mut sim = Sim::new(1);
let (mut manual, mut raid) = (Vec::new(), Vec::new());
for _ in 0..600 {
sim.add_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::Z, aim);
let d = sim.drones.pop().unwrap();
manual.push(horizontal(d.target - aim).length());
let d = sim.make_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::Z, aim);
raid.push(horizontal(d.target - aim).length());
assert!(d.err != DVec3::ZERO && (d.target - aim - d.err).length() < 1e-9);
}
for (name, v) in [("manual", &mut manual), ("raid", &mut raid)] {
v.sort_by(f64::total_cmp);
let median = v[v.len() / 2];
assert!((median - cep).abs() < cep * 0.25, "{kind:?} {name}: median {median} for a CEP of {cep}");
let far = v.iter().filter(|r| **r > MISS_RADIUS).count() as f64 / v.len() as f64;
assert!(far > gross * 0.4 && far < gross * 2.5 + 0.02, "{kind:?} {name}: {far} beyond the blast for a fault rate of {gross}");
}
}
// A planned raid's drones all carry their error, and a smart retarget gives the new aim on top of it.
let mut sim = Sim::new(3);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Ew, DVec3::new(0.0, 70.0, 900.0), 0.0));
sim.intel.revealed.push((id, DVec3::new(0.0, 70.0, 900.0), 0));
let order = RaidOrder { kinds: vec![(DroneKind::Zircon, 20)], approach: Approach::Sector, dist: 400.0, aim: Aim::Objects };
sim.order_raid(&order, &[DVec3::new(0.0, 70.0, 0.0)], &mut Out::default());
for _ in 0..40 {
if !sim.planning() {
break;
}
sim.step(&mut Ground::default(), None, &mut Out::default());
}
assert!(!sim.queue.is_empty() && sim.queue.iter().all(|(_, d)| d.err != DVec3::ZERO), "raid drones without a navigation error");
let mut d = sim.make_drone(DroneKind::Zircon, DVec3::new(0.0, 100.0, -500.0), DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
let err = d.err;
d.smart = true;
sim.queue.clear();
sim.queue.push((0, d));
sim.tick = 40;
sim.adapt();
let d = &sim.queue[0].1;
assert!((d.target - d.err - DVec3::new(0.0, 70.0, 900.0)).length() < 1e-9 && d.err == err, "the retarget lost or doubled the error");
}
// #66: an interceptor steers on what it was told or what its seeker holds, not on the true position
#[test]
fn interceptors_guide_on_tracks_and_seekers_not_on_truth() {
let fly = |hidden: DVec3, fix: Option<Fix>| {
let mut sim = Sim::new(1);
sim.add_drone(DroneKind::Shahed, hidden, DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0));
sim.drones[0].vel = DVec3::ZERO;
let (target, id) = (sim.drones[0].id, sim.id());
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix, seen: 0 });
sim.tick = 100;
sim.step_missiles(&mut Ground::default(), &mut Out::default());
sim.missiles.first().map(|m| m.vel)
};
// The issue's case: no system, no track, a target 600 away that is somewhere else in the second copy.
let a = fly(DVec3::new(0.0, 100.0, 600.0), None);
let b = fly(DVec3::new(300.0, 100.0, 600.0), None);
assert_eq!(a, b, "the missile knows where the target is without any measurement");
// The data link's fix is what it steers on, wherever the target went since.
let told = Some(Fix { pos: DVec3::new(300.0, 100.0, 600.0), vel: DVec3::ZERO, tick: 100, seeker: false });
let (c, d) = (fly(DVec3::new(0.0, 100.0, 600.0), told), fly(DVec3::new(-300.0, 100.0, 600.0), told));
assert_eq!(c, d);
assert!(c.unwrap().x > 0.05, "it did not turn to the fix: {c:?}");
// The seeker takes over once the target is in its reach: now the truth steers it.
let near = |at: DVec3| {
let mut sim = Sim::new(1);
sim.add_drone(DroneKind::Shahed, at, DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0));
sim.drones[0].vel = DVec3::ZERO;
let (target, id) = (sim.drones[0].id, sim.id());
let stale = Some(Fix { pos: DVec3::new(-60.0, 100.0, 80.0), vel: DVec3::ZERO, tick: 90, seeker: false });
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix: stale, seen: 0 });
sim.tick = 100;
sim.step_missiles(&mut Ground::default(), &mut Out::default());
(sim.missiles[0].vel, sim.missiles[0].seen)
};
let (v, seen) = near(DVec3::new(40.0, 100.0, 70.0));
assert!(seen == 100 && v.x > 0.05, "the seeker did not hold a target in its reach: {v:?}");
let (v, seen) = near(DVec3::new(300.0, 100.0, 70.0));
assert!(seen == 0 && v.x < -0.05, "a target outside its reach steered it: {v:?}");
// Behind a mountain the seeker sees nothing.
let mut sim = Sim::new(1);
sim.add_drone(DroneKind::Shahed, DVec3::new(40.0, 100.0, 70.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0));
sim.drones[0].vel = DVec3::ZERO;
let (target, id) = (sim.drones[0].id, sim.id());
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix: Some(Fix { pos: DVec3::new(0.0, 100.0, 300.0), vel: DVec3::ZERO, tick: 90, seeker: false }), seen: 0 });
let hill = Blocks(|x, y, z| Some(y < 64 || ((10..14).contains(&x) && (0..40).contains(&z) && y < 300)));
let mut ground = Ground::default();
for t in 0..6 {
ground.world.probe(DVec3::new(0.0, 100.0, 0.0), DVec3::new(40.0, 100.0, 70.0), t);
let q = ground.world.take_wanted(t);
ground.world.serve(&hill, q, t);
}
sim.tick = 100;
sim.step_missiles(&mut ground, &mut Out::default());
assert_eq!(sim.missiles[0].seen, 0, "the seeker saw through a mountain");
// No fix for too long and no seeker on it: the interceptor is lost, and counted so.
let mut sim = Sim::new(1);
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, 3000.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0));
sim.drones[0].vel = DVec3::ZERO;
let (target, id) = (sim.drones[0].id, sim.id());
let old = Some(Fix { pos: DVec3::new(0.0, 100.0, 300.0), vel: DVec3::ZERO, tick: 0, seeker: false });
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix: old, seen: 0 });
sim.tick = MAX_COAST + 50;
sim.step_missiles(&mut Ground::default(), &mut Out::default());
assert!(sim.missiles.is_empty() && sim.tally.expired == 1, "{:?}", sim.tally);
}
// #69: many separate strikes: the list, what is drawn and the lights are bounded; the rest end properly
#[test]
fn mass_strikes_keep_fires_effects_and_lights_bounded() {
let mut sim = Sim::new(1);
sim.damage = true;
let mut ground = Ground::default();
ground.answer((-100..2000).flat_map(|x| (-10..10).map(move |z| (x, z, 70))).collect(), 0);
for i in 0..120 {
sim.ignite(DVec3::new(f64::from(i) * 10.0, 70.0, 0.0), (i % 5) as u32, None);
}
assert!(sim.fires.len() <= fires::LIST_MAX, "{} fires kept", sim.fires.len());
let mut peak_particles = 0;
for _ in 0..200 {
sim.tick += 1;
let mut out = Out::default();
sim.step_fires(&mut ground, &mut out);
peak_particles = peak_particles.max(out.particles.iter().map(|p| p.count as usize).sum::<usize>());
assert!(sim.fires.list.iter().filter(|f| f.shown).count() <= fires::MAX);
assert!(sim.fires.len() <= fires::LIST_MAX);
}
// Twelve fires at full strength: well under what 120 would have been.
assert!(peak_particles < 12 * 24 + fires::LIST_MAX, "{peak_particles} particles in a tick");
// A long stream of strikes, one every few ticks, stays bounded; at the end all go out and the blocks go with them.
for i in 0..400 {
sim.tick += 5;
sim.ignite(DVec3::new(f64::from(i % 150) * 12.0, 70.0, 0.0), 2, None);
sim.step_fires(&mut ground, &mut Out::default());
assert!(sim.fires.len() <= fires::LIST_MAX);
}
let mut commands = 0;
for _ in 0..8000 {
sim.tick += 1;
let mut out = Out::default();
sim.step_fires(&mut ground, &mut out);
commands += out.commands.len();
if sim.fires.is_empty() {
break;
}
}
assert!(sim.fires.is_empty(), "fires burn for ever");
let _ = commands;
}
+228
View File
@@ -0,0 +1,228 @@
//! Radar scan cycles with ageing tracks (#46) and damage to a system's parts (#50).
use super::sight::{swept, FORECAST_AGE};
use super::*;
fn flat() -> (Ground, Out) {
(Ground::default(), Out::default())
}
fn radar_at(sim: &mut Sim, kind: SamKind, x: f64) -> usize {
let id = sim.id();
sim.sams.push(Sam::new(id, kind, DVec3::new(x, 70.0, 0.0), 0.0));
sim.sams.len() - 1
}
/// A Shahed flying east along z = 40, from x = `from`, high enough to be above any horizon of the systems.
fn drone_from(sim: &mut Sim, from: f64) -> u32 {
sim.add_drone(DroneKind::Shahed, DVec3::new(from, 95.0, 40.0), DVec3::X, DVec3::new(5000.0, 95.0, 40.0));
sim.drones.last().unwrap().id
}
// #46: the radar measures a target when the beam passes it; between passes its track stays where it was measured.
#[test]
fn a_radar_measures_once_a_turn_and_the_track_stays_put_between() {
let mut sim = Sim::new(5);
let (mut ground, mut out) = flat();
let r = radar_at(&mut sim, SamKind::Radar, 0.0);
let id = drone_from(&mut sim, -150.0);
let (mut seen_at, mut last) = (Vec::new(), None::<(u64, DVec3)>);
let mut stale_gap = 0.0f64;
for _ in 0..700 {
sim.step(&mut ground, None, &mut out);
let Some(t) = sim.sams[r].track(id).copied() else { continue };
match last {
Some((tick, pos)) if tick == t.tick => {
assert_eq!(pos, t.pos, "the track moved without a new measurement");
let d = sim.drones.iter().find(|d| d.id == id).unwrap();
stale_gap = stale_gap.max(d.pos.distance(t.pos));
if t.age(sim.tick) > FORECAST_AGE {
assert!(t.forecast(sim.tick));
}
}
_ => seen_at.push(t.tick),
}
last = Some((t.tick, t.pos));
}
assert!(seen_at.len() >= 2, "measured {} times", seen_at.len());
// A turn of the early-warning radar is 140 ticks (less relative to a target that moves round it): never every tick.
assert!(seen_at.windows(2).all(|w| w[1] - w[0] >= 60), "{seen_at:?}");
assert!(stale_gap > 20.0, "the track never fell behind the target ({stale_gap})");
}
#[test]
fn a_beam_crossing_north_between_ticks_still_passes_a_target_there() {
let tau = std::f64::consts::TAU;
assert!(swept(0.01, tau - 0.05, 0.1));
assert!(swept(tau - 0.02, tau - 0.05, 0.1));
assert!(!swept(0.2, tau - 0.05, 0.1));
assert!(!swept(tau - 0.1, tau - 0.05, 0.1));
}
#[test]
fn two_radars_have_their_own_phase_and_the_network_keeps_the_age() {
let mut sim = Sim::new(7);
let (mut ground, mut out) = flat();
let a = radar_at(&mut sim, SamKind::Radar, 0.0);
let b = radar_at(&mut sim, SamKind::Radar, 500.0);
let n = radar_at(&mut sim, SamKind::P1sun, 250.0);
assert_ne!(sim.sams[a].beam, sim.sams[b].beam, "radars start at different phases");
let id = drone_from(&mut sim, -50.0);
let mut relayed = 0;
for _ in 0..800 {
sim.step(&mut ground, None, &mut out);
let own = |s: usize| sim.sams[s].track(id).map_or(0, |t| t.own_tick);
let Some(t) = sim.sams[n].track(id) else { continue };
relayed += 1;
assert_eq!(t.own_tick, 0, "a relayed measurement is not the receiver's own");
assert!(t.tick <= own(a).max(own(b)), "the network made a track fresher than any measurement");
if own(a).max(own(b)) > 0 {
assert_eq!(t.tick, own(a).max(own(b)), "the network holds the freshest measurement");
}
}
assert!(relayed > 100, "the network never passed a track ({relayed})");
}
#[test]
fn a_lock_measures_every_tick_and_a_dead_radar_stops_the_feed() {
let mut sim = Sim::new(3);
let (mut ground, mut out) = flat();
let p = radar_at(&mut sim, SamKind::Pantsir, 0.0);
let net = radar_at(&mut sim, SamKind::P1sun, 100.0);
let id = drone_from(&mut sim, -190.0);
let mut locked = 0;
for _ in 0..600 {
sim.step(&mut ground, None, &mut out);
if sim.sams[p].target == Some(id)
&& let Some(t) = sim.sams[p].track(id)
&& t.age(sim.tick) <= 1
&& t.locked
{
locked += 1;
}
}
assert!(locked > 5, "no fire-control lock ({locked})");
// The radar is destroyed: the network's track is no longer renewed.
let newest = sim.sams[net].tracks.iter().map(|t| t.tick).max();
sim.sams[p].health = 0.0;
let drones = sim.drones.iter().filter(|d| d.alive()).count();
for _ in 0..40 {
sim.step(&mut ground, None, &mut out);
}
assert!(sim.sams[p].tracks.is_empty() && sim.sams[p].seen.is_empty());
if drones > 0 {
assert!(sim.sams[net].tracks.iter().map(|t| t.tick).max() <= newest, "a dead radar still renewed a track");
}
}
#[test]
fn a_failed_radar_gives_no_own_tracks_but_the_network_still_feeds_it() {
let mut sim = Sim::new(9);
let (mut ground, mut out) = flat();
let p = radar_at(&mut sim, SamKind::Patriot, 0.0);
let early = radar_at(&mut sim, SamKind::Radar, 120.0);
let id = drone_from(&mut sim, -150.0);
sim.sams[p].comps.0[Part::Sensor as usize] = 0.0;
for _ in 0..500 {
sim.step(&mut ground, None, &mut out);
}
assert!(!sim.sams[p].radar_up());
assert!(sim.sams[p].own.is_empty(), "a failed radar measured something");
assert!(sim.sams[p].track(id).is_some(), "the network should still feed the system");
// Without its link it is blind.
sim.sams[p].comps.0[Part::Comms as usize] = 0.0;
sim.sams[p].tracks.clear();
for _ in 0..200 {
sim.step(&mut ground, None, &mut out);
}
assert!(sim.sams[p].tracks.is_empty(), "no link, no network tracks");
assert!(sim.sams[early].own.len() >= sim.sams[p].own.len());
}
// #50: a blast hurts the parts nearest to it.
#[test]
fn a_blast_from_behind_hurts_the_link_a_blast_from_the_front_the_drive() {
let at = DVec3::new(0.0, 70.0, 0.0);
let hurt = |dz: f64| {
let mut sim = Sim::new(4);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Patriot, at, 0.0));
let mut out = Out::default();
sim.blast_damage(at + DVec3::new(0.0, 3.0, dz), 2, &mut out);
assert!(sim.sams[0].alive());
let c = sim.sams[0].comps;
(1.0 - c.get(Part::Comms), 1.0 - c.get(Part::Mount), 1.0 - c.get(Part::Sensor))
};
let (back_comms, back_mount, _) = hurt(6.0);
let (front_comms, front_mount, _) = hurt(-6.0);
assert!(back_comms > back_mount, "behind: link {back_comms} drive {back_mount}");
assert!(front_mount > front_comms, "front: link {front_comms} drive {front_mount}");
}
#[test]
fn a_close_blast_can_leave_a_firing_but_immobile_system() {
// Over many blasts at moderate range some leave the system able to fire and unable to drive.
let mut found = false;
for seed in 0..60 {
let mut sim = Sim::new(seed);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Pantsir, DVec3::new(0.0, 70.0, 0.0), 0.0));
let mut out = Out::default();
sim.tick = seed;
sim.blast_damage(DVec3::new(0.0, 69.0, 6.0 + seed as f64 * 0.06), 3, &mut out);
let s = &sim.sams[0];
if s.alive() && s.part(Part::Tracks) <= 0.0 && s.part(Part::Launcher) > 0.0 && s.part(Part::Gun) > 0.0 {
assert!(!sim.order_move(0, DVec3::new(300.0, 70.0, 0.0)), "failed running gear must refuse a march");
assert!(why::missile_blocks(&sim.sams[0]) & why::LAUNCHER_DOWN == 0);
assert!(sim.why(0).iter().any(|c| c.text.contains("ходовая отказала")), "{:?}", sim.why(0).iter().map(|c| &c.text).collect::<Vec<_>>());
found = true;
break;
}
}
assert!(found, "no seed left it firing but immobile");
}
#[test]
fn failed_parts_block_what_they_serve_and_repair_restores_them() {
let mut sim = Sim::new(2);
let g = radar_at(&mut sim, SamKind::Gepard, 0.0);
let p = radar_at(&mut sim, SamKind::Patriot, 400.0);
sim.sams[g].comps.0[Part::Gun as usize] = 0.0;
assert!(!sim.sams[g].gun_up());
assert!(why::gun_blocks(&sim.sams[g], 1, 1, 0.0, 10.0, 90.0, 0) & why::JAMMED != 0);
sim.sams[p].comps.0[Part::Launcher as usize] = 0.0;
assert!(why::missile_blocks(&sim.sams[p]) & why::LAUNCHER_DOWN != 0);
// A worn launcher: fewer cells, a longer reload.
sim.sams[p].comps.0[Part::Launcher as usize] = 0.3;
sim.sams[p].fit_cells();
let full = Sam::new(0, SamKind::Patriot, DVec3::ZERO, 0.0);
assert!(sim.sams[p].cells() < full.cells());
assert!(sim.sams[p].missiles <= sim.sams[p].cells());
assert!(sim.sams[p].reload_time() > full.reload_time());
// A worn mount turns slower, a failed one not at all.
sim.sams[p].comps.0[Part::Mount as usize] = 0.0;
assert_eq!(sim.sams[p].drive_fit(), 0.0);
sim.sams[p].comps.0[Part::Tracks as usize] = 0.0;
assert_eq!(sim.repair(), 2, "both damaged systems are counted");
assert!(sim.sams.iter().all(|s| s.comps.whole() && s.cells() == s.kind.spec().missile.as_ref().map_or(0, |m| m.magazine)));
assert!(sim.later.is_empty(), "repair must not leave cook-offs behind");
assert!(sim.order_move(p, DVec3::new(500.0, 70.0, 0.0)));
}
#[test]
fn the_same_seed_rolls_the_same_scan_and_damage() {
let run = || {
let mut sim = Sim::new(11);
let (mut ground, mut out) = flat();
let r = radar_at(&mut sim, SamKind::Radar, 0.0);
let id = drone_from(&mut sim, -150.0);
let mut ticks = Vec::new();
for _ in 0..500 {
sim.step(&mut ground, None, &mut out);
if let Some(t) = sim.sams[r].track(id) {
ticks.push(t.tick);
}
}
ticks
};
assert_eq!(run(), run());
}
+246 -22
View File
@@ -3,6 +3,17 @@
//! (over the curve of the earth, scaled to the game's distances), and the land
//! hides what flies behind hills and woods: each system keeps its horizon, the
//! highest the land rises seen from its antenna, ray by ray round it.
//!
//! A radar does not know where everything is all the time. Its beam sweeps round
//! (a period by the kind of radar; an electronic one also revisits what it holds
//! more often), a target is measured when the beam passes it, with a chance that
//! falls with range and size, and the measurement is kept as a *track*: where it
//! was and how fast it went then, and when. Between measurements the track is
//! carried on by that velocity (a forecast, shown as one), and after three missed
//! scans it is dropped. A fire-control lock on a target the system is engaging is
//! a dedicated channel: it measures every tick while the target is in reach and in
//! sight. The network passes tracks with the age of their measurement; relaying
//! makes none of them fresher.
use super::*;
/// Rays round a radar, and blocks between the land's samples along them.
@@ -10,6 +21,13 @@ const RAYS: usize = 48;
const STEP: f64 = 16.0;
/// How far units share their tracks (the network's data links).
pub(super) const LINK: f64 = 1500.0;
/// A launcher fires on a track no older than this (ticks); a gun needs its own radar's, newer than `GUN_AGE`.
pub(super) const MISSILE_AGE: u64 = 120;
pub(super) const GUN_AGE: u64 = 3;
/// A gun takes a target its radar measured this recently (then the lock refreshes it every tick).
pub(super) const GUN_PICK_AGE: u64 = 40;
/// A track older than this is shown as a forecast.
pub const FORECAST_AGE: u64 = 20;
/// Radio horizon: `HORIZON * (√mast + √height)` blocks.
const HORIZON: f64 = 35.0;
/// Anything this near is seen (and heard), whatever hides it.
@@ -70,13 +88,144 @@ pub(super) fn reach(sam: &Sam, kind: DroneKind, height: f64) -> f64 {
if spec.sensor <= 0.0 {
return 0.0;
}
let radar = spec.sensor * sam.fitness() * kind.rcs().powf(0.25);
let radar = spec.sensor * sam.radar_fit() * kind.rcs().powf(0.25);
let horizon = HORIZON * (spec.mast.sqrt() + height.max(0.0).sqrt());
radar.min(horizon).max(NEAR)
}
/// How a kind's radar looks round.
pub(super) struct Scan {
/// Ticks for the beam to go round.
pub period: u32,
/// An electronic radar: what it holds it revisits at once a third of the period, between the beam's passes.
pub agile: bool,
}
impl SamKind {
pub(super) fn scan(self) -> Scan {
let (period, agile) = match self {
SamKind::Patriot => (30, true),
SamKind::S400 => (36, true),
SamKind::S500 => (24, true),
SamKind::IrisT | SamKind::Nasams => (40, true),
SamKind::Buk => (50, false),
SamKind::Tor | SamKind::Pantsir => (40, false),
SamKind::Gepard => (50, false),
// The early-warning radar: a big antenna turning slowly.
SamKind::Radar => (140, false),
_ => (40, false),
};
Scan { period, agile }
}
}
/// How well a track says what it is.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Ident {
/// One measurement: something is there.
Unknown,
/// Measured again: a drone or missile by how it flies.
Probable,
/// Held by a lock or by several measurements.
Confirmed,
}
impl Ident {
pub fn name(self) -> &'static str {
match self {
Ident::Unknown => "неопознана",
Ident::Probable => "предположительно воздушная цель",
Ident::Confirmed => "подтверждена",
}
}
}
/// What a system knows of one target: the last measurement, not the target itself.
#[derive(Clone, Copy, Debug)]
pub struct Track {
pub id: u32,
/// Where it was and how it went when last measured (blocks, blocks a tick).
pub pos: DVec3,
pub vel: DVec3,
/// When that was, and when this system's own radar last measured it (0: never, the network's only).
pub tick: u64,
pub own_tick: u64,
/// Ticks the track lives without a new measurement.
pub ttl: u32,
pub hits: u8,
/// The last own measurement came from a fire-control lock.
pub locked: bool,
}
impl Track {
pub fn age(&self, now: u64) -> u64 {
now.saturating_sub(self.tick)
}
/// Where the target should be by now, carrying the last measurement on: the forecast, not a measurement.
pub fn est(&self, now: u64) -> DVec3 {
self.pos + self.vel * self.age(now) as f64
}
pub fn forecast(&self, now: u64) -> bool {
self.age(now) > FORECAST_AGE
}
/// Own radar measured it within `age` ticks.
pub fn own_within(&self, now: u64, age: u64) -> bool {
self.own_tick > 0 && now.saturating_sub(self.own_tick) <= age
}
pub fn ident(&self) -> Ident {
match self.hits {
0 | 1 => Ident::Unknown,
2 => Ident::Probable,
_ => Ident::Confirmed,
}
}
/// How far the forecast may be off, blocks: grows with the age.
pub fn uncertainty(&self, now: u64) -> f64 {
1.0 + self.age(now) as f64 * (0.05 + self.vel.length() * 0.1)
}
}
impl Sam {
/// Ticks for its beam to go round (longer when the radar is damaged).
pub(super) fn scan_ticks(&self) -> f64 {
self.kind.scan().period as f64 / self.radar_fit().max(0.2)
}
/// Its own radar works: it has one, it is not failed and the unit is deployed.
pub fn radar_up(&self) -> bool {
self.alive() && !self.moving() && self.kind.spec().sensor > 0.0 && self.part(Part::Sensor) > 0.0
}
pub fn track(&self, id: u32) -> Option<&Track> {
self.tracks.iter().find(|t| t.id == id)
}
}
/// Whether a beam that moved from `from` by `w` radians this tick passed `bearing` (all angles taken round 2π,
/// so a beam crossing north between two ticks still passes a target there).
pub(super) fn swept(bearing: f64, from: f64, w: f64) -> bool {
(bearing - from).rem_euclid(TAU) < w
}
/// A new measurement into a list of tracks (the index maps ids to places): a fresher one replaces an older.
fn merge(tracks: &mut Vec<Track>, index: &mut HashMap<u32, usize>, t: Track) {
match index.get(&t.id) {
Some(&k) => {
let mine = &mut tracks[k];
if t.tick > mine.tick {
let (own_tick, locked) = (mine.own_tick, if t.own_tick > 0 { t.locked } else { mine.locked });
let hits = mine.hits.max(t.hits);
*mine = Track { own_tick: if t.own_tick > 0 { t.own_tick } else { own_tick }, hits, locked, ..t };
}
}
None => {
index.insert(t.id, tracks.len());
tracks.push(t);
}
}
}
impl Sim {
/// Rebuilds the systems' horizons in turn, and what each radar holds now.
/// Rebuilds the systems' horizons in turn, then lets each radar's beam pass the sky: new measurements
/// into the tracks, old tracks out, and the network's tracks shared.
pub(super) fn update_sight(&mut self, ground: &mut Ground) {
let now = self.tick;
// One horizon every few ticks: the land answers come in over time.
@@ -95,44 +244,108 @@ impl Sim {
// Each drone's height over the land under it (or over the systems' ground, unknown).
let heights: Vec<Option<f64>> =
self.drones.iter().map(|d| ground.at(d.pos, now).map(|g| d.pos.y - g)).collect();
let drone_at: HashMap<u32, usize> = self.drones.iter().enumerate().map(|(k, d)| (d.id, k)).collect();
let seed = self.seed;
let mut index: HashMap<u32, usize> = HashMap::new();
for sam in &mut self.sams {
sam.seen.clear();
sam.own.clear();
if !sam.alive() || sam.kind.spec().sensor <= 0.0 || sam.moving() {
// A track of a drone that is gone is dropped (the kill is seen), so is one not renewed for its time.
sam.tracks.retain(|t| now - t.tick <= t.ttl as u64 && drone_at.get(&t.id).is_some_and(|&k| self.drones[k].alive()));
if !sam.alive() || sam.moving() {
sam.tracks.clear();
continue;
}
if !sam.radar_up() {
continue;
}
index.clear();
index.extend(sam.tracks.iter().enumerate().map(|(k, t)| (t.id, k)));
let spec = sam.kind.spec();
let scan = sam.kind.scan();
let period = sam.scan_ticks();
let w = TAU / period;
let (b0, ttl) = (sam.beam, ((period * 3.0) as u32).max(60));
sam.beam = (b0 + w) % TAU;
let at = antenna(sam);
let horizon = self.horizons.get(&sam.id);
let mut fresh: Vec<Track> = Vec::new();
for (d, h) in self.drones.iter().zip(&heights) {
if !d.alive() {
continue;
}
let dist = d.pos.distance(at);
let rel = d.pos - at;
let dist = rel.length();
if dist > spec.sensor * 1.2 {
continue;
}
let locked = sam.target == Some(d.id) || sam.gun_target == Some(d.id);
let held = index.get(&d.id).map(|&k| sam.tracks[k]);
// The beam passes its bearing this tick (the angle wraps at 2π: the difference is taken round).
let in_beam = swept(rel.x.atan2(-rel.z), b0, w);
let revisit = scan.agile && held.is_some_and(|t| t.own_tick > 0 && now - t.own_tick >= (period / 3.0) as u64);
if !(in_beam || locked || revisit) {
continue;
}
let height = h.unwrap_or(d.pos.y - sam.pos.y);
if dist > reach(sam, d.kind, height) {
let reach = reach(sam, d.kind, height);
if dist > reach || (dist > NEAR && horizon.is_some_and(|hz| hz.hides(at, d.pos))) {
continue;
}
if dist > NEAR && horizon.is_some_and(|hz| hz.hides(at, d.pos)) {
let key = (sam.id as u64) << 32 | d.id as u64;
// A target in the beam is measured with a chance that falls towards the edge of reach and
// with the target's stealth; one the radar already follows is lost only now and then.
let p = if locked || revisit {
0.985
} else {
((0.97 - 0.5 * (dist / reach).powi(3)) / d.kind.spec().stealth.sqrt()).clamp(0.05, 0.97)
};
if dist > NEAR && dice(seed, now, key, 0) >= p {
continue;
}
sam.own.push(d.id);
let sigma = if locked { 0.3 } else { 0.8 + dist * 0.004 };
let noise = DVec3::new(dice(seed, now, key, 1) - 0.5, (dice(seed, now, key, 2) - 0.5) * 0.5, dice(seed, now, key, 3) - 0.5) * 2.0 * sigma;
fresh.push(Track {
id: d.id,
pos: d.pos + noise,
vel: d.vel,
tick: now,
own_tick: now,
ttl,
hits: held.map_or(1, |t| t.hits.saturating_add(1)),
locked,
});
}
for t in fresh {
merge(&mut sam.tracks, &mut index, t);
}
}
// The network: every unit gets the tracks of the radars within data-link range.
let tracks: Vec<(DVec3, Vec<u32>)> =
self.sams.iter().filter(|s| !s.own.is_empty()).map(|s| (s.pos, s.own.clone())).collect();
// Merged in the radars' order, each track once: a set marks what is in already.
let mut have: std::collections::HashSet<u32> = std::collections::HashSet::new();
// The network: every unit gets the tracks the radars within data-link range measured themselves,
// with the time they were measured: a relayed track is never fresher than its measurement.
let shared: Vec<(u32, DVec3, f64, Vec<Track>)> = self
.sams
.iter()
.filter(|s| s.alive() && !s.moving() && s.link_range() > 0.0)
.map(|s| (s.id, s.pos, s.link_range(), s.tracks.iter().filter(|t| t.own_tick == t.tick).copied().collect::<Vec<_>>()))
.filter(|s| !s.3.is_empty())
.collect();
for sam in &mut self.sams {
sam.seen.clone_from(&sam.own);
if !sam.alive() || sam.moving() || !sam.kind.armed() {
continue;
if sam.alive() && !sam.moving() && sam.kind.armed() && sam.link_range() > 0.0 && !shared.is_empty() {
index.clear();
index.extend(sam.tracks.iter().enumerate().map(|(k, t)| (t.id, k)));
let mine = sam.link_range();
for (id, at, range, tracks) in &shared {
if *id != sam.id && at.distance(sam.pos) <= range.min(mine) {
for t in tracks {
merge(&mut sam.tracks, &mut index, Track { own_tick: 0, ..*t });
}
}
}
}
have.clear();
have.extend(sam.own.iter().copied());
for (at, ids) in &tracks {
if at.distance(sam.pos) <= LINK {
sam.seen.extend(ids.iter().copied().filter(|id| have.insert(*id)));
sam.seen.clear();
sam.own.clear();
for t in &sam.tracks {
sam.seen.push(t.id);
if t.own_within(now, t.ttl as u64) {
sam.own.push(t.id);
}
}
}
@@ -142,4 +355,15 @@ impl Sim {
pub fn tracked(&self, id: u32) -> bool {
self.sams.iter().any(|s| s.seen.contains(&id))
}
/// The freshest track of each drone any system holds: the air picture.
pub fn picture(&self) -> HashMap<u32, Track> {
let mut best: HashMap<u32, Track> = HashMap::new();
for t in self.sams.iter().flat_map(|s| s.tracks.iter()) {
if best.get(&t.id).is_none_or(|b| t.tick > b.tick) {
best.insert(t.id, *t);
}
}
best
}
}
+354
View File
@@ -0,0 +1,354 @@
//! 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.
/// 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,
}
}
/// 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 => {
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)
}
+110 -33
View File
@@ -11,29 +11,32 @@ use super::*;
pub(super) const DEAD: u8 = 1;
pub(super) const ROLE: u8 = 2;
pub(super) const UNSEEN: u8 = 4;
/// Its track is older than a launcher fires on.
pub(super) const STALE: u8 = 8;
pub(super) fn target_blocks(roles: u8, seen: &[u32], d: &Drone) -> u8 {
pub(super) fn target_blocks(roles: u8, track: Option<&Track>, now: u64, d: &Drone) -> u8 {
(if d.alive() { 0 } else { DEAD })
| (if roles & d.kind.spec().class.bit() != 0 { 0 } else { ROLE })
| (if seen.contains(&d.id) { 0 } else { UNSEEN })
| (if track.is_some() { 0 } else { UNSEEN })
| (if track.is_some_and(|t| t.age(now) > sight::MISSILE_AGE) { STALE } else { 0 })
}
/// Alive, of a class the system engages, and on its picture (own radar or the network's).
pub(super) fn allowed(roles: u8, seen: &[u32], d: &Drone) -> bool {
target_blocks(roles, seen, d) == 0
/// Alive, of a class the system engages, and on its picture (own radar or the network's), fresh enough.
pub(super) fn allowed(roles: u8, track: &Track, now: u64, d: &Drone) -> bool {
target_blocks(roles, Some(track), now, d) == 0
}
pub(super) const TOO_CLOSE: u8 = 1;
pub(super) const TOO_FAR: u8 = 2;
/// Missile range: `min` to `max * k` (a target already held may stray to 1.1 × max).
pub(super) fn reach_blocks(min: f64, max: f64, k: f64, pos: DVec3, d: &Drone) -> u8 {
let dist = d.pos.distance(pos);
pub(super) fn reach_blocks(min: f64, max: f64, k: f64, pos: DVec3, at: DVec3) -> u8 {
let dist = at.distance(pos);
(if dist >= min { 0 } else { TOO_CLOSE }) | (if dist < max * k { 0 } else { TOO_FAR })
}
pub(super) fn in_reach(min: f64, max: f64, k: f64, pos: DVec3, d: &Drone) -> bool {
reach_blocks(min, max, k, pos, d) == 0
pub(super) fn in_reach(min: f64, max: f64, k: f64, pos: DVec3, at: DVec3) -> bool {
reach_blocks(min, max, k, pos, at) == 0
}
/// Interceptors a target has or will have: in flight, and other systems' launchers on it.
@@ -47,9 +50,10 @@ pub(super) fn needed(d: &Drone) -> u32 {
if d.kind.spec().class == Class::Ballistic { 2 } else { 1 }
}
/// A gun's candidates: held by the system's own radar and within the gun's tracking range.
pub(super) fn gun_candidate(roles: u8, seen: &[u32], own: &[u32], track: f64, pos: DVec3, d: &Drone) -> bool {
allowed(roles, seen, d) && own.contains(&d.id) && d.pos.distance(pos) < track
/// A gun's candidates: measured by the system's own radar a moment ago and within the gun's tracking range
/// (by the track's forecast, not the target's true place).
pub(super) fn gun_candidate(roles: u8, track: &Track, now: u64, gun_track: f64, pos: DVec3, d: &Drone) -> bool {
allowed(roles, track, now, d) && track.own_within(now, sight::GUN_PICK_AGE) && track.est(now).distance(pos) < gun_track
}
/// How many systems have each drone as their missile target and as their gun target.
@@ -66,8 +70,8 @@ pub(super) fn engagement_counts(sams: &[Sam]) -> (HashMap<u32, u32>, HashMap<u32
// ---------------------------------------------------------------- aiming
/// Where the lead point is and the yaw and pitch that point the mount at it.
pub(super) fn aim_wants(mount: DVec3, aim: &Drone, lead_speed: f64) -> (DVec3, f64, f64) {
let lead = aim.pos + aim.vel * (aim.pos.distance(mount) / lead_speed);
pub(super) fn aim_wants(mount: DVec3, at: DVec3, vel: DVec3, lead_speed: f64) -> (DVec3, f64, f64) {
let lead = at + vel * (at.distance(mount) / lead_speed);
let to = lead - mount;
let want_yaw = heading_of(to);
let want_pitch = (to.y / to.length().max(0.01)).asin().clamp(-0.1, 1.45);
@@ -88,19 +92,22 @@ pub(super) fn aim_error(family: Family, yaw: f64, pitch: f64, want_yaw: f64, wan
pub(super) const COOLDOWN: u8 = 1;
pub(super) const RELOADING: u8 = 2;
pub(super) const NO_ROUNDS: u8 = 4;
/// The launcher has failed.
pub(super) const LAUNCHER_DOWN: u8 = 8;
/// What keeps a loaded launcher from firing now (aim aside).
pub(super) fn missile_blocks(s: &Sam) -> u8 {
(if s.cooldown == 0 { 0 } else { COOLDOWN })
(if s.part(Part::Launcher) > 0.0 { 0 } else { LAUNCHER_DOWN })
| (if s.cooldown == 0 { 0 } else { COOLDOWN })
| (if s.reloading == 0 { 0 } else { RELOADING })
| (if s.missiles > 0 { 0 } else { NO_ROUNDS })
}
/// The launcher points near enough: a trainable one only by its training, a turret by its
/// whole aim at this target, the vertical ones need nothing.
pub(super) fn missile_aligned(family: Family, yaw: f64, mount: DVec3, t: Option<&Drone>, aim_id: u32, mt: u32, aim_error: f64) -> bool {
pub(super) fn missile_aligned(family: Family, yaw: f64, mount: DVec3, t: Option<DVec3>, aim_id: u32, mt: u32, aim_error: f64) -> bool {
match family {
Family::Trainable => t.is_some_and(|t| angle_diff(yaw, heading_of(t.pos - mount)).abs() < 0.15),
Family::Trainable => t.is_some_and(|t| angle_diff(yaw, heading_of(t - mount)).abs() < 0.15),
Family::Turret => aim_id == mt && aim_error < 0.15,
_ => true,
}
@@ -109,11 +116,17 @@ pub(super) fn missile_aligned(family: Family, yaw: f64, mount: DVec3, t: Option<
pub(super) const NOT_AIMED: u8 = 8;
pub(super) const AIM_ERROR: u8 = 16;
pub(super) const OUT_OF_RANGE: u8 = 32;
/// The gun is jammed (for a while, or failed).
pub(super) const JAMMED: u8 = 64;
/// Its radar has no fresh measurement of the target.
pub(super) const NO_LOCK: u8 = 128;
/// What keeps a gun from firing now: the turret on another target, not yet laid, out of range,
/// and the gun's own cooldown, reload and shells (`COOLDOWN`, `RELOADING`, `NO_ROUNDS` bits).
pub(super) fn gun_blocks(s: &Sam, aim_id: u32, gt: u32, aim_error: f64, dist: f64, range: f64) -> u8 {
pub(super) fn gun_blocks(s: &Sam, aim_id: u32, gt: u32, aim_error: f64, dist: f64, range: f64, now: u64) -> u8 {
(if aim_id == gt { 0 } else { NOT_AIMED })
| (if s.gun_up() { 0 } else { JAMMED })
| (if s.track(gt).is_some_and(|t| t.own_within(now, sight::GUN_AGE)) { 0 } else { NO_LOCK })
| (if aim_error < 0.1 { 0 } else { AIM_ERROR })
| (if dist < range { 0 } else { OUT_OF_RANGE })
| (if s.gun_cooldown == 0 { 0 } else { COOLDOWN })
@@ -160,9 +173,38 @@ impl Sim {
let logistics = self.logistics();
let (missile_on, guns_on) = engagement_counts(&self.sams);
let alive = self.drones.iter().filter(|d| d.alive()).count();
let now = self.tick;
let seen = self.drones.iter().filter(|d| d.alive() && sam.seen.contains(&d.id)).count();
let own = self.drones.iter().filter(|d| d.alive() && sam.own.contains(&d.id)).count();
v.push(check(alive > 0, format!("в небе {alive}, на картине {seen} (своя РЛС {own}, остальные по сети)")));
// The parts: what is worn or failed and what it costs.
if !sam.comps.whole() {
v.push(check(false, format!("узлы: {}", sam.parts_text())));
}
for p in Part::ALL {
if !p.on(sam.kind) || sam.part_state(p) == PartState::Ok {
continue;
}
let failed = sam.part_state(p) == PartState::Failed;
let effect = match (p, failed) {
(Part::Sensor, true) => "РЛС отказала: своих измерений нет, стволы не стреляют, ракеты — по данным сети".to_string(),
(Part::Sensor, false) => format!("РЛС повреждена: дальность {:.0}%, обзор вдвое реже при худшем", sam.radar_fit() * 100.0),
(Part::Mount, true) => format!("привод наведения заклинил: стоит на {:.0}°, не доворачивается", sam.yaw.to_degrees().rem_euclid(360.0)),
(Part::Mount, false) => format!("привод наведения повреждён: поворот {:.0}% от нормы", sam.drive_fit() * 100.0),
(Part::Launcher, true) => "пусковая отказала: пуски невозможны".to_string(),
(Part::Launcher, false) => format!("пусковая повреждена: {} ячеек из {}, перезарядка {:.0} с", sam.cells(), spec.missile.as_ref().map_or(0, |m| m.magazine), sam.reload_time() as f64 / 20.0),
(Part::Gun, true) => "орудие заклинило: не стреляет".to_string(),
(Part::Gun, false) => format!("орудие повреждено: меткость {:.0}%, заклинивает", sam.gun_fit() * 100.0),
(Part::Tracks, true) => "ходовая отказала: на марш не выйдет".to_string(),
(Part::Tracks, false) => format!("ходовая повреждена: скорость марша {:.0}%", sam.drive_speed() * 100.0),
(Part::Comms, true) => "связь отказала: сети нет, цели только свои".to_string(),
(Part::Comms, false) => format!("связь повреждена: линия {:.0} м", sam.link_range()),
};
v.push(check(false, effect));
}
if sam.jam > 0 {
v.push(check(false, format!("орудие заклинило, ещё {:.1} с", sam.jam as f64 / 20.0)));
}
let drone_at = |id: u32| self.drones.iter().find(|d| d.id == id);
let subject = sam.target.or(sam.gun_target).and_then(drone_at).or_else(|| {
self.drones
@@ -175,24 +217,42 @@ impl Sim {
return v;
};
let held = sam.target == Some(t.id) || sam.gun_target == Some(t.id);
let dist = t.pos.distance(sam.pos);
let dist = sam.track(t.id).map_or(t.pos, |tr| tr.est(self.tick)).distance(sam.pos);
v.push(check(
true,
format!("цель: {} {}, {}", t.kind.spec().name, if held { "(сопровождается)" } else { "(самая опасная)" }, crate::panel::distance(dist)),
));
let blocks = target_blocks(sam.roles, &sam.seen, t);
let track = sam.track(t.id);
let blocks = target_blocks(sam.roles, track, now, t);
// Where the system believes the target is: its track's forecast (the true place only when it has no track).
let est = track.map_or(t.pos, |tr| tr.est(now));
let evel = track.map_or(t.vel, |tr| tr.vel);
v.push(check(
blocks & ROLE == 0,
format!("роль: {} {} [{}]", t.kind.spec().class.name(), if blocks & ROLE == 0 { "в ролях" } else { "не в ролях" }, roles_text(sam.roles)),
));
let detect = match (sam.own.contains(&t.id), sam.seen.contains(&t.id)) {
(true, _) => "обнаружена своей РЛС",
(false, true) => "видна только по сети (стволы не стреляют)",
_ => "не обнаружена ни своей РЛС, ни сетью",
let detect = match (track, sam.own.contains(&t.id)) {
(Some(tr), own) => {
let kind = if own { "своей РЛС" } else { "по сети (стволы не стреляют)" };
let age = tr.age(now) as f64 / 20.0;
let lock = if tr.own_within(now, sight::GUN_AGE) && tr.locked { ", захват" } else { "" };
let how = if tr.forecast(now) { format!("прогноз ±{:.0} м", tr.uncertainty(now)) } else { "измерение".to_string() };
format!("трек {kind}: {}, возраст {age:.1} с, {how}{lock}, {}", tr.ident().name(), if blocks & STALE != 0 { "устарел для пуска" } else { "годен для пуска" })
}
_ => "не обнаружена ни своей РЛС, ни сетью".to_string(),
};
v.push(check(blocks & UNSEEN == 0, detect));
let fit = sam.fitness();
v.push(check(fit >= 0.9, format!("состояние {:.0}%: поворот и точность {:.0}%", sam.health * 100.0, fit * 100.0)));
v.push(check(blocks & (UNSEEN | STALE) == 0, detect));
if sam.kind.spec().sensor > 0.0 {
let period = sam.scan_ticks();
v.push(check(
sam.radar_up(),
if sam.radar_up() {
format!("обзор: оборот {:.1} с{}, луч на {:.0}°", period / 20.0, if sam.kind.scan().agile { ", электронный, цели в треке чаще" } else { "" }, sam.beam.to_degrees())
} else {
"обзор: РЛС не работает".to_string()
},
));
}
// Ammunition and its supply.
let mut rounds = String::new();
if let Some(load) = &spec.missile {
@@ -211,12 +271,12 @@ impl Sim {
v.push(check(sam.reloading == 0 && !sam.empty && (sam.missiles > 0 || sam.bursts > 0), format!("{rounds}— {supply}")));
let mount = sam.mount().0;
let lead_speed = spec.gun.as_ref().map_or(5.0, |g| g.shell);
let (_, want_yaw, want_pitch) = aim_wants(mount, t, lead_speed);
let (_, want_yaw, want_pitch) = aim_wants(mount, est, evel, lead_speed);
let err = aim_error(spec.family, sam.yaw, sam.pitch, want_yaw, want_pitch);
let vertical = spec.family == Family::Vertical;
let mut fires = false;
if let Some(load) = &spec.missile {
let reach = reach_blocks(load.min, load.max, 1.0, sam.pos, t);
let reach = reach_blocks(load.min, load.max, 1.0, sam.pos, est);
v.push(check(
reach == 0,
format!(
@@ -241,15 +301,30 @@ impl Sim {
if mb & NO_ROUNDS != 0 {
why.push("нет ракет".to_string());
}
if mb & LAUNCHER_DOWN != 0 {
why.push("пусковая отказала".to_string());
}
v.push(check(mb == 0, if mb == 0 { "пусковая готова".to_string() } else { format!("пусковая не готова: {}", why.join(", ")) }));
let aligned = missile_aligned(spec.family, sam.yaw, mount, Some(t), t.id, t.id, err);
let aligned = missile_aligned(spec.family, sam.yaw, mount, Some(est), t.id, t.id, err);
let turn = if vertical { "вертикальный старт" } else { "доворот" };
v.push(check(aligned, format!("{turn}: ошибка наведения {:.0}°{}", err.to_degrees(), if aligned { "" } else { " — ещё доворачивается" })));
fires |= reach == 0 && free < need && mb == 0 && aligned && blocks == 0;
// 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));
let cand = gun_candidate(sam.roles, &sam.seen, &sam.own, gun.track, sam.pos, t);
let cand = track.is_some_and(|tr| gun_candidate(sam.roles, tr, now, gun.track, sam.pos, t));
v.push(check(
cand,
format!(
@@ -259,7 +334,7 @@ impl Sim {
if cand { "" } else { " — вне сопровождения (нужна своя РЛС)" }
),
));
let gb = gun_blocks(sam, t.id, t.id, err, mount.distance(t.pos), gun.range);
let gb = gun_blocks(sam, t.id, t.id, err, mount.distance(est), gun.range, now);
let mut why = Vec::new();
for (bit, name) in [
(AIM_ERROR, format!("орудие ещё наводится ({:.0}°)", err.to_degrees())),
@@ -267,6 +342,8 @@ impl Sim {
(COOLDOWN, "пауза между очередями".to_string()),
(RELOADING, "перезарядка".to_string()),
(NO_ROUNDS, "нет снарядов".to_string()),
(JAMMED, "орудие заклинило".to_string()),
(NO_LOCK, "нет свежего захвата своей РЛС".to_string()),
] {
if gb & bit != 0 {
why.push(name);
+303 -10
View File
@@ -18,6 +18,8 @@ pub const PER_TICK: usize = 64;
const MAX_SECTIONS: usize = 12_000;
/// Ticks before an unanswered or unknown section is asked again.
const RETRY: u64 = 60;
/// Ticks after which a section in use is read again, whether or not a change was reported (#62).
const RECHECK: u64 = 1200;
/// Longest stretch of a flight asked for ahead, in blocks.
const AHEAD_MAX: f64 = 192.0;
/// Questions waiting: more are dropped (they are asked again when needed).
@@ -57,8 +59,11 @@ pub enum Sweep {
Clear,
/// The first solid block: where the segment meets it, which way the face looks, and the block.
Hit { t: f64, point: DVec3, normal: [i32; 3], block: [i32; 3] },
/// The walk reached blocks the game doesn't know (before any solid one), `t` of the way.
Unknown { t: f64 },
/// The walk reached blocks the game doesn't know (before any solid one), `t` of the way. `pending`: the
/// section hasn't been answered yet (it is asked for, urgently); else the game said it has no chunk there.
Unknown { t: f64, pending: bool },
/// A solid block was met at `t` and its exact shape is being asked for (#63): the flight holds at its face.
Wait { t: f64 },
}
impl Sweep {
@@ -83,10 +88,60 @@ pub struct WorldStats {
pub invalidated: u64,
pub evicted: u64,
pub world_changes: u64,
/// Steps cut short at unread blocks (#61), and steps let through after waiting for too long.
pub held: u64,
pub passed_unknown: u64,
/// Sections dropped or re-read for a chunk's unload or replacement, or for age (#62).
pub aged: u64,
pub columns_changed: u64,
}
/// What the game's exact ray said of a flight's step, once it answered (#63).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Exact {
Pending,
/// The collision shapes leave the way open.
Clear,
/// Where the shapes stop it: the point, the face and the block.
Hit { point: DVec3, normal: [i32; 3], block: [i32; 3] },
}
struct RayEntry {
id: u32,
a: DVec3,
b: DVec3,
state: Exact,
asked: u64,
}
/// The game's verdict on an exact ray, as `render.rays` gives it.
#[derive(Clone, Copy, Debug)]
pub struct RayReply {
pub id: u32,
pub clear: bool,
pub hit: bool,
pub fraction: f64,
pub normal: [i32; 3],
pub block: [i32; 3],
}
/// Ticks an exact ray may take to be answered before the block counts as the solid cube the sections say.
const RAY_WAIT: u64 = 30;
/// Exact rays asked in one tick (the game's budget is 256).
const RAYS_PER_TICK: usize = 128;
#[derive(Default)]
struct RayDesk {
next: u32,
/// By flight and block: a flight that meets the same block again finds its answer.
entries: HashMap<(u32, [i32; 3]), RayEntry>,
by_id: HashMap<u32, (u32, [i32; 3])>,
out: Vec<(u32, DVec3, DVec3)>,
}
#[derive(Default)]
pub struct WorldCache {
rays: RayDesk,
secs: HashMap<Sec, Entry>,
urgent: Vec<Sec>,
ahead: Vec<Sec>,
@@ -94,6 +149,15 @@ pub struct WorldCache {
/// Sections asked, and when.
asked: HashMap<Sec, u64>,
epoch: u32,
/// The game's count of block changes, as of the last note, and the one the section entries were read at.
revision: u64,
/// The last `solid` that could not tell: the section was not answered yet.
unread_pending: bool,
/// Sweeps made as a look ahead (flight planning) ask for sections after the urgent ones.
quiet: bool,
/// 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,
pub stats: WorldStats,
}
@@ -134,6 +198,22 @@ impl WorldCache {
/// The game's note of changes: another world voids everything; changed
/// sections (or too many to say) are read again when next used.
pub fn changed(&mut self, epoch: u32, overflow: bool, sections: &[Sec]) {
self.changed_rev(epoch, self.revision, overflow, sections);
}
/// The same with the game's change counter: a jump in it that the listed sections don't explain
/// (changes the game didn't list, a chunk replaced behind the subscription's back) makes every section
/// read before it due for a new read when it is next used (#62).
pub fn changed_rev(&mut self, epoch: u32, revision: u64, overflow: bool, sections: &[Sec]) {
let unexplained = self.epoch == epoch && revision != self.revision && sections.is_empty() && !overflow;
self.revision = revision;
if unexplained {
// Not named, so not known which: a light update alone doesn't move the counter, a block change does.
for e in self.secs.values_mut() {
e.stale = true;
}
self.stats.invalidated += self.secs.len() as u64;
}
if epoch != self.epoch {
if self.epoch != 0 {
self.secs.clear();
@@ -141,9 +221,13 @@ impl WorldCache {
self.ahead.clear();
self.queued.clear();
self.asked.clear();
self.changed_all = true;
}
self.epoch = epoch;
}
if overflow || unexplained {
self.changed_all = true;
}
if overflow {
for e in self.secs.values_mut() {
e.stale = true;
@@ -151,6 +235,7 @@ impl WorldCache {
self.stats.invalidated += self.secs.len() as u64;
}
for s in sections {
self.note_column(s.0, s.2);
if let Some(e) = self.secs.get_mut(s) {
e.stale = true;
self.stats.invalidated += 1;
@@ -159,6 +244,38 @@ impl WorldCache {
self.stats.world_changes += sections.len() as u64;
}
fn note_column(&mut self, sx: i32, sz: i32) {
if self.changed_cols.len() >= 4096 {
self.changed_all = true;
} else {
self.changed_cols.insert((sx, sz));
}
}
/// Chunk columns (x, z in chunks) the game unloaded or replaced (#62): what is known of their sections is
/// void (`Unknown`) until they are read again. A new chunk packet replaces the blocks wholesale, which no
/// per-block note says.
pub fn columns_changed(&mut self, columns: &[(i32, i32)]) {
if columns.is_empty() {
return;
}
let set: HashSet<(i32, i32)> = columns.iter().copied().collect();
let mut n = 0u64;
for &(cx, cz) in columns {
self.note_column(cx, cz);
}
for (s, e) in self.secs.iter_mut() {
if set.contains(&(s.0, s.2)) {
e.fill = Fill::Unknown;
e.stale = true;
e.since = 0;
n += 1;
}
}
self.stats.invalidated += n;
self.stats.columns_changed += columns.len() as u64;
}
/// An explosion is about to change the land: the sections round it are read again.
pub fn touched(&mut self, at: DVec3, radius: f64) {
let lo = sec_of((at.x - radius).floor() as i32, (at.y - radius).floor() as i32, (at.z - radius).floor() as i32);
@@ -218,11 +335,24 @@ impl WorldCache {
}
fn want(&mut self, s: Sec, urgent: bool, now: u64) {
if self.queued.contains(&s) || self.asked.get(&s).is_some_and(|&t| now.saturating_sub(t) < RETRY) {
if self.queued.contains(&s) {
// Already waiting as a look-ahead, and a step needs it now: it moves up (#61).
if urgent && let Some(i) = self.ahead.iter().position(|a| *a == s) {
self.ahead.swap_remove(i);
self.urgent.push(s);
}
return;
}
if self.asked.get(&s).is_some_and(|&t| now.saturating_sub(t) < RETRY) {
return;
}
if self.urgent.len() + self.ahead.len() >= QUEUE_MAX {
return;
// Full: an urgent need pushes a look-ahead out, a look-ahead waits.
if !urgent || self.ahead.is_empty() {
return;
}
let gone = self.ahead.swap_remove(0);
self.queued.remove(&gone);
}
if urgent {
self.urgent.push(s);
@@ -264,7 +394,12 @@ impl WorldCache {
return false;
};
e.used = now;
let refresh = e.stale || (e.fill == Fill::Unknown && now.saturating_sub(e.since) >= RETRY);
// Whatever is in use is read again now and then, bounded: a change nobody told of ends up seen (#62).
let aged = e.fill != Fill::Unknown && now.saturating_sub(e.since) >= RECHECK;
if aged {
self.stats.aged += 1;
}
let refresh = e.stale || aged || (e.fill == Fill::Unknown && now.saturating_sub(e.since) >= RETRY);
if refresh {
self.want(s, urgent, now);
}
@@ -277,10 +412,19 @@ 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,
self.unread_pending = false;
let Some(entry) = self.secs.get(&s) else {
self.unread_pending = true;
return None;
};
match &entry.fill {
Fill::Unknown => {
// Asked and answered unknown: no chunk there. Asked again (or not at all yet): waiting.
self.unread_pending = entry.stale && entry.since == 0;
None
}
Fill::Empty => Some(false),
Fill::Full => Some(true),
Fill::Mixed(bits) => {
@@ -301,6 +445,84 @@ 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
}
/// A step of flight `key` from `a` to `b` that met a solid block (`hit`): the occupancy says "stops
/// something", the shape may leave room (slabs, stairs, fences, panes). The block's exact shapes are asked
/// of the game (a ray over the step) and the flight holds at the face meanwhile; a `Hit` comes back as the
/// exact contact, a `Clear` lets this flight through that block. An answer that doesn't come in
/// [`RAY_WAIT`] ticks, or `unknown`, leaves the hit as the solid cube.
pub fn narrow(&mut self, key: u32, hit: Sweep, a: DVec3, b: DVec3, now: u64) -> Sweep {
let Sweep::Hit { t, block, .. } = hit else { return hit };
let k = (key, block);
let Some(e) = self.rays.entries.get_mut(&k) else {
if self.rays.entries.len() >= 4096 {
return hit;
}
self.rays.next = self.rays.next.wrapping_add(1);
let id = self.rays.next;
self.rays.entries.insert(k, RayEntry { id, a, b, state: Exact::Pending, asked: now });
self.rays.by_id.insert(id, k);
self.rays.out.push((id, a, b));
return Sweep::Wait { t };
};
if e.state == Exact::Pending && now.saturating_sub(e.asked) > RAY_WAIT {
e.state = Exact::Hit { point: a + (b - a) * t, normal: [0; 3], block };
}
match e.state {
Exact::Pending => Sweep::Wait { t },
Exact::Clear => Sweep::Clear,
Exact::Hit { point, normal, block } => {
let d = b - a;
let f = ((point - a).dot(d) / d.length_squared().max(1e-12)).max(0.0);
if f > 1.0 {
// The shape's face is beyond this step: nothing is met yet.
Sweep::Clear
} else {
Sweep::Hit { t: f, point, normal, block }
}
}
}
}
/// The exact rays to ask this tick: id, start, end.
pub fn take_rays(&mut self, now: u64) -> Vec<(u32, DVec3, DVec3)> {
let n = RAYS_PER_TICK.min(self.rays.out.len());
let out: Vec<_> = self.rays.out.drain(..n).collect();
if now % 128 == 0 {
let dead: Vec<_> = self.rays.entries.iter().filter(|(_, e)| now.saturating_sub(e.asked) > 600).map(|(k, _)| *k).collect();
for k in dead {
if let Some(e) = self.rays.entries.remove(&k) {
self.rays.by_id.remove(&e.id);
}
}
}
out
}
/// The game's answers to the exact rays.
pub fn answer_rays(&mut self, replies: &[RayReply]) {
for r in replies {
let Some(k) = self.rays.by_id.get(&r.id).copied() else { continue };
let Some(e) = self.rays.entries.get_mut(&k) else { continue };
e.state = if r.clear {
Exact::Clear
} else if r.hit {
Exact::Hit { point: e.a + (e.b - e.a) * r.fraction, normal: r.normal, block: r.block }
} else {
// No verdict: the solid cube the sections said.
Exact::Hit { point: e.a, normal: [0; 3], block: k.1 }
};
}
}
/// 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).
@@ -329,7 +551,7 @@ impl WorldCache {
match self.solid(&mut last, pos[0], pos[1], pos[2], now) {
None => {
self.stats.unknown_sweeps += 1;
return Sweep::Unknown { t };
return Sweep::Unknown { t, pending: self.unread_pending };
}
Some(true) if !(first && skip_start) => {
self.stats.hits += 1;
@@ -355,7 +577,7 @@ impl WorldCache {
face[axis] = -step[axis];
}
self.stats.unknown_sweeps += 1;
Sweep::Unknown { t }
Sweep::Unknown { t, pending: false }
}
}
@@ -364,6 +586,38 @@ impl WorldCache {
pub trait WorldSource {
/// Whether the block stops a projectile; `None` where there is no chunk.
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool>;
/// The collision boxes of the block (`[x0, y0, z0, x1, y1, z1]` inside the block): a full cube where
/// it is solid, unless the world has finer shapes.
fn boxes(&self, x: i32, y: i32, z: i32) -> Vec<[f64; 6]> {
if self.solid(x, y, z) == Some(true) { vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]] } else { Vec::new() }
}
}
/// The first entry of segment `a` to `b` into the box (slab test): the fraction and the axis.
fn enter_box(a: DVec3, d: DVec3, lo: DVec3, hi: DVec3) -> Option<(f64, usize)> {
let (mut t0, mut t1, mut axis) = (0.0f64, 1.0f64, usize::MAX);
for i in 0..3 {
let (o, v) = (a[i], d[i]);
if v.abs() < 1e-12 {
if o < lo[i] || o > hi[i] {
return None;
}
} else {
let (mut n, mut f) = ((lo[i] - o) / v, (hi[i] - o) / v);
if n > f {
std::mem::swap(&mut n, &mut f);
}
if n > t0 {
t0 = n;
axis = i;
}
t1 = t1.min(f);
if t0 > t1 {
return None;
}
}
}
Some((t0, axis))
}
/// A [`WorldSource`] as the game would answer `render.query-sections` with it.
@@ -391,9 +645,48 @@ pub fn section_of(source: &dyn WorldSource, s: Sec) -> Fill {
}
impl WorldCache {
/// Answers the exact rays of the last tick from a synthetic world, as the game does one tick later.
pub fn serve_rays(&mut self, source: &dyn WorldSource, rays: Vec<(u32, DVec3, DVec3)>) {
let replies: Vec<RayReply> = rays
.into_iter()
.map(|(id, a, b)| {
let d = b - a;
let mut best: Option<(f64, usize, [i32; 3])> = None;
let (lo, hi) = (a.min(b).floor(), a.max(b).floor());
for x in lo.x as i32..=hi.x as i32 {
for y in lo.y as i32..=hi.y as i32 {
for z in lo.z as i32..=hi.z as i32 {
for bx in source.boxes(x, y, z) {
let o = DVec3::new(f64::from(x), f64::from(y), f64::from(z));
if let Some((t, axis)) = enter_box(a, d, o + DVec3::new(bx[0], bx[1], bx[2]), o + DVec3::new(bx[3], bx[4], bx[5]))
&& best.is_none_or(|b| t < b.0)
{
best = Some((t, axis, [x, y, z]));
}
}
}
}
}
match best {
None => RayReply { id, clear: true, hit: false, fraction: 0.0, normal: [0; 3], block: [0; 3] },
Some((t, axis, block)) => {
let mut normal = [0; 3];
if axis < 3 {
normal[axis] = if d[axis] > 0.0 { -1 } else { 1 };
}
RayReply { id, clear: false, hit: true, fraction: t, normal, block }
}
}
})
.collect();
self.answer_rays(&replies);
}
/// Answers the questions of the last tick from a synthetic world, as the game does one tick later.
pub fn serve(&mut self, source: &dyn WorldSource, wanted: Vec<Sec>, now: u64) {
let answers = wanted.into_iter().map(|sec| SectionAnswer { sec, fill: section_of(source, sec) }).collect();
self.answer(answers, now);
let rays = self.take_rays(now);
self.serve_rays(source, rays);
}
}
+179
View File
@@ -0,0 +1,179 @@
//! Smoke for the HD renderer (`render.set-smoke`): exhaust trails of drones and missiles that leave
//! a clear tunnel in fog and cloud, smoke columns over fires, and the smoke of blasts. The game
//! derives everything from a segment's age, so the columns and blasts are a pure function of the
//! tick (nothing grows twice when a frame is shown again); trails are the only state, and they are
//! cut back when the tick goes backwards.
use crate::sim::{Class, Sim};
use glam::DVec3;
use horizon_mod_sdk::render::SmokeSegment;
use std::collections::HashMap;
const MAX_TRAIL: usize = 2400;
const MAX_TOTAL: usize = 4000;
struct Trail {
seg: SmokeSegment,
}
#[derive(Default)]
pub struct Smoke {
trails: Vec<Trail>,
/// Where each flying thing last left a segment: (kind, id) -> (position, tick).
last: HashMap<(u8, u32), (DVec3, u64)>,
last_tick: u64,
next: u32,
/// The renderer draws the smoke (the answer of the last call).
pub hd: bool,
}
fn seg(id: u32, a: DVec3, b: DVec3, born: u64, life: u32) -> SmokeSegment {
SmokeSegment {
id,
ax: a.x,
ay: a.y,
az: a.z,
bx: b.x,
by: b.y,
bz: b.z,
born: born as f64,
life: life as f32,
radius_start: 1.0,
radius_end: 6.0,
density: 0.5,
heat: 0.0,
soot: 0.4,
rise: 0.2,
clear: 0.0,
}
}
impl Smoke {
fn trail(&mut self, key: (u8, u32), at: DVec3, tick: u64, style: impl Fn(&mut SmokeSegment)) {
let Some(&(from, when)) = self.last.get(&key) else {
self.last.insert(key, (at, tick));
return;
};
if at.distance(from) < 5.0 && tick - when < 10 {
return;
}
// A jump (a teleport, a respawn) is no trail.
if at.distance(from) < 400.0 {
self.next = self.next.wrapping_add(1);
let mut s = seg(self.next, from, at, tick, 1200);
style(&mut s);
self.trails.push(Trail { seg: s });
}
self.last.insert(key, (at, tick));
}
/// Follows the simulation one tick: new trail segments for what flies, old ones dropped.
pub fn update(&mut self, sim: &Sim) {
let tick = sim.tick;
if tick < self.last_tick {
self.trails.retain(|t| t.seg.born as u64 <= tick);
self.last.clear();
}
self.last_tick = tick;
for d in &sim.drones {
let class = d.kind.spec().class;
let (r0, r1, dens, heat, soot, clear, life): (f32, f32, f32, f32, f32, f32, f32) = match class {
Class::Drone => (0.9, 5.0, 2.0, 0.0, 0.45, 3.0, 1200.0),
Class::Jet | Class::Cruise => (1.1, 7.0, 2.5, 0.15, 0.35, 4.0, 1300.0),
Class::Supersonic => (1.4, 9.0, 3.2, 0.5, 0.2, 5.0, 1300.0),
Class::Ballistic => (2.0, 13.0, 4.0, 0.7, 0.15, 7.0, 1500.0),
};
self.trail((0, d.id), d.pos, tick, |s| {
s.radius_start = r0;
s.radius_end = r1;
s.density = dens;
s.heat = heat;
s.soot = soot;
s.clear = clear;
s.life = life;
});
}
for m in &sim.missiles {
self.trail((1, m.id), m.pos, tick, |s| {
s.radius_start = 0.6;
s.radius_end = 5.0;
s.density = 3.0;
s.heat = 0.8;
s.soot = 0.05;
s.clear = 3.0;
s.life = 1000.0;
});
}
// Forget what is gone.
if tick % 100 == 0 {
let alive: std::collections::HashSet<(u8, u32)> = sim
.drones
.iter()
.map(|d| (0, d.id))
.chain(sim.missiles.iter().map(|m| (1, m.id)))
.collect();
self.last.retain(|k, _| alive.contains(k));
}
self.trails.retain(|t| tick < t.seg.born as u64 + t.seg.life as u64);
if self.trails.len() > MAX_TRAIL {
let cut = self.trails.len() - MAX_TRAIL;
self.trails.drain(..cut);
}
}
/// Every segment alive at the simulation's tick: trails, fire columns, blast smoke.
pub fn segments(&self, sim: &Sim) -> Vec<SmokeSegment> {
let tick = sim.tick;
let mut out: Vec<SmokeSegment> = self.trails.iter().map(|t| t.seg.clone()).collect();
// A column over each fire: a puff every 15 ticks that rises, spreads and thins.
for f in &sim.fires.list {
if f.quiet && f.size < 1.0 {
continue;
}
// Only the strongest fires get a full column; the rest a thin puff now and then.
let step = if f.shown { 15 } else { 90 };
let first = f.start.max(tick.saturating_sub(520));
let mut k = first.div_ceil(step) * step;
while k <= tick && out.len() < MAX_TOTAL {
let (flame, smoke) = f.level(k);
if smoke > 0.05 && k >= f.start {
let id = 0x4000_0000 | (f.id.wrapping_mul(131) ^ (k / step) as u32) & 0x3fff_ffff;
let at = f.at + DVec3::Y * 0.6;
let mut s = seg(id, at, at, k, 540);
s.radius_start = 1.0 * f.size;
s.radius_end = 8.0 * f.size + 2.0;
s.density = 2.6 * smoke * f.size.sqrt() * if f.shown { 1.0 } else { 0.5 };
s.heat = flame * 0.6;
s.soot = 0.85;
s.rise = 2.6;
out.push(s);
}
k += step;
}
}
// Blasts: a billow of puffs going up.
for b in &sim.blasts {
let age = tick.saturating_sub(b.start);
if age > 900 {
continue;
}
for j in 0..6u64 {
let born = b.start + j * 3;
if born > tick {
break;
}
let h = b.size as f64 * (0.2 + 0.5 * j as f64);
let at = b.at + DVec3::Y * h;
let mut s = seg(0x5000_0000 | (b.id.wrapping_mul(7) + j as u32) & 0x0fff_ffff, at, at, born, 800);
s.radius_start = b.size * 0.6;
s.radius_end = b.size * 3.2 + 4.0;
s.density = 2.4 / (1.0 + j as f32 * 0.25);
s.heat = if j < 3 { 1.0 } else { 0.4 };
s.soot = 0.55;
s.rise = 4.0 - 0.3 * j as f32;
out.push(s);
}
}
out.truncate(MAX_TOTAL);
out
}
}
+84 -9
View File
@@ -19,12 +19,17 @@ 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` |
| Материалы (классы спрайтов: листва, растения, металл, полированное), ветер, мокрые поверхности | `gfx/hd/materials.rs`; `terrain.wgsl` (`terrain_vertex`/`wind_offset`, `material_params`), `entity.wgsl`, `gfx/hd/light.wgsl` (`shade_surface`) |
| Пост-проход мода (взрывы, дым, вспышки, свечение) и его прелюдия | `airdef-mod/shaders/effects.wgsl`, прелюдия в `horizon/.../gfx/mod_post.rs` |
| Источники света мода (пламя, ракеты, взрывы, прожекторы) | `airdef-mod/src/lib.rs`, `fn lights` |
| API мода: `render.set-lights`, `render.set-lighting` (уровень API 10) | `horizon/crates/horizon-mod-sdk/wit/horizon.wit`, `horizon/crates/horizon-mods/src/runtime.rs` |
@@ -113,6 +118,7 @@ Bind groups кэшируются и пересоздаются только пр
| 15 | текстура неба (`sky_lut`) |
| 16, 17 | объёмный туман (свет+пропускание) и длина его луча |
| 18, 19 | объёмные облака, карта теней облаков |
| 20 | длина луча каждого текселя облаков (`cloud_dist_tex`, для апсэмплинга по глубине) |
`HdFrame` (Rust и WGSL держать в синхроне!): `inverse_view_proj`, `resolution` (w, h, 1/w, 1/h), `params` (число
источников, экспозиция, bloom, счётчик кадров), `cascades[2]`, `sun` (направление, сила), `sun_color` (rgb, ночь),
@@ -120,6 +126,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 +157,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`):
@@ -155,6 +185,14 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс
текстурах и анимация воды), а при ошибке теней было 8.5%. Сравни с `HORIZON_HD_TAA=0` / `HORIZON_HD_AO=0` /
`HORIZON_HD_SHADOW_FAST=0`, чтобы найти виновника.
Графический полигон: `python3 tools/hd_bench.py` (`--list` — сцены; `--only polygon_noon` — одна сцена; `--env
HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home-bench/out/before` — diff ×6 и
пороги; `--budget-ms 18` — GPU-бюджет). Сам копирует `Polygon`/`Hills` в `run-home-bench/home`, сокет
`run-home-bench/c.sock`, одна игра за раз; отчёт `report.md` с таблицей, проходами GPU и кадрами. Время и погода
заморожены (`gamerule advance_time/advance_weather false`); вода и листва идут по часам стены, поэтому кадры сравниваются
по статичным пикселям: 3 кадра подряд, пиксели с размахом > 16/255 (вода, облака, листва) не участвуют в сравнении с базой
и в мерцании (маска `<сцена>_motion.png`); `--no-freeze` — живой замер производительности; `--help` — все пороги.
Отладка: временно выводи промежуточные величины цветом в `fs_light` (так нашли пустую карту теней и неверное
направление солнца). Помни: солнце в мире идёт по оси X (`(-sin a, cos a, 0)`), а `Globals.sun` неба — в своей
плоскости YZ.
@@ -180,10 +218,24 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс
| Объёмные облака (рэймарч, мультирассеяние Photon, тени на землю, TAA-история, ванильные скрыты) | готово | патч 24 |
| Объёмный туман (лучи через тени каскадов, высотная дымка, утренний туман, полразрешения) | готово | патч 25 |
| API моделей по хэндлам (#30, уровень API 11: `model-id`, `model-generation`, `set-models-by-id`) | готово | патч 17 |
| #41: пасмурность неба и сила солнца берутся из мирового уровня дождя (`Sky.rain`, ставит `apply_weather`), а не из наличия геометрии осадков | готово | в рабочем дереве форка |
| #38: TAA без апскейла ищет историю по матрице прошлого кадра **с джиттером** (`HdScene.prev_view_proj_jittered`); `fs_taau` по-прежнему берёт матрицу без джиттера | готово | в рабочем дереве форка |
| #35: ванильные облака (HD-облака выключены) красятся закатным цветом в собственном проходе (`hd::vanilla_cloud_tint` → цвет в `clouds::Input`), эвристика по глубине G-буфера убрана из `fs_light` | готово | в рабочем дереве форка |
| #37: воздух (ванильный туман, aerial, объёмный туман, лучи, ореолы) на воде считается до **поверхности** и кладётся один раз поверх готовой воды: `fs_light` пишет во вторую цель `air` (рассеяние + пропускание), `fs_water` применяет её после поглощения | готово | в рабочем дереве форка |
| #36: ванильная вода не рисует цвет в HD (`fs_translucent` при `Globals.hd_flags.x`, глубину оставляет); прозрачное перед водой (`d` ближе `wd`) не отключает HD-воду: вода считается на освещённом дне и добавляется к сцене с весом 0.7; преломление не берёт тексели с таким прозрачным (`textureGather` по альфе) | готово (проверены стекло; частицы и scale 0.67 не проверялись) | в рабочем дереве форка |
| #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) | готово | в рабочем дереве форка |
| Материалы и блики (план 5): G-буфер `levels.zw` = шероховатость, металличность; GGX-блик солнца с Френелем и отражение неба (`sky_along`/`atmosphere_sky`) в `shade_surface`; мокрые поверхности и лужи в дождь (`HdFrame.surface`); `HORIZON_HD_SPECULAR=0` — выкл. | готово | в рабочем дереве форка |
| Ветер (план 7): листва и растения качаются в вершинной стадии террейна (`Globals.hd_wind`, `hd_materials`), те же смещения в тенях; `HORIZON_HD_WIND=0` — выкл. | готово | в рабочем дереве форка |
| #67: облака марш идут до ближайшей поверхности (`fs_cloud` читает глубину мира, вторая цель — длина луча, история отбрасывается при другой длине); `fs_light` кладёт облака на все пиксели (`cloud_at`: 4 текселя с весом по близости длины луча), под туман и над поверхностью, для воды — в `air`; луч внутри слоя вдоль горизонта больше не пропускается | готово; проверено только пустой полигон (нет гор/техники в слое, снимки `scratchpad/c*.png`), бюджет GPU на Mac не мерился | в рабочем дереве форка |
| #68: шум облаков по Y — от низа слоя, а не от камеры; шкалы тумана 170→128, дыма 22→32, луж 0.45/1.3→0.5/1.25 делят период обёртки 65536/256; тест `noise_scales_divide_the_camera_wrap` | готово | в рабочем дереве форка |
| #70: `Game::sky_darken` — всегда реальное время мира (ИИ, спавн, блоки, статистика); `shown_sky_darken` (время реплея) — только тени сущностей и свет глаза; небо/луна/цвета остаются по показанному времени | готово (собрано, игровой проверки день/ночь реплея нет) | в рабочем дереве форка |
| Объёмный дым и следы ракет (#54, п. 8 плана): API уровня 14 `render.set-smoke`, сетка дыма 128×64×128 по 3 блока (`gfx/hd/smoke.rs`, `smoke_grid.wgsl`), марш в проходе тумана, «туннель» в тумане и облаках, `HORIZON_HD_SMOKE=0`; мод: `airdef-mod/src/smoke.rs` (следы дронов и ракет, колонны над пожарами, дым взрывов) | готово, в рабочем дереве форка; вид не настраивался (см. «Дым и следы») | не закоммичено |
## План (по порядку) и ТЗ
Каждый пункт — самостоятельная задача. Перед началом прочитай `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-проход восстанавливает полное разрешение: история в полном разрешении, текущий кадр семплируется с учётом
@@ -198,10 +250,10 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс
текстуре), солнце/луна дисками, звёзды из ванили; туман и лучи берут цвет из той же модели. Учесть дождь
(`weather`), закат, Незер/Энд (там — ванильное небо).
4. **Объёмные облака** — СДЕЛАНО (патч 24, см. «Закат, облака и туман»). Осталось: перистые/высококучевые слои Photon, облака в отражении воды.
5. **Блики и материалы**: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из
5. **Блики и материалы** — СДЕЛАНО (см. «Материалы, мокрое, ветер»). Исходное ТЗ: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из
мода), мокрые поверхности и лужи в дождь.
6. **PCSS** (мягкость тени по расстоянию до тенеотбрасывателя) и цветные тени через стекло/листву.
7. **Ветер** листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя.
7. **Ветер** — СДЕЛАНО (рябь от дождя на воде осталась). Исходное ТЗ: листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя.
8. **Объёмный дым** пожаров и взрывов, принимающий свет и тени (сейчас дым — в `effects.wgsl`, без теней).
9. Опционально: motion blur, DoF (для кинокамеры повторов).
10. **RT-пресет**: `wgpu` `EXPERIMENTAL_RAY_QUERY` (Vulkan/DX12/Metal M3+), BLAS по секциям, ray-query тени и
@@ -255,5 +307,28 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс
- **Облака (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 (преемнику)
- **Цели света.** `fs_light` пишет две цели: `lit` и `air` (`Rgba16Float`). Альфа `lit`: 1 — обычный пиксель, 0.5 — вода (в `lit` дно без воздуха, в `air` рассеяние воздуха до поверхности и пропускание в альфе), 0.6 — прозрачное перед водой (в `lit` сцена как нарисована, в `air.rgb` освещённое дно). Привязка 11 композита воды — `air`.
- **Ванильная вода в HD.** Пока `hd.water_active()`, `fs_translucent` не рисует цвет воды (альфа 0), но пишет её глубину; флаг — `Globals.hd_flags.x` (поле добавлено в Rust-`Globals`, `terrain.wgsl`, `entity.wgsl`; пишется после `hd.write` и в `hd.main_globals`). Вода без дна (`g = 0`) считается водой толщиной 48.
- **Не сделано.** Полупрозрачное перед водой берёт вес 0.7 на «вклад воды» (приближение: настоящий порядок «вода → прозрачное» потребовал бы разделить проход сцены). Частицы перед водой, `HORIZON_HD_SCALE=0.67`, поиск мерцания на рябь после этих правок не прогонялись.
- **`HORIZON_HD_TAA=0`.** На Polygon в полдень (`tp @s 40 76 40 135 15`) HD с выключенным TAA выглядит так же (дымка, небо, тени на месте), GPU 16.5 мс против 18.1 мс с TAA (минус ≈ проход TAA); потери вида и падения до 4.8 мс не воспроизвелись. В коде `HORIZON_HD_TAA` читают только `taa_enabled()` (джиттер, проход `taa`, история), остальные проходы от него не зависят. Если проявится снова — проверь, что до шота прошёл `/airdef shader hd` (при неготовой команде кадр без HD ~5 мс).
## Материалы, мокрое, ветер: что знать преемнику
- **Класс спрайта.** `gfx/hd/materials.rs` по имени спрайта (`minecraft:block/<имя>`) даёт класс 0..8 (обычный, листва, растение, нижняя/верхняя половина двойного растения, металл, гладкое, глянец, дерево). Таблица — по 4 бита на слой, первые 2048 слоёв (`Globals.hd_materials`, 64 × `vec4<u32>`, считается в `game.rs` и кладётся в `Renderer`); спрайты модов дальше 2048 — обычные. Числа `MAT_*` в `terrain.wgsl` держать в синхроне. Добавить блок в класс — список имён в `classify`.
- **G-буфер.** `levels.zw` (было не занято) = шероховатость и металличность (`material_params` в `terrain.wgsl`; для сущностей — по оверлею в `fs_entity_gbuffer`: обычные 0.9/0; модели мода `41` — крашеный металл 0.42/0.3 (по умолчанию для всех моделей мода, `game/mod_models.rs`), `42` — голый металл 0.28/1 (мод пока не умеет выбирать), `40` — свечение 0.5/0). Вода своим проходом, без материала.
- **Блик.** В `shade_surface` (`light.wgsl`): GGX (α = rough², не ниже 0.03) × Смит × Френель Шлика, у шероховатых Френель почти не растёт (`max(gloss, F0)`); цвет — солнце × тень × `sky_reach`; потолок 6. Отражение неба: `atmosphere_sky(reflect)` × Френель × gloss² × доступ к небесному свету × AO, без SSR на террейне. Металл сохраняет 35% диффузного (карты окружения нет). Дно под водой блеск не получает.
- **Мокро.** `HdFrame.surface.x` — влажность 0..1 (CPU, `Hd::wetness`): растёт за ~15 с дождя к уровню дождя мира, сохнет ~150 с. Под открытым небом (`lm.x` > 0.8) поверхности темнеют до ×0.7 (боковые грани слабее) и их шероховатость падает к 0.12; лужи — на плоских гранях вверх, по 2-октавному value-шуму в мировых координатах (`wet_noise`, период 256 блоков), с влажности ~0.3, альбедо ×0.6 и шероховатость 0.03, отражают небо.
- **Ветер.** `terrain_vertex` смещает вершины листвы (±0.04 блока, непрерывно по мировой позиции, чтобы блоки не расходились) и растений (до 0.14 блока вверху спрайта; низ — по `uv.y`; двойные растения стыкуются на середине). Волны — целые циклы на 256 блоков и 600 секунд (как у воды). Данные ветра — в `Globals.hd_wind` (сила 1 + 1.2·дождь, секунды, камера mod 256); они же в глобалах каскадов теней, поэтому смещение одно и то же во всех проходах (ванильный, G-буфер, тени, глубина совпадают). Вне HD сила 0 — вершинный шейдер не меняется. TAA не знает о движении листвы (малые амплитуды).
- **Переключатели.** `HORIZON_HD_WIND=0`, `HORIZON_HD_SPECULAR=0` (блик, отражения и мокрое).
## Проверка материалов/мокрого/ветра (что видно и что нет)
- Увидено в игре (Hills, `run-home-bench/out/mat1`, `out/rain`): ветер работает, листва и трава двигаются, артефактов швов нет; в дождь сцена заметно темнее (мокрое), небо серое. Не проверено глазами: блики на технике (в Polygon сцена бенча сейчас без техники, не сопоставима с базой `before-mat`), отдельные лужи (кадр дождя смотрит в небо), высыхание (`out/rain/drying_30s.png` снят, не разобран), ночь.
- Мерцание: `hills_river_noon` 9% без ветра (`out/nowind`) против 31% с ветром — это движение листвы вблизи камеры, не шум; амплитуда листвы снижена с 0.04 до 0.025 после этого замера (повторно не мерилось).
- Тайминги **не сняты**: параллельно шли игры других агентов (числа 25–155 мс недействительны). Перед замером брать `run-home/.gpu-lock`.
- Известно: `shaders_translate_to_hlsl_for_directx_12` падает на `cloud.wgsl` (не мой файл).
@@ -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/24] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 01/29] 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/24] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 02/29] 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/24] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 03/29] 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/24] Mod models: draw far models as scaled impostors past
Subject: [PATCH 04/29] 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/24] Mod cameras: render.set-views draws the world from
Subject: [PATCH 05/29] 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/24] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 06/29] 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/24] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 07/29] 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/24] Mod cameras: distant terrain tiles, fog measured from
Subject: [PATCH 08/29] 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/24] Mods: game.world-id, so mods keep state per world (API
Subject: [PATCH 09/29] 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/24] cargo-horizon-mod: trial runs behind feature full, so a
Subject: [PATCH 10/29] 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/24] HD lighting: G-buffer pass, per-pixel mod point lights
Subject: [PATCH 11/29] 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/24] HD lighting stage 2: sun and moon shadow cascades, the
Subject: [PATCH 12/29] 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/24] HD: bind groups cached until a resize or a new source,
Subject: [PATCH 13/29] 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/24] HD: lights culled per 16x16 tile (surface and halo
Subject: [PATCH 14/29] 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/24] HD: GPU time per pass in the F3 timing list,
Subject: [PATCH 15/29] 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/24] HD: shadows draw only sun-facing quads and skip buried
Subject: [PATCH 16/29] 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/24] Mods: model handles (render.model-id, model-generation,
Subject: [PATCH 17/29] 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/24] HD: temporal upscaling (HORIZON_HD_SCALE: world,
Subject: [PATCH 18/29] 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/24] HD water: its own pass (colour, normal, depth), the
Subject: [PATCH 19/29] 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/24] HD water: no vanilla texture on the surface (the biome
Subject: [PATCH 20/29] 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/24] HD sky and light: a physical atmosphere (Rayleigh, Mie,
Subject: [PATCH 21/29] 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/24] HD water: a darker blue, a little clearer, ripples a
Subject: [PATCH 22/29] 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/24] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
Subject: [PATCH 23/29] 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/24] Mods: world queries for simulations (render.query-rays,
Subject: [PATCH 24/29] 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
@@ -0,0 +1,992 @@
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/29] HD fixes: rain level from the world not from rain
geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint
without a depth heuristic (#35), air fog once over water (#37), translucent
things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick
air capped; mods: render.set-time-override (API level 13) so replays show
their recorded time of day (#57)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/entity.wgsl | 2 +
crates/horizon-client/src/environment.rs | 5 +
crates/horizon-client/src/game.rs | 18 +-
crates/horizon-client/src/gfx.rs | 52 ++-
crates/horizon-client/src/gfx/hd.rs | 411 ++++++++++++++++-------
crates/horizon-client/src/gfx/hd_sky.rs | 8 +
crates/horizon-client/src/terrain.wgsl | 8 +
crates/horizon-mod-sdk/wit/horizon.wit | 5 +
crates/horizon-mods/src/api_level.rs | 6 +-
crates/horizon-mods/src/runtime.rs | 12 +
docs/api/mods-reference.md | 1 +
11 files changed, 387 insertions(+), 141 deletions(-)
diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl
index 61bc1f92a..b3fc9bc82 100644
--- a/crates/horizon-client/src/entity.wgsl
+++ b/crates/horizon-client/src/entity.wgsl
@@ -20,6 +20,8 @@ struct Globals {
crumbling_sprite: vec4<f32>,
// Selection outline parameters (read by terrain.wgsl).
outline_params: vec4<f32>,
+ // AirDef fork. x: the HD water draws the water (fs_translucent leaves vanilla's out).
+ hd_flags: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
}
diff --git a/crates/horizon-client/src/environment.rs b/crates/horizon-client/src/environment.rs
index 20855b749..9b8acf13f 100644
--- a/crates/horizon-client/src/environment.rs
+++ b/crates/horizon-client/src/environment.rs
@@ -141,6 +141,9 @@ pub struct Sky {
pub fog_end: f32,
/// `visual/cloud_fog_end_distance`.
pub cloud_fog_end: f32,
+ /// AirDef fork: the world's rain level (0..1), set by `apply_weather`. The HD sky reads this
+ /// rather than the presence of rain geometry near the camera.
+ pub rain: f32,
}
impl Environment {
pub fn load(root: &Path) -> Result<Self> {
@@ -305,6 +308,7 @@ impl Environment {
.base
.get("minecraft:visual/fog_end_distance")
.map_or(1024.0, |v| v[0]),
+ rain: 0.0,
cloud_fog_end: self
.base
.get("minecraft:visual/cloud_fog_end_distance")
@@ -315,6 +319,7 @@ impl Environment {
/// WeatherAttributes: rain then thunder layers lerped by their levels
/// (rain counts only the part not covered by thunder).
pub fn apply_weather(sky: &mut Sky, rain: f32, thunder: f32) {
+ sky.rain = rain.clamp(0.0, 1.0);
let grey = |c: [f32; 3]| c[0] * 0.3 + c[1] * 0.59 + c[2] * 0.11;
let lerp3 = |t: f32, a: [f32; 3], b: [f32; 3]| -> [f32; 3] {
std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t)
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index 366be39c4..fad8de187 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -8370,6 +8370,14 @@ impl Game<'_> {
.collect()
}
+ /// The time of day the player sees: a mod's override (a replay's recorded time), else the world's.
+ fn shown_day_time(&self) -> i64 {
+ self.mods
+ .mods
+ .time_override()
+ .map_or(self.world.day_time, |t| t as i64)
+ }
+
fn sky_darken(&self) -> i32 {
if self.dim == Dim::Nether {
return 11;
@@ -8378,12 +8386,12 @@ impl Game<'_> {
let sky = self
.res
.end_environment()
- .sample(self.world.day_time, None, None);
+ .sample(self.shown_day_time(), None, None);
return (15.0 - 15.0 * sky.sky_light_level).round() as i32;
}
let (rain, thunder) = self.world.weather.levels(1.0);
// Sampled once per (time, weather): every mob asks each tick.
- let key = (self.world.day_time, rain.to_bits(), thunder.to_bits());
+ let key = (self.shown_day_time(), rain.to_bits(), thunder.to_bits());
if let Some((k, darken)) = self.sky_darken_memo.get()
&& k == key
{
@@ -8392,7 +8400,7 @@ impl Game<'_> {
let mut sky = self
.res
.environment()
- .sample(self.world.day_time, None, None);
+ .sample(self.shown_day_time(), None, None);
horizon_client::environment::apply_weather(&mut sky, rain, thunder);
// Level.updateSkyBrightness: darkening from the sky light level.
let darken = (15.0 - 15.0 * sky.sky_light_level).round() as i32;
@@ -8898,7 +8906,7 @@ impl Game<'_> {
"first_quarter",
"waxing_gibbous",
];
- let phase = phases[(self.world.day_time / 24000).rem_euclid(8) as usize];
+ let phase = phases[(self.shown_day_time() / 24000).rem_euclid(8) as usize];
body(
&format!("environment/celestial/moon/{phase}"),
sky.moon_angle,
@@ -11608,7 +11616,7 @@ impl Game<'_> {
);
// visual/water_fog_color: no dimension sets it, its default is #050533.
let water_fog = blend(|b| Some(b.water_fog), [0.0; 3]);
- let mut sky = environment.sample(self.world.day_time, Some(biome_sky), Some(biome_fog));
+ let mut sky = environment.sample(self.shown_day_time(), Some(biome_sky), Some(biome_fog));
let (rain, thunder) = if self.dim == Dim::Overworld {
self.world.weather.levels(alpha as f32)
} else {
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index 072090e33..a8031c1f6 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -194,6 +194,9 @@ struct Globals {
crumbling_sprite: [f32; 4],
/// Framebuffer size, selection stroke width, perspective layering scale.
outline_params: [f32; 4],
+ /// AirDef fork. x: 1 while the HD water draws the water (fs_translucent then leaves vanilla's
+ /// water out of the colour and keeps its depth).
+ hd_flags: [f32; 4],
/// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`).
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
}
@@ -578,7 +581,7 @@ pub struct Renderer {
hd_far: Option<(Mat4, glam::DVec3, Vec3, u32)>,
/// HD's TAA: frames jittered so far, and the last one's unjittered view-projection and camera.
taa_frame: u32,
- taa_prev: Option<(Mat4, glam::DVec3)>,
+ taa_prev: Option<(Mat4, glam::DVec3, Mat4)>,
hd: Option<hd::Hd>,
mod_params: (Vec<f32>, f32),
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
@@ -1207,17 +1210,15 @@ impl Renderer {
if sprite_arrays > 1 {
log::info!("Horizon: {layers} block textures in {sprite_arrays} arrays");
}
+ // AirDef fork, HD water: the sprite layers terrain.wgsl's fs_water takes for water (and
+ // fs_translucent clears while the HD water draws it instead).
let sprite_constants = [
("SPRITE_ARRAYS", f64::from(sprite_arrays)),
("SPRITE_SPLIT", f64::from(split)),
- ];
- // AirDef fork, HD water: the sprite layers terrain.wgsl's fs_water takes for water.
- let water_constants = [
- ("SPRITE_ARRAYS", f64::from(sprite_arrays)),
- ("SPRITE_SPLIT", f64::from(split)),
("WATER_STILL", f64::from(water_layers[0])),
("WATER_FLOW", f64::from(water_layers[1])),
];
+ let water_constants = sprite_constants;
let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon terrain bindings"),
entries: &[
@@ -2823,7 +2824,7 @@ impl Renderer {
mip_levels,
mipmaps: mip_levels - 1,
terrain_layout: bindings,
- terrain_sprites: sprite_constants.map(|(_, v)| v),
+ terrain_sprites: [f64::from(sprite_arrays), f64::from(split)],
sprite_sampler,
lightmap_view,
lightmap_sampler,
@@ -4049,7 +4050,7 @@ impl Renderer {
};
let taa_prev = std::mem::replace(
&mut self.taa_prev,
- taa.then_some((unjittered, frame.camera)),
+ taa.then_some((unjittered, frame.camera, view_proj)),
);
if frame.title {
self.panorama.prepare(
@@ -4135,6 +4136,7 @@ impl Renderer {
frame.glint_ticks,
],
outline_params: outline_params(self.config.width, self.config.height),
+ hd_flags: [0.0; 4],
dynamic_lights: {
let mut lights = [[0.0; 4]; crate::dynamic_light::MAX];
for (to, from) in lights.iter_mut().zip(frame.dynamic_lights) {
@@ -4260,11 +4262,14 @@ impl Renderer {
taa: taa && taa_prev.is_some(),
jitter_px,
prev_view_proj: taa_prev.map_or(unjittered, |p| p.0),
+ prev_view_proj_jittered: taa_prev.map_or(view_proj, |p| p.2),
camera_delta: taa_prev.map_or(Vec3::ZERO, |p| (frame.camera - p.1).as_vec3()),
// The world's sun: vanilla turns the sky -90° about Y, so it crosses from east to west.
sun: Vec3::new(-sun_angle.sin(), sun_angle.cos(), 0.0),
dark: 1.0 - frame.sky.sky_light_factor,
- rain: if frame.weather.is_empty() { 0.0 } else { 0.7 },
+ // The world's rain level (fades in and out with the weather), not the rain geometry
+ // near the camera: hiding the particles or standing under a roof leaves the sky alone.
+ rain: frame.sky.rain.clamp(0.0, 1.0) * 0.7,
fog_color,
fog,
sky: frame.skybox && !frame.end_sky && !frame.nether_lighting && !frame.underwater,
@@ -4827,7 +4832,20 @@ impl Renderer {
+ shadow_quads
+ translucent_particle_quads
+ translucent_item_quads;
- let cloud_quads = if let Some(input) = &frame.clouds {
+ // AirDef fork: vanilla's clouds (HD's volumetric ones off) take the low sun's colour in their
+ // own colour, so a mountain or glass behind them does not change it.
+ let cloud_input = frame.clouds.as_ref().map(|c| {
+ if !hd_wanted || !hd_scene.sky || hd::clouds_enabled() {
+ return crate::clouds::Input { ..*c };
+ }
+ let [r, g, b, a] = c.color;
+ let [tr, tg, tb] = hd::vanilla_cloud_tint(&hd_scene);
+ crate::clouds::Input {
+ color: [r * tr, g * tg, b * tb, a],
+ ..*c
+ }
+ });
+ let cloud_quads = if let Some(input) = &cloud_input {
let (rebuilt, uniform) =
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
let vertices = self.cloud_mesh.vertices.len() as u64;
@@ -5154,6 +5172,15 @@ impl Renderer {
if let Some(settings) = frame.hd {
hd.write(&self.queue, &hd_scene, frame.hd_lights, settings);
}
+ // vanilla's translucent water leaves the colour while the HD water draws it.
+ let hd_flags_at = std::mem::offset_of!(Globals, hd_flags) as u64;
+ let hd_flags: [f32; 4] = [if hd.water_active() { 1.0 } else { 0.0 }, 0.0, 0.0, 0.0];
+ self.uploads.buffer(
+ &mut encoder,
+ &self.globals,
+ hd_flags_at,
+ bytemuck::bytes_of(&hd_flags),
+ );
hd.set_timing(device, &self.queue, self.pass_timing);
hd.timing_begin(device, &self.queue);
// The sun's (or moon's) shadow cascades: the world's globals swapped for the light's.
@@ -5184,6 +5211,11 @@ impl Renderer {
}
self.queue
.write_buffer(&hd.main_globals, 0, bytemuck::bytes_of(&globals));
+ self.queue.write_buffer(
+ &hd.main_globals,
+ hd_flags_at,
+ bytemuck::bytes_of(&hd_flags),
+ );
// Facing the light only: the nearest surface to it is always one that does.
let toward = (hd_shadow_fast() && self.sides_on).then(|| shadow_dir * 1.0e5);
for (c, cascade) in hd_cascades.iter().enumerate() {
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
index 3b9daabf4..e84a618a9 100644
--- a/crates/horizon-client/src/gfx/hd.rs
+++ b/crates/horizon-client/src/gfx/hd.rs
@@ -133,6 +133,10 @@ pub struct HdScene {
pub view_proj: Mat4,
/// Last frame's view-projection without jitter, and the camera's move since (TAA).
pub prev_view_proj: Mat4,
+ /// The same frame's matrix as it was drawn, jitter included. History kept on the jittered
+ /// pixel grid (`fs_taa`, scale 1) is looked up with this; `fs_taau` resolves the history on the
+ /// unjittered output grid and uses `prev_view_proj`.
+ pub prev_view_proj_jittered: Mat4,
pub camera_delta: glam::Vec3,
/// This frame is anti-aliased over time (the projection is jittered).
pub taa: bool,
@@ -403,31 +407,104 @@ fn fog_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
return sum / total;
}
+struct LightOut {
+ // The lit colour; alpha 0.5 marks water (the floor under it, lit without any air), 0.6 an
+ // object in front of the water (the scene as drawn).
+ @location(0) lit: vec4<f32>,
+ // Water: the air between the camera and the surface (scattered light, transmittance);
+ // in front of the water: the floor lit (rgb); else nothing.
+ @location(1) air: vec4<f32>,
+}
+
+// An opaque surface lit: the ambient, the sun and the mods' lights over its albedo.
+fn shade_surface(px: vec2<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
+ let sun = frame.sun.xyz;
+ let sun_on = frame.sun.w;
+ let atmosphere = frame.sky_sun.w > 0.5;
+ let count = u32(frame.params.x);
+ let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u;
+ let surface_start = tiles[tile];
+ let surface_all = tiles[tile + 1u] == 0xffffffffu;
+ let surface_count = select(tiles[tile + 1u], count, surface_all);
+ let n = normalize(textureLoad(normal_tex, px, 0).xyz);
+ let albedo = textureLoad(albedo_tex, px, 0);
+ let lm = textureLoad(light_tex, px, 0).xy;
+ // Vanilla's light (sky and blocks through the lightmap), shaded where the sun can't reach.
+ let base = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(lm.y, lm.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
+ let block = textureSampleLevel(lightmap, linear, vec2<f32>(lm.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb;
+ let sky_reach = smoothstep(0.35, 0.95, lm.x);
+ var s = 1.0;
+ let ndl = dot(n, sun);
+ if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow_taps(p, n, select(1, 0, water)) * cloud_shadow(p); } else { s = 0.0; }
+ // Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds
+ // direct light by the face's angle.
+ let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
+ // Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on
+ // the mods' lights by half, not on the sun or on emission.
+ var occlusion = 1.0;
+ // (The water dims and blurs the floor under it: no occlusion there.)
+ if frame.ambient.w > 0.0 && !water { occlusion = ao_at(px, length(p)); }
+ let ao_ambient = mix(1.0, occlusion, frame.ambient.w);
+ let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5);
+ // The sky's light is bluish from above and carries the horizon's colour from the sides;
+ // faces turned up take more of it than the ones turned down.
+ var sky_tint = vec3<f32>(1.0);
+ if atmosphere {
+ let reach = smoothstep(0.25, 0.9, lm.x);
+ let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
+ let shape = mix(0.66, 1.1, smoothstep(0.1, 1.0, up));
+ let hue = mix(vec3<f32>(1.0), sky_light_tint(n), 0.4 * (1.0 - 0.5 * frame.sun_color.a));
+ sky_tint = mix(vec3<f32>(1.0), hue * shape, reach);
+ }
+ var light = max(base * sky_tint * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct;
+ // The mods' lights.
+ for (var k = 0u; k < surface_count; k++) {
+ let l = lights[select(tiles[surface_start + k], k, surface_all)];
+ let to = l.pos.xyz - p;
+ let dist = length(to);
+ if dist >= l.pos.w { continue; }
+ let window = pow(clamp(1.0 - pow(dist / l.pos.w, 4.0), 0.0, 1.0), 2.0);
+ let falloff = l.color.w / (dist * dist + 1.0) * window;
+ let k = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0);
+ light += l.color.rgb * falloff * k * ao_lights;
+ }
+ return albedo.rgb * light + albedo.rgb * albedo.a * 1.5;
+}
+
@fragment
-fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
+fn fs_light(in: FullOut) -> LightOut {
let px = vec2<i32>(in.position.xy);
let scene = textureLoad(scene_color, px, 0).rgb;
let d = textureLoad(scene_depth, px, 0);
let g = textureLoad(gbuffer_depth, px, 0);
- // Water in front of the opaque world with nothing translucent over it: here the floor is lit
- // and the water composite lays the surface over it (it knows the pixel by the alpha 0.5).
- var water = false;
- if frame.water.x > 0.5 && g > 0.0 {
- let wd = textureLoad(water_depth, px, 0);
- water = wd > 0.0 && wd >= g && d - wd <= d * 0.002;
- }
- let dd = select(d, g, water);
+ // Water over the opaque world (or over nothing): the water composite lays the surface over
+ // the floor, which is lit here with no air. Something translucent in front of the water
+ // (`front`: nearer than its surface) keeps the scene as drawn; the floor goes beside it.
+ var wd = 0.0;
+ var water_here = false;
+ if frame.water.x > 0.5 {
+ wd = textureLoad(water_depth, px, 0);
+ water_here = wd > 0.0 && (g <= 0.0 || wd >= g);
+ }
+ let front = water_here && g > 0.0 && d - wd > d * 0.002;
+ let water = water_here && !front;
+ let has_floor = water && g > 0.0;
+ let dd = select(d, g, has_floor);
let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(dd, 0.000001), 1.0);
let h = frame.inverse_view_proj * ndc;
let p = h.xyz / h.w;
let view = normalize(p);
+ // Where the air ends: the water's surface over water, else the pixel itself.
+ var ps = p;
+ if water {
+ let hf = frame.inverse_view_proj * vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(wd, 0.000001), 1.0);
+ ps = hf.xyz / hf.w;
+ }
+ let view_s = normalize(ps);
let sun = frame.sun.xyz;
let sun_on = frame.sun.w;
let count = u32(frame.params.x);
let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u;
- let surface_start = tiles[tile];
- let surface_all = tiles[tile + 1u] == 0xffffffffu;
- let surface_count = select(tiles[tile + 1u], count, surface_all);
let halo_start = tiles[tile + 2u];
let halo_all = tiles[tile + 3u] == 0xffffffffu;
let halo_count = select(tiles[tile + 3u], count, halo_all);
@@ -455,113 +532,81 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
c = c * cl.a + cl.rgb;
}
}
- // Clouds (vanilla's, drawn into the world with depth but not into the G-buffer): lit by the low
- // sun's colour at dusk and dawn instead of vanilla's flat white.
- if atmosphere && frame.clouds.z < 0.5 && !water && d > 0.0 && g <= 0.0 && p.y > 8.0 && dot(p, p) > 2304.0 {
- let low = 1.0 - smoothstep(0.05, 0.5, frame.sky_sun.y);
- c = scene * mix(vec3<f32>(1.0), mix(frame.sun_color.rgb, vec3<f32>(1.0), 0.45) * 1.1, low * 0.7 * (1.0 - frame.sky_extra.z));
- }
- let surface = water || (d > 0.0 && abs(d - g) <= max(d, g) * 0.002);
+ // (Vanilla's clouds, when the volumetric ones are off, take the low sun's colour in their own
+ // pass: `vanilla_cloud_tint`, applied to their colour on the CPU.)
+ let surface = has_floor || (!water_here && d > 0.0 && abs(d - g) <= max(d, g) * 0.002);
if surface {
- let n = normalize(textureLoad(normal_tex, px, 0).xyz);
- let albedo = textureLoad(albedo_tex, px, 0);
- let lm = textureLoad(light_tex, px, 0).xy;
- // Vanilla's light (sky and blocks through the lightmap), shaded where the sun can't reach.
- let base = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(lm.y, lm.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
- let block = textureSampleLevel(lightmap, linear, vec2<f32>(lm.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb;
- let sky_reach = smoothstep(0.35, 0.95, lm.x);
- var s = 1.0;
- let ndl = dot(n, sun);
- if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow_taps(p, n, select(1, 0, water)) * cloud_shadow(p); } else { s = 0.0; }
- // Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds
- // direct light by the face's angle.
- let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
- // Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on
- // the mods' lights by half, not on the sun or on emission.
- var occlusion = 1.0;
- // (The water dims and blurs the floor under it: no occlusion there.)
- if frame.ambient.w > 0.0 && !water { occlusion = ao_at(px, length(p)); }
- let ao_ambient = mix(1.0, occlusion, frame.ambient.w);
- let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5);
- // The sky's light is bluish from above and carries the horizon's colour from the sides;
- // faces turned up take more of it than the ones turned down.
- var sky_tint = vec3<f32>(1.0);
- if atmosphere {
- let reach = smoothstep(0.25, 0.9, lm.x);
- let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
- let shape = mix(0.66, 1.1, smoothstep(0.1, 1.0, up));
- let hue = mix(vec3<f32>(1.0), sky_light_tint(n), 0.4 * (1.0 - 0.5 * frame.sun_color.a));
- sky_tint = mix(vec3<f32>(1.0), hue * shape, reach);
- }
- var light = max(base * sky_tint * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct;
- // The mods' lights.
- for (var k = 0u; k < surface_count; k++) {
- let l = lights[select(tiles[surface_start + k], k, surface_all)];
- let to = l.pos.xyz - p;
- let dist = length(to);
- if dist >= l.pos.w { continue; }
- let window = pow(clamp(1.0 - pow(dist / l.pos.w, 4.0), 0.0, 1.0), 2.0);
- let falloff = l.color.w / (dist * dist + 1.0) * window;
- let k = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0);
- light += l.color.rgb * falloff * k * ao_lights;
- }
- c = albedo.rgb * light + albedo.rgb * albedo.a * 1.5;
- // Vanilla's fog over it again.
- let sph = length(p);
- let cyl = max(length(p.xz), abs(p.y));
- let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w));
- let haze = haze_colour(view);
- c = mix(c, haze, f * frame.fog_color.a);
- // Aerial perspective: far things take the horizon's colour a little before the fog does.
- if atmosphere && frame.fog.x > 1.0e6 {
- c = mix(c, haze, (1.0 - exp(-sph / 700.0)) * 0.5);
+ c = shade_surface(px, p, water);
+ // Vanilla's fog over it again (the water composite does it at the surface).
+ if !water {
+ let sph = length(p);
+ let cyl = max(length(p.xz), abs(p.y));
+ let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w));
+ let haze = haze_colour(view);
+ c = mix(c, haze, f * frame.fog_color.a);
+ // Aerial perspective: far things take the horizon's colour a little before the fog does.
+ if atmosphere && frame.fog.x > 1.0e6 {
+ c = mix(c, haze, (1.0 - exp(-sph / 700.0)) * 0.5);
+ }
}
}
- // The volumetric fog: the light the air scatters towards the camera (shafts through the trees,
- // mist, haze), and how much of the pixel it hides.
- let far = select(min(length(p), frame.shadow.z), frame.shadow.z, d <= 0.0);
+ // What lies behind a translucent thing in front of the water: the floor, lit.
+ var floor_lit = vec3<f32>(0.0);
+ if front {
+ let hg = frame.inverse_view_proj * vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(g, 0.000001), 1.0);
+ floor_lit = shade_surface(px, hg.xyz / hg.w, true);
+ }
+ // The air between the camera and the pixel (the water's surface over water): the volumetric
+ // fog (what it hides, what it scatters), the sun's rays between the shadows, and the halos
+ // round the mods' lights. Over water they go to the composite, which lays them over the
+ // finished water; elsewhere over the pixel here.
+ var air_t = 1.0;
+ var air_s = vec3<f32>(0.0);
+ let far = select(min(length(ps), frame.shadow.z), frame.shadow.z, d <= 0.0);
let density = frame.ambient.z;
let volumetric = frame.vfog.x > 0.5 && atmosphere;
if volumetric {
var fog_len = 192.0;
- if d > 0.0 {
- var pf = p;
- if water {
- let hf = frame.inverse_view_proj * vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(d, 0.000001), 1.0);
- pf = hf.xyz / hf.w;
- }
- fog_len = min(length(pf), 192.0);
- }
+ if d > 0.0 { fog_len = min(length(ps), 192.0); }
let fg = fog_at(px, fog_len);
- c = c * fg.a + fg.rgb;
+ air_t = fg.a;
+ air_s = fg.rgb;
}
- // Light in the air (without the volumetric fog): the sun's rays between the shadows; always the
- // halos round the mods' lights.
if !volumetric && sun_on > 0.0 && density > 0.0 {
let steps = 14;
let jitter = ign(in.position.xy);
var acc = 0.0;
for (var i = 0; i < steps; i++) {
let t = (f32(i) + jitter) / f32(steps) * far;
- acc += lit_at(view * t);
+ acc += lit_at(view_s * t);
}
// Henyey-Greenstein, forward scattering: brightest looking towards the sun.
- let cos_t = dot(view, sun);
+ let cos_t = dot(view_s, sun);
let gk = 0.6;
let phase = (1.0 - gk * gk) / pow(1.0 + gk * gk - 2.0 * gk * cos_t, 1.5) * 0.08;
- c += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density));
+ air_s += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density));
}
for (var k = 0u; k < halo_count; k++) {
let l = lights[select(tiles[halo_start + k], k, halo_all)];
// Nearest point of the view ray to the light, and the glow along the ray in front of it.
- let t0 = dot(l.pos.xyz, view);
- let hh = length(l.pos.xyz - view * t0) + 0.5;
+ let t0 = dot(l.pos.xyz, view_s);
+ let hh = length(l.pos.xyz - view_s * t0) + 0.5;
if hh > l.pos.w * 0.5 { continue; }
let a0 = atan((0.0 - t0) / hh);
let a1 = atan((far - t0) / hh);
- c += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + density * 300.0);
+ // Thicker air glows more round a light, but only so far: the mist at dusk and many fires
+ // at once would otherwise wash the picture out.
+ air_s += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + min(density * 300.0, 0.9));
+ }
+ var out: LightOut;
+ if water {
+ out.lit = vec4<f32>(c, 0.5);
+ out.air = vec4<f32>(air_s, air_t);
+ } else {
+ out.lit = vec4<f32>(c * air_t + air_s, select(1.0, 0.6, front));
+ out.air = vec4<f32>(floor_lit, 0.0);
}
- return vec4<f32>(c, select(1.0, 0.5, water));
+ return out;
}
"#;
@@ -580,6 +625,9 @@ const REFLECT_RANGE: f32 = 96.0;
@group(0) @binding(8) var lightmap: texture_2d<f32>;
@group(0) @binding(9) var linear: sampler;
//SKY10
+// What the lighting left beside the lit world: over water the air in front of the surface (scattered
+// light, transmittance), in front of the water the lit floor.
+@group(0) @binding(11) var air_tex: texture_2d<f32>;
fn ign(px: vec2<f32>) -> f32 {
let p = px + vec2<f32>(5.588238 * frame.params.w);
@@ -761,6 +809,9 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
let px = vec2<i32>(in.position.xy);
let base = textureLoad(lit_tex, px, 0);
if base.a > 0.75 { return vec4<f32>(base.rgb, 1.0); }
+ // Alpha 0.6: something translucent stands in front of the water here (base is the scene).
+ let front = base.a > 0.55;
+ let aux = textureLoad(air_tex, px, 0);
let wd = textureLoad(water_depth, px, 0);
let g = textureLoad(gbuffer_depth, px, 0);
let tint = textureLoad(water_tint, px, 0);
@@ -792,23 +843,35 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
let volume_light = max(ambient * frame.ambient.x, block * frame.ambient.y) + frame.sun_color.rgb * sun_on * sunlit * 0.8;
// Refraction: where the ray through the waves meets the world, against where the flat surface
- // would send it (so still water looks as vanilla's and only the waves distort).
- let floor0 = position_at(in.uv, g);
- let depth0 = max(length(floor0) - dist, 0.0);
- let bent = refract(-v, n, 0.75) - refract(-v, geometric, 0.75);
+ // would send it (so still water looks as vanilla's and only the waves distort). Not where
+ // something translucent is in front of the water, nor onto texels that hold such a thing.
+ let has_floor = g > 0.0;
var uv_floor = in.uv;
var floor_z = g;
- let moved = project(floor0 + bent * min(depth0, 24.0));
- if moved.z > 0.0 && on_screen(moved.xy) {
- let z = textureLoad(gbuffer_depth, texel(moved.xy), 0);
- // Only what lies behind the water: not the bank or an entity in front of it.
- if z > 0.0 && z <= wd {
- uv_floor = moved.xy;
- floor_z = z;
+ if has_floor && !front {
+ let floor0 = position_at(in.uv, g);
+ let depth0 = max(length(floor0) - dist, 0.0);
+ let bent = refract(-v, n, 0.75) - refract(-v, geometric, 0.75);
+ let moved = project(floor0 + bent * min(depth0, 24.0));
+ if moved.z > 0.0 && on_screen(moved.xy) {
+ let z = textureLoad(gbuffer_depth, texel(moved.xy), 0);
+ // The four texels the filter blends: none may be a pixel with a translucent thing
+ // in front of the water.
+ let marks = textureGather(3, lit_tex, linear, moved.xy);
+ let tainted = any((marks > vec4<f32>(0.55)) & (marks < vec4<f32>(0.75)));
+ // Only what lies behind the water: not the bank or an entity in front of it.
+ if z > 0.0 && z <= wd && !tainted {
+ uv_floor = moved.xy;
+ floor_z = z;
+ }
}
}
- let thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0);
- let behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb;
+ var thickness = 48.0;
+ if has_floor {
+ thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0);
+ }
+ var behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb;
+ if front { behind = aux.rgb; }
// A deep, dark water: the biome's colour darkened and a little greyed, so it reads as
// depth rather than as bright paint.
let biome = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
@@ -840,6 +903,13 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
if frame.sky_sun.w > 0.5 && frame.fog.x > 1.0e6 {
c = mix(c, haze, (1.0 - exp(-dist / 700.0)) * 0.5);
}
+ if front {
+ // The scene as drawn, with the water's change to the floor laid under the translucent thing.
+ c = base.rgb + (c - aux.rgb) * 0.7;
+ } else {
+ // The air between the camera and the surface: volumetric fog, rays, halos.
+ c = c * aux.a + aux.rgb;
+ }
return vec4<f32>(max(c, vec3<f32>(0.0)), 1.0);
}
"#;
@@ -1413,6 +1483,8 @@ pub struct Hd {
water_normal: wgpu::TextureView,
water_depth: wgpu::TextureView,
lit_water: wgpu::TextureView,
+ /// The lighting's second target: the air over water, the floor beside translucent things.
+ air: wgpu::TextureView,
water_layout: wgpu::BindGroupLayout,
water_pipeline: wgpu::RenderPipeline,
water_group: Option<((wgpu::TextureView, wgpu::Sampler), wgpu::BindGroup)>,
@@ -1849,6 +1921,7 @@ impl Hd {
FLOAT,
SAMPLER,
FLOAT,
+ UNFILTERED,
]),
});
let sky_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -1961,15 +2034,39 @@ impl Hd {
},
alpha: wgpu::BlendComponent::REPLACE,
};
- let light_pipeline = fullscreen(
- device,
- "HD lighting",
- &light_layout,
- &light,
- "fs_light",
- HDR,
- None,
- );
+ let light_pipeline = {
+ let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("HD lighting"),
+ bind_group_layouts: &[Some(&light_layout)],
+ ..Default::default()
+ });
+ let target = Some(wgpu::ColorTargetState {
+ format: HDR,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ });
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("HD lighting"),
+ layout: Some(&layout),
+ vertex: wgpu::VertexState {
+ module: &light,
+ entry_point: Some("vs_full"),
+ compilation_options: Default::default(),
+ buffers: &[],
+ },
+ fragment: Some(wgpu::FragmentState {
+ module: &light,
+ entry_point: Some("fs_light"),
+ compilation_options: Default::default(),
+ targets: &[target.clone(), target],
+ }),
+ primitive: Default::default(),
+ depth_stencil: None,
+ multisample: Default::default(),
+ multiview_mask: None,
+ cache: None,
+ })
+ };
let prefilter = fullscreen(
device,
"HD bloom prefilter",
@@ -2108,6 +2205,7 @@ impl Hd {
)
.view,
lit: target(device, "HD lit", [1, 1], HDR).view,
+ air: target(device, "HD air", [1, 1], HDR).view,
fx: target(device, "HD post", [1, 1], HDR).view,
water_tint: target(device, "HD water colour", [1, 1], WATER_TINT).view,
water_normal: target(device, "HD water normal", [1, 1], WATER_NORMAL).view,
@@ -2199,6 +2297,7 @@ impl Hd {
self.levels = target(device, "HD light levels", size, LIGHT_LEVELS).view;
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
self.lit = target(device, "HD lit", size, HDR).view;
+ self.air = target(device, "HD air", size, HDR).view;
self.fx = target(device, "HD post", size, HDR).view;
self.water_tint = target(device, "HD water colour", size, WATER_TINT).view;
self.water_normal = target(device, "HD water normal", size, WATER_NORMAL).view;
@@ -2322,7 +2421,12 @@ impl Hd {
0.0
};
let frame = HdFrame {
- prev_view_proj: scene.prev_view_proj.to_cols_array_2d(),
+ prev_view_proj: if self.upscaling() {
+ scene.prev_view_proj
+ } else {
+ scene.prev_view_proj_jittered
+ }
+ .to_cols_array_2d(),
taa: [
scene.camera_delta.x,
scene.camera_delta.y,
@@ -2685,15 +2789,28 @@ impl Hd {
return;
};
self.water_composited = false;
- Self::draw(
- encoder,
- "HD lighting",
- &self.lit,
- false,
- &self.light_pipeline,
- group,
- stamp(&mut self.timing, "hd lighting"),
- );
+ let clear = |view| {
+ Some(wgpu::RenderPassColorAttachment {
+ view,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
+ store: wgpu::StoreOp::Store,
+ },
+ })
+ };
+ {
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("HD lighting"),
+ timestamp_writes: stamp(&mut self.timing, "hd lighting"),
+ color_attachments: &[clear(&self.lit), clear(&self.air)],
+ ..Default::default()
+ });
+ pass.set_pipeline(&self.light_pipeline);
+ pass.set_bind_group(0, group, &[]);
+ pass.draw(0..3, 0..1);
+ }
}
/// A bind group of consecutive bindings 0.., counted (`groups_last_frame`).
@@ -2987,6 +3104,7 @@ impl Hd {
wgpu::BindingResource::TextureView(lightmap.0),
wgpu::BindingResource::Sampler(&self.sampler),
wgpu::BindingResource::TextureView(&self.sky_lut),
+ wgpu::BindingResource::TextureView(&self.air),
],
);
self.water_group = Some((key, group));
@@ -3098,6 +3216,20 @@ pub fn light_of(scene: &HdScene) -> (glam::Vec3, f32, glam::Vec3) {
}
}
+/// The factor on vanilla's cloud colour that lights the clouds with the low sun's colour at dusk
+/// and dawn instead of a flat white. It is the clouds' own colour (the cloud pass gets it), not a
+/// guess from the depth buffer, so what stands behind a cloud cannot change it.
+pub fn vanilla_cloud_tint(scene: &HdScene) -> [f32; 3] {
+ let sun = scene.sun.normalize_or(glam::Vec3::Y);
+ let low = 1.0 - smooth(0.05, 0.5, sun.y);
+ let overcast = (scene.rain / 0.7).clamp(0.0, 1.0) * 0.85;
+ let (_, _, color) = light_of(scene);
+ let warm = color.lerp(glam::Vec3::ONE, 0.45) * 1.1;
+ let t = low * 0.7 * (1.0 - overcast);
+ let tint = glam::Vec3::ONE.lerp(warm, t);
+ [tint.x, tint.y, tint.z]
+}
+
/// The x of the sun's direction: positive in the morning, negative in the evening.
fn sun_dir_x(scene: &HdScene) -> f32 {
scene.sun.normalize_or(glam::Vec3::Y).x
@@ -3201,6 +3333,37 @@ mod tests {
assert_eq!(seen.len(), 16);
}
+ #[test]
+ fn taa_history_lookup_follows_the_jittered_grid() {
+ // A static point under two frames' different Halton jitters: fs_taa finds this frame's
+ // point in the history by the previous frame's matrix *as drawn*, which lands on the pixel
+ // the previous frame wrote it to; the unjittered one is off by that frame's jitter.
+ let size = [1920u32, 1080];
+ let unjittered = Mat4::perspective_rh(1.0, 16.0 / 9.0, 0.05, 1000.0);
+ let to_uv = |m: Mat4, p: glam::Vec3| {
+ let c = m * p.extend(1.0);
+ [c.x / c.w * 0.5 + 0.5, 0.5 - c.y / c.w * 0.5]
+ };
+ let drawn = |frame: u32| {
+ let j = jitter(frame, size, 16);
+ Mat4::from_translation(glam::Vec3::new(j[0], j[1], 0.0)) * unjittered
+ };
+ let p = glam::Vec3::new(0.0, 0.0, -20.0);
+ for frame in 1..16 {
+ let prev = drawn(frame - 1);
+ // The pixel (in uv) the previous frame drew the point at: what the history holds there.
+ let wrote = to_uv(prev, p);
+ let old = to_uv(unjittered, p);
+ let err = [
+ (old[0] - wrote[0]).abs() * size[0] as f32,
+ (old[1] - wrote[1]).abs() * size[1] as f32,
+ ];
+ let pj = jitter(frame - 1, size, 16);
+ assert!((err[0] - pj[0].abs() * size[0] as f32 * 0.5).abs() < 1e-3);
+ assert!((err[1] - pj[1].abs() * size[1] as f32 * 0.5).abs() < 1e-3);
+ }
+ }
+
#[test]
fn jitter_covers_the_upscale_ratio() {
assert_eq!(jitter_count(1.0), 16);
diff --git a/crates/horizon-client/src/gfx/hd_sky.rs b/crates/horizon-client/src/gfx/hd_sky.rs
index ec55de4c8..dbe8c8853 100644
--- a/crates/horizon-client/src/gfx/hd_sky.rs
+++ b/crates/horizon-client/src/gfx/hd_sky.rs
@@ -283,6 +283,14 @@ fn vanilla_sunrise_weight(d: vec3<f32>) -> f32 {
let theta = radians(flip + 90.0);
let c = cos(theta);
let s = sin(theta);
+ // Above the horizon the fan reaches only the sun's side (x of the same sign as the apex's, -s)
+ // and rises at most alpha/3 per block of that distance (its rim stands 40*alpha high over a
+ // 120-block radius and the points above the horizon lie at least 120 * the share of the rim
+ // from the axis); anything else cannot hit it, so most of the sky skips the 16 triangles.
+ if d.y > 0.0 {
+ let side = -s * d.x;
+ if side <= 0.0 || d.y > alpha * (1.0 / 3.0) * side * 1.02 + 0.001 { return 0.0; }
+ }
var best = 0.0;
let apex = vanilla_rotate_x90(vec3<f32>(-100.0 * s, 100.0 * c, 0.0));
for (var i = 0; i < 16; i++) {
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
index 0a2a6cdba..cb098a64a 100644
--- a/crates/horizon-client/src/terrain.wgsl
+++ b/crates/horizon-client/src/terrain.wgsl
@@ -22,6 +22,8 @@ struct Globals {
// Atlas rect of the current destroy stage (read by entity.wgsl).
crumbling_sprite: vec4<f32>,
outline_params: vec4<f32>,
+ // AirDef fork. x: the HD water draws the water (fs_translucent leaves vanilla's out).
+ hd_flags: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
}
@@ -211,6 +213,12 @@ struct WaterOut {
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
if color.a < 0.01 { discard; }
+ // The HD water composite draws the water itself: vanilla's stays out of the colour (alpha 0
+ // blends to nothing) but keeps its depth, which the lighting reads.
+ let layer = in.layer & 0x7fffu;
+ if globals.hd_flags.x > 0.5 && (layer == WATER_STILL || layer == WATER_FLOW) {
+ return vec4(0.0);
+ }
return apply_fog(color, in.spherical, in.cylindrical);
}
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
index 8411ac9aa..5fa5e8a27 100644
--- a/crates/horizon-mod-sdk/wit/horizon.wit
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
@@ -694,6 +694,11 @@ interface render {
/// Switches the HD lighting on or off for this mod's world, with its
/// exposure and bloom; off, the world is drawn as the game draws it.
set-lighting: func(lighting: lighting);
+ /// Shows the world at this time of day (AirDef fork): the sky, the sun
+ /// and moon, the sky light and the fog, as `day-time` ticks (24000 a day,
+ /// as `clock`). Only what the player sees changes: the world's own clock,
+ /// its saves and its rules do not. None shows the world's own time.
+ set-time-override: func(day-time: option<u64>);
}
/// What the game calls. Only the hooks `init` asks for are ever called.
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
index 8cf350044..bc1ade067 100644
--- a/crates/horizon-mods/src/api_level.rs
+++ b/crates/horizon-mods/src/api_level.rs
@@ -7,13 +7,13 @@
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
-pub const LEVEL: u32 = 12;
+pub const LEVEL: u32 = 13;
/// Every function the game offers mods, as `interface.function`, with its
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
-pub const FUNCTIONS: [(&str, u32); 66] = [
+pub const FUNCTIONS: [(&str, u32); 67] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -82,6 +82,8 @@ pub const FUNCTIONS: [(&str, u32); 66] = [
("render.query-sections", 12),
("render.sections", 12),
("render.world-changes", 12),
+ // AirDef fork: a replay shows its recorded time of day.
+ ("render.set-time-override", 13),
];
/// What the game's program carries so the launcher can read its level
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
index 2189a99e1..fb6180f1c 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -507,6 +507,8 @@ struct State {
/// `render.set-lights` and `render.set-lighting` (AirDef fork).
lights: Vec<PointLight>,
lighting: Option<ModLighting>,
+ /// `render.set-time-override` (AirDef fork).
+ time_override: Option<u64>,
input: InputState,
browser: BrowserState,
/// Capability `storage`'s folder; made on first `set`.
@@ -749,6 +751,7 @@ impl Mods {
views: Vec::new(),
lights: Vec::new(),
lighting: None,
+ time_override: None,
input: InputState::default(),
browser: BrowserState::default(),
data_dir,
@@ -1334,6 +1337,12 @@ impl Mods {
.iter()
.find_map(|e| e.run.as_ref()?.store.data().lighting)
}
+ /// The time of day the first running mod asked to show, if any.
+ pub fn time_override(&self) -> Option<u64> {
+ self.entries
+ .iter()
+ .find_map(|e| e.run.as_ref()?.store.data().time_override)
+ }
/// The columns each running mod asked the ground height of, taken.
pub fn take_ground_queries(&mut self) -> Vec<(usize, Vec<(i32, i32)>)> {
self.entries
@@ -2237,6 +2246,9 @@ impl render::Host for State {
};
self.lighting = Some(lighting);
}
+ fn set_time_override(&mut self, day_time: Option<u64>) {
+ self.time_override = day_time.map(|t| t.min(1 << 40));
+ }
fn query_ground(&mut self, columns: Vec<(i32, i32)>) {
let room = 512usize.saturating_sub(self.ground_queries.len());
self.ground_queries.extend(columns.into_iter().take(room));
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
index 2afb5bf62..b58f863c2 100644
--- a/docs/api/mods-reference.md
+++ b/docs/api/mods-reference.md
@@ -646,6 +646,7 @@ The HD lighting (AirDef fork): the world lit per pixel by the mods' lights, in h
| `set-views` | `func(views: list<camera-view>)` | Replaces the mod's cameras: up to 8. A `view` shape shows one; the game renders up to three a frame, in turn. |
| `set-lights` | `func(lights: list<point-light>)` | Replaces the mod's lights: up to 512, interpolated per id between the last two calls. |
| `set-lighting` | `func(lighting: lighting)` | Switches the HD lighting on or off for this mod's world, with its exposure and bloom; off, the world is drawn as the game draws it. |
+| `set-time-override` | `func(day-time: option<u64>)` | Shows the world at this time of day (AirDef fork): the sky, the sun and moon, the sky light and the fog, as `day-time` ticks (24000 a day, as `clock`). Only what the player sees changes: the world's own clock, its saves and its rules do not. None shows the world's own time. |
## `events`
--
2.54.0 (Apple Git-157)
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/29] 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)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,603 @@
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/29] 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
only, gameplay sky darkening uses the world's time (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/game.rs | 37 +++--
crates/horizon-client/src/game/render_tick.rs | 2 +-
crates/horizon-client/src/gfx/hd/cloud.wgsl | 54 ++++++-
.../src/gfx/hd/cloud_common.wgsl | 3 +-
crates/horizon-client/src/gfx/hd/fog.wgsl | 6 +-
crates/horizon-client/src/gfx/hd/light.wgsl | 42 +++++-
crates/horizon-client/src/gfx/hd/lighting.rs | 4 +-
crates/horizon-client/src/gfx/hd/volumes.rs | 135 +++++++++++++++---
8 files changed, 239 insertions(+), 44 deletions(-)
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index 2130d8d88..f21b3dcdd 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -1838,6 +1838,8 @@ struct Game<'a> {
events: events::Events,
/// The overworld's last `sky_darken` and its inputs (time, rain, thunder).
sky_darken_memo: std::cell::Cell<Option<((i64, u32, u32), i32)>>,
+ /// The same for the time a mod shows (a replay's): render only.
+ shown_darken_memo: std::cell::Cell<Option<((i64, u32, u32), i32)>>,
persist: persist::EntityMemory,
/// Connected to a server: the world comes from the network (game/remote.rs).
remote: Option<remote::Remote>,
@@ -8020,7 +8022,7 @@ impl Game<'_> {
.flatten();
if let Some(sprite) = shadow_sprite {
let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 };
- let darken = self.sky_darken();
+ let darken = self.shown_sky_darken();
let a = alpha;
let lerp = |p: Vec3, q: Vec3| {
glam::DVec3::new(
@@ -8378,33 +8380,43 @@ impl Game<'_> {
.map_or(self.world.day_time, |t| t as i64)
}
+ /// The sky's darkening for the game's rules (mob AI, spawning, block behaviour, stats): always
+ /// the world's real time, never a mod's render-only override.
fn sky_darken(&self) -> i32 {
+ self.sky_darken_at(self.world.day_time, &self.sky_darken_memo)
+ }
+
+ /// The sky's darkening as the player sees it (entity shadows, particle light): follows the mod's
+ /// time override, so a replay's night looks dark without touching the rules.
+ fn shown_sky_darken(&self) -> i32 {
+ self.sky_darken_at(self.shown_day_time(), &self.shown_darken_memo)
+ }
+
+ fn sky_darken_at(
+ &self,
+ time: i64,
+ memo: &std::cell::Cell<Option<((i64, u32, u32), i32)>>,
+ ) -> i32 {
if self.dim == Dim::Nether {
return 11;
}
if self.dim == Dim::End {
- let sky = self
- .res
- .end_environment()
- .sample(self.shown_day_time(), None, None);
+ let sky = self.res.end_environment().sample(time, None, None);
return (15.0 - 15.0 * sky.sky_light_level).round() as i32;
}
let (rain, thunder) = self.world.weather.levels(1.0);
// Sampled once per (time, weather): every mob asks each tick.
- let key = (self.shown_day_time(), rain.to_bits(), thunder.to_bits());
- if let Some((k, darken)) = self.sky_darken_memo.get()
+ let key = (time, rain.to_bits(), thunder.to_bits());
+ if let Some((k, darken)) = memo.get()
&& k == key
{
return darken;
}
- let mut sky = self
- .res
- .environment()
- .sample(self.shown_day_time(), None, None);
+ let mut sky = self.res.environment().sample(time, None, None);
horizon_client::environment::apply_weather(&mut sky, rain, thunder);
// Level.updateSkyBrightness: darkening from the sky light level.
let darken = (15.0 - 15.0 * sky.sky_light_level).round() as i32;
- self.sky_darken_memo.set(Some((key, darken)));
+ memo.set(Some((key, darken)));
darken
}
fn mob_hit(&self) -> Option<(usize, f64)> {
@@ -13954,6 +13966,7 @@ impl<'a> Game<'a> {
mods: Default::default(),
native_shaders: Default::default(),
sky_darken_memo: Default::default(),
+ shown_darken_memo: Default::default(),
persist: Default::default(),
remote: None,
lan: None,
diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs
index 6807d7718..9912970a5 100644
--- a/crates/horizon-client/src/game/render_tick.rs
+++ b/crates/horizon-client/src/game/render_tick.rs
@@ -241,7 +241,7 @@ impl Game<'_> {
eye[2].floor() as i32,
];
let packed = self.world.light(b[0], b[1], b[2]);
- let sky = i32::from(packed >> 4) - self.sky_darken();
+ let sky = i32::from(packed >> 4) - self.shown_sky_darken();
let raw = sky.max(i32::from(packed & 15)).clamp(0, 15) as u8;
lightmap_brightness(if self.dim == Dim::Nether { 0.1 } else { 0.0 }, raw)
}
diff --git a/crates/horizon-client/src/gfx/hd/cloud.wgsl b/crates/horizon-client/src/gfx/hd/cloud.wgsl
index fad234a46..39a239f4d 100644
--- a/crates/horizon-client/src/gfx/hd/cloud.wgsl
+++ b/crates/horizon-client/src/gfx/hd/cloud.wgsl
@@ -1,6 +1,17 @@
@group(0) @binding(6) var history: texture_2d<f32>;
@group(0) @binding(7) var smoke_tex: texture_2d<f32>;
+@group(0) @binding(8) var scene_depth: texture_depth_2d;
+@group(0) @binding(9) var history_dist: texture_2d<f32>;
+
+// What the sky pixels report as the length of their ray (nothing stops them).
+const SKY_LEN: f32 = 20000.0;
+
+struct CloudOut {
+ @location(0) cloud: vec4<f32>,
+ // How far the ray was marched: the surface behind this texel, or SKY_LEN for sky.
+ @location(1) dist: vec4<f32>,
+}
fn ign(px: vec2<f32>) -> f32 {
let p = px + vec2<f32>(5.588238 * frame.params.w);
@@ -65,7 +76,26 @@ fn cloud_scatter(
}
@fragment
-fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
+fn fs_cloud(in: FullOut) -> CloudOut {
+ // The nearest of the four world pixels this texel stands for: the march ends at the surface
+ // there (the sky counts as infinitely far), so what stands in front of a cloud stays in front
+ // and what stands behind it is hidden by it.
+ let size = vec2<i32>(frame.resolution.xy);
+ let base = vec2<i32>(in.position.xy) * 2;
+ var zmax = 0.0;
+ var best = base;
+ for (var k = 0; k < 4; k++) {
+ let q = min(base + vec2<i32>(k & 1, k >> 1), size - 1);
+ let z = textureLoad(scene_depth, q, 0);
+ if z > zmax {
+ zmax = z;
+ best = q;
+ }
+ }
+ let suv = (vec2<f32>(best) + 0.5) * frame.resolution.zw;
+ let sndc = vec4<f32>(suv.x * 2.0 - 1.0, 1.0 - suv.y * 2.0, max(zmax, 0.000001), 1.0);
+ let sh = frame.inverse_view_proj * sndc;
+ let scene_len = select(SKY_LEN, length(sh.xyz / sh.w), zmax > 0.0);
let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, 0.5, 1.0);
let hh = frame.inverse_view_proj * ndc;
let dir = normalize(hh.xyz / hh.w);
@@ -73,11 +103,13 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
var hit_t = 900.0;
let y0 = frame.clouds.x;
let y1 = y0 + frame.clouds.y;
- if abs(dir.y) > 0.004 {
- let ta = y0 / dir.y;
- let tb = y1 / dir.y;
+ // A level ray stays in the slab if the camera is in it (it never leaves), else misses it.
+ let dy = select(dir.y, select(-1.0e-4, 1.0e-4, dir.y >= 0.0), abs(dir.y) < 1.0e-4);
+ {
+ let ta = y0 / dy;
+ let tb = y1 / dy;
let lo = max(min(ta, tb), 0.0);
- let hi = min(max(ta, tb), 4200.0);
+ let hi = min(max(ta, tb), min(4200.0, scene_len));
if hi > lo {
let dither = ign(in.position.xy);
let n = clamp(i32(ceil((hi - lo) / 14.0)), 8, 26);
@@ -131,6 +163,8 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
cur = vec4<f32>(colour, trans);
}
}
+ var out: CloudOut;
+ out.dist = vec4<f32>(scene_len, 0.0, 0.0, 0.0);
// Reproject the history by where the clouds are (rotation and the camera's step) and blend.
if frame.cloud_cam.w > 0.5 {
let hp = frame.prev_view_proj * vec4<f32>(dir * hit_t + frame.taa.xyz, 1.0);
@@ -138,12 +172,18 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
let huv = vec2<f32>(hp.x / hp.w * 0.5 + 0.5, 0.5 - hp.y / hp.w * 0.5);
if all(huv > vec2<f32>(0.0)) && all(huv < vec2<f32>(1.0)) {
let old = textureSampleLevel(history, linear, huv, 0.0);
+ // A history that was marched to another surface (an edge, something that moved in
+ // front) does not describe this ray.
+ let hs = vec2<f32>(textureDimensions(history_dist));
+ let old_len = textureLoad(history_dist, vec2<i32>(huv * hs), 0).x;
+ let same_len = 1.0 - smoothstep(0.5, 1.0, abs(old_len - scene_len) / (0.1 * min(scene_len, old_len) + 2.0));
// Not too much of a history that disagrees by a lot (a gust, a fast turn).
let diff = length(old.rgb - cur.rgb) + abs(old.a - cur.a);
- let keep = 0.9 * (1.0 - smoothstep(0.35, 1.2, diff));
+ let keep = 0.9 * (1.0 - smoothstep(0.35, 1.2, diff)) * same_len;
cur = mix(cur, old, keep);
}
}
}
- return cur;
+ out.cloud = cur;
+ return out;
}
diff --git a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
index e2352f008..8ee014420 100644
--- a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
+++ b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
@@ -44,7 +44,8 @@ fn cloud_density(pos: vec3<f32>, detail: f32) -> f32 {
d *= smoothstep(0.0, 0.2, h);
if d < 0.002 { return 0.0; }
if detail > 0.0 {
- let p3 = vec3<f32>(w.x, pos.y, w.y);
+ // Height above the layer's base, not the camera's height: the noise stays put in the world.
+ let p3 = vec3<f32>(w.x, pos.y - frame.clouds.x, w.y);
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;
diff --git a/crates/horizon-client/src/gfx/hd/fog.wgsl b/crates/horizon-client/src/gfx/hd/fog.wgsl
index 38b9098e3..d035bbf0d 100644
--- a/crates/horizon-client/src/gfx/hd/fog.wgsl
+++ b/crates/horizon-client/src/gfx/hd/fog.wgsl
@@ -40,7 +40,7 @@ fn sun_visible(p: vec3<f32>) -> f32 {
}
// The air's extinction per block at a camera-relative point: a haze that thins with height above
-// the sea, a morning mist hugging the ground, rain, patches from a 3D noise that drifts; none
+// the sea, a morning mist hugging the ground, rain, patches from a 3D noise that drifts (its scale divides 65536, the wrap of the camera's x, z, so it does not jump at x or z = 0); none
// deep underground.
fn fog_sigma(p: vec3<f32>) -> f32 {
let wy = p.y + frame.vfog.y;
@@ -50,7 +50,7 @@ fn fog_sigma(p: vec3<f32>) -> f32 {
s += frame.sky_extra.z * 0.003;
let w = vec3<f32>(p.x + frame.cloud_cam.x, wy, p.z + frame.cloud_cam.y)
+ vec3<f32>(0.5, 0.0, 0.35) * frame.clouds.w;
- let n = textureSampleLevel(noise3, repeat_s, w / 170.0, 0.0).b;
+ let n = textureSampleLevel(noise3, repeat_s, w / 128.0, 0.0).b;
let patchy = 0.35 + 1.3 * smoothstep(0.25, 0.75, n);
// Fade out far below the sea's height: caves keep their own dark air.
return s * patchy * smoothstep(28.0, 58.0, wy);
@@ -70,7 +70,7 @@ fn smoke_detail(pos: vec3<f32>, core: f32) -> f32 {
let w = vec3<f32>(pos.x + frame.cloud_cam.x, pos.y + frame.vfog.y, pos.z + frame.cloud_cam.y)
- vec3<f32>(SMOKE_WIND.x, 0.0, SMOKE_WIND.y) * frame.smoke.z;
let n1 = textureSampleLevel(noise3, repeat_s, w / 64.0, 0.0).r;
- let n2 = textureSampleLevel(noise3, repeat_s, w / 22.0, 0.0).g;
+ let n2 = textureSampleLevel(noise3, repeat_s, w / 32.0, 0.0).g;
let n = 0.55 * n1 + 0.45 * n2;
// Thin smoke breaks up into wisps, thick smoke stays whole.
let keep = smoothstep(0.18, 0.62, n + 0.45 * min(core, 1.5) - 0.12);
diff --git a/crates/horizon-client/src/gfx/hd/light.wgsl b/crates/horizon-client/src/gfx/hd/light.wgsl
index 2534a3db8..870ae0d7d 100644
--- a/crates/horizon-client/src/gfx/hd/light.wgsl
+++ b/crates/horizon-client/src/gfx/hd/light.wgsl
@@ -30,6 +30,9 @@ struct Light {
@group(0) @binding(17) var fog_dist_tex: texture_2d<f32>;
@group(0) @binding(18) var cloud_tex: texture_2d<f32>;
@group(0) @binding(19) var cloud_shadow_tex: texture_2d<f32>;
+// How far each cloud texel's ray was marched (the surface behind it, or SKY_RAY for sky).
+@group(0) @binding(20) var cloud_dist_tex: texture_2d<f32>;
+const SKY_RAY: f32 = 20000.0;
const CLOUD_SHADOW_R: f32 = 1200.0;
// The pixel being lit, for the shadow filter's noise.
var<private> frag_px: vec2<f32>;
@@ -194,6 +197,34 @@ fn fog_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
return sum / total;
}
+// The volumetric clouds between the camera and a surface `dist` away (SKY_RAY for sky): the four
+// nearest half-resolution texels weighted by closeness and by how near the length they were
+// marched to is, so a cloud in front of a mountain does not bleed onto the sky beside it.
+fn cloud_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
+ let size = vec2<i32>(textureDimensions(cloud_tex));
+ let f = vec2<f32>(px) * 0.5 - vec2<f32>(0.25);
+ let base = vec2<i32>(floor(f));
+ let t = f - floor(f);
+ var sum = vec4<f32>(0.0);
+ var total = 0.0;
+ for (var j = 0; j < 2; j++) {
+ for (var i = 0; i < 2; i++) {
+ let q = clamp(base + vec2<i32>(i, j), vec2<i32>(0), size - 1);
+ let s = textureLoad(cloud_tex, q, 0);
+ let sd = textureLoad(cloud_dist_tex, q, 0).x;
+ let bw = select(1.0 - t.x, t.x, i == 1) * select(1.0 - t.y, t.y, j == 1);
+ let dz = (sd - dist) / (0.06 * min(sd, dist) + 0.4);
+ let w = bw / (1.0 + dz * dz * dz * dz);
+ sum += s * w;
+ total += w;
+ }
+ }
+ if total < 0.0001 {
+ return textureLoad(cloud_tex, clamp(px / 2, vec2<i32>(0), size - 1), 0);
+ }
+ return sum / total;
+}
+
struct LightOut {
// The lit colour; alpha 0.5 marks water (the floor under it, lit without any air), 0.6 an
// object in front of the water (the scene as drawn).
@@ -246,7 +277,7 @@ fn shade_surface(px: vec2<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
let flat_up = smoothstep(0.9, 0.98, n.y);
let soak = wet * open_sky * (0.35 + 0.65 * flat_up);
let wp = frame.cloud_cam.xy + p.xz;
- let nz = 0.6 * wet_noise(wp * 0.45) + 0.4 * wet_noise(wp * 1.3 + vec2<f32>(17.0, 5.0));
+ let nz = 0.6 * wet_noise(wp * 0.5) + 0.4 * wet_noise(wp * 1.25 + vec2<f32>(17.0, 5.0));
puddle = smoothstep(0.66 - 0.16 * wet, 0.74 - 0.16 * wet, nz) * smoothstep(0.3, 0.7, wet) * open_sky * flat_up;
surface_rgb *= mix(1.0, 0.7, soak) * mix(1.0, 0.6, puddle);
rough = mix(rough, 0.12, soak * (1.0 - 0.5 * metal));
@@ -377,7 +408,7 @@ fn fs_light(in: FullOut) -> LightOut {
c = sky + kept;
// The volumetric clouds over the sky and the sun.
if frame.clouds.z > 0.5 {
- let cl = textureSampleLevel(cloud_tex, linear, in.uv, 0.0);
+ let cl = cloud_at(px, SKY_RAY);
c = c * cl.a + cl.rgb;
}
}
@@ -421,6 +452,13 @@ fn fs_light(in: FullOut) -> LightOut {
air_t = fg.a;
air_s = fg.rgb;
}
+ // The clouds between the camera and the surface (inside the layer, or in front of a mountain
+ // or a flying thing): laid over the surface and under the fog, as the sky's are.
+ if d > 0.0 && atmosphere && frame.clouds.z > 0.5 {
+ let cl = cloud_at(px, length(ps));
+ air_s += air_t * cl.rgb;
+ air_t *= cl.a;
+ }
if !volumetric && sun_on > 0.0 && density > 0.0 {
let steps = 14;
let jitter = ign(in.position.xy);
diff --git a/crates/horizon-client/src/gfx/hd/lighting.rs b/crates/horizon-client/src/gfx/hd/lighting.rs
index da56d00ba..d26f1e513 100644
--- a/crates/horizon-client/src/gfx/hd/lighting.rs
+++ b/crates/horizon-client/src/gfx/hd/lighting.rs
@@ -34,7 +34,7 @@ pub const MAX_LIGHTS: usize = 512;
pub(super) const LIGHT: &str = include_str!("light.wgsl");
/// The lighting's group 0, binding by binding (see the contract above).
-pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 20] = [
+pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 21] = [
UNIFORM,
UNFILTERED,
DEPTH,
@@ -55,6 +55,7 @@ pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 20] = [
UNFILTERED,
FLOAT,
FLOAT,
+ UNFILTERED,
];
/// The outside views and sampler the lighting group reads.
@@ -219,6 +220,7 @@ impl Hd {
wgpu::BindingResource::TextureView(&self.volumes.fog_dist),
wgpu::BindingResource::TextureView(self.volumes.cloud_view()),
wgpu::BindingResource::TextureView(&self.volumes.cloud_shadow),
+ wgpu::BindingResource::TextureView(self.volumes.cloud_dist_view()),
];
let entries: Vec<_> = resources
.into_iter()
diff --git a/crates/horizon-client/src/gfx/hd/volumes.rs b/crates/horizon-client/src/gfx/hd/volumes.rs
index a338d756c..d3b55e2f2 100644
--- a/crates/horizon-client/src/gfx/hd/volumes.rs
+++ b/crates/horizon-client/src/gfx/hd/volumes.rs
@@ -26,8 +26,8 @@
use super::noise as hd_noise;
use super::{
- DEPTH, FLOAT, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, UNIFORM, entries, fullscreen, module,
- stamp, stamped_pass, target,
+ DEPTH, FLOAT, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, UNFILTERED, UNIFORM, entries,
+ fullscreen, module, stamp, stamped_pass, target,
};
pub const CLOUD_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
@@ -86,6 +86,9 @@ pub struct HdVolumes {
shadow_sampler: wgpu::Sampler,
/// The clouds' half-resolution target and its history (read one, write the other).
pub clouds: [wgpu::TextureView; 2],
+ /// How far each cloud texel's ray was marched (ping-pong with `clouds`).
+ cloud_dist: [wgpu::TextureView; 2],
+ cloud_depth: Option<wgpu::TextureView>,
flip: usize,
history_ready: bool,
pub cloud_shadow: wgpu::TextureView,
@@ -111,9 +114,10 @@ const D3: wgpu::BindingType = wgpu::BindingType::Texture {
};
/// `fs_cloud`'s group 0: frame, 3D noise, 2D noise, clamped and repeating samplers, the sky
-/// texture, the previous cloud frame.
-pub(super) const CLOUD_BINDINGS: [wgpu::BindingType; 8] =
- [UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT, FLOAT];
+/// texture, the previous cloud frame, the smoke atlas, the world's depth, the previous march lengths.
+pub(super) const CLOUD_BINDINGS: [wgpu::BindingType; 10] = [
+ UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT, FLOAT, DEPTH, UNFILTERED,
+];
/// `fs_cloud_shadow`'s group 0: as the clouds' without the history.
pub(super) const CLOUD_SHADOW_BINDINGS: [wgpu::BindingType; 6] =
[UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT];
@@ -218,15 +222,40 @@ impl HdVolumes {
let cloud_module = module(device, "HD clouds", &cloud_src);
let shadow_module = module(device, "HD cloud shadows", &shadow_src);
let fog_module = module(device, "HD fog", &fog_body());
- let cloud_pipeline = fullscreen(
- device,
- "HD clouds",
- &cloud_layout,
- &cloud_module,
- "fs_cloud",
- CLOUD_FORMAT,
- None,
- );
+ let cloud_pipeline = {
+ let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("HD clouds"),
+ bind_group_layouts: &[Some(&cloud_layout)],
+ ..Default::default()
+ });
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("HD clouds"),
+ layout: Some(&layout),
+ vertex: wgpu::VertexState {
+ module: &cloud_module,
+ entry_point: Some("vs_full"),
+ compilation_options: Default::default(),
+ buffers: &[],
+ },
+ fragment: Some(wgpu::FragmentState {
+ module: &cloud_module,
+ entry_point: Some("fs_cloud"),
+ compilation_options: Default::default(),
+ targets: &[CLOUD_FORMAT, FOG_DIST_FORMAT].map(|format| {
+ Some(wgpu::ColorTargetState {
+ format,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ })
+ }),
+ }),
+ primitive: Default::default(),
+ depth_stencil: None,
+ multisample: Default::default(),
+ multiview_mask: None,
+ cache: None,
+ })
+ };
let shadow_pipeline = fullscreen(
device,
"HD cloud shadows",
@@ -286,6 +315,11 @@ impl HdVolumes {
one("HD clouds", [1, 1], CLOUD_FORMAT),
one("HD clouds", [1, 1], CLOUD_FORMAT),
],
+ cloud_dist: [
+ one("HD cloud distance", [1, 1], FOG_DIST_FORMAT),
+ one("HD cloud distance", [1, 1], FOG_DIST_FORMAT),
+ ],
+ cloud_depth: None,
flip: 0,
history_ready: false,
cloud_shadow: one(
@@ -316,6 +350,10 @@ impl HdVolumes {
one("HD clouds", CLOUD_FORMAT),
one("HD clouds", CLOUD_FORMAT),
];
+ self.cloud_dist = [
+ one("HD cloud distance", FOG_DIST_FORMAT),
+ one("HD cloud distance", FOG_DIST_FORMAT),
+ ];
self.fog = one("HD fog", FOG_FORMAT);
self.fog_dist = one("HD fog distance", FOG_DIST_FORMAT);
self.history_ready = false;
@@ -342,6 +380,11 @@ impl HdVolumes {
&self.clouds[self.flip]
}
+ /// The march lengths that go with `cloud_view`.
+ pub fn cloud_dist_view(&self) -> &wgpu::TextureView {
+ &self.cloud_dist[self.flip]
+ }
+
fn group(
&mut self,
device: &wgpu::Device,
@@ -372,7 +415,12 @@ impl HdVolumes {
frame: &wgpu::Buffer,
sky_lut: &wgpu::TextureView,
atlas: &wgpu::TextureView,
+ depth: &wgpu::TextureView,
) {
+ if self.cloud_depth.as_ref() != Some(depth) {
+ self.cloud_groups = None;
+ self.cloud_depth = Some(depth.clone());
+ }
if self.shadow_group.is_none() {
let layout = self.shadow_layout.clone();
let (n3, n2, clamp, repeat) = (
@@ -405,6 +453,7 @@ impl HdVolumes {
self.repeat.clone(),
);
let hist = self.clouds.clone();
+ let hist_dist = self.cloud_dist.clone();
let groups = [0, 1].map(|read| {
self.group(
device,
@@ -419,6 +468,8 @@ impl HdVolumes {
wgpu::BindingResource::TextureView(sky_lut),
wgpu::BindingResource::TextureView(&hist[read]),
wgpu::BindingResource::TextureView(atlas),
+ wgpu::BindingResource::TextureView(depth),
+ wgpu::BindingResource::TextureView(&hist_dist[read]),
],
)
});
@@ -455,7 +506,7 @@ impl HdVolumes {
stamped_pass(
encoder,
"HD clouds",
- &[&self.clouds[write]],
+ &[&self.clouds[write], &self.cloud_dist[write]],
&self.cloud_pipeline,
&groups[read],
stamp,
@@ -525,8 +576,13 @@ impl Hd {
depth: &wgpu::TextureView,
) {
if self.clouds_active {
- self.volumes
- .prepare_clouds(device, &self.frame, &self.sky.lut, &self.smoke.atlas);
+ self.volumes.prepare_clouds(
+ device,
+ &self.frame,
+ &self.sky.lut,
+ &self.smoke.atlas,
+ depth,
+ );
self.volumes
.draw_cloud_shadow(encoder, stamp(&mut self.timing, "hd cloud shadows"));
self.volumes
@@ -570,3 +626,48 @@ pub fn shader_sources() -> Vec<(&'static str, String, &'static [wgpu::BindingTyp
("fog", fog_body(), &FOG_BINDINGS),
]
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Every world-anchored noise lookup divides the world coordinate (the camera's x, z wrapped to
+ /// 65536) by a scale that divides 65536, or the pattern jumps where the wrap happens (x or z = 0).
+ #[test]
+ fn noise_scales_divide_the_camera_wrap() {
+ let mut found = 0;
+ for (name, src) in [("cloud_common", CLOUD_COMMON), ("fog", FOG)] {
+ for key in ["w / ", "p3 / "] {
+ for part in src.split(key).skip(1) {
+ let num: String = part
+ .chars()
+ .take_while(|c| c.is_ascii_digit() || *c == '.')
+ .collect();
+ let Ok(scale) = num.parse::<f64>() else {
+ continue;
+ };
+ found += 1;
+ assert!(
+ scale >= 1.0 && scale.fract() == 0.0 && 65536 % (scale as u64) == 0,
+ "{name}: the scale {scale} does not divide 65536"
+ );
+ }
+ }
+ }
+ assert!(found >= 8, "found only {found} noise lookups");
+ }
+
+ /// The cloud noise's height is measured from the layer's base, not from the camera: the same
+ /// world point gives the same noise coordinate at any camera height.
+ #[test]
+ fn cloud_noise_height_is_anchored_to_the_layer() {
+ assert!(CLOUD_COMMON.contains("pos.y - frame.clouds.x"));
+ // frame.clouds.x = CLOUD_BASE - camera height: y_world - CLOUD_BASE however high the camera.
+ for cam in [64.0f32, 210.0, 1000.0] {
+ let world_y = 230.0f32;
+ let rel = world_y - cam;
+ let base_rel = CLOUD_BASE - cam;
+ assert!((rel - base_rel - (world_y - CLOUD_BASE)).abs() < 1e-3);
+ }
+ }
+}
--
2.54.0 (Apple Git-157)
+209 -87
View File
@@ -1,24 +1,37 @@
#!/usr/bin/env python3
"""Графический полигон HD (#44): фиксированные сцены, сравнение кадров, мерцание и GPU-бюджет.
"""Графический полигон HD (#44, #71): фиксированные сцены, сравнение кадров, мерцание и GPU-бюджет.
python3 tools/hd_bench.py --list # сцены и их параметры
python3 tools/hd_bench.py # все сцены -> run-home-bench/out/<время>/
python3 tools/hd_bench.py --only polygon_noon # одна сцена (годится и при занятой игре)
python3 tools/hd_bench.py --env HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off
python3 tools/hd_bench.py --baseline run-home-bench/out/before --out run-home-bench/out/after
python3 tools/hd_bench.py --no-freeze # живой замер производительности: время и погода идут
Каждый прогон: свежая копия миров Polygon и Hills из run-home/saves в run-home-bench/home, мод airdef,
игра офскрин на сокете run-home-bench/c.sock (одна за раз; чужой игре run-home/ctl.sock не мешаем).
Для каждой сцены: время суток, ясная погода, телепорт, пауза на устаканивание, кадр, ещё один кадр
(мерцание), `bench 4` (GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1.
Для каждой сцены: время и погода заморожены (`gamerule advance_time/advance_weather false`), время суток,
ясная погода, телепорт, пауза на устаканивание, подряд --stills кадров (по умолчанию 3), прогрев, `bench 4`
(GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1.
Итог: report.md и results.json в папке вывода.
Анимация (#71). Игровое время и погоду мы замораживаем, но вода (волны, HD `water.y`) и листва (ветер,
`wind_seconds`) идут по часам стены, поэтому два прогона одного бинарника разные и без изменений кода.
Поэтому кадры не сравниваются целиком: по кадрам одного прогона пиксель, у которого размах значений между
кадрами больше --motion-level (16 из 255), считается движущимся (вода, облака, листва). Такие пиксели не
участвуют в сравнении с базой (ни в одном из двух прогонов). Мерцание считается по остальным пикселям:
размах между кадрами больше --flicker-level (6 из 255) и не больше --motion-level. Для движущейся части
выводится отдельная средняя разница, только для информации. Картинка `<сцена>_motion.png` показывает маску.
Коды выхода: 0 — всё в порогах и бюджете; 1 — есть превышение; 2 — ошибка запуска или аргументов.
Только стандартная библиотека; при установленном Pillow сравнение быстрее.
Только стандартная библиотека; при установленном Pillow декодирование PNG быстрее.
"""
import argparse
import datetime
import hashlib
import itertools
import json
import operator
import os
import re
import shutil
@@ -38,9 +51,12 @@ HOME = BENCH / "home" # HORIZON_HOME одного прогона, пересо
CTL = ROOT / "horizon" / "target" / "fast" / "horizon-ctl"
SAVES = ROOT / "run-home" / "saves"
OTHER_SOCK = "run-home/ctl.sock" # чужая игра (другой агент): только читаем, не трогаем
DIFF_LEVEL = 20 # порог «заметной» разницы по каналу, из 255
DIFF_LEVEL = 20 # порог «заметной» разницы по каналу, из 255 (сравнение с базой)
DIFF_GAIN = 6 # усиление разницы в diff-картинках
MOTION_LEVEL = 16 # размах значения между кадрами одного прогона выше этого — движение (вода, облака, листва)
FLICKER_LEVEL = 6 # размах выше этого на статичных пикселях — мерцание
TIMING_RE = re.compile(r"shader pack GPU time per pass .*?: (.*) = ([\d.]+)\s*$")
FREEZE_RULES = ("advance_time", "advance_weather") # имена игровых правил в horizon-client-core/gamerules.rs
@dataclass
@@ -173,6 +189,14 @@ def stop_game():
SOCK.unlink()
def file_sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
# --- кадры: PNG без Pillow и с ним -------------------------------------------------------------
def _paeth(a, b, c):
@@ -248,51 +272,103 @@ def write_png_rgb(path, w, h, rgb):
+ chunk(b"IEND", b""))
try: # необязательно: быстрее декодирование и сравнение
from PIL import Image, ImageChops, ImageStat
try: # необязательно: быстрее декодирование
from PIL import Image
except ImportError: # pragma: no cover
Image = None
def compare_frames(a, b, diff_out=None):
"""Средняя разница по каналам (0..255) и доля пикселей с разницей > DIFF_LEVEL, в %."""
def load_rgb(path):
if Image is not None:
A = Image.open(a).convert("RGB")
B = Image.open(b).convert("RGB")
if A.size != B.size:
raise BenchError(f"размеры кадров разные: {A.size} и {B.size}")
d = ImageChops.difference(A, B)
mean = sum(ImageStat.Stat(d).mean) / 3
r, g, bl = d.split()
mx = ImageChops.lighter(ImageChops.lighter(r, g), bl)
hist = mx.histogram()
total = A.size[0] * A.size[1]
pct = 100.0 * sum(hist[DIFF_LEVEL + 1:]) / total
if diff_out:
d.point(lambda v: min(255, v * DIFF_GAIN)).save(diff_out)
return {"mean": mean, "pct": pct, "size": list(A.size)}
wa, ha, pa = read_png_rgb(a)
wb, hb, pb = read_png_rgb(b)
if (wa, ha) != (wb, hb):
raise BenchError(f"размеры кадров разные: {wa}x{ha} и {wb}x{hb}")
total_abs = 0
over = 0
diff = bytearray(len(pa)) if diff_out else None
for i in range(0, len(pa), 3):
dr = abs(pa[i] - pb[i])
dg = abs(pa[i + 1] - pb[i + 1])
db = abs(pa[i + 2] - pb[i + 2])
total_abs += dr + dg + db
if max(dr, dg, db) > DIFF_LEVEL:
over += 1
if diff is not None:
diff[i] = min(255, dr * DIFF_GAIN)
diff[i + 1] = min(255, dg * DIFF_GAIN)
diff[i + 2] = min(255, db * DIFF_GAIN)
im = Image.open(path).convert("RGB")
return im.size[0], im.size[1], im.tobytes()
return read_png_rgb(path)
def load_frames(paths):
"""(w, h, [bytes RGB каждого кадра]); размеры должны совпадать."""
w = h = None
rgbs = []
for p in paths:
pw, ph, px = load_rgb(p)
if w is None:
w, h = pw, ph
elif (pw, ph) != (w, h):
raise BenchError(f"размеры кадров разные: {w}x{h} и {pw}x{ph} ({Path(p).name})")
rgbs.append(px)
return w, h, rgbs
def shot_frames(folder, name, n=None):
"""Кадры сцены по порядку: <имя>.png, <имя>_still2.png, ... до n или пока файлы есть."""
paths = []
while n is None or len(paths) < n:
k = len(paths) + 1
p = Path(folder) / (f"{name}.png" if k == 1 else f"{name}_still{k}.png")
if not p.exists():
break
paths.append(p)
return paths
# --- сравнение кадров с учётом анимации (#71) ---------------------------------------------------
def _per_pixel_max(ch):
"""Из байтов по каналам (RGB, по 3 на пиксель) — максимум по каналам на пиксель."""
return bytes(map(max, ch[0::3], ch[1::3], ch[2::3]))
def _expand3(mask):
"""Маска на пиксель -> маска на байт (по 3 на пиксель)."""
m = bytearray(3 * len(mask))
m[0::3] = mask
m[1::3] = mask
m[2::3] = mask
return m
def motion_range(rgbs):
"""Размах значения каждого пикселя между кадрами одного прогона (максимум по каналам, 0..255)."""
lo = hi = rgbs[0]
for f in rgbs[1:]:
lo = bytes(map(min, lo, f))
hi = bytes(map(max, hi, f))
return _per_pixel_max(bytes(map(operator.sub, hi, lo)))
def flicker_stats(rng, w, h, n_frames, motion_level, flicker_level):
npx = w * h
flick = sum(1 for r in rng if flicker_level < r <= motion_level)
moving = sum(1 for r in rng if r > motion_level)
return {"pct": 100.0 * flick / npx, "animated_pct": 100.0 * moving / npx,
"frames": n_frames, "size": [w, h]}
def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_out=None, motion_out=None):
"""Первый кадр текущего прогона против первого кадра базы. Движущиеся пиксели (в любом из двух
прогонов) в сравнении не участвуют: mean и pct — только по статичным; для движущихся — отдельная
средняя разница (информация, не порог)."""
npx = w * h
static = bytes(1 if (a <= motion_level and b <= motion_level) else 0 for a, b in zip(cur_rng, base_rng))
d = bytes(map(abs, map(operator.sub, cur_rgb[0], base_rgb[0])))
dpix = _per_pixel_max(d)
n_static = sum(static)
n_moving = npx - n_static
mean = sum(itertools.compress(d, _expand3(static))) / (3.0 * n_static) if n_static else 0.0
over = sum(1 for p, s in zip(dpix, static) if s and p > DIFF_LEVEL)
moving_mean = (sum(itertools.compress(d, _expand3(bytes(1 - s for s in static)))) / (3.0 * n_moving)
if n_moving else 0.0)
if diff_out:
write_png_rgb(diff_out, wa, ha, bytes(diff))
npx = wa * ha
return {"mean": total_abs / (3.0 * npx), "pct": 100.0 * over / npx, "size": [wa, ha]}
img = bytearray(min(255, v * DIFF_GAIN) for v in d)
for i, s in enumerate(static):
if not s: # движущиеся пиксели — синим, чтобы не путать с регрессией
j = 3 * i
img[j], img[j + 1], img[j + 2] = 0, 0, 80
write_png_rgb(diff_out, w, h, bytes(img))
if motion_out:
write_png_rgb(motion_out, w, h, bytes(_expand3(bytes(0 if s else 255 for s in static))))
return {"mean": mean, "pct": 100.0 * over / npx, "animated_pct": 100.0 * n_moving / npx,
"animated_mean": moving_mean, "size": [w, h]}
# --- тайминги -----------------------------------------------------------------------------------
@@ -345,19 +421,26 @@ def bench_gpu_avg(reply):
# --- сцена ------------------------------------------------------------------------------------
def run_shot(shot, out, settle, bench_secs, timing):
def run_shot(shot, out, settle, bench_secs, timing, stills, gap, warmup, freeze):
x, y, z, yaw, pitch = shot.pose
if freeze: # игровое время и погода стоят: облачные часы и погода не уходят между снимками
for rule in FREEZE_RULES:
ctl("command", f"gamerule {rule} false")
ctl("command", f"time set {shot.time}")
ctl("command", "weather clear")
ctl("command", f"tp @s {x} {y} {z} {yaw} {pitch}")
ctl("wait-ready", 120, timeout=150)
time.sleep(settle)
first = out / f"{shot.name}.png"
ctl("screenshot", str(first.resolve()))
time.sleep(0.5)
second = out / f"{shot.name}_still2.png"
ctl("screenshot", str(second.resolve()))
result = {"frame": first.name, "still2": second.name, "gpu_avg_ms": None, "passes": {},
frames = []
for k in range(stills):
if k:
time.sleep(gap)
path = out / (f"{shot.name}.png" if k == 0 else f"{shot.name}_still{k + 1}.png")
ctl("screenshot", str(path.resolve()))
frames.append(path.name)
if warmup > 0: # прогрев перед замером, не попадает в отчёт
ctl("bench", warmup, timeout=warmup + 60)
result = {"frame": frames[0], "frames": frames, "gpu_avg_ms": None, "passes": {},
"pass_total_ms": None, "bench_json": None}
offset = LOG.stat().st_size if LOG.exists() else 0
reply = ctl("bench", bench_secs, timeout=bench_secs + 60)
@@ -397,43 +480,53 @@ def judge(entry, args):
if gpu is None and not entry.get("error"):
reasons.append("нет GPU-таймингов (bench)")
fl = entry.get("flicker")
if fl and fl["pct"] > args.max_flicker:
reasons.append(f"мерцание {fl['pct']:.2f}% > {args.max_flicker:g}%")
if fl and args.max_flicker is not None and fl["pct"] > args.max_flicker:
reasons.append(f"мерцание статичных пикселей {fl['pct']:.2f}% > {args.max_flicker:g}%")
base = entry.get("vs_baseline")
if base:
if base.get("error"):
reasons.append(base["error"])
else:
if base["mean"] > args.max_mean:
reasons.append(f"разница с базой {base['mean']:.2f} > {args.max_mean:g}")
reasons.append(f"разница с базой (статика) {base['mean']:.2f} > {args.max_mean:g}")
if base["pct"] > args.max_pct:
reasons.append(f"пикселей >20/255 {base['pct']:.2f}% > {args.max_pct:g}%")
reasons.append(f"статичных пикселей >20/255 {base['pct']:.2f}% > {args.max_pct:g}%")
return reasons
def write_report(out, entries, args, env_extra, size, baseline):
def write_report(out, entries, args, env_extra, size, baseline, meta):
lines = []
add = lines.append
add(f"# HD-полигон: {out.name}")
add("")
add(f"- коммит: `{git_rev()}`, дата: {datetime.datetime.now():%Y-%m-%d %H:%M}")
add(f"- движок: `{meta['game']}`, sha256 `{meta['game_sha256'][:16]}`; коммит мода (не движка): `{git_rev()}`")
add(f"- ресурсы: `{meta['resources']}`")
add(f"- дата: {datetime.datetime.now():%Y-%m-%d %H:%M}")
add(f"- разрешение кадра: {size[0]}x{size[1]}" if size else "- разрешение кадра: ?")
add(f"- окружение игры: " + (", ".join(f"`{k}={v}`" for k, v in sorted(env_extra.items())) or "по умолчанию"))
add(f"- бюджет GPU: {args.budget_ms:g} мс (bench {args.bench_secs:g} с), мерцание <= {args.max_flicker:g}%")
add("- время и погода: " + ("заморожены (`gamerule advance_time/advance_weather false`)" if meta["freeze"]
else "идут (`--no-freeze`, живой замер)"))
add(f"- кадров на сцену: {meta['stills']} через {meta['still_gap']:g} с; прогрев {meta['warmup_secs']:g} с")
add(f"- движение: размах между кадрами > {meta['motion_level']}/255 (вода, облака, листва) не сравнивается с базой; "
f"мерцание: статичные пиксели с размахом > {meta['flicker_level']}/255")
add("- окружение игры: " + (", ".join(f"`{k}={v}`" for k, v in sorted(env_extra.items())) or "по умолчанию"))
add(f"- бюджет GPU: {args.budget_ms:g} мс (bench {args.bench_secs:g} с), мерцание: "
+ (f"<= {args.max_flicker:g}%" if args.max_flicker is not None else "только в отчёте (порог не задан)"))
if baseline:
add(f"- сравнение с: `{baseline}` (средняя разница <= {args.max_mean:g}, пикселей >20/255 <= {args.max_pct:g}%)")
add(f"- сравнение с: `{baseline}` (статичные пиксели: средняя разница <= {args.max_mean:g}, "
f"пикселей >20/255 <= {args.max_pct:g}%)")
add("")
add("| сцена | мир | время | GPU avg, мс | бюджет | мерцание %>20 | к базе: mean | к базе: %>20 | итог |")
add("|---|---|---|---|---|---|---|---|---|")
add("| сцена | мир | время | GPU avg, мс | бюджет | мерцание % | движение % | к базе (статика): mean | %>20 | итог |")
add("|---|---|---|---|---|---|---|---|---|---|")
for e in entries:
gpu = f"{e['gpu_avg_ms']:.2f}" if e.get("gpu_avg_ms") is not None else "-"
fl = f"{e['flicker']['pct']:.2f}" if e.get("flicker") else "-"
mv = f"{e['flicker']['animated_pct']:.2f}" if e.get("flicker") else "-"
b = e.get("vs_baseline") or {}
bm = f"{b['mean']:.2f}" if "mean" in b else "-"
bp = f"{b['pct']:.2f}" if "pct" in b else "-"
status = "OK" if not e["fail"] else "FAIL: " + "; ".join(e["fail"])
budget = "да" if e.get("gpu_avg_ms") is not None and e["gpu_avg_ms"] <= args.budget_ms else ("нет" if e.get("gpu_avg_ms") is not None else "-")
add(f"| {e['name']} | {e['world']} | {e['time']} | {gpu} | {budget} | {fl} | {bm} | {bp} | {status} |")
add(f"| {e['name']} | {e['world']} | {e['time']} | {gpu} | {budget} | {fl} | {mv} | {bm} | {bp} | {status} |")
passes = []
for e in entries:
for p in e.get("passes", {}):
@@ -458,7 +551,10 @@ def write_report(out, entries, args, env_extra, size, baseline):
add(f"![{e['name']}]({e['frame']})")
if e.get("diff_image"):
add("")
add(f"Разница с базой (x{DIFF_GAIN}): ![diff]({e['diff_image']})")
add(f"Разница с базой (x{DIFF_GAIN}, синим — движущиеся пиксели, не сравниваются): ![diff]({e['diff_image']})")
if e.get("motion_image"):
add("")
add(f"Движущиеся пиксели (белым): ![motion]({e['motion_image']})")
if e.get("warning"):
add("")
add(f"> {e['warning']}")
@@ -477,14 +573,26 @@ def parse_args(argv):
p.add_argument("--env", action="append", default=[], metavar="КЛЮЧ=ЗНАЧЕНИЕ", help="переменная для игры, например HORIZON_HD_TAA=0")
p.add_argument("--budget-ms", type=float, default=18.0, help="бюджет GPU на кадр, мс (по умолчанию 18)")
p.add_argument("--bench-secs", type=float, default=4.0, help="сколько секунд bench (по умолчанию 4)")
p.add_argument("--settle", type=float, default=4.0, help="пауза после телепорта, с (по умолчанию 4)")
p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы с базой, 0..255")
p.add_argument("--max-pct", type=float, default=5.0, help="порог пикселей >20/255 с базой, %%")
p.add_argument("--max-flicker", type=float, default=5.0, help="порог мерцания (два кадра подряд), %%")
p.add_argument("--settle", type=float, default=12.0, help="пауза после телепорта, с (по умолчанию 12: за 4 с TAA и экспозиция ещё сходятся, мерцание 20%% -> 6%%)")
p.add_argument("--stills", type=int, default=3, help="кадров подряд на сцену, не меньше 2 (по умолчанию 3)")
p.add_argument("--still-gap", type=float, default=0.5, help="пауза между кадрами, с (по умолчанию 0.5)")
p.add_argument("--warmup-secs", type=float, default=1.0, help="прогрев перед замером GPU, с; не в отчёте (по умолчанию 1)")
p.add_argument("--motion-level", type=int, default=MOTION_LEVEL,
help=f"размах между кадрами выше этого (из 255) — движение, не сравнивается с базой (по умолчанию {MOTION_LEVEL})")
p.add_argument("--flicker-level", type=int, default=FLICKER_LEVEL,
help=f"размах статичного пикселя выше этого (из 255) — мерцание (по умолчанию {FLICKER_LEVEL})")
p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы статичных пикселей с базой, 0..255")
p.add_argument("--max-pct", type=float, default=5.0, help="порог статичных пикселей >20/255 с базой, %%")
p.add_argument("--max-flicker", type=float, default=None, help="порог мерцания статичных пикселей, %%; без него мерцание только в отчёте (порог пока не откалиброван двумя одинаковыми прогонами)")
p.add_argument("--no-freeze", action="store_true",
help="не замораживать игровое время и погоду (живой замер производительности, не снимки)")
p.add_argument("--no-pass-timing", action="store_true", help="не включать HORIZON_SHADER_PACK_TIMING")
p.add_argument("--game", help="horizon-client (по умолчанию как в tools/airdef.py)")
p.add_argument("--resources", help="подготовленные ресурсы Minecraft 26.3")
return p.parse_args(argv)
args = p.parse_args(argv)
if args.stills < 2:
p.error("--stills: нужно не меньше 2 кадров, иначе нет ни мерцания, ни движения")
return args
def main(argv=None):
@@ -518,6 +626,14 @@ def main(argv=None):
game = Path(args.game) if args.game else airdef.built_game()
resources = Path(args.resources) if args.resources else airdef.resources()
try:
game_sha = file_sha256(game)
except OSError:
game_sha = "?" * 64
meta = {"game": str(game), "game_sha256": game_sha, "resources": str(resources),
"freeze": not args.no_freeze, "stills": args.stills, "still_gap": args.still_gap,
"warmup_secs": args.warmup_secs, "motion_level": args.motion_level,
"flicker_level": args.flicker_level}
timing = not args.no_pass_timing
env = game_env(env_extra, timing)
prepare_home()
@@ -537,23 +653,28 @@ def main(argv=None):
for s in group:
entry = {**asdict(s), "frame": f"{s.name}.png"}
try:
entry.update(run_shot(s, out, args.settle, args.bench_secs, timing))
fl = compare_frames(out / f"{s.name}.png", out / f"{s.name}_still2.png")
entry["flicker"] = fl
entry.update(run_shot(s, out, args.settle, args.bench_secs, timing, args.stills,
args.still_gap, args.warmup_secs, not args.no_freeze))
w, h, rgbs = load_frames(shot_frames(out, s.name, args.stills))
rng = motion_range(rgbs)
entry["flicker"] = flicker_stats(rng, w, h, len(rgbs), args.motion_level, args.flicker_level)
if baseline and not entry.get("error"):
base_paths = shot_frames(baseline, s.name) # все кадры базы, сколько есть
if not base_paths:
entry["vs_baseline"] = {"error": f"в базе нет {s.name}.png"}
else:
bw, bh, brgbs = load_frames(base_paths)
if (bw, bh) != (w, h):
raise BenchError(f"размеры кадров разные: база {bw}x{bh}, прогон {w}x{h}")
brng = motion_range(brgbs)
diff_name, motion_name = f"{s.name}_diff.png", f"{s.name}_motion.png"
entry["vs_baseline"] = compare_runs(rgbs, rng, brgbs, brng, w, h, args.motion_level,
out / diff_name, out / motion_name)
entry["vs_baseline"]["base_frames"] = len(brgbs)
entry["diff_image"] = diff_name
entry["motion_image"] = motion_name
except BenchError as e:
entry["error"] = str(e)
if baseline and not entry.get("error"):
base_png = baseline / f"{s.name}.png"
if base_png.exists():
try:
diff_name = f"{s.name}_diff.png"
m = compare_frames(base_png, out / f"{s.name}.png", out / diff_name)
entry["vs_baseline"] = m
entry["diff_image"] = diff_name
except BenchError as e:
entry["vs_baseline"] = {"error": str(e)}
else:
entry["vs_baseline"] = {"error": f"в базе нет {base_png.name}"}
entry["fail"] = judge(entry, args)
entries.append(entry)
stop_game()
@@ -567,15 +688,16 @@ def main(argv=None):
break
if size is None and entries and entries[0].get("flicker"):
size = entries[0]["flicker"]["size"]
results = {"out": str(out), "env": env_extra, "budget_ms": args.budget_ms,
results = {"out": str(out), "env": env_extra, "budget_ms": args.budget_ms, "meta": meta,
"baseline": str(baseline) if baseline else None, "shots": entries}
(out / "results.json").write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n")
write_report(out, entries, args, env_extra, size, str(baseline) if baseline else None)
write_report(out, entries, args, env_extra, size, str(baseline) if baseline else None, meta)
failed = [e for e in entries if e["fail"]]
for e in entries:
print(f"{e['name']:22} GPU {e.get('gpu_avg_ms') if e.get('gpu_avg_ms') is None else round(e['gpu_avg_ms'], 2)} мс "
f"мерцание {e['flicker']['pct']:.2f}%" if e.get("flicker") else f"{e['name']:22} {e.get('error', '')}")
f"мерцание {e['flicker']['pct']:.2f}% движение {e['flicker']['animated_pct']:.2f}%" if e.get("flicker")
else f"{e['name']:22} {e.get('error', '')}")
for r in e["fail"]:
print(f" FAIL: {r}")
print(f"отчёт: {out / 'report.md'}")