From cd3c4ed76d3308ec1d87341b962ff04aadada293 Mon Sep 17 00:00:00 2001 From: chexit Date: Sun, 1 Nov 2026 02:34:39 +0300 Subject: [PATCH] Separate live and replay worlds, refresh acoustic lifecycle (#105 #110 #111 #112) Co-Authored-By: Kimi --- docs/ACOUSTICS.md | 12 +- ...locks-while-replay-shows-history-105.patch | 887 +++++++++++++++++ ...ency-and-voice-lifecycle-110-111-112.patch | 927 ++++++++++++++++++ 3 files changed, 1822 insertions(+), 4 deletions(-) create mode 100644 horizon-patches/0043-Mods-read-live-blocks-while-replay-shows-history-105.patch create mode 100644 horizon-patches/0044-Acoustics-follows-chunk-residency-and-voice-lifecycle-110-111-112.patch diff --git a/docs/ACOUSTICS.md b/docs/ACOUSTICS.md index 4f3aa3f..ec8461f 100644 --- a/docs/ACOUSTICS.md +++ b/docs/ACOUSTICS.md @@ -27,9 +27,12 @@ GPL-файлы три: `acoustics/spr_occlusion.rs`, (стадия 2) `acoustics - Дальность 512 блоков (SPR: ~64), счёт клеток, не времени: 40 тыс. клеток на тик встроенно (новый звук, 1 путь, чтобы взрыв не начинался «ярким»), 400 тыс. на задание рабочего потока (9 путей, одно задание за раз); при съёмке всё встроенно, 600 тыс. клеток на тик. Порядок: по громкости, затем по id; сумма голосов в `mix` теперь тоже по id (раньше порядок HashMap делал дорожку - недетерминированной при нескольких голосах). + недетерминированной при нескольких голосах). При выходе за дальность/порог слышимости или потере геометрии микшеру явно + возвращают обычные `gain=1` и воздушный `cutoff` без старой стены; посылки и эхо отзываются, при возвращении считаются заново. - Кеш по ячейкам 2x2x2 источника и слушателя, 4096 записей; сброс по секциям `World.sound_changes` (как `mod_changes`), - по эпохе мира и при переполнении; результат задания, начатого до изменения в его коробке, отбрасывается. + по эпохе мира и при переполнении; результат задания, начатого до изменения в его коробке, отбрасывается. Вставка, замена + и выгрузка чанка меняют residency-счётчик и инвалидируют затронутые секции и старые worker-задания. Трасса, встретившая + Unknown, пересчитывается не позднее чем через 40 тиков, поэтому приехавшая геометрия применяется без движения слушателя. - Воздух: `0.994^расстояние` на верха (EFX по умолчанию, не проверено по OpenAL Soft) — иначе при `acoustics=on` пропадала бы дальность, которую раньше давали `_far`-сэмплы. - Мод: API 21, `render.acoustics()`; при включённой акустике мод играет ближние варианты до 480 блоков (`ACOUSTIC_FAR`), дальше @@ -64,8 +67,9 @@ GPL-файлы три: `acoustics/spr_occlusion.rs`, (стадия 2) `acoustics - Срез близких отражений: отвод ближе 30 мс к прямому звуку отбрасывается, до 50 мс плавно входит (иначе стена рядом с игроком даёт гребёнку, а не эхо). Из найденных плоскостей берутся до 6 сильнейших не ближе 40 мс друг к другу. - Кому: `deserves_echo` в `audio.rs`: не петля, не относительный, громкость >= 0.5, дальность (linear) >= 96, длина 0.1-12 с, - прошло меньше 70% звука; не более `ECHO_VOICES = 8` одновременно (самые громкие). Сотня двигателей эха не получает - (петли), сотня выстрелов: восемь самых громких. + прошло меньше 70% звука; не более `ECHO_VOICES = 8` активных наборов (самые громкие). При смене приоритетов старый набор + отзывается и затухает ~50 мс, поэтому во время ротации кратковременно звучат до 16, затем снова 8. Сотня двигателей эха + не получает (петли), сотня выстрелов: восемь самых громких. - Планирование: счёт по клеткам, не по времени. Живьём трассировка идёт на том же рабочем потоке (в задании до 4 трасс и 240 тыс. клеток), кеш по блочным ячейкам источника и слушателя (центры ячеек, 256 записей), сброс по секциям, которых касаются точки опоры отводов, и по эпохе; в съёмке трасса синхронная, 480 тыс. клеток на тик. Микшер получает отводы diff --git a/horizon-patches/0043-Mods-read-live-blocks-while-replay-shows-history-105.patch b/horizon-patches/0043-Mods-read-live-blocks-while-replay-shows-history-105.patch new file mode 100644 index 0000000..55f2c7d --- /dev/null +++ b/horizon-patches/0043-Mods-read-live-blocks-while-replay-shows-history-105.patch @@ -0,0 +1,887 @@ +From 2fff36c3c6183a440d55371b2db0f3bd6eb586c5 Mon Sep 17 00:00:00 2001 +From: chexit +Date: Sun, 1 Nov 2026 02:30:48 +0300 +Subject: [PATCH 1/2] Mods read live blocks while replay shows history (#105) + +Co-Authored-By: Kimi +--- + crates/horizon-client/src/game.rs | 9 + + crates/horizon-client/src/game/mod_world.rs | 80 +----- + crates/horizon-client/src/game/mods.rs | 10 +- + crates/horizon-client/src/lib.rs | 1 + + crates/horizon-client/src/streaming.rs | 4 +- + crates/horizon-client/src/world.rs | 97 ++++++- + crates/horizon-client/src/world_log.rs | 135 ++++++++- + crates/horizon-client/src/world_view.rs | 296 ++++++++++++++++++++ + 8 files changed, 549 insertions(+), 83 deletions(-) + create mode 100644 crates/horizon-client/src/world_view.rs + +diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs +index d7529b12d..a2099747b 100644 +--- a/crates/horizon-client/src/game.rs ++++ b/crates/horizon-client/src/game.rs +@@ -10798,6 +10798,15 @@ impl Game<'_> { + HeightmapType::MotionBlocking.is_opaque(properties.heightmap_state(s)) + }) + } ++ /// `rain_height` over the real blocks: what a mod's ground query reads, whatever a film's ++ /// view of an earlier time shows (the weather's rain follows the picture). ++ fn live_rain_height(&self, x: i32, z: i32) -> i32 { ++ use horizon_world::{heightmap::HeightmapType, worldgen::terrain::TerrainBlockStates}; ++ let properties = &self.pack.assets.properties; ++ self.world.live_motion_blocking(x, z, |s| { ++ HeightmapType::MotionBlocking.is_opaque(properties.heightmap_state(s)) ++ }) ++ } + fn tick_weather_effects(&mut self) { + if self.flash > 0 { + self.flash -= 1; +diff --git a/crates/horizon-client/src/game/mod_world.rs b/crates/horizon-client/src/game/mod_world.rs +index 7e3a97b0c..2cda0fae7 100644 +--- a/crates/horizon-client/src/game/mod_world.rs ++++ b/crates/horizon-client/src/game/mod_world.rs +@@ -4,11 +4,13 @@ + //! the game holds: a chunk it doesn't have is `unknown`, never air. The + //! walk and the bit packing are `horizon_mods::world_query`; this is the + //! game's side: which blocks count under each policy, and the chunks. ++//! The chunks are read through `horizon_client::world_view`, which puts back ++//! every block a film's view of an earlier time shows otherwise: what a mod ++//! simulates is the real world, whatever the picture (and #105). + use super::Game; +-use horizon_client::chunk::{Chunk, MIN_Y, SECTIONS}; +-use horizon_client::world::World; ++use horizon_client::world_view::{SectionView, WorldProbe}; + use horizon_core::geometry::Axis; +-use horizon_mods::world_query::{self, BlockProbe, Cell, SectionBits, Trace}; ++use horizon_mods::world_query::{SectionBits, Trace}; + use horizon_mods::{ + SectionFill, WorldChanges, WorldPolicy, WorldRay, WorldRayAnswer, WorldSection, WorldVerdict, + }; +@@ -43,41 +45,6 @@ fn mask(policy: WorldPolicy) -> u8 { + } + } + +-/// The blocks of the resident chunks, for [`world_query::trace`]. +-struct Probe<'a> { +- world: &'a World, +- tables: &'a WorldTables, +- mask: u8, +- last: Option<((i32, i32), &'a Arc)>, +- cells: usize, +-} +-impl BlockProbe for Probe<'_> { +- fn cell(&mut self, x: i32, y: i32, z: i32) -> Cell<'_> { +- self.cells += 1; +- let key = (x >> 4, z >> 4); +- let chunk = match self.last { +- Some((k, c)) if k == key => c, +- _ => { +- let Some(c) = self.world.chunks.get(&key) else { +- return Cell::Unknown; +- }; +- self.last = Some((key, c)); +- c +- } +- }; +- let state = chunk.get(x & 15, y, z & 15) as usize; +- if self +- .tables +- .flags +- .get(state) +- .is_none_or(|f| f & self.mask == 0) +- { +- return Cell::Empty; +- } +- Cell::Solid(self.tables.boxes[state].as_deref()) +- } +-} +- + impl Game<'_> { + /// The tables for this world, made when it is new. + fn world_tables(&mut self) -> Arc { +@@ -158,7 +125,7 @@ impl Game<'_> { + let (changed, overflow) = self.world.mod_changes.take(); + let changes = WorldChanges { + epoch: self.world.epoch, +- revision: self.world.revision, ++ revision: self.world.live_revision, + overflow, + sections: changed, + }; +@@ -192,14 +159,8 @@ impl Game<'_> { + if budget == 0 { + return (unknown(r.id, 0.0), 0); + } +- let mut probe = Probe { +- world: &self.world, +- tables, +- mask: mask(r.policy), +- last: None, +- cells: 0, +- }; +- let t = world_query::trace( ++ let mut probe = WorldProbe::new(&self.world, &tables.flags, &tables.boxes, mask(r.policy)); ++ let t = horizon_mods::world_query::trace( + &mut probe, + [r.start.0, r.start.1, r.start.2], + [r.end.0, r.end.1, r.end.2], +@@ -238,25 +199,12 @@ impl Game<'_> { + fill: SectionFill::Unknown, + bits: Vec::new(), + }; +- let Some(chunk) = self.world.chunks.get(&(x, z)) else { +- return s; +- }; +- let index = y - (MIN_Y >> 4); +- if !(0..SECTIONS as i32).contains(&index) { +- s.fill = SectionFill::Empty; +- return s; +- } +- let section = &chunk.sections[index as usize]; +- let mut states = [0u32; 4096]; +- if let Some(v) = section.blocks.uniform() { +- states.fill(v); +- } else { +- section.unpack(&mut states); +- } +- match world_query::section_bits(&states, &tables.flags, mask) { +- SectionBits::Empty => s.fill = SectionFill::Empty, +- SectionBits::Full => s.fill = SectionFill::Full, +- SectionBits::Mixed(bits) => { ++ match self.world.live_section(x, y, z, &tables.flags, mask) { ++ SectionView::Unknown => {} ++ SectionView::Empty => s.fill = SectionFill::Empty, ++ SectionView::Bits(SectionBits::Empty) => s.fill = SectionFill::Empty, ++ SectionView::Bits(SectionBits::Full) => s.fill = SectionFill::Full, ++ SectionView::Bits(SectionBits::Mixed(bits)) => { + s.fill = SectionFill::Mixed; + s.bits = bits.to_vec(); + } +diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs +index e96744775..e95aaed2d 100644 +--- a/crates/horizon-client/src/game/mods.rs ++++ b/crates/horizon-client/src/game/mods.rs +@@ -789,8 +789,9 @@ impl Game<'_> { + tickets.truncate(CHUNK_TICKETS); + } + } +- /// `render.query-ground`'s answer: the heightmap, or `i32::MIN` for a column whose chunk a +- /// mod asked to load and which is not there yet. ++ /// `render.query-ground`'s answer: the heightmap of the real blocks, or `i32::MIN` for a ++ /// column whose chunk a mod asked to load and which is not there yet. A film's view of an ++ /// earlier time is the picture only: a simulation's ground is the one it flies over. + fn mod_ground(&self, x: i32, z: i32) -> i32 { + let key = (x >> 4, z >> 4); + if !self.world.chunks.contains_key(&key) +@@ -798,7 +799,7 @@ impl Game<'_> { + { + return i32::MIN; + } +- self.rain_height(x, z) ++ self.live_rain_height(x, z) + } + /// The mods' persistent sound sources (API level 20): their changes go to the mixer, and the + /// sources of a mod that no longer runs are released. The generation the mods read is the +@@ -941,6 +942,9 @@ impl Game<'_> { + match wanted { + Some(id) => { + if self.world.log.as_ref().is_none_or(|l| l.id != id) { ++ // A log replaced while it still shows earlier blocks puts them back first: ++ // only the log knows which block is real where the picture differs. ++ self.world.view_log(None, usize::MAX, None); + self.world.log = Some(WorldLog::new(id, self.ticks)); + } + } +diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs +index 879133e81..d7fa5303b 100644 +--- a/crates/horizon-client/src/lib.rs ++++ b/crates/horizon-client/src/lib.rs +@@ -186,6 +186,7 @@ pub mod win_screen; + pub mod world; + pub mod world_log; + pub mod world_spawn; ++pub mod world_view; + pub mod worldborder; + pub mod zip; + pub use horizon_client_core::Result; +diff --git a/crates/horizon-client/src/streaming.rs b/crates/horizon-client/src/streaming.rs +index 6588ac32d..4432626f0 100644 +--- a/crates/horizon-client/src/streaming.rs ++++ b/crates/horizon-client/src/streaming.rs +@@ -626,7 +626,7 @@ impl Streaming { + let key = (chunk.x, chunk.z); + self.lighting.remove(&key); + self.light_snapshots.remove(&key); +- world.chunks.insert(key, Arc::new(chunk)); ++ world.insert_chunk(key, Arc::new(chunk)); + self.lit.insert(key); + self.generated_total += 1; + for dz in -1..=1 { +@@ -770,7 +770,7 @@ impl Streaming { + // A chunk saved with its light needs no light job; its unlit + // neighbours still light themselves from it. + let loaded = chunk.light_loaded; +- world.chunks.insert(key, Arc::new(chunk)); ++ world.insert_chunk(key, Arc::new(chunk)); + if loaded { + self.lit.insert(key); + for sy in 0..SECTIONS as i32 { +diff --git a/crates/horizon-client/src/world.rs b/crates/horizon-client/src/world.rs +index d42e51ac8..5c2b0c267 100644 +--- a/crates/horizon-client/src/world.rs ++++ b/crates/horizon-client/src/world.rs +@@ -52,7 +52,12 @@ pub struct World { + pub fast_lava: bool, + pub chunks: crate::chunk_map::ChunkMap, + pub random: LegacyRandom, ++ /// The revision of the blocks shown: a real change, and a view of an earlier time that ++ /// changes the picture (the renderer and the acoustics follow it). Mods cache on ++ /// `live_revision`: a film's view is no change of the world they simulate. + pub revision: u64, ++ /// The revision of the real blocks, which a view of an earlier time leaves alone. ++ pub live_revision: u64, + pub health_rules: HealthRules, + pub air: BlockStateId, + /// Sections whose geometry changed and need remeshing. +@@ -130,6 +135,23 @@ impl ModChanges { + self.sections.insert((x >> 4, y >> 4, z >> 4)); + } + } ++ /// A column's residency changed (a chunk loaded, was replaced by a reload ++ /// or is gone): every section of the column counts as changed, for caches ++ /// keyed to sections. Bounded like `note` is. ++ pub fn column(&mut self, x: i32, z: i32) { ++ if self.sections.len() < Self::MAX { ++ for sy in 0..SECTIONS as i32 { ++ self.sections.insert((x, sy, z)); ++ } ++ } ++ if self.sections.len() >= Self::MAX { ++ self.overflow = true; ++ } ++ } ++ /// The change log overflowed or the resident columns were cleared: every cached section may be stale. ++ pub fn overflow(&mut self) { ++ self.overflow = true; ++ } + /// What changed since the last call, as the mod reads it. + pub fn take(&mut self) -> (Vec<(i32, i32, i32)>, bool) { + ( +@@ -209,6 +231,7 @@ impl World { + chunks: Default::default(), + random: LegacyRandom::new(seed), + revision: 0, ++ live_revision: 0, + health_rules: HealthRules::default(), + air, + fast_lava: false, +@@ -335,6 +358,7 @@ impl World { + self.watched_writes.push(((x, y, z), previous, state.0)); + } + self.revision = self.revision.wrapping_add(1); ++ self.live_revision = self.live_revision.wrapping_add(1); + if self.mod_changes.watching { + self.mod_changes.note(x, y, z); + } +@@ -378,7 +402,9 @@ impl World { + } + /// A mod's view of the world at an earlier tick (`render.set-world-time-view`): moves it + /// toward `target` by at most `budget` changes, leaving alone the blocks around `protect` +- /// (a block position whose neighbourhood stays as it is: the player's). Only the client's copy of the blocks changes. True when it has got there. ++ /// (a block position whose neighbourhood stays as it is: the player's). Only the picture ++ /// changes: the renderer meshes these chunks, while a simulation reads the real blocks ++ /// ([`Self::live_block`]). True when it has got there. + pub fn view_log( + &mut self, + target: Option, +@@ -400,7 +426,9 @@ impl World { + done + } + } +-/// The world as a log's view writes it: only what is drawn changes. ++/// The world as a log's view writes it: the picture the renderer meshes changes, and nothing ++/// else. The real state of every block written stays in the log (`WorldLog::real`), which is ++/// what a simulation reads; the world's own change signals stay the real ones. + struct ShownBlocks<'a>(&'a mut World); + impl crate::world_log::Blocks for ShownBlocks<'_> { + fn get(&self, pos: [i32; 3]) -> Option { +@@ -419,10 +447,10 @@ impl crate::world_log::Blocks for ShownBlocks<'_> { + }; + let previous = Arc::make_mut(chunk).set(x & 15, y, z & 15, state); + if previous != state { ++ // The picture changed, so the renderer and the acoustics look at it again; the ++ // blocks did not, so `live_revision` and `mod_changes` — what mods cache and ++ // simulate by — stay as they are. + w.revision = w.revision.wrapping_add(1); +- if w.mod_changes.watching { +- w.mod_changes.note(x, y, z); +- } + if w.sound_changes.watching { + w.sound_changes.note(x, y, z); + } +@@ -452,6 +480,65 @@ impl World { + .get(&(x >> 4, z >> 4)) + .map_or(self.air, |chunk| chunk.block(x & 15, y, z & 15)) + } ++ /// Inserts a whole current column. A replay keeps its picture, but the real states behind it ++ /// are refreshed from this column and simulations hear the change. ++ pub fn insert_chunk(&mut self, key: (i32, i32), mut chunk: Arc) -> Option> { ++ if let Some(log) = &mut self.log { ++ let current = self.chunks.get(&key).cloned(); ++ log.reload_column(key.0, key.1, current.as_deref(), Arc::make_mut(&mut chunk)); ++ } ++ self.live_revision = self.live_revision.wrapping_add(1); ++ if self.mod_changes.watching { ++ self.mod_changes.column(key.0, key.1); ++ } ++ self.chunks.insert(key, chunk) ++ } ++ /// The real block at `(x, y, z)`: what a simulation reads, where a view of an earlier ++ /// time shows something else for a film ([`Self::view_log`]). [`Self::block`] stays the ++ /// picture, which is what the renderer meshes. ++ pub fn live_block(&self, x: i32, y: i32, z: i32) -> BlockStateId { ++ match self.log.as_ref().and_then(|log| log.real([x, y, z])) { ++ Some(real) => BlockStateId(real), ++ None => self.block(x, y, z), ++ } ++ } ++ /// Whether a view of an earlier time shows a block of the column `(x >> 4, z >> 4)` ++ /// otherwise: until it does, the column reads as the real one. ++ pub fn view_shows_in_column(&self, cx: i32, cz: i32) -> bool { ++ self.log ++ .as_ref() ++ .is_some_and(|log| log.shows_in_column(cx, cz)) ++ } ++ /// The blocks of section `(cx, sy, cz)` (vanilla section coordinates, `sy = y >> 4`), in ++ /// `chunk::index` order, with every block a view of an earlier time shows otherwise put ++ /// back to its real state. ++ pub fn live_section_states(&self, cx: i32, sy: i32, cz: i32, states: &mut [u32; 4096]) { ++ let Some(log) = &self.log else { ++ return; ++ }; ++ if !log.shows_in_section(cx, sy, cz) { ++ return; ++ } ++ for ([x, y, z], real) in log.real_in_section(cx, sy, cz) { ++ states[crate::chunk::index(x, y, z)] = real; ++ } ++ } ++ /// [`Self::motion_blocking`] over the real blocks: a mod's ground query, whatever a view ++ /// of an earlier time shows. ++ pub fn live_motion_blocking( ++ &self, ++ x: i32, ++ z: i32, ++ blocks: impl Fn(BlockStateId) -> bool, ++ ) -> i32 { ++ if !self.view_shows_in_column(x >> 4, z >> 4) { ++ return self.motion_blocking(x, z, blocks); ++ } ++ (MIN_Y..MIN_Y + (SECTIONS as i32) * 16) ++ .rev() ++ .find(|&y| blocks(self.live_block(x, y, z))) ++ .map_or(MIN_Y, |y| y + 1) ++ } + /// Whether a block of the box `lo..=hi` may match, from the palettes of + /// the sections it reaches (a palette of the whole registry may hold + /// anything): `false` means [`Self::block`] matches in none of its cells, +diff --git a/crates/horizon-client/src/world_log.rs b/crates/horizon-client/src/world_log.rs +index d0c0b1158..315388c00 100644 +--- a/crates/horizon-client/src/world_log.rs ++++ b/crates/horizon-client/src/world_log.rs +@@ -7,6 +7,11 @@ + //! while the cursor is behind the end is logged and kept as the block's real state, but not + //! written (it lies after the view's time): it shows when the cursor passes it, or when the + //! view ends and every displaced block is put back. Pure over [`Blocks`]; the world is one. ++//! ++//! A block written here is the picture (what the renderer meshes): the real state of every ++//! displaced block stays in `shown`, and what stands for the simulation reads it ++//! ([`WorldLog::real`], `World::live_block`). Moving the view changes the picture alone, so it ++//! is no block change of the world a mod simulates. + use std::collections::{HashMap, VecDeque}; + + pub type Pos = [i32; 3]; +@@ -39,6 +44,10 @@ pub struct WorldLog { + cursor: u64, + /// Blocks shown other than they are: their real state. + shown: HashMap, ++ /// How many blocks of each section (`x >> 4, y >> 4, z >> 4`) and column (`x >> 4, ++ /// z >> 4`) are in `shown`: absent means none, so a query passes those over whole. ++ sections: HashMap<(i32, i32, i32), usize>, ++ columns: HashMap<(i32, i32), usize>, + cap: usize, + /// The tick the log began, and the tick of the newest change it let go. + start: u64, +@@ -56,6 +65,8 @@ impl WorldLog { + base: 0, + cursor: 0, + shown: HashMap::new(), ++ sections: HashMap::new(), ++ columns: HashMap::new(), + cap: cap.max(1), + start: tick, + lost: None, +@@ -82,6 +93,54 @@ impl WorldLog { + pub fn displaced(&self) -> usize { + self.shown.len() + } ++ /// The real state of a block the view shows otherwise, which is what a simulation reads: ++ /// None where the block is shown as it is. ++ pub fn real(&self, pos: Pos) -> Option { ++ self.shown.get(&pos).copied() ++ } ++ /// Whether the view shows a block of section `(x >> 4, y >> 4, z >> 4)` otherwise. ++ pub fn shows_in_section(&self, cx: i32, sy: i32, cz: i32) -> bool { ++ self.sections.contains_key(&(cx, sy, cz)) ++ } ++ /// Whether the view shows a block of column `(x >> 4, z >> 4)` otherwise. ++ pub fn shows_in_column(&self, cx: i32, cz: i32) -> bool { ++ self.columns.contains_key(&(cx, cz)) ++ } ++ /// The blocks of a section the view shows otherwise, with their real states. ++ pub fn real_in_section( ++ &self, ++ cx: i32, ++ sy: i32, ++ cz: i32, ++ ) -> impl Iterator + '_ { ++ self.shown.iter().filter_map(move |(&pos, &real)| { ++ (pos[0] >> 4 == cx && pos[1] >> 4 == sy && pos[2] >> 4 == cz).then_some((pos, real)) ++ }) ++ } ++ /// A whole column arrived from the server or generation: its current blocks replace the real ++ /// states saved behind the picture, while the picture itself stays at the replay's time. ++ pub fn reload_column( ++ &mut self, ++ cx: i32, ++ cz: i32, ++ current: Option<&crate::chunk::Chunk>, ++ next: &mut crate::chunk::Chunk, ++ ) { ++ let changes: Vec<(Pos, u32, u32)> = self ++ .shown ++ .iter() ++ .filter(|(pos, _)| pos[0] >> 4 == cx && pos[2] >> 4 == cz) ++ .map(|(&pos, &real)| { ++ let fresh = next.get(pos[0] & 15, pos[1], pos[2] & 15); ++ let picture = current.map_or(real, |chunk| chunk.get(pos[0] & 15, pos[1], pos[2] & 15)); ++ (pos, fresh, picture) ++ }) ++ .collect(); ++ for (pos, fresh, picture) in changes { ++ next.set(pos[0] & 15, pos[1], pos[2] & 15, picture); ++ self.shown.insert(pos, fresh); ++ } ++ } + /// The first sequence number whose change came after tick `t` (the world at `t` is the real + /// one with those undone). + fn seq_after(&self, t: u64) -> u64 { +@@ -114,7 +173,7 @@ impl WorldLog { + self.cursor = self.end(); + } + if deferred { +- self.shown.insert(pos, new); ++ self.displace(pos, new); + if at_end { + // Displaced blocks wait for their restoration with the cursor at the end. + self.cursor = self.end(); +@@ -162,7 +221,7 @@ impl WorldLog { + .collect(); + for (pos, real) in rest { + blocks.put(pos, real); +- self.shown.remove(&pos); ++ self.put_back(pos); + } + } + self.cursor == goal && (goal != self.end() || self.shown.is_empty()) +@@ -186,13 +245,45 @@ impl WorldLog { + None => return, + }, + }; +- if !blocks.put(pos, state) { +- self.shown.remove(&pos); +- } else if state == real { +- self.shown.remove(&pos); ++ if !blocks.put(pos, state) || state == real { ++ self.put_back(pos); + } else { +- self.shown.insert(pos, real); ++ self.displace(pos, real); ++ } ++ } ++ /// Keeps `real` as the real state of a block shown otherwise. ++ fn displace(&mut self, pos: Pos, real: u32) { ++ if self.shown.insert(pos, real).is_some() { ++ return; ++ } ++ count( ++ &mut self.sections, ++ (pos[0] >> 4, pos[1] >> 4, pos[2] >> 4), ++ 1, ++ ); ++ count(&mut self.columns, (pos[0] >> 4, pos[2] >> 4), 1); ++ } ++ /// Forgets a displaced block: what is shown is its real state again. ++ fn put_back(&mut self, pos: Pos) { ++ if self.shown.remove(&pos).is_none() { ++ return; + } ++ count( ++ &mut self.sections, ++ (pos[0] >> 4, pos[1] >> 4, pos[2] >> 4), ++ -1, ++ ); ++ count(&mut self.columns, (pos[0] >> 4, pos[2] >> 4), -1); ++ } ++} ++ ++/// Keeps the number of displaced blocks under `key`, dropping it at zero. ++fn count(counts: &mut HashMap, key: K, by: isize) { ++ let n = counts.get(&key).copied().unwrap_or(0) as isize + by; ++ if n > 0 { ++ counts.insert(key, n as usize); ++ } else { ++ counts.remove(&key); + } + } + +@@ -369,4 +460,34 @@ mod tests { + log.view(&mut w, None, 100, all); + assert_eq!(w.shows([0, 0, 0]), 7); + } ++ ++ #[test] ++ fn the_real_blocks_of_a_view_are_read_where_it_shows_others() { ++ let (mut w, mut log) = raid(); ++ log.view(&mut w, Some(5), 100, all); ++ // The sections and columns the view touches, and the real state of what it shows otherwise. ++ assert_eq!(log.real([0, 0, 0]), Some(7)); ++ assert_eq!(log.real([9, 0, 0]), None); ++ assert!(log.shows_in_column(0, 0)); ++ assert!(!log.shows_in_column(1, 0)); ++ assert!(log.shows_in_section(0, 0, 0)); ++ assert!(!log.shows_in_section(0, 1, 0)); ++ let displaced: Vec<_> = log.real_in_section(0, 0, 0).collect(); ++ assert_eq!(displaced.len(), 3); ++ for (pos, real) in [([0, 0, 0], 7), ([1, 0, 0], 0), ([2, 0, 0], 0)] { ++ assert!(displaced.contains(&(pos, real))); ++ } ++ // A change arriving behind the view is the block's real state, shown nowhere. ++ w.set(&mut log, 40, [20, 20, 3], 5); ++ assert_eq!(w.shows([20, 20, 3]), 0); ++ assert_eq!(log.real([20, 20, 3]), Some(5)); ++ assert!(log.shows_in_column(1, 0) && log.shows_in_section(1, 1, 0)); ++ // The end puts every block back: nothing is left to read. ++ assert!(log.view(&mut w, None, 100, all)); ++ for pos in [[0, 0, 0], [20, 20, 3]] { ++ assert_eq!(log.real(pos), None); ++ } ++ assert!(!log.shows_in_column(0, 0) && !log.shows_in_column(1, 0)); ++ assert!(!log.shows_in_section(0, 0, 0) && !log.shows_in_section(1, 1, 0)); ++ } + } +diff --git a/crates/horizon-client/src/world_view.rs b/crates/horizon-client/src/world_view.rs +new file mode 100644 +index 000000000..fdc3f6515 +--- /dev/null ++++ b/crates/horizon-client/src/world_view.rs +@@ -0,0 +1,296 @@ ++//! The world as a mod's simulation reads it (capability `render`, AirDef fork): the real ++//! blocks. A film's view of an earlier time (`World::view_log`) is written into the chunks ++//! the renderer meshes, so a query that stands for a simulation has to read past it: a raid ++//! does not go back in time with the picture (#105). ++use crate::chunk::{Chunk, MIN_Y, SECTIONS}; ++use crate::world::World; ++use horizon_mods::world_query::{BlockProbe, Cell, SectionBits, section_bits}; ++use std::sync::Arc; ++ ++/// The resident chunks, with every block a view of an earlier time shows otherwise put back ++/// to its real state. ++pub struct WorldProbe<'a> { ++ world: &'a World, ++ /// Per state: the policy bits that it stops. ++ flags: &'a [u8], ++ /// Per state: its collision boxes in block space; none for a full cube (or a state that ++ /// stops nothing). ++ boxes: &'a [Option>], ++ mask: u8, ++ /// The last column read, and whether the view shows any of its blocks otherwise. ++ last: Option<((i32, i32), &'a Arc, bool)>, ++ /// Cells read. ++ pub cells: usize, ++} ++ ++impl<'a> WorldProbe<'a> { ++ pub fn new( ++ world: &'a World, ++ flags: &'a [u8], ++ boxes: &'a [Option>], ++ mask: u8, ++ ) -> Self { ++ Self { ++ world, ++ flags, ++ boxes, ++ mask, ++ last: None, ++ cells: 0, ++ } ++ } ++} ++ ++impl BlockProbe for WorldProbe<'_> { ++ fn cell(&mut self, x: i32, y: i32, z: i32) -> Cell<'_> { ++ self.cells += 1; ++ let key = (x >> 4, z >> 4); ++ let (chunk, view) = match self.last { ++ Some((k, chunk, view)) if k == key => (chunk, view), ++ _ => { ++ let Some(chunk) = self.world.chunks.get(&key) else { ++ return Cell::Unknown; ++ }; ++ let view = self.world.view_shows_in_column(key.0, key.1); ++ self.last = Some((key, chunk, view)); ++ (chunk, view) ++ } ++ }; ++ // Only a column the view touches costs a lookup per block. ++ let state = if view { ++ self.world.live_block(x, y, z).0 ++ } else { ++ chunk.get(x & 15, y, z & 15) ++ } as usize; ++ if self.flags.get(state).is_none_or(|f| f & self.mask == 0) { ++ return Cell::Empty; ++ } ++ Cell::Solid(self.boxes[state].as_deref()) ++ } ++} ++ ++/// A section as a mod's query reads it: the real blocks, never a film's picture. ++#[derive(Clone, Debug, PartialEq, Eq)] ++pub enum SectionView { ++ /// No chunk holds the column. ++ Unknown, ++ /// The section lies outside the world's height: nothing there. ++ Empty, ++ /// Its real blocks under the state table. ++ Bits(SectionBits), ++} ++ ++impl World { ++ /// A section (`x >> 4, y >> 4, z >> 4`: vanilla section coordinates) under a state table ++ /// and a policy mask, read from the real blocks. ++ pub fn live_section(&self, cx: i32, sy: i32, cz: i32, flags: &[u8], mask: u8) -> SectionView { ++ let Some(chunk) = self.chunks.get(&(cx, cz)) else { ++ return SectionView::Unknown; ++ }; ++ let index = sy - (MIN_Y >> 4); ++ if !(0..SECTIONS as i32).contains(&index) { ++ return SectionView::Empty; ++ } ++ let section = &chunk.sections[index as usize]; ++ let mut states = [0u32; 4096]; ++ if let Some(v) = section.blocks.uniform() { ++ states.fill(v); ++ } else { ++ section.unpack(&mut states); ++ } ++ self.live_section_states(cx, sy, cz, &mut states); ++ SectionView::Bits(section_bits(&states, flags, mask)) ++ } ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ use crate::chunk::index; ++ use crate::world_log::WorldLog; ++ use horizon_mods::world_query::{Trace, trace}; ++ use horizon_world::registry::BlockStateId; ++ use std::sync::Arc; ++ ++ const AIR: u32 = 0; ++ const STONE: u32 = 1; ++ /// The policy bits of `COLLISION | SIGHT | SOUND`, as `game::mod_world` makes them. ++ const ALL: u8 = 0b111; ++ /// The section of the wall at y 100 (`y >> 4`). ++ const SY: i32 = 6; ++ ++ type Tables = (Vec, Vec>>); ++ ++ /// A floor at y 70 and a wall at x 10, z 0, in the one resident chunk. ++ fn walled() -> World { ++ let mut world = World::new(BlockStateId(AIR), 0); ++ world.chunks.insert((0, 0), Arc::new(Chunk::empty(0, 0))); ++ for z in 0..=2 { ++ for x in 0..=14 { ++ world.set(x, 70, z, BlockStateId(STONE)).unwrap(); ++ } ++ } ++ world.set(10, 100, 0, BlockStateId(STONE)).unwrap(); ++ world ++ } ++ ++ /// State flags and collision boxes: stone stops every policy, as a full cube. ++ fn tables() -> Tables { ++ let mut flags = vec![0u8; 8]; ++ flags[STONE as usize] = ALL; ++ (flags, vec![None; 8]) ++ } ++ ++ /// A collision flight from x 9.5 to 11.5 at y 100.5, along `z`, as a mod asks one. ++ fn ray(world: &World, z: i32, tables: &Tables) -> Trace { ++ let mut probe = WorldProbe::new(world, &tables.0, &tables.1, 0b001); ++ let z = f64::from(z) + 0.5; ++ trace(&mut probe, [9.5, 100.5, z], [11.5, 100.5, z]) ++ } ++ ++ /// Whether the section's presented bits have the block `(10, y, 0)` set. ++ fn presented_bit(world: &World, flags: &[u8], y: i32) -> bool { ++ let chunk = world.chunks.get(&(0, 0)).unwrap(); ++ let mut states = [0u32; 4096]; ++ chunk.sections[((y - MIN_Y) >> 4) as usize].unpack(&mut states); ++ let i = index(10, y, 0); ++ match section_bits(&states, flags, 0b001) { ++ SectionBits::Mixed(bits) => bits[i >> 3] & (1 << (i & 7)) != 0, ++ _ => false, ++ } ++ } ++ ++ #[test] ++ fn a_films_view_of_earlier_blocks_is_no_change_of_the_simulated_world() { ++ let tables = tables(); ++ let mut world = walled(); ++ // The wall stops the flight. ++ let wall = ray(&world, 0, &tables); ++ assert!( ++ matches!( ++ wall, ++ Trace::Hit { ++ block: [10, 100, 0], ++ .. ++ } ++ ), ++ "{wall:?}" ++ ); ++ world.log = Some(WorldLog::new(1, 0)); ++ // The raid breaks it: a real change, logged. ++ world.log_tick = 10; ++ world.set(10, 100, 0, BlockStateId(AIR)).unwrap(); ++ let cleared = ray(&world, 0, &tables); ++ assert_eq!(cleared, Trace::Clear); ++ assert_eq!( ++ world.live_section(0, SY, 0, &tables.0, 0b001), ++ SectionView::Bits(SectionBits::Empty) ++ ); ++ let live_revision = world.live_revision; ++ // Watching for block changes, as a mod's world query does. ++ world.mod_changes.watching = true; ++ let _ = world.mod_changes.take(); ++ // The film goes back to before the blast: the picture has the wall, the world has not. ++ assert!(world.view_log(Some(0), 100, None)); ++ assert_eq!(world.block(10, 100, 0), BlockStateId(STONE), "the picture"); ++ assert_eq!(world.live_block(10, 100, 0), BlockStateId(AIR), "the world"); ++ assert!(presented_bit(&world, &tables.0, 100)); ++ assert_eq!( ++ world.live_section(0, SY, 0, &tables.0, 0b001), ++ SectionView::Bits(SectionBits::Empty) ++ ); ++ assert_eq!( ++ ray(&world, 0, &tables), ++ cleared, ++ "the same flight through the passage the raid made" ++ ); ++ // The picture changed; no block change reached the mod, and its revision stands. ++ assert_eq!(world.mod_changes.take(), (Vec::new(), false)); ++ assert_eq!(world.live_revision, live_revision); ++ // A real change during the film: the simulation meets it, the picture stays at tick 0. ++ world.log_tick = 20; ++ world.set(10, 100, 2, BlockStateId(STONE)).unwrap(); ++ assert_eq!(world.block(10, 100, 2), BlockStateId(AIR), "not shown"); ++ assert!( ++ matches!( ++ ray(&world, 2, &tables), ++ Trace::Hit { ++ block: [10, 100, 2], ++ .. ++ } ++ ), ++ "a change behind the view is the real block" ++ ); ++ assert_ne!(world.mod_changes.take(), (Vec::new(), false)); ++ assert_eq!(world.live_revision, live_revision + 1); ++ // Seeking about changes nothing else either. ++ assert!(world.view_log(None, usize::MAX, None)); ++ assert_eq!(world.mod_changes.take(), (Vec::new(), false)); ++ assert_eq!(world.live_revision, live_revision + 1); ++ // The film ends: the picture is the real world again, and so are both flights. ++ world.log = None; ++ assert_eq!(world.block(10, 100, 0), BlockStateId(AIR)); ++ assert_eq!(world.block(10, 100, 2), BlockStateId(STONE)); ++ assert_eq!(ray(&world, 0, &tables), cleared); ++ assert!(matches!( ++ ray(&world, 2, &tables), ++ Trace::Hit { ++ block: [10, 100, 2], ++ .. ++ } ++ )); ++ } ++ ++ #[test] ++ fn a_ground_query_reads_the_real_blocks() { ++ let mut world = walled(); ++ let opaque = |s: BlockStateId| s.0 != AIR; ++ assert_eq!(world.motion_blocking(10, 0, opaque), 101); ++ world.log = Some(WorldLog::new(1, 0)); ++ world.log_tick = 10; ++ world.set(10, 100, 0, BlockStateId(AIR)).unwrap(); ++ assert_eq!(world.live_motion_blocking(10, 0, opaque), 71); ++ world.view_log(Some(0), 100, None); ++ assert_eq!(world.motion_blocking(10, 0, opaque), 101, "the picture"); ++ assert_eq!(world.live_motion_blocking(10, 0, opaque), 71, "the world"); ++ // A column of the same chunk with no displaced block reads the same height either way. ++ assert_eq!(world.motion_blocking(5, 0, opaque), 71); ++ assert_eq!(world.live_motion_blocking(5, 0, opaque), 71); ++ world.view_log(None, usize::MAX, None); ++ assert_eq!(world.motion_blocking(10, 0, opaque), 71); ++ assert_eq!(world.live_motion_blocking(10, 0, opaque), 71); ++ } ++ ++ #[test] ++ fn a_server_chunk_replacement_refreshes_the_real_blocks_behind_the_picture() { ++ let tables = tables(); ++ let mut world = walled(); ++ world.log = Some(WorldLog::new(1, 0)); ++ world.log_tick = 10; ++ world.set(10, 100, 0, BlockStateId(AIR)).unwrap(); ++ world.view_log(Some(0), 100, None); ++ assert_eq!(world.block(10, 100, 0), BlockStateId(STONE), "the old wall is shown"); ++ world.mod_changes.watching = true; ++ let _ = world.mod_changes.take(); ++ ++ let mut replacement = Chunk::empty(0, 0); ++ replacement.set(10, 100, 2, STONE); ++ world.insert_chunk((0, 0), Arc::new(replacement)); ++ assert_eq!(world.live_block(10, 100, 0), BlockStateId(AIR)); ++ assert_eq!(world.live_block(10, 100, 2), BlockStateId(STONE)); ++ assert_eq!(world.block(10, 100, 0), BlockStateId(STONE), "the picture is unchanged"); ++ assert!(matches!(ray(&world, 2, &tables), Trace::Hit { block: [10, 100, 2], .. })); ++ assert_eq!(world.mod_changes.take().0.len(), crate::chunk::SECTIONS); ++ } ++ ++ #[test] ++ fn a_ray_into_an_unloaded_column_is_unknown() { ++ let tables = tables(); ++ let world = walled(); ++ let mut probe = WorldProbe::new(&world, &tables.0, &tables.1, 0b001); ++ assert_eq!( ++ trace(&mut probe, [20.5, 100.5, 0.5], [40.5, 100.5, 0.5]), ++ Trace::Unknown { known_to: 0.0 } ++ ); ++ } ++} +-- +2.54.0 (Apple Git-157) + diff --git a/horizon-patches/0044-Acoustics-follows-chunk-residency-and-voice-lifecycle-110-111-112.patch b/horizon-patches/0044-Acoustics-follows-chunk-residency-and-voice-lifecycle-110-111-112.patch new file mode 100644 index 0000000..749bb37 --- /dev/null +++ b/horizon-patches/0044-Acoustics-follows-chunk-residency-and-voice-lifecycle-110-111-112.patch @@ -0,0 +1,927 @@ +From b1dd9f55c1af556b95797e34211319909910ab49 Mon Sep 17 00:00:00 2001 +From: chexit +Date: Sun, 1 Nov 2026 02:30:58 +0300 +Subject: [PATCH 2/2] Acoustics follows chunk residency and voice lifecycle + (#110 #111 #112) + +Co-Authored-By: Kimi +--- + crates/horizon-client/src/acoustics.rs | 480 +++++++++++++++++++- + crates/horizon-client/src/chunk_map.rs | 97 +++- + crates/horizon-client/src/game/acoustics.rs | 18 +- + 3 files changed, 574 insertions(+), 21 deletions(-) + +diff --git a/crates/horizon-client/src/acoustics.rs b/crates/horizon-client/src/acoustics.rs +index 7dd895d83..a5281d1b6 100644 +--- a/crates/horizon-client/src/acoustics.rs ++++ b/crates/horizon-client/src/acoustics.rs +@@ -13,13 +13,25 @@ + //! at a time, and applied a tick or two later. While a film is being made, everything is traced + //! at once and under the same kind of budget (cells, never time), so the sound of a film does not + //! depend on the speed of the machine. Results are cached by the cells (2 blocks) of the source +-//! and the listener, and dropped when a block changes in a section a path passed through. ++//! and the listener, and dropped when a block changes in a section a path passed through, or when ++//! the chunk underneath them arrives, is replaced by a reload or goes away. A trace that met a ++//! block nobody knows is marked so and is traced again once it is a bit old, so a wall that ++//! streams in is found without anything else happening; answers that come back possibly after a ++//! chunk moved are checked against that change and dropped when it was theirs. ++//! ++//! What the mixer is told is also scoped: a sound that leaves the acoustic range (beyond ++//! [`RANGE`], too quiet to hear, or a world that is gone for a moment) is explicitly given the ++//! plain numbers again — the air's absorption at its distance, no wall, no echoes, no reverb sends ++//! — so what was once traced for it is not remembered for it; when it comes back, the current ++//! geometry applies. + //! + //! Stage 2 adds echo taps for loud one-shots (`echo`): rays from the source and the listener find + //! the walls, and each wall that is really there gives a copy of the sound delayed by the longer + //! path. They are traced on the same worker, cached by the 1-block cells of the source and the + //! listener, and handed to the mixer by [`Acoustics::take_echoes`]; only [`ECHO_VOICES`] sounds +-//! at a time get them. ++//! get them at once (the loudest that deserve them) — a sound that drops out of that set has its ++//! taps recalled, which the mixer fades out over about 50 ms, so the taps actually sounding may ++//! briefly reach another round of [`ECHO_VOICES`] while a rotation plays out. + //! + //! Stage 3 adds the reverb (`room`): rays from the listener (and from each echo source, in its + //! echo trace) measure how enclosed, how large and how reflective the space is, which gives the +@@ -39,7 +51,7 @@ pub use room::Room; + use spr_occlusion::run_occlusion; + + use crate::chunk::{Chunk, HEIGHT, MIN_Y}; +-use crate::fast_hash::FxHashMap; ++use crate::fast_hash::{FxHashMap, FxHashSet}; + use std::collections::HashMap; + use std::sync::Arc; + use std::sync::mpsc::{Receiver, Sender}; +@@ -61,9 +73,17 @@ const JOB_CELLS: usize = 400_000; + const JOB_SOURCES: usize = 32; + /// Sources that get the nine paths; the rest get one. + const FULL_PATHS: usize = 16; ++/// Ticks a trace that met unknown cells is kept before it is traced again: the snapshot then may ++/// miss chunks that are not there yet, and they arrive on their own. A section change always ++/// drops the trace at once; this only bounds how long a partial answer is mixed with. ++const UNKNOWN_TICKS: u64 = 40; + /// Cached estimates. + const CACHE: usize = 4096; +-/// Sounds that have echo taps at the same time (the loudest of those that deserve them). ++/// Sounds that have echo taps at the same time (the loudest of those that deserve them). This is ++/// the budget of the engine's work, not of the mixer's taps alone: a sound that leaves the set has ++/// its taps recalled, which the mixer fades out over about 50 ms (about five mixer buffers), so ++/// right after a full rotation up to twice this many taps are sounding, and within that fade the ++/// steady number is `ECHO_VOICES` again. + pub const ECHO_VOICES: usize = 8; + /// Echo traces one worker job holds. + const ECHO_JOB: usize = 4; +@@ -100,6 +120,9 @@ pub struct Snapshot { + pub reflect: Arc>, + pub epoch: u32, + pub revision: u64, ++ /// The residency of the columns it was made from (`ChunkMap::content_revision`): a reload, ++ /// an unload or a new column means the ranks' contents may differ from these `Arc`s. ++ pub chunk_rev: u64, + pub tick: u64, + pub center: [f64; 3], + } +@@ -177,6 +200,10 @@ pub struct Changes { + pub epoch: u32, + pub overflow: bool, + pub sections: Vec<(i32, i32, i32)>, ++ /// What the resident chunks held (`ChunkMap::content_revision`): when it moves, a worker job ++ /// made from the snapshot before it applies to chunks that may be gone, and its answers are ++ /// dropped. The `sections` of the same change still drop the individual cached estimates. ++ pub residency: u64, + } + + type Cell = (i32, i32, i32); +@@ -187,6 +214,11 @@ struct Entry { + /// The box (in blocks) the paths lay in. + min: [i32; 3], + max: [i32; 3], ++ /// The tick it was traced at, so a trace that met unknown cells (see ++ /// [`UNKNOWN_TICKS`]) is not kept for ever. ++ born: u64, ++ /// The walk met blocks nobody knows (a chunk the snapshot is missing). ++ unk: bool, + used: u64, + } + impl Entry { +@@ -206,12 +238,16 @@ struct EchoEntry { + room: Room, + min: [i32; 3], + max: [i32; 3], ++ born: u64, ++ unk: bool, + used: u64, + } + /// The space round the listener's block. + #[derive(Clone, Copy, Debug)] + struct RoomEntry { + room: Room, ++ born: u64, ++ unk: bool, + used: u64, + } + /// The box a room measure from `cell` looked in. +@@ -270,7 +306,7 @@ struct Done { + seq: u64, + entries: Vec<(Key, Entry)>, + echoes: Vec<(Key, EchoEntry, u64)>, +- rooms: Vec<(Cell, Room)>, ++ rooms: Vec<(Cell, Room, bool)>, + cells: usize, + unknown: usize, + } +@@ -294,7 +330,15 @@ fn trace_echo(geometry: &dyn Geometry, key: Key, budget: &mut usize, clock: u64) + let m = room::measure(geometry, centre(key.0), &mut room_budget); + let us = start.elapsed().as_micros() as u64; + ( +- EchoEntry { taps: t.taps, room: m.room, min: t.min, max: t.max, used: clock }, ++ EchoEntry { ++ taps: t.taps, ++ room: m.room, ++ min: t.min, ++ max: t.max, ++ born: clock, ++ unk: t.unknown + m.unknown > 0, ++ used: clock, ++ }, + t.cells + m.cells, + t.unknown + m.unknown, + us, +@@ -320,6 +364,8 @@ fn trace(geometry: &dyn Geometry, listener: [f64; 3], at: [f64; 3], paths: usize + paths: paths as u8, + min: [lo(0), lo(1), lo(2)], + max: [hi(0), hi(1), hi(2)], ++ born: 0, ++ unk: unknown > 0, + used: 0, + }, + before - *budget, +@@ -334,6 +380,12 @@ pub struct Acoustics { + cache: HashMap, + clock: u64, + applied: HashMap, ++ /// The sounds that were in the acoustic range at the last step (with the geometry present), ++ /// so one that leaves it (beyond `RANGE`, inaudible, or the world gone) is told so plainly. ++ scoped: FxHashSet, ++ /// The residency (`Changes::residency`) the last step saw: worker jobs made before a change ++ /// are dropped (`floor`) when it moves. ++ residency: Option, + log: std::collections::VecDeque<(u64, Cell)>, + seq: u64, + /// Jobs up to this number belong to a world that is gone. +@@ -360,6 +412,8 @@ impl Acoustics { + cache: HashMap::new(), + clock: 0, + applied: HashMap::new(), ++ scoped: FxHashSet::default(), ++ residency: None, + log: Default::default(), + seq: 0, + floor: 0, +@@ -381,6 +435,8 @@ impl Acoustics { + pub fn reset(&mut self) { + self.cache.clear(); + self.applied.clear(); ++ self.scoped.clear(); ++ self.residency = None; + self.echo_cache.clear(); + self.echo_applied.clear(); + self.echo_pending.clear(); +@@ -427,7 +483,7 @@ impl Acoustics { + let m = room::measure(&*job.geometry, centre(cell), &mut b); + cells += m.cells; + unknown += m.unknown; +- rooms.push((cell, m.room)); ++ rooms.push((cell, m.room, m.unknown > 0)); + } + if done_tx.send(Done { seq: job.seq, entries, echoes, rooms, cells, unknown }).is_err() { + break; +@@ -453,6 +509,7 @@ impl Acoustics { + self.floor = self.seq; + self.in_flight = None; + self.applied.clear(); ++ self.scoped.clear(); + return; + } + for &s in &changes.sections { +@@ -473,6 +530,7 @@ impl Acoustics { + + fn insert(&mut self, key: Key, mut entry: Entry) { + entry.used = self.clock; ++ entry.born = self.clock; + self.cache.insert(key, entry); + if self.cache.len() > CACHE { + let mut ages: Vec = self.cache.values().map(|e| e.used).collect(); +@@ -484,6 +542,9 @@ impl Acoustics { + + fn insert_echo(&mut self, key: Key, mut entry: EchoEntry) { + entry.used = self.clock; ++ if entry.born == 0 { ++ entry.born = self.clock; ++ } + self.echo_cache.insert(key, entry); + if self.echo_cache.len() > ECHO_CACHE { + let mut ages: Vec = self.echo_cache.values().map(|e| e.used).collect(); +@@ -510,6 +571,14 @@ impl Acoustics { + ) -> Vec { + self.clock += 1; + self.invalidate(changes); ++ // The resident chunks changed: a job traced from the snapshot before them applies to ++ // chunks that may be gone, so its answers are dropped with the old residency's answers. ++ if self.residency != Some(changes.residency) { ++ self.residency = Some(changes.residency); ++ self.seq += 1; ++ self.floor = self.seq; ++ self.in_flight = None; ++ } + // The worker's results; one that began before a change in its box is not kept. + if let Some((_, rx)) = &self.worker { + let done: Vec = rx.try_iter().collect(); +@@ -535,13 +604,16 @@ impl Acoustics { + self.insert_echo(key, e); + } + } +- for (cell, room) in d.rooms { ++ for (cell, room, unknown) in d.rooms { + let (lo, hi) = room_box(cell); + self.stats.room_traces += 1; + if self.log.iter().any(|(s, c)| *s >= d.seq && box_touches(lo, hi, *c)) { + self.stats.dropped_late += 1; + } else { +- self.room_cache.insert(cell, RoomEntry { room, used: self.clock }); ++ self.room_cache.insert( ++ cell, ++ RoomEntry { room, born: self.clock, unk: unknown, used: self.clock }, ++ ); + } + } + for (key, e) in d.entries { +@@ -562,8 +634,11 @@ impl Acoustics { + self.applied.retain(|id, _| sources.iter().any(|s| s.id == *id)); + self.sends_applied.retain(|id, _| sources.iter().any(|s| s.id == *id)); + self.echo_applied.retain(|id, _| sources.iter().any(|s| s.id == *id)); ++ // A sound that left the acoustic range no longer holds what it was once told: the mixer ++ // gets the plain numbers for it (and no taps, no sends), instead of its last wall. ++ let mut told = self.settle_scope(sources, &live, listener, geometry.is_none()); + let Some(geometry) = geometry else { +- return self.settle(&live, listener, None); ++ return told; + }; + // The loudest first; ties by id, so the order never depends on a hash. + let mut order: Vec = (0..live.len()).collect(); +@@ -577,7 +652,9 @@ impl Acoustics { + let want = if rank < FULL_PATHS { 9 } else { 1 }; + if let Some(e) = self.cache.get_mut(&key) { + e.used = self.clock; +- if e.paths as usize >= want || e.paths == 9 { ++ // A trace that met unknown cells is not kept for ever: a chunk may have arrived. ++ let partial = e.unk && self.clock >= e.born + UNKNOWN_TICKS; ++ if !partial && (e.paths as usize >= want || e.paths == 9) { + self.stats.hits += 1; + continue; + } +@@ -621,7 +698,8 @@ impl Acoustics { + } + } + self.stats.cached = self.cache.len(); +- self.settle(&live, listener, Some(lcell)) ++ told.extend(self.settle(&live, listener, Some(lcell))); ++ told + } + + /// Gives the loudest echo sources their taps from the cache, traces the missing ones at once +@@ -638,10 +716,22 @@ impl Acoustics { + } + if served >= ECHO_VOICES { + self.stats.echo_capped += 1; ++ // It was served before (its taps are in the mixer): they are recalled, so the ++ // cap holds on the taps that sound, not only on these traced just now. ++ self.recall_echo(s.id); + continue; + } + served += 1; + let key = (block_of(s.at), lcell); ++ // A trace that met unknown cells is not kept for ever: a chunk may have arrived ++ // under it, and it is traced again like a missing one would be. ++ if self ++ .echo_cache ++ .get(&key) ++ .is_some_and(|e| e.unk && self.clock >= e.born + UNKNOWN_TICKS) ++ { ++ self.echo_cache.remove(&key); ++ } + if let Some(e) = self.echo_cache.get_mut(&key) { + e.used = self.clock; + self.stats.echo_hits += 1; +@@ -677,24 +767,38 @@ impl Acoustics { + } + + /// The space the listener is in: from the cache, measured at once (a film) or asked of the +- /// worker (the cell is returned). The last known space stays until the new one is. ++ /// worker (the cell is returned). The last known space stays until the new one is. A measure ++ /// that met unknown cells is not kept for ever: a chunk may have arrived under it. + fn settle_room(&mut self, geometry: &dyn Geometry, listener: [f64; 3]) -> Vec { + let cell = block_of(listener); + if let Some(e) = self.room_cache.get_mut(&cell) { + e.used = self.clock; +- self.stats.room_hits += 1; +- self.room_now = Some(e.room); +- } else if self.sync { ++ if !(e.unk && self.clock >= e.born + UNKNOWN_TICKS) { ++ self.stats.room_hits += 1; ++ self.room_now = Some(e.room); ++ return self.room_cache_tidy(); ++ } ++ self.room_cache.remove(&cell); ++ } ++ if self.sync { + let mut budget = ROOM_CELLS; + let m = room::measure(geometry, centre(cell), &mut budget); + self.stats.room_traces += 1; + self.stats.room_cells += m.cells as u64; + self.stats.unknown_cells += m.unknown as u64; +- self.room_cache.insert(cell, RoomEntry { room: m.room, used: self.clock }); ++ self.room_cache.insert( ++ cell, ++ RoomEntry { room: m.room, born: self.clock, unk: m.unknown > 0, used: self.clock }, ++ ); + self.room_now = Some(m.room); + } else { + return vec![cell]; + } ++ self.room_cache_tidy() ++ } ++ ++ /// Keeps the room cache bounded and carries the listener's space into the stats. ++ fn room_cache_tidy(&mut self) -> Vec { + if self.room_cache.len() > ROOM_CACHE { + let mut ages: Vec = self.room_cache.values().map(|e| e.used).collect(); + ages.sort_unstable(); +@@ -741,6 +845,76 @@ impl Acoustics { + std::mem::take(&mut self.sends_pending) + } + ++ /// A sound that left the acoustic range is not traced any more, but what the mixer was told ++ /// keeps playing: the sounds that just left the range (beyond [`RANGE`], too quiet to hear, or ++ /// with the world gone for a step) are given the plain numbers again — the air's absorption ++ /// and no wall — and their echo taps are recalled and their reverb sends to nothing. Nothing ++ /// is kept for a sound the mixer never had numbers for. ++ fn settle_scope(&mut self, sources: &[Source], live: &[Source], listener: [f64; 3], no_geometry: bool) -> Vec { ++ let mut out = Vec::new(); ++ let mut was = std::mem::take(&mut self.scoped); ++ if no_geometry { ++ // The world is gone for a step: nothing can be traced for the live ones either, so ++ // the plain numbers stand for them until the world is read again. ++ for s in live { ++ if was.remove(&s.id) { ++ let cutoff = AIR.powf(dist(s.at, listener) as f32); ++ out.extend(self.unhold(s.id, 1.0, cutoff)); ++ } ++ } ++ } else { ++ for s in live { ++ was.remove(&s.id); ++ } ++ } ++ // What is left are the sounds that left the range while the geometry was there: beyond ++ // RANGE, or quieter than the ear hears. The air still takes the highs by the distance, ++ // so the numbers continue the open air's numbers without the old wall. ++ for s in sources { ++ if !was.remove(&s.id) { ++ continue; ++ } ++ let cutoff = AIR.powf(dist(s.at, listener) as f32); ++ out.extend(self.unhold(s.id, 1.0, cutoff)); ++ } ++ if !no_geometry { ++ self.scoped.extend(live.iter().map(|s| s.id)); ++ } ++ out ++ } ++ ++ /// Puts one sound's acoustics back to the plain numbers. The mixer hears them only when they ++ /// differ; the echo taps are recalled (they fade out there) and the reverb send goes to ++ /// nothing (it fades in the mixer too). Returns the numbers, if the mixer has to hear them. ++ fn unhold(&mut self, id: u32, gain: f32, cutoff: f32) -> Option { ++ let out = self ++ .applied ++ .get(&id) ++ .is_some_and(|p| { ++ (p.gain - gain).abs() >= 0.002 || (p.cutoff - cutoff).abs() >= 0.002 * p.cutoff.max(0.01) ++ }) ++ .then_some(Applied { id, gain, cutoff }); ++ if let Some(now) = out { ++ self.applied.insert(id, now); ++ } ++ self.recall_echo(id); ++ if self.sends_applied.get(&id).is_some_and(|s| s.iter().any(|v| v > &0.0)) { ++ self.sends_applied.insert(id, [0.0; 4]); ++ self.sends_pending.push((id, [0.0; 4])); ++ self.stats.sends += 1; ++ } ++ out ++ } ++ ++ /// The echo taps of one sound are recalled (the mixer fades them out, so a tap that comes ++ /// back a moment later picks up where it left off). A sound that had none gains none. ++ fn recall_echo(&mut self, id: u32) { ++ if self.echo_applied.get(&id).is_some_and(|t| !t.is_empty()) { ++ self.echo_applied.insert(id, Vec::new()); ++ self.echo_pending.push((id, Vec::new())); ++ } ++ } ++ + /// The numbers for each live source from the cache; only the changed ones are returned. + fn settle(&mut self, live: &[Source], listener: [f64; 3], lcell: Option) -> Vec { + let lcell = lcell.unwrap_or_else(|| cell_of(listener)); +@@ -1033,4 +1207,276 @@ mod tests { + } + panic!("the worker did not answer"); + } ++ ++ /// A wall appears at x = 40..41 once `arrived` is set; while it is not, its columns are ++ /// unknown to the walk (a chunk that is not resident yet), and open ground otherwise. ++ struct Arriving { ++ arrived: std::sync::atomic::AtomicU32, ++ wall: std::ops::Range, ++ } ++ impl Geometry for Arriving { ++ fn walker(&self) -> Box Option + '_> { ++ let arrived = self.arrived.load(std::sync::atomic::Ordering::Relaxed); ++ let wall = self.wall.clone(); ++ Box::new(move |x, _, _| match (arrived, wall.contains(&x)) { ++ (0, true) => None, ++ (1, true) => Some(1.0), ++ _ => Some(0.0), ++ }) ++ } ++ } ++ ++ /// (AirDef fork #110.) A wall streams in underneath a trace made while its columns were ++ /// unknown: nothing invalidates the cache, but the unknown trace does not outlive it. ++ #[test] ++ fn an_unknown_trace_is_not_kept_and_is_traced_again_when_the_chunks_answer() { ++ let concrete = Arc::new(Arriving { arrived: 0.into(), wall: 40..42 }); ++ let g: Arc = concrete.clone(); ++ let mut a = Acoustics::new(true); ++ let l = [0.5, 70.5, 0.5]; ++ let before = a.step(Some(&g), l, &[src(1, 90.5)], &Changes::default()); ++ assert!(before[0].cutoff > 0.5, "nothing is known yet: {before:?}"); ++ assert!(a.stats.unknown_cells > 0); ++ concrete.arrived.store(1, std::sync::atomic::Ordering::Relaxed); ++ // The partial answer is mixed with only for a bounded while. ++ for _ in 1..UNKNOWN_TICKS { ++ let out = a.step(Some(&g), l, &[src(1, 90.5)], &Changes::default()); ++ assert!(out.is_empty(), "coasting on the partial answer: {out:?}"); ++ } ++ // Then it is traced again against the geometry that now knows the wall: muted. ++ let after = a.step(Some(&g), l, &[src(1, 90.5)], &Changes::default()); ++ assert_eq!(after.len(), 1); ++ assert!(after[0].cutoff < 0.01, "{after:?}"); ++ assert!(a.stats.traces >= 2); ++ // And being out of partial answers, the cache answers again. ++ assert!(a.step(Some(&g), l, &[src(1, 90.5)], &Changes::default()).is_empty()); ++ } ++ ++ /// (AirDef fork #110.) The column is reloaded (a chunk packet replaces a loaded one) and ++ /// then it goes away: the estimates traced through its sections are dropped both ways. ++ #[test] ++ fn a_reload_of_the_same_column_with_other_content_changes_the_sound() { ++ let mut a = Acoustics::new(true); ++ let open: Arc = Arc::new(Arriving { arrived: 0.into(), wall: 0..0 }); ++ let l = [0.5, 70.5, 0.5]; ++ let first = a.step(Some(&open), l, &[src(1, 90.5)], &Changes::default()); ++ assert!(first[0].cutoff > 0.5, "{first:?}"); ++ // Reloaded with a wall in it: its section counts as changed, and the estimate traced ++ // through it is not kept. ++ let walled: Arc = Arc::new(Arriving { arrived: 1.into(), wall: 40..42 }); ++ let reload = a.step( ++ Some(&walled), ++ l, ++ &[src(1, 90.5)], ++ &Changes { residency: 3, sections: vec![(2, 4, 0)], ..Default::default() }, ++ ); ++ assert!(reload[0].cutoff < 0.01, "{reload:?}"); ++ // Unloaded again: bright once more. ++ let gone = a.step( ++ Some(&open), ++ l, ++ &[src(1, 90.5)], ++ &Changes { residency: 4, sections: vec![(2, 4, 0)], ..Default::default() }, ++ ); ++ assert!(gone[0].cutoff > 0.5, "{gone:?}"); ++ } ++ ++ /// (AirDef fork #111.) A sound (or a listener) that walks out of the acoustic range gives ++ /// up its old wall and takes the plain numbers of its new distance until it comes back. ++ #[test] ++ fn a_sound_that_leaves_the_range_lets_the_wall_go_and_comes_back_traced_anew() { ++ let mut a = Acoustics::new(true); ++ let wall = Wall::new(100..103, 1.0); ++ let l = [0.5, 70.5, 0.5]; ++ let first = a.step(Some(&wall), l, &[src(1, 200.5)], &Changes::default()); ++ assert!(first[0].cutoff < 0.001, "{first:?}"); ++ // The same sound moves past the range: the wall it was behind stops applying. ++ let far = a.step(Some(&wall), l, &[src(1, 700.5)], &Changes::default()); ++ assert_eq!(far.len(), 1); ++ assert_eq!(far[0].gain, 1.0); ++ assert!((far[0].cutoff - AIR.powf(700.0)).abs() < 1e-6, "{far:?}"); ++ // Already there: the mixer hears nothing new. ++ assert!(a.step(Some(&wall), l, &[src(1, 700.5)], &Changes::default()).is_empty()); ++ // It walks back into the range and the actual wall applies again. ++ let back = a.step(Some(&wall), l, &[src(1, 200.5)], &Changes::default()); ++ assert!(back[0].cutoff < 0.001 && (back[0].gain - 0.41).abs() < 0.01, "{back:?}"); ++ // A listener walking past the range falls back the same way. ++ let away = a.step(Some(&wall), [800.5, 70.5, 0.5], &[src(1, 200.5)], &Changes::default()); ++ assert!((away[0].cutoff - AIR.powf(600.0)).abs() < 1e-6, "{away:?}"); ++ let here = a.step(Some(&wall), l, &[src(1, 200.5)], &Changes::default()); ++ assert!(here[0].cutoff < 0.001, "{here:?}"); ++ // One sound, one set of numbers: nothing piles up. ++ assert_eq!(a.applied.len(), 1); ++ } ++ ++ /// (AirDef fork #111.) A sound the distance gain makes inaudible stops holding the wall. ++ #[test] ++ fn a_temporarily_inaudible_sound_goes_plain_and_gets_the_wall_back_when_loud() { ++ let mut a = Acoustics::new(true); ++ let wall = Wall::new(40..42, 1.0); ++ let l = [0.5, 70.5, 0.5]; ++ a.step(Some(&wall), l, &[src(1, 90.5)], &Changes::default()); ++ let quiet = [Source { loud: 0.0001, ..src(1, 90.5) }]; ++ let plain = a.step(Some(&wall), l, &quiet, &Changes::default()); ++ assert_eq!(plain.len(), 1, "{plain:?}"); ++ assert_eq!(plain[0].gain, 1.0); ++ assert!((plain[0].cutoff - AIR.powf(90.0)).abs() < 1e-6); ++ let loud_again = a.step(Some(&wall), l, &[src(1, 90.5)], &Changes::default()); ++ assert!(loud_again[0].cutoff < 0.01, "{loud_again:?}"); ++ } ++ ++ /// (AirDef fork #111.) A world that is gone for a step (no geometry to read) does not keep ++ /// any sound's numbers beyond its last trace, and the real numbers apply when it returns. ++ #[test] ++ fn a_world_that_is_gone_for_a_step_lets_every_sound_go_until_it_can_be_read() { ++ let mut a = Acoustics::new(true); ++ let wall = Wall::new(100..103, 1.0); ++ let l = [0.5, 70.5, 0.5]; ++ let muted = a.step(Some(&wall), l, &[src(1, 200.5)], &Changes::default()); ++ assert!(muted[0].cutoff < 0.001, "{muted:?}"); ++ // No geometry: the plain numbers, and nothing gets traced. ++ let plain = a.step(None, l, &[src(1, 200.5)], &Changes::default()); ++ assert_eq!(plain.len(), 1); ++ assert_eq!(plain[0].gain, 1.0); ++ assert!((plain[0].cutoff - AIR.powf(200.0)).abs() < 1e-6); ++ assert!(a.step(None, l, &[src(1, 200.5)], &Changes::default()).is_empty(), "no repeats"); ++ assert_eq!(a.stats.traces, 1, "nothing was traced without the geometry"); ++ // The world is back: the actual numbers apply again. ++ let back = a.step(Some(&wall), l, &[src(1, 200.5)], &Changes::default()); ++ assert!(back[0].cutoff < 0.001 && (back[0].gain - 0.41).abs() < 0.01, "{back:?}"); ++ assert_eq!(a.applied.len(), 1); ++ } ++ ++ /// (AirDef fork #111.) An echo sound out of the range recalls its taps (an empty set, which ++ /// the mixer fades out) and takes the current geometry's taps when it comes back. ++ #[test] ++ fn an_echo_sound_leaving_the_range_recalls_its_taps_and_takes_them_back() { ++ let mut a = Acoustics::new(true); ++ let g = gorge(true); ++ a.step(Some(&g), LISTENER, &[shot(1, 0.5)], &Changes::default()); ++ assert_eq!(a.take_echoes().len(), 1, "served once"); ++ a.take_sends(); ++ assert_eq!(a.echo_applied.len(), 1); ++ assert_eq!(a.echo_applied[&1].len(), 2); ++ a.step(Some(&g), LISTENER, &[shot(1, 700.5)], &Changes::default()); ++ assert_eq!(a.take_echoes(), [(1, Vec::new())], "recalled"); ++ a.take_sends(); ++ // Nothing more while it stays out. ++ a.step(Some(&g), LISTENER, &[shot(1, 700.5)], &Changes::default()); ++ assert!(a.take_echoes().is_empty() && a.take_sends().is_empty()); ++ // Back in range: the current taps apply again. ++ a.step(Some(&g), LISTENER, &[shot(1, 0.5)], &Changes::default()); ++ assert_eq!(a.echo_applied[&1].len(), 2); ++ assert_eq!(a.echo_applied.len(), 1, "one sound, one record"); ++ } ++ ++ /// (AirDef fork #111.) A sound out of the range stops its reverb send explicitly. ++ #[test] ++ fn a_sound_leaving_the_range_sends_nothing_to_the_reverb_until_it_returns() { ++ let mut a = Acoustics::new(true); ++ let inside = [12.5, 64.5, 12.5]; ++ let source = Source { id: 1, at: [12.5, 64.5, 18.5], loud: 0.5, echo: true }; ++ a.step(Some(&hall()), inside, &[source], &Changes::default()); ++ assert!(!a.take_sends().is_empty(), "the hall sends"); ++ // Far away: the send goes to nothing explicitly (the mixer fades it out). ++ let far = Source { at: [700.5, 64.5, 12.5], ..source }; ++ a.step(Some(&hall()), inside, &[far], &Changes::default()); ++ assert_eq!(a.take_sends(), [(1, [0.0; 4])]); ++ // Already nothing: no repeats. ++ assert!(a.step(Some(&hall()), inside, &[far], &Changes::default()).is_empty()); ++ // Back in the hall: its sends apply again. ++ a.step(Some(&hall()), inside, &[source], &Changes::default()); ++ let again = a.take_sends(); ++ assert_eq!(again.len(), 1); ++ assert!(again[0].1.iter().any(|v| *v > 0.0), "{again:?}"); ++ } ++ ++ /// (AirDef fork #112.) Rotating the loudest ECHO_VOICES around: the voices that leave the ++ /// budget have their taps recalled, so the budget holds on the taps that sound. ++ #[test] ++ fn the_echo_budget_recalls_the_taps_of_the_voices_that_leave_it() { ++ let mut a = Acoustics::new(true); ++ let rocks: Vec = (0..16).map(|i| shot(i, f64::from(i) * 1.5 + 0.5)).collect(); ++ a.step(Some(&gorge(true)), LISTENER, &rocks, &Changes::default()); ++ a.take_echoes(); ++ // Equal loudnesses: the first eight ids take the budget, each with taps. ++ assert_eq!(a.echo_applied.len(), 8); ++ assert_eq!(a.echo_applied[&0].len(), 2); ++ // Now the even ids are louder: they take the budget, the odd ones' taps are recalled. ++ let swapped: Vec = (0..16) ++ .map(|i| Source { loud: if i % 2 == 0 { 0.9 } else { 0.1 }, ..shot(i, f64::from(i) * 1.5 + 0.5) }) ++ .collect(); ++ a.step(Some(&gorge(true)), LISTENER, &swapped, &Changes::default()); ++ let told = a.take_echoes(); ++ assert_eq!( ++ told.iter().filter(|(_, t)| t.is_empty()).map(|(id, _)| *id).collect::>(), ++ [1, 3, 5, 7], ++ "the recalled voices", ++ ); ++ assert_eq!( ++ told.iter().filter(|(_, t)| !t.is_empty()).map(|(id, _)| *id).collect::>(), ++ [8, 10, 12, 14], ++ "the new voices", ++ ); ++ // Exactly the cap holds on the voices that carry taps, through the rotation. ++ assert_eq!(a.echo_applied.iter().filter(|(_, t)| !t.is_empty()).count(), ECHO_VOICES); ++ assert_eq!(a.echo_applied.iter().filter(|(_, t)| t.is_empty()).count(), 4); ++ // And rotating back does not pile anything up. ++ a.step(Some(&gorge(true)), LISTENER, &rocks, &Changes::default()); ++ a.take_echoes(); ++ assert_eq!(a.echo_applied.iter().filter(|(_, t)| !t.is_empty()).count(), ECHO_VOICES); ++ assert_eq!(a.echo_applied.len(), 12); ++ assert_eq!(a.stats.echo_capped, 24); ++ } ++ ++ /// A geometry whose walk is slow, so that a worker job can be watched without a race. ++ struct Slow(std::ops::Range); ++ impl Geometry for Slow { ++ fn walker(&self) -> Box Option + '_> { ++ Box::new(move |x, _, _| { ++ std::hint::black_box( ++ (0..3_000).fold(1u64, |s, i| s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(i)), ++ ); ++ Some(if self.0.contains(&x) { 1.0 } else { 0.0 }) ++ }) ++ } ++ } ++ ++ /// (AirDef fork #110.) A worker job that traced the snapshot before the resident chunks ++ /// changed is dropped as a late answer; the job made after the change applies. ++ #[test] ++ fn an_answer_made_from_the_old_residency_is_dropped_and_the_new_one_applies() { ++ let mut a = Acoustics::new(false); ++ let slow: Arc = Arc::new(Slow(40..42)); ++ let l = [0.5, 70.5, 0.5]; ++ let s = [src(1, 90.5)]; ++ a.step(Some(&slow), l, &s, &Changes::default()); ++ // The one-path estimate goes to the worker for its nine paths; before the answer lands, ++ // the resident columns change: its answer is dropped with the old residency's answers. ++ a.step(Some(&slow), l, &s, &Changes { residency: 1, ..Default::default() }); ++ let after = Changes { residency: 7, ..Default::default() }; ++ for _ in 0..200 { ++ a.step(None, l, &s, &after); ++ if a.stats.jobs >= 1 && a.stats.dropped_late > 0 { ++ break; ++ } ++ std::thread::sleep(std::time::Duration::from_millis(5)); ++ } ++ assert!(a.stats.dropped_late >= 1, "the job was not dropped: {:?}", &a.stats); ++ assert_eq!(a.cache.len(), 1, "only the game thread's own estimate is cached"); ++ assert_eq!(a.cache.values().next().unwrap().paths, 1, "the worker's refined answer never landed"); ++ // The next job, made after the change, is kept. Wait for its actual result rather than ++ // for the number of unrelated echo/room jobs that happened to finish first. ++ a.step(Some(&slow), l, &s, &after); ++ for _ in 0..400 { ++ a.step(None, l, &s, &after); ++ if a.cache.values().next().is_some_and(|e| e.paths == 9) { ++ break; ++ } ++ std::thread::sleep(std::time::Duration::from_millis(5)); ++ } ++ assert!(a.stats.jobs >= 2); ++ assert_eq!(a.cache.len(), 1, "the new residency's answer is cached"); ++ assert_eq!(a.cache.values().next().unwrap().paths, 9, "it refined to nine paths"); ++ } + } +diff --git a/crates/horizon-client/src/chunk_map.rs b/crates/horizon-client/src/chunk_map.rs +index fddded9e1..a53ffda5e 100644 +--- a/crates/horizon-client/src/chunk_map.rs ++++ b/crates/horizon-client/src/chunk_map.rs +@@ -1,7 +1,9 @@ +-//! Resident chunk membership, with a token for consumers that cache its keys. +-use crate::{chunk::Chunk, fast_hash::FxHashMap}; ++//! Resident chunk membership, with a token for consumers that cache its keys and the columns ++//! whose residency changed (AirDef fork #110: the acoustics must hear that too). ++use crate::{chunk::Chunk, fast_hash::{FxHashMap, FxHashSet}}; + use std::{ + collections::hash_map::{Iter, ValuesMut}, ++ collections::VecDeque, + ops::Deref, + sync::{ + Arc, +@@ -16,18 +18,31 @@ fn next_revision() -> u64 { + NEXT_REVISION.fetch_add(1, Ordering::Relaxed) + } + ++/// Columns kept in the changed log past this many of them: everything counts as changed, then. ++const CHANGE_CAP: usize = 256; + /// Read access behaves like a map. Structural writes are explicit so edits to + /// blocks or light don't invalidate consumers of the loaded chunk key set. + /// Tokens are process-wide: replacing the entire map also invalidates caches. + pub struct ChunkMap { + columns: Columns, + revision: u64, ++ /// Bumped by every change of what the columns hold: an insertion (new or ++ /// replacing a loaded one) and a removal. Edits through `get_mut` are ++ /// block writes, which the world reports by itself. ++ content: u64, ++ /// The columns whose residency changed since `take_changed` last read, ++ /// in the order they changed, up to [`CHANGE_CAP`] of them. ++ changed: VecDeque, ++ changed_dropped: bool, + } + impl Default for ChunkMap { + fn default() -> Self { + Self { + columns: Columns::default(), + revision: next_revision(), ++ content: next_revision(), ++ changed: VecDeque::default(), ++ changed_dropped: false, + } + } + } +@@ -35,17 +50,41 @@ impl ChunkMap { + pub fn membership_revision(&self) -> u64 { + self.revision + } ++ /// What the columns hold, not just which of them are resident: a consumer ++ /// that keeps chunk contents behind `Arc`s (the acoustics' snapshot) must ++ /// be made again when this moves. ++ pub fn content_revision(&self) -> u64 { ++ self.content ++ } ++ /// The columns whose residency changed since the last call, and whether ++ /// changes were dropped (every column counts as changed, then). ++ pub fn take_changed(&mut self) -> (FxHashSet, bool) { ++ let dropped = std::mem::take(&mut self.changed_dropped); ++ let changed = self.changed.drain(..).collect(); ++ (changed, dropped) ++ } ++ fn changed_column(&mut self, key: Key) { ++ if self.changed.len() >= CHANGE_CAP { ++ self.changed_dropped = true; ++ self.changed.clear(); ++ } ++ self.changed.push_back(key); ++ } + pub fn insert(&mut self, key: Key, chunk: Arc) -> Option> { + let previous = self.columns.insert(key, chunk); ++ self.content = next_revision(); + if previous.is_none() { + self.revision = next_revision(); + } ++ self.changed_column(key); + previous + } + pub fn remove(&mut self, key: &Key) -> Option> { + let previous = self.columns.remove(key); + if previous.is_some() { + self.revision = next_revision(); ++ self.content = next_revision(); ++ self.changed_column(*key); + } + previous + } +@@ -53,6 +92,9 @@ impl ChunkMap { + if !self.columns.is_empty() { + self.columns.clear(); + self.revision = next_revision(); ++ self.content = next_revision(); ++ self.changed_dropped = true; ++ self.changed.clear(); + } + } + pub fn get_mut(&mut self, key: &Key) -> Option<&mut Arc> { +@@ -122,4 +164,55 @@ mod tests { + map = ChunkMap::default(); + assert_ne!(cleared, map.membership_revision()); + } ++ ++ #[test] ++ fn insertions_replacements_and_removals_change_the_content_token_and_are_reported() { ++ let mut map = ChunkMap::default(); ++ let chunk = Arc::new(Chunk::empty(0, 0)); ++ let (changed, dropped) = map.take_changed(); ++ assert!(changed.is_empty() && !dropped); ++ // An insertion is reported once. ++ map.insert((0, 0), chunk.clone()); ++ let content = map.content_revision(); ++ let membership = map.membership_revision(); ++ let had_it = map.take_changed(); ++ assert_eq!(had_it.0.len(), 1); ++ assert!(had_it.0.contains(&(0, 0))); ++ // A replacement (a reload with other content): the key set is the ++ // same, what the column holds is not. ++ let mut other = Chunk::empty(0, 0); ++ other.set(0, 0, 0, 9); ++ let reload = map.insert((0, 0), Arc::new(other)).is_some(); ++ assert!(reload); ++ assert_eq!(membership, map.membership_revision(), "the keys did not move"); ++ assert_ne!(content, map.content_revision(), "the content did"); ++ let reloaded = map.take_changed(); ++ assert_eq!(reloaded.0.len(), 1); ++ assert!(reloaded.0.contains(&(0, 0))); ++ // A removal is reported; reads of an idle map report nothing. ++ assert!(map.remove(&(0, 0)).is_some()); ++ let removed = map.take_changed(); ++ assert_eq!(removed.0.len(), 1); ++ assert!(removed.0.contains(&(0, 0))); ++ let (idle, dropped) = map.take_changed(); ++ assert!(idle.is_empty() && !dropped, "{dropped}"); ++ // Endless change stays bounded: the drop says everything counts. ++ for x in 0..1_000 { ++ map.insert((x % 5, 0), chunk.clone()); ++ } ++ let (_, dropped) = map.take_changed(); ++ assert!(dropped); ++ // Idle again; a clear of a loaded map says so. ++ let (empty, dropped) = map.take_changed(); ++ assert!(empty.is_empty() && !dropped); ++ let was = map.content_revision(); ++ map.clear(); ++ let (_, dropped) = map.take_changed(); ++ assert!(dropped, "a clear loses every column"); ++ assert_ne!(was, map.content_revision()); ++ // A clear of an idle map is idle. ++ map.clear(); ++ let (empty, dropped) = map.take_changed(); ++ assert!(empty.is_empty() && !dropped); ++ } + } +diff --git a/crates/horizon-client/src/game/acoustics.rs b/crates/horizon-client/src/game/acoustics.rs +index fe2c26de5..ddaa85a0e 100644 +--- a/crates/horizon-client/src/game/acoustics.rs ++++ b/crates/horizon-client/src/game/acoustics.rs +@@ -60,13 +60,16 @@ impl Game<'_> { + (table, reflect) + } + +- /// The snapshot of the chunks round the listener, made again when a block changed or the +- /// listener went far from where it was made. ++ /// The snapshot of the chunks round the listener, made again when a block changed, the ++ /// resident columns arrived, were replaced or went away (AirDef fork #110), or the listener ++ /// went far from where it was made. + fn acoustic_snapshot(&mut self) -> Arc { + let at = self.audio.listener; ++ let chunk_rev = self.world.chunks.content_revision(); + if let Some(s) = &self.mods.acoustic.snapshot + && s.epoch == self.world.epoch + && s.revision == self.world.revision ++ && s.chunk_rev == chunk_rev + && (s.center[0] - at[0]).abs() < 96.0 + && (s.center[2] - at[2]).abs() < 96.0 + { +@@ -87,6 +90,7 @@ impl Game<'_> { + reflect, + epoch: self.world.epoch, + revision: self.world.revision, ++ chunk_rev, + tick: self.ticks as u64, + center: at, + }); +@@ -101,6 +105,15 @@ impl Game<'_> { + return; + } + self.world.sound_changes.watching = true; ++ // The columns whose residency changed (a chunk arrived, was replaced by a reload or is ++ // gone) count as changed sections for the acoustics (AirDef fork #110). ++ let (columns, dropped) = self.world.chunks.take_changed(); ++ for (x, z) in columns { ++ self.world.sound_changes.column(x, z); ++ } ++ if dropped { ++ self.world.sound_changes.overflow(); ++ } + if !self.audio.acoustics_wanted() { + return; + } +@@ -109,6 +122,7 @@ impl Game<'_> { + epoch: self.world.epoch, + overflow, + sections, ++ residency: self.world.chunks.content_revision(), + }; + let geometry: Arc = self.acoustic_snapshot(); + self.audio.tick_acoustics(Some(&geometry), &changes); +-- +2.54.0 (Apple Git-157) +