Separate live and replay worlds, refresh acoustic lifecycle (#105 #110 #111 #112)
build / mod (push) Successful in 8m39s
build / mod (push) Successful in 8m39s
Co-Authored-By: Kimi <noreply@moonshot.ai>
This commit is contained in:
+8
-4
@@ -27,9 +27,12 @@ GPL-файлы три: `acoustics/spr_occlusion.rs`, (стадия 2) `acoustics
|
|||||||
- Дальность 512 блоков (SPR: ~64), счёт клеток, не времени: 40 тыс. клеток на тик встроенно (новый звук, 1 путь, чтобы взрыв
|
- Дальность 512 блоков (SPR: ~64), счёт клеток, не времени: 40 тыс. клеток на тик встроенно (новый звук, 1 путь, чтобы взрыв
|
||||||
не начинался «ярким»), 400 тыс. на задание рабочего потока (9 путей, одно задание за раз); при съёмке всё встроенно, 600 тыс.
|
не начинался «ярким»), 400 тыс. на задание рабочего потока (9 путей, одно задание за раз); при съёмке всё встроенно, 600 тыс.
|
||||||
клеток на тик. Порядок: по громкости, затем по id; сумма голосов в `mix` теперь тоже по id (раньше порядок HashMap делал дорожку
|
клеток на тик. Порядок: по громкости, затем по id; сумма голосов в `mix` теперь тоже по id (раньше порядок HashMap делал дорожку
|
||||||
недетерминированной при нескольких голосах).
|
недетерминированной при нескольких голосах). При выходе за дальность/порог слышимости или потере геометрии микшеру явно
|
||||||
|
возвращают обычные `gain=1` и воздушный `cutoff` без старой стены; посылки и эхо отзываются, при возвращении считаются заново.
|
||||||
- Кеш по ячейкам 2x2x2 источника и слушателя, 4096 записей; сброс по секциям `World.sound_changes` (как `mod_changes`),
|
- Кеш по ячейкам 2x2x2 источника и слушателя, 4096 записей; сброс по секциям `World.sound_changes` (как `mod_changes`),
|
||||||
по эпохе мира и при переполнении; результат задания, начатого до изменения в его коробке, отбрасывается.
|
по эпохе мира и при переполнении; результат задания, начатого до изменения в его коробке, отбрасывается. Вставка, замена
|
||||||
|
и выгрузка чанка меняют residency-счётчик и инвалидируют затронутые секции и старые worker-задания. Трасса, встретившая
|
||||||
|
Unknown, пересчитывается не позднее чем через 40 тиков, поэтому приехавшая геометрия применяется без движения слушателя.
|
||||||
- Воздух: `0.994^расстояние` на верха (EFX по умолчанию, не проверено по OpenAL Soft) — иначе при `acoustics=on` пропадала бы
|
- Воздух: `0.994^расстояние` на верха (EFX по умолчанию, не проверено по OpenAL Soft) — иначе при `acoustics=on` пропадала бы
|
||||||
дальность, которую раньше давали `_far`-сэмплы.
|
дальность, которую раньше давали `_far`-сэмплы.
|
||||||
- Мод: API 21, `render.acoustics()`; при включённой акустике мод играет ближние варианты до 480 блоков (`ACOUSTIC_FAR`), дальше
|
- Мод: API 21, `render.acoustics()`; при включённой акустике мод играет ближние варианты до 480 блоков (`ACOUSTIC_FAR`), дальше
|
||||||
@@ -64,8 +67,9 @@ GPL-файлы три: `acoustics/spr_occlusion.rs`, (стадия 2) `acoustics
|
|||||||
- Срез близких отражений: отвод ближе 30 мс к прямому звуку отбрасывается, до 50 мс плавно входит (иначе стена рядом с
|
- Срез близких отражений: отвод ближе 30 мс к прямому звуку отбрасывается, до 50 мс плавно входит (иначе стена рядом с
|
||||||
игроком даёт гребёнку, а не эхо). Из найденных плоскостей берутся до 6 сильнейших не ближе 40 мс друг к другу.
|
игроком даёт гребёнку, а не эхо). Из найденных плоскостей берутся до 6 сильнейших не ближе 40 мс друг к другу.
|
||||||
- Кому: `deserves_echo` в `audio.rs`: не петля, не относительный, громкость >= 0.5, дальность (linear) >= 96, длина 0.1-12 с,
|
- Кому: `deserves_echo` в `audio.rs`: не петля, не относительный, громкость >= 0.5, дальность (linear) >= 96, длина 0.1-12 с,
|
||||||
прошло меньше 70% звука; не более `ECHO_VOICES = 8` одновременно (самые громкие). Сотня двигателей эха не получает
|
прошло меньше 70% звука; не более `ECHO_VOICES = 8` активных наборов (самые громкие). При смене приоритетов старый набор
|
||||||
(петли), сотня выстрелов: восемь самых громких.
|
отзывается и затухает ~50 мс, поэтому во время ротации кратковременно звучат до 16, затем снова 8. Сотня двигателей эха
|
||||||
|
не получает (петли), сотня выстрелов: восемь самых громких.
|
||||||
- Планирование: счёт по клеткам, не по времени. Живьём трассировка идёт на том же рабочем потоке (в задании до 4 трасс и
|
- Планирование: счёт по клеткам, не по времени. Живьём трассировка идёт на том же рабочем потоке (в задании до 4 трасс и
|
||||||
240 тыс. клеток), кеш по блочным ячейкам источника и слушателя (центры ячеек, 256 записей), сброс по секциям, которых
|
240 тыс. клеток), кеш по блочным ячейкам источника и слушателя (центры ячеек, 256 записей), сброс по секциям, которых
|
||||||
касаются точки опоры отводов, и по эпохе; в съёмке трасса синхронная, 480 тыс. клеток на тик. Микшер получает отводы
|
касаются точки опоры отводов, и по эпохе; в съёмке трасса синхронная, 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)
|
||||||
|
|
||||||
+927
@@ -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)
|
||||||
|
|
||||||
Reference in New Issue
Block a user