From 5003376e70a804207d1e17b531762e381ee2ffad Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 22:09:23 +0300 Subject: [PATCH] Acoustics stage 1: mod plays near sounds when the engine muffles, band-energy check tool (#96, fork patch 39) Co-Authored-By: Claude Opus 5.5 --- airdef-mod/src/lib.rs | 9 +- docs/ACOUSTICS.md | 38 + ...ork-world-models-client-particles-so.patch | 2 +- ...izon-mod-report-particles-and-sounds.patch | 2 +- ...-fork-full-screen-post-pass-for-mods.patch | 2 +- ...far-models-as-scaled-impostors-past-.patch | 2 +- ...er.set-views-draws-the-world-from-ot.patch | 2 +- ...er-tick-and-per-event-AirDef-plans-r.patch | 2 +- ...-up-to-1920x1080-full-screen-replays.patch | 2 +- ...ant-terrain-tiles-fog-measured-from-.patch | 2 +- ...id-so-mods-keep-state-per-world-API-.patch | 2 +- ...d-trial-runs-behind-feature-full-so-.patch | 2 +- ...ffer-pass-per-pixel-mod-point-lights.patch | 2 +- ...e-2-sun-and-moon-shadow-cascades-the.patch | 2 +- ...ached-until-a-resize-or-a-new-source.patch | 2 +- ...-per-16x16-tile-surface-and-halo-lis.patch | 2 +- ...pass-in-the-F3-timing-list-half-reso.patch | 2 +- ...only-sun-facing-quads-and-skip-burie.patch | 2 +- ...es-render.model-id-model-generation-.patch | 2 +- ...aling-HORIZON_HD_SCALE-world-G-buffe.patch | 2 +- ...-pass-colour-normal-depth-the-floor-.patch | 2 +- ...lla-texture-on-the-surface-the-biome.patch | 2 +- ...-a-physical-atmosphere-Rayleigh-Mie-.patch | 2 +- ...r-blue-a-little-clearer-ripples-a-li.patch | 2 +- ...sunsets-Rec.-2020-atmosphere-sun-tin.patch | 2 +- ...es-for-simulations-render.query-rays.patch | 2 +- ...vel-from-the-world-not-from-rain-geo.patch | 2 +- ...dules-gfx-hd-one-Rust-file-and-one-W.patch | 2 +- ...scales-that-divide-the-camera-s-6553.patch | 2 +- ...oke-and-missile-trails-with-clear-tu.patch | 2 +- ...ited-by-depth-so-terrain-and-vehicle.patch | 2 +- ...rails-drawn-at-least-as-wide-as-a-gr.patch | 2 +- ...main-view-API-level-15-renders-the-s.patch | 2 +- ...mods-render.set-spot-lights-API-leve.patch | 2 +- ...g-polish-cloud-panorama-cirrus-terra.patch | 2 +- ...through-glass-and-leaves-rain-ripple.patch | 2 +- ...y-frame-in-flight-reaches-the-writer.patch | 2 +- ...s-command.set-tree-and-set-suggestio.patch | 2 +- ...ound-sources-start-update-stop-with-.patch | 2 +- ...rom-a-far-point-so-the-view-bob-s-ey.patch | 2 +- ...1-direct-path-occlusion-ported-from-.patch | 1684 +++++++++++++++++ tools/acoustics_check.py | 81 + 42 files changed, 1848 insertions(+), 40 deletions(-) create mode 100644 horizon-patches/0039-Acoustics-stage-1-direct-path-occlusion-ported-from-.patch create mode 100644 tools/acoustics_check.py diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index e6ab42c..f770bb0 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -192,6 +192,8 @@ struct PendingSound { const SOUND_SPEED: f64 = 17.15; /// Past this the far variant plays: muffled, more rolling echo. const FAR: f64 = 140.0; +/// The same when the game's acoustics muffle (its tracing reaches 512 blocks). +const ACOUSTIC_FAR: f64 = 480.0; /// Impulsive sounds (blasts, launches, shots, shock waves) come late from afar and have far variants. fn travels(event: &str) -> bool { @@ -747,13 +749,16 @@ impl AirDef { let now = self.sim.tick; // Blasts play layered (cinema.rs), the nearest ones in full. let mut booms: Vec<(f64, usize, DVec3, f32, f32)> = Vec::new(); + // The game muffles by walls and distance itself (#96): the near variants play out to the + // reach of its tracing, so nothing is muffled twice; past it the far ones still do. + let far = if render::acoustics() { ACOUSTIC_FAR } else { FAR }; for (event, at, volume, pitch) in out.sounds { let dist = at.distance(self.listener); if let Some(power) = cinema::blast_power(event) { booms.push((dist, power, at, volume, pitch)); } else if travels(event) { // Seen first, heard later; from afar muffled. - let name = if dist > FAR { format!("{event}_far") } else { event.to_string() }; + let name = if dist > far { format!("{event}_far") } else { event.to_string() }; let delay = (dist / SOUND_SPEED) as u64; if delay == 0 { render::play_sound(&name, at.x, at.y, at.z, volume, pitch); @@ -766,7 +771,7 @@ impl AirDef { } booms.sort_by(|a, b| a.0.total_cmp(&b.0)); for (rank, (dist, power, at, volume, pitch)) in booms.into_iter().enumerate() { - for l in cinema::layers(power, dist, FAR, volume, pitch, rank) { + for l in cinema::layers(power, dist, far, volume, pitch, rank) { // Seen first, heard later: each voice after the sound has travelled. let delay = (dist / SOUND_SPEED) as u64 + l.delay; if delay == 0 { diff --git a/docs/ACOUSTICS.md b/docs/ACOUSTICS.md index da1ec00..08cf9d8 100644 --- a/docs/ACOUSTICS.md +++ b/docs/ACOUSTICS.md @@ -11,6 +11,44 @@ - Коммит из текста #96 (`76f680eb…`) в последних 200 коммитах `master` не нашёлся. Выводы привязаны к `453654ec`. +## Статус + +**Стадия 1 (прямой звук) сделана**, включается `HORIZON_ACOUSTICS=1` (по умолчанию выключено, звук как был). Стадии 2-3 +не начаты. Коммит SPR прежний, `453654ec`; атрибуция: `horizon/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md`. +GPL-файл один: `acoustics/spr_occlusion.rs`; остальное (движок `acoustics.rs`, склейка `game/acoustics.rs`, DSP в `audio.rs`) наше. + +Что вышло и чем отличается от плана выше: + +- Фильтр голоса: не однополюсный lowpass, а **полка** (`audio::Occlusion`): ниже 1 кГц звук проходит с `gain`, выше с + `gain * cutoff`, два полюса, сглаживание 80 мс на буфер. Однополюсник резал бы и басы толстой стены; нужно «глухо», а не «тихо». +- Формулы SPR: `cutoff = exp(-occ*3)`, `gain = cutoff^0.1`, 9 путей со сдвигом 0.35 (для 16 самых громких; остальные 1 путь), + блок источника не считается, неизвестный чанк = воздух, не полный блок x0.25. Луч — обход вокселей (свой), а не clip по формам: + забор, дверь считаются ячейкой целиком (x0.25). Жидкости пока не глушат. +- Дальность 512 блоков (SPR: ~64), счёт клеток, не времени: 40 тыс. клеток на тик встроенно (новый звук, 1 путь, чтобы взрыв + не начинался «ярким»), 400 тыс. на задание рабочего потока (9 путей, одно задание за раз); при съёмке всё встроенно, 600 тыс. + клеток на тик. Порядок: по громкости, затем по id; сумма голосов в `mix` теперь тоже по id (раньше порядок HashMap делал дорожку + недетерминированной при нескольких голосах). +- Кеш по ячейкам 2x2x2 источника и слушателя, 4096 записей; сброс по секциям `World.sound_changes` (как `mod_changes`), + по эпохе мира и при переполнении; результат задания, начатого до изменения в его коробке, отбрасывается. +- Воздух: `0.994^расстояние` на верха (EFX по умолчанию, не проверено по OpenAL Soft) — иначе при `acoustics=on` пропадала бы + дальность, которую раньше давали `_far`-сэмплы. +- Мод: API 21, `render.acoustics()`; при включённой акустике мод играет ближние варианты до 480 блоков (`ACOUSTIC_FAR`), дальше + `_far`. Задержку распространения мод держит сам (не трогали). +- Реплей: мир, из которого читает акустика, — тот самый `World`, где реплей показывает блоки на нужный момент (журнал + изменений, `set-world-time-view`); перемотка пишет блоки через `put`/`set_block`, а они попадают в `sound_changes`. Значит + стена, которой уже нет, в реплее не глушит, а которая была — глушит. Проверено чтением кода, не запуском. + +Измерено (release, M-серия, `audio::tests::bench_one_hundred_voices_with_and_without_occlusion`, 100 голосов, буфер 512 кадров +10.67 мс): 0.110 мс без акустики, 0.327 мс с полкой на всех (+0.217 мс, около 2% буфера); оценка 0.5-1 мс выше была завышена. + +Проверка без слуха (`audio::tests::export_wall_tracks` через настоящие `Acoustics` и `mix`, затем `tools/acoustics_check.py`), +выстрел 1.5 с на 60 блоках, стена камня в 2 блока: ниже 300 Гц -5.2 дБ (SPR: gain 0.55), 300-1500 -7.4, 1500-5000 -19.7, выше 5000 -40.0 дБ. +Тесты: `cargo test -p horizon-client --lib acoustics` (формулы, неизвестное, щель, кеш, инвалидация, детерминизм, рабочий поток) и `audio`. + +Не сделано / не проверено: запуск в игре и экспорт видео с настоящей сценой (склейка `game/acoustics.rs` — таблица состояний, +снимок чанков — компилируется, но в игре не прогонялась); влияние на кадр при налёте; жидкости; дверь как проём (считается +ячейкой); потолок памяти снимка. Стадии 2-3 (отводы эха, шины реверба) не начаты. + ## 1. Как SPR идёт от источника к результату Сигнал: Minecraft вызывает `Channel.play` → `mixin/SourceMixin.play` → `SoundPhysics.onPlaySound` → diff --git a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch b/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch index 05c26b7..0c05276 100644 --- a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch +++ b/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch @@ -1,7 +1,7 @@ From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:37:45 +0300 -Subject: [PATCH 01/38] Mod API (AirDef fork): world models, client particles, +Subject: [PATCH 01/39] Mod API (AirDef fork): world models, client particles, sounds, ground heights render.set-models draws block models with an interpolated pose per id; diff --git a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch index d24b372..1c352a1 100644 --- a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch +++ b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch @@ -1,7 +1,7 @@ From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:39:45 +0300 -Subject: [PATCH 02/38] cargo-horizon-mod: report particles and sounds +Subject: [PATCH 02/39] cargo-horizon-mod: report particles and sounds Co-Authored-By: Claude Opus 5.5 --- diff --git a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch b/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch index 3295f9d..15d610e 100644 --- a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch +++ b/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch @@ -1,7 +1,7 @@ From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 13:33:10 +0300 -Subject: [PATCH 03/38] Mod API (AirDef fork): full-screen post pass for mods +Subject: [PATCH 03/39] Mod API (AirDef fork): full-screen post pass for mods render.set-post compiles a mod's WGSL after a prelude (camera matrices, time, 256 vec4 of params, the world's colour and depth) and runs it after diff --git a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch index f04e948..e25046c 100644 --- a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch +++ b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch @@ -1,7 +1,7 @@ From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:09:30 +0300 -Subject: [PATCH 04/38] Mod models: draw far models as scaled impostors past +Subject: [PATCH 04/39] Mod models: draw far models as scaled impostors past the far plane, cull at 4096 --- diff --git a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch index 53680c9..23e1d57 100644 --- a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch +++ b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch @@ -1,7 +1,7 @@ From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:31:00 +0300 -Subject: [PATCH 05/38] Mod cameras: render.set-views draws the world from +Subject: [PATCH 05/39] Mod cameras: render.set-views draws the world from other points into pictures that HUD view shapes show (API level 9) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch b/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch index 2ce4a0f..2564521 100644 --- a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch +++ b/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch @@ -1,7 +1,7 @@ From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:08:21 +0300 -Subject: [PATCH 06/38] Mods: more fuel per tick and per event (AirDef plans +Subject: [PATCH 06/39] Mods: more fuel per tick and per event (AirDef plans raids) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch b/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch index b0858dc..167e19f 100644 --- a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch +++ b/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch @@ -1,7 +1,7 @@ From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:25:29 +0300 -Subject: [PATCH 07/38] Mod cameras up to 1920x1080 (full-screen replays) +Subject: [PATCH 07/39] Mod cameras up to 1920x1080 (full-screen replays) Co-Authored-By: Claude Opus 5.5 --- diff --git a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch b/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch index ab937d6..a711a20 100644 --- a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch +++ b/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch @@ -1,7 +1,7 @@ From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 11:15:11 +0300 -Subject: [PATCH 08/38] Mod cameras: distant terrain tiles, fog measured from +Subject: [PATCH 08/39] Mod cameras: distant terrain tiles, fog measured from the camera, no impostors while one is shown Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch b/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch index 285ce30..630deb8 100644 --- a/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch +++ b/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch @@ -1,7 +1,7 @@ From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 11:25:45 +0300 -Subject: [PATCH 09/38] Mods: game.world-id, so mods keep state per world (API +Subject: [PATCH 09/39] Mods: game.world-id, so mods keep state per world (API level 9) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch b/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch index 9f0fc38..24a7754 100644 --- a/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch +++ b/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch @@ -1,7 +1,7 @@ From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 12:01:08 +0300 -Subject: [PATCH 10/38] cargo-horizon-mod: trial runs behind feature full, so a +Subject: [PATCH 10/39] cargo-horizon-mod: trial runs behind feature full, so a packing-only tool skips wasmtime Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch b/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch index 72922f7..2cb173b 100644 --- a/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch +++ b/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch @@ -1,7 +1,7 @@ From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:18:43 +0300 -Subject: [PATCH 11/38] HD lighting: G-buffer pass, per-pixel mod point lights +Subject: [PATCH 11/39] HD lighting: G-buffer pass, per-pixel mod point lights in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API level 10) diff --git a/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch b/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch index 29b1066..c84ae23 100644 --- a/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch +++ b/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch @@ -1,7 +1,7 @@ From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:59:43 +0300 -Subject: [PATCH 12/38] HD lighting stage 2: sun and moon shadow cascades, the +Subject: [PATCH 12/39] HD lighting stage 2: sun and moon shadow cascades, the world relit (sun by face angle and shadow, sky and block light through the lightmap), light shafts and halos, fog over it again, a light colour grade diff --git a/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch b/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch index 9d8e739..fd122db 100644 --- a/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch +++ b/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch @@ -1,7 +1,7 @@ From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:19:53 +0300 -Subject: [PATCH 13/38] HD: bind groups cached until a resize or a new source, +Subject: [PATCH 13/39] HD: bind groups cached until a resize or a new source, shadow passes draw only the entities that cast shadows Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch b/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch index b41096d..2bbd0f0 100644 --- a/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch +++ b/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch @@ -1,7 +1,7 @@ From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:25:44 +0300 -Subject: [PATCH 14/38] HD: lights culled per 16x16 tile (surface and halo +Subject: [PATCH 14/39] HD: lights culled per 16x16 tile (surface and halo lists built on the CPU), a naga test for the lighting shaders Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch b/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch index 0f0c417..32bdd4d 100644 --- a/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch +++ b/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch @@ -1,7 +1,7 @@ From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:43:36 +0300 -Subject: [PATCH 15/38] HD: GPU time per pass in the F3 timing list, +Subject: [PATCH 15/39] HD: GPU time per pass in the F3 timing list, half-resolution GTAO with a depth-aware blur Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch b/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch index 3b83689..8ca7adf 100644 --- a/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch +++ b/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch @@ -1,7 +1,7 @@ From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:02:02 +0300 -Subject: [PATCH 16/38] HD: shadows draw only sun-facing quads and skip buried +Subject: [PATCH 16/39] HD: shadows draw only sun-facing quads and skip buried sections, the far cascade every second frame; TAA (Halton jitter, reprojection by depth, YCoCg variance clipping) before bloom diff --git a/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch b/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch index 2b093df..5257c98 100644 --- a/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch +++ b/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch @@ -1,7 +1,7 @@ From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:02:02 +0300 -Subject: [PATCH 17/38] Mods: model handles (render.model-id, model-generation, +Subject: [PATCH 17/39] Mods: model handles (render.model-id, model-generation, set-models-by-id; API level 11) so mods send no model names each tick; clear-post and set-lighting documented diff --git a/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch b/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch index 6c24ee2..f06353e 100644 --- a/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch +++ b/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch @@ -1,7 +1,7 @@ From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:19:57 +0300 -Subject: [PATCH 18/38] HD: temporal upscaling (HORIZON_HD_SCALE: world, +Subject: [PATCH 18/39] HD: temporal upscaling (HORIZON_HD_SCALE: world, G-buffer and lighting at a lower size, rebuilt to the window in the TAA pass); fix flickering shadows near the player (the near cascade lost its sections on frames the far one was held, the held far matrix ignored the diff --git a/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch b/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch index b754e29..52d8927 100644 --- a/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch +++ b/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch @@ -1,7 +1,7 @@ From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:47:52 +0300 -Subject: [PATCH 19/38] HD water: its own pass (colour, normal, depth), the +Subject: [PATCH 19/39] HD water: its own pass (colour, normal, depth), the floor under it lit, a composite with world-space waves, refraction with depth absorption in the biome colour, screen-space reflections, Fresnel and a shadowed sun glint (HORIZON_HD_WATER=0 to switch off) diff --git a/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch b/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch index 0392c03..4ac72a3 100644 --- a/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch +++ b/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch @@ -1,7 +1,7 @@ From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:56:04 +0300 -Subject: [PATCH 20/38] HD water: no vanilla texture on the surface (the biome +Subject: [PATCH 20/39] HD water: no vanilla texture on the surface (the biome colour only), a darker, deeper colour Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch b/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch index c67a4fb..2480158 100644 --- a/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch +++ b/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch @@ -1,7 +1,7 @@ From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 18:38:19 +0300 -Subject: [PATCH 21/38] HD sky and light: a physical atmosphere (Rayleigh, Mie, +Subject: [PATCH 21/39] HD sky and light: a physical atmosphere (Rayleigh, Mie, ozone; constants and transmittance after Photon, MIT) for the sky, haze, fog and water reflections; warmer sunlight from the atmosphere's transmittance, a gentle sky-coloured ambient, soft PCSS-lite shadows near the player, a haze diff --git a/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch b/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch index 03b0349..0a4e194 100644 --- a/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch +++ b/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch @@ -1,7 +1,7 @@ From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 19:02:16 +0300 -Subject: [PATCH 22/38] HD water: a darker blue, a little clearer, ripples a +Subject: [PATCH 22/39] HD water: a darker blue, a little clearer, ripples a little higher Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch b/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch index f505fac..e028e6a 100644 --- a/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch +++ b/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch @@ -1,7 +1,7 @@ From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 21:41:48 +0300 -Subject: [PATCH 23/38] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun +Subject: [PATCH 23/39] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun tint, blue hour; MIT), volumetric clouds with ground shadows instead of vanilla's in HD, volumetric fog with sun shafts and morning mist (HORIZON_HD_CLOUDS=0, HORIZON_HD_VOLFOG=0) diff --git a/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch b/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch index 52de64c..d312339 100644 --- a/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch +++ b/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch @@ -1,7 +1,7 @@ From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 21:43:22 +0300 -Subject: [PATCH 24/38] Mods: world queries for simulations (render.query-rays, +Subject: [PATCH 24/39] Mods: world queries for simulations (render.query-rays, query-sections, world-changes; API level 12): block-exact rays and section occupancy by collision, sight or sound, answered next tick within budgets, and a log of changed sections diff --git a/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch b/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch index aca5406..9495442 100644 --- a/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch +++ b/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch @@ -1,7 +1,7 @@ From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 22:56:15 +0300 -Subject: [PATCH 25/38] HD fixes: rain level from the world not from rain +Subject: [PATCH 25/39] HD fixes: rain level from the world not from rain geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint without a depth heuristic (#35), air fog once over water (#37), translucent things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick diff --git a/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch b/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch index 6468a58..62d7005 100644 --- a/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch +++ b/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch @@ -1,7 +1,7 @@ From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 23:19:53 +0300 -Subject: [PATCH 26/38] HD split into modules (gfx/hd/: one Rust file and one +Subject: [PATCH 26/39] HD split into modules (gfx/hd/: one Rust file and one WGSL file per pass, a shared prelude), contracts for colour, depth, alpha, time and coordinates written down, tests that the frame layout and bindings match between Rust and WGSL (#45) diff --git a/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch b/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch index da7f567..bf56b0b 100644 --- a/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch +++ b/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch @@ -1,7 +1,7 @@ From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 23:22:19 +0300 -Subject: [PATCH 27/38] HD clouds: noise scales that divide the camera's +Subject: [PATCH 27/39] HD clouds: noise scales that divide the camera's 65536-block wrap, so the clouds and their shadows no longer jump at x or z = 0 diff --git a/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch b/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch index 05e474b..836e3f8 100644 --- a/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch +++ b/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch @@ -1,7 +1,7 @@ From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:17:11 +0300 -Subject: [PATCH 28/38] HD: volumetric smoke and missile trails with clear +Subject: [PATCH 28/39] HD: volumetric smoke and missile trails with clear tunnels through fog and cloud (render.set-smoke, API level 14; #54); sun speculars by material, wet surfaces and puddles in rain, wind in foliage (HORIZON_HD_SPECULAR=0, HORIZON_HD_WIND=0, HORIZON_HD_SMOKE=0); HD shaders diff --git a/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch b/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch index 02c8b33..b28a4e9 100644 --- a/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch +++ b/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch @@ -1,7 +1,7 @@ From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:26:09 +0300 -Subject: [PATCH 29/38] HD clouds composited by depth so terrain and vehicles +Subject: [PATCH 29/39] HD clouds composited by depth so terrain and vehicles inside or behind the layer are hidden and those in front stay in front (#67); world-anchored noise everywhere (cloud detail by height above the layer, fog, smoke and puddle scales dividing the wrap) (#68); the replay's time is visual diff --git a/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch b/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch index 9ab4b90..7f1e69f 100644 --- a/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch +++ b/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch @@ -1,7 +1,7 @@ From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:44:45 +0300 -Subject: [PATCH 30/38] HD smoke: young trails drawn at least as wide as a grid +Subject: [PATCH 30/39] HD smoke: young trails drawn at least as wide as a grid cell (density scaled to keep the mass), gentler bulges, so a trail is a smooth ribbon and not a chain of puffs diff --git a/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch b/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch index 3742d47..3e8ac20 100644 --- a/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch +++ b/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch @@ -1,7 +1,7 @@ From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:59:41 +0300 -Subject: [PATCH 31/38] Mods: render.set-main-view (API level 15) renders the +Subject: [PATCH 31/39] Mods: render.set-main-view (API level 15) renders the screen's world from a mod camera with the full pipeline, so replays get HD and the post effects; small mod views get the post pass with their own camera diff --git a/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch b/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch index 64a5fdb..e9f30a6 100644 --- a/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch +++ b/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch @@ -1,7 +1,7 @@ From 27e24527550942695164dd3a769a42a659e797ca Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 09:28:05 +0300 -Subject: [PATCH 32/38] Spot lights for mods (render.set-spot-lights, API level +Subject: [PATCH 32/39] Spot lights for mods (render.set-spot-lights, API level 16): surfaces lit in the cone, a volumetric beam stopped by depth (#60); film capture (render.capture-*, set-film-quality, API level 17): fixed-step clock, frames piped to ffmpeg or PNGs, offline audio, no chat in the frames (#58); diff --git a/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch b/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch index 8d00abb..471dff4 100644 --- a/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch +++ b/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch @@ -1,7 +1,7 @@ From 615f5c3fa7c19a9895f8972a88333c6f250ad78a Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 14:30:39 +0300 -Subject: [PATCH 33/38] HD clouds and fog polish: cloud panorama, cirrus, +Subject: [PATCH 33/39] HD clouds and fog polish: cloud panorama, cirrus, terrain-aware fog, far smoke cascade; replay world log and film clock; material overlays by sprite name diff --git a/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch b/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch index 9365aea..78d052f 100644 --- a/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch +++ b/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch @@ -1,7 +1,7 @@ From 0254984fdcce53a4a0f2bff15f9be3b78c93842e Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 15:01:09 +0300 -Subject: [PATCH 34/38] HD: coloured sun through glass and leaves, rain ripples +Subject: [PATCH 34/39] HD: coloured sun through glass and leaves, rain ripples on water, film motion blur and depth of field Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0035-Film-export-every-frame-in-flight-reaches-the-writer.patch b/horizon-patches/0035-Film-export-every-frame-in-flight-reaches-the-writer.patch index 22e5751..38b7e6e 100644 --- a/horizon-patches/0035-Film-export-every-frame-in-flight-reaches-the-writer.patch +++ b/horizon-patches/0035-Film-export-every-frame-in-flight-reaches-the-writer.patch @@ -1,7 +1,7 @@ From 9645d3f1f47d8bb358d5630288bdd801bd874911 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 15:19:32 +0300 -Subject: [PATCH 35/38] Film export: every frame in flight reaches the writer, +Subject: [PATCH 35/39] Film export: every frame in flight reaches the writer, listener follows the main view, cloud wind on film frames, set-smoke reports real activity diff --git a/horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch b/horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch index 50974c2..ab170fb 100644 --- a/horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch +++ b/horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch @@ -1,7 +1,7 @@ From 22d6835507d5627b0fe4a749b082b025b35e0262 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 15:43:11 +0300 -Subject: [PATCH 36/38] Mod command trees: command.set-tree and +Subject: [PATCH 36/39] Mod command trees: command.set-tree and set-suggestions, typed parse, colouring and completion from one tree (API 19) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0037-Persistent-mod-sound-sources-start-update-stop-with-.patch b/horizon-patches/0037-Persistent-mod-sound-sources-start-update-stop-with-.patch index 29f260b..8a3c845 100644 --- a/horizon-patches/0037-Persistent-mod-sound-sources-start-update-stop-with-.patch +++ b/horizon-patches/0037-Persistent-mod-sound-sources-start-update-stop-with-.patch @@ -1,7 +1,7 @@ From 9be21a54fff055e78db245488c28a971a823fdba Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 16:10:23 +0300 -Subject: [PATCH 37/38] Persistent mod sound sources: start, update, stop with +Subject: [PATCH 37/39] Persistent mod sound sources: start, update, stop with generations, click-free voices, loop sections, voice budget and virtual loops (API 20) diff --git a/horizon-patches/0038-HD-clouds-rays-from-a-far-point-so-the-view-bob-s-ey.patch b/horizon-patches/0038-HD-clouds-rays-from-a-far-point-so-the-view-bob-s-ey.patch index 28402d2..55ec993 100644 --- a/horizon-patches/0038-HD-clouds-rays-from-a-far-point-so-the-view-bob-s-ey.patch +++ b/horizon-patches/0038-HD-clouds-rays-from-a-far-point-so-the-view-bob-s-ey.patch @@ -1,7 +1,7 @@ From e84cdc21a9fabc4518c18edbeacb2df24c85b983 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 19:07:37 +0300 -Subject: [PATCH 38/38] HD clouds: rays from a far point, so the view bob's eye +Subject: [PATCH 38/39] HD clouds: rays from a far point, so the view bob's eye offset does not bend them Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0039-Acoustics-stage-1-direct-path-occlusion-ported-from-.patch b/horizon-patches/0039-Acoustics-stage-1-direct-path-occlusion-ported-from-.patch new file mode 100644 index 0000000..eae4f34 --- /dev/null +++ b/horizon-patches/0039-Acoustics-stage-1-direct-path-occlusion-ported-from-.patch @@ -0,0 +1,1684 @@ +From 19f07ff4295766c7bee4bd1fbfc509b6a82d12d4 Mon Sep 17 00:00:00 2001 +From: chexit +Date: Fri, 30 Oct 2026 22:09:23 +0300 +Subject: [PATCH 39/39] Acoustics stage 1: direct-path occlusion ported from + Sound Physics Remastered 453654e (GPL-3.0, separate files), off by default; + stereo output on surround devices; mixer sum in a fixed order (API 21) + +Co-Authored-By: Claude Opus 5.5 +--- + crates/horizon-client/src/acoustics.rs | 543 ++++++++++++++++++ + .../src/acoustics/THIRD_PARTY_SPR.md | 23 + + .../src/acoustics/spr_occlusion.rs | 234 ++++++++ + crates/horizon-client/src/audio.rs | 341 ++++++++++- + crates/horizon-client/src/game.rs | 1 + + crates/horizon-client/src/game/acoustics.rs | 113 ++++ + crates/horizon-client/src/game/film.rs | 4 + + crates/horizon-client/src/game/mods.rs | 4 + + crates/horizon-client/src/game/music.rs | 1 + + crates/horizon-client/src/lib.rs | 1 + + crates/horizon-client/src/world.rs | 9 + + crates/horizon-mod-sdk/wit/horizon.wit | 5 + + crates/horizon-mods/src/api_level.rs | 6 +- + crates/horizon-mods/src/runtime.rs | 11 + + crates/horizon-mods/tests/ui_guest/src/lib.rs | 1 + + docs/api/mods-reference.md | 1 + + docs/api/mods.md | 4 + + 17 files changed, 1298 insertions(+), 4 deletions(-) + create mode 100644 crates/horizon-client/src/acoustics.rs + create mode 100644 crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md + create mode 100644 crates/horizon-client/src/acoustics/spr_occlusion.rs + create mode 100644 crates/horizon-client/src/game/acoustics.rs + +diff --git a/crates/horizon-client/src/acoustics.rs b/crates/horizon-client/src/acoustics.rs +new file mode 100644 +index 000000000..8863e4e39 +--- /dev/null ++++ b/crates/horizon-client/src/acoustics.rs +@@ -0,0 +1,543 @@ ++//! AirDef fork: acoustics for the mixer (#96), stage 1: the direct path. What lies between a ++//! sound and the listener (a wall, a mountain) dims the sound and cuts its highs. ++//! ++//! The formulas are Sound Physics Remastered's (`spr_occlusion`, GPL-3.0, see ++//! `THIRD_PARTY_SPR.md`); the rest is ours. The world is read from a [`Geometry`], a snapshot of ++//! the resident chunks (their `Arc`s, so a block written meanwhile copies the chunk and the ++//! snapshot stays what it was). Tracing never happens in the audio callback: the mixer only ++//! holds the numbers (`audio::Occlusion`) that [`Acoustics::step`] hands to it, once per tick. ++//! ++//! Work comes in two sizes. A sound the engine has no number for yet is traced at once on the ++//! game thread by one path (cheap, so a blast starts muffled, not bright), under a count budget. ++//! The full estimate (nine paths) of every audible source is traced on a worker thread, one job ++//! 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. ++//! ++//! `HORIZON_ACOUSTICS=1` turns it on; off (the default) nothing here runs and the mixer is the ++//! mixer it was. ++pub mod spr_occlusion; ++ ++use crate::chunk::{Chunk, HEIGHT, MIN_Y}; ++use crate::fast_hash::FxHashMap; ++use std::collections::HashMap; ++use std::sync::Arc; ++use std::sync::mpsc::{Receiver, Sender}; ++ ++/// Sources further than this from the listener are not traced (the world beyond is unknown to the ++/// walk anyway); SPR gives up at 512 too, but sees only 64 blocks of world. ++pub const RANGE: f64 = 512.0; ++/// The gain of the highs the air leaves per block (EFX `AIR_ABSORPTION_GAINHF` default). ++pub const AIR: f32 = 0.994; ++/// Sources quieter than this (volume times distance gain) are left alone. ++const AUDIBLE: f32 = 0.003; ++/// Cells a tick may walk at once for sources with no number yet (live), one path each. ++const INLINE_CELLS: usize = 40_000; ++/// Cells a film's tick may walk (nine paths where the source is loud). ++const FILM_CELLS: usize = 600_000; ++/// Cells one worker job may walk. ++const JOB_CELLS: usize = 400_000; ++/// Sources one job holds, the loudest first. ++const JOB_SOURCES: usize = 32; ++/// Sources that get the nine paths; the rest get one. ++const FULL_PATHS: usize = 16; ++/// Cached estimates. ++const CACHE: usize = 4096; ++/// Change records kept to judge a job that returns late. ++const LOG: usize = 2048; ++ ++/// The environment's occlusion by block: `None` is a block nobody knows (no chunk). ++pub trait Geometry: Send + Sync { ++ /// A function giving the occlusion of the block at x, y, z (0: sound passes). ++ fn walker(&self) -> Box Option + '_>; ++} ++ ++/// The resident chunks and a table of the occlusion of each block state. ++pub struct Snapshot { ++ pub chunks: FxHashMap<(i32, i32), Arc>, ++ pub table: Arc>, ++ pub epoch: u32, ++ pub revision: u64, ++ pub tick: u64, ++ pub center: [f64; 3], ++} ++impl Geometry for Snapshot { ++ fn walker(&self) -> Box Option + '_> { ++ let mut last: Option<((i32, i32), &Arc)> = None; ++ Box::new(move |x, y, z| { ++ if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) { ++ return Some(0.0); ++ } ++ let key = (x >> 4, z >> 4); ++ let chunk = match last { ++ Some((k, c)) if k == key => c, ++ _ => { ++ let c = self.chunks.get(&key)?; ++ last = Some((key, c)); ++ c ++ } ++ }; ++ let state = chunk.get(x & 15, y, z & 15) as usize; ++ Some(self.table.get(state).copied().unwrap_or(0.0)) ++ }) ++ } ++} ++ ++/// A sound the mixer is playing, as the engine sees it. ++#[derive(Clone, Copy, Debug, PartialEq)] ++pub struct Source { ++ pub id: u32, ++ pub at: [f64; 3], ++ /// Volume times the distance gain: what the listener would hear without any wall. ++ pub loud: f32, ++} ++impl Source { ++ pub fn audible(&self) -> bool { ++ self.loud >= AUDIBLE ++ } ++} ++ ++/// What the mixer is told about a source: the overall gain and the gain of the highs. ++#[derive(Clone, Copy, Debug, PartialEq)] ++pub struct Applied { ++ pub id: u32, ++ pub gain: f32, ++ pub cutoff: f32, ++} ++ ++/// The block changes since the last step, as the world reports them. ++#[derive(Clone, Debug, Default)] ++pub struct Changes { ++ pub epoch: u32, ++ pub overflow: bool, ++ pub sections: Vec<(i32, i32, i32)>, ++} ++ ++type Cell = (i32, i32, i32); ++#[derive(Clone, Copy, Debug)] ++struct Entry { ++ occlusion: f64, ++ paths: u8, ++ /// The box (in blocks) the paths lay in. ++ min: [i32; 3], ++ max: [i32; 3], ++ used: u64, ++} ++impl Entry { ++ fn touches(&self, s: Cell) -> bool { ++ let b = [s.0 * 16, s.1 * 16, s.2 * 16]; ++ (0..3).all(|a| self.min[a] < b[a] + 16 && self.max[a] >= b[a]) ++ } ++} ++ ++/// What the engine measured, for `status`. ++#[derive(Clone, Debug, Default)] ++pub struct Stats { ++ pub traces: u64, ++ pub cells: u64, ++ pub unknown_cells: u64, ++ pub hits: u64, ++ pub misses: u64, ++ pub jobs: u64, ++ pub dropped_late: u64, ++ pub invalidated: u64, ++ pub skipped_busy: u64, ++ pub sources: usize, ++ pub cached: usize, ++} ++ ++struct Job { ++ seq: u64, ++ geometry: Arc, ++ listener: [f64; 3], ++ items: Vec<(Key, [f64; 3], usize)>, ++} ++struct Done { ++ seq: u64, ++ entries: Vec<(Key, Entry)>, ++ cells: usize, ++ unknown: usize, ++} ++type Key = (Cell, Cell); ++ ++fn cell_of(p: [f64; 3]) -> Cell { ++ ((p[0] / 2.0).floor() as i32, (p[1] / 2.0).floor() as i32, (p[2] / 2.0).floor() as i32) ++} ++ ++/// Traces the direct path of one source. Returns the entry, the cells walked and the unknown ones. ++fn trace(geometry: &dyn Geometry, listener: [f64; 3], at: [f64; 3], paths: usize, budget: &mut usize) -> (Entry, usize, usize) { ++ let before = *budget; ++ let mut cell = geometry.walker(); ++ let (occlusion, unknown) = ++ spr_occlusion::calculate_occlusion(&mut *cell, at, listener, paths, budget); ++ let pad = 1.0; ++ let lo = |a: usize| (at[a].min(listener[a]) - pad).floor() as i32; ++ let hi = |a: usize| (at[a].max(listener[a]) + pad).floor() as i32; ++ ( ++ Entry { ++ occlusion, ++ paths: paths as u8, ++ min: [lo(0), lo(1), lo(2)], ++ max: [hi(0), hi(1), hi(2)], ++ used: 0, ++ }, ++ before - *budget, ++ unknown, ++ ) ++} ++ ++/// The engine: a cache of estimates, a worker, and what the mixer was last told. ++pub struct Acoustics { ++ /// Film capture (or a test): traced at once, never on the worker. ++ pub sync: bool, ++ cache: HashMap, ++ clock: u64, ++ applied: HashMap, ++ log: std::collections::VecDeque<(u64, Cell)>, ++ seq: u64, ++ /// Jobs up to this number belong to a world that is gone. ++ floor: u64, ++ in_flight: Option, ++ worker: Option<(Sender, Receiver)>, ++ epoch: u32, ++ pub stats: Stats, ++} ++ ++impl Acoustics { ++ pub fn new(sync: bool) -> Self { ++ Self { ++ sync, ++ cache: HashMap::new(), ++ clock: 0, ++ applied: HashMap::new(), ++ log: Default::default(), ++ seq: 0, ++ floor: 0, ++ in_flight: None, ++ worker: None, ++ epoch: 0, ++ stats: Stats::default(), ++ } ++ } ++ ++ /// Forget everything (another world, a film began or ended). ++ pub fn reset(&mut self) { ++ self.cache.clear(); ++ self.applied.clear(); ++ self.log.clear(); ++ // A job in flight returns into a world that is gone: its sequence number is stale. ++ self.seq += 1; ++ self.floor = self.seq; ++ self.in_flight = None; ++ } ++ ++ fn spawn(&mut self) { ++ let (job_tx, job_rx) = std::sync::mpsc::channel::(); ++ let (done_tx, done_rx) = std::sync::mpsc::channel::(); ++ let spawned = std::thread::Builder::new().name("acoustics".into()).spawn(move || { ++ for job in job_rx { ++ let mut budget = JOB_CELLS; ++ let (mut cells, mut unknown) = (0, 0); ++ let mut entries = Vec::with_capacity(job.items.len()); ++ for (key, at, paths) in job.items { ++ let (entry, c, u) = trace(&*job.geometry, job.listener, at, paths, &mut budget); ++ cells += c; ++ unknown += u; ++ entries.push((key, entry)); ++ } ++ if done_tx.send(Done { seq: job.seq, entries, cells, unknown }).is_err() { ++ break; ++ } ++ } ++ }); ++ if spawned.is_ok() { ++ self.worker = Some((job_tx, done_rx)); ++ } ++ } ++ ++ fn invalidate(&mut self, changes: &Changes) { ++ if changes.epoch != self.epoch || changes.overflow { ++ self.epoch = changes.epoch; ++ self.stats.invalidated += self.cache.len() as u64; ++ self.cache.clear(); ++ self.log.clear(); ++ self.seq += 1; ++ self.floor = self.seq; ++ self.in_flight = None; ++ self.applied.clear(); ++ return; ++ } ++ for &s in &changes.sections { ++ let before = self.cache.len(); ++ self.cache.retain(|_, e| !e.touches(s)); ++ self.stats.invalidated += (before - self.cache.len()) as u64; ++ self.log.push_back((self.seq, s)); ++ } ++ while self.log.len() > LOG { ++ self.log.pop_front(); ++ } ++ } ++ ++ fn insert(&mut self, key: Key, mut entry: Entry) { ++ entry.used = self.clock; ++ self.cache.insert(key, entry); ++ if self.cache.len() > CACHE { ++ let mut ages: Vec = self.cache.values().map(|e| e.used).collect(); ++ ages.sort_unstable(); ++ let cut = ages[ages.len() / 4]; ++ self.cache.retain(|_, e| e.used > cut); ++ } ++ } ++ ++ /// One step (a game tick): take what the worker finished, settle the changes, give each ++ /// source its numbers, trace what is missing, send the rest to the worker. Returns the ++ /// numbers that changed, for the mixer. ++ pub fn step( ++ &mut self, ++ geometry: Option<&Arc>, ++ listener: [f64; 3], ++ sources: &[Source], ++ changes: &Changes, ++ ) -> Vec { ++ self.clock += 1; ++ self.invalidate(changes); ++ // The worker's results; one that began before a change in its box is not kept. ++ if let Some((_, rx)) = &self.worker { ++ let done: Vec = rx.try_iter().collect(); ++ for d in done { ++ if self.in_flight == Some(d.seq) { ++ self.in_flight = None; ++ } ++ self.stats.cells += d.cells as u64; ++ self.stats.unknown_cells += d.unknown as u64; ++ self.stats.jobs += 1; ++ if d.seq <= self.floor { ++ self.stats.dropped_late += d.entries.len() as u64; ++ continue; ++ } ++ for (key, e) in d.entries { ++ if self.log.iter().any(|(s, c)| *s >= d.seq && e.touches(*c)) { ++ self.stats.dropped_late += 1; ++ } else { ++ self.insert(key, e); ++ } ++ } ++ } ++ } ++ let live: Vec = sources ++ .iter() ++ .copied() ++ .filter(|s| s.audible() && dist(s.at, listener) <= RANGE) ++ .collect(); ++ self.stats.sources = live.len(); ++ self.applied.retain(|id, _| sources.iter().any(|s| s.id == *id)); ++ let Some(geometry) = geometry else { ++ return self.settle(&live, listener, None); ++ }; ++ // The loudest first; ties by id, so the order never depends on a hash. ++ let mut order: Vec = (0..live.len()).collect(); ++ order.sort_by(|&a, &b| live[b].loud.total_cmp(&live[a].loud).then(live[a].id.cmp(&live[b].id))); ++ let lcell = cell_of(listener); ++ let mut inline = if self.sync { FILM_CELLS } else { INLINE_CELLS }; ++ let mut job: Vec<(Key, [f64; 3], usize)> = Vec::new(); ++ for (rank, &i) in order.iter().enumerate() { ++ let s = &live[i]; ++ let key = (cell_of(s.at), lcell); ++ 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 { ++ self.stats.hits += 1; ++ continue; ++ } ++ } ++ self.stats.misses += 1; ++ // No number yet (a new sound) or a film: at once. Otherwise the worker refines it. ++ let fresh = !self.applied.contains_key(&s.id) && !self.cache.contains_key(&key); ++ if self.sync || fresh { ++ if inline == 0 { ++ continue; ++ } ++ let paths = if self.sync { want } else { 1 }; ++ let (e, cells, unknown) = trace(&**geometry, listener, s.at, paths, &mut inline); ++ self.stats.traces += 1; ++ self.stats.cells += cells as u64; ++ self.stats.unknown_cells += unknown as u64; ++ self.insert(key, e); ++ } else if job.len() < JOB_SOURCES { ++ job.push((key, s.at, want)); ++ } ++ } ++ if !job.is_empty() && !self.sync { ++ if self.worker.is_none() { ++ self.spawn(); ++ } ++ if self.in_flight.is_some() { ++ self.stats.skipped_busy += 1; ++ } else if let Some((tx, _)) = &self.worker { ++ self.seq += 1; ++ let seq = self.seq; ++ if tx ++ .send(Job { seq, geometry: geometry.clone(), listener, items: job }) ++ .is_ok() ++ { ++ self.in_flight = Some(seq); ++ } ++ } ++ } ++ self.stats.cached = self.cache.len(); ++ self.settle(&live, listener, Some(lcell)) ++ } ++ ++ /// The numbers for each live source from the cache; only the changed ones are returned. ++ fn settle(&mut self, live: &[Source], listener: [f64; 3], lcell: Option) -> Vec { ++ let lcell = lcell.unwrap_or_else(|| cell_of(listener)); ++ let mut out = Vec::new(); ++ for s in live { ++ let Some(e) = self.cache.get(&(cell_of(s.at), lcell)) else { ++ continue; ++ }; ++ let (cutoff, gain) = spr_occlusion::direct_params(e.occlusion); ++ // The air takes the highs with the distance (OpenAL's default for sources that ask ++ // for it: 0.994 of the gain at 5 kHz per block). ++ let cutoff = cutoff * AIR.powf(dist(s.at, listener) as f32); ++ let now = Applied { id: s.id, gain, cutoff }; ++ let same = self.applied.get(&s.id).is_some_and(|p| { ++ (p.gain - gain).abs() < 0.002 && (p.cutoff - cutoff).abs() < 0.002 * p.cutoff.max(0.01) ++ }); ++ if !same { ++ self.applied.insert(s.id, now); ++ out.push(now); ++ } ++ } ++ out ++ } ++} ++ ++fn dist(a: [f64; 3], b: [f64; 3]) -> f64 { ++ ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ /// A grid of blocks for tests: a wall at x in `wall`, occlusion `v`. ++ struct Wall { ++ wall: std::ops::Range, ++ v: std::sync::atomic::AtomicU32, ++ } ++ impl Wall { ++ fn new(wall: std::ops::Range, v: f32) -> Arc { ++ Arc::new(Wall { wall, v: std::sync::atomic::AtomicU32::new(v.to_bits()) }) ++ } ++ } ++ impl Geometry for Wall { ++ fn walker(&self) -> Box Option + '_> { ++ let v = f32::from_bits(self.v.load(std::sync::atomic::Ordering::Relaxed)); ++ Box::new(move |x, _, _| Some(if self.wall.contains(&x) { v } else { 0.0 })) ++ } ++ } ++ fn src(id: u32, x: f64) -> Source { ++ Source { id, at: [x, 70.5, 0.5], loud: 0.5 } ++ } ++ ++ #[test] ++ fn a_wall_between_dims_and_muffles_and_open_ground_does_not() { ++ let mut a = Acoustics::new(true); ++ let wall = Wall::new(100..103, 1.0); ++ let out = a.step(Some(&wall), [0.5, 70.5, 0.5], &[src(1, 200.5), src(2, 50.5)], &Changes::default()); ++ let behind = out.iter().find(|o| o.id == 1).unwrap(); ++ assert!(behind.cutoff < 0.001 && (behind.gain - 0.41).abs() < 0.01, "{behind:?}"); ++ // The open one needs no change to the mixer's defaults. ++ let open = out.iter().find(|o| o.id == 2).unwrap(); ++ assert_eq!(open.gain, 1.0); ++ assert!((open.cutoff - AIR.powf(50.0)).abs() < 1e-6, "only the air: {open:?}"); ++ } ++ ++ #[test] ++ fn the_same_inputs_give_the_same_numbers_whatever_the_machine() { ++ let run = || { ++ let mut a = Acoustics::new(true); ++ let wall = Wall::new(40..42, 1.0); ++ let sources: Vec = (0..40).map(|i| src(i, 80.5 + f64::from(i) * 3.0)).collect(); ++ let mut all = Vec::new(); ++ for _ in 0..3 { ++ all.extend(a.step(Some(&wall), [0.5, 70.5, 0.5], &sources, &Changes::default())); ++ } ++ all ++ }; ++ assert_eq!(run(), run()); ++ } ++ ++ #[test] ++ fn a_changed_section_drops_what_passed_through_it_and_the_wall_gone_unmuffles() { ++ let mut a = Acoustics::new(true); ++ let wall = Wall::new(40..42, 1.0); ++ let l = [0.5, 70.5, 0.5]; ++ let first = a.step(Some(&wall), l, &[src(1, 90.5)], &Changes::default()); ++ assert!(first[0].cutoff < 0.01); ++ // Same cell, cached: nothing to tell the mixer. ++ assert!(a.step(Some(&wall), l, &[src(1, 90.5)], &Changes::default()).is_empty()); ++ assert!(a.stats.hits >= 1); ++ // The wall is destroyed: the section (2, 4, 0) changed. ++ let open = Wall::new(0..0, 1.0); ++ let change = Changes { sections: vec![(2, 4, 0)], ..Default::default() }; ++ let out = a.step(Some(&open), l, &[src(1, 90.5)], &change); ++ assert_eq!(out[0].gain, 1.0); ++ assert!((out[0].cutoff - AIR.powf(90.0)).abs() < 1e-6, "{:?}", out[0]); ++ // A change elsewhere keeps the cache. ++ let hits = a.stats.hits; ++ a.step(Some(&open), l, &[src(1, 90.5)], &Changes { sections: vec![(50, 4, 50)], ..Default::default() }); ++ assert_eq!(a.stats.hits, hits + 1); ++ } ++ ++ #[test] ++ fn a_new_world_forgets_everything_and_the_budget_is_counted() { ++ 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 out = a.step(Some(&wall), l, &[src(1, 90.5)], &Changes { epoch: 9, ..Default::default() }); ++ assert_eq!(out.len(), 1, "told again after the epoch moved"); ++ // 400 loud sources: the film budget caps the cells walked in a step. ++ let mut b = Acoustics::new(true); ++ let many: Vec = (0..400).map(|i| src(i, 300.0 + f64::from(i % 50) * 4.0)).collect(); ++ b.step(Some(&Wall::new(5..6, 1.0)), l, &many, &Changes::default()); ++ assert!(b.stats.cells <= FILM_CELLS as u64, "{}", b.stats.cells); ++ } ++ ++ #[test] ++ fn the_worker_refines_a_source_and_never_the_game_thread() { ++ let mut a = Acoustics::new(false); ++ let wall = Wall::new(40..42, 1.0); ++ let l = [0.5, 70.5, 0.5]; ++ let s = [src(1, 90.5)]; ++ // A new source: one path at once. ++ let first = a.step(Some(&wall), l, &s, &Changes::default()); ++ assert_eq!(first.len(), 1); ++ assert_eq!(a.stats.traces, 1); ++ // The one-path estimate is refined to nine on the worker, and its answer arrives. ++ let wait = |a: &mut Acoustics, want: u64, srcs: &[Source]| { ++ for _ in 0..400 { ++ a.step(Some(&wall), l, srcs, &Changes::default()); ++ if a.stats.jobs >= want { ++ return; ++ } ++ std::thread::sleep(std::time::Duration::from_millis(5)); ++ } ++ panic!("the worker did not answer"); ++ }; ++ wait(&mut a, 1, &s); ++ // Refined: the same cell asks nothing more. ++ for _ in 0..3 { ++ a.step(Some(&wall), l, &s, &Changes::default()); ++ } ++ assert_eq!(a.stats.jobs, 1); ++ // A moved source in a new cell goes to the worker too. ++ let moved = [src(1, 94.5)]; ++ wait(&mut a, 2, &moved); ++ assert_eq!(a.stats.traces, 1, "the game thread traced only the new source"); ++ } ++} +diff --git a/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md b/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md +new file mode 100644 +index 000000000..9c756af88 +--- /dev/null ++++ b/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md +@@ -0,0 +1,23 @@ ++# Third-party code: Sound Physics Remastered ++ ++`spr_occlusion.rs` in this folder is derived from Sound Physics Remastered by henkelmax and its ++contributors, https://github.com/henkelmax/sound-physics-remastered, branch `master`, commit ++453654ece086f40c97712d9016baa4dee7c4f6d2, distributed under the GNU General Public License, ++version 3 (the `LICENSE` file of that repository). ++ ++What is taken: from `SoundPhysics.java` the direct path's occlusion (`runOcclusion`: the sum of ++the occlusion of the solid blocks along the line, at most 16 of them, stopped at 64; ++`calculateOcclusion`: the least over the straight route and eight routes moved by 0.35 blocks ++at both ends) and its result (`cutoff = exp(-occlusion * 3 * block_absorption)`, ++`gain = cutoff^0.1`, the source's own block ignored); from `OcclusionConfig.java` the occlusion ++of the block groups (1.0 by default, wool 1.5, moss 0.75, honey 0.5, glass and snow 0.1, plants ++and ladders 0) and `non_full_block_occlusion_factor` 0.25. The ray walk (a voxel walk instead of ++Minecraft's `traverseBlocks` over collision shapes), the filter that applies the result, the ++scheduler, the cache and the worker are our own. SPR's reflections, reverb and EFX parts are not ++used at this stage. ++ ++Because this file is GPL-3.0-only, a build of Horizon that contains it is distributed under the ++terms of the GPL-3.0 for the whole program (source on request, same licence for the combination). ++The other files of the fork remain MIT; this file carries its notice in its header. ++ ++The licence text is at https://www.gnu.org/licenses/gpl-3.0.txt. +diff --git a/crates/horizon-client/src/acoustics/spr_occlusion.rs b/crates/horizon-client/src/acoustics/spr_occlusion.rs +new file mode 100644 +index 000000000..1bb5fbbbc +--- /dev/null ++++ b/crates/horizon-client/src/acoustics/spr_occlusion.rs +@@ -0,0 +1,234 @@ ++// SPDX-License-Identifier: GPL-3.0-only ++// ++// Derived from Sound Physics Remastered by henkelmax and contributors ++// (https://github.com/henkelmax/sound-physics-remastered), commit ++// 453654ece086f40c97712d9016baa4dee7c4f6d2, GPL-3.0: ++// common/src/main/java/com/sonicether/soundphysics/SoundPhysics.java ++// (calculateOcclusion, runOcclusion, the direct cutoff and gain of evaluateEnvironment) ++// common/src/main/java/com/sonicether/soundphysics/config/OcclusionConfig.java (the table) ++// common/src/main/java/com/sonicether/soundphysics/config/SoundPhysicsConfig.java (defaults) ++// Ported to Rust for Horizon's own mixer, not line by line: the ray walk is a voxel walk, the ++// filter is ours (see `crate::audio`), the block groups are Horizon's sound groups. See ++// THIRD_PARTY_SPR.md next to this file. The rest of this crate is MIT; this file is GPL-3.0-only. ++//! The direct path of a sound: how much solid material lies between the source and the listener ++//! and what that does to the sound (SPR's occlusion and absorption). ++ ++/// `block_absorption` (1.0) times SPR's constant 3. ++pub const ABSORPTION: f64 = 3.0; ++/// `max_occlusion`: the sum stops growing here. ++pub const MAX_OCCLUSION: f64 = 64.0; ++/// `max_occlusion_rays`: the solid blocks counted on one path. ++pub const MAX_HITS: usize = 16; ++/// `occlusion_variation`: the corners of the cube the nine paths are offset by. ++pub const VARIATION: f64 = 0.35; ++/// `non_full_block_occlusion_factor`. ++pub const NON_FULL: f32 = 0.25; ++/// `defaultBlockOcclusionFactor`. ++pub const DEFAULT_OCCLUSION: f32 = 1.0; ++ ++/// What one block group does to a sound passing through (`OcclusionConfig.addDefaults`), by the ++/// group name in the block's step sound (`minecraft:block.wool.step` is `wool`). ++pub fn group_occlusion(group: &str) -> f32 { ++ match group { ++ "wool" => 1.5, ++ "moss" => 0.75, ++ "honey_block" => 0.5, ++ "glass" | "snow" | "powder_snow" | "bamboo" | "bamboo_sapling" | "wet_grass" ++ | "moss_carpet" => 0.1, ++ "weeping_vines" | "twisting_vines" | "vine" | "cave_vines" | "sweet_berry_bush" ++ | "spore_blossom" | "small_dripleaf" | "big_dripleaf" | "roots" | "hanging_roots" ++ | "pointed_dripstone" | "scaffolding" | "glow_lichen" | "crop" | "nether_sprouts" ++ | "fungus" | "lily_pad" | "large_amethyst_bud" | "medium_amethyst_bud" ++ | "small_amethyst_bud" | "ladder" | "chain" => 0.0, ++ _ => DEFAULT_OCCLUSION, ++ } ++} ++ ++/// The group of a step sound id: `minecraft:block.stone.step` gives `stone`. ++pub fn group_of(step: &str) -> &str { ++ let name = step.rsplit(':').next().unwrap_or(step); ++ let name = name.strip_prefix("block.").unwrap_or(name); ++ name.strip_suffix(".step").unwrap_or(name) ++} ++ ++/// The direct sound's `(cutoff, gain)` for a summed occlusion: the high-frequency gain ++/// `exp(-occlusion * 3)` and the overall gain `cutoff^0.1`. ++pub fn direct_params(occlusion: f64) -> (f32, f32) { ++ let cutoff = (-occlusion * ABSORPTION).exp() as f32; ++ (cutoff, cutoff.powf(0.1)) ++} ++ ++/// The solid material along `from` to `to`: the sum of the occlusion of every block the segment ++/// passes through, except the block the source is in. `cell` gives a block's occlusion (0: the ++/// sound passes) or `None` where the world is not known, which counts as open air. `budget` is the ++/// number of block cells left to look at; the walk stops when it is spent. Returns the sum and ++/// the number of unknown cells met. ++pub fn run_occlusion( ++ mut cell: impl FnMut(i32, i32, i32) -> Option, ++ from: [f64; 3], ++ to: [f64; 3], ++ budget: &mut usize, ++) -> (f64, usize) { ++ let d = [to[0] - from[0], to[1] - from[1], to[2] - from[2]]; ++ let mut at = [from[0].floor() as i32, from[1].floor() as i32, from[2].floor() as i32]; ++ let end = [to[0].floor() as i32, to[1].floor() as i32, to[2].floor() as i32]; ++ let mut step = [0i32; 3]; ++ let mut t_max = [f64::INFINITY; 3]; ++ let mut t_delta = [f64::INFINITY; 3]; ++ for a in 0..3 { ++ if d[a] > 0.0 { ++ step[a] = 1; ++ t_delta[a] = 1.0 / d[a]; ++ t_max[a] = (f64::from(at[a]) + 1.0 - from[a]) / d[a]; ++ } else if d[a] < 0.0 { ++ step[a] = -1; ++ t_delta[a] = -1.0 / d[a]; ++ t_max[a] = (from[a] - f64::from(at[a])) / -d[a]; ++ } ++ } ++ let (mut sum, mut hits, mut unknown) = (0.0f64, 0usize, 0usize); ++ let mut first = true; ++ loop { ++ // SPR ignores the block the sound is in. ++ if !first { ++ if *budget == 0 { ++ break; ++ } ++ *budget -= 1; ++ match cell(at[0], at[1], at[2]) { ++ None => unknown += 1, ++ Some(v) if v > 0.0 => { ++ sum += f64::from(v); ++ hits += 1; ++ if sum > MAX_OCCLUSION || hits >= MAX_HITS { ++ break; ++ } ++ } ++ Some(_) => {} ++ } ++ } ++ first = false; ++ if at == end { ++ break; ++ } ++ let a = if t_max[0] <= t_max[1] && t_max[0] <= t_max[2] { ++ 0 ++ } else if t_max[1] <= t_max[2] { ++ 1 ++ } else { ++ 2 ++ }; ++ if t_max[a] > 1.0 { ++ break; ++ } ++ at[a] += step[a]; ++ t_max[a] += t_delta[a]; ++ } ++ (sum, unknown) ++} ++ ++/// The least occlusion over `paths` routes (`calculateOcclusion`): the straight one, then (for 9) ++/// the same segment moved by `(+-v, +-v, +-v)` at both ends, so that a gap by the door or the ++/// edge of a wall lets the sound through. A free route ends the search, as nothing is less. ++pub fn calculate_occlusion( ++ mut cell: impl FnMut(i32, i32, i32) -> Option, ++ from: [f64; 3], ++ to: [f64; 3], ++ paths: usize, ++ budget: &mut usize, ++) -> (f64, usize) { ++ let (mut best, mut unknown) = run_occlusion(&mut cell, from, to, budget); ++ if paths <= 1 { ++ return (best.min(MAX_OCCLUSION), unknown); ++ } ++ for x in [-1.0, 1.0] { ++ for y in [-1.0, 1.0] { ++ for z in [-1.0, 1.0] { ++ if best <= 0.0 { ++ return (0.0, unknown); ++ } ++ let o = [x * VARIATION, y * VARIATION, z * VARIATION]; ++ let (v, u) = run_occlusion( ++ &mut cell, ++ [from[0] + o[0], from[1] + o[1], from[2] + o[2]], ++ [to[0] + o[0], to[1] + o[1], to[2] + o[2]], ++ budget, ++ ); ++ best = best.min(v); ++ unknown += u; ++ } ++ } ++ } ++ (best.min(MAX_OCCLUSION), unknown) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ /// A wall of `thick` blocks of `v` occlusion at x = 5.., open elsewhere. ++ fn wall(thick: i32, v: f32) -> impl FnMut(i32, i32, i32) -> Option { ++ move |x, _, _| Some(if (5..5 + thick).contains(&x) { v } else { 0.0 }) ++ } ++ ++ #[test] ++ fn stone_walls_cut_the_highs_as_spr_does() { ++ // 1, 2 and 3 blocks of stone: cutoff 0.050, 0.0025, 0.0001, gain 0.74, 0.55, 0.41. ++ for (thick, cutoff, gain) in [(1, 0.0498, 0.741), (2, 0.00248, 0.549), (3, 0.000123, 0.407)] { ++ let mut budget = 10_000; ++ let (occ, _) = run_occlusion(wall(thick, 1.0), [0.5, 0.5, 0.5], [12.5, 0.5, 0.5], &mut budget); ++ assert!((occ - f64::from(thick)).abs() < 1e-9, "{thick}: {occ}"); ++ let (c, g) = direct_params(occ); ++ assert!((c - cutoff).abs() / cutoff < 0.02, "{thick}: {c}"); ++ assert!((g - gain).abs() < 0.005, "{thick}: {g}"); ++ } ++ } ++ ++ #[test] ++ fn the_source_block_is_ignored_and_open_air_is_free() { ++ let mut budget = 10_000; ++ let inside = |x: i32, _, _| Some(if x == 0 { 1.0 } else { 0.0 }); ++ assert_eq!(run_occlusion(inside, [0.5; 3], [9.5, 0.5, 0.5], &mut budget).0, 0.0); ++ assert_eq!(run_occlusion(|_, _, _| Some(0.0), [0.5; 3], [-90.0, 30.0, 12.0], &mut budget).0, 0.0); ++ } ++ ++ #[test] ++ fn unknown_chunks_count_as_open_air() { ++ let mut budget = 10_000; ++ let (occ, unknown) = run_occlusion(|_, _, _| None, [0.5; 3], [30.5, 0.5, 0.5], &mut budget); ++ assert_eq!(occ, 0.0); ++ assert_eq!(unknown, 30); ++ } ++ ++ #[test] ++ fn a_gap_beside_a_wall_lets_the_sound_through_with_nine_paths() { ++ // A wall at x = 5 with a hole 1 block wide at y = 0..1; the source looks along y = 0.9 ++ // at the hole's edge: one path hits the wall's rim, the shifted ones pass. ++ let cell = |x: i32, y: i32, _| Some(if x == 5 && y != 0 { 1.0 } else { 0.0 }); ++ let mut budget = 100_000; ++ let single = calculate_occlusion(cell, [0.5, 1.2, 0.5], [12.5, 1.2, 0.5], 1, &mut budget).0; ++ let nine = calculate_occlusion(cell, [0.5, 1.2, 0.5], [12.5, 1.2, 0.5], 9, &mut budget).0; ++ assert_eq!(single, 1.0); ++ assert!(nine < single, "{nine}"); ++ } ++ ++ #[test] ++ fn the_sum_is_capped_and_the_budget_is_counted() { ++ let mut budget = 10_000; ++ let (occ, _) = run_occlusion(|_, _, _| Some(40.0), [0.5; 3], [90.5, 0.5, 0.5], &mut budget); ++ assert!(occ > MAX_OCCLUSION && occ <= MAX_OCCLUSION + 40.0); ++ assert_eq!(calculate_occlusion(|_, _, _| Some(40.0), [0.5; 3], [90.5, 0.5, 0.5], 1, &mut budget).0, MAX_OCCLUSION); ++ let mut small = 5; ++ let (occ, _) = run_occlusion(|_, _, _| Some(0.0), [0.5; 3], [90.5, 0.5, 0.5], &mut small); ++ assert_eq!((occ, small), (0.0, 0)); ++ } ++ ++ #[test] ++ fn groups_come_from_the_step_sound() { ++ assert_eq!(group_of("minecraft:block.wool.step"), "wool"); ++ assert_eq!(group_occlusion("wool"), 1.5); ++ assert_eq!(group_occlusion(group_of("minecraft:block.glass.step")), 0.1); ++ assert_eq!(group_occlusion(group_of("minecraft:block.stone.step")), 1.0); ++ assert_eq!(group_occlusion(group_of("minecraft:block.ladder.step")), 0.0); ++ } ++} +diff --git a/crates/horizon-client/src/audio.rs b/crates/horizon-client/src/audio.rs +index 61df7bf7d..9ef116839 100644 +--- a/crates/horizon-client/src/audio.rs ++++ b/crates/horizon-client/src/audio.rs +@@ -41,6 +41,49 @@ struct Channel { + relative: bool, + /// A mod source (AirDef fork): smoothed gain, pan and pitch, a click-free envelope. + voice: Option, ++ /// Acoustics (AirDef fork, #96): what stands between the source and the listener. ++ occlusion: Option, ++} ++/// The direct sound's treatment by what is between the source and the listener: an overall gain ++/// and a gain of the highs (a shelf above [`SHELF_HZ`]: the low part passes at `gain`, the high ++/// part at `gain * cut`), both moving towards their targets over about 80 ms, so a new estimate ++/// is no step. The lows are kept on purpose: a mountain muffles a blast, it does not silence its ++/// rumble. ++#[derive(Clone, Debug)] ++struct Occlusion { ++ gain: f32, ++ cut: f32, ++ gain_to: f32, ++ cut_to: f32, ++ /// The low-pass states (two poles) that split the sound at [`SHELF_HZ`]. ++ low: f32, ++ low2: f32, ++} ++/// Where the occlusion shelf splits the sound. ++const SHELF_HZ: f32 = 1000.0; ++impl Occlusion { ++ fn new(gain: f32, cut: f32) -> Self { ++ Self { gain, cut, gain_to: gain, cut_to: cut, low: 0.0, low2: 0.0 } ++ } ++ /// Moves towards the targets for a buffer of `seconds`: the gain and cut at its start and end, ++ /// and the coefficient of the split. ++ fn buffer(&mut self, seconds: f32, rate: u32) -> ([f32; 2], [f32; 2], f32) { ++ let alpha = 1.0 - (-seconds / 0.08).exp(); ++ let (g0, c0) = (self.gain, self.cut); ++ self.gain += (self.gain_to - self.gain) * alpha; ++ self.cut += (self.cut_to - self.cut) * alpha; ++ let a = 1.0 - (-std::f32::consts::TAU * SHELF_HZ / rate as f32).exp(); ++ ([g0, self.gain], [c0, self.cut], a) ++ } ++ /// One sample, `t` of the way through the buffer. ++ #[inline] ++ fn run(&mut self, x: f32, t: f32, g: [f32; 2], c: [f32; 2], a: f32) -> f32 { ++ // Two poles, so the highs really are cut (one would leave an eighth of 8 kHz). ++ self.low += a * (x - self.low); ++ self.low2 += a * (self.low - self.low2); ++ let cut = c[0] + (c[1] - c[0]) * t; ++ (self.low2 + cut * (x - self.low2)) * (g[0] + (g[1] - g[0]) * t) ++ } + } + /// What makes a channel a mod source's voice. + #[derive(Clone, Debug)] +@@ -228,6 +271,12 @@ pub struct Audio { + film_saved: Option<(bool, bool)>, + /// The mods' persistent sound sources (mod API level 20). + pub mod_sources: crate::mod_sources::ModSources, ++ /// Acoustics (AirDef fork, #96), when `HORIZON_ACOUSTICS` asks for them. ++ pub acoustics: Option, ++} ++/// Whether `HORIZON_ACOUSTICS` turns the acoustics on (unset, empty or `0`: off). ++pub fn acoustics_requested() -> bool { ++ std::env::var("HORIZON_ACOUSTICS").is_ok_and(|v| !v.is_empty() && v != "0") + } + fn decode(path: &Path) -> Option<(Vec, u32)> { + use symphonia::core::{ +@@ -297,6 +346,7 @@ impl Backend for MixerBackend { + at: [0.0; 3], + relative: false, + voice: None, ++ occlusion: None, + }, + ); + Some(id) +@@ -571,6 +621,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) { + let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0) * dop); + voices.push((*id, [left, right, gain], step)); + } ++ // In a fixed order, so the sum (and a film's track) is the same in every run. ++ voices.sort_by_key(|v| v.0); + let buffer_seconds = (data.len() / channels.max(1)) as f32 / rate as f32; + for (id, [left, right, gain], mut step) in voices { + let Some(c) = mixer.channels.get_mut(&id) else { +@@ -595,6 +647,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) { + let end = if looping { region.1 } else { span }; + let frames = (data.len() / channels.max(1)).max(1); + let env_step = v.env_rate / rate as f32; ++ let shelf = c.occlusion.as_mut().map(|o| o.buffer(buffer_seconds, rate)); + for (k, frame) in data.chunks_mut(channels).enumerate() { + if c.position >= end { + if looping { +@@ -615,6 +668,10 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) { + } + let s = voice_sample(&samples, c.position, looping, region) * v.env; + let t = k as f32 / frames as f32; ++ let s = match (c.occlusion.as_mut(), &shelf) { ++ (Some(o), Some((g, cut, a))) => o.run(s, t, *g, *cut, *a), ++ _ => s, ++ }; + let lerp = |a: f32, b: f32| a + (b - a) * t; + if channels >= 2 { + frame[0] += s * lerp(from[0], left); +@@ -628,7 +685,9 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) { + v.age += buffer_seconds; + continue; + } +- for frame in data.chunks_mut(channels) { ++ let shelf = c.occlusion.as_mut().map(|o| o.buffer(buffer_seconds, rate)); ++ let frames = (data.len() / channels.max(1)).max(1); ++ for (k, frame) in data.chunks_mut(channels).enumerate() { + let mut i = c.position as usize; + if i + 1 >= samples.len() { + if c.looping && samples.len() > 1 { +@@ -641,6 +700,10 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) { + } + let t = (c.position - i as f64) as f32; + let s = samples[i] * (1.0 - t) + samples[i + 1] * t; ++ let s = match (c.occlusion.as_mut(), &shelf) { ++ (Some(o), Some((g, cut, a))) => o.run(s, k as f32 / frames as f32, *g, *cut, *a), ++ _ => s, ++ }; + if channels >= 2 { + frame[0] += s * left; + frame[1] += s * right; +@@ -692,7 +755,7 @@ fn open_device( + mixer: Arc>, + ) -> Result<(cpal::Stream, cpal::SupportedStreamConfig)> { + use cpal::SampleFormat as F; +- let config = device.default_output_config()?; ++ let config = preferred_config(device)?; + let stream = match config.sample_format() { + F::F32 => build_stream::(device, &config, mixer), + F::F64 => build_stream::(device, &config, mixer), +@@ -707,6 +770,42 @@ fn open_device( + stream.play()?; + Ok((stream, config)) + } ++/// The device's default configuration, except that a device with more than two channels (a ++/// virtual 7.1 headset) opens as stereo, at the same rate, when it supports that: the mixer ++/// pans for two channels. ++#[cfg(feature = "audio")] ++fn preferred_config(device: &cpal::Device) -> Result { ++ let default = device.default_output_config()?; ++ if default.channels() <= 2 { ++ return Ok(default); ++ } ++ let rate = default.sample_rate(); ++ let stereo = device ++ .supported_output_configs() ++ .into_iter() ++ .flatten() ++ .filter(|c| c.channels() == 2 && c.min_sample_rate() <= rate && rate <= c.max_sample_rate()) ++ .min_by_key(|c| c.sample_format() != default.sample_format()); ++ Ok(stereo.map_or(default, |c| c.with_sample_rate(rate))) ++} ++/// On a device with more than two channels the stereo mix is also copied to every further pair ++/// (an odd last channel gets the left), so a driver that routes another pair than the front one ++/// still plays it. ++#[cfg_attr(not(feature = "audio"), allow(dead_code))] ++fn mirror_pairs(data: &mut [f32], channels: usize) { ++ if channels <= 2 { ++ return; ++ } ++ for frame in data.chunks_mut(channels) { ++ if frame.len() < channels { ++ break; ++ } ++ let (left, right) = (frame[0], frame[1]); ++ for (i, s) in frame.iter_mut().enumerate().skip(2) { ++ *s = if i % 2 == 0 { left } else { right }; ++ } ++ } ++} + #[cfg(feature = "audio")] + fn build_stream>( + device: &cpal::Device, +@@ -725,6 +824,7 @@ fn build_stream>( + scratch.fill(0.0); + } else { + mix(&mut m, &mut scratch, channels, rate); ++ mirror_pairs(&mut scratch, channels); + } + } else { + scratch.fill(0.0); +@@ -798,6 +898,7 @@ impl Audio { + stopped: Default::default(), + film_saved: None, + mod_sources: Default::default(), ++ acoustics: acoustics_requested().then(|| crate::acoustics::Acoustics::new(false)), + } + } + /// Film capture starts: every sound stops, and from now on only the sounds played between +@@ -817,6 +918,10 @@ impl Audio { + m.offline = true; + m.channels.clear(); + } ++ if let Some(a) = self.acoustics.as_mut() { ++ a.reset(); ++ a.sync = true; ++ } + } + /// Whether the sounds played now are the film's. + pub fn film_allow(&mut self, on: bool) { +@@ -844,6 +949,10 @@ impl Audio { + m.offline = false; + m.channels.clear(); + } ++ if let Some(a) = self.acoustics.as_mut() { ++ a.reset(); ++ a.sync = false; ++ } + } + #[cfg(not(feature = "audio"))] + fn open(_: Arc>) -> Result { +@@ -957,6 +1066,70 @@ impl Audio { + m.tick(listener, e, b); + }); + } ++ /// Whether acoustics are on and a sound is playing in the world: the game then builds the ++ /// geometry for [`Audio::tick_acoustics`]. ++ pub fn acoustics_wanted(&self) -> bool { ++ self.acoustics.is_some() ++ && self.backend.mixer.lock().is_ok_and(|m| { ++ m.channels.values().any(|c| c.state == State::Playing && !c.relative) ++ }) ++ } ++ /// The positional sounds that play now, for the acoustics. ++ fn acoustic_sources(&self) -> Vec { ++ let Ok(m) = self.backend.mixer.lock() else { ++ return Vec::new(); ++ }; ++ let mut out: Vec = m ++ .channels ++ .iter() ++ .filter(|(_, c)| c.state == State::Playing && !c.relative && c.samples.is_some()) ++ .map(|(id, c)| { ++ let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])]; ++ let d = [at[0] - m.listener[0], at[1] - m.listener[1], at[2] - m.listener[2]]; ++ let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32; ++ crate::acoustics::Source { ++ id: *id as u32, ++ at, ++ loud: c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance), ++ } ++ }) ++ .collect(); ++ out.sort_by_key(|s| s.id); ++ out ++ } ++ /// Once per tick, with the geometry (None: the world is not there to read): traces what is ++ /// missing and tells the mixer what lies between each sound and the listener. ++ pub fn tick_acoustics( ++ &mut self, ++ geometry: Option<&Arc>, ++ changes: &crate::acoustics::Changes, ++ ) { ++ if self.acoustics.is_none() { ++ return; ++ } ++ let sources = self.acoustic_sources(); ++ let listener = self.listener; ++ let Some(acoustics) = self.acoustics.as_mut() else { ++ return; ++ }; ++ let applied = acoustics.step(geometry, listener, &sources, changes); ++ if applied.is_empty() { ++ return; ++ } ++ if let Ok(mut m) = self.backend.mixer.lock() { ++ for a in applied { ++ if let Some(c) = m.channels.get_mut(&(a.id as ChannelId)) { ++ match c.occlusion.as_mut() { ++ Some(o) => { ++ o.gain_to = a.gain; ++ o.cut_to = a.cutoff; ++ } ++ None => c.occlusion = Some(Occlusion::new(a.gain, a.cutoff)), ++ } ++ } ++ } ++ } ++ } + /// `SoundManager.pauseAllExcept(MUSIC, UI)`. + pub fn pause(&mut self) { + self.mod_sources.pause(&mut self.backend); +@@ -1094,6 +1267,15 @@ impl Audio { + "sources": sources, "voices": voiced, "fading": fading, + "epoch": self.mod_sources.epoch, "dropped": self.mod_sources.dropped, + }); ++ if let Some(a) = &self.acoustics { ++ let t = &a.stats; ++ census["acoustics"] = serde_json::json!({ ++ "sync": a.sync, "sources": t.sources, "cached": t.cached, "traces": t.traces, ++ "cells": t.cells, "unknown_cells": t.unknown_cells, "hits": t.hits, ++ "misses": t.misses, "jobs": t.jobs, "dropped_late": t.dropped_late, ++ "invalidated": t.invalidated, "skipped_busy": t.skipped_busy, ++ }); ++ } + if let Ok(cache) = self.backend.cache.lock() { + census["decoded"] = cache.entries.len().into(); + census["decoded_mib"] = (cache.bytes >> 20).into(); +@@ -1203,6 +1385,7 @@ mod tests { + at, + relative: false, + voice: None, ++ occlusion: None, + } + } + +@@ -1462,6 +1645,160 @@ mod tests { + assert_eq!(run(), run()); + } + ++ /// The rms of a stereo buffer's left channel. ++ fn rms(x: &[f32]) -> f32 { ++ let l: Vec = x.chunks(2).map(|f| f[0]).collect(); ++ (l.iter().map(|v| v * v).sum::() / l.len() as f32).sqrt() ++ } ++ fn tone(hz: f32, n: usize) -> Vec { ++ (0..n).map(|i| (std::f32::consts::TAU * hz * i as f32 / 48_000.0).sin()).collect() ++ } ++ /// A relative source dead ahead, so the pan is a plain number; `occlusion`: (gain, cut). ++ fn heard(hz: f32, occlusion: Option<(f32, f32)>) -> f32 { ++ let mut m = Mixer::default(); ++ let mut c = channel(tone(hz, 48_000), None, [0.0; 3], true); ++ c.occlusion = occlusion.map(|(g, k)| Occlusion::new(g, k)); ++ m.channels.insert(1, c); ++ let out = render(&mut m, 40, 480); ++ rms(&out[out.len() / 2..]) ++ } ++ ++ #[test] ++ fn a_surround_device_hears_the_stereo_mix_on_every_pair() { ++ // Two frames of a 7.1 device: the mixer wrote the first pair only. ++ let mut data = [0.3, -0.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; ++ mirror_pairs(&mut data, 8); ++ assert_eq!(&data[..8], &[0.3, -0.2, 0.3, -0.2, 0.3, -0.2, 0.3, -0.2]); ++ assert_eq!(&data[8..], &[0.5, 0.1, 0.5, 0.1, 0.5, 0.1, 0.5, 0.1]); ++ // An odd count (5.0): the last channel gets the left. ++ let mut five = [0.4, 0.6, 0.0, 0.0, 0.0]; ++ mirror_pairs(&mut five, 5); ++ assert_eq!(five, [0.4, 0.6, 0.4, 0.6, 0.4]); ++ // Stereo and mono are left alone. ++ let mut stereo = [0.1, 0.2]; ++ mirror_pairs(&mut stereo, 2); ++ assert_eq!(stereo, [0.1, 0.2]); ++ } ++ ++ #[test] ++ fn a_wall_cuts_the_highs_and_keeps_the_lows() { ++ // One block of stone in SPR's numbers: highs x0.05, overall gain 0.74. ++ let (g, k) = (0.741, 0.0498); ++ let open_low = heard(100.0, None); ++ let open_high = heard(8_000.0, None); ++ let low = heard(100.0, Some((g, k))) / open_low; ++ let high = heard(8_000.0, Some((g, k))) / open_high; ++ assert!((low - g).abs() < 0.03, "the lows pass at the gain: {low}"); ++ assert!(high < g * (k + 0.03), "the highs are cut to about gain x cut: {high}"); ++ // And with nothing between, the shelf is no filter. ++ let clear = heard(8_000.0, Some((1.0, 1.0))) / open_high; ++ assert!((clear - 1.0).abs() < 0.01, "{clear}"); ++ } ++ ++ #[test] ++ fn a_new_estimate_is_a_slide_not_a_step() { ++ let mut m = Mixer::default(); ++ let mut c = channel(tone(300.0, 48_000), None, [0.0; 3], true); ++ c.occlusion = Some(Occlusion::new(1.0, 1.0)); ++ m.channels.insert(1, c); ++ let before = render(&mut m, 10, 480); ++ let o = m.channels.get_mut(&1).unwrap().occlusion.as_mut().unwrap(); ++ (o.gain_to, o.cut_to) = (0.4, 0.001); ++ let after = render(&mut m, 40, 480); ++ let all: Vec = before.iter().chain(&after).copied().collect(); ++ assert!(max_step(&all) < 0.1, "no click: {}", max_step(&all)); ++ assert!(rms(&after[after.len() - 960..]) < 0.5 * rms(&before[before.len() - 960..])); ++ } ++ ++ #[test] ++ fn occluded_rendering_is_deterministic() { ++ let run = || { ++ let mut m = Mixer::default(); ++ for id in 1..=5u64 { ++ let mut c = voice_channel(tone(200.0 * id as f32, 9_600), [id as f32, 0.0, 9.0], true, 0.0, 0.0, 0.0); ++ c.occlusion = Some(Occlusion::new(0.5, 0.05 * id as f32)); ++ m.channels.insert(id, c); ++ } ++ render(&mut m, 30, 333) ++ }; ++ assert_eq!(run(), run()); ++ } ++ ++ /// Writes the track of a blast with and without a wall between it and the listener, through ++ /// the real acoustics and mixer, for `tools/acoustics_check.py`: ++ /// `HORIZON_ACOUSTICS_OUT=/dir cargo test -p horizon-client --lib audio::tests::export_wall_tracks -- --ignored`. ++ #[test] ++ #[ignore] ++ fn export_wall_tracks() { ++ use crate::acoustics::{Acoustics, Changes, Geometry, Source}; ++ struct Wall(bool); ++ impl Geometry for Wall { ++ fn walker(&self) -> Box Option + '_> { ++ let wall = self.0; ++ Box::new(move |x, _, _| Some(if wall && (20..22).contains(&x) { 1.0 } else { 0.0 })) ++ } ++ } ++ let Some(dir) = std::env::var_os("HORIZON_ACOUSTICS_OUT") else { ++ return; ++ }; ++ // A blast: noise with a fast decay and a low thump, 1.5 s. ++ let mut seed = 12345u32; ++ let blast: Vec = (0..72_000) ++ .map(|i| { ++ seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); ++ let noise = (seed >> 8) as f32 / 8_388_608.0 - 1.0; ++ let t = i as f32 / 48_000.0; ++ (noise * 0.6 + (std::f32::consts::TAU * 70.0 * t).sin() * 0.5) * (-t * 4.0).exp() ++ }) ++ .collect(); ++ for (name, wall) in [("open", false), ("wall", true)] { ++ let mut m = Mixer::default(); ++ m.listener = [0.5, 70.5, 0.5]; ++ m.channels.insert(1, channel(blast.clone(), Some(500.0), [60.5, 70.5, 0.5], false)); ++ let geometry: Arc = Arc::new(Wall(wall)); ++ let mut acoustics = Acoustics::new(true); ++ let source = Source { id: 1, at: [60.5, 70.5, 0.5], loud: 0.88 }; ++ for a in acoustics.step(Some(&geometry), m.listener, &[source], &Changes::default()) { ++ m.channels.get_mut(&1).unwrap().occlusion = Some(Occlusion::new(a.gain, a.cutoff)); ++ } ++ let out = render(&mut m, 150, 480); ++ let raw: Vec = out.iter().flat_map(|s| ((s.clamp(-1.0, 1.0) * 32767.0) as i16).to_le_bytes()).collect(); ++ std::fs::write(std::path::Path::new(&dir).join(format!("{name}.raw")), raw).unwrap(); ++ } ++ } ++ ++ /// The cost of the shelf for 100 voices, in release: `cargo test -p horizon-client --lib ++ /// --release audio::tests::bench -- --ignored --nocapture`. ++ #[test] ++ #[ignore] ++ fn bench_one_hundred_voices_with_and_without_occlusion() { ++ let make = |occluded: bool| { ++ let mut m = Mixer::default(); ++ for id in 1..=100u64 { ++ let mut c = voice_channel(tone(110.0 + id as f32 * 7.0, 96_000), [id as f32 * 3.0, 1.0, 40.0], true, 0.0, 0.0, 0.0); ++ c.linear = Some(500.0); ++ if occluded { ++ c.occlusion = Some(Occlusion::new(0.5, 0.1)); ++ } ++ m.channels.insert(id, c); ++ } ++ m ++ }; ++ let time = |mut m: Mixer| { ++ let mut data = vec![0.0f32; 1024]; ++ for _ in 0..50 { ++ mix(&mut m, &mut data, 2, 48_000); ++ } ++ let start = std::time::Instant::now(); ++ for _ in 0..1000 { ++ mix(&mut m, &mut data, 2, 48_000); ++ } ++ start.elapsed().as_secs_f64() * 1e3 / 1000.0 ++ }; ++ let (plain, occluded) = (time(make(false)), time(make(true))); ++ println!("100 voices, 512 frames: {plain:.3} ms plain, {occluded:.3} ms with occlusion ({:+.3} ms); buffer is 10.67 ms", occluded - plain); ++ } ++ + #[test] + fn doppler_is_bounded_and_off_at_zero() { + assert_eq!(doppler_factor(-300.0, 0.0), 1.0); +diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs +index 7a87e9ffb..735f26d21 100644 +--- a/crates/horizon-client/src/game.rs ++++ b/crates/horizon-client/src/game.rs +@@ -109,6 +109,7 @@ mod minecarts; + mod mob_blocks; + mod mob_gear; + mod mob_sounds; ++mod acoustics; + mod mod_browser; + #[cfg(feature = "mods")] + mod mod_browser_recipes; +diff --git a/crates/horizon-client/src/game/acoustics.rs b/crates/horizon-client/src/game/acoustics.rs +new file mode 100644 +index 000000000..e4c8cc400 +--- /dev/null ++++ b/crates/horizon-client/src/game/acoustics.rs +@@ -0,0 +1,113 @@ ++//! AirDef fork: the game's side of the acoustics (#96): the table of what each block state does ++//! to a sound, the snapshot of the resident chunks they trace through, the block changes that ++//! drop what was traced. The engine is `horizon_client::acoustics`; this only feeds it. ++use super::Game; ++use horizon_client::acoustics::{Changes, Geometry, Snapshot, spr_occlusion}; ++use horizon_world::registry::BlockStateId; ++use std::sync::Arc; ++ ++/// The chunks are read within this many chunks of the listener (the audible range and a margin). ++const CHUNKS: i32 = 36; ++ ++#[derive(Default)] ++pub(super) struct AcousticState { ++ /// The occlusion of each block state, made for a world (its epoch) and the table's size. ++ table: Option<(u32, usize, Arc>)>, ++ snapshot: Option>, ++} ++ ++impl Game<'_> { ++ /// The occlusion of every block state (SPR's table by the block's sound group, a quarter of it ++ /// for a block that is not a full cube); fluids and plants let the sound pass. ++ fn acoustic_table(&mut self) -> Arc> { ++ let epoch = self.world.epoch; ++ let facts = self.res.facts(); ++ if let Some((e, n, t)) = &self.mods.acoustic.table ++ && *e == epoch ++ && *n == facts.states.len() ++ { ++ return t.clone(); ++ } ++ let cached = self.collision.cached_shapes(); ++ let mut table = vec![0.0f32; facts.states.len()]; ++ for (i, f) in facts.states.iter().enumerate() { ++ let state = BlockStateId(i as u32); ++ if facts.is_air(state) { ++ continue; ++ } ++ let (solid, full) = match cached.cached_shape(state).ok().flatten() { ++ Some(s) if s.is_empty() => (false, false), ++ Some(s) => (true, s.is_block()), ++ // Its shape depends on the neighbours: the outline stands in, fluids aside. ++ None => ( ++ f.fluid == horizon_items::blocks::Fluid::None ++ && !f.outline.is_empty() ++ && f.full_collision, ++ f.full_collision, ++ ), ++ }; ++ if !solid { ++ continue; ++ } ++ let group = spr_occlusion::group_of(&f.sounds.step); ++ let v = spr_occlusion::group_occlusion(group); ++ table[i] = if full { v } else { v * spr_occlusion::NON_FULL }; ++ } ++ let table = Arc::new(table); ++ self.mods.acoustic.table = Some((epoch, facts.states.len(), table.clone())); ++ table ++ } ++ ++ /// The snapshot of the chunks round the listener, made again when a block changed or the ++ /// listener went far from where it was made. ++ fn acoustic_snapshot(&mut self) -> Arc { ++ let at = self.audio.listener; ++ if let Some(s) = &self.mods.acoustic.snapshot ++ && s.epoch == self.world.epoch ++ && s.revision == self.world.revision ++ && (s.center[0] - at[0]).abs() < 96.0 ++ && (s.center[2] - at[2]).abs() < 96.0 ++ { ++ return s.clone(); ++ } ++ let table = self.acoustic_table(); ++ let (cx, cz) = ((at[0].floor() as i32) >> 4, (at[2].floor() as i32) >> 4); ++ let chunks = self ++ .world ++ .chunks ++ .iter() ++ .filter(|((x, z), _)| (x - cx).abs() <= CHUNKS && (z - cz).abs() <= CHUNKS) ++ .map(|(k, c)| (*k, c.clone())) ++ .collect(); ++ let snapshot = Arc::new(Snapshot { ++ chunks, ++ table, ++ epoch: self.world.epoch, ++ revision: self.world.revision, ++ tick: self.ticks as u64, ++ center: at, ++ }); ++ self.mods.acoustic.snapshot = Some(snapshot.clone()); ++ snapshot ++ } ++ ++ /// Once per tick, after the mods and the sound engine: what lies between each sound and the ++ /// listener. Nothing happens unless `HORIZON_ACOUSTICS` is on and a sound plays in the world. ++ pub(super) fn tick_acoustics(&mut self) { ++ if self.audio.acoustics.is_none() { ++ return; ++ } ++ self.world.sound_changes.watching = true; ++ if !self.audio.acoustics_wanted() { ++ return; ++ } ++ let (sections, overflow) = self.world.sound_changes.take(); ++ let changes = Changes { ++ epoch: self.world.epoch, ++ overflow, ++ sections, ++ }; ++ let geometry: Arc = self.acoustic_snapshot(); ++ self.audio.tick_acoustics(Some(&geometry), &changes); ++ } ++} +diff --git a/crates/horizon-client/src/game/film.rs b/crates/horizon-client/src/game/film.rs +index 1535eb281..4450624bb 100644 +--- a/crates/horizon-client/src/game/film.rs ++++ b/crates/horizon-client/src/game/film.rs +@@ -738,6 +738,10 @@ impl Game<'_> { + + /// After the frame: the finished copies go to the writer, the audio of the frame is mixed. + pub(super) fn film_after_render(&mut self, rendered: bool) { ++ // Sounds started since the tick's step are traced before they are mixed (#96). ++ if self.film.film.as_ref().is_some_and(|f| f.phase == Phase::Recording) { ++ self.tick_acoustics(); ++ } + let Some(film) = self.film.film.as_mut() else { + return; + }; +diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs +index 59851d91f..edece10f7 100644 +--- a/crates/horizon-client/src/game/mods.rs ++++ b/crates/horizon-client/src/game/mods.rs +@@ -39,6 +39,8 @@ use { + + #[derive(Default)] + pub(super) struct ModsState { ++ /// The acoustics' view of the world (AirDef fork, #96). ++ pub(super) acoustic: super::acoustics::AcousticState, + /// The open world, for `game.world-id` (AirDef fork): its folder, or the server's address. + pub(super) world_id: Option, + #[cfg(feature = "mods")] +@@ -708,6 +710,7 @@ impl Game<'_> { + }) + }); + self.mods.mods.set_source_generation(self.audio.mod_sources.epoch); ++ self.mods.mods.set_acoustics(self.audio.acoustics.is_some()); + self.mods.mods.tick(self.ticks); + self.refresh_mod_trees(); + self.update_mod_models(); +@@ -833,6 +836,7 @@ impl Game<'_> { + self.mods.mods.set_snapshot(snapshot); + } + self.mods.mods.set_source_generation(self.audio.mod_sources.epoch); ++ self.mods.mods.set_acoustics(self.audio.acoustics.is_some()); + self.mods.mods.frame(elapsed.as_secs_f32()); + self.show_mod_output(); + self.sync_mod_sources(); +diff --git a/crates/horizon-client/src/game/music.rs b/crates/horizon-client/src/game/music.rs +index 6e303e7d3..9ffe94f1f 100644 +--- a/crates/horizon-client/src/game/music.rs ++++ b/crates/horizon-client/src/game/music.rs +@@ -44,6 +44,7 @@ impl Game<'_> { + frozen: !self.clock.runs_normally(), + }; + self.audio.tick(self.pause, &ctx); ++ self.tick_acoustics(); + } + /// `getSituationalMusic` and `getMusicVolume`. + fn situational_music(&self) -> (Option, f32) { +diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs +index ba7c8a7a7..879133e81 100644 +--- a/crates/horizon-client/src/lib.rs ++++ b/crates/horizon-client/src/lib.rs +@@ -1,6 +1,7 @@ + //! Horizon native client: resources, world streaming, rendering and the + //! bindings between the validated simulation pieces and the game loop. + #![forbid(unsafe_code)] ++pub mod acoustics; + pub mod advancements; + pub mod ambience; + pub mod animate_tick; +diff --git a/crates/horizon-client/src/world.rs b/crates/horizon-client/src/world.rs +index 40ecbdf7d..d42e51ac8 100644 +--- a/crates/horizon-client/src/world.rs ++++ b/crates/horizon-client/src/world.rs +@@ -105,6 +105,8 @@ pub struct World { + /// The sections whose blocks changed since a mod last asked, kept only + /// while a mod watches (`render.world-changes`). + pub mod_changes: ModChanges, ++ /// The same for the acoustics (AirDef fork, #96): their cache is dropped where a block changed. ++ pub sound_changes: ModChanges, + /// AirDef fork: the log of block changes a mod asked for (`render.world-log-start`) and the + /// game tick the changes are stamped with. + pub log: Option, +@@ -231,6 +233,7 @@ impl World { + pois: Default::default(), + epoch: NEXT_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + mod_changes: ModChanges::default(), ++ sound_changes: ModChanges::default(), + log: None, + log_tick: 0, + } +@@ -335,6 +338,9 @@ impl World { + if self.mod_changes.watching { + self.mod_changes.note(x, y, z); + } ++ if self.sound_changes.watching { ++ self.sound_changes.note(x, y, z); ++ } + if let Some(log) = &mut self.block_log { + log.push((x, y, z)); + } +@@ -417,6 +423,9 @@ impl crate::world_log::Blocks for ShownBlocks<'_> { + if w.mod_changes.watching { + w.mod_changes.note(x, y, z); + } ++ if w.sound_changes.watching { ++ w.sound_changes.note(x, y, z); ++ } + w.touched(x, y, z, previous, state); + } + true +diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit +index b7d17d075..5bf3fa665 100644 +--- a/crates/horizon-mod-sdk/wit/horizon.wit ++++ b/crates/horizon-mod-sdk/wit/horizon.wit +@@ -662,6 +662,11 @@ interface render { + /// reloaded). A mod reads it before `sound-sources` and starts its + /// sources again when it changed. + sound-source-generation: func() -> u32; ++ /// Whether the game muffles sounds by what lies between them and the ++ /// listener (AirDef fork, API level 21: `HORIZON_ACOUSTICS`). A mod that ++ /// muffles by itself (a `_far` variant for a distant blast) plays the near ++ /// one instead when this is true, so nothing is muffled twice. ++ acoustics: func() -> bool; + /// Starts, updates and stops persistent sound sources. `generation` is + /// the `sound-source-generation` the mod's sources belong to; if it is + /// not the current one the call is ignored and returns false (read the +diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs +index 8abd768eb..ee826712f 100644 +--- a/crates/horizon-mods/src/api_level.rs ++++ b/crates/horizon-mods/src/api_level.rs +@@ -7,13 +7,13 @@ + //! version's level so the launcher can tell before it installs. + + /// The newest level this Horizon serves. +-pub const LEVEL: u32 = 20; ++pub const LEVEL: u32 = 21; + + /// Every function the game offers mods, as `interface.function`, with its + /// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new + /// function takes the next level and raises [`LEVEL`] (the `api_reference` + /// test fails until it is listed here). +-pub const FUNCTIONS: [(&str, u32); 82] = [ ++pub const FUNCTIONS: [(&str, u32); 83] = [ + ("log.write", 0), + ("game.get-player", 0), + ("game.get-clock", 0), +@@ -106,6 +106,8 @@ pub const FUNCTIONS: [(&str, u32); 82] = [ + // AirDef fork: persistent sound sources over the mixer. + ("render.sound-source-generation", 20), + ("render.sound-sources", 20), ++ // AirDef fork: whether the game muffles sounds by what is between them and the listener. ++ ("render.acoustics", 21), + ]; + + /// What the game's program carries so the launcher can read its level +diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs +index f974495d6..182ea7f3f 100644 +--- a/crates/horizon-mods/src/runtime.rs ++++ b/crates/horizon-mods/src/runtime.rs +@@ -328,6 +328,8 @@ pub struct ModelTable { + time_view: Option<(u32, u64)>, + /// `render.sound-source-generation`: moves on when the game released every source. + source_generation: u32, ++ /// `render.acoustics`: the game muffles sounds itself. ++ acoustics: bool, + } + + /// The ids of block-change logs: never reused in a run of the game. +@@ -1403,6 +1405,12 @@ impl Mods { + } + } + } ++ /// Whether the game muffles sounds itself (`render.acoustics`). ++ pub fn set_acoustics(&mut self, on: bool) { ++ if let Ok(mut t) = self.model_table.lock() { ++ t.acoustics = on; ++ } ++ } + /// The source changes every running mod made since the last call, tagged with the + /// number of the mod's run ([`Mods::source_runs`]). + pub fn take_source_changes(&mut self) -> Vec<(u64, crate::sources::SourceChange)> { +@@ -2690,6 +2698,9 @@ impl render::Host for State { + sections: Vec::new(), + }) + } ++ fn acoustics(&mut self) -> bool { ++ self.model_table.lock().is_ok_and(|t| t.acoustics) ++ } + fn sound_source_generation(&mut self) -> u32 { + self.model_table.lock().map_or(0, |t| t.source_generation) + } +diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs +index 5219e1ffe..ccd1f6527 100644 +--- a/crates/horizon-mods/tests/ui_guest/src/lib.rs ++++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs +@@ -268,6 +268,7 @@ impl Mod for Guest { + // API level 20: the generation, and a source started and stopped at once. + "source" | "source_keep" => { + let g = render::sound_source_generation(); ++ let _ = render::acoustics(); + let op = |kind| render::SourceOp { + kind, + id: 1, +diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md +index c33e79ec4..bcbd91ebe 100644 +--- a/docs/api/mods-reference.md ++++ b/docs/api/mods-reference.md +@@ -803,6 +803,7 @@ Where a capture is. + | `spawn-particles` | `func(particles: list)` | Spawns particles on this client only: no command, no server. At most 512 spawns a call. | + | `play-sound` | `func(event: string, x: f64, y: f64, z: f64, volume: f32, pitch: f32)` | Plays a sound event (`minecraft:entity.generic.explode`, `:`) at a point on this client. | + | `sound-source-generation` | `func() -> u32` | Moves on whenever the game released every source of every mod (the world closed, the session ended, a film began or ended, the sounds reloaded). A mod reads it before `sound-sources` and starts its sources again when it changed. | ++| `acoustics` | `func() -> bool` | Whether the game muffles sounds by what lies between them and the listener (AirDef fork, API level 21: `HORIZON_ACOUSTICS`). A mod that muffles by itself (a `_far` variant for a distant blast) plays the near one instead when this is true, so nothing is muffled twice. | + | `sound-sources` | `func(generation: u32, ops: list) -> bool` | Starts, updates and stops persistent sound sources. `generation` is the `sound-source-generation` the mod's sources belong to; if it is not the current one the call is ignored and returns false (read the generation, forget the sources, start them again). Stops are never dropped by a limit: only new sources beyond 256 running are, and at most 512 entries a call count. Every source of a mod that stops or is reloaded is released by the game. | + | `query-ground` | `func(columns: list>)` | Asks for the ground height of these columns (x, z): at most 512 a tick, answered by `ground` from the next tick on. | + | `ground` | `func() -> list>` | The answers to the last tick's `query-ground`: (x, z, y), y one above the highest block that blocks motion or holds a fluid, as the motion blocking heightmap; -64 where the chunk isn't loaded. | +diff --git a/docs/api/mods.md b/docs/api/mods.md +index 7f0db0417..73c3cf780 100644 +--- a/docs/api/mods.md ++++ b/docs/api/mods.md +@@ -114,6 +114,10 @@ What real mods need next, in the order modpacks and servers need it: + source updated once a tick has no steps; 24 sources hold a voice (loudest and nearest times priority), the rest of + the loops stay virtual; a stop is never lost to a limit; the sources of a mod that stops or is reloaded are + released. A film mixes them on its own clock like `play-sound`. ++- API level 21 (AirDef fork, capability `render`): `acoustics` tells whether the game muffles sounds by what ++ lies between them and the listener (`HORIZON_ACOUSTICS=1`: a wall or a mountain dims a sound and cuts its highs, ++ traced from a snapshot of the loaded chunks, never in the audio callback). A mod that muffles by itself, such as a ++ `_far` variant for a distant blast, plays the near one when it is true, so nothing is muffled twice. + - API level 19 (AirDef fork, capability `command`): `set-tree` gives a client mod's command a tree of subcommands + (literals), typed words (`word`, `choice`, `integer` and `number` with bounds, `names` from a list, `block-pos`), + optional branches (`runs`), the rest of the line (`text`) and `key=value` options; `set-suggestions` sets the list a +-- +2.54.0 (Apple Git-157) + diff --git a/tools/acoustics_check.py b/tools/acoustics_check.py new file mode 100644 index 0000000..9f745b5 --- /dev/null +++ b/tools/acoustics_check.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Проверка акустики без слуха (#96): энергия по полосам в дорожке с преградой и без неё. + + python3 tools/acoustics_check.py open.raw wall.raw # s16le, 48000 Гц, стерео + python3 tools/acoustics_check.py open.raw wall.raw --rate 48000 --channels 2 + +Дорожки берутся из `Audio::film_mix` (экспорт видео пишет `<имя>.audio.raw`) или из теста +`cargo test -p horizon-client --lib audio::tests::export_wall_tracks -- --ignored` (HORIZON_ACOUSTICS_OUT=папка). +Для каждой дорожки считается энергия левого канала в четырёх полосах (до 300 Гц, 300-1500, 1500-5000, выше +5000) и разница в дБ между дорожками. Преграда должна срезать верхи (выше 5 кГц не меньше чем на 15 дБ) и +оставить низы (до 300 Гц теряют не больше 8 дБ). Коды выхода: 0 — в порогах, 1 — нет, 2 — ошибка аргументов. +Только стандартная библиотека. +""" +import argparse +import array +import cmath +import math +import sys + +BANDS = [("<300", 0, 300), ("300-1500", 300, 1500), ("1500-5000", 1500, 5000), (">5000", 5000, 1e9)] + + +def fft(a): + n = len(a) + if n == 1: + return a + even, odd = fft(a[0::2]), fft(a[1::2]) + t = [cmath.exp(-2j * math.pi * k / n) * odd[k] for k in range(n // 2)] + return [even[k] + t[k] for k in range(n // 2)] + [even[k] - t[k] for k in range(n // 2)] + + +def bands(path, rate, channels): + raw = array.array("h") + with open(path, "rb") as f: + data = f.read() + raw.frombytes(data[: len(data) // 2 * 2]) + left = [s / 32768.0 for s in raw[0::channels]] + n = 1 + while n * 2 <= len(left): + n *= 2 + n = min(n, 1 << 16) + if n < 1024: + sys.exit(f"{path}: too short ({len(left)} frames)") + # A Hann window over the loudest n frames' start: the burst is at the start of the track. + w = [left[i] * (0.5 - 0.5 * math.cos(2 * math.pi * i / (n - 1))) for i in range(n)] + spec = fft([complex(v) for v in w]) + out = [0.0] * len(BANDS) + for k in range(1, n // 2): + hz = k * rate / n + for i, (_, lo, hi) in enumerate(BANDS): + if lo <= hz < hi: + out[i] += abs(spec[k]) ** 2 + return out + + +def db(a, b): + return 10 * math.log10(max(a, 1e-20) / max(b, 1e-20)) + + +def main(): + p = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + p.add_argument("open") + p.add_argument("wall") + p.add_argument("--rate", type=int, default=48000) + p.add_argument("--channels", type=int, default=2) + p.add_argument("--min-high-cut", type=float, default=15.0, help="dB the band above 5 kHz must drop") + p.add_argument("--max-low-loss", type=float, default=8.0, help="dB the band below 300 Hz may drop") + a = p.parse_args() + o, w = bands(a.open, a.rate, a.channels), bands(a.wall, a.rate, a.channels) + print(f"{'band, Hz':>10} {'open dB':>9} {'wall dB':>9} {'change dB':>10}") + ref = max(o) + for (name, _, _), x, y in zip(BANDS, o, w): + print(f"{name:>10} {db(x, ref):9.1f} {db(y, ref):9.1f} {db(y, x):10.1f}") + low, high = db(w[0], o[0]), db(w[-1], o[-1]) + ok = high <= -a.min_high_cut and low >= -a.max_low_loss + print(f"highs {high:.1f} dB (need <= -{a.min_high_cut}), lows {low:.1f} dB (need >= -{a.max_low_loss}): {'ok' if ok else 'FAIL'}") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main())