Compare commits

..

1 Commits

Author SHA1 Message Date
chexit 766b292aec Continuous engine sound on drones and missiles, live and in replays, through persistent sound sources (#95, fork patch 37)
build / mod (push) Successful in 3m51s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:10:24 +03:00
43 changed files with 2700 additions and 58 deletions
+6
View File
@@ -221,6 +221,12 @@ API мода `render.set-lights` (до 512, плавно интерполиру
У каждого объекта свой звук и своя дальность слышимости (`attenuation_distance` в `sounds.json`):
- **двигатели:** двухтактный мопед шахеда (220 м), турбина Герани-3, электромотор Ланцета, тяжёлый рёв крылатых ракет (320 м),
ракетный рёв Х-22, Циркона и перехватчиков, нарастающий визг падающей баллистики и её ударная волна (700–900 м);
Двигатели дронов и ракет — **постоянные источники звука** (API 20, `render.sound-sources`, #95): один источник на
объект идёт за его настоящей позицией, зажигается с нарастанием, при сбитии «захлёбывается» (тон падает, громкость
гаснет за секунду) и замолкает вместе с объектом; в повторе и экспорте источники берутся из записанных траекторий и
времени повтора (перемотка и смена плана пересоздают их). Одновременно звучат 24 ближайших и громких, остальные
«виртуальные». Взрывы, пуски и выстрелы остались разовыми, с задержкой по скорости звука. Баллистика (визг при
падении) тоже разовая. Зацикливается устойчивая часть файла (начало и затухание в конце обрезаны), стык — 50 мс.
- **пуски:** хлопок и рвущийся рёв Patriot, свистящий старт Панциря, глухой газовый выброс и затем огромный рёв С-400,
у С-500 ещё ниже и дольше (до 1 км);
- **пушки:** тяжёлые 35-мм очереди Гепарда и частые 30-мм Панциря;
+330
View File
@@ -0,0 +1,330 @@
//! Engines as persistent sound sources (#95): a drone's or a missile's engine is one source that
//! follows the entity, instead of a one-shot repeated every second or two.
//!
//! The simulation (live) and the replay (from a recording's tracks and the replay's time) each
//! *declare* the engines sounding this tick; [`Engines::step`] turns that into the changes of
//! `render.sound-sources`: a start with an attack when an engine appears (ignition), an update
//! every tick, a stop with a release when it is gone (burnout, the entity removed), and a fall
//! of pitch and gain over a second when it is damaged (a drone shot down). A declaration missing
//! for a tick or two (a step the simulation skipped) does not stop the sound; a *cut* (a replay
//! seek, a new shot) stops every engine and starts them again under new ids, so nothing glides
//! across the jump.
use glam::DVec3;
use std::collections::{BTreeMap, BTreeSet};
/// Ticks an engine may go undeclared before it is stopped.
const GRACE: u32 = 3;
/// Ticks a damaged engine takes to die: its pitch falls and it fades.
const DYING: u32 = 20;
/// The most engines that sound at once (the nearest ones; the game's budget picks the audible).
const MAX: usize = 128;
/// Doppler applied to engines. The kinds already differ in pitch, and the fight's speed must not
/// change what is heard, so it stays off; the game bounds it to 12% anyway.
pub const DOPPLER: f32 = 0.0;
/// An engine that sounds this tick.
#[derive(Clone, Debug, PartialEq)]
pub struct Decl {
/// The entity: a drone's id, a missile's with a high bit, a replay track's with another.
pub key: u32,
pub event: &'static str,
pub at: DVec3,
/// Blocks a second.
pub vel: DVec3,
pub gain: f32,
pub pitch: f32,
/// Shot down: the engine dies away.
pub damaged: bool,
}
/// What a source is asked to be.
#[derive(Clone, Debug, PartialEq)]
pub struct Voice {
pub event: &'static str,
pub at: DVec3,
pub vel: DVec3,
pub gain: f32,
pub pitch: f32,
pub attack: f32,
pub release: f32,
pub loop_from: f32,
pub loop_to: f32,
}
/// One change of `render.sound-sources`.
#[derive(Clone, Debug, PartialEq)]
pub enum Op {
Start(u32, Voice),
Update(u32, Voice),
Stop(u32),
}
/// The steady part of each engine sound, seconds into the file: the sounds fade in at their start
/// and die away at their end (synthesis, reverb tail, fade: tools/gen_sounds.py), so looping a
/// whole file leaves a gap; these were found as `audio::tests::a_loop_of_the_steady_part_of_the_
/// engine_sounds_has_no_hole` finds them (after the fade-in, before the sound begins to die).
pub fn steady_part(event: &str) -> (f32, f32) {
match event {
"airdef:drone.shahed" => (0.2, 2.2),
"airdef:drone.geran3" => (0.2, 2.0),
"airdef:drone.lancet" => (0.2, 1.4),
"airdef:drone.cruise" => (0.2, 2.6),
"airdef:drone.rocket" => (0.2, 2.6),
"airdef:missile.flight" => (0.2, 2.2),
_ => (0.0, 0.0),
}
}
struct Live {
id: u32,
event: &'static str,
missed: u32,
dying: u32,
}
#[derive(Default)]
pub struct Engines {
live: BTreeMap<u32, Live>,
/// Engines that died and are still declared (a wreck falling): not started again.
dead: BTreeSet<u32>,
next_id: u32,
}
impl Engines {
pub fn is_empty(&self) -> bool {
self.live.is_empty()
}
/// The game released every source: forget them (nothing to stop).
pub fn reset(&mut self) {
self.live.clear();
self.dead.clear();
}
fn voice(d: &Decl, dying: u32) -> Voice {
let u = (dying as f32 / DYING as f32).min(1.0);
let (loop_from, loop_to) = steady_part(d.event);
Voice {
event: d.event,
at: d.at,
vel: d.vel,
// A shot-down engine coughs: the pitch falls by almost half, the sound fades.
gain: d.gain * (1.0 - u),
pitch: d.pitch * (1.0 - 0.45 * u),
attack: 0.3,
release: 0.25,
loop_from,
loop_to,
}
}
/// One tick: what is declared now, and whether the picture cut (a seek or a new shot).
pub fn step(&mut self, mut decls: Vec<Decl>, cut: bool, listener: DVec3) -> Vec<Op> {
let mut ops = Vec::new();
if cut {
for (_, l) in std::mem::take(&mut self.live) {
ops.push(Op::Stop(l.id));
}
self.dead.clear();
}
if decls.len() > MAX {
decls.sort_by(|a, b| {
a.at.distance_squared(listener)
.total_cmp(&b.at.distance_squared(listener))
.then(a.key.cmp(&b.key))
});
decls.truncate(MAX);
}
decls.sort_by_key(|d| d.key);
for l in self.live.values_mut() {
l.missed += 1;
}
let declared: BTreeSet<u32> = decls.iter().map(|d| d.key).collect();
self.dead.retain(|k| declared.contains(k));
for d in &decls {
if self.dead.contains(&d.key) {
continue;
}
match self.live.get_mut(&d.key) {
Some(l) if l.event == d.event => {
l.missed = 0;
if d.damaged {
l.dying += 1;
}
if l.dying >= DYING {
ops.push(Op::Stop(l.id));
self.live.remove(&d.key);
self.dead.insert(d.key);
} else {
ops.push(Op::Update(l.id, Self::voice(d, l.dying)));
}
}
other => {
// A new engine, or the same entity with another sound: the old one releases.
if let Some(l) = other {
ops.push(Op::Stop(l.id));
}
self.next_id = self.next_id.wrapping_add(1).max(1);
let dying = u32::from(d.damaged);
ops.push(Op::Start(self.next_id, Self::voice(d, dying)));
self.live.insert(
d.key,
Live {
id: self.next_id,
event: d.event,
missed: 0,
dying,
},
);
}
}
}
// Gone for good: the entity was removed (burnout, a hit, a crash).
let gone: Vec<u32> = self
.live
.iter()
.filter(|(_, l)| l.missed > GRACE)
.map(|(k, _)| *k)
.collect();
for k in gone {
if let Some(l) = self.live.remove(&k) {
ops.push(Op::Stop(l.id));
}
}
ops
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d(key: u32, x: f64) -> Decl {
Decl {
key,
event: "airdef:drone.shahed",
at: DVec3::new(x, 50.0, 0.0),
vel: DVec3::new(30.0, 0.0, 0.0),
gain: 0.8,
pitch: 1.0,
damaged: false,
}
}
fn starts(ops: &[Op]) -> usize {
ops.iter().filter(|o| matches!(o, Op::Start(..))).count()
}
fn stops(ops: &[Op]) -> usize {
ops.iter().filter(|o| matches!(o, Op::Stop(_))).count()
}
#[test]
fn an_engine_ignites_follows_and_stops_when_its_entity_goes() {
let mut e = Engines::default();
let ops = e.step(vec![d(1, 0.0)], false, DVec3::ZERO);
assert_eq!(starts(&ops), 1);
let Op::Start(id, v) = &ops[0] else { panic!() };
assert_eq!((v.attack, v.loop_from, v.loop_to), (0.3, 0.2, 2.2));
for i in 1..10 {
let ops = e.step(vec![d(1, f64::from(i))], false, DVec3::ZERO);
assert!(matches!(&ops[..], [Op::Update(u, v)] if u == id && v.at.x == f64::from(i)));
}
// Undeclared for a moment: still sounding; then gone.
for _ in 0..GRACE {
assert!(e.step(vec![], false, DVec3::ZERO).is_empty());
}
let ops = e.step(vec![], false, DVec3::ZERO);
assert_eq!(ops, vec![Op::Stop(*id)]);
assert!(e.is_empty());
}
#[test]
fn a_shot_down_engine_coughs_and_dies_and_stays_dead_while_it_falls() {
let mut e = Engines::default();
e.step(vec![d(1, 0.0)], false, DVec3::ZERO);
let mut last = 1.0;
let mut stopped_at = None;
for t in 1..60 {
let mut f = d(1, 0.0);
f.damaged = true;
let ops = e.step(vec![f], false, DVec3::ZERO);
match ops.as_slice() {
[Op::Update(_, v)] => {
assert!(v.pitch < last, "the pitch falls");
last = v.pitch;
}
[Op::Stop(_)] => stopped_at = stopped_at.or(Some(t)),
[] => {}
o => panic!("{o:?}"),
}
}
assert_eq!(stopped_at, Some(DYING));
assert!(last < 0.6);
// The wreck is gone, a new drone with the same key sounds again.
e.step(vec![], false, DVec3::ZERO);
assert_eq!(starts(&e.step(vec![d(1, 0.0)], false, DVec3::ZERO)), 1);
}
#[test]
fn a_cut_stops_every_engine_and_starts_them_under_new_ids() {
let mut e = Engines::default();
let a = e.step(vec![d(1, 0.0), d(2, 5.0)], false, DVec3::ZERO);
let ops = e.step(vec![d(1, 100.0), d(2, 105.0)], true, DVec3::ZERO);
assert_eq!((stops(&ops), starts(&ops)), (2, 2));
let old: Vec<u32> = a.iter().filter_map(|o| if let Op::Start(i, _) = o { Some(*i) } else { None }).collect();
for o in &ops {
if let Op::Start(i, _) = o {
assert!(!old.contains(i), "a new id, so nothing glides across the jump");
}
}
}
#[test]
fn another_sound_for_the_same_entity_restarts_it() {
let mut e = Engines::default();
e.step(vec![d(1, 0.0)], false, DVec3::ZERO);
let mut other = d(1, 0.0);
other.event = "airdef:drone.rocket";
let ops = e.step(vec![other], false, DVec3::ZERO);
assert_eq!((stops(&ops), starts(&ops)), (1, 1));
}
#[test]
fn only_the_nearest_are_kept_and_the_same_input_gives_the_same_ops() {
let run = || {
let mut e = Engines::default();
let all: Vec<Decl> = (0..300).map(|i| d(i, f64::from(i) * 3.0)).collect();
e.step(all, false, DVec3::ZERO)
};
let ops = run();
assert_eq!(starts(&ops), MAX);
assert_eq!(ops, run());
let far = ops.iter().filter_map(|o| if let Op::Start(_, v) = o { Some(v.at.x) } else { None }).fold(0.0, f64::max);
assert!(far <= (MAX as f64) * 3.0);
}
#[test]
fn a_hundred_seek_cycles_leave_no_engine_behind() {
let mut e = Engines::default();
let mut open = std::collections::BTreeSet::new();
for round in 0..100 {
let ops = e.step(vec![d(1, round as f64), d(2, 0.0)], round % 3 == 0, DVec3::ZERO);
for o in ops {
match o {
Op::Start(i, _) => {
open.insert(i);
}
Op::Stop(i) => {
assert!(open.remove(&i), "a stop for a source that was never started");
}
Op::Update(i, _) => assert!(open.contains(&i)),
}
}
}
for o in e.step(vec![], true, DVec3::ZERO) {
if let Op::Stop(i) = o {
open.remove(&i);
}
}
assert!(open.is_empty(), "every start was stopped");
}
}
+87 -1
View File
@@ -13,6 +13,7 @@ use horizon_mod_sdk::{
};
mod cinema;
mod engines;
mod commands;
mod layout;
mod film;
@@ -107,6 +108,11 @@ struct AirDef {
punch: f32,
/// Sounds on their way to the player at the speed of sound.
pending: Vec<PendingSound>,
/// The engines as persistent sound sources (#95), and the source generation they were started under.
engines: engines::Engines,
engine_gen: u32,
/// Whether the engines are a replay's (a change of mode cuts them).
engines_replay: bool,
/// The handles `render.model-id` gave the model names (valid under `model_generation`); none:
/// a name the game doesn't know.
model_ids: std::collections::HashMap<&'static str, Option<u32>>,
@@ -149,6 +155,30 @@ fn switch_allowed(exporting: bool) -> Result<(), String> {
}
}
/// An engine voice as a `render.sound-sources` entry.
fn source_op(kind: render::SourceOpKind, id: u32, v: &engines::Voice) -> render::SourceOp {
render::SourceOp {
kind,
id,
event: v.event.to_string(),
x: v.at.x,
y: v.at.y,
z: v.at.z,
vx: v.vel.x as f32,
vy: v.vel.y as f32,
vz: v.vel.z as f32,
gain: v.gain,
pitch: v.pitch,
looping: true,
priority: 1.0,
attack: v.attack,
release: v.release,
doppler: engines::DOPPLER,
loop_from: v.loop_from,
loop_to: v.loop_to,
}
}
/// A sound heard later: when, what, where, and how loud and high it was.
struct PendingSound {
due: u64,
@@ -615,7 +645,59 @@ impl AirDef {
}
}
fn emit(&mut self, out: Out) {
/// The engines the simulation or the replay declared become persistent sound sources (#95):
/// started, followed, stopped. The one-shots below stay as they were.
fn step_engines(&mut self, out: &mut Out) {
let replay = self.replay.as_ref().is_some_and(|r| r.filming());
let cut = std::mem::take(&mut out.engine_cut) || replay != self.engines_replay;
self.engines_replay = replay;
let generation = render::sound_source_generation();
if generation != self.engine_gen {
// The game released every source (a world closed, a film began or ended).
self.engines.reset();
self.engine_gen = generation;
}
if self.engines.is_empty() && out.engines.is_empty() && !cut {
return;
}
let ops: Vec<render::SourceOp> = self
.engines
.step(std::mem::take(&mut out.engines), cut, self.listener)
.into_iter()
.map(|op| match op {
engines::Op::Start(id, v) => source_op(render::SourceOpKind::Start, id, &v),
engines::Op::Update(id, v) => source_op(render::SourceOpKind::Update, id, &v),
engines::Op::Stop(id) => render::SourceOp {
kind: render::SourceOpKind::Stop,
id,
event: String::new(),
x: 0.0,
y: 0.0,
z: 0.0,
vx: 0.0,
vy: 0.0,
vz: 0.0,
gain: 0.0,
pitch: 1.0,
looping: false,
priority: 1.0,
attack: 0.0,
release: 0.0,
doppler: 0.0,
loop_from: 0.0,
loop_to: 0.0,
},
})
.collect();
if !ops.is_empty() && !render::sound_sources(generation, &ops) {
// Released between the two calls: start them again under the new generation.
self.engines.reset();
self.engine_gen = u32::MAX;
}
}
fn emit(&mut self, mut out: Out) {
self.step_engines(&mut out);
if self.sim.shader {
render::set_params(&self.shader_params(&out));
}
@@ -1404,6 +1486,9 @@ impl Mod for AirDef {
listener: DVec3::ZERO,
punch: 0.0,
pending: Vec::new(),
engines: engines::Engines::default(),
engine_gen: u32::MAX,
engines_replay: false,
model_ids: std::collections::HashMap::new(),
model_generation: u32::MAX,
by_id: Vec::new(),
@@ -1547,6 +1632,7 @@ impl Mod for AirDef {
out.models.clear();
out.particles.clear();
out.sounds.clear();
out.engines.clear();
out.shake = None;
}
r.tick(&self.sim, &mut self.ground, self.listener, &mut out);
+180
View File
@@ -68,6 +68,8 @@ pub struct Player {
fov: f32,
/// The last tick whose events made their sounds.
heard: f64,
/// The replay time the engines were last declared at (a jump back or far ahead cuts them).
engine_t: f64,
/// Time stands still (Space, or the end reached).
paused: bool,
/// The film's own clock in seconds (the replay's moment follows from the plan).
@@ -265,6 +267,7 @@ impl Player {
look: DVec3::NEG_Z,
fov: 50.0,
heard: start,
engine_t: -1.0,
paused: false,
tau: 0.0,
wait: false,
@@ -443,6 +446,48 @@ impl Player {
sim.draw_ghosts(rec, self.t, GHOSTS, out);
self.direct(rec, ground);
self.sounds(rec, listener, out);
self.engines(rec, listener, false, out);
}
/// The engines as the recording had them at the replay's time, for persistent sound sources
/// (#95): every flying drone and lit interceptor, where its track puts it now, moving as the
/// track moves, placed round the listener like `sounds`. Taken from the tracks and the replay's
/// time, never from the live entities. Silent while time stands still, runs too fast to
/// follow, or a title card shows; a seek, a clip change or a new shot cuts them (they start
/// afresh), so no sound glides across a jump.
fn engines(&mut self, rec: &Recording, listener: DVec3, shot_changed: bool, out: &mut Out) {
let moved = self.t - self.engine_t;
if self.engine_t < 0.0 || shot_changed || moved < -1e-9 || moved > self.rate * 1.5 + 2.0 {
out.engine_cut = true;
}
self.engine_t = self.t;
if self.rate <= 0.0 || self.rate > 4.0 || self.card > 0 {
return;
}
let step = STEP as f64;
for (k, tr) in rec.tracks.iter().enumerate() {
let Some(p) = tr.at(rec.origin, self.t) else { continue };
let ahead = tr.at(rec.origin, self.t + step).unwrap_or(p);
let behind = tr.at(rec.origin, self.t - step).unwrap_or(p);
// Blocks per tick of the replay: the track's, times how fast the replay runs.
let vel = (ahead - behind) / (2.0 * step) * self.rate;
let key = 0x8000_0000 | k as u32;
let at = listener + (p - self.eye);
match tr.what {
What::Drone(kind) => {
if let Some((event, pitch)) = kind.engine_voice() {
let falling = tr.fell.is_some_and(|f| self.t >= f as f64);
out.engine(key, event, at, vel, 0.8, pitch, falling);
}
}
What::Missile(kind, _) => {
if self.t - tr.first as f64 > kind.lit_after() as f64 {
let (event, gain, pitch) = kind.engine_voice();
out.engine(key, event, at, vel, gain, pitch, false);
}
}
}
}
}
/// Sounds as the camera hears them: moved so the player stands where the camera is.
@@ -512,6 +557,7 @@ impl Player {
sim.draw_ghosts(rec, self.t, base, out);
if !self.wait {
self.sounds(rec, listener, out);
self.engines(rec, listener, changed, out);
} else {
self.heard = self.t;
}
@@ -1004,3 +1050,137 @@ mod surface_tests {
assert_eq!(above_surface(eye, Some(None)), eye);
}
}
#[cfg(test)]
mod engine_tests {
use super::*;
use crate::engines::{Engines, Op};
use crate::sim::{MissileKind, record::Recording};
fn recording() -> Recording {
let mut rec = Recording::blank(2000);
// A Shahed flying 1000 blocks east over 1000 ticks, shot down at 600; an interceptor from 400.
rec.straight(1, What::Drone(DroneKind::Shahed), 0, 1000, DVec3::new(0.0, 80.0, 0.0), DVec3::new(1000.0, 80.0, 0.0));
rec.tracks[0].fell = Some(600);
rec.straight(2, What::Missile(MissileKind::Pac3, 0), 400, 700, DVec3::new(0.0, 0.0, 0.0), DVec3::new(600.0, 80.0, 0.0));
rec
}
fn player(rec: &Recording) -> Player {
let mut p = Player::new(1, Mode::Full, &Sim::new(1));
p.eye = DVec3::new(500.0, 80.0, 30.0);
let _ = rec;
p
}
/// A host that remembers which sources run.
#[derive(Default)]
struct Host(std::collections::BTreeMap<u32, [f64; 3]>);
impl Host {
fn apply(&mut self, ops: Vec<Op>) {
for o in ops {
match o {
Op::Start(id, v) => assert!(self.0.insert(id, [v.at.x, v.at.y, v.at.z]).is_none()),
Op::Update(id, v) => assert!(self.0.insert(id, [v.at.x, v.at.y, v.at.z]).is_some()),
Op::Stop(id) => assert!(self.0.remove(&id).is_some()),
}
}
}
}
#[test]
fn engines_follow_the_recorded_tracks_at_the_replays_time_around_the_listener() {
let rec = recording();
let mut p = player(&rec);
let listener = DVec3::new(-5.0, 70.0, 12.0);
p.t = 450.0;
p.rate = 1.0;
let mut out = Out::default();
p.engines(&rec, listener, false, &mut out);
// The first declaration of a replay starts the engines afresh.
assert!(out.engine_cut);
assert_eq!(out.engines.len(), 2);
let drone = out.engines.iter().find(|d| d.key == 0x8000_0000).unwrap();
// The drone at tick 450 is at x = 450; the eye stands at x = 500; the player at -5.
assert!((drone.at.x - (listener.x + (450.0 - 500.0))).abs() < 1e-6, "{:?}", drone.at);
assert!((drone.at.z - (listener.z + (0.0 - 30.0))).abs() < 1e-6);
assert!(!drone.damaged && drone.vel.x > 0.0);
// The interceptor is lit after its cold launch only.
p.t = 401.0;
let mut out = Out::default();
p.engines(&rec, listener, false, &mut out);
assert_eq!(out.engines.iter().filter(|d| d.key == 0x8000_0001).count(), usize::from(401.0 - 400.0 > f64::from(MissileKind::Pac3.lit_after())));
}
#[test]
fn pause_seek_and_cut_release_and_restart_engines_without_leaving_any() {
let rec = recording();
let mut p = player(&rec);
let mut engines = Engines::default();
let mut host = Host::default();
let listener = DVec3::ZERO;
let mut tick = |p: &mut Player, engines: &mut Engines, host: &mut Host, shot: bool| {
let mut out = Out::default();
p.engines(&rec, listener, shot, &mut out);
host.apply(engines.step(std::mem::take(&mut out.engines), out.engine_cut, listener));
};
p.t = 100.0;
p.rate = 1.0;
for _ in 0..30 {
p.t += 1.0;
tick(&mut p, &mut engines, &mut host, false);
}
assert_eq!(host.0.len(), 1, "the drone alone");
// Paused: the sources go after their grace.
p.rate = 0.0;
for _ in 0..6 {
tick(&mut p, &mut engines, &mut host, false);
}
assert!(host.0.is_empty());
// Played on again: they start again without a cut.
p.rate = 1.0;
tick(&mut p, &mut engines, &mut host, false);
assert_eq!(host.0.len(), 1);
let before: Vec<u32> = host.0.keys().copied().collect();
// A seek back and forward, and a hundred shot changes: always one drone, a new source each time.
p.t = 50.0;
tick(&mut p, &mut engines, &mut host, false);
assert_eq!(host.0.len(), 1);
assert!(!host.0.contains_key(&before[0]), "the seek restarted the source");
p.t = 380.0;
tick(&mut p, &mut engines, &mut host, false);
for i in 0..100 {
p.t = 380.0 + f64::from(i % 7);
tick(&mut p, &mut engines, &mut host, i % 3 == 0);
assert!(host.0.len() <= 2);
}
// The drone is shot down at 600: its engine dies away and stays dead while the wreck falls
// (the interceptor, flying till 700, is the one source left).
p.t = 599.0;
tick(&mut p, &mut engines, &mut host, false);
let mut silent_after = None;
for i in 0..40 {
p.t = 600.0 + f64::from(i);
tick(&mut p, &mut engines, &mut host, false);
if host.0.len() == 1 && silent_after.is_none() {
silent_after = Some(i);
}
}
assert!(silent_after.is_some_and(|i| i <= 25), "{silent_after:?}");
// The film ends: nothing is left.
p.rate = 0.0;
for _ in 0..6 {
tick(&mut p, &mut engines, &mut host, false);
}
assert!(host.0.is_empty());
}
#[test]
fn a_fast_replay_is_silent() {
let rec = recording();
let mut p = player(&rec);
p.t = 100.0;
p.rate = 16.0;
let mut out = Out::default();
p.engines(&rec, DVec3::ZERO, false, &mut out);
assert!(out.engines.is_empty());
}
}
+21 -21
View File
@@ -20,6 +20,7 @@ mod ew;
pub mod lights;
mod logistics;
mod orders;
mod voices;
pub mod outcome;
mod units;
mod why;
@@ -81,6 +82,10 @@ pub struct Out {
pub models: Vec<Model>,
pub particles: Vec<Particle>,
pub sounds: Vec<(&'static str, DVec3, f32, f32)>,
/// The engines sounding this tick (persistent sources, `crate::engines`), and whether the
/// picture cut (a replay seek or a new shot): every engine then starts afresh.
pub engines: Vec<crate::engines::Decl>,
pub engine_cut: bool,
pub commands: Vec<String>,
/// Camera jolt (strength, seconds), the strongest this tick.
pub shake: Option<(f32, f32)>,
@@ -109,6 +114,10 @@ impl Out {
pub fn sound(&mut self, event: &'static str, at: DVec3, volume: f32, pitch: f32) {
self.sounds.push((event, at, volume, pitch));
}
/// An engine that sounds: `vel` is the simulation's, blocks a tick.
pub fn engine(&mut self, key: u32, event: &'static str, at: DVec3, vel: DVec3, gain: f32, pitch: f32, damaged: bool) {
self.engines.push(crate::engines::Decl { key, event, at, vel: vel * 20.0, gain, pitch, damaged });
}
fn jolt(&mut self, strength: f32, seconds: f32) {
if self.shake.is_none_or(|(s, _)| s < strength) {
self.shake = Some((strength.min(1.0), seconds));
@@ -1684,6 +1693,9 @@ impl Sim {
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 let Some((event, pitch)) = d.kind.engine_voice() {
out.engine(d.id, event, d.pos, d.vel, 0.8, pitch, true);
}
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) };
@@ -1927,9 +1939,9 @@ impl Sim {
out.particles("minecraft:cloud", back - d.vel.normalize_or_zero() * 1.5, 0.15, DVec3::ZERO, 0.005, 1);
}
}
let (event, pitch, every) = spec.sound;
if d.age % every == 0 {
out.sound(event, d.pos, 1.0, pitch);
// The engine is one source that follows the drone (#95).
if let Some((event, pitch)) = d.kind.engine_voice() {
out.engine(d.id, event, d.pos, d.vel, 0.8, pitch, false);
}
}
for (drone, pos) in splits {
@@ -2692,24 +2704,12 @@ impl Sim {
done.push((m.id, None, false, m.sam));
continue;
}
if lit && spec.flame == 0.0 {
if m.age % 20 == 1 {
out.sound("airdef:drone.lancet", m.pos, 0.8, 1.6);
}
} else if lit {
if (m.age - spec.cold) % 46 == 1 {
let pitch = match m.kind {
MissileKind::Pac3 => 1.0,
MissileKind::M57e6 => 1.12,
MissileKind::M48n6 => 0.88,
MissileKind::M77n6 => 0.8,
MissileKind::IrisT | MissileKind::Amraam => 1.05,
MissileKind::M9m317 => 0.9,
MissileKind::M9m338 => 1.15,
MissileKind::P1sun => 1.7,
};
out.sound("airdef:missile.flight", m.pos, 1.0, pitch);
}
if lit {
// The motor is one source that follows the missile from ignition on (#95).
let (event, gain, pitch) = m.kind.engine_voice();
out.engine(0x4000_0000 | m.id, event, m.pos, m.vel, gain, pitch, false);
}
if lit && spec.flame > 0.0 {
let back = m.pos - new * 1.4 * spec.scale;
out.particles("minecraft:flame", back, 0.05, -new * 0.1, 0.01, 2);
if m.age % 2 == 0 {
+40
View File
@@ -0,0 +1,40 @@
//! What the simulation tells the engine sources (`crate::engines`): which sound an entity's
//! engine makes, from the specs the rest of the simulation owns.
use super::{DroneKind, MissileKind};
impl DroneKind {
/// The sound of its engine and its pitch; none for the ballistic ones, which fall with a
/// scream played once (and the Oreshnik's blocks and submunitions, which have no engine).
pub fn engine_voice(self) -> Option<(&'static str, f32)> {
let spec = self.spec();
if spec.ballistic.is_some() || matches!(self, DroneKind::OreshnikBlock | DroneKind::OreshnikSub) {
return None;
}
Some((spec.sound.0, spec.sound.1))
}
}
impl MissileKind {
/// The sound of its motor, its gain and its pitch: a rocket's roar, or the electric whine of
/// a motor without a flame.
pub fn engine_voice(self) -> (&'static str, f32, f32) {
if self.spec().flame == 0.0 {
return ("airdef:drone.lancet", 0.8, 1.6);
}
let pitch = match self {
MissileKind::Pac3 => 1.0,
MissileKind::M57e6 => 1.12,
MissileKind::M48n6 => 0.88,
MissileKind::M77n6 => 0.8,
MissileKind::IrisT | MissileKind::Amraam => 1.05,
MissileKind::M9m317 => 0.9,
MissileKind::M9m338 => 1.15,
MissileKind::P1sun => 1.7,
};
("airdef:missile.flight", 1.0, pitch)
}
/// Ticks from the launch until the motor lights (a cold launch throws it out of the tube first).
pub fn lit_after(self) -> u32 {
self.spec().cold
}
}
@@ -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/36] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 01/37] 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/36] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 02/37] 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/36] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 03/37] 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/36] Mod models: draw far models as scaled impostors past
Subject: [PATCH 04/37] 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/36] Mod cameras: render.set-views draws the world from
Subject: [PATCH 05/37] 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/36] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 06/37] 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/36] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 07/37] 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/36] Mod cameras: distant terrain tiles, fog measured from
Subject: [PATCH 08/37] 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/36] Mods: game.world-id, so mods keep state per world (API
Subject: [PATCH 09/37] 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/36] cargo-horizon-mod: trial runs behind feature full, so a
Subject: [PATCH 10/37] 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/36] HD lighting: G-buffer pass, per-pixel mod point lights
Subject: [PATCH 11/37] 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/36] HD lighting stage 2: sun and moon shadow cascades, the
Subject: [PATCH 12/37] 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/36] HD: bind groups cached until a resize or a new source,
Subject: [PATCH 13/37] 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/36] HD: lights culled per 16x16 tile (surface and halo
Subject: [PATCH 14/37] 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/36] HD: GPU time per pass in the F3 timing list,
Subject: [PATCH 15/37] 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/36] HD: shadows draw only sun-facing quads and skip buried
Subject: [PATCH 16/37] 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/36] Mods: model handles (render.model-id, model-generation,
Subject: [PATCH 17/37] 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/36] HD: temporal upscaling (HORIZON_HD_SCALE: world,
Subject: [PATCH 18/37] 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/36] HD water: its own pass (colour, normal, depth), the
Subject: [PATCH 19/37] 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/36] HD water: no vanilla texture on the surface (the biome
Subject: [PATCH 20/37] 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/36] HD sky and light: a physical atmosphere (Rayleigh, Mie,
Subject: [PATCH 21/37] 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/36] HD water: a darker blue, a little clearer, ripples a
Subject: [PATCH 22/37] HD water: a darker blue, a little clearer, ripples a
little higher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 21:41:48 +0300
Subject: [PATCH 23/36] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
Subject: [PATCH 23/37] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
tint, blue hour; MIT), volumetric clouds with ground shadows instead of
vanilla's in HD, volumetric fog with sun shafts and morning mist
(HORIZON_HD_CLOUDS=0, HORIZON_HD_VOLFOG=0)
@@ -1,7 +1,7 @@
From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 21:43:22 +0300
Subject: [PATCH 24/36] Mods: world queries for simulations (render.query-rays,
Subject: [PATCH 24/37] Mods: world queries for simulations (render.query-rays,
query-sections, world-changes; API level 12): block-exact rays and section
occupancy by collision, sight or sound, answered next tick within budgets,
and a log of changed sections
@@ -1,7 +1,7 @@
From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 22:56:15 +0300
Subject: [PATCH 25/36] HD fixes: rain level from the world not from rain
Subject: [PATCH 25/37] HD fixes: rain level from the world not from rain
geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint
without a depth heuristic (#35), air fog once over water (#37), translucent
things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick
@@ -1,7 +1,7 @@
From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 23:19:53 +0300
Subject: [PATCH 26/36] HD split into modules (gfx/hd/: one Rust file and one
Subject: [PATCH 26/37] HD split into modules (gfx/hd/: one Rust file and one
WGSL file per pass, a shared prelude), contracts for colour, depth, alpha,
time and coordinates written down, tests that the frame layout and bindings
match between Rust and WGSL (#45)
@@ -1,7 +1,7 @@
From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 23:22:19 +0300
Subject: [PATCH 27/36] HD clouds: noise scales that divide the camera's
Subject: [PATCH 27/37] HD clouds: noise scales that divide the camera's
65536-block wrap, so the clouds and their shadows no longer jump at x or z =
0
@@ -1,7 +1,7 @@
From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:17:11 +0300
Subject: [PATCH 28/36] HD: volumetric smoke and missile trails with clear
Subject: [PATCH 28/37] HD: volumetric smoke and missile trails with clear
tunnels through fog and cloud (render.set-smoke, API level 14; #54); sun
speculars by material, wet surfaces and puddles in rain, wind in foliage
(HORIZON_HD_SPECULAR=0, HORIZON_HD_WIND=0, HORIZON_HD_SMOKE=0); HD shaders
@@ -1,7 +1,7 @@
From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:26:09 +0300
Subject: [PATCH 29/36] HD clouds composited by depth so terrain and vehicles
Subject: [PATCH 29/37] HD clouds composited by depth so terrain and vehicles
inside or behind the layer are hidden and those in front stay in front (#67);
world-anchored noise everywhere (cloud detail by height above the layer, fog,
smoke and puddle scales dividing the wrap) (#68); the replay's time is visual
@@ -1,7 +1,7 @@
From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:44:45 +0300
Subject: [PATCH 30/36] HD smoke: young trails drawn at least as wide as a grid
Subject: [PATCH 30/37] HD smoke: young trails drawn at least as wide as a grid
cell (density scaled to keep the mass), gentler bulges, so a trail is a
smooth ribbon and not a chain of puffs
@@ -1,7 +1,7 @@
From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:59:41 +0300
Subject: [PATCH 31/36] Mods: render.set-main-view (API level 15) renders the
Subject: [PATCH 31/37] Mods: render.set-main-view (API level 15) renders the
screen's world from a mod camera with the full pipeline, so replays get HD
and the post effects; small mod views get the post pass with their own camera
@@ -1,7 +1,7 @@
From 27e24527550942695164dd3a769a42a659e797ca Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 09:28:05 +0300
Subject: [PATCH 32/36] Spot lights for mods (render.set-spot-lights, API level
Subject: [PATCH 32/37] Spot lights for mods (render.set-spot-lights, API level
16): surfaces lit in the cone, a volumetric beam stopped by depth (#60); film
capture (render.capture-*, set-film-quality, API level 17): fixed-step clock,
frames piped to ffmpeg or PNGs, offline audio, no chat in the frames (#58);
@@ -1,7 +1,7 @@
From 615f5c3fa7c19a9895f8972a88333c6f250ad78a Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 14:30:39 +0300
Subject: [PATCH 33/36] HD clouds and fog polish: cloud panorama, cirrus,
Subject: [PATCH 33/37] HD clouds and fog polish: cloud panorama, cirrus,
terrain-aware fog, far smoke cascade; replay world log and film clock;
material overlays by sprite name
@@ -1,7 +1,7 @@
From 0254984fdcce53a4a0f2bff15f9be3b78c93842e Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 15:01:09 +0300
Subject: [PATCH 34/36] HD: coloured sun through glass and leaves, rain ripples
Subject: [PATCH 34/37] HD: coloured sun through glass and leaves, rain ripples
on water, film motion blur and depth of field
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 9645d3f1f47d8bb358d5630288bdd801bd874911 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 15:19:32 +0300
Subject: [PATCH 35/36] Film export: every frame in flight reaches the writer,
Subject: [PATCH 35/37] Film export: every frame in flight reaches the writer,
listener follows the main view, cloud wind on film frames, set-smoke reports
real activity
@@ -1,7 +1,7 @@
From 22d6835507d5627b0fe4a749b082b025b35e0262 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 15:43:11 +0300
Subject: [PATCH 36/36] Mod command trees: command.set-tree and
Subject: [PATCH 36/37] Mod command trees: command.set-tree and
set-suggestions, typed parse, colouring and completion from one tree (API 19)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
File diff suppressed because it is too large Load Diff