Compare commits

..

3 Commits

44 changed files with 6950 additions and 156 deletions
+1
View File
@@ -5,3 +5,4 @@ airdef-mod/target/
__pycache__/
/out/
.DS_Store
run-home-bench/
+18 -1
View File
@@ -18,6 +18,13 @@ pub enum Terrain {
Hills { base: i32, amp: f64, period: f64 },
}
/// The land as blocks: solid at and below the column's height (what the game would answer `render.sections` with).
impl airdef::sim::world::WorldSource for Terrain {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
Some(y < self.height(x, z))
}
}
impl Terrain {
pub fn height(self, x: i32, z: i32) -> i32 {
match self {
@@ -98,6 +105,9 @@ pub struct Report {
/// Missiles fired by air defence (sum of the systems' `fired`).
pub fired: u32,
pub ground_queries: u64,
/// The block cache's counters (issue #43) and the drones that struck the land (issue #42).
pub world: String,
pub crashes: u32,
pub ground_batches: u64,
pub tick_p50: Duration,
pub tick_p95: Duration,
@@ -163,6 +173,8 @@ pub fn run(sc: &Scenario) -> Report {
let t = Instant::now();
sim.step(&mut ground, None, &mut out);
let wanted = ground.take_wanted();
let sections = ground.world.take_wanted(now);
ground.world.serve(&sc.terrain, sections, now);
let dt = t.elapsed();
times.push(dt);
if planning {
@@ -212,6 +224,8 @@ pub fn run(sc: &Scenario) -> Report {
r.hits = sim.hits;
r.fired = sim.sams.iter().map(|s| s.fired).sum();
r.raids_ordered = ordered.iter().filter(|o| **o).count();
r.world = ground.world.text();
r.crashes = sim.crashes;
r.raids_ended = ended.iter().filter(|o| **o).count();
r.all_ended = r.raids_ended == sc.raids.len();
r.tick_total = times.iter().sum();
@@ -253,7 +267,8 @@ impl Report {
planning: {pl:.1} ms over {pt} ticks\n\
ground queries: {gq} in {gb} batches\n\
non-finite positions: {nan}\n\
stuck trucks: {stuck:?} (busy truck-ticks {busy})\n",
stuck trucks: {stuck:?} (busy truck-ticks {busy})\n\
crashes into the land: {crashes}; {world}\n",
name = self.name,
seed = self.seed,
units = self.units,
@@ -276,6 +291,8 @@ impl Report {
nan = self.nan_positions,
stuck = self.stuck_trucks,
busy = self.truck_busy_ticks,
crashes = self.crashes,
world = self.world,
)
}
pub fn json(&self) -> String {
+60 -6
View File
@@ -330,6 +330,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 {
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);
@@ -541,8 +551,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 +642,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 +785,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 +980,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> {
@@ -1061,14 +1082,38 @@ impl Mod for AirDef {
}
let now = self.sim.tick;
self.ground.answer(render::ground(), now);
// The blocks themselves (issues #42, #43): what changed, and the sections asked for last tick.
let changes = render::world_changes();
self.ground.world.changed(changes.epoch, changes.overflow, &changes.sections);
let sections = render::sections()
.into_iter()
.map(|s| sim::world::SectionAnswer {
sec: (s.x, s.y, s.z),
fill: match s.fill {
render::SectionFill::Unknown => sim::world::Fill::Unknown,
render::SectionFill::Empty => sim::world::Fill::Empty,
render::SectionFill::Full => sim::world::Fill::Full,
render::SectionFill::Mixed => match <[u8; 512]>::try_from(s.bits) {
Ok(bits) => sim::world::Fill::Mixed(Box::new(bits)),
Err(_) => sim::world::Fill::Unknown,
},
},
})
.collect();
self.ground.world.answer(sections, now);
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);
let wanted = self.ground.take_wanted();
if !wanted.is_empty() {
render::query_ground(&wanted);
}
let sections = self.ground.world.take_wanted(now);
if !sections.is_empty() {
render::query_sections(render::Policy::Collision, &sections);
}
if let Some(mut r) = self.replay.take() {
r.tick(&self.sim, self.listener, &mut out);
if r.done {
@@ -1085,6 +1130,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));
@@ -1123,7 +1170,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));
@@ -1386,7 +1434,7 @@ impl Mod for AirDef {
["repair"] => {
let n = self.sim.repair();
self.dirty = true;
Some(format!("Отремонтировано комплексов: {n}; объекты восстановлены"))
Some(format!("Отремонтировано комплексов: {n} (корпус и все узлы: РЛС, привод, пусковая, орудие, ходовая, связь); объекты восстановлены"))
}
["clear"] => {
self.sim.clear();
@@ -1405,6 +1453,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;
@@ -1451,12 +1503,14 @@ impl Mod for AirDef {
Some(format!("Эффекты: {}", match *v { "off" => "выкл", "hd" => "HD", _ => "обычные" }))
}
["stats"] => Some(format!(
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}",
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}, разбились о препятствия: {}; {}",
self.sim.drones.iter().filter(|d| d.alive()).count(),
self.sim.missiles.len(),
self.sim.sams.len(),
self.sim.kills,
self.sim.hits
self.sim.hits,
self.sim.crashes,
self.ground.world.text()
)),
_ => return Err(HELP.to_string()),
};
+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).
+13 -3
View File
@@ -107,6 +107,7 @@ pub fn title(rec: &Recording, i: usize) -> String {
EventKind::Hit { kind, .. } => format!("Прилёт: «{}»", kind.spec().name),
EventKind::SamLost { kind, index, .. } => format!("{} №{} уничтожен", kind.name(), index + 1),
EventKind::Split { .. } => "«Орешник»: разделение боевых блоков".into(),
EventKind::Crash { kind, .. } => format!("«{}» разбивается о препятствие", kind.spec().name),
}
}
@@ -122,6 +123,7 @@ pub fn row(rec: &Recording, i: usize) -> String {
EventKind::Hit { kind, .. } => format!("{}: прилёт", kind.spec().name),
EventKind::SamLost { kind, index, .. } => format!("{}-{} уничтожен", kind.name(), index + 1),
EventKind::Split { .. } => "Орешник: разделение".into(),
EventKind::Crash { kind, .. } => format!("{}: о рельеф", kind.spec().name),
};
format!("{} {what}", clock(e.at as f64))
}
@@ -152,6 +154,7 @@ fn interest(rec: &Recording, i: usize) -> f64 {
power as f64 * 1.5 + if object.is_some() { 6.0 } else if sam.is_some() { 5.0 } else { 0.0 }
}
EventKind::SamLost { .. } => 12.0,
EventKind::Crash { power, .. } => power as f64,
EventKind::Split { drone } => {
// The bus's split, not each block's.
let first = rec.events.iter().position(|o| matches!(o.kind, EventKind::Split { .. }));
@@ -168,6 +171,7 @@ fn clip(rec: &Recording, i: usize) -> Clip {
EventKind::Hit { .. } => (160.0, 60.0),
EventKind::SamLost { .. } => (80.0, 90.0),
EventKind::Split { .. } => (40.0, 170.0),
EventKind::Crash { .. } => (120.0, 60.0),
};
Clip { event: i, from: (at - before).max(0.0), to: (at + after).min(rec.len as f64) }
}
@@ -293,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 {
@@ -352,7 +361,7 @@ impl Player {
for e in rec.events.iter().filter(|e| (self.heard..self.t).contains(&(e.at as f64))) {
let event = match e.kind {
EventKind::Kill { .. } => "airdef:blast.air",
EventKind::Hit { power, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][(power as usize).min(4)],
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][(power as usize).min(4)],
EventKind::SamLost { .. } => "airdef:blast.big",
EventKind::Split { .. } => "airdef:boom.sonic",
};
@@ -412,7 +421,7 @@ impl Player {
}
}
}
EventKind::Hit { drone, .. } => {
EventKind::Hit { drone, .. } | EventKind::Crash { drone, .. } => {
if t < te - 50.0 {
if let Some((eye, look)) = chase(drone, 12.0, 4.0) {
return (eye, look, 55.0, 0);
@@ -672,7 +681,7 @@ impl Player {
s.push(line((x - 2.5, y - 2.5), (x + 2.5, y + 2.5), 1.2, 0xFFFFE040));
s.push(line((x + 2.5, y - 2.5), (x - 2.5, y + 2.5), 1.2, 0xFFFFE040));
}
EventKind::Hit { power, .. } => {
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => {
let r = 2.0 + power as f32;
s.push(fill(x - r, y - r, r * 2.0, r * 2.0, 0xB0FF3020));
}
@@ -705,6 +714,7 @@ impl Player {
let c = match e.kind {
EventKind::Kill { .. } => 0xFFFFE040,
EventKind::Hit { .. } => 0xFFFF3020,
EventKind::Crash { .. } => 0xFF903020,
EventKind::SamLost { .. } => 0xFFFF00FF,
EventKind::Split { .. } => 0xFFFF9020,
};
+286 -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];
@@ -28,7 +146,72 @@ impl MissileKind {
}
}
/// A strike that ends farther than this from its aim point misses (the lethal radius of a point target).
pub(super) const MISS_RADIUS: f64 = 3.0;
/// A system's hull, as a cylinder: its radius (× scale) and how far above the ground it reaches (× scale).
const HULL_RADIUS: f64 = 2.2;
const HULL_TOP: f64 = 3.0;
impl DroneKind {
/// Strike accuracy: the CEP (blocks; half the strikes land within it, the scatter is Rayleigh)
/// and the share of strikes with a rare gross navigation fault, which lands beyond `MISS_RADIUS`.
pub(super) fn accuracy(self) -> (f64, f64) {
match self {
DroneKind::Shahed => (1.2, 0.03),
DroneKind::Geran3 => (1.0, 0.03),
DroneKind::Lancet => (0.8, 0.02),
DroneKind::Gerbera => (1.5, 0.04),
DroneKind::Kh101 => (1.3, 0.05),
DroneKind::Kalibr => (1.3, 0.05),
DroneKind::Kh22 => (1.6, 0.06),
DroneKind::Zircon => (1.5, 0.06),
DroneKind::Iskander => (1.4, 0.06),
DroneKind::Kinzhal => (1.5, 0.06),
// The bus and its blocks: the blocks are aimed by the split, so only the bus's own error applies.
DroneKind::Oreshnik | DroneKind::OreshnikBlock | DroneKind::OreshnikSub => (1.6, 0.08),
}
}
}
impl Sim {
/// The aim error of a new strike: drawn once from the seed, the drone keeps it for its whole flight.
pub(super) fn aim_error(&mut self, kind: DroneKind) -> DVec3 {
let (cep, gross) = kind.accuracy();
let angle = self.rng.f() * std::f64::consts::TAU;
let r = if self.rng.chance(gross) {
MISS_RADIUS + cep * (0.5 + 2.5 * self.rng.f())
} else {
// Rayleigh with its median at the CEP.
let u = 1.0 - self.rng.f();
cep * (-2.0 * u.ln() / (2.0 * std::f64::consts::LN_2)).sqrt()
};
DVec3::new(angle.cos() * r, 0.0, angle.sin() * r)
}
/// An armed warhead reached `pos` (its last step was `vel`): a system whose hull it swept through is
/// destroyed at once, whatever the blast would have done to it. A near miss is only splash.
pub(super) fn direct_hit(&mut self, pos: DVec3, vel: DVec3, tnt: u32, out: &mut Out) {
if !self.damage || tnt == 0 {
return;
}
let from = pos - vel;
for i in 0..self.sams.len() {
let sam = &self.sams[i];
if !sam.alive() {
continue;
}
let s = sam.kind.spec().scale;
let hit = (0..=8).any(|k| {
let p = from.lerp(pos, k as f64 / 8.0);
let across = DVec3::new(p.x - sam.pos.x, 0.0, p.z - sam.pos.z).length();
across <= HULL_RADIUS * s && p.y >= sam.pos.y - 1.0 && p.y <= sam.pos.y + HULL_TOP * s
});
if hit {
self.destroy(i, out);
}
}
}
/// A blast of `power` (0 to 4) at `pos`: the systems and objects round it take damage.
pub(super) fn blast_damage(&mut self, pos: DVec3, power: usize, out: &mut Out) {
let (reach, hit) = (REACH[power], HIT[power]);
@@ -47,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);
@@ -79,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;
@@ -159,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 {
@@ -171,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;
@@ -191,6 +474,7 @@ impl Sim {
}
// The wrecks' ammunition still to cook off goes with them.
self.later.clear();
self.fires.extinguish();
n
}
+223
View File
@@ -0,0 +1,223 @@
//! 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 burning at once.
pub const MAX: usize = 12;
/// 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,
}
#[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,
});
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);
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 {
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.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);
}
}
+196 -44
View File
@@ -10,6 +10,7 @@ use glam::{DQuat, DVec3};
use std::collections::HashMap;
mod damage;
pub mod fires;
mod raid;
pub mod record;
mod sight;
@@ -19,9 +20,15 @@ mod logistics;
mod orders;
mod units;
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};
@@ -211,6 +218,8 @@ 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 blocks themselves (issues #42, #43): what stops a flight.
pub world: world::WorldCache,
}
/// Ticks between full clean-ups of a big ground cache.
@@ -251,10 +260,32 @@ impl Ground {
self.asked.clear();
std::mem::take(&mut self.wanted)
}
/// The first solid block on the way from `a` to `b` (the real blocks, not the height of the land).
pub fn sweep(&mut self, a: DVec3, b: DVec3, skip_start: bool, now: u64) -> world::Sweep {
self.world.sweep(a, b, skip_start, now)
}
/// 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);
}
/// Whether `pos` is in the land by the column's height (`below`, as `at` gave it), given what the sweep
/// of this step saw: over blocks known and open, the height only counts where its top block is known
/// to be open, a fluid's surface (not a roof or a bridge deck above the flight, nor a block unread).
pub fn ground_hit(&mut self, below: Option<f64>, pos: DVec3, offset: f64, sweep: &world::Sweep, now: u64) -> bool {
let Some(g) = below else { return false };
if pos.y > g + offset {
return false;
}
match sweep {
world::Sweep::Clear => self.world.is_known_open(pos.x.floor() as i32, g.floor() as i32 - 1, pos.z.floor() as i32, now),
_ => true,
}
}
/// Explosions change the land: forget what was known round them.
fn forget(&mut self, at: DVec3, radius: i32) {
let (cx, cz) = (at.x.floor() as i32, at.z.floor() as i32);
self.known.retain(|&(x, z), _| (x - cx).abs() > radius || (z - cz).abs() > radius);
self.world.touched(at, f64::from(radius) + 2.0);
}
}
@@ -722,9 +753,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)
}
}
@@ -1009,7 +1044,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>,
@@ -1062,6 +1104,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,
@@ -1083,12 +1129,16 @@ impl Sam {
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);
@@ -1099,9 +1149,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();
@@ -1118,7 +1165,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 { "" }),
_ => {}
}
@@ -1128,6 +1178,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 {
@@ -1172,9 +1225,15 @@ pub struct Sim {
pub tick: u64,
pub kills: u32,
pub hits: u32,
/// Drones that struck the land or a building short of their aim.
pub crashes: u32,
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.
@@ -1201,6 +1260,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>,
@@ -1225,8 +1288,11 @@ impl Sim {
tick: 0,
kills: 0,
hits: 0,
crashes: 0,
launched: 0,
damage: true,
seed: seed.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1,
truth: false,
shader: false,
hold: false,
horizons: HashMap::new(),
@@ -1241,6 +1307,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(),
}
@@ -1289,6 +1357,8 @@ 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);
@@ -1357,6 +1427,7 @@ impl Sim {
self.later.clear();
self.flares.clear();
self.lit.clear();
self.fires.extinguish();
}
/// One game tick.
@@ -1373,10 +1444,11 @@ impl Sim {
self.update_sight(ground);
self.step_lights(out);
self.step_sams(out);
self.step_missiles(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;
@@ -1394,10 +1466,14 @@ impl Sim {
// Warheads that reached their aim (for the replays): drone, kind, where, TNT; and splits.
let mut impacts: Vec<(u32, DroneKind, DVec3, u32)> = Vec::new();
let mut splits: Vec<(u32, DVec3)> = Vec::new();
// Drones that struck the land or a building short of their aim: drone, kind, where, TNT.
let mut crashes: Vec<(u32, DroneKind, DVec3, u32)> = Vec::new();
for d in &mut self.drones {
d.age += 1;
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);
// 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
@@ -1412,11 +1488,15 @@ 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);
if let Some((_, p)) = sweep.hit() {
d.pos = p;
}
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);
}
out.particles("minecraft:flame", d.pos, 0.25, DVec3::ZERO, 0.02, 2);
if below.is_some_and(|g| d.pos.y <= g + 0.3) || d.age > 600 {
if sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.3, &sweep, now) || d.age > 600 {
// The wreck's warhead may still go off where it lands.
let warhead = if spec.decoy || !self.rng.chance(0.35) { 0 } else { spec.warhead.min(2) };
boom.push((d.pos, warhead, true));
@@ -1439,13 +1519,24 @@ 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);
if let Some((_, p)) = sweep.hit() {
d.pos = p;
}
let speed = d.vel.length();
let landed = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now);
let hit = d.pos.distance(d.target) < speed.max(2.0)
|| below.is_some_and(|g| d.pos.y <= g + 0.2)
|| landed
|| (d.vel.y < 0.0 && d.pos.y < d.target.y - 1.0 && horizontal(d.target - d.pos).length() < speed * 2.0)
|| d.age > 4000;
if hit {
boom.push((d.pos, spec.warhead, false));
// Into the land or a building short of its aim: a crash, not a hit.
if landed && d.pos.distance(d.target) >= speed.max(2.0) + 6.0 {
crashes.push((d.id, d.kind, d.pos, spec.warhead));
removed.push(d.id);
continue;
}
impacts.push((d.id, d.kind, d.pos, spec.warhead));
removed.push(d.id);
self.hits += 1;
@@ -1546,10 +1637,22 @@ impl Sim {
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);
if let Some((_, p)) = sweep.hit() {
d.pos = p;
}
let at_target = d.pos.distance(d.target) < speed.max(1.5);
let in_ground = below.is_some_and(|g| d.pos.y <= g + 0.2);
let in_ground = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now);
if at_target || in_ground || d.age > 8000 {
boom.push((d.pos, spec.warhead, spec.decoy));
// Into the land or a building short of its aim: a crash, not a hit.
if in_ground && d.pos.distance(d.target) >= speed.max(1.5) + 3.0 {
if !spec.decoy {
crashes.push((d.id, d.kind, d.pos, spec.warhead));
}
removed.push(d.id);
continue;
}
if !spec.decoy {
impacts.push((d.id, d.kind, d.pos, spec.warhead));
}
@@ -1580,6 +1683,17 @@ impl Sim {
for (drone, pos) in splits {
self.log(pos, record::EventKind::Split { drone });
}
for (drone, kind, pos, tnt) in crashes {
self.crashes += 1;
let power = match tnt {
0 => 0,
1 | 2 => 1,
3..=5 => 2,
6 | 7 => 3,
_ => 4,
};
self.log(pos, record::EventKind::Crash { drone, kind, power });
}
for (drone, kind, pos, tnt) in impacts {
if let Some(sector) = self.drones.iter().find(|x| x.id == drone).map(|x| x.sector as usize) {
self.learn(sector, false);
@@ -1594,6 +1708,8 @@ impl Sim {
let object = self.targets.iter().position(|t| t.distance(pos) < 16.0);
let sam = self.sams.iter().enumerate().find(|(_, s)| s.alive() && s.pos.distance(pos) < 12.0).map(|(i, s)| (s.kind, i));
self.log(pos, record::EventKind::Hit { drone, kind, power, object, sam });
let vel = self.drones.iter().find(|x| x.id == drone).map_or(DVec3::ZERO, |x| x.vel);
self.direct_hit(pos, vel, tnt, out);
}
for (pos, warhead, small) in boom {
self.explode(pos, warhead, small, ground, player, out);
@@ -1797,18 +1913,20 @@ 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 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);
}
@@ -1830,23 +1948,27 @@ 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 tracks = &self.sams[i].tracks;
// 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) && 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.
@@ -1854,12 +1976,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)
@@ -1883,18 +2006,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 {
@@ -1910,7 +2038,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).
@@ -1933,7 +2062,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
@@ -1941,8 +2070,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;
@@ -1952,7 +2089,7 @@ impl Sim {
sam.empty = true;
ammo_waits += 1;
} else {
sam.reloading = spec.reload
sam.reloading = sam.reload_time()
}
}
let (_, rot) = sam.mount();
@@ -1979,7 +2116,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 {
@@ -1992,8 +2131,8 @@ 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);
let t = sam.track(mt).filter(|_| drone_at.contains_key(&mt)).map(|tr| tr.est(now));
let aligned = why::missile_aligned(spec.family, sam.yaw, mount, t, aim_id, mt, aim_error);
if ready && aligned {
let n = load.magazine - sam.missiles;
let (muzzle, dir) = sam.muzzle(n);
@@ -2014,7 +2153,7 @@ impl Sim {
sam.empty = true;
ammo_waits += 1;
} else {
sam.reloading = spec.reload
sam.reloading = sam.reload_time()
}
}
}
@@ -2061,10 +2200,12 @@ impl Sim {
// ------------------------------------------------ interceptors
fn step_missiles(&mut self, out: &mut Out) {
fn step_missiles(&mut self, ground: &mut Ground, out: &mut Out) {
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 sams_fit: HashMap<u32, f64> = self.sams.iter().map(|s| (s.id, s.fitness())).collect();
let mut blocked = 0u32;
let sams_fit: HashMap<u32, f64> = self.sams.iter().map(|s| (s.id, 1.0 - 0.3 * (1.0 - s.health.clamp(0.0, 1.0)))).collect();
for m in &mut self.missiles {
m.age += 1;
let spec = m.kind.spec();
@@ -2091,13 +2232,16 @@ impl Sim {
let before = m.pos;
m.vel = new * speed;
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();
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;
let rel1 = t.pos - m.pos;
let delta = rel1 - rel0;
let k = (-rel0.dot(delta) / delta.length_squared().max(1e-9)).clamp(0.0, 1.0);
if (rel0 + delta * k).length() < spec.fuse {
if (rel0 + delta * k).length() < spec.fuse && world.is_none_or(|(w, _)| k <= w) {
// A target faster than the interceptor is hard to hit.
let ratio = (spec.speed / t.vel.length().max(0.1)).min(1.0).powf(1.5);
let fit = sams_fit.get(&m.sam).copied().unwrap_or(1.0);
@@ -2107,6 +2251,13 @@ impl Sim {
continue;
}
}
if let Some((_, p)) = world {
// The land or a building is in the way: the interceptor is lost there.
m.pos = p;
blocked += 1;
done.push((m.id, None, false, m.sam));
continue;
}
if m.age > spec.life || (target.is_none() && m.age > 30) {
// Its target gone (shot down by another, or lost), or its life spent chasing one.
if target.is_none() {
@@ -2151,6 +2302,7 @@ impl Sim {
t.missed += missed;
t.expired += expired;
t.orphaned += orphaned;
t.blocked += blocked;
});
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);
+67 -1
View File
@@ -39,6 +39,8 @@ pub enum EventKind {
SamLost { sam: u32, kind: SamKind, index: usize },
/// The Oreshnik's bus or a block splitting.
Split { drone: u32 },
/// A drone's warhead went off against the land or a building short of its aim.
Crash { drone: u32, kind: DroneKind, power: u32 },
}
#[derive(Clone, Copy, Debug)]
@@ -61,6 +63,8 @@ pub struct Tally {
pub tracks_lost: u32,
/// Times a system ran dry and began waiting for a reload truck.
pub ammo_waits: u32,
/// Interceptors lost against the land or a building on their way.
pub blocked: u32,
}
impl Tally {
@@ -71,6 +75,7 @@ impl Tally {
orphaned: self.orphaned.saturating_sub(start.orphaned),
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),
}
}
/// The non-zero counts as a line, or nothing.
@@ -81,6 +86,7 @@ impl Tally {
(self.orphaned, "ракет остались без цели"),
(self.tracks_lost, "потерь трека"),
(self.ammo_waits, "ожиданий БК"),
(self.blocked, "ракет разбились о препятствие"),
]
.into_iter()
.filter(|p| p.0 > 0)
@@ -104,10 +110,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> {
@@ -163,6 +175,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,
}
}
@@ -178,6 +191,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])
}
@@ -205,6 +231,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
@@ -230,6 +265,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);
@@ -304,6 +345,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;
@@ -312,7 +378,7 @@ impl Sim {
}
let size = match e.kind {
EventKind::Kill { .. } => 3.5,
EventKind::Hit { power, .. } => [2.5, 4.5, 7.0, 10.0, 13.0][(power as usize).min(4)],
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => [2.5, 4.5, 7.0, 10.0, 13.0][(power as usize).min(4)],
EventKind::SamLost { .. } => 8.0,
EventKind::Split { .. } => 2.0,
};
+325
View File
@@ -1,6 +1,7 @@
//! Regression tests for the issues filed on Gitea (each names its issue).
use super::*;
use super::raid::Grid;
use super::damage::MISS_RADIUS;
// #1: [bug] Запросы рельефа голодают: часть направлений РЛС никогда не получает высоты
#[test]
@@ -222,3 +223,327 @@ fn drive_arrives_without_overshoot() {
assert!(left.abs() < 1e-9 && vel == 0.0);
}
}
// #49: a direct hit by an armed drone destroys the system at once, not with a dent of its health
#[test]
fn armed_strike_on_a_system_destroys_it_at_once() {
let centre = DVec3::new(0.0, 70.0, 0.0);
let hull = 2.2 * SamKind::Patriot.spec().scale;
for kind in [DroneKind::Lancet, DroneKind::Shahed] {
let mut destroyed = 0;
for seed in 1..=40 {
let mut sim = Sim::new(seed);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Patriot, centre, 0.0));
let mut ground = Ground::default();
sim.add_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::NEG_Y, centre);
let mut out = Out::default();
let mut impact = None;
for _ in 0..400 {
sim.step(&mut ground, None, &mut out);
if let Some(b) = sim.blasts.first() {
impact = Some(b.at);
break;
}
if sim.drones.is_empty() {
break;
}
}
let at = impact.expect("the strike must go off");
let inside = DVec3::new(at.x, 0.0, at.z).distance(DVec3::new(centre.x, 0.0, centre.z)) <= hull && at.y < centre.y + 3.0;
let dead = !sim.sams[0].alive();
if inside {
assert!(dead, "{kind:?} seed {seed} struck the hull at {at:?} but the system lives");
}
destroyed += dead as u32;
}
assert!(destroyed >= 30, "{kind:?}: only {destroyed}/40 direct strikes destroyed the system");
}
}
// #49: a near miss is only splash, an unarmed body is no direct hit, the top of the hull counts
#[test]
fn near_miss_and_unarmed_body_are_not_direct_hits() {
let mut sim = Sim::new(1);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
let down = DVec3::new(0.0, -2.0, 0.0);
sim.direct_hit(DVec3::new(6.0, 71.0, 0.0), down, 3, &mut Out::default());
assert!(sim.sams[0].alive(), "a near miss six blocks off destroyed the system");
sim.direct_hit(DVec3::new(0.0, 71.0, 0.0), down, 0, &mut Out::default());
assert!(sim.sams[0].alive(), "an unarmed body destroyed the system");
sim.direct_hit(DVec3::new(0.0, 73.0, 0.0), down, 1, &mut Out::default());
assert!(!sim.sams[0].alive(), "an armed warhead into the top of the hull did not destroy the system");
}
// #59: a strike misses at a small, kind-specific rate; its scatter is the kind's CEP (half land within it)
#[test]
fn strike_scatter_matches_each_kinds_cep_and_miss_rate() {
const N: u64 = 5000;
for kind in DroneKind::ALL {
let mut sim = Sim::new(7);
let (cep, gross) = kind.accuracy();
let sigma = cep / (2.0 * std::f64::consts::LN_2).sqrt();
let expected = (1.0 - gross) * (-MISS_RADIUS * MISS_RADIUS / (2.0 * sigma * sigma)).exp() + gross;
let (mut misses, mut within, mut farthest) = (0u64, 0u64, 0.0f64);
for _ in 0..N {
let e = sim.aim_error(kind);
assert_eq!(e.y, 0.0);
let r = e.length();
within += (r <= cep) as u64;
if r > MISS_RADIUS {
misses += 1;
farthest = farthest.max(r);
}
}
let rate = misses as f64 / N as f64;
let half = within as f64 / N as f64;
assert!((rate - expected).abs() < 0.015, "{kind:?}: miss rate {rate:.4}, expected {expected:.4}");
assert!((0.005..=0.20).contains(&rate), "{kind:?}: miss rate {rate:.4} is not a small share (the IRBM bus is the loosest)");
assert!((half - 0.5 * (1.0 - gross)).abs() < 0.03, "{kind:?}: {half:.3} of strikes within the CEP {cep}");
assert!(farthest < MISS_RADIUS + 4.0 * cep, "{kind:?}: a miss {farthest:.2} blocks out, far from the aim");
}
}
// #59: the drone flies to its own error point and bursts there: the miss lands near the aim, by the CEP
#[test]
fn strikes_burst_near_their_aim_within_the_kinds_cep() {
let aim = DVec3::new(0.0, 70.0, 0.0);
for kind in [DroneKind::Shahed, DroneKind::Lancet] {
let cep = kind.accuracy().0;
let mut misses = 0;
for seed in 1..=60 {
let mut sim = Sim::new(seed);
let mut ground = Ground::default();
sim.add_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::NEG_Y, aim);
let mut out = Out::default();
let mut impact = None;
for _ in 0..400 {
sim.step(&mut ground, None, &mut out);
if let Some(b) = sim.blasts.first() {
impact = Some(b.at);
break;
}
if sim.drones.is_empty() {
break;
}
}
let at = impact.expect("the strike must go off");
let off = DVec3::new(at.x - aim.x, 0.0, at.z - aim.z).length();
assert!(off < MISS_RADIUS + 4.0 * cep + 2.0, "{kind:?} seed {seed} burst {off:.2} blocks from its aim");
misses += (off > MISS_RADIUS) as u32;
}
assert!(misses <= 6, "{kind:?}: {misses}/60 strikes missed");
}
}
// ---- #42, #43: flights are swept against the blocks of the world (a synthetic world stands in for the game)
/// Blocks from a function: `Some(solid)`, or `None` where there is no chunk.
struct Blocks<F: Fn(i32, i32, i32) -> Option<bool>>(F);
impl<F: Fn(i32, i32, i32) -> Option<bool>> world::WorldSource for Blocks<F> {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
(self.0)(x, y, z)
}
}
/// Reads the sections a flight along `a` to `b` needs, as the game would answer them over a few ticks.
fn warm(ground: &mut Ground, src: &dyn world::WorldSource, a: DVec3, b: DVec3) {
for _ in 0..4 {
ground.world.sweep(a, b, false, 0);
let wanted = ground.world.take_wanted(0);
ground.world.serve(src, wanted, 0);
}
}
/// One tick of the drones with the game answering what was asked.
fn fly(sim: &mut Sim, ground: &mut Ground, src: &dyn world::WorldSource) {
sim.tick += 1;
sim.step_drones(ground, None, &mut Out::default());
let wanted = ground.world.take_wanted(sim.tick);
ground.world.serve(src, wanted, sim.tick);
}
// #42: a drone that crosses a one-block wall in a tick explodes at the wall, and that is no hit on its aim
#[test]
fn fast_drone_must_not_cross_a_one_block_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);
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;
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");
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");
assert!(sim.replays.iter().chain(sim.rec.iter()).all(|r| r.events.iter().all(|e| !matches!(e.kind, record::EventKind::Hit { .. }))));
}
// #42: a drone flying into a hill side explodes at the face of the hill
#[test]
fn drone_flying_into_a_hill_explodes_at_its_face() {
// Flat at 64 to x=50, then a slope of 1:1.
let height = |x: i32| 64 + (x - 50).max(0);
let src = Blocks(move |x, y, _| Some(y < height(x)));
for kind in [DroneKind::Shahed, DroneKind::Zircon] {
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));
sim.drones[0].flight = Flight::Dive;
for _ in 0..300 {
if sim.drones.is_empty() {
break;
}
fly(&mut sim, &mut ground, &src);
}
assert!(sim.drones.is_empty(), "{kind:?} flew on");
let at = sim.blasts.first().expect("no blast").at;
// The slope meets y=80 at x=66.
assert!(at.x > 62.0 && at.x < 67.5, "{kind:?} burst at {at:?}, not at the hill face (x~66)");
assert_eq!(sim.crashes, 1, "{kind:?}");
}
}
// #42: an interceptor cannot pass through a wall to its target, and does not kill it from behind the wall
#[test]
fn missile_cannot_pass_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();
// The target stands still just behind the wall, inside the fuse's reach.
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());
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 });
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());
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");
assert_eq!(sim.drones[0].engaged, 0, "the target stays claimed by a missile that is gone");
assert_eq!(sim.tally.blocked, 1);
assert!(sim.blasts.iter().all(|b| b.at.x < 3.5), "burst behind the wall");
}
// #42, #43: a bridge deck above the flight is no ground under it
#[test]
fn flight_under_a_bridge_is_not_a_ground_hit() {
let src = Blocks(|_, y, _| Some(y < 64 || y == 140));
let mut sim = Sim::new(1);
sim.damage = false;
let mut ground = Ground::default();
// The height map knows only the deck: 141 everywhere.
ground.answer((-5..400).map(|x| (x, 0, 141)).collect(), 0);
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, 0.0), DVec3::X, DVec3::new(2000.0, 100.0, 0.0));
warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(300.0, 100.0, 0.0));
for _ in 0..40 {
fly(&mut sim, &mut ground, &src);
}
assert_eq!(sim.drones.len(), 1, "the deck above was taken for the ground");
assert_eq!(sim.crashes, 0);
}
// #43: unknown chunks and spent budgets are never air; changes and another world void the cache
#[test]
fn unknown_is_not_clear_and_changes_invalidate() {
let mut ground = Ground::default();
let (a, b) = (DVec3::new(0.5, 70.5, 0.5), DVec3::new(30.5, 70.5, 0.5));
// Nothing read yet: the segment is unknown and the sections are asked for.
assert!(matches!(ground.world.sweep(a, b, false, 1), world::Sweep::Unknown { .. }));
let asked = ground.world.take_wanted(1);
assert!(asked.contains(&(0, 4, 0)), "{asked:?}");
// The game has no chunk there: still unknown, not air.
let nothing = Blocks(|_, _, _| None);
ground.world.serve(&nothing, asked, 2);
assert!(matches!(ground.world.sweep(a, b, false, 3), world::Sweep::Unknown { .. }));
// A wall appears in a chunk that now is loaded: found after it is read again.
let wall = Blocks(|x, _, _| Some(x == 20));
for now in [70, 71, 72] {
ground.world.sweep(a, b, false, now);
let w = ground.world.take_wanted(now);
ground.world.serve(&wall, w, now);
}
let hit = ground.world.sweep(a, b, false, 73);
assert!(matches!(hit, world::Sweep::Hit { block: [20, 70, 0], normal: [-1, 0, 0], .. }), "{hit:?}");
// The wall is broken: the game says which section changed, and the next sweep asks again.
let open = Blocks(|_, _, _| Some(false));
ground.world.changed(7, false, &[(1, 4, 0)]);
assert!(matches!(ground.world.sweep(a, b, false, 74), world::Sweep::Hit { .. }), "stale data is used until the answer");
let w = ground.world.take_wanted(74);
assert!(w.contains(&(1, 4, 0)), "{w:?}");
ground.world.serve(&open, w, 75);
assert_eq!(ground.world.sweep(a, b, false, 76), world::Sweep::Clear);
// Another world: everything goes.
ground.world.changed(8, false, &[]);
assert!(ground.world.is_empty());
}
// #43: the same wall and tunnel fixtures answer alike by sweep and by sections, and the cache is bounded
#[test]
fn wall_and_tunnel_fixtures_agree() {
let tunnel = Blocks(|x, y, z| Some(y < 60 || (x == 10 && !(y == 70 && z == 0))));
let mut ground = Ground::default();
for z in [0.5, 1.5] {
let (a, b) = (DVec3::new(0.5, 70.5, z), DVec3::new(30.5, 70.5, z));
warm(&mut ground, &tunnel, a, b);
let r = ground.world.sweep(a, b, false, 1);
if z == 0.5 {
assert_eq!(r, world::Sweep::Clear, "the tunnel is open");
} else {
assert!(matches!(r, world::Sweep::Hit { block: [10, 70, 1], .. }), "{r:?}");
}
}
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);
}
+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
}
}
+97 -32
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,18 @@ 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;
}
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 +322,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 +330,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);
+399
View File
@@ -0,0 +1,399 @@
//! What the game's blocks are to the simulation (issues #42, #43): a cache of
//! 16-block sections answered by `render.sections` (a bit per block that
//! stops a projectile), a swept segment through it, and the bookkeeping that
//! keeps the questions few: the sections along a flight asked for ahead of
//! time, at most 64 a tick, nearest need first; changes of the world marking
//! sections stale; a bounded cache. A chunk the game doesn't have answers
//! `Unknown`, which is never the same as air: the callers fall back to the
//! height of the land there and the counters say how often.
use glam::DVec3;
use std::collections::{HashMap, HashSet};
/// A section: block coordinates divided by 16.
pub type Sec = (i32, i32, i32);
/// Sections asked in one tick (the game's budget).
pub const PER_TICK: usize = 64;
/// Sections kept; the least recently used go first beyond it.
const MAX_SECTIONS: usize = 12_000;
/// Ticks before an unanswered or unknown section is asked again.
const RETRY: u64 = 60;
/// 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).
const QUEUE_MAX: usize = 512;
/// What a section holds, as the game answered.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Fill {
/// The game has no such chunk.
Unknown,
Empty,
Full,
/// 512 bytes, one bit per block, index `(y & 15) * 256 + (z & 15) * 16 + (x & 15)`.
Mixed(Box<[u8; 512]>),
}
/// An answer for a section, as the game gives it.
pub struct SectionAnswer {
pub sec: Sec,
pub fill: Fill,
}
struct Entry {
fill: Fill,
/// A block changed (or may have) since it was read: asked again when next used.
stale: bool,
/// The tick it was last used in a sweep.
used: u64,
/// The tick it was answered.
since: u64,
}
/// How a swept segment ended.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Sweep {
/// Every block along it is known and none stops it.
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 },
}
impl Sweep {
pub fn hit(&self) -> Option<(f64, DVec3)> {
match *self {
Sweep::Hit { t, point, .. } => Some((t, point)),
_ => None,
}
}
}
/// Counters for the report: questions, unknowns, memory, cost.
#[derive(Clone, Copy, Default, Debug)]
pub struct WorldStats {
pub sweeps: u64,
pub cells: u64,
pub hits: u64,
pub unknown_sweeps: u64,
pub sections_asked: u64,
pub sections_answered: u64,
pub sections_unknown: u64,
pub invalidated: u64,
pub evicted: u64,
pub world_changes: u64,
}
#[derive(Default)]
pub struct WorldCache {
secs: HashMap<Sec, Entry>,
urgent: Vec<Sec>,
ahead: Vec<Sec>,
queued: HashSet<Sec>,
/// Sections asked, and when.
asked: HashMap<Sec, u64>,
epoch: u32,
pub stats: WorldStats,
}
fn sec_of(x: i32, y: i32, z: i32) -> Sec {
(x >> 4, y >> 4, z >> 4)
}
impl WorldCache {
/// Memory the cached sections hold, about.
pub fn bytes(&self) -> usize {
self.secs
.values()
.map(|e| 48 + if matches!(e.fill, Fill::Mixed(_)) { 512 } else { 0 })
.sum()
}
pub fn len(&self) -> usize {
self.secs.len()
}
pub fn is_empty(&self) -> bool {
self.secs.is_empty()
}
/// One line for the report.
pub fn text(&self) -> String {
let s = &self.stats;
format!(
"мир: {} секций ({} КБ), запросов {}, неизвестно {}, лучей {} (ударов {}, неизв. {}), клеток {}",
self.len(),
self.bytes() / 1024,
s.sections_asked,
s.sections_unknown,
s.sweeps,
s.hits,
s.unknown_sweeps,
s.cells
)
}
/// 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]) {
if epoch != self.epoch {
if self.epoch != 0 {
self.secs.clear();
self.urgent.clear();
self.ahead.clear();
self.queued.clear();
self.asked.clear();
}
self.epoch = epoch;
}
if overflow {
for e in self.secs.values_mut() {
e.stale = true;
}
self.stats.invalidated += self.secs.len() as u64;
}
for s in sections {
if let Some(e) = self.secs.get_mut(s) {
e.stale = true;
self.stats.invalidated += 1;
}
}
self.stats.world_changes += sections.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);
let hi = sec_of((at.x + radius).floor() as i32, (at.y + radius).floor() as i32, (at.z + radius).floor() as i32);
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
if let Some(e) = self.secs.get_mut(&(x, y, z)) {
e.stale = true;
}
}
}
}
}
pub fn answer(&mut self, answers: Vec<SectionAnswer>, now: u64) {
for a in answers {
self.asked.remove(&a.sec);
self.stats.sections_answered += 1;
if a.fill == Fill::Unknown {
self.stats.sections_unknown += 1;
}
self.secs.insert(a.sec, Entry { fill: a.fill, stale: false, used: now, since: now });
}
if self.secs.len() > MAX_SECTIONS {
// Whatever wasn't used for a minute goes; if that's not enough, the older half.
self.secs.retain(|_, e| now.saturating_sub(e.used) < 1200);
if self.secs.len() > MAX_SECTIONS {
let mut used: Vec<u64> = self.secs.values().map(|e| e.used).collect();
used.sort_unstable();
let cut = used[used.len() / 2];
self.secs.retain(|_, e| e.used >= cut);
}
self.stats.evicted += 1;
}
// Forget questions the game never answered, so they can be asked again.
if now % 64 == 0 {
self.asked.retain(|_, t| now.saturating_sub(*t) < RETRY);
}
}
/// The sections to ask this tick (at most [`PER_TICK`]): the ones a sweep
/// needed first, then those along flights.
pub fn take_wanted(&mut self, now: u64) -> Vec<Sec> {
let mut out = Vec::new();
for list in [&mut self.urgent, &mut self.ahead] {
let room = PER_TICK - out.len();
let n = room.min(list.len());
out.extend(list.drain(..n));
}
for s in &out {
self.queued.remove(s);
self.asked.insert(*s, now);
}
self.stats.sections_asked += out.len() as u64;
out
}
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) {
return;
}
if self.urgent.len() + self.ahead.len() >= QUEUE_MAX {
return;
}
if urgent {
self.urgent.push(s);
} else {
self.ahead.push(s);
}
self.queued.insert(s);
}
/// Asks for the sections along a flight from `pos` at `vel` a tick, `ticks` ahead.
pub fn look_ahead(&mut self, pos: DVec3, vel: DVec3, ticks: f64, now: u64) {
let mut d = vel * ticks;
let len = d.length();
if len < 1e-6 {
return;
}
if len > AHEAD_MAX {
d *= AHEAD_MAX / len;
}
// A section at a time along the segment (a section is 16 wide: half-section steps meet each).
let steps = (d.length() / 8.0).ceil().max(1.0) as i32;
let mut last = None;
for i in 0..=steps {
let p = pos + d * (i as f64 / steps as f64);
let s = sec_of(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
if last == Some(s) {
continue;
}
last = Some(s);
self.touch(s, false, now);
}
}
/// Touches a section the way a sweep or a look needs it: used now, and
/// asked again when stale or (after a while) unknown.
fn touch(&mut self, s: Sec, urgent: bool, now: u64) -> bool {
let Some(e) = self.secs.get_mut(&s) else {
self.want(s, urgent, now);
return false;
};
e.used = now;
let refresh = e.stale || (e.fill == Fill::Unknown && now.saturating_sub(e.since) >= RETRY);
if refresh {
self.want(s, urgent, now);
}
true
}
/// Whether the block at `(x, y, z)` stops a projectile: `None` where the
/// game hasn't said. `last` is the section just touched.
fn solid(&mut self, last: &mut Option<Sec>, x: i32, y: i32, z: i32, now: u64) -> Option<bool> {
let s = sec_of(x, y, z);
if *last != Some(s) {
*last = Some(s);
self.touch(s, true, now);
}
match &self.secs.get(&s)?.fill {
Fill::Unknown => None,
Fill::Empty => Some(false),
Fill::Full => Some(true),
Fill::Mixed(bits) => {
let i = ((y & 15) << 8 | (z & 15) << 4 | (x & 15)) as usize;
Some(bits[i >> 3] >> (i & 7) & 1 != 0)
}
}
}
/// Whether the block is known to hold no solid block, with the sky above it
/// in view: used to tell a fluid surface from a roof.
pub fn is_known_open(&mut self, x: i32, y: i32, z: i32, now: u64) -> bool {
self.solid(&mut None, x, y, z, now) == Some(false)
}
/// Whether the block is known to be solid.
pub fn is_known_solid(&mut self, x: i32, y: i32, z: i32, now: u64) -> bool {
self.solid(&mut None, x, y, z, now) == Some(true)
}
/// 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).
pub fn sweep(&mut self, a: DVec3, b: DVec3, skip_start: bool, now: u64) -> Sweep {
self.stats.sweeps += 1;
let d = b - a;
let mut pos = [a.x.floor() as i32, a.y.floor() as i32, a.z.floor() as i32];
let dv = [d.x, d.y, d.z];
let av = [a.x, a.y, a.z];
let step: [i32; 3] = dv.map(|v| if v > 0.0 { 1 } else { -1 });
let mut t_max = [f64::INFINITY; 3];
let mut t_delta = [f64::INFINITY; 3];
for i in 0..3 {
if dv[i] != 0.0 {
let edge = f64::from(pos[i]) + if dv[i] > 0.0 { 1.0 } else { 0.0 };
t_max[i] = (edge - av[i]) / dv[i];
t_delta[i] = (1.0 / dv[i]).abs();
}
}
let mut last: Option<Sec> = None;
let mut face = [0i32; 3];
let mut t = 0.0f64;
let mut first = true;
for _ in 0..4096 {
self.stats.cells += 1;
match self.solid(&mut last, pos[0], pos[1], pos[2], now) {
None => {
self.stats.unknown_sweeps += 1;
return Sweep::Unknown { t };
}
Some(true) if !(first && skip_start) => {
self.stats.hits += 1;
return Sweep::Hit { t, point: a + d * t, normal: face, block: pos };
}
_ => {}
}
first = false;
let axis = if t_max[0] < t_max[1] {
if t_max[0] < t_max[2] { 0 } else { 2 }
} else if t_max[1] < t_max[2] {
1
} else {
2
};
t = t_max[axis];
if t > 1.0 {
return Sweep::Clear;
}
t_max[axis] += t_delta[axis];
pos[axis] += step[axis];
face = [0; 3];
face[axis] = -step[axis];
}
self.stats.unknown_sweeps += 1;
Sweep::Unknown { t }
}
}
/// Where the game's blocks come from in tests and the native runner: any
/// world that can say what a block is.
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>;
}
/// A [`WorldSource`] as the game would answer `render.query-sections` with it.
pub fn section_of(source: &dyn WorldSource, s: Sec) -> Fill {
let mut bits = Box::new([0u8; 512]);
let (mut set, mut known) = (0u32, 0u32);
for i in 0..4096usize {
let (x, y, z) = (s.0 * 16 + (i & 15) as i32, s.1 * 16 + (i >> 8) as i32, s.2 * 16 + ((i >> 4) & 15) as i32);
match source.solid(x, y, z) {
Some(true) => {
bits[i >> 3] |= 1 << (i & 7);
set += 1;
known += 1;
}
Some(false) => known += 1,
None => {}
}
}
match (known, set) {
(0, _) => Fill::Unknown,
(_, 0) if known == 4096 => Fill::Empty,
(_, 4096) => Fill::Full,
_ => Fill::Mixed(bits),
}
}
impl WorldCache {
/// 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);
}
}
+36 -2
View File
@@ -20,6 +20,7 @@ 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`) |
| Встраивание в кадр: 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` |
@@ -62,6 +63,7 @@ cd .. && rm -f horizon-patches/*.patch && git -C horizon format-patch -q $(cat H
землёй решают потребители). Пайплайны `gbuffer_water` (секции) и `flat.water` (flat-путь).
3a. **GTAO** (полразрешения, `Rg16Float`: окклюзия, дистанция) + depth-aware блюр 5×5. 3 среза × 5 шагов, радиус
1.8 блока, шум IGN по кадру. Сила — `HdFrame.ambient.w` (`HORIZON_HD_AO=<число>`, 0 = выкл).
3b. **Облака и туман** (`hd.volumes`, до освещения, после прохода неба): `hd cloud shadows` (карта 256² оптической толщины облаков по ходу солнца, ±1200 блоков вокруг камеры), `hd clouds` (половина разрешения мира, `Rgba16Float`: свет + пропускание; рэймарч слоя 192.33…264 блока, 8–26 шагов с джиттером IGN, свет — 3 шага с удвоением, небо — 1, 4 октавы многократного рассеяния Photon; история ping-pong с репроекцией по направлению и сдвигу камеры, вес до 0.9, отбрасывается при расхождении) и `hd fog` (половина разрешения: 18 шагов от камеры до глубины мира, не дальше 192 блоков, плотность — высотная дымка + утренний туман у земли + дождь + шум, свет — солнце × тень каскадов × фаза Photon + свет неба; вторая цель — длина луча для апсэмплинга). Ванильные облака при этом не рисуются (`hd_volumetric_clouds` в `gfx.rs`). `fs_light` накладывает облака на небо (`c·T + S`), туман на всё (`fog_at`: 4 текселя с весом по близости длины луча), а тень облаков (`cloud_shadow`) умножает прямой солнечный свет. `HORIZON_HD_CLOUDS=0` / `HORIZON_HD_VOLFOG=0` выключают (облака тогда ванильные, лучи — старые 14 шагов).
4. **Освещение** `fs_light` → `lit` (HDR): пиксель неба (глубина мира 0, Оверворлд, без env-тумана) — это небо атмосферы + то, что мир нарисовал поверх ванильного неба (`max(scene − vanilla_sky(view), 0)`: спрайты луны, звёзды; квадрат ванильного солнца вырезается вокруг направления солнца и заменяется круглым диском с гало); облака (глубина > 0, нет в G-буфере, y > 8, дальше 48 блоков) на закате/рассвете окрашиваются цветом солнца. Свет неба: ванильный `lightmap` × подкраска цветом неба (зенит сверху, горизонт сбоку; яркость 1) × форма по нормали (вверх 1.1, вниз 0.66), только там, где есть небесный свет. Тени: ближний каскад — PCSS-lite (поиск блокировщиков 8 выборок, ширина полутени от расстояния до блокировщика, 12 выборок спирали Фогеля, повёрнутых IGN; сглаживает TAA), дальний — 3×3. Туман: цвет — небо над горизонтом в направлении пикселя (`haze_colour`) + воздушная перспектива `1 − exp(−d/700)` × 0.5. Далее прежнее: пиксель — вода, если `water_depth >= g` и перед водой в мировой глубине ничего нет
(`d - wd <= 0.2%`); тогда освещается **дно** по данным G-буфера (одна выборка тени, без AO), а в альфу `lit` пишется 0.5 (признак воды). `max(небо·ambient.x, блоки·ambient.y)·AO + солнце·N·L·тень + точечные
источники` × albedo + эмиссия; затем ванильный туман заново, объёмные лучи (14 шагов, Henyey-Greenstein g=0.6),
@@ -109,10 +111,12 @@ Bind groups кэшируются и пересоздаются только пр
| 13 | AO |
| 14 | глубина воды (`water_depth`) |
| 15 | текстура неба (`sky_lut`) |
| 16, 17 | объёмный туман (свет+пропускание) и длина его луча |
| 18, 19 | объёмные облака, карта теней облаков |
`HdFrame` (Rust и WGSL держать в синхроне!): `inverse_view_proj`, `resolution` (w, h, 1/w, 1/h), `params` (число
источников, экспозиция, bloom, счётчик кадров), `cascades[2]`, `sun` (направление, сила), `sun_color` (rgb, ночь),
`fog_color`, `fog`, `ambient` (тень неба, блоки, плотность воздуха, AO), `shadow` (вкл, R0, R1, тайлов в строке), `prev_view_proj`, `taa`, `taa_params`, `view_proj` (с джиттером, для лучей воды), `water` (x: эффект идёт, y: секунды волн mod 600, zw: камера x, z mod 256), `sky_color` (w: есть небо), `sky_sun` (xyz: на само солнце, w: 1 — небо рисует атмосфера), `sky_extra` (x: конец ванильного тумана неба, z: пасмурность от дождя 0..0.85), `sunrise` (ванильная полоса восхода/заката — вычитается из мира).
`fog_color`, `fog`, `ambient` (тень неба, блоки, плотность воздуха, AO), `shadow` (вкл, R0, R1, тайлов в строке), `prev_view_proj`, `taa`, `taa_params`, `view_proj` (с джиттером, для лучей воды), `water` (x: эффект идёт, y: секунды волн mod 600, zw: камера x, z mod 256), `sky_color` (w: есть небо), `sky_sun` (xyz: на само солнце, w: 1 — небо рисует атмосфера), `sky_extra` (x: конец ванильного тумана неба, z: пасмурность от дождя 0..0.85), `sunrise` (ванильная полоса восхода/заката — вычитается из мира), `clouds` (низ слоя относительно камеры, толщина, вкл, секунды ветра), `cloud_cam` (x, z камеры по модулю 65536, добавка покрытия от дождя, история облаков годна), `cloud_dir` / `cloud_sun` (откуда и каким цветом светят облаку: солнце до чуть ниже горизонта, ночью луна), `vfog` (туман вкл, высота камеры, утренний туман).
Композит воды (`WATER`, group 0): 0 `HdFrame`, 1 `lit`, 2 глубина G-буфера, 3 цвет воды, 4 нормаль воды, 5 глубина воды, 6–7 тени, 8 лайтмап, 9 сэмплер, 10 текстура неба. Проход неба (`SKY_PASS`, group 0): только `HdFrame`.
@@ -151,6 +155,11 @@ 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 и кадрами.
Отладка: временно выводи промежуточные величины цветом в `fs_light` (так нашли пустую карту теней и неверное
направление солнца). Помни: солнце в мире идёт по оси X (`(-sin a, cos a, 0)`), а `Globals.sun` неба — в своей
плоскости YZ.
@@ -172,7 +181,16 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс
| Вода: проход `fs_water`, преломление, SSR, Френель, блик (`HORIZON_HD_WATER=0` — выкл) | готово | патч 19 (+20: без ванильной текстуры на поверхности, цвет темнее и глубже) |
| Вода: светлее, густая мелкая рябь (26 волн), блик a=0.07 (мерцание на реке ≈1.4% пикселей >20/255) | готово | патч 21 |
| Небо и атмосфера (Рэлей/Ми/озон, текстура неба, воздушная перспектива, цвет солнца из пропускания, голубоватый ambient по нормали, PCSS-lite ближнего каскада, диск солнца, цвет облаков на закате; порт констант и Chapman из Photon, MIT) | готово | патч 22 |
| Закат как у Photon: атмосфера в Rec. 2020 (коэффициенты и матрицы Photon), фаза Клейна-Нишины, тинт и усиление солнца на закате/рассвете/синем часе (`get_sun_tint`), насыщенность неба у низкого солнца | готово | патч 23 |
| Объёмные облака (рэймарч, мультирассеяние 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 не проверялись) | в рабочем дереве форка |
| #39: веер восхода `vanilla_sunrise_weight` отсекается до цикла по 16 треугольникам выше горизонта (знак x стороны солнца и наклон ≤ α/3; проверено перебором 200 тыс. лучей без пропусков); `hd lighting` на рассвете 5.3 → 3.3 мс (M-серия, 2560×1440) | готово | в рабочем дереве форка |
## План (по порядку) и ТЗ
@@ -190,7 +208,7 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс
3. **Небо и атмосфера** — СДЕЛАНО (патч 22, см. «Небо и свет» ниже). Исходное ТЗ: рассеяние Рэлея/Ми (предвычисленная LUT пропускания и небесного цвета в маленькой
текстуре), солнце/луна дисками, звёзды из ванили; туман и лучи берут цвет из той же модели. Учесть дождь
(`weather`), закат, Незер/Энд (там — ванильное небо).
4. **Объёмные облака** (рэймарч слоя с шумом) + их тени на земле (в карту/маску солнца).
4. **Объёмные облака** — СДЕЛАНО (патч 24, см. «Закат, облака и туман»). Осталось: перистые/высококучевые слои Photon, облака в отражении воды.
5. **Блики и материалы**: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из
мода), мокрые поверхности и лужи в дождь.
6. **PCSS** (мягкость тени по расстоянию до тенеотбрасывателя) и цветные тени через стекло/листву.
@@ -241,3 +259,19 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс
(`light_of`: множитель (1, 0.93, 0.78)). Проверять баланс на `Polygon` в полдень (`time set 6000`) — белое должно
оставаться белым с тёплым оттенком.
## Закат, облака и туман (патчи 23–25): что знать преемнику
- **Закат (23).** Небо марширует в Rec. 2020 (коэффициенты Photon сдвинуты матрицей `rec709_to_rec2020`, результат возвращается в 709): это убрало жёлто-зелёный горизонт. Добавлены фаза Клейна-Нишины для Ми и деполяризованная Рэлея, `sun_light_tint` (розоватый тинт и подъём яркости на закате, фиолетовый на синем часе, из `get_sun_tint`), сильнее насыщенность у низкого солнца (`sky_show`). Цвет прямого света (`light_of`) умножен на тот же тинт. Сверка: 12000/12500/13000 (запад) и 23000/23500 (восток) — оранжевый/розовый/пурпурный; полдень Polygon (6000) прежний.
- **Облака (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` (записывается из встроенного, если файла нет) — правки видны после перезапуска игры без пересборки; готовое переносится в константы вручную.
- **Слабые места.** Облака не отражаются в воде (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 мс).
@@ -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/22] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 01/25] 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/22] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 02/25] 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/22] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 03/25] 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/22] Mod models: draw far models as scaled impostors past
Subject: [PATCH 04/25] 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/22] Mod cameras: render.set-views draws the world from
Subject: [PATCH 05/25] 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/22] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 06/25] 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/22] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 07/25] 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/22] Mod cameras: distant terrain tiles, fog measured from
Subject: [PATCH 08/25] 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/22] Mods: game.world-id, so mods keep state per world (API
Subject: [PATCH 09/25] 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/22] cargo-horizon-mod: trial runs behind feature full, so a
Subject: [PATCH 10/25] 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/22] HD lighting: G-buffer pass, per-pixel mod point lights
Subject: [PATCH 11/25] 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/22] HD lighting stage 2: sun and moon shadow cascades, the
Subject: [PATCH 12/25] 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/22] HD: bind groups cached until a resize or a new source,
Subject: [PATCH 13/25] 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/22] HD: lights culled per 16x16 tile (surface and halo
Subject: [PATCH 14/25] 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/22] HD: GPU time per pass in the F3 timing list,
Subject: [PATCH 15/25] 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/22] HD: shadows draw only sun-facing quads and skip buried
Subject: [PATCH 16/25] 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/22] Mods: model handles (render.model-id, model-generation,
Subject: [PATCH 17/25] 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/22] HD: temporal upscaling (HORIZON_HD_SCALE: world,
Subject: [PATCH 18/25] 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/22] HD water: its own pass (colour, normal, depth), the
Subject: [PATCH 19/25] 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/22] HD water: no vanilla texture on the surface (the biome
Subject: [PATCH 20/25] 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/22] HD sky and light: a physical atmosphere (Rayleigh, Mie,
Subject: [PATCH 21/25] 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/22] HD water: a darker blue, a little clearer, ripples a
Subject: [PATCH 22/25] HD water: a darker blue, a little clearer, ripples a
little higher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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/25] 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)
+589
View File
@@ -0,0 +1,589 @@
#!/usr/bin/env python3
"""Графический полигон HD (#44): фиксированные сцены, сравнение кадров, мерцание и 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
Каждый прогон: свежая копия миров 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.
Итог: report.md и results.json в папке вывода.
Коды выхода: 0 — всё в порогах и бюджете; 1 — есть превышение; 2 — ошибка запуска или аргументов.
Только стандартная библиотека; при установленном Pillow сравнение быстрее.
"""
import argparse
import datetime
import json
import os
import re
import shutil
import struct
import subprocess
import sys
import time
import zlib
from dataclasses import asdict, dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
BENCH = ROOT / "run-home-bench"
SOCK = BENCH / "c.sock" # короткий путь: лимит Unix-сокета около 104 байт
LOG = SOCK.with_suffix(".log") # лог игры рядом с сокетом (как ctl.log в run-home)
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_GAIN = 6 # усиление разницы в diff-картинках
TIMING_RE = re.compile(r"shader pack GPU time per pass .*?: (.*) = ([\d.]+)\s*$")
@dataclass
class Shot:
name: str
world: str
time: int # время суток: 0 рассвет, 1000 утро, 6000 полдень, 12000 закат, 18000 полночь
pose: tuple # x, y, z, yaw, pitch (телепорт `tp @s`)
note: str
# Позы проверены в игре: полигон с техникой, река Hills (-26.5 72 0.5, взгляд на реку и холм), лес Hills на восток
# перед сравнением и поправь здесь, если кадр не то, что написано в note.
SHOTS = [
Shot("polygon_noon", "Polygon", 6000, (40.5, 76.0, 40.5, 135.0, 25.0),
"полигон с техникой, полдень, белые материалы"),
Shot("hills_river_noon", "Hills", 6000, (-26.5, 72.0, 0.5, -150.0, 8.0),
"река на Hills, полдень: вода, преломление, блики"),
Shot("hills_river_sunset", "Hills", 12000, (-26.5, 72.0, 0.5, -150.0, 8.0),
"река на Hills, закат: тинт неба и воды"),
Shot("hills_river_night", "Hills", 18000, (-26.5, 72.0, 0.5, -150.0, 8.0),
"река на Hills, ночь: луна, туман"),
Shot("forest_morning_sun", "Hills", 1000, (-26.5, 71.0, 0.5, -90.0, 3.0),
"лес на Hills, утро, взгляд на восток (солнце ~15 градусов над горизонтом)"),
Shot("sky_noon", "Polygon", 6000, (40.5, 76.0, 40.5, 0.0, -40.0),
"небо и облака, полдень, взгляд вверх"),
]
class BenchError(RuntimeError):
pass
def die(msg, code=2):
print(f"hd_bench: {msg}", file=sys.stderr)
sys.exit(code)
# --- процессы и игра -------------------------------------------------------------------------
def pgrep(pattern):
return subprocess.run(["pgrep", "-f", pattern], capture_output=True).returncode == 0
def ctl_ok(*args):
r = subprocess.run([str(CTL), "--socket", str(SOCK), "--timeout", "3", *args],
capture_output=True, text=True, timeout=20)
return r.returncode == 0
def bench_running():
"""Наша игра на c.sock: по командной строке (--control-socket) или по живому сокету."""
if pgrep(str(SOCK.relative_to(ROOT))):
return True
if SOCK.exists():
if ctl_ok("status"):
return True
SOCK.unlink() # мёртвый сокет от прошлого прогона
return False
def ctl(*args, timeout=30.0):
cmd = [str(CTL), "--socket", str(SOCK), "--timeout", f"{timeout:g}", *map(str, args)]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 60)
except subprocess.TimeoutExpired as e:
raise BenchError(f"horizon-ctl {' '.join(map(str, args))}: таймаут") from e
if r.returncode != 0:
raise BenchError(f"horizon-ctl {' '.join(map(str, args))}: "
f"{(r.stdout or r.stderr).strip()[:300]}")
return r.stdout.strip()
def game_env(extra, timing):
env = dict(os.environ)
env.update({
"HORIZON_HOME": str(HOME),
"HORIZON_NO_AUDIO": "1",
"HORIZON_LAUNCHER_NO_SELF_UPDATE": "1",
})
if timing:
env["HORIZON_SHADER_PACK_TIMING"] = "1"
env.update(extra)
return env
def prepare_home():
if HOME.exists():
shutil.rmtree(HOME)
(HOME / "saves").mkdir(parents=True)
for world in sorted({s.world for s in SHOTS}):
src = SAVES / world
if not src.is_dir():
die(f"нет мира {src}")
shutil.copytree(src, HOME / "saves" / world,
ignore=shutil.ignore_patterns("session.lock", "*.lock"))
def install_mod(env):
subprocess.run([sys.executable, str(ROOT / "tools" / "airdef.py"), "install"],
env={**env, "HORIZON_HOME": str(HOME)}, check=True, cwd=ROOT)
def launch(world, env, game, resources):
if SOCK.exists():
SOCK.unlink()
cmd = [str(CTL), "--socket", str(SOCK), "launch", "--world", world, "--offscreen", "--wait",
"--game", str(game), "--resources", str(resources)]
r = subprocess.run(cmd, capture_output=True, text=True, env=env, cwd=HOME, timeout=600)
if r.returncode != 0:
raise BenchError(f"launch {world}: {(r.stdout or r.stderr).strip()[:400]}")
ctl("command", "gamemode creative")
ctl("key", "t", "tap") # чат, как игрок: /airdef shader hd
ctl("type", "/airdef shader hd")
ctl("key", "return", "tap")
time.sleep(0.5)
ctl("key", "f1", "tap") # скрыть HUD (один раз на запуск)
def stop_game():
try:
ctl("quit", timeout=20)
except BenchError:
pass
for _ in range(100):
if not ctl_ok("status"):
break
time.sleep(0.2)
if SOCK.exists() and not ctl_ok("status"):
SOCK.unlink()
# --- кадры: PNG без Pillow и с ним -------------------------------------------------------------
def _paeth(a, b, c):
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
return a if pa <= pb and pa <= pc else (b if pb <= pc else c)
def read_png_rgb(path):
"""(w, h, bytes RGB). Только 8-бит RGB/RGBA без чередования (так пишет игра)."""
data = Path(path).read_bytes()
if data[:8] != b"\x89PNG\r\n\x1a\n":
raise BenchError(f"{path}: не PNG")
pos, idat, w = 8, [], 0
while pos < len(data):
(ln,) = struct.unpack(">I", data[pos:pos + 4])
kind = data[pos + 4:pos + 8]
body = data[pos + 8:pos + 8 + ln]
pos += 12 + ln
if kind == b"IHDR":
w, h, depth, ctype, _, _, interlace = struct.unpack(">IIBBBBB", body)
elif kind == b"IDAT":
idat.append(body)
elif kind == b"IEND":
break
if depth != 8 or interlace or ctype not in (2, 6):
raise BenchError(f"{path}: нужен 8-бит RGB/RGBA без чередования")
bpp = 3 if ctype == 2 else 4
stride = w * bpp
raw = zlib.decompress(b"".join(idat))
out = bytearray(w * h * 3)
prev = bytearray(stride)
p = 0
for y in range(h):
ft = raw[p]
line = raw[p + 1:p + 1 + stride]
p += 1 + stride
cur = bytearray(stride)
for i in range(stride):
x = line[i]
a = cur[i - bpp] if i >= bpp else 0
b = prev[i]
c = prev[i - bpp] if i >= bpp else 0
if ft == 1:
x += a
elif ft == 2:
x += b
elif ft == 3:
x += (a + b) >> 1
elif ft == 4:
x += _paeth(a, b, c)
cur[i] = x & 255
base = y * w * 3
if bpp == 3:
out[base:base + w * 3] = cur
else:
out[base:base + w * 3:3] = cur[0::4]
out[base + 1:base + w * 3:3] = cur[1::4]
out[base + 2:base + w * 3:3] = cur[2::4]
prev = cur
return w, h, bytes(out)
def write_png_rgb(path, w, h, rgb):
raw = b"".join(b"\x00" + rgb[y * w * 3:(y + 1) * w * 3] for y in range(h))
def chunk(kind, body):
return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF)
Path(path).write_bytes(b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 6))
+ chunk(b"IEND", b""))
try: # необязательно: быстрее декодирование и сравнение
from PIL import Image, ImageChops, ImageStat
except ImportError: # pragma: no cover
Image = None
def compare_frames(a, b, diff_out=None):
"""Средняя разница по каналам (0..255) и доля пикселей с разницей > DIFF_LEVEL, в %."""
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)
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]}
# --- тайминги -----------------------------------------------------------------------------------
def read_from(path, offset):
try:
with open(path, "rb") as f:
f.seek(offset)
return f.read().decode("utf-8", "replace")
except FileNotFoundError:
return ""
def parse_pass_reports(text):
reports = []
for line in text.splitlines():
m = TIMING_RE.search(line)
if not m:
continue
passes = {}
for part in m.group(1).split(", "):
name, _, ms = part.rpartition(" ")
try:
passes[name] = float(ms)
except ValueError:
pass
reports.append((passes, float(m.group(2))))
return reports
def average_passes(reports):
if not reports:
return {}, None
sums, counts = {}, {}
for passes, _ in reports:
for name, ms in passes.items():
sums[name] = sums.get(name, 0.0) + ms
counts[name] = counts.get(name, 0) + 1
avg = {n: sums[n] / counts[n] for n in sums}
total = sum(avg.values())
return avg, total
def bench_gpu_avg(reply):
try:
return float(json.loads(reply)["gpu"]["avg_ms"])
except (ValueError, KeyError, TypeError):
return None
# --- сцена ------------------------------------------------------------------------------------
def run_shot(shot, out, settle, bench_secs, timing):
x, y, z, yaw, pitch = shot.pose
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": {},
"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)
(out / f"{shot.name}_bench.json").write_text(reply + "\n")
result["bench_json"] = f"{shot.name}_bench.json"
result["gpu_avg_ms"] = bench_gpu_avg(reply)
if timing:
time.sleep(1.0) # последний отчёт о проходах
reports = parse_pass_reports(read_from(LOG, offset))
result["passes"], result["pass_total_ms"] = average_passes(reports)
if not reports:
result["warning"] = "нет строк проходов в логе (мало таймед-кадров?)"
return result
def group_by_world(shots):
order = []
for s in shots:
if s.world not in order:
order.append(s.world)
return [(w, [s for s in shots if s.world == w]) for w in order]
def git_rev():
r = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"],
capture_output=True, text=True)
return r.stdout.strip() or "?"
def judge(entry, args):
reasons = []
if entry.get("error"):
reasons.append("ошибка: " + entry["error"])
gpu = entry.get("gpu_avg_ms")
if gpu is not None and gpu > args.budget_ms:
reasons.append(f"GPU {gpu:.2f} мс > бюджета {args.budget_ms:g}")
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}%")
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}")
if base["pct"] > args.max_pct:
reasons.append(f"пикселей >20/255 {base['pct']:.2f}% > {args.max_pct:g}%")
return reasons
def write_report(out, entries, args, env_extra, size, baseline):
lines = []
add = lines.append
add(f"# HD-полигон: {out.name}")
add("")
add(f"- коммит: `{git_rev()}`, дата: {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}%")
if baseline:
add(f"- сравнение с: `{baseline}` (средняя разница <= {args.max_mean:g}, пикселей >20/255 <= {args.max_pct:g}%)")
add("")
add("| сцена | мир | время | GPU avg, мс | бюджет | мерцание %>20 | к базе: 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 "-"
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} |")
passes = []
for e in entries:
for p in e.get("passes", {}):
if p not in passes:
passes.append(p)
if passes:
add("")
add("## Проходы GPU, среднее по отчётам лога, мс")
add("")
add("| проход | " + " | ".join(e["name"] for e in entries) + " |")
add("|---|" + "---|" * len(entries))
for p in passes:
add(f"| {p} | " + " | ".join(f"{e['passes'][p]:.3f}" if p in e.get("passes", {}) else "-" for e in entries) + " |")
totals = [f"{e['pass_total_ms']:.3f}" if e.get("pass_total_ms") is not None else "-" for e in entries]
add("| **итого** | " + " | ".join(totals) + " |")
add("")
add("## Кадры")
for e in entries:
add("")
add(f"### {e['name']}: {e['note']}")
add("")
add(f"![{e['name']}]({e['frame']})")
if e.get("diff_image"):
add("")
add(f"Разница с базой (x{DIFF_GAIN}): ![diff]({e['diff_image']})")
if e.get("warning"):
add("")
add(f"> {e['warning']}")
if e.get("error"):
add("")
add(f"> ошибка: {e['error']}")
(out / "report.md").write_text("\n".join(lines) + "\n")
def parse_args(argv):
p = argparse.ArgumentParser(description="Графический полигон HD: кадры, мерцание, GPU-бюджет.")
p.add_argument("--list", action="store_true", help="показать сцены и выйти")
p.add_argument("--only", action="append", default=[], metavar="СЦЕНА", help="только эти сцены (можно несколько раз)")
p.add_argument("--out", help="папка вывода (по умолчанию run-home-bench/out/<время>)")
p.add_argument("--baseline", help="папка прошлого прогона для сравнения кадров")
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("--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)
def main(argv=None):
args = parse_args(argv)
if args.list:
for s in SHOTS:
print(f"{s.name:22} {s.world:8} время {s.time:5} поза {s.pose} {s.note}")
return 0
shots = [s for s in SHOTS if not args.only or s.name in args.only]
unknown = set(args.only) - {s.name for s in SHOTS}
if unknown:
die(f"нет сцен: {', '.join(sorted(unknown))} (см. --list)")
env_extra = {}
for item in args.env:
if "=" not in item:
die(f"--env ждёт КЛЮЧ=ЗНАЧЕНИЕ, получено: {item}")
k, v = item.split("=", 1)
env_extra[k] = v
if bench_running():
die("уже запущена игра на run-home-bench/c.sock: один прогон за раз")
if pgrep(OTHER_SOCK):
if len(shots) > 1:
die(f"работает чужая игра на {OTHER_SOCK}: запусти одну сцену (--only) или дождись конца теста")
print(f"hd_bench: внимание: работает чужая игра на {OTHER_SOCK}, снимок будет под нагрузкой",
file=sys.stderr)
out = Path(args.out) if args.out else BENCH / "out" / datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
out = out.resolve()
out.mkdir(parents=True, exist_ok=True)
sys.path.insert(0, str(ROOT / "tools"))
import airdef # tools/airdef.py: ресурсы и путь к игре
game = Path(args.game) if args.game else airdef.built_game()
resources = Path(args.resources) if args.resources else airdef.resources()
timing = not args.no_pass_timing
env = game_env(env_extra, timing)
prepare_home()
install_mod(env)
entries = []
baseline = Path(args.baseline).resolve() if args.baseline else None
try:
for world, group in group_by_world(shots):
try:
launch(world, env, game, resources)
except BenchError as e:
for s in group:
entries.append({**asdict(s), "error": str(e), "fail": [f"ошибка: {e}"]})
stop_game()
continue
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
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()
finally:
stop_game()
size = None
for e in entries:
if e.get("vs_baseline") and "size" in e["vs_baseline"]:
size = e["vs_baseline"]["size"]
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,
"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)
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', '')}")
for r in e["fail"]:
print(f" FAIL: {r}")
print(f"отчёт: {out / 'report.md'}")
return 1 if failed else 0
if __name__ == "__main__":
try:
sys.exit(main())
except BenchError as e:
die(str(e), code=2)