Separate live and replay worlds, refresh acoustic lifecycle (#105 #110 #111 #112)
build / mod (push) Successful in 8m39s

Co-Authored-By: Kimi <noreply@moonshot.ai>
This commit is contained in:
2026-11-01 02:34:39 +03:00
parent 31d967b036
commit cd3c4ed76d
3 changed files with 1822 additions and 4 deletions
+8 -4
View File
@@ -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 тыс. клеток на тик. Микшер получает отводы
@@ -0,0 +1,887 @@
From 2fff36c3c6183a440d55371b2db0f3bd6eb586c5 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@moonshot.ai>
---
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<Chunk>)>,
- 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<WorldTables> {
@@ -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<u64>,
@@ -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<u32> {
@@ -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<crate::chunk::Chunk>) -> Option<Arc<crate::chunk::Chunk>> {
+ 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<Pos, u32>,
+ /// 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<u32> {
+ 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<Item = (Pos, u32)> + '_ {
+ 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<K: std::hash::Hash + Eq>(counts: &mut HashMap<K, usize>, 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<Box<[[f64; 6]]>>],
+ mask: u8,
+ /// The last column read, and whether the view shows any of its blocks otherwise.
+ last: Option<((i32, i32), &'a Arc<Chunk>, bool)>,
+ /// Cells read.
+ pub cells: usize,
+}
+
+impl<'a> WorldProbe<'a> {
+ pub fn new(
+ world: &'a World,
+ flags: &'a [u8],
+ boxes: &'a [Option<Box<[[f64; 6]]>>],
+ 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<u8>, Vec<Option<Box<[[f64; 6]]>>>);
+
+ /// 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)
@@ -0,0 +1,927 @@
From b1dd9f55c1af556b95797e34211319909910ab49 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
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 <noreply@moonshot.ai>
---
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<Vec<f32>>,
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<Key, Entry>,
clock: u64,
applied: HashMap<u32, Applied>,
+ /// 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<u32>,
+ /// The residency (`Changes::residency`) the last step saw: worker jobs made before a change
+ /// are dropped (`floor`) when it moves.
+ residency: Option<u64>,
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<u64> = 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<u64> = self.echo_cache.values().map(|e| e.used).collect();
@@ -510,6 +571,14 @@ impl Acoustics {
) -> Vec<Applied> {
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<Done> = 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<usize> = (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<Cell> {
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<Cell> {
if self.room_cache.len() > ROOM_CACHE {
let mut ages: Vec<u64> = 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<Applied> {
+ 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<Applied> {
+ 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<Cell>) -> Vec<Applied> {
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<i32>,
+ }
+ impl Geometry for Arriving {
+ fn walker(&self) -> Box<dyn FnMut(i32, i32, i32) -> Option<f32> + '_> {
+ 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<dyn Geometry> = 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<dyn Geometry> = 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<dyn Geometry> = 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<Source> = (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<Source> = (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::<Vec<_>>(),
+ [1, 3, 5, 7],
+ "the recalled voices",
+ );
+ assert_eq!(
+ told.iter().filter(|(_, t)| !t.is_empty()).map(|(id, _)| *id).collect::<Vec<_>>(),
+ [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<i32>);
+ impl Geometry for Slow {
+ fn walker(&self) -> Box<dyn FnMut(i32, i32, i32) -> Option<f32> + '_> {
+ 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<dyn Geometry> = 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<Key>,
+ 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<Key>, 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<Chunk>) -> Option<Arc<Chunk>> {
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<Arc<Chunk>> {
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<Chunk>> {
@@ -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<Snapshot> {
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<dyn Geometry> = self.acoustic_snapshot();
self.audio.tick_acoustics(Some(&geometry), &changes);
--
2.54.0 (Apple Git-157)