From ccc3e20902e5b3744fc5e84ae5f293767c3cbb20 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 23:44:14 +0300 Subject: [PATCH] Playtest fixes: smoke by engine, searchlights, far launch sites that load their chunks, air defence balance, last chance and radar-cued shots, radars in smart raids, echo, Windows release build from a tag Smoke: one rule by engine, no heat on trails, no trail on P1-SUN. Searchlights: two lamps a target, off with an empty sky. Halos fade before their reach and their sum is limited. Launch sites load chunks through the server thread, go off-map as a last resort, and reach 4000 blocks. Air defence: ranges x1.5, shots grow with urgency, claims released, /airdef lastchance, long shots on a big radar's tracks. Build: rust-toolchain.toml, airdef.py check, generator manifest and --check. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/build.yml | 4 +- .gitea/workflows/desktop.yml | 78 +- .gitea/workflows/release.yml | 96 + README.md | 43 +- airdef-mod/generated-manifest.txt | 572 +++++ airdef-mod/src/commands.rs | 13 +- airdef-mod/src/layout.rs | 2 + airdef-mod/src/lib.rs | 33 +- airdef-mod/src/scenario.rs | 1 + airdef-mod/src/sim/balance.rs | 297 +++ airdef-mod/src/sim/lights.rs | 16 +- airdef-mod/src/sim/mod.rs | 144 +- airdef-mod/src/sim/raid.rs | 188 +- airdef-mod/src/sim/regress.rs | 2 + airdef-mod/src/sim/sight.rs | 2 +- airdef-mod/src/sim/sites.rs | 240 +- airdef-mod/src/sim/terrain.rs | 4 +- airdef-mod/src/sim/units.rs | 42 +- airdef-mod/src/sim/why.rs | 341 ++- airdef-mod/src/smoke.rs | 114 +- airdef-mod/src/wave.rs | 7 +- docs/ACOUSTICS.md | 52 +- docs/INSTALL.md | 14 +- ...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 | 2 +- ...s-through-the-server-thread-API-22-s.patch | 2202 +++++++++++++++++ rust-toolchain.toml | 5 + tools/acoustics_check.py | 68 +- tools/airdef.py | 117 +- tools/gen_models.py | 126 +- 67 files changed, 4573 insertions(+), 328 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 airdef-mod/generated-manifest.txt create mode 100644 airdef-mod/src/sim/balance.rs create mode 100644 horizon-patches/0040-Mod-chunk-tickets-through-the-server-thread-API-22-s.patch create mode 100644 rust-toolchain.toml diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index e82771b..9867c1a 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -1,7 +1,7 @@ # На каждый пуш: только мод (.hmod, один для всех ОС) и релиз build-<номер> с ним. # Раннер живёт на одной машине с Gitea и другими сервисами (2 ядра, 4 ГБ): сборка мода лёгкая — # один поток компиляции, низший приоритет, без системных пакетов и без кэша target. -# Игра-форк собирается отдельно и только вручную: game.yml (Linux) и desktop.yml (macOS/Windows). +# Игра-форк здесь не собирается: только по тегу версии (release.yml, Windows) и вручную (game.yml Linux, desktop.yml macOS). name: build on: @@ -47,7 +47,7 @@ jobs: run: | set -e mkdir -p out && cp airdef-mod/target/horizon-mod/airdef.hmod out/airdef.hmod - BODY=$(printf 'Сборка %s (%s).\n\n- airdef.hmod — мод, для всех ОС.\n\nИгра-форк Horizon (нужна для мода): собрать у себя — python3 tools/airdef.py all, или запустить вручную workflow game (Linux) / desktop (macOS, Windows) с этим тегом.\nКак запускать: docs/INSTALL.md.' "$TAG" "$SHA") + BODY=$(printf 'Сборка %s (%s).\n\n- airdef.hmod — мод, для всех ОС.\n\nИгра-форк Horizon (нужна для мода): собрать у себя — python3 tools/airdef.py all, или скачать из большого релиза v* (Windows), или запустить вручную workflow game (Linux) / desktop (macOS) с этим тегом.\nКак запускать: docs/INSTALL.md.' "$TAG" "$SHA") DATA=$(BODY="$BODY" python3 -c 'import json,os;print(json.dumps({"tag_name":os.environ["TAG"],"target_commitish":os.environ["SHA"],"name":os.environ["TAG"],"body":os.environ["BODY"]}))') if [ -z "$TOKEN" ]; then echo "::error::нет токена: добавь секрет RELEASE_TOKEN"; exit 1; fi CODE=$(curl -sS -o release.json -w '%{http_code}' -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" "$API/releases" -d "$DATA") diff --git a/.gitea/workflows/desktop.yml b/.gitea/workflows/desktop.yml index 9f90e8f..6b7e98c 100644 --- a/.gitea/workflows/desktop.yml +++ b/.gitea/workflows/desktop.yml @@ -1,6 +1,6 @@ -# Сборки игры-форка для macOS и Windows: запускаются вручную (Actions → desktop → Run workflow) -# с тегом готового релиза (build-N) и дописывают свои архивы в него. Нужны раннеры с метками -# macos-latest / windows-latest (например, act_runner в режиме host на своём Маке или ПК). +# Сборка игры-форка для macOS: запускается вручную (Actions → desktop → Run workflow) с тегом готового +# релиза (build-N) и дописывает свой архив в него. Нужен раннер с меткой macos-latest (например, act_runner +# в режиме host на своём Маке). Windows собирается на сервере по тегу версии: release.yml. name: desktop on: @@ -9,17 +9,12 @@ on: tag: description: Тег релиза, например build-3 required: true - os: - description: macos, windows или both - required: true - default: both env: CARGO_TERM_COLOR: never jobs: game-macos: - if: inputs.os == 'macos' || inputs.os == 'both' runs-on: macos-latest steps: - name: Проверка релиза @@ -83,70 +78,3 @@ jobs: set -e # RELEASE_ID взят в шаге «Проверка релиза» (релиз build-N, созданный build.yml) curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/airdef-macos-arm64.tar.gz" "$API/releases/$RELEASE_ID/assets?name=airdef-macos-arm64.tar.gz" > /dev/null - - game-windows: - if: inputs.os == 'windows' || inputs.os == 'both' - runs-on: windows-latest - defaults: - run: - shell: pwsh - steps: - - name: Проверка релиза - env: - TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }} - API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} - TAG: ${{ inputs.tag }} - run: | - $ErrorActionPreference = 'Stop' - if ($env:TAG -notmatch '^build-\d+$') { Write-Output "::error::тег '$env:TAG' не похож на build-N"; exit 1 } - $h = @{ Authorization = "token $env:TOKEN" } - try { $r = Invoke-RestMethod -Headers $h "$env:API/releases/tags/$env:TAG" } - catch { Write-Output "::error::релиза с тегом $env:TAG нет: сначала дождись сборки build.yml на main"; exit 1 } - $assets = @($r.assets | ForEach-Object { $_.name }) - if ($r.tag_name -ne $env:TAG -or $r.name -ne $env:TAG -or ($assets -notcontains 'airdef.hmod')) { - Write-Output "::error::релиз $env:TAG не создан build.yml (нужны имя = тег и вложение airdef.hmod): чужой релиз не трогаем"; exit 1 - } - "RELEASE_ID=$($r.id)" | Out-File -Append -Encoding utf8 $env:GITHUB_ENV - "RELEASE_SHA=$($r.target_commitish)" | Out-File -Append -Encoding utf8 $env:GITHUB_ENV - - uses: actions/checkout@v4 - with: - ref: refs/tags/${{ inputs.tag }} - - name: Проверка коммита - env: - TAG: ${{ inputs.tag }} - run: | - $ErrorActionPreference = 'Stop' - $head = git rev-parse HEAD - if ($env:RELEASE_SHA -match '^[0-9a-f]{40}$' -and $head -ne $env:RELEASE_SHA) { Write-Output "::error::тег $env:TAG указывает на $head, а релиз создан из $env:RELEASE_SHA"; exit 1 } - Write-Output "собираю $env:TAG = $head" - - name: Rust - run: | - if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { - Invoke-WebRequest https://win.rustup.rs/x86_64 -OutFile rustup-init.exe - .\rustup-init.exe -y --profile minimal - } - "$env:USERPROFILE\.cargo\bin" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH - - name: Форк Horizon - run: python tools/airdef.py fork - - name: Игра - run: python tools/airdef.py game - - name: Мод - run: | - rustup target add wasm32-unknown-unknown - python tools/airdef.py mod - New-Item -ItemType Directory -Force out | Out-Null - Copy-Item airdef-mod\target\horizon-mod\airdef.hmod out\ - - name: Архив - run: | - $D = "airdef-windows-x86_64" - New-Item -ItemType Directory -Force $D | Out-Null - Copy-Item horizon\target\fast\horizon-client.exe, horizon\target\fast\horizon-ctl.exe, out\airdef.hmod, dist\play.bat, docs\INSTALL.md $D - Compress-Archive -Path $D -DestinationPath "out\$D.zip" -Force - - name: В релиз - env: - TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }} - API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} - run: | - # RELEASE_ID взят в шаге «Проверка релиза» (релиз build-N, созданный build.yml) - curl.exe -sf -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@out\airdef-windows-x86_64.zip" "$env:API/releases/$env:RELEASE_ID/assets?name=airdef-windows-x86_64.zip" - diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..ec97ad7 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,96 @@ +# Большой релиз: только по тегу vX.Y.Z (git tag v0.3.0 && git push origin v0.3.0) или вручную с готовым тегом. +# Игра-форк для Windows собирается на Linux-сервере кросс-компиляцией (x86_64-pc-windows-gnu), без Windows-раннера. +# Сервер общий (2 ядра, 4 ГБ, Gitea и боты), поэтому контейнер ограничен: при нехватке памяти убивается сборка, а не +# сервисы. Замер 2026-10-30: 20,5 минуты, 2 ГБ памяти постоянно в потолке, у сервера не меньше 726 МБ свободно. +# Обычные коммиты сюда не попадают: build.yml собирает только мод. +name: release + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: Существующий тег, например v0.3.0 + required: true + +env: + CARGO_TERM_COLOR: never + CARGO_BUILD_JOBS: "1" + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_FAST_INCREMENTAL: "false" + CARGO_PROFILE_FAST_DEBUG: "false" + CARGO_PROFILE_FAST_STRIP: "true" + TAG: ${{ inputs.tag || github.ref_name }} + +jobs: + windows: + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + options: --memory=2g --memory-swap=4500m --cpus=1.5 --cpu-shares=128 --blkio-weight=100 --oom-score-adj=800 + steps: + - name: Место на диске + run: | + FREE=$(df --output=avail -BG / | tail -1 | tr -dc 0-9) + echo "свободно: ${FREE} ГБ" + if [ "$FREE" -lt 12 ]; then echo "::error::на сервере свободно ${FREE} ГБ, нужно хотя бы 12"; exit 1; fi + - uses: actions/checkout@v4 + with: + ref: refs/tags/${{ inputs.tag || github.ref_name }} + - name: Инструменты + run: | + SUDO=$(command -v sudo || true) + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq --no-install-recommends gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 zip + curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain none + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + # Версия Rust берётся из rust-toolchain.toml. + - name: Цели Rust + run: rustup target add x86_64-pc-windows-gnu wasm32-unknown-unknown + - name: Форк Horizon + run: nice -n 19 python3 tools/airdef.py fork + - name: Мод + run: nice -n 19 python3 tools/airdef.py mod + - name: Игра для Windows + working-directory: horizon + run: | + # Сторож: сервер важнее сборки. Память контейнера ограничена, это на случай, если её съело что-то ещё. + ( while sleep 10; do + AVAIL=$(awk '/MemAvailable/{print int($2/1024)}' /proc/meminfo) + DISK=$(df --output=avail -BG / | tail -1 | tr -dc 0-9) + if [ "$AVAIL" -lt 250 ] || [ "$DISK" -lt 6 ]; then + echo "::error::сборка остановлена: свободно ${AVAIL} МБ памяти и ${DISK} ГБ диска" + pkill -9 cargo; pkill -9 rustc; exit 1 + fi + done ) & + WATCH=$! + nice -n 19 ionice -c3 cargo build --profile fast -p horizon-client --features mods --target x86_64-pc-windows-gnu + kill "$WATCH" + - name: Архив + run: | + set -e + D=airdef-windows-x86_64 + T=horizon/target/x86_64-pc-windows-gnu/fast + mkdir -p "$D" out + cp "$T/horizon-client.exe" "$T/horizon-ctl.exe" airdef-mod/target/horizon-mod/airdef.hmod dist/play.bat docs/INSTALL.md "$D/" + zip -qr "out/$D.zip" "$D" + cp airdef-mod/target/horizon-mod/airdef.hmod out/ + - name: Релиз + env: + TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + run: | + set -e + if [ -z "$TOKEN" ]; then echo "::error::нет токена: добавь секрет RELEASE_TOKEN"; exit 1; fi + # Тег с дефисом (v0.3.0-rc1) выходит как предварительный релиз; уже созданный релиз тега дополняется. + DATA=$(python3 -c 'import json,os;t=os.environ["TAG"];print(json.dumps({"tag_name":t,"name":t,"prerelease":"-" in t,"body":"Windows: распакуй airdef-windows-x86_64.zip и запусти play.bat. Нужен Minecraft Java 26.3 и лаунчер Horizon, запущенный один раз. Подробнее: docs/INSTALL.md.\n\nmacOS: сборка из исходников, python3 tools/airdef.py all."}))') + CODE=$(curl -sS -o release.json -w '%{http_code}' -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG") + if [ "$CODE" != "200" ]; then + CODE=$(curl -sS -o release.json -w '%{http_code}' -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" "$API/releases" -d "$DATA") + if [ "$CODE" != "201" ]; then echo "::error::Gitea не создала релиз (HTTP $CODE): $(cat release.json)"; exit 1; fi + fi + ID=$(python3 -c 'import json;print(json.load(open("release.json"))["id"])') + for F in airdef-windows-x86_64.zip airdef.hmod; do + curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/$F" "$API/releases/$ID/assets?name=$F" > /dev/null + done diff --git a/README.md b/README.md index a30ea55..defaa4b 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,8 @@ CI: `.gitea/workflows/build.yml` на каждый пуш собирает то - *умный противник* — планировщик знает, где стоят комплексы и что видят их РЛС: ведёт дроны и крылатые ракеты маршрутами в обход зон поражения, низко, за рельефом и через мёртвые зоны; «Герберы» прилетают на 15 с раньше, чтобы оттянуть ракеты; «Ланцеты» идут по Гепардам и Панцирям, Х-22/Циркон и часть баллистики — по дальнобойным - комплексам; всё остальное стартует со сдвигом так, чтобы прилететь одновременно. + комплексам; на каждую РЛС дальнего обнаружения идут 2–3 быстрых ударника (Х-22, Циркон, Искандер, Кинжал, затем Х-101, + Герань-3, Ланцет, Шахед; не больше четверти налёта); всё остальное стартует со сдвигом так, чтобы прилететь одновременно. После налёта приходит отчёт: сколько сбито и прорвалось, ракет ПВО потрачено, комплексов потеряно, урон объектам. @@ -72,7 +73,7 @@ CI: `.gitea/workflows/build.yml` на каждый пуш собирает то **Сохранение.** Комплексы, объекты, их повреждения и настройки сохраняются отдельно для каждого мира (и для каждого сервера); уничтоженный комплекс остаётся остовом и после перезахода, пока его не отремонтируют. -**Сеть ПВО.** Каждая единица делится целями со всеми в радиусе 1,5 км (линия связи): РЛС дальнего обнаружения +**Сеть ПВО.** Каждая единица делится целями со всеми в радиусе 2,25 км (линия связи): РЛС дальнего обнаружения даёт раннее предупреждение всем, пусковая без своей РЛС (P1-SUN) стреляет по чужим данным. Пушкам (Гепард, пушки Панциря) нужна своя цель: они стреляют только по тому, что видят сами. Уничтоженная РЛС — дыра в сети. @@ -149,12 +150,13 @@ API мода `render.set-lights` (до 512, плавно интерполиру Клавиши N, K, J, R и U можно переназначить в «Настройки → Моды». Команды: -- `/airdef wave <тип> [кол-во] [дальность]` — налёт на ближайшую цель. Типы: `shahed`, `geran3`, `lancet`, `gerbera`, `kh101`, `kalibr`, `kh22`, `zircon`, `iskander`, `kinzhal`, `oreshnik` и `mix` (комбинированный удар двумя секторами: «Герберы», шахеды, Герани-3, Х-101, «Калибры», Х-22, Циркон, «Искандер», «Кинжал», при 30+ — «Орешник»); +- `/airdef wave <тип> [кол-во] [дальность]` — налёт на ближайшую цель. Дальность старта — от 60 до 4000 блоков (в меню: 200–4000); землю для площадок, которой нет в загруженных чанках, игра подгружает сама (API 22, `render.load-chunks`, пока идёт поиск), а где подгрузить нельзя (сервер) или нет пригодного места, дроны всё равно стартуют: «за картой», с высоты полёта на заказанной дальности, без площадки. Типы: `shahed`, `geran3`, `lancet`, `gerbera`, `kh101`, `kalibr`, `kh22`, `zircon`, `iskander`, `kinzhal`, `oreshnik` и `mix` (комбинированный удар двумя секторами: «Герберы», шахеды, Герани-3, Х-101, «Калибры», Х-22, Циркон, «Искандер», «Кинжал», при 30+ — «Орешник»); - `/airdef replay [map|full|reel|stop]` — повтор последнего налёта; - `/airdef replay film [best|all|N]` — фильм режиссёра: для каждого перехвата/прилёта несколько планов (комплекс, пуск, погоня за ракетой, взгляд с цели, замедленный крупный план поражения, общий план после), склейки, плавные движения камеры, камера не уходит под землю; играется в максимальном качестве (HD, мир в полном разрешении, больше шагов облаков и тумана; настройки возвращаются после); - `/airdef replay export [имя] [best|all|N] [fps=60] [size=1920x1080] [crf=14] [sec=N] [noaudio]` — тот же фильм кадр за кадром с фиксированным шагом (скорость компьютера не важна) в MP4 с звуком: `/airdef-films/<имя>.mp4` (нужен `ffmpeg` в PATH или `HORIZON_FFMPEG`; без него — PNG-последовательность и готовая команда ffmpeg). Прогресс в чате, отмена — `/airdef replay stop`. Окно игры держите в фокусе (пауза останавливает съёмку); чат попадает в кадр. - `/airdef import` — перенести в текущий мир комплексы и цели из старой версии мода (до 0.2.x они были общими для всех миров); - `/airdef raid` — налёт, настроенный в меню; `/airdef target here` — отметить объект под ногами; `/airdef repair` — ремонт; +- `/airdef lastchance ` — «последний шанс»: комплекс стреляет и по классу вне ролей, если цель вот-вот попадёт (по умолчанию вкл; сохраняется); - `/airdef roles` — показать, по каким классам целей работает каждый комплекс; `/airdef roles <классы|default>` — задать (классы через запятую: `drone`, `jet`, `cruise`, `supersonic`, `ballistic` или `all`), например `/airdef roles s400 cruise,ballistic`; - `/airdef deploy ` — поставить комплекс в 8 блоках перед собой; - `/airdef hold on|off` — запретить или разрешить ПВО огонь, удобно для съёмки ударов; @@ -192,25 +194,34 @@ API мода `render.set-lights` (до 512, плавно интерполиру | ПВО | Вооружение | Дальность | |---|---|---| -| Patriot | 4 × PAC-3, пусковая разворачивается на цель | 10–300 | -| Гепард | 2 × 35 мм, башня и стволы со слежением и упреждением, 160 очередей | 90 (слежение с 130) | -| Панцирь-С1 | 12 × 57Э6 и 2 × 30 мм; модуль поворачивается, пакеты и стволы поднимаются | ракеты 25–150, пушки 60 | -| С-400 | 4 × 48Н6, вертикальный холодный старт, РЛС обзора на прицепе | 30–480 | -| С-500 | 4 × 77Н6, холодный старт, самые быстрые ракеты | 40–700 | -| IRIS-T SLM | 8 ракет, вертикальный пуск, быстрые и манёвренные | 15–400 | -| NASAMS | 6 × AMRAAM, наводимая пусковая на прицепе | 10–300 | -| Бук-М2 | 4 × 9М317 на направляющих, башня с РЛС подсвета | 20–450 | -| Тор-М2 | 8 × 9М338 вертикально из башни, быстрая реакция против дронов | 8–160 | -| P1-SUN | 8 дронов-перехватчиков против Шахедов и Герань; своей РЛС нет — стреляет по данным сети | 5–450 | +| Patriot | 4 × PAC-3, пусковая разворачивается на цель | 10–450 | +| Гепард | 2 × 35 мм, башня и стволы со слежением и упреждением, 160 очередей | 135 (слежение с 195) | +| Панцирь-С1 | 12 × 57Э6 и 2 × 30 мм; модуль поворачивается, пакеты и стволы поднимаются | ракеты 25–225, пушки 90 | +| С-400 | 4 × 48Н6, вертикальный холодный старт, РЛС обзора на прицепе | 30–720 | +| С-500 | 4 × 77Н6, холодный старт, самые быстрые ракеты | 40–1050 | +| IRIS-T SLM | 8 ракет, вертикальный пуск, быстрые и манёвренные | 15–600 | +| NASAMS | 6 × AMRAAM, наводимая пусковая на прицепе | 10–450 | +| Бук-М2 | 4 × 9М317 на направляющих, башня с РЛС подсвета | 20–675 | +| Тор-М2 | 8 × 9М338 вертикально из башни, быстрая реакция против дронов | 8–240 | +| P1-SUN | 8 дронов-перехватчиков против Шахедов и Герань; своей РЛС нет — стреляет по данным сети | 5–675 | Обслуживающая техника (тоже пультами или `/airdef deploy <ключ>`): **РЛС дальнего обнаружения** (`radar`, -видит на 1,7 км с высокой мачты, хуже прячутся за рельефом), **склад боеприпасов** (`depot`), **ТЗМ** (`tzm`), +видит на 2,5 км с высокой мачты, хуже прячутся за рельефом), **склад боеприпасов** (`depot`), **ТЗМ** (`tzm`), **станция РЭБ** (`ew`), **прожектор** (`light`). Новые комплексы: `irist`, `nasams`, `buk`, `tor`, `p1sun`. -**Согласование огня.** Комплексы делят цели между собой: на обычную цель идёт одна ракета, на баллистическую две, -пушки расходятся по разным целям. Первыми бьют по самым опасным (быстрым и тяжёлым) целям, при этом ложные +**Согласование огня.** Комплексы делят цели между собой: на обычную цель идёт одна ракета, на баллистическую две; чем ближе цель к своей точке попадания, +тем больше перехватчиков разрешено на неё сразу (за ~12 с до удара ещё один, за ~5 с — ещё один). Комплекс, у которого кончились ракеты +или идёт перезарядка, цель не держит; тот, кто не смог навестись за ~2 с, отпускает её другим. Пушки расходятся по разным целям. Первыми бьют по самым опасным (быстрым и тяжёлым) целям, при этом ложные «Герберы» выглядят для них как настоящие. Шанс поражения падает, если цель быстрее перехватчика. +**Последний шанс** (`/airdef lastchance on|off`, по умолчанию вкл). Комплекс стреляет и по классу целей вне своих ролей +(С-400, С-500 и Patriot по умолчанию не бьют дроны), если цель долетит до своей точки меньше чем за 10 с и на ней ещё нет +ничьего перехватчика. Комплексу с пустыми ролями (огонь запрещён) это не касается. `/airdef why` пишет «последний шанс». + +**Дальний пуск по РЛС.** С-400, С-500 и Patriot могут стрелять дальше своей дальности (до 1,6×) по цели, которую держит +свежим треком РЛС дальнего обнаружения в радиусе связи: не чаще раза в 15 с, при не менее чем половине боезапаса, только по +своим ролям и только пока в обычной дальности нет цели; шанс поражения на краю вдвое ниже. `/airdef why`: «дальний пуск по РЛС». + **Роли по умолчанию** (в сводке в квадратных скобках: Д — дроны, Г — реактивные, К — крылатые, С — сверхзвуковые, Б — баллистика): Гепард ДГК, Панцирь ДГКС, Patriot ГКСБ, С-400 ГКСБ, С-500 СБ. Сбитый дрон падает с остатком скорости, и его боевая часть может сдетонировать на земле. diff --git a/airdef-mod/generated-manifest.txt b/airdef-mod/generated-manifest.txt new file mode 100644 index 0000000..c89f95e --- /dev/null +++ b/airdef-mod/generated-manifest.txt @@ -0,0 +1,572 @@ +61d8c0b90973a3f1398d4db914b1fc9c39ff5ac4316085d7cc05d070ea62d15f assets/airdef/blockstates/amraam_missile.json +7f8fe0d11f6c8929e48c136b2f34d1c653f060511957a32fe4988377fc61d62c assets/airdef/blockstates/barge.json +dc5d543e28c50474df938163495a70fb81becd0814dc59d516493adb7c087110 assets/airdef/blockstates/beam.json +b2cc12fe02e4ccba502f47085046defe6a51e22be3fd739c4678677ed977c312 assets/airdef/blockstates/buk_chassis.json +8e2fe3bce22d718a9384a3805034691ee0ea068aad81d10aededa20da20afb9a assets/airdef/blockstates/buk_chassis_burnt.json +035b3898bfee5c0dfeed946e64e9365ad111c6e017ce0222e6455ee860f8d8cf assets/airdef/blockstates/buk_rails.json +b8ff32da42179af8d3762b5bddd3f33df7172fbef5352fbf51ddd1a8d5899c21 assets/airdef/blockstates/buk_rails_burnt.json +a4b1563f2c4578014b48778177d1e7032cc3f23afa99a715d4c9b08a67397b9e assets/airdef/blockstates/buk_turret.json +413edabb0c8db5b5f39f10f91ace49ee658036a00ba53db7d4ffc20615f70814 assets/airdef/blockstates/buk_turret_burnt.json +3b3145ddb3dad08986aeebacee5a389aea74e6ae7f2c778da52201fd8f24f2f4 assets/airdef/blockstates/debris.json +254aaebdf2c9c63d0c3bd95093368ebd6466cbb768d31ede32f07fc4fef5e808 assets/airdef/blockstates/depot.json +4062e069929859ea7fb18487b42953ac43cb9bd6e90d349e3ae49afbd29426c6 assets/airdef/blockstates/depot_burnt.json +d02848bd8df8af516fd12c3412636457e4f62577e221082a8627f041b714609c assets/airdef/blockstates/ew_truck.json +6921ae900410d3415aa5357d008c9ef122891082f1f71d11dfda01b145e2127e assets/airdef/blockstates/ew_truck_burnt.json +3cd4de622d0c8e43caf42f22547df388dbb78ff55ac868d9cfad37a23d37800f assets/airdef/blockstates/fireball.json +7355433bd94843c5c0d7848f1a5afb810ef08221b3f0389c0061e50970b5d92f assets/airdef/blockstates/flame.json +840655b48624534badc6367d88cc1472d748c989ff8c7082ecacaf79f54cc2e5 assets/airdef/blockstates/flare.json +0654962121ca92b60cf95beced4b062fb377cb2776ad1ff08f44d92624d59a08 assets/airdef/blockstates/flash.json +2453542113245c015b7c54971fd57184b80beb970eaefb420e7adab3618da005 assets/airdef/blockstates/gepard_dish.json +0a3fe2f20da19c69744f304cf769e3ce3ef236a6d251afee380477e2b5276292 assets/airdef/blockstates/gepard_guns.json +4645e4959fa2b126dc913e710dd15babbb30c139f8450d86cdb3676ec590ea0f assets/airdef/blockstates/gepard_guns_burnt.json +477ce5eb4b76da642c6b758098715c20d0fbb7c114177fceb705f3bbe97b5c59 assets/airdef/blockstates/gepard_hull.json +7b18a4ba743d64e554529efa25c881ab3ea7a500aa3390fca4a98c5691724380 assets/airdef/blockstates/gepard_hull_burnt.json +ca3e0e913af91bfffc6d9910a557b3acb1e01cfa497e3452ad37e935be491697 assets/airdef/blockstates/gepard_turret.json +80c3bfd8b7fc2252110ccd54b3ae8118ddf3100d7b983ce326aa6b1fcc6e6d97 assets/airdef/blockstates/gepard_turret_burnt.json +41108d18eda14c14e6259e3c68d3894d86aee8ac10cccec33fe271e0345e9202 assets/airdef/blockstates/geran3.json +4f0737d7739bee31106845b4062dc65336ae49bde0f310a3bbccc74c17f31ad5 assets/airdef/blockstates/geran3_flame.json +b6fe8c5937cf30957d0ad01f7313146760beb3958d8f25a8586021b5a8ed3c8f assets/airdef/blockstates/gerbera.json +2c270b14b4130c4831eda0ea9acc378e4f720e42f4adab5babea25662d7500ef assets/airdef/blockstates/irist_chassis.json +22f2e998245f414f789ca3929d43d73d66b03bf95334ad41c2c5ce768e6b23b4 assets/airdef/blockstates/irist_chassis_burnt.json +16a21bd9b4051a1087b64e9c606541d098ee3074e492aedb6142e6f16eac4c58 assets/airdef/blockstates/irist_missile.json +e6e1cb2e1aa92837a187016cc1d6238f962fb1a256c168395aeb15fc2dbb8efc assets/airdef/blockstates/irist_tubes.json +204d6cb12a261672f190d8b7958837fe9eb5efd960b891accb68fc61b46a110b assets/airdef/blockstates/irist_tubes_burnt.json +1d1fdd506878e5ad8fdbb64bb1608179c4dc247f7369b8e0ffd85835d05a8685 assets/airdef/blockstates/iskander.json +c62e1d8ffaeaf14e3b57ecf1e161cec6bcb13a9aee36dca3e88442584f1dd155 assets/airdef/blockstates/kalibr.json +c09ae784b9bc87e969a845975294f0d4880b9a90692f57c79e2de04c5a650146 assets/airdef/blockstates/kh101.json +0d30bb49f1227ac4c78a9f05babfcf6856b17d8ba059e00669a4d27a558ff519 assets/airdef/blockstates/kh22.json +f37a2c415049fe1579bc71cb8caa14b05c6e96c17e6a2f9760bc83a97b93c4a4 assets/airdef/blockstates/kinzhal.json +88b47dffffe1d95cf2c5981ef67837e597f6be06d417177d4967451bb55b3b8c assets/airdef/blockstates/lancet.json +83f736f00a673c90c0c1dcf726dd8a7350ab1d1efa37dcd85eacd8dfe94383f8 assets/airdef/blockstates/launch_ramp.json +de0bc51183c7a8379dddab26952f063a4a4a4b51b8dd862bc061989ec89681d7 assets/airdef/blockstates/launcher_truck.json +5352e83f412ea524aba1519367f0f9a921958a5d4caefe17d509a5b7e04a1cbe assets/airdef/blockstates/m48n6.json +64bc9d9193ca5f20a2bbb189bf6216211c167e051cc63c297a72655d711cfe67 assets/airdef/blockstates/m57e6.json +dc55ec1579702d9d48c01e5268dfae7097d0ece87d85b1a469222134f962f4b1 assets/airdef/blockstates/m77n6.json +2331d3c3c614e38d39d67d8d85c4b16030ed7ecab37bfaa9da938379534f57cd assets/airdef/blockstates/nasams_launcher.json +f05bb84135b39bc469fadbc4f144e0f69d203b1887396a0b20adac59e549c1ed assets/airdef/blockstates/nasams_launcher_burnt.json +9fa63fdb0fe84521f00326dad6955af6adb3dea1093f42404e6e07770cd9bcf5 assets/airdef/blockstates/nasams_trailer.json +640b83fc5b43f6fce1d13106016e9b14c0150603035a6d9b19ad6f0598511959 assets/airdef/blockstates/nasams_trailer_burnt.json +446bd55fb9bacd7828c6a7028595de64e26cc5cbe3ef4280713c3d9f1cdf850e assets/airdef/blockstates/p1sun.json +9e2e00e75afb10c38684b1994688ea11bcc2faace46ff551967e1e05ae08ec85 assets/airdef/blockstates/p1sun_launcher.json +02f9474f5f459e33096931887b71b515d957b7a1be14ccb81a0bba36af551ec9 assets/airdef/blockstates/p1sun_launcher_burnt.json +da5130347d961fe0ca0df11d31b250dc1fa268cacfeed184662fe22901b6ebd7 assets/airdef/blockstates/pac3.json +f5674075063a8dbd897394de194b980a426e08faf7803ec10b8117070a579a78 assets/airdef/blockstates/pantsir_dish.json +365f6ca767ad9dd9cc724ea859f41ea6879f0d0ba88d6aaaab2ae7d5fe34acd8 assets/airdef/blockstates/pantsir_pack.json +53af2cf24abb2df609f02d41e711ba49e38be4b6a9a0a6e1d8cf395dde3b58a5 assets/airdef/blockstates/pantsir_pack_burnt.json +6a93f7b1eee7b8ff02567c38c05196f8326d66b319cc5d69c942902986bed946 assets/airdef/blockstates/pantsir_truck.json +81804b614abfd0776e1513c9f759d1cc22952346cbbc980b006fd367b68792a2 assets/airdef/blockstates/pantsir_truck_burnt.json +c274aaf1c5e81683c540c784bf0eb7de15ac8522a5e4d7397cd7b181367c7e83 assets/airdef/blockstates/pantsir_turret.json +05c3dd4f66c8cd7332654e19aeb60115f00e246c8f57f4eef2c300f1ddb97a2a assets/airdef/blockstates/pantsir_turret_burnt.json +c96640ff19099af66c2da0a9a7561b847d6355ab2de8bbfbe03f2daf38b89ff3 assets/airdef/blockstates/patriot_launcher.json +c3b0b6f1d9b7da434c95f2794b97b9e2c05c62ccbce9f0884df071e4ec28a468 assets/airdef/blockstates/patriot_launcher_burnt.json +83058657f466b769c9140f5d6c4a5079f9567f9cb13ece7a453da191aee5b89d assets/airdef/blockstates/patriot_radar.json +3ecae997dc4fe1939848fc60347979c02075396a164b31dec3995a2e907a8447 assets/airdef/blockstates/patriot_trailer.json +31fecc6476129990a7d368f6d7cdb954b13e777b28d1396a68168770024112ce assets/airdef/blockstates/patriot_trailer_burnt.json +183d0b2ae9c24c94c3d83f2819e1167b5e2d134cc7889e0e3bda12aa784c7baa assets/airdef/blockstates/patriot_truck.json +37714e19e555cc50a9f195e1a8845a28c9985c27ed38b577c0813f4629b66fcb assets/airdef/blockstates/radar_panel.json +95fe14b9d3e30597f5505aa93f31c3357cebea6117596bd9f5a5bc840ab22415 assets/airdef/blockstates/radar_trailer.json +a95a1b8731d6bedda7d8b352ff3c448ea8be691b0ead01f14acab39234590c45 assets/airdef/blockstates/radar_truck.json +332e6f39c40063ca690064e13a1a256ef215fc2bb96985b842d9dda58abee2eb assets/airdef/blockstates/radar_truck_burnt.json +67b93302bea867248b6fa0109a4b5af2065e5c857d4a5c7e088bc9849fe624de assets/airdef/blockstates/reentry.json +7f3e952f5de83880549012945827f8469883355b0a3dd966e7f03c44aee532ab assets/airdef/blockstates/s400_chassis.json +411d0a0ac102b21016bf48d30482e073a7e8054c2775d8fd5a7c91364a2cb1f7 assets/airdef/blockstates/s400_chassis_burnt.json +3ffc2edef1362f318d6c087a20cd730feda6f35f7381942cfc8012f06f4dc174 assets/airdef/blockstates/s400_tubes.json +e570e68b27a7554e3aa8b63b30108a2f4e2c3bec6e91cd3cff7509e3d7eb3400 assets/airdef/blockstates/s400_tubes_burnt.json +eb8061c3cb198600ad8f587738e8a676d9ef114ed171764bc905f4730eb3a0ba assets/airdef/blockstates/s500_chassis.json +f0cf8bafdf7053ae757e5b0baee92accb984a24bfbc18d65e1e824384e09180c assets/airdef/blockstates/s500_chassis_burnt.json +2b7bd3099ec300184a185e2beb48dd748004af869696f4a26793032bf9675439 assets/airdef/blockstates/s500_tubes.json +3d6fc384684a24218888e871ba0efc72d15ce29de84cf45e3adffc5f7f98a45c assets/airdef/blockstates/s500_tubes_burnt.json +3af7b3f01780265cbff35e20aa149691bceb8de10a6991129b32046c753c6f8b assets/airdef/blockstates/searchlight.json +8e530da837dba9ec426651efa136c3389c4f9f9e8baf453b9055b4d260b5900f assets/airdef/blockstates/searchlight_burnt.json +48b14bddcb967c4a4933a827568c0b66d9e6c9e2d3a62c63b6ca55ddab8764dd assets/airdef/blockstates/searchlight_head.json +bc57f94b431786c60a6b204ea54126c9d2b363427673c39a45ce0a53336ee7c8 assets/airdef/blockstates/searchlight_head_burnt.json +9c25df8f750763b5e3011557a6aa59289e9a41efa1811c18c30522747473dde6 assets/airdef/blockstates/shahed.json +ab6d7a96cda417cf70fa31aca6f6c7bcdaaf4f8d413b079386f97ecef6d33709 assets/airdef/blockstates/shahed_prop.json +137dc0f2bc5723534dc2e4f44f1706d589c0dd85318d17c207f2f6772d98d989 assets/airdef/blockstates/target.json +e766e18158d00f0480f6dfe32dfe142890b8d452bc493b7474fda0984cc2dd4c assets/airdef/blockstates/tor_chassis.json +ff125150e206ada5a7fb47394e809039d338c22a843207a32352e9ee59e278a3 assets/airdef/blockstates/tor_chassis_burnt.json +04a5fb931f419086995791c1defd9486b36b3c725581bf5a10191a2c71c69984 assets/airdef/blockstates/tor_turret.json +fdcbe36a0f4968005e8c6588f2fa5d8196c55da3183cf89760ea221a5c4cb431 assets/airdef/blockstates/tor_turret_burnt.json +cf7c26abeb1d8f7d84106bd83b84bf541a3d11369b7745a77802b7e51dcdf447 assets/airdef/blockstates/tracer.json +8b547ccbc890fa53b613cdb2cf55ceaa5833348479b0863651c6df01a9f778b3 assets/airdef/blockstates/tzm_truck.json +11fca31a88e658c819954fbb86b53d7709b66b971af8747a5edda24be34e5dbb assets/airdef/blockstates/tzm_truck_burnt.json +4a74928eae32adeaf6e0e5d964ccbda97be0a2208961772848f109c59a9c6c1f assets/airdef/blockstates/zircon.json +48aeeb65e6be762ef975540826b2b3303a2df5378c3181e5e99826206df370e4 assets/airdef/items/cam_geran3.json +e6d0a3b45670cda451ebe4689323b269de22afb7d99b341510a711a22db75c85 assets/airdef/items/cam_gerbera.json +e22e53fbfadaa018ec8091213406b57eee49b39341e029daf96d7915f500e50a assets/airdef/items/cam_iskander.json +a3174ebb0b1720b803316c3bdce3abf09146d2c1a2b78bb6c091aa4a363716ec assets/airdef/items/cam_kalibr.json +f357a1510ead55131a7f93bfe43d46c3e0c229deb25cfe82bfecfa84caba543e assets/airdef/items/cam_kh101.json +65625082b0ca6b0835b9a7bb04657b83b76103a25f263d85254835e0d29f6725 assets/airdef/items/cam_kh22.json +16fe2fa4cfa528878261fde9c3a9923989608ffc2ebbdc770cdda2b9098b0287 assets/airdef/items/cam_kinzhal.json +f4d13fcb599d8fed03ab4fa5a95900df6a0c766d29b3095d32fe9dc2f06d3554 assets/airdef/items/cam_lancet.json +ed5405e2cd20da02053d8b614a630ef5c32f476c08dabccbbeaaf0b261823956 assets/airdef/items/cam_reentry.json +7654d42382b9f072ea8a0f348c30e78c147921f5bf95723fe08bfe23b1b28c1a assets/airdef/items/cam_shahed.json +adb65d10541f62f8e0a4c0b9da63a9e7bf4581f44672d2542d11bb16f89e75fc assets/airdef/items/cam_zircon.json +0c5fd99e7447602834eae3296026031fc7419ba6126b507132503925b01af1e4 assets/airdef/items/deploy_buk.json +b5c9bbceb576dcf3e4ddc3d01f6e3743d67f087ab98a24966c506585aaa67b2b assets/airdef/items/deploy_depot.json +b50fe5804cbbe10df992ebbc7e095a32ce8b73fc35159fa19889640681e408e9 assets/airdef/items/deploy_ew.json +728e5e8bfb4c351f11db6be872834e4a07a9b04fb6be63d9bb1ad42495bb57d3 assets/airdef/items/deploy_gepard.json +0c9c5ee3fc87e2dc6d716bdb0bb5f30d3b265268bfd5083462b4861a8da65f0f assets/airdef/items/deploy_irist.json +09e46a6c4b59f66c24060ac627e6a287bf6797c7b1e32a9ec6c9d9662fdc3de3 assets/airdef/items/deploy_light.json +6983561b3558aee16662aa83d2d9af760fb6236cc7dbe79ba20802f8a2ff15b9 assets/airdef/items/deploy_nasams.json +d8ac6a9258e2fc2e0791bbce18a3a07a72e471fbc72b6be7b5ca545c53f8d633 assets/airdef/items/deploy_p1sun.json +e2353c70f6e5053864ab2fdf51df886d72c03e5c34db84fef8f3fff9c84a7ae4 assets/airdef/items/deploy_pantsir.json +68dd9b1382b2dd6ad23d0f507a8cfcdcfa18ef5c4203e69c61f6ade9cfbf238e assets/airdef/items/deploy_patriot.json +d93b6bf77de4aed1f1955e76353661729f06445788ad7a6654ed6af1a67fc48d assets/airdef/items/deploy_radar.json +0b9445f3b54a9e958da0b9bd4243a41d85fc8f12b5c4e56bbc070edb8f1bf618 assets/airdef/items/deploy_s400.json +dd7478f447d87dcad42e1a7e6dcb5fae555c832e933a3722022d0e10e2de011e assets/airdef/items/deploy_s500.json +14e9a20dcddee5a6262ef624783900576dac2bd16e5079e731cf6115f13c76ec assets/airdef/items/deploy_tor.json +d4ff57f630ca92bf52ba228d8c6fd5292928b685d6bb5a9c9b9dc0c3d586fc2e assets/airdef/items/deploy_tzm.json +044fc626ad99ff8be6db0800f96dc743fc80606d813bd59abad531396bf9d8ce assets/airdef/items/designator.json +5a07f4adb780e52b920d1c8ca5296e43e84f44897b0f6afe258a8c2c7712f83c assets/airdef/items/flare_gun.json +25fc0a8ac7b5e01c6de19c86584e5bfc2dd9cbfa8e7ffb0468db82cb89f5585a assets/airdef/items/launch_geran3.json +5dd3ca665c44c43bef0c4c25361b9a4d45d0b2a7b01d2c8e66cefbf62ee38180 assets/airdef/items/launch_iskander.json +106695745868cf5ed80534ca35d81944a9fc40a74d30d4d4fdf43d716dc5b28b assets/airdef/items/launch_kalibr.json +5c45ad03265b1331ee73b8085cfc33a8538339c9fd1c9916c3f5471bb2ed0bfb assets/airdef/items/launch_kh101.json +ed9356cb86466025eab00d4283a72adfd1cf6aa9549f051c5e6088c72078263c assets/airdef/items/launch_kh22.json +8af907c3597963f6332c2cd677d5236e41dc9584d816fdbbd820ddb99217fdf1 assets/airdef/items/launch_kinzhal.json +54045e7d6cb53e92700accb04f6caf68e229d71cba6038669c425e9c109b904d assets/airdef/items/launch_lancet.json +4a007ad0f768a53c605236476667178e981e47980baf04a973fefde2bd4b20c3 assets/airdef/items/launch_oreshnik.json +b4d98a938486d49db28c6c7a608ca3b6ab0c9c3eac266ecf12c7dd931cc28f93 assets/airdef/items/launch_shahed.json +489799dcb5b441e9d4be8bfb3d73a6ebd5fdf2b5ee614b62f38b85bb2fbfad75 assets/airdef/items/launch_zircon.json +6b6da5887aa8d9bc987b2c181c243bc2afdd5846a869818f699b59204e559929 assets/airdef/items/mover.json +177f7535e4c52ed975d6f737c2b2acb7741b7a928955c8899aced85211443bbf assets/airdef/items/remover.json +9c54b89d2840841494bdfb2161c038385b23e41a42a87a24e95d485a1a541a82 assets/airdef/lang/en_us.json +9c54b89d2840841494bdfb2161c038385b23e41a42a87a24e95d485a1a541a82 assets/airdef/lang/ru_ru.json +b9b5b5bec33a77e944552c225ee2f526ab9820fa8fca4b3ccf311f2a325925ee assets/airdef/models/block/amraam_missile.json +c54e6b24ab69e9d9a2dbbbc63fe2ac60fa37735bf644500ddd5c3de0b91908cf assets/airdef/models/block/barge.json +a4198ff647de5866919f9b82a3d53e6486fc56cc9f175f7907951d063a6f4526 assets/airdef/models/block/beam.json +050bc585d82f48b39fee175ba795fafb642fc2658c932901549d8907e5d63401 assets/airdef/models/block/buk_chassis.json +384ef97ea938470c82e68ab9659b9cb67cb69494fe3b9eb14357107bc6ad416b assets/airdef/models/block/buk_chassis_burnt.json +e2b031f4edbfd8395ff04311bca5bb3c4a2fd5cd7022d1070c8c7f67a6aa7177 assets/airdef/models/block/buk_rails.json +a76b58dd0c282353b9101beb48774d5619369303e0665fc821458a47cd3d0402 assets/airdef/models/block/buk_rails_burnt.json +bdbb639071f3ff18fd624f3179aeb5dcd1216828b599e976617c13e5c77d115b assets/airdef/models/block/buk_turret.json +7f1248fe849972d51a37c4162d92991b5567487b5415e2e9ea2cf43644bb500b assets/airdef/models/block/buk_turret_burnt.json +684edfac71d15f328b92aee2f2cc508532f6654d5a664ac78c13b6868c3307ba assets/airdef/models/block/debris.json +aef93931af7ed7bf71eca89070adaf52af9e0d8894d3d4e36c926b219e196e02 assets/airdef/models/block/depot.json +107aec61d1dcbbfc6ec9203cf8590c23cde3cb22e8debc121b6ea30d675e6401 assets/airdef/models/block/depot_burnt.json +89ea90f53a06814d71cec4d8e89a43d7dbbc4536a8104d099b743332cabafedd assets/airdef/models/block/ew_truck.json +b7cd8f04aaf8a6c5b7021a59dc7f6c60e29b1159237ba666d7eeefb91254f348 assets/airdef/models/block/ew_truck_burnt.json +c00df220e46a265c5a33e68df1cf407dc1fd25bb70978b6b8d850d7826044f3c assets/airdef/models/block/fireball.json +fd5ca8c85d054580e6f95cb88bfed6216c9b41ddab9efec2eb4a642ad408c17c assets/airdef/models/block/flame.json +3962ad6a9bfd88ed4638dd72d27ddbd0933583e7f0d40b808ddabfd6e3a35399 assets/airdef/models/block/flare.json +6bf4a388b9a38a5359053be38cf09f1c86d0ea47d61e5de0016b2b1c7f7dbb4a assets/airdef/models/block/flash.json +9fa54aaabfc0dd1b6af4fdd1fd4ac9a4248c4837801a7d74e441627f565a18fa assets/airdef/models/block/gepard_dish.json +993b426346250e248abea70a461a7e9343d264585997adee252d8f803d635acd assets/airdef/models/block/gepard_guns.json +9f5b572110c02fbae42a1aff8db716094d198b209d19dfc2e6a72430378fc179 assets/airdef/models/block/gepard_guns_burnt.json +288029fc93a84ee5d68605d061bde9fdb31b759a4a17766bb8df008beb9c2064 assets/airdef/models/block/gepard_hull.json +743dba3635ae9683e744c1691315a104e48e10011cfbe6e74565eecd6ce7dfcb assets/airdef/models/block/gepard_hull_burnt.json +38ae7a16ba13e5ac0cff7f8f1856e24b512f4e68bff7b71297b3d2838333fc2a assets/airdef/models/block/gepard_turret.json +cf464948fd36c74cf6f49b10f4f2e0e4b9e52b9d8324c27302e8e1c2ae8ae73f assets/airdef/models/block/gepard_turret_burnt.json +d43f9cda780b1821a6e778555a1be5bd112a1d9655ff96d35acebec61cf11e0b assets/airdef/models/block/geran3.json +83cf0a42e59f6aee1039925f849398960334f2545558409e7ccd7173ac841d73 assets/airdef/models/block/geran3_flame.json +ac93e872743844465f0688162dddd7433352e8710612dd391a1e92b7f36e6b0d assets/airdef/models/block/gerbera.json +8c273c2e02dd1884012f5597a6ad2022a0cfcf56c9c5b23043729d3c34635783 assets/airdef/models/block/irist_chassis.json +417c68e386d715da987c2d2e9fe86d25dcf0d923546b602d5f01c1eba1d25543 assets/airdef/models/block/irist_chassis_burnt.json +db7b914b2d027b8ebde485b940951244e4bc4cb614eb2eefc49ccf0b26c98bdd assets/airdef/models/block/irist_missile.json +89453602195bc437ecb70885ad4475bc04ee300eff377682321bc3cd1113e9d7 assets/airdef/models/block/irist_tubes.json +c8cb7d80202b72070e955015d4abeffb33586c9fa94a3ca29311ae68cfd7ca9a assets/airdef/models/block/irist_tubes_burnt.json +be87037b84cd45b2c53d7fde6653f51769e782eea1b8586383afcfababf19ab6 assets/airdef/models/block/iskander.json +98d488ca797fb3d0193957b8a93a82f48376a89f8e091032e6365d1845016a90 assets/airdef/models/block/kalibr.json +be9eb1389e169a76732f16e604499260b147b524b4c929d2a4e77b580a032fb7 assets/airdef/models/block/kh101.json +ea7c9af888f1285502b2ca51114f226573311be6fb90b8d449b9f75cfbb6b885 assets/airdef/models/block/kh22.json +7b73015e33d85d4cef31660ef874ba8dcd6e07ae3a95416d0ead1d325bd0cb6c assets/airdef/models/block/kinzhal.json +911e55de28058e1723a61d15de80676789e57ab15bfc09fd75757cc85095fbcb assets/airdef/models/block/lancet.json +15c9884ce51311cf4b9b9d106e142e33d72c5ba18dc9236c5b51da01232f8e44 assets/airdef/models/block/launch_ramp.json +159fa5e3b6a096a1872dec2a550bfa30575d57519654a860607e20dab8f35f53 assets/airdef/models/block/launcher_truck.json +2069198ba24a932816f504fb52f53dc4bc740cacaf45637698a17d9175da851c assets/airdef/models/block/m48n6.json +61968f4d63adb6dfebd5770ce5225f4cf5610a7d7ef863c4665875e82abb4f25 assets/airdef/models/block/m57e6.json +238ed59a5dfa261272c0a4dabbda3ee62d2c1c7b9829cced644bf8b2322d4bfc assets/airdef/models/block/m77n6.json +e8786fdbf6feb21f4819015c7faaf87983f4a7ed88c6f7917bd49d1b14eb9140 assets/airdef/models/block/nasams_launcher.json +574e069129ff20e6137b469740cb279e16e70beeafd1e933c22cd7dca583d74d assets/airdef/models/block/nasams_launcher_burnt.json +56a0a41e54b5056689525909179651dd5ec0fe551c06bd2bd2b1edb63c369aec assets/airdef/models/block/nasams_trailer.json +79b26bc489f644afbff7a9f616481b81034fb13a0590e88ca1753601eca4f3ea assets/airdef/models/block/nasams_trailer_burnt.json +ce9f0dc61bd07cfcb3be5cadf12df0d2ef1b3167aa2260bc4b42414dc3825b58 assets/airdef/models/block/p1sun.json +b5346ab109ffb8c259592574b091a47bd396de34b0fe96e684b672316cee6b86 assets/airdef/models/block/p1sun_launcher.json +4457a527977ad5c7d381991ca1c9d304cac0b7074f632a04328e836bfd83f784 assets/airdef/models/block/p1sun_launcher_burnt.json +2e11e64942baaf057ccc60f42dc629f7519eb3892198c1906c22fcfa95584e7b assets/airdef/models/block/pac3.json +ba90d89595c3c5d6d5a30f1041540f50504676bc64df5cf34038f8682307bd3e assets/airdef/models/block/pantsir_dish.json +622c6f537f6adb99b1a536e38eb0a5d30c05c62f671997365ff0b2e9fa9e6562 assets/airdef/models/block/pantsir_pack.json +5413c0a546ee6b82dcf3e782d6cdab4e7080642b71bbb08a4b800a9105f39216 assets/airdef/models/block/pantsir_pack_burnt.json +52aacf44022a6826bd88fb161bce81966033de89f67b9d505c1b649bcf294eb0 assets/airdef/models/block/pantsir_truck.json +53e0a5c2bec25e3a52eb52bb098c9aa186f737fff7d6c5b2ccf927c313416e1e assets/airdef/models/block/pantsir_truck_burnt.json +84857ebb50b8e98796e3907d8ce4eeb8427c29de9f81aec7a8bd8d9ba4ca516e assets/airdef/models/block/pantsir_turret.json +fc3555a87f290805ad723607f034d90645799cd8d9ef3ba42fedee2a50248059 assets/airdef/models/block/pantsir_turret_burnt.json +33884253bc3ab463d15a7fe952187b1f9fb3b78632e825e1fabc85ca5335b543 assets/airdef/models/block/patriot_launcher.json +3fed073655020b72dd515dca7eb362f642af50251b473012429a5ca946426f8d assets/airdef/models/block/patriot_launcher_burnt.json +2a8ca10c933e236270810ce9de40c83dfd8016b0f0d32413433e3a5f8622329f assets/airdef/models/block/patriot_radar.json +eb9a8af98374220657fa722a281bc18f6d884d51c5869975d43eeabcb171677b assets/airdef/models/block/patriot_trailer.json +428fbe50bea9dbca55bc4933b15ccecf57164dc915e661ec9ae610151810a50a assets/airdef/models/block/patriot_trailer_burnt.json +4c3dcfe38527de677cc1d56f6fdf17980604b93ce24f4337b3f825a2f446620b assets/airdef/models/block/patriot_truck.json +e5733ece9b976998c9c6a6c968b17cada868b62d9e2a7bc67e1272e333e35399 assets/airdef/models/block/radar_panel.json +b0ae344fbad3b982e7821cd1b26f9922e2a5713b38107521ddc5a5b2abd9e39c assets/airdef/models/block/radar_trailer.json +088f42bb17f4d6099c16adf6cc0426b6d04ff9a43abdf125476a3571e924fcbd assets/airdef/models/block/radar_truck.json +4cd60dfb1e598efbd127894b0ad1b1737569dfc0fc5c9b193034f9125daa9c15 assets/airdef/models/block/radar_truck_burnt.json +f102765390762d45954b26405b6acdde845df7f0a319bac53d25508e768957b5 assets/airdef/models/block/reentry.json +0a27047ed66a18b513c5113e4b4ba1d8667ac75d36f661e58415a9d6870c0bc9 assets/airdef/models/block/s400_chassis.json +b86ccc0a31dc63239b598542070173ebbc6d354b3d8dec880c62ea192d6ea9f7 assets/airdef/models/block/s400_chassis_burnt.json +0107a6689048c7267ea1c68713593026fa495cb33443a6b1aab815a17602fe81 assets/airdef/models/block/s400_tubes.json +6e4a35843d29bba30b9969af7ed9a60ad05ea4e4b9fde0507ea6ba6dcb9d7dd7 assets/airdef/models/block/s400_tubes_burnt.json +088f42bb17f4d6099c16adf6cc0426b6d04ff9a43abdf125476a3571e924fcbd assets/airdef/models/block/s500_chassis.json +3e35ed19ea22c8309ac7c0ccc9c12e31b2387de97c6b72ba519bae34f681c58c assets/airdef/models/block/s500_chassis_burnt.json +6ce00881e2a5107874e82e94a243fcd95c3eb6e9a24e8839c11227c8e330485f assets/airdef/models/block/s500_tubes.json +88109407ec749d6f45a4a38a9a43fd1594ac6190d7f08433b07da572c040458d assets/airdef/models/block/s500_tubes_burnt.json +536928e90a907c50b078a488bad49061f79aafe12479f58a2b2ce79aaad5adda assets/airdef/models/block/searchlight.json +0e3cbae5029377886325aedc43705d9975cb1dc889a37613b92a97bdf36b4db7 assets/airdef/models/block/searchlight_burnt.json +2d790e8845598e8cf7ae577492632ef52e075d590011e66fa8ab686096cda313 assets/airdef/models/block/searchlight_head.json +f0f9ec6cefc0750e4d6cb330c21df8c2bcf02b1b7408f90f40ebb35a572a13cb assets/airdef/models/block/searchlight_head_burnt.json +b2e28d73f50b238f6bc7d6dae560695e70ae8b843f6853f9e468bf8d2b371e6c assets/airdef/models/block/shahed.json +d5b9ed76b1744d0c93a809b6a4c16bc3dfa7ff4784e3cb9968aa36b12fc30484 assets/airdef/models/block/shahed_prop.json +465e6aa2a8ac3c563b1e546fc844a0520fc89f3ce528d49f193aa6c21e19308f assets/airdef/models/block/target.json +69c5c103c228abe6162f2e1252139689b768ff8dd3f835f8f79ce7d324c3f124 assets/airdef/models/block/tor_chassis.json +81e66314bfcd4786950ab4c23c6ede79ff9b30687b44f23e2b7b9be233fe380f assets/airdef/models/block/tor_chassis_burnt.json +b2d2f856a4004853cb799785c0a586d605c623759d5a099dc70e19ba6a67b100 assets/airdef/models/block/tor_turret.json +e95be4a89de1384038df47861705b934b0091f61f7b135c9ea6933fe2197f31f assets/airdef/models/block/tor_turret_burnt.json +b2380510f56dc60e914919cb6e20b82e3613a9d62b08a1c81740f960569c3425 assets/airdef/models/block/tracer.json +447d237b290255f01c7ce51047df3de1b9261cb75cfeb83ab190f6abe914747d assets/airdef/models/block/tzm_truck.json +1954ece9e339d89a6ce63b78d255637011b3661572fdfe051ea4814a891746ce assets/airdef/models/block/tzm_truck_burnt.json +b85d09a73a1c251bbc76945aa8f99b5e7e606ed41e1d120f811a435f441b1e96 assets/airdef/models/block/zircon.json +8360c341dbe0aac6c485bf0d2c9ace49378173f1749f5b52793a79b98697685b assets/airdef/models/item/cam_geran3.json +5b35d42715fa9c4afe8d0bccf5060380a8b5216edc9509ba0d859e8e953232d7 assets/airdef/models/item/cam_gerbera.json +f06af8a128e76975bdf3178e7db1453d01a888f06b2b5e9f997fbc43e0b1266a assets/airdef/models/item/cam_iskander.json +300ef4f62f89aff9b59567da8ea32749abec2966c77041846835953b2dca0d0e assets/airdef/models/item/cam_kalibr.json +8e7b91623d1d3e72dab4e701b506eb1de6c87ab774d3f6e6ed9ab76a4d82270b assets/airdef/models/item/cam_kh101.json +5ea688448ece076431da2d4539806211825c2a63b816d00416f946351b92f509 assets/airdef/models/item/cam_kh22.json +c6977e576991ba245796d82caa87a0ae87105bae7067ae2bf33d933779bfcaa5 assets/airdef/models/item/cam_kinzhal.json +56400ae5307d0f02273ebf919f12f7263e5fe410a3e1833b83a5f19d32ddb9d5 assets/airdef/models/item/cam_lancet.json +ee660fe4736f801646f944d54144f72a777048c269d828d1b71239c8732b0730 assets/airdef/models/item/cam_reentry.json +99637f9ce3aa4ba3517b682890bbf7e35835145df666a9f74f53302684ecd1ba assets/airdef/models/item/cam_shahed.json +182acfb9fb1d8f42741378a0bf17484c89a757118ffd7bd3bdd9bda2941906b9 assets/airdef/models/item/cam_zircon.json +c6cba2497f834e0a0e0dbc7604afeb1f1106e0c64e2a1b497a66e932db0ce073 assets/airdef/models/item/deploy_buk.json +43ad086ad7cff44263c30b2404bb6266834f36310ed044e661537bf665f12150 assets/airdef/models/item/deploy_depot.json +5047028c52687c2c789be9d339f97d520619e595ea7a33b589ea6ef45d41f067 assets/airdef/models/item/deploy_ew.json +a5d0450da8d6757b9b45f86e6b36977894dd9e3e1278c3668b99a8acab36f399 assets/airdef/models/item/deploy_gepard.json +e80ee81085241b9712e024bbef73ef744ef5493eebb88770f4912d8aae138fe6 assets/airdef/models/item/deploy_irist.json +637b9bfe426ee50045f8fcbfd4f25dfbc1d819ce36dedbd60abdac63c274f088 assets/airdef/models/item/deploy_light.json +3398844e90203f97ee73e35619684c775d1856c8ca3399b5ed682786170d9327 assets/airdef/models/item/deploy_nasams.json +76019c05d070cf1141cdf95b744b3d6198ab99469dba1f88a6903de1de303bde assets/airdef/models/item/deploy_p1sun.json +8b1af688bc83be5a8065673bca7054deee86ca425f0bfd1fb4ffb491844e90bf assets/airdef/models/item/deploy_pantsir.json +d0091d809816350c648e532a90ae59a3c5c9b94f837b2a46b5695e7f985a00f9 assets/airdef/models/item/deploy_patriot.json +332f9de8c88658a562748fc7a6de7ec37847273ac0cd0733e23c0dff4b44418c assets/airdef/models/item/deploy_radar.json +9ee1dfa7d2c2587c85a8f73554962a02a06f1969a9a56c56110162dd93db7301 assets/airdef/models/item/deploy_s400.json +55f1dae5cd7f0126533825c9da3ed3ee316e74d4be8f9bb217a3ebc6f827d24d assets/airdef/models/item/deploy_s500.json +aca5ea765ca0d84ed12444bf433c024b57d6ea9b9b7cf06449dfbd6892a66ae6 assets/airdef/models/item/deploy_tor.json +67912cc711c2f1514e6e2f8f17aa340fa9a99534fbbaeb2b8e319aa6ef80f38d assets/airdef/models/item/deploy_tzm.json +d0976d990130c6398f4adf5d3086e9cf50e5a2adbec081e4682e810af368c332 assets/airdef/models/item/designator.json +b20ec2241060123a208ef66e86183261a7514611206b1b4389d2ababf40b6ca8 assets/airdef/models/item/flare_gun.json +4fc1487d0ea8634bd164c01b54833d243933fd4dd8e40029d3c662f6c0afe9d8 assets/airdef/models/item/launch_geran3.json +a5685a17b3169165faa53b27357933388fcc46ce55ae73049360ca0deee58c64 assets/airdef/models/item/launch_iskander.json +ea86f852034e540430bd7c624fec49072eb775fee4263e48f22e722387393fd3 assets/airdef/models/item/launch_kalibr.json +8954e2d7de8f39b2702e5aec20b10f39cfc4911acb4f7bb438fee76948040487 assets/airdef/models/item/launch_kh101.json +6a27d903c35b802c6a755e4fb24af5bd38421b0dab9f6e39425cc7b5236a3d2b assets/airdef/models/item/launch_kh22.json +148e92840c1884c4915cb14316f6018fbc96ad4cd701f3871407753e5bc4ecd9 assets/airdef/models/item/launch_kinzhal.json +8a2a008a9b58913120acb044a72403679e57c1daa55e9e115676576e7f7ad217 assets/airdef/models/item/launch_lancet.json +dc99bbddf69f5666530f35bd5d92b0e01999afaa69e007f00f03c06bcad8065f assets/airdef/models/item/launch_oreshnik.json +f2fae6baddbb09d9c1889657a58bf9b31d012e357118108c3f8ff45120e31a2a assets/airdef/models/item/launch_shahed.json +45f5442bc6b47c15955f2c080c28db7cccd7485c9fa70fc4f3e9ed976b74ff95 assets/airdef/models/item/launch_zircon.json +0528d0a362eb44e43ef26771e2527ba14eb6f59bc7e71a6b7dd40a908e469b0c assets/airdef/models/item/mover.json +0be54b1273d2a3ab75fd4c643004a753643a423db0ad099b80f4bff66a6b9b4c assets/airdef/models/item/remover.json +b68f4a6564e4a76d66502c9f765d967d80b66f0f8672309dddee5b7928d2ebeb assets/airdef/textures/block/antenna.png +8123e74fd56c6fa211b7490373625756e8792b3d17bb050d0a8ed81689bb2ad3 assets/airdef/textures/block/bare_antenna.png +09a2dd50acbaa8bcd08c9063730898e9601c74661446279c3c2cc10a373a1f1c assets/airdef/textures/block/bare_antenna_b.png +048e8d01ddffd59665e07c1954a6e2d725089dd9ac84933ecf85f093d048751d assets/airdef/textures/block/bare_barrel.png +0decb34bef6baf602616899053ead3378dbe798282ab2f6c931f20c98aa67820 assets/airdef/textures/block/bare_barrel_b.png +dcf8d38a0275fd73c1c1592384962fbe0f0327103414fc8a00c41e8758af113f assets/airdef/textures/block/bare_dish.png +3b5cc50eaf6d3eaf6f6fcc7d7f28935980420fa2a5f8ad680a4790d09f58313f assets/airdef/textures/block/bare_lamp.png +fcb6a151da7c25d755c012629ad5f8b55e2de951ce745c2d7b3b2f0d191babb1 assets/airdef/textures/block/bare_lamp_b.png +de9acb4f73b59d80a4bcc6d0569dfb286d627991c40c1643d7c3c4535a02ac56 assets/airdef/textures/block/bare_nozzle.png +b83bf9da366623e9060981ad77909398ecec55c2f0a7a0aefc941070db961c5b assets/airdef/textures/block/bare_nozzle_b.png +070ce486efc729c756072e19e0f469763e1401289889c302800e45c68d1fb92a assets/airdef/textures/block/barrel.png +b3dfaf452ffe4569477fa270e73d57e16eaed0e7639276336a0ffa4050b53507 assets/airdef/textures/block/beam.png +510a01548a0dce9d3caf767f1a9f172045b253be4f3b3e98e367a281f550ec55 assets/airdef/textures/block/booster.png +7de5699fd66a82395d03e389d2ba0b2aed87d7a52c4f7bcfbcf9a6fdb34ca301 assets/airdef/textures/block/booster_b.png +2d9d72a90bee81630131ecf60f84b74691f6f16e8a7427fef62e2fa7923e5334 assets/airdef/textures/block/canister.png +909b3acf5ac784c4917acba2e1e5c290d412c71fae5457e3916a770c9b20809a assets/airdef/textures/block/canister_b.png +082e2d3fd7df0d548c294c410a6a7643b54d482d76643e0737bae1dcc54736fe assets/airdef/textures/block/canister_front.png +53adaaade4d845d87ec3535adddfba8c0fc84ce373e9722650508ddc0fec7420 assets/airdef/textures/block/canister_front_b.png +2248649b97f906d44208c5d83be7482867e1439d7bdab3e974b0b63af5224249 assets/airdef/textures/block/concrete.png +e43d62b61bb7a5cf7973be9bbe197218c63924c87274852a46c1deb7713c5f30 assets/airdef/textures/block/concrete_b.png +68a21931d31f90f74f1101047ba307b9587ef2cd13139b03fb13cd1119f5d551 assets/airdef/textures/block/crate.png +5fbef49d433c12ff76842d66d8b6bcc9aaeab0e7d47305523f97d12842b2ff0c assets/airdef/textures/block/crate_b.png +331cacfccc1c61b16a986b33e422d751e57cd8d07cb064a012f762105bfca93d assets/airdef/textures/block/dark_metal.png +d262dfab6a1e4bde719f85493246c2ccecdd367a5240d8e7a25cf0eaef2bda7a assets/airdef/textures/block/dark_metal_b.png +deee51e051cb1fdf7495a8dc1fa05bf9a166df9cb5e7e446bf3d29cea856efb3 assets/airdef/textures/block/dish.png +698c9095df8f6b48c260759655797e67df35b390791ceba3e7ce2eb3cce3f8ab assets/airdef/textures/block/fire.png +8532e81c8d5056db50100d963c84522205b6ee698081081a1888f1ee48b7bf08 assets/airdef/textures/block/flame.png +b5b75b2380ed2a12a7ae1b0618efe17358e3a9ebcb9af5de46adf5195dbba271 assets/airdef/textures/block/flecktarn.png +6be4508463644978a41aa426cf9fb547ba2c313e22140b6cb898dd1f54e7446e assets/airdef/textures/block/flecktarn_b.png +b9d5beda0383b16f3f52bafad020768929f0910d0cfd5555b58d176c2bcbf3a7 assets/airdef/textures/block/flecktarn_s13.png +52ca49f3c196de9b7216a1a40b359942a2da3204a39aa0716b085a67dfc073cf assets/airdef/textures/block/flecktarn_s13_b.png +e6b307c552e008962695052a7136cc9037ca8c524a697d8450eb591cd7518e78 assets/airdef/textures/block/flecktarn_s21.png +979c60d195732ac97352408a7e42aa645de153dbf1c55bbb3d72ccf65f8fe695 assets/airdef/textures/block/flecktarn_s21_b.png +1aa7201611114554ce054a9d46a60da566f9c571f2a4ff4ebd4133258c0bbcfb assets/airdef/textures/block/flecktarn_s23.png +7439316183817ce23322367cbd51c80602f0e1537977c78b410671dbbab166bd assets/airdef/textures/block/flecktarn_s23_b.png +5517a43554b08fbbf5cccec8e537ecb23965e1472323e5ae4ad1420a12fcdd4a assets/airdef/textures/block/flecktarn_s31.png +5c44f0f17d55077549982690bab313711543fd706ee944207759262c76e00cb8 assets/airdef/textures/block/flecktarn_s31_b.png +007b3b806bc431be1c84dfca31f2f81fb4c2f9d5a522e40ce0d03f5d3716b106 assets/airdef/textures/block/foam.png +7f8f88fff7d9f4b872ab7bbdc3e976e782730d91355941c7837ef2f7685730f4 assets/airdef/textures/block/geran.png +014374b3428ffe8c438c0df2806c3edd9215c707ec3fd4e18edefd304b9d49cb assets/airdef/textures/block/geran_engine.png +d44f6059ac51c7d707dcb8152036f87fe83af63cdece0c06f88370b5db56a5e0 assets/airdef/textures/block/glass.png +5f733a261a5e8c8eb6a7837ab1e6e28d058410d7cca16e124d7b315f032c3ebc assets/airdef/textures/block/glass_cab.png +b1a6096232481d08c710ff4133547badd1e0061676d6fc4f8e110e691c5e2e0e assets/airdef/textures/block/glass_cab_b.png +3c836747774bc6c5f037eb5f6f581e9b9913c365747251f702164690ec3241e7 assets/airdef/textures/block/glow_ember.png +8f2a9c7485d8b56c7bcf26ee3823f9efdea7ffdb5e3d770f991228cf883eab30 assets/airdef/textures/block/hub.png +0b9e2514e1ff4832ade4ce231e4f1fda0ad50c16ebe7ae726ad749e454e07e1c assets/airdef/textures/block/hub_b.png +216ec5f36c0ff06a55fef95691190eedb0d3ca7af5c42b97c7b901eed91fd1e8 assets/airdef/textures/block/intake.png +d76e4cecd7bedde37dd5d7f84fa2ff6c2b690bcc6e3d9fe905589e24849c045a assets/airdef/textures/block/kh101.png +3413594a1f8ef3e91fd85660114f81c36f3db54a03dc645d030b545d1ba16e4e assets/airdef/textures/block/kh101_belly.png +b52ffbc21c7d5f7cb4d48b5b379e09e92656c5677b8aa66116483521fb3cb7e1 assets/airdef/textures/block/lamp.png +d6f46d3018b98fd8d5f36df9ca11c07f77e5983e05db24bbc59e10916dd6efcd assets/airdef/textures/block/lamp_b.png +e25bd88852f4a50609487eea13bbf58f397c54926f0bc9d02e9d596b17e058e1 assets/airdef/textures/block/lancet.png +d76ebbd51a689233c256776b2c55893268f40567b2c059c9805e3cfc323307ec assets/airdef/textures/block/metal.png +b8f411252cbce0a9e08cccfb4788aad721f6d7aa76c05a40a1c25757c27404d8 assets/airdef/textures/block/metal_b.png +ca2f6efebc39b27cc2a3f974b3ea6b4d9f64929a8355ce4ec1d96ce44fa3dca2 assets/airdef/textures/block/missile.png +898c77bf0dca8c5242468969ad9ae96d13a36d0cfe2c12798f9f51804244a840 assets/airdef/textures/block/missile_grey.png +bf63b80d2b4e9c2f229fccf4e1162dafe738e5522c4784d5262f89afb3eab2cb assets/airdef/textures/block/missile_ru.png +b3c7d5fd6518311f7d22f5b98c9edf230814716f5ec33e5ef84747f49cc7a639 assets/airdef/textures/block/missile_ru_b.png +22730d3a16bf4793e7a2b0cedde24fa015312f6cd91d00d6cd7767faf82f6a06 assets/airdef/textures/block/mk_de.png +9089911e06b239c2f782268c4d637a96e75e0a1eb48a1228f09201245ccc7c9c assets/airdef/textures/block/mk_de_b.png +b8c28ed48383b17725d1d4229e52db9a153bbbcce114e6cfabe6ac86dca86f18 assets/airdef/textures/block/mk_ru.png +7cc8d37e381e965b44cbb53e82626a2d52692a3085ea142c0ec9faa77cf99690 assets/airdef/textures/block/mk_ru2.png +812fee3fc8c346e8eeed2ad30e1caab80c0f6bcb5e94a33ee6d47a6732cb4ddb assets/airdef/textures/block/mk_ru2_b.png +e6a89c73305fc2429df0a7a3fab1b6e6c2d1eb2de48251f1d556dc9a8476a14f assets/airdef/textures/block/mk_ru_b.png +a9de0c4e228fd2adaa996d0ca6123b6d8195fc5c1719201ae741c4979882f1d5 assets/airdef/textures/block/mk_star.png +0b2017578093f28b49994c7d120fa19ec4fa07d137f5b8d30c58326aad00164c assets/airdef/textures/block/mk_star_b.png +8526c1439d9723d1592c3c7c06da8bb8ac2f44cbd5310aaa689019e583a1cc19 assets/airdef/textures/block/mk_us.png +65c308e09a0ba82cdffb87d334ff5bb517a27c2d60d6244cfd53cd9bff1c5080 assets/airdef/textures/block/nato_green.png +5bf07917f3ac31f74ccffd1ae381e8d62f58fd4748bce7b16bfb135850c9bd74 assets/airdef/textures/block/nato_green_b.png +66eb485456876d3aea75cd5e3afb676998d3315056041ee04220b7708ced7856 assets/airdef/textures/block/nato_green_s21.png +76174f752b5e96040e0edb0454b4fcd354182d95786033e396e2bab29e57f2e1 assets/airdef/textures/block/nato_green_s21_b.png +09af19d2003a7ef61c83cb4d4ce4ad544ca760d98c2cb60a881ee9039cf39d65 assets/airdef/textures/block/nato_green_s22.png +6559a12b24829a933b05b21a4bb673878e53bdfa5d7f87a8810c6e9e27ea3d00 assets/airdef/textures/block/nato_green_s22_b.png +28737c6592a187f4b542c18084dfa26ef5e2a5ef0424b94fa5fc1db06825f0e8 assets/airdef/textures/block/nato_green_s23.png +d5aa425480264085dd0a5654e97be78620a28b0adb2e17ad952d6faadcf5a168 assets/airdef/textures/block/nato_green_s23_b.png +491f3e54e771764a9a3a345a577838bb5c2d4bdf80a827d05ff6e8d98f229551 assets/airdef/textures/block/nato_green_s31.png +dd8c9056df254fe788a6848c96af4e56c5951f08717b25ebaa8815d1fb823751 assets/airdef/textures/block/nato_green_s31_b.png +311932fa43f0b513342e8f3746208bd845b936f883f6f5c8d453fd4ef6d315dc assets/airdef/textures/block/nato_tan.png +6a1289cfba0b17732cedfadceb6522b0b9f847f754dd56975716b3e2e21e777a assets/airdef/textures/block/nose_dark.png +a8c0d707e86dedb7591b7e89f07ffd2df85ab0763e903a4c4c9ea17c97c4baee assets/airdef/textures/block/nozzle.png +3f908df37b1456884d840e346f2da60ccce5b61bd226317de9ca24430bd59a8c assets/airdef/textures/block/prop.png +98442b32c81dd7523c8d38bc6ae9810d8e0295474c814533b7fbde33f4910e3b assets/airdef/textures/block/radar_big.png +5dff7780ae2f934bc05bb58742f1965b7cf06ad28459d6dd63b574a0a327f949 assets/airdef/textures/block/radar_face.png +83a012f282bf709a4628530632fac3e0e3227325e931d7774747d7b95401d3d3 assets/airdef/textures/block/radar_face_b.png +144ce90863430c724243ba27bfb212ab1e4e5ff58650d0ec884b14d47232ddeb assets/airdef/textures/block/rm_dark.png +46a86ef31a44a7672c9f5d4e8c0605b78964ccbe1131127dd94d9c4bb307695e assets/airdef/textures/block/rm_dark_t.png +5e37e431975e5816388bf8906cd8d32e41b6ee44760f355983ca9c26cc004932 assets/airdef/textures/block/rm_grey.png +a0d4a601d1d29b1988e73b74c1198c9b6fde7cbfccceb3057465b22124959fd2 assets/airdef/textures/block/rm_grey_t.png +0d21b93c78581892e08f31ca8e4f01c9edf7793f61bca78c8ee72cb0bfa2e40f assets/airdef/textures/block/rm_khaki.png +e832c1bbff632eac6c9b62be8d68981a62a7837499437c0b85f9fb51a4506550 assets/airdef/textures/block/rm_khaki_t.png +4a8e048b9219e04b143da3ae9214f12fa96c68b4e33201b9ec248d65c9eb7f7f assets/airdef/textures/block/rm_pac3.png +08c9ad4315458bc7c59632aba8bc65de051639b2039dc9d252b79917dce15f7a assets/airdef/textures/block/rm_pac3_t.png +a4280a067f3c3068b3ba47dcf4881e091e70a269685e192e19dd025bf7532843 assets/airdef/textures/block/rm_ru.png +7992de763ec57589788eedb01bfb656cec49e73d3dbc15b13aab8a65506c6f5b assets/airdef/textures/block/rm_ru_t.png +c9abfcb99b86708b009270d96a563036c4a539c7f7cfa734c94a97c05b3f36ac assets/airdef/textures/block/rm_white.png +f336ac70e704bbb2977ef36ce6c6ce853ad57e3970cc44ee16eabb9554485707 assets/airdef/textures/block/rm_white_t.png +b92c1817d1b25d626b03998762d8ae29900f6ef41d883be35191877edd4bfd93 assets/airdef/textures/block/ru_green.png +4c932c1c226af577ed1b77c6846d1f1ce95e5da053bda1723e7ddb3442d78959 assets/airdef/textures/block/ru_green_b.png +c3cea9d7709650d88515a7fbfaeec719eb3395687d10cfd92ce374c8b1687e4d assets/airdef/textures/block/ru_green_s12.png +b8cdde5132e534c46c7521a3612b490656221e7549045990edf881f9c2e5e388 assets/airdef/textures/block/ru_green_s12_b.png +bc82211cc1a25e52672452dcd5cd533e818821c79b06a705402d2aff36d48286 assets/airdef/textures/block/ru_green_s13.png +4ca0d50c73cbb661c6c86da9dc2add011da52de185f844dd7dfb515c26b9db72 assets/airdef/textures/block/ru_green_s13_b.png +58729c9509463a4858dddd7fa0964de696e265f6c409a77fae732a73a177542b assets/airdef/textures/block/ru_green_s21.png +167683d721a45aa4a104edeafdf231b0eecfc70442b59e190460009bee3b2e89 assets/airdef/textures/block/ru_green_s21_b.png +7795c2ef19789d465b9b1420ca3aacf1201b0904414b7e39610172c84049c0c1 assets/airdef/textures/block/ru_green_s22.png +5d92c96f896314e390711b66c07922c832a443c3c56e1daec2890195960de47a assets/airdef/textures/block/ru_green_s22_b.png +1c53b3d9cecaa5ed932bf1d58a864e77481e515729c7a72c4032a734897e0b34 assets/airdef/textures/block/ru_green_s23.png +c1424132a342f0245430fa165a1b2d18b6660580cf4ae100fcc19dd3feb2e20d assets/airdef/textures/block/ru_green_s23_b.png +ba4511238d904460c91a55af88781560eac6272de937ed57d54714590f0a3907 assets/airdef/textures/block/ru_green_s31.png +c7425dcb7916bb5ec48bfa57def2a8d6a44a88600fc2651392e9fae8b2fe4888 assets/airdef/textures/block/ru_green_s31_b.png +934ea2bc2623e42310adc0dd67e01916cd21dd32dd691ed759bd681cac958525 assets/airdef/textures/block/ru_khaki.png +abab312510b9d18ca5561c8424aa0ad33feab2575b8d5dfe42b0a6bf31430bd3 assets/airdef/textures/block/ru_khaki_b.png +055fdbc8ed3b808e263a13f694cce5444c4fada440994cd443103d308d54fd20 assets/airdef/textures/block/ru_khaki_s13.png +a53ae1e5fd32671e28d74a090ab3828e2dc34ef223b4c71a1b6f535c0e999a9e assets/airdef/textures/block/ru_khaki_s13_b.png +8df45cca0a57f01efeb85da2c76544c2b103fc474387aa76215d093e998242d9 assets/airdef/textures/block/ru_khaki_s21.png +06dc7f90bdf077097534322435be402f08e5ee9bc34135e410809a8892b940e8 assets/airdef/textures/block/ru_khaki_s21_b.png +194d9a3bca192f65b4f2e808b21c28346dc85d96e923e4bebe2138bc5a0b4927 assets/airdef/textures/block/ru_khaki_s22.png +5f8a6dfed6add932e9b4d0cee17adc4c811cba85433877fcbf0f7dfd9773cb1b assets/airdef/textures/block/ru_khaki_s22_b.png +6d0aa0dcc1702486c764120f49fc3e2ae6f17c953b8c06f53b395a645463a666 assets/airdef/textures/block/ru_khaki_s23.png +cea69d4094324363c2088e1f186927f8dabab4e7eb34a97add85587ea144fda4 assets/airdef/textures/block/ru_khaki_s23_b.png +898405a1bb59a934a499409431003f3dc44a5f3f1874f41e28750f2c4331befc assets/airdef/textures/block/ru_khaki_s31.png +dddcaf43fdf7f5d4e2433c72e5267548e3cb7d65c745082be0a60b14367810b0 assets/airdef/textures/block/ru_khaki_s31_b.png +76b33dc7f36d1a67cb07f9e65b5dbda1e07ddbc41fbc75ad974654aa1ebacb57 assets/airdef/textures/block/ru_khaki_s33.png +3e81c65d26070613c59fc6e363f0e0772c376bea5ab86c37b6cb19431a816535 assets/airdef/textures/block/shahed.png +36e55b26a48838c8c25ec6d1660cfcdfcb1fc7e430dda0ac8908709c0c05f9b4 assets/airdef/textures/block/shahed_nose.png +29a415d359ee6eb0b4b85a18edb38b77e852b5f6a441abbeb9485d1068726e35 assets/airdef/textures/block/target.png +c047457ac99436c8a69bfc7fa755fa55e842ae3261890965df09a2011b8e6201 assets/airdef/textures/block/tire.png +97bd7e7731bd1ad8e4eb582e9acd44981ac4fc47a51a1a5ec4e39d9873a42079 assets/airdef/textures/block/tire_b.png +4edb1f39d7ffeb69257098023b6c3e4709014e5d54cd306ef0930e54257ee93a assets/airdef/textures/block/tracer.png +564d4e443a0270a09cc2e7202ca73e40ffb00422c68a9842b19c10d4c2c184b9 assets/airdef/textures/block/tracer_hot.png +e1da001ded920abd57814b7301d0388a04db72b0eeac51f73d67b6287d56ef43 assets/airdef/textures/block/track.png +337e60bf5be059e10649e78da7fc98f1f12cc47ae8dddeb85f733535d3fa0c48 assets/airdef/textures/block/track_b.png +68b9407648c541ea0a167e731340358b4ef45862be13ee5a582f7e088818cfaf assets/airdef/textures/block/track_side.png +fa2848684a76002a17932aae6a0eead4af19ac594be39e1f7f995d2a239d6c6c assets/airdef/textures/block/track_side_s31.png +02fa231c4ba6ba4d42c254fe815d1e0d74308a7f8fc1735cec5f955b494c9970 assets/airdef/textures/block/track_side_s31_b.png +7a83f09a9c4feca1eb1dbde5abefa770b3623d5e491666d6835d2272d70d3709 assets/airdef/textures/block/tube.png +137d90569624d7b164c16bf5d8dec2e9c4aed8096e3ec0d96f9e3661df90ee63 assets/airdef/textures/block/tube_b.png +03570f9d5963c5fd61472d10cb68d56c26a7fd5806ccc86b86c5095476966b58 assets/airdef/textures/block/tube_cap.png +e622dfd1caabbfde83081c89447fe49c13301f4edb92efe149402e110cbcb64e assets/airdef/textures/block/tube_cap_b.png +bfd57114127057959dc3d46c60b1234e27dcdb35387a5e2b4f045ceb676ca09f assets/airdef/textures/block/tubes6.png +4a229d23538c8f22c812633362f70ee4d887e515ef1d798fcd8a26c28f715696 assets/airdef/textures/block/tubes6_b.png +92c449cdceb109949b31d49ff5627173cc0a33daf2c610748aef709623dcef23 assets/airdef/textures/block/white.png +fb41e5b003f0c16308bc819fd632828d21fe1855ad9cd32283e02de3defb67fe assets/airdef/textures/item/deploy_buk.png +0c06c682684abbf03e01d5ce7d8d2a4ece2ddc96e96bf87d7597bb0247ce3806 assets/airdef/textures/item/deploy_depot.png +d0a6911668824195a4630a60f2b618958a134dada220a269a1c1c8faf2b77922 assets/airdef/textures/item/deploy_ew.png +303c3352b21762b59eb23f2402dcb59f77983e5d2db08711414be99624695364 assets/airdef/textures/item/deploy_gepard.png +a3dd2f7ad102f84be1df0182e22f2c2189bb872a8dcf3ddb7136ac05434c6abc assets/airdef/textures/item/deploy_irist.png +805e883f86175f4f16ac8e43488f0db469f8c7d4bf0545c80439440517ea4685 assets/airdef/textures/item/deploy_light.png +e7e303005314c70ec142e69ca6511d7dd1d6daba909563ecf6e3cbd6d146a9a8 assets/airdef/textures/item/deploy_nasams.png +cfe4373e86a9e2f268e0623279da6498b4327afbbeec3b7cef0b1775f6007c73 assets/airdef/textures/item/deploy_p1sun.png +e7522f1c583dae4cdf313a0ddbf22aae837dc34e5ec36a46daa5d7cb459d59fb assets/airdef/textures/item/deploy_pantsir.png +2cffed28123c0e4eb828eb314e7ea65ced2576bb158913e0a837b96f0fb67730 assets/airdef/textures/item/deploy_patriot.png +8b015f6480fbd7d9c1078efc73f3097fe32832fb5bbd6b8fe45307cd545a3026 assets/airdef/textures/item/deploy_radar.png +37ad7777de7034487d3c53f5224eca34b5dac6de41aeb2080fdbd112ec7eae0e assets/airdef/textures/item/deploy_s400.png +4cfb6250543f00b37fdb9e09e63c3107f7d88beeeaf72838150f9f301284cdd8 assets/airdef/textures/item/deploy_s500.png +c473b97913fe7afab44d84768c80e11b752645b8bf9de9ffe5a026d3eb4d8e9c assets/airdef/textures/item/deploy_tor.png +483c40bf24d5dae5ffad872c1b1df719cebcfb1f36c1153c2a5790981588d493 assets/airdef/textures/item/deploy_tzm.png +3be70aa7d5210da09a910701605dcf8b2b4dbc5c43ef2d2c77ce36a7f2029012 assets/airdef/textures/item/designator.png +f336d25be27818f3b0f4b2fc7c6f7b3e8be9050c9f9231f5703056a4bd8c1685 assets/airdef/textures/item/flare_gun.png +51d28156b180efa780ad91dc7b5d0b5d89880ae3a35a5f228d3dc8ca1ec80938 assets/airdef/textures/item/launch_geran3.png +f6165a2f251c61a6b89711e493da86ec0facb422f6b1e0364a8b7e707d641bf5 assets/airdef/textures/item/launch_iskander.png +748f050d5229d70414e81ef204f66f075f659e78117c40818bc51cb728d73180 assets/airdef/textures/item/launch_kalibr.png +925224a3deffa75f32207bd4324f8c06a6e1c08739126a96ab83b327902e55bf assets/airdef/textures/item/launch_kh101.png +dc6e92e3386badb8b3b8fc17dffe93f19ae9a406e4e2275141d6313743d8ed4c assets/airdef/textures/item/launch_kh22.png +0e81d738ddf1d7eb1db828f27e88e45066c413fd2ade293561d9c08965232908 assets/airdef/textures/item/launch_kinzhal.png +41586acc732102fbc07a5183d91dd614a622aaecf3fed6de795e34e59af41fe6 assets/airdef/textures/item/launch_lancet.png +f446b6d8b37f5be99390bd77e4e36752547085f004b98520359b3f3e80edfdea assets/airdef/textures/item/launch_oreshnik.png +7e0643346b7ad04caa4f234c793dd671abdbb9dbc5138a3836d867aebec95a48 assets/airdef/textures/item/launch_shahed.png +a1439f23a2d368f3d3ebd1b7d26d44af27c05797c3aad76a30878a79e225cce9 assets/airdef/textures/item/launch_zircon.png +60332733e12c2c0d346db2f3b8108d0083167cfc75e8b1e0c76a52c495b95924 assets/airdef/textures/item/mover.png +e43175307463d0946562f21d44ec5393c2605537820708dae3687065d3284aa1 assets/airdef/textures/item/remover.png +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/amraam_missile.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/barge.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/beam.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/buk_chassis.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/buk_chassis_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/buk_rails.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/buk_rails_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/buk_turret.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/buk_turret_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/debris.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/depot.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/depot_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/ew_truck.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/ew_truck_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/fireball.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/flame.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/flare.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/flash.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/gepard_dish.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/gepard_guns.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/gepard_guns_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/gepard_hull.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/gepard_hull_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/gepard_turret.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/gepard_turret_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/geran3.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/geran3_flame.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/gerbera.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/irist_chassis.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/irist_chassis_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/irist_missile.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/irist_tubes.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/irist_tubes_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/iskander.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/kalibr.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/kh101.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/kh22.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/kinzhal.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/lancet.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/launch_ramp.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/launcher_truck.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/m48n6.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/m57e6.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/m77n6.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/nasams_launcher.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/nasams_launcher_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/nasams_trailer.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/nasams_trailer_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/p1sun.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/p1sun_launcher.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/p1sun_launcher_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/pac3.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/pantsir_dish.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/pantsir_pack.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/pantsir_pack_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/pantsir_truck.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/pantsir_truck_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/pantsir_turret.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/pantsir_turret_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/patriot_launcher.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/patriot_launcher_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/patriot_radar.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/patriot_trailer.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/patriot_trailer_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/patriot_truck.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/radar_panel.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/radar_trailer.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/radar_truck.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/radar_truck_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/reentry.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/s400_chassis.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/s400_chassis_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/s400_tubes.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/s400_tubes_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/s500_chassis.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/s500_chassis_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/s500_tubes.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/s500_tubes_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/searchlight.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/searchlight_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/searchlight_head.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/searchlight_head_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/shahed.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/shahed_prop.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/target.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tor_chassis.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tor_chassis_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tor_turret.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tor_turret_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tracer.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tzm_truck.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tzm_truck_burnt.json +cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/zircon.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_geran3.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_gerbera.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_iskander.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_kalibr.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_kh101.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_kh22.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_kinzhal.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_lancet.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_reentry.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_shahed.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_zircon.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_buk.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_depot.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_ew.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_gepard.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_irist.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_light.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_nasams.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_p1sun.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_pantsir.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_patriot.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_radar.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_s400.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_s500.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_tor.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_tzm.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/designator.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/flare_gun.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_geran3.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_iskander.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_kalibr.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_kh101.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_kh22.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_kinzhal.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_lancet.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_oreshnik.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_shahed.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_zircon.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/mover.json +2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/remover.json diff --git a/airdef-mod/src/commands.rs b/airdef-mod/src/commands.rs index 150fcdc..37cb9e5 100644 --- a/airdef-mod/src/commands.rs +++ b/airdef-mod/src/commands.rs @@ -68,7 +68,7 @@ pub fn tree() -> Tree { Node::lit("deploy").then(Node::arg("комплекс", Value::choice(deploy_kinds)).runs()), Node::lit("wave").then( Node::arg("тип", Value::choice(wave_kinds)).runs().then( - Node::arg("кол-во", Value::int(1, 80)).runs().then(Node::arg("дальность", Value::number(60.0, 800.0)).runs()), + Node::arg("кол-во", Value::int(1, 80)).runs().then(Node::arg("дальность", Value::number(60.0, 4000.0)).runs()), ), ), Node::lit("clear").runs(), @@ -77,6 +77,7 @@ pub fn tree() -> Tree { toggle("truth"), Node::lit("shader").then(Node::arg("режим", Value::choice(["on", "off", "hd"])).runs()), toggle("hold"), + toggle("lastchance"), Node::lit("roles").runs().then( Node::arg("комплекс", Value::choice(armed)).then( Node::arg( @@ -148,8 +149,8 @@ mod tests { "replay export mine all fps=30 size=1280x720 crf=18 sec=12.5 noaudio", "replay export fps=60", "replay stop", "scenario save a", "scenario save a fresh", "scenario load a", "scenario load a force", "scenario list", "scenario delete a", "why", "why 2", "ew on", "ew off", "move 4", "target here", "targets clear", - "deploy patriot", "deploy light", "deploy s400", "wave mix", "wave shahed", "wave shahed 20", "wave kh101 80 800", - "wave oreshnik 1 60", "damage on", "damage off", "truth on", "shader hd", "shader off", "shader on", "hold on", + "deploy patriot", "deploy light", "deploy s400", "wave mix", "wave shahed", "wave shahed 20", "wave kh101 80 800", "wave shahed 20 4000", + "wave oreshnik 1 60", "damage on", "damage off", "truth on", "shader hd", "shader off", "shader on", "hold on", "lastchance on", "lastchance off", "roles all default", "roles patriot drone,jet", "roles pantsir all", "roles tor ballistic", ] { ok(line); @@ -174,7 +175,7 @@ mod tests { assert_eq!(bad_at("wave shahed 0"), 12); assert_eq!(bad_at("wave shahed 81"), 12); assert_eq!(bad_at("wave shahed 5 59"), 14); - assert_eq!(bad_at("wave shahed 5 801"), 14); + assert_eq!(bad_at("wave shahed 5 4001"), 14); assert_eq!(bad_at("wave shahed x"), 12); assert_eq!(bad_at("wave scud"), 5); assert_eq!(bad_at("wave"), 4); @@ -218,10 +219,10 @@ mod tests { fn completion_offers_the_next_word_only() { let top = complete("", &[]); for w in ["menu", "monitor", "grid", "replay", "scenario", "why", "raid", "repair", "import", "ew", "flare", "move", - "target", "give", "deploy", "wave", "clear", "targets", "damage", "truth", "shader", "hold", "roles", "stats"] { + "target", "give", "deploy", "wave", "clear", "targets", "damage", "truth", "shader", "hold", "lastchance", "roles", "stats"] { assert!(top.contains(&w.to_string()), "{w} in {top:?}"); } - assert_eq!(top.len(), 24); + assert_eq!(top.len(), 25); assert_eq!(complete("rep", &[]), ["repair", "replay"]); assert_eq!(complete("replay ", &[]), ["export", "film", "full", "map", "reel", "stop"]); assert_eq!(complete("replay film ", &[]), ["all", "best"]); diff --git a/airdef-mod/src/layout.rs b/airdef-mod/src/layout.rs index 6226697..f046ac7 100644 --- a/airdef-mod/src/layout.rs +++ b/airdef-mod/src/layout.rs @@ -30,6 +30,7 @@ pub struct Layout { pub sams: Vec, pub targets: Vec<(DVec3, f64)>, pub damage: Option, + pub last_chance: Option, pub seed: Option, pub wave: Option>, pub far: Option, @@ -122,6 +123,7 @@ impl Layout { out.targets.push((pos, hp)); } "damage" => out.damage = l.flag(1, "значение")?, + "lastchance" => out.last_chance = l.flag(1, "значение")?, "far" => out.far = l.flag(1, "значение")?, "effects" => out.effects = Some((l.flag(1, "шейдер")?.unwrap_or(true), l.flag(2, "HD")?.unwrap_or(false))), "seed" => { diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index f770bb0..a9ef49e 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -453,6 +453,7 @@ impl AirDef { let id = 0x6200_0000 | (f.id & 0x00ff_ffff); add(id, f.at + DVec3::Y * 1.2, (1.0, 0.5, 0.17), 70.0 * f.size * glow * flicker(f.id), 20.0 + 10.0 * f.size); } + let mut spotted: Vec = Vec::new(); for s in &self.sim.sams { if !s.alive() && s.burning() { add(s.id + 15, s.pos + DVec3::Y * 1.5, (1.0, 0.5, 0.18), 80.0 * flicker(s.id), 30.0); @@ -462,7 +463,9 @@ impl AirDef { && s.kind == SamKind::Light && let Some(t) = s.target.and_then(|t| self.sim.drones.iter().find(|d| d.id == t)) && self.sim.lit.contains(&t.id) + && !spotted.contains(&t.id) { + spotted.push(t.id); add(s.id + 14, t.pos, (0.95, 0.97, 1.0), 220.0, 30.0); } } @@ -494,7 +497,7 @@ impl AirDef { dy: b.dir.y as f32, dz: b.dir.z as f32, color: (0.95, 0.97, 1.0), - intensity: 90_000.0, + intensity: 12_000.0, range: (b.free + 3.5).min(1024.0) as f32, inner_angle: sim::lights::BEAM_INNER as f32, outer_angle: sim::lights::BEAM_OUTER as f32, @@ -857,6 +860,7 @@ impl AirDef { text += &format!("target {} {} {} {h}\n", t.x, t.y, t.z); } text += &format!("damage {}\n", self.sim.damage); + text += &format!("lastchance {}\n", self.sim.last_chance); if view { text += &format!("far {}\n", self.far); text += &format!("effects {} {}\n", self.sim.shader, self.hd); @@ -945,6 +949,9 @@ impl AirDef { if let Some(d) = layout.damage { self.sim.damage = d; } + if let Some(l) = layout.last_chance { + self.sim.last_chance = l; + } // A scenario's: the random generator, and the wave menu's raid. if let Some(v) = layout.seed { self.sim.reseed(v); @@ -1617,6 +1624,11 @@ impl Mod for AirDef { if !wanted.is_empty() { render::query_ground(&wanted); } + // The chunks the launch-site search is looking at: the game loads them for a few seconds (API 22). + let chunks = self.ground.take_chunks(); + if !chunks.is_empty() { + render::load_chunks(&chunks); + } let rays: Vec = self .ground .world @@ -1891,7 +1903,7 @@ impl Mod for AirDef { } ["wave", kind, rest @ ..] => { let count = rest.first().and_then(|v| v.parse().ok()).unwrap_or(5u32).clamp(1, 80); - let dist = rest.get(1).and_then(|v| v.parse().ok()).unwrap_or(260.0f64).clamp(60.0, 800.0); + let dist = rest.get(1).and_then(|v| v.parse().ok()).unwrap_or(260.0f64).clamp(wave::DIST_MIN, wave::DIST_MAX); let p = player().ok_or("Нет игрока")?; let target = self .sim @@ -1904,7 +1916,9 @@ impl Mod for AirDef { let kind = DroneKind::from_key(kind).ok_or( "Тип: shahed, geran3, lancet, gerbera, kh101, kalibr, kh22, zircon, iskander, kinzhal, oreshnik или mix", )?; - self.sim.raid(kind, target, count, dist, &mut out); + // Through the menu's path (`order_raid`), so every drone leaves a launch site. + let order = sim::RaidOrder { kinds: vec![(kind, count)], approach: sim::Approach::Sector, dist, aim: sim::Aim::Objects }; + self.sim.order_raid(&order, &[target], &mut out); } None } @@ -1999,6 +2013,10 @@ impl Mod for AirDef { let names: Vec<&str> = Class::ALL.iter().filter(|c| r & c.bit() != 0).map(|c| c.name()).collect(); say(&format!("{}: {}", kind.name(), if names.is_empty() { "ничего".into() } else { names.join(", ") })); } + say(&format!( + "Последний шанс: {} (комплекс стреляет и по классу вне ролей, если цель вот-вот попадёт; /airdef lastchance on|off)", + if self.sim.last_chance { "вкл" } else { "выкл" } + )); Some("Классы: drone, jet, cruise, supersonic, ballistic (или all). /airdef roles <комплекс|all> <классы|default>".into()) } ["roles", who, what] => { @@ -2025,6 +2043,15 @@ impl Mod for AirDef { self.dirty = true; Some("Роли ПВО обновлены".into()) } + ["lastchance", v @ ("on" | "off")] => { + self.sim.last_chance = *v == "on"; + self.dirty = true; + Some(if self.sim.last_chance { + "Последний шанс: вкл — комплекс стреляет и по классу вне ролей, если цель вот-вот попадёт".into() + } else { + "Последний шанс: выкл — комплексы стреляют только по своим ролям".into() + }) + } ["hold", v @ ("on" | "off")] => { self.sim.hold = *v == "on"; Some(if self.sim.hold { "ПВО: огонь запрещён".into() } else { "ПВО: огонь разрешён".into() }) diff --git a/airdef-mod/src/scenario.rs b/airdef-mod/src/scenario.rs index 1987798..34b6f7a 100644 --- a/airdef-mod/src/scenario.rs +++ b/airdef-mod/src/scenario.rs @@ -71,6 +71,7 @@ impl AirDef { self.sim.targets.clear(); self.sim.target_health.clear(); self.sim.roles.clear(); + self.sim.last_chance = true; self.sim.kills = 0; self.sim.hits = 0; self.sim.launched = 0; diff --git a/airdef-mod/src/sim/balance.rs b/airdef-mod/src/sim/balance.rs new file mode 100644 index 0000000..8d98ba8 --- /dev/null +++ b/airdef-mod/src/sim/balance.rs @@ -0,0 +1,297 @@ +//! Balance measurements: a whole raid on flat land, flown headlessly against a cluster of systems. +//! The `#[ignore]`d tests print tables (`cargo test --release balance -- --ignored --nocapture`); +//! the others assert the cheap invariants. The tables are what the air defence's tuning is judged by. +use super::sites::testland::{Land, serve}; +use super::*; + +/// The ten armed kinds in the order a cluster takes them (long-range heavy ones spread evenly). +const MIX: [SamKind; 10] = [ + SamKind::Patriot, + SamKind::IrisT, + SamKind::S400, + SamKind::Pantsir, + SamKind::Nasams, + SamKind::Tor, + SamKind::S500, + SamKind::Buk, + SamKind::Gepard, + SamKind::P1sun, +]; + +/// What one drone went through, for saying why it got through. +#[derive(Default, Clone)] +struct Hist { + kind: Option, + /// Ticks any system held a track of it. + seen: u32, + /// Ticks some loaded launcher with the class in its roles had it in range (`cover`), and held its track too (`reach`). + cover: u32, + reach: u32, + /// Distinct interceptors sent at it. + shots: std::collections::HashSet, + dead: bool, + last: (DVec3, DVec3), + /// Ticks to the aim when first covered, first tracked by a covering launcher, first shot at. + ttg: (f64, f64, f64), +} + +#[derive(Default, Debug, Clone)] +pub(super) struct Result { + pub launched: u32, + pub kills: u32, + pub leaked: u32, + pub missiles: u32, + pub ticks: u64, + /// Leaks by cause. + pub unseen: u32, + pub no_cover: u32, + pub untracked: u32, + pub hits: u32, + pub crashes: u32, + pub unshot: u32, + pub missed: u32, + /// Missiles fired by the heavy systems (S-400, S-500, Patriot). + pub heavy: u32, + pub by_kind: Vec<(DroneKind, u32, u32)>, + pub tally: String, +} + +pub(super) struct Setup { + pub systems: usize, + pub smart: bool, + pub seed: u64, + pub size: u32, + pub radars: usize, + pub objects: usize, + /// Called on the sim before the raid (a test sets its switches). + pub tweak: fn(&mut Sim), +} + +impl Setup { + pub fn new(systems: usize, smart: bool, seed: u64) -> Self { + Setup { systems, smart, seed, size: 36, radars: (systems / 10).max(1), objects: 36, tweak: |_| {} } + } +} + +/// The cluster: `objects` in a grid, systems of the mix scattered round them, radars among them. +pub(super) fn cluster(sim: &mut Sim, s: &Setup) { + let mut rng = Rng::new(s.seed.wrapping_mul(977) + 5); + let side = (s.objects as f64).sqrt().ceil() as usize; + for k in 0..s.objects { + let p = DVec3::new(((k % side) as f64 - side as f64 / 2.0) * 70.0, 70.0, ((k / side) as f64 - side as f64 / 2.0) * 70.0); + sim.targets.push(p); + sim.target_health.push(100.0); + } + let objects = sim.targets.clone(); + for i in 0..s.systems { + let at = objects[(rng.f() * objects.len() as f64) as usize % objects.len()]; + let a = rng.f() * TAU; + let r = 30.0 + rng.f() * 110.0; + let id = sim.id(); + sim.sams.push(Sam::new(id, MIX[i % MIX.len()], at + DVec3::new(a.sin() * r, 0.0, a.cos() * r), rng.f() * TAU)); + } + for _ in 0..s.radars { + let a = rng.f() * TAU; + let r = rng.f() * 220.0; + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(a.sin() * r, 70.0, a.cos() * r), 0.0)); + } +} + +/// Flies one raid to its end and counts. +pub(super) fn fly(s: &Setup) -> Result { + let mut sim = Sim::new(s.seed); + cluster(&mut sim, s); + (s.tweak)(&mut sim); + let mut ground = Ground::default(); + let land = Land(|_, _| 64); + let mut out = Out::default(); + for _ in 0..200 { + serve(&mut ground, &land, sim.tick); + sim.step(&mut ground, None, &mut out); + } + let mut order = crate::wave::WaveConfig::mix(s.size, 500.0); + // The Oreshnik's blocks are a raid of their own; this measures the ordinary mass raid. + order.kinds.retain(|k| k.0 != DroneKind::Oreshnik); + if s.smart { + order.aim = raid::Aim::Smart; + order.approach = raid::Approach::AllSides; + } + let targets = sim.targets.clone(); + let fired0: u32 = sim.sams.iter().map(|m| m.fired).sum(); + sim.order_raid(&order, &targets, &mut out); + let mut hist: HashMap = HashMap::new(); + let mut result = Result::default(); + let start = sim.tick; + let mut gone_unkilled: Vec = Vec::new(); + for _ in 0..9000 { + serve(&mut ground, &land, sim.tick); + out = Out::default(); + sim.step(&mut ground, None, &mut out); + let now = sim.tick; + for d in &sim.drones { + let h = hist.entry(d.id).or_default(); + h.kind = Some(d.kind); + h.last = (d.pos, d.target); + if !d.alive() { + h.dead = true; + continue; + } + if sim.sams.iter().any(|m| m.seen.contains(&d.id)) { + h.seen += 1; + } + let class = d.kind.spec().class.bit(); + let covers = |m: &&Sam| { + m.alive() && m.roles & class != 0 && m.missiles > 0 && m.kind.spec().missile.as_ref().is_some_and(|l| (l.min..l.max).contains(&m.pos.distance(d.pos))) + }; + let ttg = why::arrival(d); + if h.cover == 0 && sim.sams.iter().filter(covers).next().is_some() { + h.ttg.0 = ttg; + } + if h.reach == 0 && sim.sams.iter().filter(covers).any(|m| m.seen.contains(&d.id)) { + h.ttg.1 = ttg; + } + h.cover += u32::from(sim.sams.iter().filter(covers).next().is_some()); + h.reach += u32::from(sim.sams.iter().filter(covers).any(|m| m.seen.contains(&d.id))); + } + for m in &sim.missiles { + if let Some(h) = hist.get_mut(&m.target) { + if h.shots.is_empty() { + h.ttg.2 = h.last.0.distance(h.last.1) / h.kind.map_or(1.0, |k| k.spec().speed); + } + h.shots.insert(m.id); + } + } + // Drones gone from the sky that were never brought down arrived (or crashed). + let here: std::collections::HashSet = sim.drones.iter().map(|d| d.id).collect(); + let queued: std::collections::HashSet = sim.queue.iter().map(|q| q.1.id).collect(); + let vanished: Vec = hist.iter().filter(|(id, h)| !here.contains(id) && !queued.contains(id) && !h.dead && h.kind.is_some()).map(|(id, _)| *id).collect(); + for id in vanished { + if let Some(h) = hist.remove(&id) { + // Gone from the sky near its aim: arrived. (A kill that detonates or breaks up removes the drone too.) + let k = h.kind.unwrap(); + if !k.spec().decoy && h.last.0.distance(h.last.1) < 2.0 * k.spec().speed + 12.0 { + if std::env::var("LEAKS").is_ok() { + eprintln!(" leak {k:?} seen {} cover {} reach {} shots {} ttg cover/track/shot {:.0}/{:.0}/{:.0}", h.seen, h.cover, h.reach, h.shots.len(), h.ttg.0, h.ttg.1, h.ttg.2); + } + gone_unkilled.push(h); + } + } + } + // Dead ones that have landed are forgotten. + hist.retain(|id, h| !(h.dead && !here.contains(id))); + if now - start > 300 && sim.queue.is_empty() && !sim.planning() && !sim.drones.iter().any(|d| d.alive()) && sim.missiles.is_empty() { + break; + } + } + result.ticks = sim.tick - start; + result.launched = sim.launched; + result.kills = sim.kills; + result.missiles = sim.sams.iter().map(|m| m.fired).sum::() - fired0; + result.heavy = sim.sams.iter().filter(|m| matches!(m.kind, SamKind::S400 | SamKind::S500 | SamKind::Patriot)).map(|m| m.fired).sum(); + result.hits = sim.hits; + result.crashes = sim.crashes; + result.leaked = sim.hits; + let mut kinds: HashMap = HashMap::new(); + for h in &gone_unkilled { + let k = h.kind.unwrap(); + kinds.entry(k).or_default().0 += 1; + if h.seen == 0 { + result.unseen += 1; + } else if h.cover == 0 { + result.no_cover += 1; + } else if h.reach == 0 { + result.untracked += 1; + } else if h.shots.is_empty() { + result.unshot += 1; + } else { + result.missed += 1; + } + } + result.by_kind = kinds.into_iter().map(|(k, v)| (k, v.0, v.1)).collect(); + result.by_kind.sort_by_key(|k| k.0 as u8); + result.tally = sim.tally.text(); + result +} + +pub(super) fn table(title: &str, smart: bool, size: u32, systems: &[usize], seeds: &[u64], tweak: fn(&mut Sim)) -> Vec<(usize, f64)> { + let mut means = Vec::new(); + eprintln!("== {title} ({} raid)", if smart { "smart" } else { "mix" }); + eprintln!("{:>4} {:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | leaks: unseen nocover untrk unshot missed hits crash", "sams", "seed", "sent", "kills", "leak", "missil", "heavy"); + for &n in systems { + let (mut total, mut missiles, mut heavy, mut clean) = (0, 0, 0, 0); + for &seed in seeds { + let mut s = Setup::new(n, smart, seed); + s.tweak = tweak; + s.size = size; + let r = fly(&s); + total += r.leaked; + clean += u32::from(r.leaked == 0); + missiles += r.missiles; + heavy += r.heavy; + if std::env::var("ROWS").is_ok() { eprintln!("{n:>4} {seed:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | {:>12} {:>7} {:>5} {:>6} {:>6} {:>4} {:>5} {:?}", r.launched, r.kills, r.leaked, r.missiles, r.heavy, r.unseen, r.no_cover, r.untracked, r.unshot, r.missed, r.hits, r.crashes, r.by_kind.iter().map(|k| format!("{:?}:{}", k.0, k.1)).collect::>()); } + if seed == seeds[0] && std::env::var("ROWS").is_ok() { + eprintln!(" tally: {}", r.tally); + } + } + let mean = total as f64 / seeds.len() as f64; + let k = seeds.len() as f64; + eprintln!("{n:>4} mean leaks {mean:.1} (clean seeds {clean}/{}), missiles {:.0}, of them heavy {:.0}", seeds.len(), missiles as f64 / k, heavy as f64 / k); + means.push((n, mean)); + } + means +} + +#[test] +#[ignore] +fn raid_table() { + for smart in [false, true] { + table("current", smart, 36, &[20, 50, 150], &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], |_| {}); + } +} + +/// A raid of 80 (about 100 drones): 20 systems clearly lose, 150 hold (a loose bound, one seed). +#[test] +fn a_small_cluster_loses_and_a_big_one_holds() { + let mut s = Setup::new(20, false, 2); + s.size = 80; + let small = fly(&s); + let mut s = Setup::new(150, false, 2); + s.size = 80; + let big = fly(&s); + eprintln!("20 systems: {small:?}\n150 systems: {big:?}"); + assert!(small.launched >= 80, "the raid was not launched: {small:?}"); + assert!(small.leaked >= 10, "20 systems should clearly lose, leaked {}", small.leaked); + assert!(big.leaked <= 5, "150 systems leaked {}", big.leaked); +} + +/// What each of the new rules adds, one at a time (the raid of 36 and a heavier one of 80). +#[test] +#[ignore] +fn rules_one_by_one() { + for size in [36, 80] { + for smart in [false, true] { + table(&format!("size {size}: roles only"), smart, size, &[20, 50, 150], &[1, 2, 3, 4, 5, 6], |s| { + s.last_chance = false; + s.far_shots = false; + }); + table(&format!("size {size}: + last chance"), smart, size, &[20, 50, 150], &[1, 2, 3, 4, 5, 6], |s| s.far_shots = false); + table(&format!("size {size}: + last chance + long shots"), smart, size, &[20, 50, 150], &[1, 2, 3, 4, 5, 6], |_| {}); + } + } +} + +/// One raid in detail, for hunting a leak: `LEAKS=1 cargo test --release one_raid -- --ignored --nocapture`. +#[test] +#[ignore] +fn one_raid() { + let n: usize = std::env::var("SAMS").ok().and_then(|v| v.parse().ok()).unwrap_or(150); + let smart = std::env::var("SMART").is_ok(); + let size: u32 = std::env::var("SIZE").ok().and_then(|v| v.parse().ok()).unwrap_or(36); + for seed in 1..=12 { + let mut s = Setup::new(n, smart, seed); + s.size = size; + let r = fly(&s); + eprintln!("seed {seed}: sent {} kills {} leaked {} missiles {} ticks {}", r.launched, r.kills, r.leaked, r.missiles, r.ticks); + } +} diff --git a/airdef-mod/src/sim/lights.rs b/airdef-mod/src/sim/lights.rs index 964bb14..afeecbf 100644 --- a/airdef-mod/src/sim/lights.rs +++ b/airdef-mod/src/sim/lights.rs @@ -15,6 +15,8 @@ pub const BEAM_INNER: f64 = 0.03; pub const BEAM_OUTER: f64 = 0.075; /// A target is lit once the axis is this close to it (inside the cone's soft edge). const LIT_ANGLE: f64 = 0.05; +/// Most lamps on one target. +const MAX_LAMPS: u32 = 2; /// Ticks between casts of the beam's axis against the land. const CAST_EVERY: u64 = 4; /// Guns' hits at night on an unlit target. @@ -75,6 +77,10 @@ impl Sim { self.flares = keep; // What the network tracks, for the searchlights. let tracked: Vec = self.sams.iter().flat_map(|s| s.seen.iter().copied()).collect(); + // A lamp holds a target only while fewer than two others do; the sky is swept only while + // something flies in it. + let mut held: HashMap = HashMap::new(); + let anything = self.drones.iter().any(|d| d.alive()); let gun_targets: Vec = self.sams.iter().filter_map(|s| s.gun_target).collect(); for i in 0..self.sams.len() { if self.sams[i].kind != SamKind::Light || !self.sams[i].alive() || self.sams[i].moving() { @@ -93,12 +99,20 @@ impl Sim { let target = self .drones .iter() - .filter(|d| d.alive() && tracked.contains(&d.id) && d.pos.distance(pos) < BEAM) + .filter(|d| d.alive() && tracked.contains(&d.id) && d.pos.distance(pos) < BEAM && held.get(&d.id).copied().unwrap_or(0) < MAX_LAMPS) .min_by(|a, b| { let key = |d: &Drone| d.pos.distance(pos) * if gun_targets.contains(&d.id) { 0.3 } else { 1.0 }; key(a).total_cmp(&key(b)) }) .map(|d| (d.id, d.pos)); + if let Some((t, _)) = target { + *held.entry(t).or_insert(0) += 1; + } else if !anything { + let sam = &mut self.sams[i]; + sam.target = None; + sam.beam_on = false; + continue; + } let sam = &mut self.sams[i]; let (head, _) = sam.mount(); let (want_yaw, want_pitch, reach) = match target { diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index 88651ea..5e3fdac 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -26,6 +26,8 @@ mod units; mod why; pub mod world; #[cfg(test)] +mod balance; +#[cfg(test)] mod regress; #[cfg(test)] mod sensor_tests; @@ -229,6 +231,13 @@ pub struct Ground { known: HashMap<(i32, i32), (i32, u64)>, wanted: Vec<(i32, i32)>, asked: std::collections::HashSet<(i32, i32)>, + /// Columns whose chunk the game is loading (it answered `PENDING`), and the tick it said so: not asked + /// again for a few ticks. + loading: HashMap<(i32, i32), u64>, + /// Chunks the search wants loaded (`render.load-chunks`), taken every tick. + chunks: Vec<(i32, i32)>, + /// The game has answered "loading" at least once: it loads chunks on request (a server doesn't). + pub loads: bool, /// The tick of the last full clean-up (a big cache would otherwise be scanned on every answer). swept: u64, /// The tick of the last answer round: what the heights' ages are measured against. @@ -237,6 +246,14 @@ pub struct Ground { pub world: world::WorldCache, } +/// What `render.ground` answers (`i32::MIN`) for a column whose chunk it is loading for the mod. +pub const PENDING: i32 = i32::MIN; +/// Ticks before a column that was loading is asked about again. +const LOAD_RECHECK: u64 = 6; +/// The chunks asked to load at once (the game holds 64). +const CHUNKS_MAX: usize = 48; +/// Ticks a strike drone may fly before it is lost: a Shahed at 0.6 blocks a tick needs 6700 for 4000 blocks. +const MAX_FLIGHT: u32 = 24_000; /// The `when` of a height reported changed: due to be asked again. const STALE: u64 = u64::MAX; /// Ticks after which a land height used by the flights' look-ahead is asked again (#64), changes told of or not. @@ -272,8 +289,17 @@ impl Ground { pub fn answer(&mut self, answers: Vec<(i32, i32, i32)>, now: u64) { self.now = now; for (x, z, y) in answers { + if y == PENDING { + self.loading.insert((x, z), now); + self.loads = true; + continue; + } + self.loading.remove(&(x, z)); self.known.insert((x, z), (y, now)); } + if self.loading.len() > 4096 { + self.loading.retain(|_, t| now.saturating_sub(*t) < 400); + } self.age_heights(); if self.known.len() > 300_000 && now.saturating_sub(self.swept) >= SWEEP_EVERY { self.swept = now; @@ -318,6 +344,25 @@ impl Ground { fn due(&self, when: u64) -> bool { when == STALE || self.now.saturating_sub(when) > HEIGHT_TTL } + /// Asks the game to load the chunk (chunk x, z) for a while, if there is room; whether it did. + pub fn want_chunk(&mut self, c: (i32, i32)) -> bool { + if self.chunks.contains(&c) { + return true; + } + if self.chunks.len() >= CHUNKS_MAX { + return false; + } + self.chunks.push(c); + true + } + /// How many chunks are wanted so far this tick. + pub fn chunks_wanted(&self) -> usize { + self.chunks.len() + } + /// The chunks to ask the game to load this tick. + pub fn take_chunks(&mut self) -> Vec<(i32, i32)> { + std::mem::take(&mut self.chunks) + } pub fn take_wanted(&mut self) -> Vec<(i32, i32)> { self.asked.clear(); std::mem::take(&mut self.wanted) @@ -524,6 +569,13 @@ pub struct DroneSpec { pub ballistic: Option, } +impl DroneSpec { + /// Its size, for how thick its smoke is. + pub fn trail_scale(&self) -> f32 { + self.scale as f32 + } +} + impl DroneKind { pub const ALL: [DroneKind; 13] = [ DroneKind::Shahed, @@ -924,6 +976,11 @@ struct MissileSpec { } impl MissileKind { + /// How thick its motor smoke is (its size); `None` for an electric one. + pub fn trail_scale(self) -> Option { + let s = self.spec(); + (s.flame > 0.0).then_some(s.scale as f32) + } /// The sound of its launch. pub fn launch_sound(self) -> &'static str { match self { @@ -1199,6 +1256,11 @@ pub struct Sam { pub bursts: u32, cooldown: u32, gun_cooldown: u32, + /// Ticks until the system may take another long shot on a big radar's cue (`why::basis`). + far_cooldown: u32, + /// Ticks it has held its present missile target without launching, and a target it gave up (id, until when). + held: u32, + shy: Option<(u32, u64)>, pub reloading: u32, /// What its missiles are after, and what its guns track. pub target: Option, @@ -1270,6 +1332,9 @@ impl Sam { bursts: spec.gun.as_ref().map_or(0, |g| g.bursts), cooldown: 40, gun_cooldown: 0, + far_cooldown: 0, + held: 0, + shy: None, reloading: 0, target: None, gun_target: None, @@ -1432,6 +1497,10 @@ pub struct Sim { pub shader: bool, /// Air defence holds fire. pub hold: bool, + /// Last chance (`/airdef lastchance`): a system fires at a class outside its roles when it is about to hit. + pub last_chance: bool, + /// The heavy systems take long shots on the big radar's cue (a switch for the balance tests). + pub far_shots: bool, /// What the land hides round each system's radar, by system id. horizons: HashMap, /// Blasts to come: (tick, where, TNT, small) — a magazine cooking off. @@ -1505,6 +1574,8 @@ impl Sim { truth: false, shader: false, hold: false, + last_chance: true, + far_shots: true, horizons: HashMap::new(), later: Vec::new(), queue: Vec::new(), @@ -1907,7 +1978,7 @@ impl Sim { } let at_target = d.pos.distance(d.target) < speed.max(1.5); let in_ground = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now); - if at_target || in_ground || d.age > 8000 { + if at_target || in_ground || d.age > MAX_FLIGHT { boom.push((d.pos, spec.warhead, spec.decoy)); // Into the land or a building short of its aim: a crash, not a hit. if in_ground && d.pos.distance(d.target) >= speed.max(1.5) + 3.0 { @@ -1929,7 +2000,7 @@ impl Sim { let back = d.pos - d.vel.normalize_or_zero() * 1.5 * spec.scale; match spec.engine { Engine::Prop(..) => { - if d.age % 2 == 0 { + if d.age % 10 == 0 { out.particles("minecraft:smoke", back, 0.05, DVec3::ZERO, 0.01, 1); } } @@ -2241,6 +2312,7 @@ impl Sim { let sam = &mut self.sams[i]; sam.cooldown = sam.cooldown.saturating_sub(1); sam.gun_cooldown = sam.gun_cooldown.saturating_sub(1); + sam.far_cooldown = sam.far_cooldown.saturating_sub(1); sam.flash = sam.flash.saturating_sub(1); sam.recoil *= 0.6; if sam.radar_up() { @@ -2288,17 +2360,30 @@ impl Sim { let drone = |tr: &Track| drone_at.get(&tr.id).map(|&k| &self.drones[k]); let claimed = |d: &Drone| why::claimed(d, &missile_on, had_target); let needed = why::needed; + let opts = why::Opts { last_chance: self.last_chance, far_shots: self.far_shots }; let target = spec.missile.as_ref().map(|load| { - let ok = |tr: &Track, k: f64| { - drone(tr).is_some_and(|d| why::allowed(roles, tr, now, d) && open(tr.id) && why::in_reach(load.min, load.max, k, pos, tr.est(now))) + // A launcher with nothing to fire (reloading, empty, failed) claims no target: others take it. + if me.missiles == 0 || me.reloading > 0 || me.part(Part::Launcher) <= 0.0 { + return None; + } + let radars: Vec<&Sam> = if me.kind.far_shooter() && opts.far_shots { self.sams.iter().filter(|s| s.kind == SamKind::Radar).collect() } else { Vec::new() }; + let cued = |id: u32| !radars.is_empty() && why::radar_cue(me, &radars, id, now); + let basis = |tr: &Track, k: f64, role_in_reach: bool| { + drone(tr).filter(|_| open(tr.id)).and_then(|d| why::basis(me, load, tr, d, now, claimed(d), k, opts, cued(tr.id), role_in_reach)) }; - let keep = had_target.filter(|t| tracks.iter().any(|tr| tr.id == *t && ok(tr, 1.1))); + // A target held too long without a launch (the mount can't lay on it, the land) is let go, + // and not taken again for a while: another system gets it. + let shy = |id: u32| me.shy.is_some_and(|(s, until)| s == id && until > now); + let stuck = me.held > why::HOLD_MAX; + let keep = had_target.filter(|t| !stuck && tracks.iter().any(|tr| tr.id == *t && basis(tr, 1.1, false).is_some())); keep.or_else(|| { + let free = |tr: &Track| drone(tr).is_some_and(|d| claimed(d) < needed(d)); + // Long shots only while nothing of the system's roles is in its normal reach. + let role_in_reach = !radars.is_empty() && tracks.iter().any(|tr| free(tr) && basis(tr, 1.0, true) == Some(why::Basis::Role)); tracks .iter() - .filter(|tr| ok(tr, 1.0)) + .filter(|tr| free(tr) && !shy(tr.id) && basis(tr, 1.0, role_in_reach).is_some()) .filter_map(|tr| drone(tr).map(|d| (tr, d))) - .filter(|(_, d)| claimed(d) < needed(d)) .max_by(|a, b| a.1.threat_at(pos, a.0.est(now)).total_cmp(&b.1.threat_at(pos, b.0.est(now)))) .map(|(_, d)| d.id) }) @@ -2330,7 +2415,16 @@ impl Sim { tracks_lost += 1; } retarget(&mut missile_on, had_target, target); - self.sams[i].target = target; + let sam = &mut self.sams[i]; + if target.is_some() && target == had_target { + sam.held += 1; + } else { + if let (Some(h), true) = (had_target, sam.held > why::HOLD_MAX) { + sam.shy = Some((h, now + why::SHY_TICKS)); + } + sam.held = 0; + } + sam.target = target; } if let Some(gun_target) = gun_target { retarget(&mut guns_on, had_gun, gun_target); @@ -2364,8 +2458,24 @@ impl Sim { } else { SamState::Search }; - // Back to the rest position, under the drive's limits like any other turn. - sam.aim_towards(sam.heading, Some(spec.rest_pitch)); + // Nothing to fire at: the mount watches the nearest track of its classes (a reaction a turn + // shorter), else it goes back to the rest position, under the drive's limits like any other turn. + let (mount, _) = sam.mount(); + let watch = sam + .tracks + .iter() + .filter(|tr| tr.age(now) <= sight::MISSILE_AGE && drone_at.get(&tr.id).is_some_and(|&k| roles & self.drones[k].kind.spec().class.bit() != 0 && self.drones[k].alive())) + .min_by(|a, b| a.est(now).distance(mount).total_cmp(&b.est(now).distance(mount))) + .map(|tr| why::aim_wants(mount, tr.est(now), tr.vel, spec.gun.as_ref().map_or(5.0, |g| g.shell))); + match (watch, spec.family) { + (Some((_, yaw, pitch)), Family::Trainable) => { + let _ = pitch; + sam.aim_towards(yaw, None) + } + (Some((_, yaw, _)), Family::Vertical) if matches!(spec.muzzle, Muzzle::Cells) => sam.aim_towards(yaw, None), + (Some((_, yaw, pitch)), Family::Gun | Family::Turret) => sam.aim_towards(yaw, Some(pitch)), + _ => sam.aim_towards(sam.heading, Some(spec.rest_pitch)), + } continue; }; let (mount, _) = sam.mount(); @@ -2496,6 +2606,10 @@ impl Sim { retarget(&mut missile_on, Some(mt), None); sam.target = None; } else if ready && aligned { + // Past its normal reach: a long shot, and the next waits. + if sam.kind.far_shooter() && t.is_some_and(|t| t.distance(sam.pos) >= load.max) { + sam.far_cooldown = why::FAR_COOLDOWN; + } launches.push((load.kind, muzzle, dir, mt, sam.id)); // Its missile is on it from now, so the systems after this one this tick see // the target taken; this one's next launch looks for another. @@ -2546,8 +2660,10 @@ impl Sim { out.sound(event, muzzle, 1.0, 1.0); } else { out.particles("minecraft:flash", muzzle, 0.0, DVec3::ZERO, 0.0, 1); - out.particles("minecraft:cloud", muzzle - dir * 2.0, 1.4, DVec3::new(0.0, 0.02, 0.0), 0.06, 70); - out.particles("minecraft:campfire_cosy_smoke", muzzle - dir * 3.0, 1.2, DVec3::ZERO, 0.02, 20); + if spec.flame > 0.0 { + out.particles("minecraft:cloud", muzzle - dir * 2.0, 1.4, DVec3::new(0.0, 0.02, 0.0), 0.06, 70); + out.particles("minecraft:campfire_cosy_smoke", muzzle - dir * 3.0, 1.2, DVec3::ZERO, 0.02, 20); + } let event = if kind == MissileKind::M57e6 { "airdef:launch.pantsir" } else { "airdef:launch.patriot" }; out.sound(event, muzzle, 1.0, 0.95 + self.vis.f() as f32 * 0.1); } @@ -2573,6 +2689,8 @@ impl Sim { let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32); let (mut blocked, mut scrubbed) = (0u32, 0u32); let sams_fit: HashMap = self.sams.iter().map(|s| (s.id, 1.0 - 0.3 * (1.0 - s.health.clamp(0.0, 1.0)))).collect(); + // Where each launcher stands and how far its missiles reach: a hit past that is less likely (`why::reach_fit`). + let launchers: HashMap = self.sams.iter().filter_map(|s| Some((s.id, (s.pos, s.kind.spec().missile.as_ref()?.max)))).collect(); // The data link (#66): the track the firing system holds of each missile's target, if it is up. let links: HashMap<(u32, u32), Track> = self .missiles @@ -2680,7 +2798,7 @@ impl Sim { if (rel0 + delta * k).length() < spec.fuse && world.map(|w| w.0).or(waiting).is_none_or(|w| k <= w) { // A target faster than the interceptor is hard to hit. let ratio = (spec.speed / t.vel.length().max(0.1)).min(1.0).powf(1.5); - let fit = sams_fit.get(&m.sam).copied().unwrap_or(1.0); + let fit = sams_fit.get(&m.sam).copied().unwrap_or(1.0) * launchers.get(&m.sam).map_or(1.0, |(at, max)| why::reach_fit(*max, at.distance(m.pos))); let kill = self.rng.chance(spec.pk * fit * ratio / t.kind.spec().stealth.sqrt()); missed += u32::from(!kill); done.push((m.id, Some(t.id), kill, m.sam)); diff --git a/airdef-mod/src/sim/raid.rs b/airdef-mod/src/sim/raid.rs index 7d6c159..23cff58 100644 --- a/airdef-mod/src/sim/raid.rs +++ b/airdef-mod/src/sim/raid.rs @@ -87,6 +87,8 @@ pub(super) struct Plan { wants: Vec, sited: bool, site_wait: u32, + /// The player has been told the enemy is still preparing. + said: bool, smart: bool, approach: Approach, total: u32, @@ -115,6 +117,9 @@ pub(super) struct WaveStats { pub(super) const CELL: f64 = 16.0; pub(super) const CELLS: i32 = 140; /// The planner's work a tick (cells priced and opened): a raid takes a few ticks to plan, never a long one. +/// The longest a drone of a raid that arrives together waits to take off (ticks): a Shahed at 0.6 blocks a tick +/// takes 6700 of them to fly 4000 blocks, and the fastest missile must wait for it. +const LONGEST_WAIT: f64 = 12_000.0; pub(super) const WORK_PER_TICK: usize = 40_000; /// The planner's grid over a raid: its corner, its cells' size and how many a side. @@ -258,6 +263,9 @@ impl Sim { let cell = CELL.max(2.0 * span / CELLS as f64); let grid = Grid { min: centre - DVec3::new(span, 0.0, span), cell, size: (2.0 * span / cell) as i32 + 1 }; let mut items = Vec::new(); + // A smart raid goes for the radars too (a few strikers each); the others pick as they always did. + let radar_jobs = if smart { self.radar_strikes(&order.kinds) } else { Vec::new() }; + let mut index = 0usize; for (group, &(kind, count)) in order.kinds.iter().enumerate() { let spec = kind.spec(); for _ in 0..count { @@ -268,7 +276,12 @@ impl Sim { }; let a = sides[side]; let object = objects[(self.rng.f() * objects.len() as f64) as usize % objects.len()]; - let (target, at_sam) = self.choose_target(kind, object, order.aim); + let job = radar_jobs.get(index).copied().flatten(); + index += 1; + let (target, at_sam) = match job { + Some((at, id)) => (at, Some(id)), + None => self.choose_target(kind, object, order.aim), + }; let aim = target + DVec3::new(self.rng.s() * 4.0, 0.0, self.rng.s() * 4.0); let dist = spec.ballistic.map_or(order.dist, |far| far.max(order.dist)) + self.rng.f() * 50.0; let height = if spec.ballistic.is_some() { 10.0 } else if spec.cruise > 0.0 { spec.cruise } else { 25.0 }; @@ -298,6 +311,7 @@ impl Sim { wants, sited: false, site_wait: 0, + said: false, smart, approach: order.approach, total, @@ -356,7 +370,8 @@ impl Sim { } }); plan.waited += 1; - if plan.unanswered.is_empty() || plan.waited >= 40 { + // The game answers 512 columns a tick: a big grid (a long range) gets the time its cells take. + if plan.unanswered.is_empty() || plan.waited >= 40 + (n * n) as u32 / 300 { plan.mapped = true; } else { self.plan = Some(plan); @@ -437,7 +452,7 @@ impl Sim { if site != 0 { self.sites.job(d.id, site); } - self.queue.push((now + wait.clamp(0.0, 2400.0) as u64, d)); + self.queue.push((now + wait.clamp(0.0, LONGEST_WAIT) as u64, d)); } self.wave = Some(WaveStats { kills: self.kills, @@ -497,6 +512,31 @@ impl Sim { } } + /// The strikes a smart raid sends at the radars (they emit, so the enemy knows them whether or not they + /// fired): for the drones of `kinds` in order, the radar's place and id for those that go for one. Each radar + /// gets two or three, the fast strikers first, and all together no more than about a quarter of the raid. + pub(super) fn radar_strikes(&self, kinds: &[(DroneKind, u32)]) -> Vec> { + let mut radars: Vec<&Sam> = self.sams.iter().filter(|s| s.alive() && s.kind == SamKind::Radar).collect(); + radars.sort_by_key(|s| s.id); + let flat: Vec = kinds.iter().flat_map(|&(k, n)| std::iter::repeat_n(k, n as usize)).collect(); + let mut jobs = vec![None; flat.len()]; + if radars.is_empty() { + return jobs; + } + // Fast ones first (a stable sort keeps the raid's order among equals). + const RANK: [DroneKind; 8] = + [DroneKind::Kh22, DroneKind::Zircon, DroneKind::Iskander, DroneKind::Kinzhal, DroneKind::Kh101, DroneKind::Geran3, DroneKind::Lancet, DroneKind::Shahed]; + let mut ranked: Vec<(usize, usize)> = flat.iter().enumerate().filter_map(|(i, k)| Some((RANK.iter().position(|r| r == k)?, i))).collect(); + ranked.sort_by_key(|r| r.0); + let want = if flat.len() >= 24 { 3 } else { 2 }; + let cap = (flat.len() / 4).max(1); + for (n, &(_, i)) in ranked.iter().take((radars.len() * want).min(cap)).enumerate() { + let radar = radars[n % radars.len()]; + jobs[i] = Some((radar.pos, radar.id)); + } + jobs + } + /// What a drone goes for: an object, or (by its kind and the raid's aim) a system. Returns the /// point and the system's id. fn choose_target(&mut self, kind: DroneKind, object: DVec3, aim: Aim) -> (DVec3, Option) { @@ -525,9 +565,9 @@ impl Sim { } // Short-range systems (guns, Tor, P1-SUN) for the Lancets; long-range ones and the radars // that feed them for the fast missiles. - let guns: Vec<&Sam> = alive.iter().copied().filter(|s| s.kind.armed() && s.kind.range() <= 200.0).collect(); + let guns: Vec<&Sam> = alive.iter().copied().filter(|s| s.kind.armed() && s.kind.range() <= 300.0).collect(); let long: Vec<&Sam> = - alive.iter().copied().filter(|s| s.kind.range() >= 300.0 || s.kind == SamKind::Radar).collect(); + alive.iter().copied().filter(|s| s.kind.range() >= 450.0 || s.kind == SamKind::Radar).collect(); let choice = match kind { DroneKind::Lancet if roll < 0.75 => pick(&guns, r).or_else(|| pick(&alive, r)), DroneKind::Kh22 | DroneKind::Zircon if roll < 0.45 => pick(&long, r), @@ -621,7 +661,8 @@ impl Sim { for (t, d) in go { // A site's job leaves its site one at a time, from its stock; a destroyed or empty site cancels it. match self.site_slot(d.id) { - sites::Slot::Free => {} + // Free: not a site's job. Air: an off-map site's drone, already in flight position. + sites::Slot::Free | sites::Slot::Air => {} sites::Slot::Busy => { self.queue.push((t, d)); continue; @@ -645,9 +686,8 @@ impl Sim { } } - /// Looks for the raid's launch sites. Returns whether they are settled (placed, or given up: drones whose - /// side has no suitable ground go to another side's site, or are cancelled with a message; a raid left with - /// nothing is cancelled whole). + /// Looks for the raid's launch sites. Returns whether they are settled (placed, or off the map: a side with + /// no suitable ground still launches, from the air at the ordered range; nothing is cancelled). fn settle_sites(&mut self, plan: &mut Plan, ground: &mut Ground, out: &mut Out) -> bool { plan.site_wait += 1; let mut keep: Vec = self.sams.iter().filter(|s| s.alive()).map(|s| s.pos).collect(); @@ -655,43 +695,15 @@ impl Sim { keep.push(plan.centre); let last = plan.site_wait >= sites::SEARCH_TICKS; if !sites::search(&mut plan.wants, ground, plan.centre, &keep, self.tick, last) { + // The game loads the land of the places looked at; the player hears the enemy is preparing. + if !plan.said && plan.site_wait >= 40 { + plan.said = true; + out.messages.push("Противник готовит налёт: ищет площадки, местность подгружается".into()); + } return false; } use sites::Search; - // A side that found nothing hands its drones to a site of the same class and range on another side. - let mut moved = 0; - let mut dropped = 0; - let found: Vec = (0..plan.wants.len()).filter(|&i| plan.wants[i].state == Search::Found).collect(); - let mut keep_items = Vec::new(); - for mut it in std::mem::take(&mut plan.items) { - let w = &plan.wants[it.want]; - if w.state != Search::Found { - let (class, range) = (w.class, w.ordered); - let near = |x: &sites::Want| x.class == class && (x.ordered - range).abs() < range * 0.35 + 30.0; - match found.iter().copied().find(|&i| near(&plan.wants[i])) { - Some(i) => { - it.want = i; - moved += 1; - } - None => { - dropped += 1; - continue; - } - } - } - keep_items.push(it); - } - plan.items = keep_items; - if moved + dropped > 0 { - out.messages.push(format!( - "Нет пригодной земли для части площадок: {moved} целей ушли с другой стороны, {dropped} отменено" - )); - } - plan.total -= dropped; - if plan.items.is_empty() { - out.messages.push("Налёт отменён: нет пригодной известной земли для пусковых площадок".into()); - return false; - } + // A side that found nothing is not given up (no raid is cancelled for it): its site is off the map. // The pads, and the sites themselves; the old raid's sites go unless they still have jobs to fly. let queued: Vec = self.queue.iter().map(|q| q.1.id).collect(); self.sites.retire(&queued); @@ -703,12 +715,18 @@ impl Sim { plan.wants[it.want].jobs += 1; } let mut shortened = false; - for w in plan.wants.iter_mut().filter(|w| w.state == Search::Found && w.jobs > 0) { - let mut pos = w.found.unwrap_or_default(); + for w in plan.wants.iter_mut().filter(|w| w.jobs > 0) { + let offmap = w.state != Search::Found; + let mut pos = if offmap { + // At the ordered range over the raid's centre height: nothing stands there. + plan.centre + DVec3::new(w.bearing.sin(), 0.0, w.bearing.cos()) * w.ordered + } else { + w.found.unwrap_or_default() + }; if w.water { pos.y += sites::DECK; } - shortened |= w.range < w.ordered - if w.class == sites::SiteClass::Battery { 1.0 } else { 75.0 }; + shortened |= !offmap && w.range < w.ordered - if w.class == sites::SiteClass::Battery { 1.0 } else { 75.0 }; w.site = self.sites.add(sites::LaunchSite { id: 0, side: w.side as u8, @@ -716,12 +734,13 @@ impl Sim { bearing: w.bearing, class: w.class, pos, - range: w.range, + range: if offmap { w.ordered } else { w.range }, water: w.water, stock: w.jobs, planned: w.jobs, alive: true, next_free: 0, + offmap, }); } if shortened { @@ -731,6 +750,11 @@ impl Sim { for it in plan.items.iter_mut() { let w = &plan.wants[it.want]; let Some(site) = self.sites.get(w.site) else { continue }; + // Off the map: it starts where the plan put it, cruising. + if site.offmap { + it.d.sector = site.sector; + continue; + } let from = site.muzzle(it.pad); let spec = it.d.kind.spec(); it.from = from; @@ -746,9 +770,16 @@ impl Sim { } it.d.sector = site.sector; } - let n = self.sites.list.iter().filter(|s| plan.wants.iter().any(|w| w.site == s.id)).count(); - let barges = self.sites.list.iter().filter(|s| s.water && plan.wants.iter().any(|w| w.site == s.id)).count(); - out.messages.push(format!("Пусковые площадки противника: {n}{}", if barges > 0 { format!(" (на баржах: {barges})") } else { String::new() })); + let mine = |s: &&sites::LaunchSite| plan.wants.iter().any(|w| w.site == s.id); + let n = self.sites.list.iter().filter(mine).filter(|s| !s.offmap).count(); + let off = self.sites.list.iter().filter(mine).filter(|s| s.offmap).count(); + let barges = self.sites.list.iter().filter(mine).filter(|s| s.water).count(); + if n > 0 { + out.messages.push(format!("Пусковые площадки противника: {n}{}", if barges > 0 { format!(" (на баржах: {barges})") } else { String::new() })); + } + if off > 0 { + out.messages.push(format!("Площадок за пределами загруженной карты: {off}: оттуда пуск без площадки, с высоты полёта")); + } // Where they are is the enemy's secret, unless the sandbox's true picture is on. if self.truth { for site in self.sites.list.iter().filter(|s| plan.wants.iter().any(|w| w.site == s.id)) { @@ -848,6 +879,63 @@ mod tests { eprintln!("4000 ticks in {:?}, kills {} hits {}", t.elapsed(), sim.kills, sim.hits); assert!(sim.queue.is_empty() && !sim.drones.iter().any(|d| d.alive()), "the raid never ended"); } + /// How many of the live and queued drones are aimed at each point (the drone's own aim: its target less its navigation error). + fn aimed_at(sim: &Sim, p: DVec3) -> Vec { + sim.drones.iter().chain(sim.queue.iter().map(|q| &q.1)).filter(|d| d.alive() && (d.target - d.err).distance(p) < 6.0).map(|d| d.kind).collect() + } + + #[test] + fn a_smart_raid_goes_for_every_radar_and_the_enemys_adapting_does_not_turn_it_away() { + let mut sim = Sim::new(21); + let mut radars = Vec::new(); + for (i, at) in [(-300.0, -200.0), (250.0, -250.0), (50.0, 300.0)].into_iter().enumerate() { + let id = sim.id(); + let p = DVec3::new(at.0, 70.0, at.1); + sim.sams.push(Sam::new(id, SamKind::Radar, p, i as f64)); + radars.push((id, p)); + } + let ew = sim.id(); + sim.sams.push(Sam::new(ew, SamKind::Ew, DVec3::new(0.0, 70.0, 0.0), 0.0)); + sim.targets.push(DVec3::new(0.0, 70.0, 0.0)); + sim.target_health.push(100.0); + let mut ground = Ground::default(); + let land = Land(|_, _| 64); + let mut out = Out::default(); + for _ in 0..20 { + serve(&mut ground, &land, sim.tick); + sim.step(&mut ground, None, &mut out); + } + let mut order = crate::wave::WaveConfig::mix(36, 500.0); + order.aim = Aim::Smart; + order.approach = Approach::AllSides; + let total: u32 = order.kinds.iter().map(|k| k.1).sum(); + sim.order_raid(&order, &sim.targets.clone(), &mut out); + while sim.planning() { + serve(&mut ground, &land, sim.tick); + sim.step(&mut ground, None, &mut out); + } + let check = |sim: &Sim, when: &str| { + let mut all = 0; + for (i, (_, p)) in radars.iter().enumerate() { + let kinds = aimed_at(sim, *p); + all += kinds.len(); + assert!(kinds.len() >= 2, "{when}: radar {i} has only {kinds:?} aimed at it"); + } + all + }; + let planned = check(&sim, "planned"); + assert!(planned as u32 <= total / 4 + 6, "{planned} of {total} at the radars (the EW station and the rest may add a few)"); + // The first strikers are the fast ones. + assert!(radars.iter().any(|(_, p)| aimed_at(&sim, *p).iter().any(|k| matches!(k, DroneKind::Kh22 | DroneKind::Zircon | DroneKind::Iskander | DroneKind::Kinzhal)))); + // The enemy has learnt of the jamming station and re-aims what it can: not these. + sim.intel.revealed.push((ew, DVec3::new(0.0, 70.0, 0.0), sim.tick)); + for _ in 0..160 { + serve(&mut ground, &land, sim.tick); + sim.step(&mut ground, None, &mut out); + } + check(&sim, "after adapting"); + } + #[test] fn systems_ready_together_send_one_missile_at_a_drone() { let mut sim = Sim::new(3); diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index 1e67235..e1b8c5c 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -234,6 +234,8 @@ fn armed_strike_on_a_system_destroys_it_at_once() { let mut destroyed = 0; for seed in 1..=40 { let mut sim = Sim::new(seed); + // The strike itself is under test: the Patriot does not shoot the Lancet down on a last chance. + sim.last_chance = false; let id = sim.id(); sim.sams.push(Sam::new(id, SamKind::Patriot, centre, 0.0)); let mut ground = Ground::default(); diff --git a/airdef-mod/src/sim/sight.rs b/airdef-mod/src/sim/sight.rs index 7709427..9aa3df8 100644 --- a/airdef-mod/src/sim/sight.rs +++ b/airdef-mod/src/sim/sight.rs @@ -20,7 +20,7 @@ use super::*; const RAYS: usize = 48; const STEP: f64 = 16.0; /// How far units share their tracks (the network's data links). -pub(super) const LINK: f64 = 1500.0; +pub(super) const LINK: f64 = 2250.0; /// A launcher fires on a track no older than this (ticks); a gun needs its own radar's, newer than `GUN_AGE`. pub(super) const MISSILE_AGE: u64 = 120; pub(super) const GUN_AGE: u64 = 3; diff --git a/airdef-mod/src/sim/sites.rs b/airdef-mod/src/sim/sites.rs index 6ba05ef..495ef7d 100644 --- a/airdef-mod/src/sim/sites.rs +++ b/airdef-mod/src/sim/sites.rs @@ -2,16 +2,23 @@ //! looks for suitable ground on each side (only land the game has told of: flat, open to the sky, clear in //! front, away from the defence), and sets up a site there: a ramp for the drones, a launcher vehicle for the //! cruise missiles, a battery for the ballistic ones (at their own range). The raid's drones are jobs of the -//! sites: each one leaves its site, one at a time, from the stock the site holds. Ground nobody has told of is -//! never suitable, and nothing is force-loaded: a side without suitable ground waits for the game's answers -//! for a few seconds, and is then given up with a message. +//! sites: each one leaves its site, one at a time, from the stock the site holds. +//! +//! The land beyond the render distance is not there until the game loads it, so the search asks for the chunks +//! of the places it is looking at (`render.load-chunks`, API 22: a few, never a ring; the game generates or reads +//! them on its workers and keeps them some seconds) and waits for them: a column the game is loading is neither +//! land nor sea, it is pending. Where nothing can be loaded (a server) or nothing suitable is found within +//! `SEARCH_TICKS`, the raid is never cancelled for it: the side's site is *off the map* (`offmap`): no kit is drawn, +//! nothing can be destroyed, and its drones start in the air at the ordered range, cruising over whatever is there. use super::*; /// The most candidate places tried for one site, and how long (ticks) a raid waits for the game's answers. pub const MAX_TRIES: usize = 90; -pub const SEARCH_TICKS: u32 = 300; +pub const SEARCH_TICKS: u32 = 400; /// Candidates asked about ahead of the one being waited for. const PREFETCH: usize = 3; +/// Prefetched candidates ask for their chunks only while fewer than this many are wanted. +const PREFETCH_CHUNKS: usize = 24; /// The footprint's samples (offsets in blocks), the most the ground may differ over it, and how far from /// the defence and the objects a site must be. const FOOTPRINT: [(f64, f64); 9] = [(0.0, 0.0), (8.0, 0.0), (-8.0, 0.0), (0.0, 8.0), (0.0, -8.0), (6.0, 6.0), (6.0, -6.0), (-6.0, 6.0), (-6.0, -6.0)]; @@ -123,6 +130,9 @@ pub struct LaunchSite { pub planned: u32, pub alive: bool, pub next_free: u64, + /// Off the map: no land to stand on was found (or none can be loaded), so there is no kit; the drones + /// start in the air at `pos`'s range. + pub offmap: bool, } impl LaunchSite { @@ -161,6 +171,8 @@ pub(super) enum Slot { /// Not a site's job (a manual launch, an old save): off it goes. Free, Go, + /// An off-map site's drone: it starts in the air, without a launch. + Air, /// The site launched a moment ago. Busy, /// The site is gone or empty: the job is cancelled. @@ -215,8 +227,9 @@ impl Sites { } s.stock -= 1; s.next_free = now + s.class.cadence(); + let air = s.offmap; self.jobs.swap_remove(j); - Slot::Go + if air { Slot::Air } else { Slot::Go } } } @@ -260,6 +273,13 @@ pub(super) struct Want { pub site: u32, } +impl Want { + /// Whether the search has nothing to show for it: the site is off the map. + pub fn offmap(&self) -> bool { + self.state == Search::Failed + } +} + /// The want of this side, class and range, made if there is none yet. pub(super) fn want_index(wants: &mut Vec, centre: DVec3, side: usize, bearing: f64, class: SiteClass, range: f64, lo: f64) -> usize { if let Some(i) = wants.iter().position(|w| w.side == side && w.class == class && (w.range - range).abs() < 1.0) { @@ -269,16 +289,19 @@ pub(super) fn want_index(wants: &mut Vec, centre: DVec3, side: usize, bear let lat = DVec3::new(bearing.cos(), 0.0, -bearing.sin()); // In order: known land at the ordered range (round it for most, the ordered range for a ballistic battery), // known land nearer in steps, then a barge on open water at the ordered range, then on water nearer. + // A long range retreats in steps of at most 100 blocks and tries wider rings, not a few huge gaps. let near = |from: f64| -> Vec { - let min = if class == SiteClass::Battery { lo.min(from).max(80.0) } else { (from * 0.3).max(80.0) }; + let min = if class == SiteClass::Battery { lo.min(from).max(80.0) } else { (from * 0.3).max(80.0).max(from - 600.0) }; (1..=6).map(|i| from - (from - min) * f64::from(i) / 6.0).collect() }; + let wide = (range / 500.0).clamp(1.0, 3.0); let round: Vec = if class == SiteClass::Battery { vec![range] } else { - [0.0, 24.0, -24.0, 48.0, -48.0, 72.0, -72.0].iter().map(|d| (range + d).max(KEEP_OUT)).collect() + [0.0, 24.0, -24.0, 48.0, -48.0, 72.0, -72.0].iter().map(|d| (range + d * wide).max(KEEP_OUT)).collect() }; - let laterals: &[f64] = &[0.0, 20.0, -20.0]; + // A battery may stand where another site of the side does (its range is its own): wider to the sides. + let laterals: &[f64] = if class == SiteClass::Battery { &[0.0, 20.0, -20.0, 60.0, -60.0] } else { &[0.0, 20.0, -20.0] }; let mut cands = Vec::new(); for water in [false, true] { for r in round.iter().copied().chain(near(range)) { @@ -300,9 +323,25 @@ enum Verdict { Good(DVec3, bool), } +/// The chunks a site at `p` needs: its footprint and the way ahead, up to 14 blocks. +fn chunks_of(p: DVec3, inward: DVec3) -> Vec<(i32, i32)> { + let mut out: Vec<(i32, i32)> = Vec::with_capacity(6); + for (dx, dz) in FOOTPRINT { + for k in [0.0, 14.0] { + let c = (((p.x + dx + inward.x * k).floor() as i32) >> 4, ((p.z + dz + inward.z * k).floor() as i32) >> 4); + if !out.contains(&c) { + out.push(c); + } + } + } + out +} + /// Whether a site fits at `p` (x, z): every sample of its footprint is land the game told of and within /// `FLAT` blocks of the others, the sky above is open, the way ahead is clear, and nothing of the defence is near. -fn judge(ground: &mut Ground, p: DVec3, inward: DVec3, keep: &[DVec3], now: u64) -> Verdict { +/// A column or chunk the game is still loading makes it `Pending`, and the chunks are asked for (unless this is +/// a look ahead at a later candidate, `ahead`, and many are wanted already). +fn judge(ground: &mut Ground, p: DVec3, inward: DVec3, keep: &[DVec3], now: u64, ahead: bool) -> Verdict { if p.x.abs() > 29_000_000.0 || p.z.abs() > 29_000_000.0 || keep.iter().any(|k| horizontal(*k - p).length() < KEEP_OUT) { return Verdict::Bad; } @@ -318,6 +357,22 @@ fn judge(ground: &mut Ground, p: DVec3, inward: DVec3, keep: &[DVec3], now: u64) } } } + // The way ahead must be loaded too before it is swept (an unknown stretch is answered "no chunk" and kept + // for a while): a column in the middle of each chunk it needs says so. + let chunks = chunks_of(p, inward); + if !pending { + for &(cx, cz) in &chunks { + pending |= ground.column(cx * 16 + 8, cz * 16 + 8, 256).is_none(); + } + } + if pending { + if !ahead || ground.chunks_wanted() < PREFETCH_CHUNKS { + for c in chunks { + ground.want_chunk(c); + } + } + return Verdict::Pending; + } if pending { return Verdict::Pending; } @@ -334,7 +389,9 @@ fn judge(ground: &mut Ground, p: DVec3, inward: DVec3, keep: &[DVec3], now: u64) match ground.world.sweep(c + DVec3::Y * 0.5, c - DVec3::Y * 0.5, false, now) { world::Sweep::Hit { .. } => solid += 1, world::Sweep::Clear => {} - world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => return Verdict::Pending, + world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => { + return Verdict::Pending; + } world::Sweep::Unknown { .. } => return Verdict::Bad, } } @@ -346,7 +403,9 @@ fn judge(ground: &mut Ground, p: DVec3, inward: DVec3, keep: &[DVec3], now: u64) for s in [up, ahead] { match s { world::Sweep::Clear => {} - world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => return Verdict::Pending, + world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => { + return Verdict::Pending; + } world::Sweep::Unknown { .. } | world::Sweep::Hit { .. } => return Verdict::Bad, } } @@ -373,7 +432,7 @@ pub(super) fn search(wants: &mut [Want], ground: &mut Ground, centre: DVec3, kee break; } let (cand, water) = w.cands[w.cursor]; - match judge(ground, cand, inward, &keep, now) { + match judge(ground, cand, inward, &keep, now, false) { Verdict::Bad => w.cursor += 1, // Land when land was asked for, a barge when water was. Verdict::Good(_, got) if got != water => w.cursor += 1, @@ -387,7 +446,7 @@ pub(super) fn search(wants: &mut [Want], ground: &mut Ground, centre: DVec3, kee Verdict::Pending => { for k in 1..=PREFETCH { if let Some(&(c, _)) = w.cands.get(w.cursor + k) { - let _ = judge(ground, c, inward, &keep, now); + let _ = judge(ground, c, inward, &keep, now, true); } } if last { @@ -414,7 +473,8 @@ impl Sim { /// Takes site `id` out of play (destroyed): its stock is gone and its queued jobs are cancelled. Returns how /// many. (The counter-strike, #101, uses this.) pub fn destroy_site(&mut self, id: u32) -> u32 { - let Some(s) = self.sites.list.iter_mut().find(|s| s.id == id) else { return 0 }; + // An off-map site is nowhere to be found: nothing to destroy. + let Some(s) = self.sites.list.iter_mut().find(|s| s.id == id && !s.offmap) else { return 0 }; s.alive = false; s.stock = 0; let ids: Vec = self.sites.jobs.iter().filter(|j| j.1 == id).map(|j| j.0).collect(); @@ -431,7 +491,7 @@ impl Sim { /// Draws every site's kit: a ramp or launcher per pad in use, turned to the way the launches go. pub(super) fn draw_sites(&self, out: &mut Out) { - for s in self.sites.list.iter().filter(|s| s.alive) { + for s in self.sites.list.iter().filter(|s| s.alive && !s.offmap) { let rot = DQuat::from_rotation_y(heading_of(s.inward())); let sc = s.class.scale(); if s.water { @@ -651,6 +711,7 @@ mod tests { planned: 5, alive: true, next_free: 0, + offmap: false, }); for i in 0..5 { let site = sim.sites.get(id).unwrap().clone(); @@ -689,9 +750,16 @@ mod tests { assert_eq!(sim.launched as usize + n, 14); } + /// Runs the sim `ticks` without the game answering anything, and counts what left. + fn run_blind(sim: &mut Sim, out: &mut Out, ground: &mut Ground, ticks: u32) { + for _ in 0..ticks { + sim.step(ground, None, out); + } + } + #[test] - fn no_suitable_ground_means_a_clear_refusal() { - // Never answered: unknown ground is not suitable, and nothing is launched. + fn no_suitable_ground_means_the_raid_launches_off_the_map() { + // Never answered: unknown ground is not suitable, but nothing is cancelled: every side starts off the map. let (mut ground, mut out) = (Ground::default(), Out::default()); let mut sim = Sim::new(3); sim.order_raid(&order(Approach::TwoSectors, 300.0), &[DVec3::new(0.0, 70.0, 0.0)], &mut out); @@ -701,20 +769,146 @@ mod tests { n += 1; assert!(n <= SEARCH_TICKS + 5, "a raid waits for ever"); } - assert!(sim.queue.is_empty() && sim.sites.list.is_empty()); - assert!(out.messages.iter().any(|m| m.contains("площад")), "{:?}", out.messages); - assert!(!sim.drones.iter().any(|d| d.alive())); - // A cliff face everywhere (too steep to stand on) is refused as well, and a sea (no land) too. + assert_eq!(sim.queue.len() + sim.launched as usize, 14); + assert!(!sim.sites.list.is_empty() && sim.sites.list.iter().all(|s| s.offmap), "{:?}", sim.sites.list); + let said: Vec<_> = out.messages.iter().filter(|m| m.contains("за пределами")).collect(); + assert_eq!(said.len(), 1, "{:?}", out.messages); + assert!(!out.messages.iter().any(|m| m.contains("отмен")), "{:?}", out.messages); + run_blind(&mut sim, &mut out, &mut ground, 400); + assert_eq!(sim.launched, 14, "{:?}", out.messages); + assert!(sim.queue.is_empty()); + // No kit, and nothing to destroy. + let mut o2 = Out::default(); + sim.draw_sites(&mut o2); + assert!(o2.models.is_empty()); + let id = sim.sites.list[0].id; + assert_eq!(sim.destroy_site(id), 0); + // A cliff face everywhere (too steep to stand on) and a sea (no land) launch off the map as well. for steep in [true, false] { let land = Land(move |x, _| if steep { x * 4 } else { -100 }); let (mut ground, mut out) = (Ground::default(), Out::default()); let mut sim = Sim::new(3); plan(&mut sim, &mut ground, &land, &order(Approach::Sector, 300.0), &mut out); - assert!(sim.queue.is_empty() && sim.sites.list.is_empty(), "steep {steep}"); - assert!(out.messages.iter().any(|m| m.contains("площад"))); + assert!(!sim.sites.list.is_empty() && sim.sites.list.iter().all(|s| s.offmap), "steep {steep}"); + assert_eq!(sim.queue.len() + sim.launched as usize, 14, "steep {steep}"); + assert!(!out.messages.iter().any(|m| m.contains("отмен")), "{:?}", out.messages); } } + /// Chunks that load on request: a column of a chunk asked for answers `PENDING` until `delay` ticks + /// after the first request, and a chunk never asked for is not there (-64). + struct Lazy { + height: i32, + delay: u64, + asked: std::cell::RefCell>, + most: usize, + } + impl Lazy { + fn loaded(&self, c: (i32, i32), now: u64) -> Option { + self.asked.borrow().get(&c).map(|&t| now >= t + self.delay) + } + fn tick(&mut self, ground: &mut Ground, now: u64) { + let chunks = ground.take_chunks(); + self.most = self.most.max(chunks.len()); + for c in chunks { + self.asked.borrow_mut().entry(c).or_insert(now); + } + let cols = ground + .take_wanted() + .into_iter() + .map(|(x, z)| { + let y = match self.loaded((x >> 4, z >> 4), now) { + Some(true) => self.height, + Some(false) => PENDING, + None => -64, + }; + (x, z, y) + }) + .collect(); + ground.answer(cols, now); + let secs = ground.world.take_wanted(now); + let view = Seen { lazy: self, now }; + ground.world.serve(&view, secs, now); + } + } + struct Seen<'a> { + lazy: &'a Lazy, + now: u64, + } + impl world::WorldSource for Seen<'_> { + fn solid(&self, x: i32, y: i32, z: i32) -> Option { + self.lazy.loaded((x >> 4, z >> 4), self.now).filter(|&l| l).map(|_| y < self.lazy.height) + } + } + + #[test] + fn a_column_pending_for_chunk_load_waits_then_finds_a_site() { + let (mut ground, mut out) = (Ground::default(), Out::default()); + let mut lazy = Lazy { height: 64, delay: 50, asked: Default::default(), most: 0 }; + let mut sim = Sim::new(21); + sim.order_raid(&order(Approach::TwoSectors, 1500.0), &[DVec3::new(0.0, 70.0, 0.0)], &mut out); + let mut n = 0; + while sim.planning() { + lazy.tick(&mut ground, sim.tick); + sim.step(&mut ground, None, &mut out); + n += 1; + assert!(n < SEARCH_TICKS + 5, "never settled"); + } + assert!(n > 50, "settled in {n} ticks, before any chunk could be there"); + assert!(sim.sites.list.iter().all(|s| !s.offmap), "{:?} {:?}", sim.sites.list, out.messages); + assert!(!sim.sites.list.is_empty()); + assert!(lazy.most <= 48 && lazy.most > 0, "{} chunks wanted at once", lazy.most); + assert!(out.messages.iter().any(|m| m.contains("готовит")), "{:?}", out.messages); + assert!(!out.messages.iter().any(|m| m.contains("отмен") || m.contains("за пределами")), "{:?}", out.messages); + // Far ones stand where the raid was ordered (1500 out), not nearer. + assert!(sim.sites.list.iter().any(|s| s.range > 1400.0), "{:?}", sim.sites.list.iter().map(|s| s.range).collect::>()); + } + + #[test] + fn a_game_that_never_loads_gets_the_same_launch_off_the_map() { + // The game answers "no chunk" (-64) for what was not asked about, and never loads what was (a server). + let (mut ground, mut out) = (Ground::default(), Out::default()); + let mut sim = Sim::new(5); + sim.order_raid(&order(Approach::Sector, 2500.0), &[DVec3::new(0.0, 70.0, 0.0)], &mut out); + let mut n = 0; + while sim.planning() { + let _ = ground.take_chunks(); + let cols = ground.take_wanted().into_iter().map(|(x, z)| (x, z, -64)).collect(); + ground.answer(cols, sim.tick); + sim.step(&mut ground, None, &mut out); + n += 1; + assert!(n < SEARCH_TICKS + 5); + } + assert!(sim.sites.list.iter().all(|s| s.offmap)); + assert_eq!(sim.queue.len() + sim.launched as usize, 14); + // Where they start: at the ordered range, not at the player. + assert!(sim.queue.iter().all(|q| horizontal(q.1.pos - DVec3::new(0.0, 70.0, 0.0)).length() > 2400.0)); + } + + #[test] + fn a_raid_ordered_4000_out_reaches_its_target() { + let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default()); + let mut sim = Sim::new(9); + let o = RaidOrder { kinds: vec![(DroneKind::Shahed, 2), (DroneKind::Kh101, 1), (DroneKind::Iskander, 1)], approach: Approach::Sector, dist: 4000.0, aim: Aim::Objects }; + let target = DVec3::new(0.0, 70.0, 0.0); + sim.order_raid(&o, &[target], &mut out); + let mut closest = [f64::MAX; 4]; + for _ in 0..16_000 { + step(&mut sim, &mut ground, &land, &mut out); + for d in sim.drones.iter().filter(|d| d.alive()) { + let k = match d.kind { + DroneKind::Shahed => 0, + DroneKind::Kh101 => 1, + _ => 2, + }; + closest[k] = closest[k].min(horizontal(d.pos - target).length()); + } + } + assert_eq!(sim.launched, 4, "{:?}", out.messages); + assert!(closest.iter().take(3).all(|&c| c < 120.0), "closest approaches {closest:?}"); + assert!(sim.drones.iter().all(|d| !d.alive()), "still flying"); + } + /// Land inside a radius of the origin (surface 64), open water outside it (surface 63 over a floor at 50): /// the game's height over water is the surface, and the blocks under it are not solid. struct Island(f64); diff --git a/airdef-mod/src/sim/terrain.rs b/airdef-mod/src/sim/terrain.rs index a2ad525..6040450 100644 --- a/airdef-mod/src/sim/terrain.rs +++ b/airdef-mod/src/sim/terrain.rs @@ -79,7 +79,9 @@ impl Ground { Some((y > -64).then_some(f64::from(y))) } None => { - if self.wanted.len() < cap && self.asked.insert((x, z)) { + // A chunk the game is loading is asked about again in a few ticks, not every one. + let loading = self.loading.get(&(x, z)).is_some_and(|&t| self.now.saturating_sub(t) < LOAD_RECHECK); + if !loading && self.wanted.len() < cap && self.asked.insert((x, z)) { self.wanted.push((x, z)); } None diff --git a/airdef-mod/src/sim/units.rs b/airdef-mod/src/sim/units.rs index c585e39..0788a42 100644 --- a/airdef-mod/src/sim/units.rs +++ b/airdef-mod/src/sim/units.rs @@ -192,7 +192,7 @@ impl SamKind { const S: SamSpec = SamSpec { name: "Patriot", key: "patriot", - missile: Some(MissileLoad { kind: MissileKind::Pac3, magazine: 4, min: 10.0, max: 300.0, interval: 16 }), + missile: Some(MissileLoad { kind: MissileKind::Pac3, magazine: 4, min: 10.0, max: 450.0, interval: 16 }), gun: None, reload: 220, scale: 1.3, @@ -208,7 +208,7 @@ impl SamKind { mount: (0.69, 1.2), pivot: DVec3::new(0.0, -0.5, 1.375), muzzle: Muzzle::Patriot, - sensor: 390.0, + sensor: 585.0, mast: 5.0, }; &S @@ -218,7 +218,7 @@ impl SamKind { name: "Гепард", key: "gepard", missile: None, - gun: Some(GunSpec { range: 90.0, track: 130.0, p: 0.3, bursts: 160, shell: 10.0, slew: (0.22, 0.16) }), + gun: Some(GunSpec { range: 135.0, track: 195.0, p: 0.3, bursts: 160, shell: 10.0, slew: (0.22, 0.16) }), reload: 160, scale: 1.25, rest_pitch: 0.0, @@ -234,7 +234,7 @@ impl SamKind { mount: (1.55, 0.25), pivot: DVec3::ZERO, muzzle: Muzzle::None, - sensor: 170.0, + sensor: 255.0, mast: 3.0, }; &S @@ -243,8 +243,8 @@ impl SamKind { const S: SamSpec = SamSpec { name: "Панцирь-С1", key: "pantsir", - missile: Some(MissileLoad { kind: MissileKind::M57e6, magazine: 12, min: 25.0, max: 150.0, interval: 14 }), - gun: Some(GunSpec { range: 60.0, track: 90.0, p: 0.25, bursts: 140, shell: 9.0, slew: (0.25, 0.18) }), + missile: Some(MissileLoad { kind: MissileKind::M57e6, magazine: 12, min: 25.0, max: 225.0, interval: 14 }), + gun: Some(GunSpec { range: 90.0, track: 135.0, p: 0.25, bursts: 140, shell: 9.0, slew: (0.25, 0.18) }), reload: 280, scale: 1.3, rest_pitch: 0.0, @@ -260,7 +260,7 @@ impl SamKind { mount: (1.35, 0.69), pivot: DVec3::ZERO, muzzle: Muzzle::Pantsir, - sensor: 195.0, + sensor: 292.0, mast: 4.0, }; &S @@ -269,7 +269,7 @@ impl SamKind { const S: SamSpec = SamSpec { name: "С-400", key: "s400", - missile: Some(MissileLoad { kind: MissileKind::M48n6, magazine: 4, min: 30.0, max: 480.0, interval: 20 }), + missile: Some(MissileLoad { kind: MissileKind::M48n6, magazine: 4, min: 30.0, max: 720.0, interval: 20 }), gun: None, reload: 420, scale: 1.5, @@ -286,7 +286,7 @@ impl SamKind { mount: (0.7, 1.45), pivot: TUBE_PIVOT, muzzle: Muzzle::Grid(2, 2, 7.0), - sensor: 624.0, + sensor: 936.0, mast: 5.0, }; &S @@ -295,7 +295,7 @@ impl SamKind { const S: SamSpec = SamSpec { name: "С-500", key: "s500", - missile: Some(MissileLoad { kind: MissileKind::M77n6, magazine: 4, min: 40.0, max: 700.0, interval: 20 }), + missile: Some(MissileLoad { kind: MissileKind::M77n6, magazine: 4, min: 40.0, max: 1050.0, interval: 20 }), gun: None, reload: 520, scale: 1.6, @@ -312,7 +312,7 @@ impl SamKind { mount: (0.7, 1.45), pivot: TUBE_PIVOT, muzzle: Muzzle::Grid(2, 1, 9.5), - sensor: 910.0, + sensor: 1365.0, mast: 5.0, }; &S @@ -321,7 +321,7 @@ impl SamKind { const S: SamSpec = SamSpec { name: "IRIS-T SLM", key: "irist", - missile: Some(MissileLoad { kind: MissileKind::IrisT, magazine: 8, min: 15.0, max: 400.0, interval: 10 }), + missile: Some(MissileLoad { kind: MissileKind::IrisT, magazine: 8, min: 15.0, max: 600.0, interval: 10 }), gun: None, reload: 300, scale: 1.4, @@ -338,7 +338,7 @@ impl SamKind { mount: (0.7, 1.45), pivot: TUBE_PIVOT, muzzle: Muzzle::Grid(4, 2, 4.0), - sensor: 520.0, + sensor: 780.0, mast: 5.0, }; &S @@ -347,7 +347,7 @@ impl SamKind { const S: SamSpec = SamSpec { name: "NASAMS", key: "nasams", - missile: Some(MissileLoad { kind: MissileKind::Amraam, magazine: 6, min: 10.0, max: 300.0, interval: 14 }), + missile: Some(MissileLoad { kind: MissileKind::Amraam, magazine: 6, min: 10.0, max: 450.0, interval: 14 }), gun: None, reload: 260, scale: 1.3, @@ -364,7 +364,7 @@ impl SamKind { mount: (0.62, 0.4), pivot: TUBE_PIVOT, muzzle: Muzzle::Grid(3, 2, 4.0), - sensor: 390.0, + sensor: 585.0, mast: 5.0, }; &S @@ -373,7 +373,7 @@ impl SamKind { const S: SamSpec = SamSpec { name: "Бук-М2", key: "buk", - missile: Some(MissileLoad { kind: MissileKind::M9m317, magazine: 4, min: 20.0, max: 450.0, interval: 18 }), + missile: Some(MissileLoad { kind: MissileKind::M9m317, magazine: 4, min: 20.0, max: 675.0, interval: 18 }), gun: None, reload: 400, scale: 1.4, @@ -389,7 +389,7 @@ impl SamKind { mount: (1.55, 0.2), pivot: DVec3::ZERO, muzzle: Muzzle::Rails(&[-9.0, -3.0, 13.0, 19.0]), - sensor: 585.0, + sensor: 878.0, mast: 4.0, }; &S @@ -398,7 +398,7 @@ impl SamKind { const S: SamSpec = SamSpec { name: "Тор-М2", key: "tor", - missile: Some(MissileLoad { kind: MissileKind::M9m338, magazine: 8, min: 8.0, max: 160.0, interval: 8 }), + missile: Some(MissileLoad { kind: MissileKind::M9m338, magazine: 8, min: 8.0, max: 240.0, interval: 8 }), gun: None, reload: 300, scale: 1.3, @@ -409,7 +409,7 @@ impl SamKind { mount: (1.5, 0.0), pivot: DVec3::ZERO, muzzle: Muzzle::Cells, - sensor: 210.0, + sensor: 315.0, mast: 3.5, }; &S @@ -418,7 +418,7 @@ impl SamKind { const S: SamSpec = SamSpec { name: "P1-SUN", key: "p1sun", - missile: Some(MissileLoad { kind: MissileKind::P1sun, magazine: 8, min: 5.0, max: 450.0, interval: 12 }), + missile: Some(MissileLoad { kind: MissileKind::P1sun, magazine: 8, min: 5.0, max: 675.0, interval: 12 }), gun: None, reload: 240, scale: 1.1, @@ -454,7 +454,7 @@ impl SamKind { mount: (0.0, 0.0), pivot: DVec3::ZERO, muzzle: Muzzle::None, - sensor: 1700.0, + sensor: 2550.0, mast: 16.0, }; &S diff --git a/airdef-mod/src/sim/why.rs b/airdef-mod/src/sim/why.rs index 1cf1987..da9d879 100644 --- a/airdef-mod/src/sim/why.rs +++ b/airdef-mod/src/sim/why.rs @@ -45,9 +45,115 @@ pub(super) fn claimed(d: &Drone, missile_on: &HashMap, had_target: Opt d.engaged + on } -/// Ballistic missiles get a pair; anything else one. +/// Ticks until the target reaches its aim at its present speed. +pub(super) fn arrival(d: &Drone) -> f64 { + d.pos.distance(d.target) / d.vel.length().max(0.1) +} + +/// Shoot-look-shoot that grows with urgency: ballistic missiles get a pair, anything else one; a target +/// this close to its aim (ticks) has one more interceptor allowed on it at once, closer still another, and in the last seconds a fourth. +pub(super) const URGENT: f64 = 240.0; +pub(super) const DESPERATE: f64 = 110.0; +pub(super) const FINAL: f64 = 60.0; + pub(super) fn needed(d: &Drone) -> u32 { - if d.kind.spec().class == Class::Ballistic { 2 } else { 1 } + let base = if d.kind.spec().class == Class::Ballistic { 2 } else { 1 }; + let t = arrival(d); + base + u32::from(t < URGENT) + u32::from(t < DESPERATE) + u32::from(t < FINAL) +} + +/// Ticks a launcher may hold a target without launching before it lets go, and how long it then keeps away from it. +pub(super) const HOLD_MAX: u32 = 24; +pub(super) const SHY_TICKS: u64 = 120; + +// ---------------------------------------------------------------- the grounds to launch on + +/// Last chance (`/airdef lastchance`): a target of a class the system does not engage that is about to +/// hit, with nobody's interceptor on it, is shot at anyway; a system told to hold fire (no roles) keeps to it. +pub(super) const LAST_CHANCE_TICKS: f64 = 200.0; + +pub(super) fn last_chance(on: bool, roles: u8, d: &Drone, claimed: u32) -> bool { + on && roles != 0 && roles & d.kind.spec().class.bit() == 0 && claimed == 0 && arrival(d) < LAST_CHANCE_TICKS +} + +/// The big radar's cue (heavy systems only): they may fire out to `FAR_K` × max at a target the radar holds. +pub(super) const FAR_K: f64 = 1.6; +/// Ticks between one system's long shots; the share of the magazine it keeps for them; the radar track's age. +pub(super) const FAR_COOLDOWN: u32 = 300; +pub(super) const FAR_RADAR_AGE: u64 = 150; + +impl SamKind { + /// Whether the kind takes long shots on a big radar's data. + pub(super) fn far_shooter(self) -> bool { + matches!(self, SamKind::S400 | SamKind::S500 | SamKind::Patriot) + } +} + +/// A live big radar in data-link reach of `sam` that measured `id` recently. +pub(super) fn radar_cue(sam: &Sam, radars: &[&Sam], id: u32, now: u64) -> bool { + radars.iter().any(|r| { + r.alive() + && !r.moving() + && r.radar_up() + && r.pos.distance(sam.pos) <= r.link_range().min(sam.link_range()) + && r.track(id).is_some_and(|t| t.own_tick > 0 && t.age(now) <= FAR_RADAR_AGE) + }) +} + +/// The ground beyond `max`: a long shot is allowed (the system, its launcher and magazine, the flag, a cue). +pub(super) fn far_blocks(on: bool, sam: &Sam, load: &units::MissileLoad, cued: bool, in_reach_of_any: bool) -> u8 { + (if on && sam.kind.far_shooter() { 0 } else { FAR_OFF }) + | (if cued { 0 } else { FAR_NO_CUE }) + | (if sam.far_cooldown == 0 { 0 } else { FAR_WAIT }) + | (if sam.missiles * 2 >= load.magazine { 0 } else { FAR_LOW }) + | (if in_reach_of_any { FAR_BUSY } else { 0 }) +} +pub(super) const FAR_OFF: u8 = 1; +pub(super) const FAR_NO_CUE: u8 = 2; +pub(super) const FAR_WAIT: u8 = 4; +pub(super) const FAR_LOW: u8 = 8; +pub(super) const FAR_BUSY: u8 = 16; + +/// Why a system may launch at a target: its role, the last chance, or a long shot on the radar's cue. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum Basis { + Role, + LastChance, + FarShot, +} + +/// What is switched on beyond the roles (the sim's settings). +#[derive(Clone, Copy)] +pub(super) struct Opts { + pub last_chance: bool, + pub far_shots: bool, +} + +/// Whether, and on what basis, `sam` may launch at `d` on `track`: `k` stretches the reach (a held target +/// may stray); `claimed` is the interceptors others have on it; `cued` that a big radar holds it; and +/// `role_in_reach` that the system has a target of its roles in range anyway (no long shots then). +#[allow(clippy::too_many_arguments)] +pub(super) fn basis(sam: &Sam, load: &units::MissileLoad, track: &Track, d: &Drone, now: u64, claimed: u32, k: f64, opts: Opts, cued: bool, role_in_reach: bool) -> Option { + let blocks = target_blocks(sam.roles, Some(track), now, d); + if blocks & !ROLE != 0 { + return None; + } + let est = track.est(now); + let reach = reach_blocks(load.min, load.max, k, sam.pos, est); + if blocks & ROLE == 0 { + if reach == 0 { + return Some(Basis::Role); + } + let far = reach == TOO_FAR && reach_blocks(load.min, load.max, FAR_K, sam.pos, est) == 0; + return (far && far_blocks(opts.far_shots, sam, load, cued, role_in_reach) == 0).then_some(Basis::FarShot); + } + (reach == 0 && last_chance(opts.last_chance, sam.roles, d, claimed)).then_some(Basis::LastChance) +} + +/// The kill chance of an interceptor that met its target `dist` from the launcher whose reach is `max`: +/// whole up to `max`, falling linearly to half at `FAR_K` × max. +pub(super) fn reach_fit(max: f64, dist: f64) -> f64 { + 1.0 - 0.5 * ((dist - max) / (max * (FAR_K - 1.0))).clamp(0.0, 1.0) } /// A gun's candidates: measured by the system's own radar a moment ago and within the gun's tracking range @@ -209,7 +315,7 @@ impl Sim { let subject = sam.target.or(sam.gun_target).and_then(drone_at).or_else(|| { self.drones .iter() - .filter(|d| d.alive() && sam.roles & d.kind.spec().class.bit() != 0) + .filter(|d| d.alive() && (sam.roles & d.kind.spec().class.bit() != 0 || last_chance(self.last_chance, sam.roles, d, claimed(d, &missile_on, sam.target)))) .max_by(|a, b| a.threat(sam.pos).total_cmp(&b.threat(sam.pos))) }); let Some(t) = subject else { @@ -227,10 +333,21 @@ impl Sim { // Where the system believes the target is: its track's forecast (the true place only when it has no track). let est = track.map_or(t.pos, |tr| tr.est(now)); let evel = track.map_or(t.vel, |tr| tr.vel); - v.push(check( - blocks & ROLE == 0, - format!("роль: {} {} [{}]", t.kind.spec().class.name(), if blocks & ROLE == 0 { "в ролях" } else { "не в ролях" }, roles_text(sam.roles)), - )); + let first_claim = claimed(t, &missile_on, sam.target); + let chance = blocks & ROLE != 0 && last_chance(self.last_chance, sam.roles, t, first_claim); + let role_text = if blocks & ROLE == 0 { + format!("роль: {} в ролях [{}]", t.kind.spec().class.name(), roles_text(sam.roles)) + } else if chance { + format!( + "последний шанс: {} не в ролях [{}], но до цели {:.0} с, перехватчиков на ней нет", + t.kind.spec().class.name(), + roles_text(sam.roles), + arrival(t) / 20.0 + ) + } else { + format!("роль: {} не в ролях [{}]", t.kind.spec().class.name(), roles_text(sam.roles)) + }; + v.push(check(blocks & ROLE == 0 || chance, role_text)); let detect = match (track, sam.own.contains(&t.id)) { (Some(tr), own) => { let kind = if own { "своей РЛС" } else { "по сети (стволы не стреляют)" }; @@ -277,17 +394,31 @@ impl Sim { let mut fires = false; if let Some(load) = &spec.missile { let reach = reach_blocks(load.min, load.max, 1.0, sam.pos, est); - v.push(check( - reach == 0, - format!( - "ракеты: дальность {}–{}, цель на {}{}", - crate::panel::distance(load.min), - crate::panel::distance(load.max), - crate::panel::distance(dist), - if reach & TOO_CLOSE != 0 { " — слишком близко" } else if reach & TOO_FAR != 0 { " — слишком далеко" } else { "" } - ), - )); - let free = claimed(t, &missile_on, sam.target); + // Beyond the normal reach, a heavy system may fire on a big radar's cue. + let radars: Vec<&Sam> = self.sams.iter().filter(|s| s.kind == SamKind::Radar).collect(); + let cued = sam.kind.far_shooter() && radar_cue(sam, &radars, t.id, now); + let role_in_reach = self.drones.iter().any(|o| { + sam.track(o.id).is_some_and(|tr| allowed(sam.roles, tr, now, o) && in_reach(load.min, load.max, 1.0, sam.pos, tr.est(now)) && claimed(o, &missile_on, sam.target) < needed(o)) + }); + let far = reach == TOO_FAR && reach_blocks(load.min, load.max, FAR_K, sam.pos, est) == 0; + let far_ok = far && track.is_some_and(|tr| basis(sam, load, tr, t, now, 0, 1.0, Opts { last_chance: self.last_chance, far_shots: self.far_shots }, cued, role_in_reach) == Some(Basis::FarShot)); + if far { + let fb = far_blocks(self.far_shots, sam, load, cued, role_in_reach); + let mut no = Vec::new(); + for (bit, name) in [ + (FAR_OFF, "не для этого комплекса"), + (FAR_NO_CUE, "нет свежего трека большой РЛС"), + (FAR_WAIT, "дальний пуск уже был недавно"), + (FAR_LOW, "меньше половины боезапаса"), + (FAR_BUSY, "есть цель в обычной дальности"), + ] { + if fb & bit != 0 { + no.push(name); + } + } + v.push(check(far_ok, if far_ok { format!("дальний пуск по РЛС: цель на {} дальше обычных {}", crate::panel::distance(dist), crate::panel::distance(load.max)) } else { format!("дальний пуск по РЛС не разрешён: {}", no.join(", ")) })); + } + let free = first_claim; let need = needed(t); v.push(check(free < need, format!("цель занята перехватчиками: {free} из {need}{}", if free < need { "" } else { " — другие уже на ней" }))); let mb = missile_blocks(sam); @@ -320,7 +451,8 @@ impl Sim { }, )); } - fires |= reach == 0 && free < need && mb == 0 && aligned && blocks == 0 && land == 0; + let grounds = blocks & !ROLE == 0 && (blocks & ROLE == 0 || chance) && (reach == 0 || far_ok); + fires |= grounds && free < need && mb == 0 && aligned && land == 0; } if let Some(gun) = &spec.gun { let on_it = guns_on.get(&t.id).copied().unwrap_or(0) - u32::from(sam.gun_target == Some(t.id)); @@ -379,4 +511,175 @@ mod tests { sim.sams[0].roles = 0xFF; assert!(sim.why(0).iter().any(|c| c.text.starts_with("роль"))); } + + use super::sites::testland::{Land, serve}; + + fn setup(kind: SamKind) -> (Sim, Ground, Land i32>, Out) { + let mut sim = Sim::new(5); + let id = sim.id(); + sim.sams.push(Sam::new(id, kind, DVec3::new(0.0, 70.0, 0.0), 0.0)); + (sim, Ground::default(), Land(|_, _| 64), Out::default()) + } + + /// Steps `ticks` or until something is fired; returns the drone's ticks to its aim at the first launch. + fn until_fired(sim: &mut Sim, ground: &mut Ground, land: &Land i32>, out: &mut Out, ticks: u32) -> Option { + for _ in 0..ticks { + serve(ground, land, sim.tick); + sim.step(ground, None, out); + if sim.sams[0].fired > 0 { + return sim.drones.first().map(arrival); + } + } + None + } + + /// A Shahed `blocks` short of its aim, which is `80` out from the system, coming in along z. + fn shahed_at(sim: &mut Sim, blocks: f64) { + sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, -80.0 - blocks), DVec3::Z, DVec3::new(0.0, 70.0, -80.0)); + } + + #[test] + fn last_chance_fires_at_a_class_out_of_roles_only_when_it_is_about_to_hit() { + // The S-400 does not engage drones by default. + assert_eq!(SamKind::S400.spec().roles & Class::Drone.bit(), 0); + let (mut sim, mut ground, land, mut out) = setup(SamKind::S400); + shahed_at(&mut sim, 60.0); + let at = until_fired(&mut sim, &mut ground, &land, &mut out, 140).expect("the S-400 never fired on a Shahed about to hit"); + assert!(at < LAST_CHANCE_TICKS, "fired with {at:.0} ticks to go"); + // The diagnostic names why: the last chance. + let (mut sim, mut ground, land, mut out) = setup(SamKind::S400); + shahed_at(&mut sim, 100.0); + for _ in 0..30 { + serve(&mut ground, &land, sim.tick); + sim.step(&mut ground, None, &mut out); + } + let w = sim.why(0); + assert!(w.iter().any(|c| c.ok && c.text.starts_with("последний шанс")), "{:?}", w.iter().map(|c| &c.text).collect::>()); + } + + #[test] + fn last_chance_waits_for_a_target_far_from_its_aim() { + let (mut sim, mut ground, land, mut out) = setup(SamKind::S400); + // In plain view 120 blocks off, but flying away from its aim, 480 blocks (800 ticks) ahead. + sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, -120.0), DVec3::NEG_Z, DVec3::new(0.0, 70.0, -600.0)); + assert!(until_fired(&mut sim, &mut ground, &land, &mut out, 150).is_none(), "fired at a Shahed far from its aim"); + let w = sim.why(0); + // Still named as the role block it was: no target of its classes. + assert!(w.last().is_some_and(|c| !c.ok) && w.iter().any(|c| c.text.contains("нет целей классов")), "{:?}", w.iter().map(|c| &c.text).collect::>()); + assert!(!w.iter().any(|c| c.text.starts_with("последний шанс"))); + } + + #[test] + fn last_chance_is_off_when_switched_off_and_with_no_roles() { + for (off, roles) in [(true, 30u8), (false, 0u8)] { + let (mut sim, mut ground, land, mut out) = setup(SamKind::S400); + sim.last_chance = !off; + sim.sams[0].roles = roles; + shahed_at(&mut sim, 60.0); + assert!(until_fired(&mut sim, &mut ground, &land, &mut out, 140).is_none(), "fired with last chance off={off}, roles {roles}"); + } + } + + #[test] + fn last_chance_leaves_a_target_that_already_has_an_interceptor_on_it() { + let (mut sim, mut ground, land, mut out) = setup(SamKind::S400); + shahed_at(&mut sim, 60.0); + // Another system's interceptor is on it. + let id = sim.drones[0].id; + sim.drones[0].engaged = 1; + assert!(until_fired(&mut sim, &mut ground, &land, &mut out, 40).is_none(), "fired at {id} that is claimed"); + } + + /// A Kh101 `dist` from the origin on the far side, at height, coming in along z; a radar beside the system. + fn far_setup(radar: bool, dist: f64) -> (Sim, Ground, Land i32>, Out) { + let (mut sim, ground, land, out) = setup(SamKind::S400); + if radar { + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(40.0, 70.0, 0.0), 0.0)); + } + sim.add_drone(DroneKind::Kh101, DVec3::new(0.0, 300.0, -dist), DVec3::Z, DVec3::new(0.0, 70.0, 0.0)); + (sim, ground, land, out) + } + + /// The radar's measurement of every live drone, fresh this tick (as if its beam had just passed). + fn radar_sees(sim: &mut Sim) { + let now = sim.tick; + let tracks: Vec = sim + .drones + .iter() + .filter(|d| d.alive()) + .map(|d| Track { id: d.id, pos: d.pos, vel: d.vel, tick: now, own_tick: now, ttl: 400, hits: 3, locked: false }) + .collect(); + for r in sim.sams.iter_mut().filter(|s| s.kind == SamKind::Radar) { + for t in &tracks { + r.tracks.retain(|o| o.id != t.id); + r.tracks.push(*t); + } + } + } + + fn fire_far(sim: &mut Sim, ground: &mut Ground, land: &Land i32>, out: &mut Out, ticks: u32) -> u32 { + for _ in 0..ticks { + radar_sees(sim); + serve(ground, land, sim.tick); + sim.step(ground, None, out); + } + sim.sams[0].fired + } + + #[test] + fn heavy_systems_shoot_far_on_a_radars_cue_but_not_without_one() { + let max = SamKind::S400.spec().missile.as_ref().unwrap().max; + let (mut sim, mut ground, land, mut out) = far_setup(true, max * 1.4); + assert!(fire_far(&mut sim, &mut ground, &land, &mut out, 80) >= 1, "no long shot at 1.4 x max with a radar"); + let w = sim.why(0); + assert!(w.iter().any(|c| c.text.starts_with("дальний пуск по РЛС")), "{:?}", w.iter().map(|c| &c.text).collect::>()); + // No radar, no shot (the system's own radar may well see it: that is not a cue). + let (mut sim, mut ground, land, mut out) = far_setup(false, max * 1.4); + assert_eq!(fire_far(&mut sim, &mut ground, &land, &mut out, 80), 0, "a long shot with no radar"); + // And a kind that is not a heavy one never does. + let (mut sim, mut ground, land, mut out) = far_setup(true, max * 1.4); + sim.far_shots = false; + assert_eq!(fire_far(&mut sim, &mut ground, &land, &mut out, 80), 0, "a long shot with them switched off"); + } + + #[test] + fn a_long_shot_waits_out_its_cooldown_and_for_nothing_closer() { + let max = SamKind::S400.spec().missile.as_ref().unwrap().max; + // Two far targets: one long shot, the second waits its 300 ticks. + let (mut sim, mut ground, land, mut out) = far_setup(true, max * 1.4); + sim.add_drone(DroneKind::Kh101, DVec3::new(0.0, 300.0, max * 1.4), DVec3::NEG_Z, DVec3::new(0.0, 70.0, 0.0)); + assert_eq!(fire_far(&mut sim, &mut ground, &land, &mut out, 70), 1, "not exactly one long shot in the cooldown"); + // A closer target in normal reach: the far one is not fired at while it stands. + let (mut sim, mut ground, land, mut out) = far_setup(true, max * 1.4); + let far = sim.drones[0].id; + sim.add_drone(DroneKind::Kh101, DVec3::new(300.0, 120.0, 0.0), DVec3::NEG_X, DVec3::new(0.0, 70.0, 0.0)); + for _ in 0..40 { + radar_sees(&mut sim); + serve(&mut ground, &land, sim.tick); + sim.step(&mut ground, None, &mut out); + assert!(!sim.missiles.iter().any(|m| m.target == far), "a long shot while a closer target was in reach"); + } + assert!(sim.sams[0].fired > 0, "the closer target was not fired at"); + } + + #[test] + fn the_kill_chance_falls_to_half_at_the_far_end() { + assert_eq!(reach_fit(100.0, 80.0), 1.0); + assert_eq!(reach_fit(100.0, 100.0), 1.0); + assert!((reach_fit(100.0, 130.0) - 0.75).abs() < 1e-9); + assert!((reach_fit(100.0, 160.0) - 0.5).abs() < 1e-9); + assert!((reach_fit(100.0, 400.0) - 0.5).abs() < 1e-9); + } + + #[test] + fn every_interceptor_lives_long_enough_to_fly_its_reach() { + // Life × top speed must cover the far end of a heavy system's reach with room to chase; the rest their plain reach. + for kind in SamKind::ARMED { + let Some(load) = kind.spec().missile.as_ref() else { continue }; + let m = load.kind.spec(); + let reach = load.max * if kind.far_shooter() { FAR_K } else { 1.0 }; + assert!(m.life as f64 * m.speed > reach * 1.2, "{}: {} x {} does not cover {reach}", kind.name(), m.life, m.speed); + } + } } diff --git a/airdef-mod/src/smoke.rs b/airdef-mod/src/smoke.rs index 320d50b..8f1db63 100644 --- a/airdef-mod/src/smoke.rs +++ b/airdef-mod/src/smoke.rs @@ -99,30 +99,48 @@ fn blast_billows(out: &mut Vec, blasts: &[crate::sim::Blast], tick } } -fn style_drone(s: &mut SmokeSegment, class: Class) { - let (r0, r1, dens, heat, soot, clear, life): (f32, f32, f32, f32, f32, f32, f32) = match class { - Class::Drone => (0.9, 5.0, 2.0, 0.0, 0.45, 3.0, 1200.0), - Class::Jet | Class::Cruise => (1.1, 7.0, 2.5, 0.15, 0.35, 4.0, 1300.0), - Class::Supersonic => (1.4, 9.0, 3.2, 0.5, 0.2, 5.0, 1300.0), - Class::Ballistic => (2.0, 13.0, 4.0, 0.7, 0.15, 7.0, 1500.0), - }; - s.radius_start = r0; - s.radius_end = r1; - s.density = dens; - s.heat = heat; - s.soot = soot; - s.clear = clear; - s.life = life; +/// How one kind of trail looks. One rule by engine: a propeller leaves a faint short grey haze, +/// a jet or a cruise missile a thin grey line, a rocket (supersonic, ballistic, interceptor) a white +/// line as thick as the missile is big. No trail has heat: the flame at the nozzle is separate. +#[derive(Clone, Copy, Debug)] +struct Look { + r0: f32, + r1: f32, + density: f32, + soot: f32, + clear: f32, + life: f32, } -fn style_missile(s: &mut SmokeSegment) { - s.radius_start = 0.6; - s.radius_end = 5.0; - s.density = 3.0; - s.heat = 0.8; - s.soot = 0.05; - s.clear = 3.0; - s.life = 1000.0; +/// A rocket's white line, thicker for a bigger missile (`scale`: Pantsir 0.85, S-500 1.7). +fn rocket(scale: f32, density: f32, life: f32) -> Look { + let scale = scale.min(2.0); + Look { r0: 0.25 * scale, r1: 1.2 + 1.6 * scale, density, soot: 0.02, clear: 0.8 + 1.2 * scale, life: life + 250.0 * scale } +} + +fn drone_look(class: Class, scale: f32) -> Look { + match class { + Class::Drone => Look { r0: 0.4, r1: 1.8, density: 0.15, soot: 0.3, clear: 0.3, life: 250.0 }, + Class::Jet => Look { r0: 0.5, r1: 2.5, density: 0.5, soot: 0.25, clear: 1.5, life: 500.0 }, + Class::Cruise => Look { r0: 0.6, r1: 3.0, density: 0.6, soot: 0.3, clear: 2.0, life: 600.0 }, + Class::Supersonic => rocket(scale, 0.9, 400.0), + Class::Ballistic => rocket(scale, 1.1, 500.0), + } +} + +/// `None`: no motor smoke at all (the electric P1-SUN). +fn missile_look(kind: crate::sim::MissileKind) -> Option { + kind.trail_scale().map(|k| rocket(k, 0.8, 250.0)) +} + +fn style(s: &mut SmokeSegment, l: Look) { + s.radius_start = l.r0; + s.radius_end = l.r1; + s.density = l.density; + s.heat = 0.0; + s.soot = l.soot; + s.clear = l.clear; + s.life = l.life; } /// Points between a replay trail segment's ends (a segment every `TRAIL_GRID * STEP` ticks). @@ -138,15 +156,15 @@ pub fn replay_smoke(rec: &crate::sim::record::Recording, t: f64) -> Vec = Vec::new(); let stride = (TRAIL_GRID as u32 * STEP) as f64; for (n, tr) in rec.tracks.iter().enumerate() { - let (life, style): (f64, Box) = match tr.what { + let look = match tr.what { What::Drone(kind) => { - let class = kind.spec().class; - let mut probe = seg(0, DVec3::ZERO, DVec3::ZERO, 0, 0); - style_drone(&mut probe, class); - (probe.life as f64, Box::new(move |s| style_drone(s, class))) + let spec = kind.spec(); + Some(drone_look(spec.class, spec.trail_scale())) } - What::Missile(..) => (1000.0, Box::new(style_missile)), + What::Missile(kind, _) => missile_look(kind), }; + let Some(look) = look else { continue }; + let life = look.life as f64; // The segments whose end point has been reached and whose life is not over. let lo = (((t - life - tr.first as f64) / stride).floor().max(0.0)) as usize; let hi = (((t - tr.first as f64) / stride).floor().max(0.0)) as usize; @@ -157,7 +175,7 @@ pub fn replay_smoke(rec: &crate::sim::record::Recording, t: f64) -> Vec Vec().ok()) { - self.dist = d.clamp(60.0, 800.0); + self.dist = d.clamp(DIST_MIN, DIST_MAX); } if let Some(a) = w.get(3).and_then(|v| v.parse::().ok()).and_then(|i| Aim::ALL.get(i)) { self.aim = *a; diff --git a/docs/ACOUSTICS.md b/docs/ACOUSTICS.md index 08cf9d8..0e0bba5 100644 --- a/docs/ACOUSTICS.md +++ b/docs/ACOUSTICS.md @@ -13,9 +13,9 @@ ## Статус -**Стадия 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`) наше. +**Стадия 1 (прямой звук) и стадия 2 (отводы эха) сделаны**, включается `HORIZON_ACOUSTICS=1` (по умолчанию выключено, +звук как был). Стадия 3 (реверб) не начата. Коммит SPR прежний, `453654ec`; атрибуция: `horizon/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md`. +GPL-файлы два: `acoustics/spr_occlusion.rs` и (стадия 2) `acoustics/spr_bounce.rs`; остальное (движок `acoustics.rs`, склейка `game/acoustics.rs`, DSP в `audio.rs`) наше. Что вышло и чем отличается от плана выше: @@ -45,9 +45,53 @@ GPL-файл один: `acoustics/spr_occlusion.rs`; остальное (дви выстрел 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`. +### Стадия 2: эхо (сделано) + +Что игрок получает: взрыв в ущелье или между домами отвечает настоящим эхо от тех стен, что там есть, с задержкой по +настоящему пути; в чистом поле эха нет. Код: `acoustics/echo.rs` (наш, MIT), `acoustics/spr_bounce.rs` (GPL: спираль +Фибоначчи, `energyTowardsPlayer`, таблица отражательности), отводы в `audio.rs` (`Echo`), склейка `game/acoustics.rs` +(таблица отражательности по состояниям блоков). + +- Метод: 32 луча от источника и 32 от слушателя (направления SPR, длина 300) находят плоскости стен (грани блоков + осевые). Для каждой плоскости мнимый источник (зеркальное отражение источника) даёт точный путь `|образ - слушатель|`; + отвод оставляем, если точка пересечения этой прямой с плоскостью лежит на твёрдом блоке (стена там правда есть) и пути + источник-стена и стена-слушатель свободны (окклюзия < 0.2). Это и есть «задержка по реальному пути»: сверка с формулой + до сэмпла. +- Задержка отвода = (путь - прямое расстояние) / 343 с: звук играет в момент прихода прямого (задержку распространения + держит мод). Уровень = `min(0.85, 2 * energyTowardsPlayer(refl)) * min(1, max(d,4)/путь)`, затем в микшере умножается на + громкость голоса и `distance_gain` длины пути; срез верхов по воздуху `0.994^(лишний путь)` (однополюсник на голос-отвод). + Окклюзия прямого звука на эхо не действует (оно приходит другим путём). +- Срез близких отражений: отвод ближе 30 мс к прямому звуку отбрасывается, до 50 мс плавно входит (иначе стена рядом с + игроком даёт гребёнку, а не эхо). Из найденных плоскостей берутся до 6 сильнейших не ближе 40 мс друг к другу. +- Кому: `deserves_echo` в `audio.rs`: не петля, не относительный, громкость >= 0.5, дальность (linear) >= 96, длина 0.1-12 с, + прошло меньше 70% звука; не более `ECHO_VOICES = 8` одновременно (самые громкие). Сотня двигателей эха не получает + (петли), сотня выстрелов: восемь самых громких. +- Планирование: счёт по клеткам, не по времени. Живьём трассировка идёт на том же рабочем потоке (в задании до 4 трасс и + 240 тыс. клеток), кеш по блочным ячейкам источника и слушателя (центры ячеек, 256 записей), сброс по секциям, которых + касаются точки опоры отводов, и по эпохе; в съёмке трасса синхронная, 480 тыс. клеток на тик. Микшер получает отводы + числами (`take_echoes`), новый набор входит за ~50 мс (совпавший по задержке в пределах 3 мс отвод меняет только уровень). + Голос после конца сэмпла живёт ещё `max_delay` (хвост), потом останавливается. +- Измерено (release, M-серия): трасса `bench_traces`: ущелье 0.054 мс (7 тыс. клеток, 2 отвода), открытое поле 0.115 мс + (15 тыс. клеток, 0 отводов), комната 0.022 мс; всё на рабочем потоке, игровой поток платит только клонированием `Arc` и + отправкой. Микшер: 108 голосов (100 петель + 8 одноразовых) 0.213 мс -> 0.348 мс с 8 x 6 отводами (+0.135 мс, 1.3% буфера + 512 кадров). Время игрового потока в `tick_acoustics` и эхо-трасс копится в `status` (`acoustics.tick_ms_*`, + `acoustics.echo.ms*`); в игре не снималось. +- Проверка без слуха: `audio::tests::export_echo_tracks` (настоящие `Acoustics` и `mix`, щелчок) + + `python3 tools/acoustics_check.py --echo-dir папка`: ущелье даёт пики 158.9 и 242.1 мс (ожидалось 158.9 и 242.1), + поле ничего, комната один пик 39.9 мс (боковые стены; торцы и потолок ближе 30 мс срезаны). Тесты: `--lib acoustics` + (ущелье, поле, комната, стена у слушателя, ширма, «стены нет там, где луч её нашёл», бюджет, детерминизм, кеш, инвалидация, + потолок голосов, рабочий поток) и `--lib audio` (отвод стоит на своём кадре до сэмпла, хвост, без щелчка, детерминизм). + +Не слушали: громкость отводов (уровни из формул SPR и 1/r, не подстраивались), пороги `deserves_echo` (угаданы: не знаем, +какую громкость и дальность мод ставит выстрелам), срез 30/50 мс и разнос 40 мс на слух, срез верхов отвода. Первые 1-2 тика +эхо нового звука ещё нет (трасса на рабочем потоке): теряется только начало отводов с задержкой менее ~100 мс. Лучи стадии +видят стены только в 32 направлениях с каждого конца: узкая далёкая стена (меньше нескольких градусов) может не найтись. +Косые стены считаются ступеньками: на склоне горы отводов много, остаются сильнейшие с разносом 40 мс. Отводы идут только +в мире (реплей читает те же блоки), на съёмке не прогонялось в игре. + Не сделано / не проверено: запуск в игре и экспорт видео с настоящей сценой (склейка `game/acoustics.rs` — таблица состояний, снимок чанков — компилируется, но в игре не прогонялась); влияние на кадр при налёте; жидкости; дверь как проём (считается -ячейкой); потолок памяти снимка. Стадии 2-3 (отводы эха, шины реверба) не начаты. +ячейкой); потолок памяти снимка. Стадия 3 (шины реверба) не начата. ## 1. Как SPR идёт от источника к результату diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 13bb86c..ec208f4 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -19,12 +19,11 @@ AirDef работает на своей сборке (форке) Horizon: в н ## 2а. Готовая сборка из релиза (проще) -В репозитории → **Releases** → последний `build-N`. В каждом релизе есть `airdef.hmod` — сам мод (один для всех -ОС). Игру-форк сервер сборок не собирает сам: это тяжело для него. Архивы с игрой появляются в релизе, только если -их собрали вручную: -- `airdef-linux-x86_64.tar.gz` — Actions → **game** → Run workflow, указать тег релиза; -- `airdef-windows-x86_64.zip`, `airdef-macos-arm64.tar.gz` — Actions → **desktop** (нужны раннеры с метками - `windows-latest` / `macos-latest`). +В репозитории → **Releases**. Большие релизы `vX.Y.Z` содержат готовую игру для Windows, +`airdef-windows-x86_64.zip`: она собирается на сервере только по тегу версии (`git tag v0.3.0`, `git push origin v0.3.0`). +Сборки `build-N` выходят на каждый коммит, в них только `airdef.hmod`, сам мод (один для всех ОС). +Архив Linux собирается вручную: Actions → **game** → Run workflow с тегом `build-N`; macOS: Actions → **desktop** +(нужен раннер с меткой `macos-latest`). Нет архива для твоей ОС — способ 2б: игра соберётся у тебя, а мод можно взять из релиза или собрать там же. @@ -79,6 +78,9 @@ python3 tools/airdef.py play # или: python3 tools/airdef.py play --world " Шаги по отдельности: `fork` (скачать Horizon и наложить патчи `horizon-patches/`), `game` (собрать игру), `mod` (собрать `airdef.hmod`), `install` (положить мод в папку игры и включить), `play`. +`check` говорит прямо, совпадают ли форк, упаковщик мода, уровень API мода с игрой и ресурсы, и подсказывает, +что запустить, если нет (`python3 tools/airdef.py check`; Windows: `py tools\airdef.py check`). Версия Rust +фиксирована в `rust-toolchain.toml`: rustup сам поставит её и цель wasm32 при первой сборке. ## 3. В игре 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 0c05276..e8337fb 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/39] Mod API (AirDef fork): world models, client particles, +Subject: [PATCH 01/40] 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 1c352a1..0a214e7 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/39] cargo-horizon-mod: report particles and sounds +Subject: [PATCH 02/40] 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 15d610e..3c5fc3b 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/39] Mod API (AirDef fork): full-screen post pass for mods +Subject: [PATCH 03/40] 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 e25046c..20efd8b 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/39] Mod models: draw far models as scaled impostors past +Subject: [PATCH 04/40] 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 23e1d57..938e012 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/39] Mod cameras: render.set-views draws the world from +Subject: [PATCH 05/40] 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 2564521..1b510ab 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/39] Mods: more fuel per tick and per event (AirDef plans +Subject: [PATCH 06/40] 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 167e19f..04f87f1 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/39] Mod cameras up to 1920x1080 (full-screen replays) +Subject: [PATCH 07/40] 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 a711a20..d503f60 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/39] Mod cameras: distant terrain tiles, fog measured from +Subject: [PATCH 08/40] 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 630deb8..b8238d7 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/39] Mods: game.world-id, so mods keep state per world (API +Subject: [PATCH 09/40] 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 24a7754..d0388fe 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/39] cargo-horizon-mod: trial runs behind feature full, so a +Subject: [PATCH 10/40] 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 2cb173b..16719e8 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/39] HD lighting: G-buffer pass, per-pixel mod point lights +Subject: [PATCH 11/40] 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 c84ae23..bdb83e2 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/39] HD lighting stage 2: sun and moon shadow cascades, the +Subject: [PATCH 12/40] 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 fd122db..0f39cc7 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/39] HD: bind groups cached until a resize or a new source, +Subject: [PATCH 13/40] 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 2bbd0f0..0e53611 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/39] HD: lights culled per 16x16 tile (surface and halo +Subject: [PATCH 14/40] 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 32bdd4d..5fb68e8 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/39] HD: GPU time per pass in the F3 timing list, +Subject: [PATCH 15/40] 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 8ca7adf..8f75c8f 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/39] HD: shadows draw only sun-facing quads and skip buried +Subject: [PATCH 16/40] 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 5257c98..e585c45 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/39] Mods: model handles (render.model-id, model-generation, +Subject: [PATCH 17/40] 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 f06353e..d6fa932 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/39] HD: temporal upscaling (HORIZON_HD_SCALE: world, +Subject: [PATCH 18/40] 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 52d8927..bfdf2e7 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/39] HD water: its own pass (colour, normal, depth), the +Subject: [PATCH 19/40] 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 4ac72a3..b21ea96 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/39] HD water: no vanilla texture on the surface (the biome +Subject: [PATCH 20/40] 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 2480158..302e1e1 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/39] HD sky and light: a physical atmosphere (Rayleigh, Mie, +Subject: [PATCH 21/40] 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 0a4e194..9c04cc7 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/39] HD water: a darker blue, a little clearer, ripples a +Subject: [PATCH 22/40] 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 e028e6a..deff9e9 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/39] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun +Subject: [PATCH 23/40] 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 d312339..b9bf9c3 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/39] Mods: world queries for simulations (render.query-rays, +Subject: [PATCH 24/40] 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 9495442..405f47b 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/39] HD fixes: rain level from the world not from rain +Subject: [PATCH 25/40] 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 62d7005..d0cb3f7 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/39] HD split into modules (gfx/hd/: one Rust file and one +Subject: [PATCH 26/40] 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 bf56b0b..78d7712 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/39] HD clouds: noise scales that divide the camera's +Subject: [PATCH 27/40] 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 836e3f8..3c7add6 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/39] HD: volumetric smoke and missile trails with clear +Subject: [PATCH 28/40] 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 b28a4e9..2d7dee7 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/39] HD clouds composited by depth so terrain and vehicles +Subject: [PATCH 29/40] 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 7f1e69f..e4d42be 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/39] HD smoke: young trails drawn at least as wide as a grid +Subject: [PATCH 30/40] 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 3e8ac20..344d18b 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/39] Mods: render.set-main-view (API level 15) renders the +Subject: [PATCH 31/40] 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 e9f30a6..2711798 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/39] Spot lights for mods (render.set-spot-lights, API level +Subject: [PATCH 32/40] 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 471dff4..f6e7a00 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/39] HD clouds and fog polish: cloud panorama, cirrus, +Subject: [PATCH 33/40] 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 78d052f..e748bea 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/39] HD: coloured sun through glass and leaves, rain ripples +Subject: [PATCH 34/40] 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 38b7e6e..1c38a03 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/39] Film export: every frame in flight reaches the writer, +Subject: [PATCH 35/40] 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 ab170fb..ccd7b10 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/39] Mod command trees: command.set-tree and +Subject: [PATCH 36/40] 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 8a3c845..b70764a 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/39] Persistent mod sound sources: start, update, stop with +Subject: [PATCH 37/40] 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 55ec993..83994a1 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/39] HD clouds: rays from a far point, so the view bob's eye +Subject: [PATCH 38/40] 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 index eae4f34..e3cb043 100644 --- 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 @@ -1,7 +1,7 @@ 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 +Subject: [PATCH 39/40] 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) diff --git a/horizon-patches/0040-Mod-chunk-tickets-through-the-server-thread-API-22-s.patch b/horizon-patches/0040-Mod-chunk-tickets-through-the-server-thread-API-22-s.patch new file mode 100644 index 0000000..039b258 --- /dev/null +++ b/horizon-patches/0040-Mod-chunk-tickets-through-the-server-thread-API-22-s.patch @@ -0,0 +1,2202 @@ +From 449edb87be5b5ddc61d0d2fbeef3a91316a97b62 Mon Sep 17 00:00:00 2001 +From: chexit +Date: Fri, 30 Oct 2026 23:44:14 +0300 +Subject: [PATCH 40/40] Mod chunk tickets through the server thread (API 22), + softer searchlights and light halos, acoustics stage 2: echo taps + +Co-Authored-By: Claude Opus 5.5 +--- + crates/horizon-client/src/acoustics.rs | 306 +++++++++++- + .../src/acoustics/THIRD_PARTY_SPR.md | 12 + + crates/horizon-client/src/acoustics/echo.rs | 403 ++++++++++++++++ + .../src/acoustics/spr_bounce.rs | 79 ++++ + crates/horizon-client/src/audio.rs | 445 +++++++++++++++++- + crates/horizon-client/src/game.rs | 6 + + crates/horizon-client/src/game/acoustics.rs | 21 +- + crates/horizon-client/src/game/integrated.rs | 4 + + crates/horizon-client/src/game/lan.rs | 9 + + crates/horizon-client/src/game/mods.rs | 65 ++- + crates/horizon-client/src/game/remote.rs | 10 +- + crates/horizon-client/src/gfx/hd/light.wgsl | 10 +- + crates/horizon-client/src/gfx/hd/spot.wgsl | 10 +- + crates/horizon-client/src/lan/guest.rs | 4 + + crates/horizon-client/src/lan/hub.rs | 50 +- + crates/horizon-mod-sdk/wit/horizon.wit | 12 +- + crates/horizon-mods/src/api_level.rs | 6 +- + crates/horizon-mods/src/runtime.rs | 19 + + crates/horizon-mods/tests/ui_guest/src/lib.rs | 1 + + docs/api/mods-reference.md | 3 +- + docs/api/mods.md | 4 + + 21 files changed, 1426 insertions(+), 53 deletions(-) + create mode 100644 crates/horizon-client/src/acoustics/echo.rs + create mode 100644 crates/horizon-client/src/acoustics/spr_bounce.rs + +diff --git a/crates/horizon-client/src/acoustics.rs b/crates/horizon-client/src/acoustics.rs +index 8863e4e39..323685878 100644 +--- a/crates/horizon-client/src/acoustics.rs ++++ b/crates/horizon-client/src/acoustics.rs +@@ -15,10 +15,21 @@ + //! 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. + //! ++//! Stage 2 adds echo taps for loud one-shots (`echo`): rays from the source and the listener find ++//! the walls, and each wall that is really there gives a copy of the sound delayed by the longer ++//! path. They are traced on the same worker, cached by the 1-block cells of the source and the ++//! listener, and handed to the mixer by [`Acoustics::take_echoes`]; only [`ECHO_VOICES`] sounds ++//! at a time get them. ++//! + //! `HORIZON_ACOUSTICS=1` turns it on; off (the default) nothing here runs and the mixer is the + //! mixer it was. ++pub mod echo; ++pub mod spr_bounce; + pub mod spr_occlusion; + ++pub use echo::Tap; ++use spr_occlusion::run_occlusion; ++ + use crate::chunk::{Chunk, HEIGHT, MIN_Y}; + use crate::fast_hash::FxHashMap; + use std::collections::HashMap; +@@ -44,6 +55,16 @@ const JOB_SOURCES: usize = 32; + const FULL_PATHS: usize = 16; + /// Cached estimates. + const CACHE: usize = 4096; ++/// Sounds that have echo taps at the same time (the loudest of those that deserve them). ++pub const ECHO_VOICES: usize = 8; ++/// Echo traces one worker job holds. ++const ECHO_JOB: usize = 4; ++/// Cells the echo traces of one worker job may walk. ++const ECHO_JOB_CELLS: usize = 240_000; ++/// Cells the echo traces of one film tick may walk. ++const ECHO_FILM_CELLS: usize = 480_000; ++/// Cached echo estimates. ++const ECHO_CACHE: usize = 256; + /// Change records kept to judge a job that returns late. + const LOG: usize = 2048; + +@@ -51,18 +72,48 @@ const LOG: usize = 2048; + 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 + '_>; ++ /// As the walker, with the reflectivity of the block too (what an echo needs); by default ++ /// every solid block reflects as SPR's default does. ++ fn surface(&self) -> Box Option<(f32, f32)> + '_> { ++ let mut w = self.walker(); ++ Box::new(move |x, y, z| w(x, y, z).map(|o| (o, spr_bounce::DEFAULT_REFLECTIVITY))) ++ } + } + + /// 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>, ++ /// The reflectivity of each block state. ++ pub reflect: Arc>, + pub epoch: u32, + pub revision: u64, + pub tick: u64, + pub center: [f64; 3], + } + impl Geometry for Snapshot { ++ fn surface(&self) -> Box Option<(f32, f32)> + '_> { ++ 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, 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), ++ self.reflect.get(state).copied().unwrap_or(spr_bounce::DEFAULT_REFLECTIVITY), ++ )) ++ }) ++ } + fn walker(&self) -> Box Option + '_> { + let mut last: Option<((i32, i32), &Arc)> = None; + Box::new(move |x, y, z| { +@@ -91,6 +142,8 @@ pub struct Source { + pub at: [f64; 3], + /// Volume times the distance gain: what the listener would hear without any wall. + pub loud: f32, ++ /// A loud one-shot that deserves echoes (the mixer decides which). ++ pub echo: bool, + } + impl Source { + pub fn audible(&self) -> bool { +@@ -126,10 +179,21 @@ struct Entry { + } + 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]) ++ box_touches(self.min, self.max, s) + } + } ++fn box_touches(min: [i32; 3], max: [i32; 3], s: Cell) -> bool { ++ let b = [s.0 * 16, s.1 * 16, s.2 * 16]; ++ (0..3).all(|a| min[a] < b[a] + 16 && max[a] >= b[a]) ++} ++/// The echo taps of one source in one pair of cells. ++#[derive(Clone, Debug)] ++struct EchoEntry { ++ taps: Vec, ++ min: [i32; 3], ++ max: [i32; 3], ++ used: u64, ++} + + /// What the engine measured, for `status`. + #[derive(Clone, Debug, Default)] +@@ -145,6 +209,20 @@ pub struct Stats { + pub skipped_busy: u64, + pub sources: usize, + pub cached: usize, ++ /// Echo traces made (game thread plus worker), the cells they walked, the cache's hits, the ++ /// taps found, and the time they took: total and longest, in microseconds. ++ pub echo_traces: u64, ++ pub echo_cells: u64, ++ pub echo_hits: u64, ++ pub echo_taps: u64, ++ pub echo_us: u64, ++ pub echo_us_max: u64, ++ /// The game thread's time in `Audio::tick_acoustics`: total, longest, calls. ++ pub tick_us: u64, ++ pub tick_us_max: u64, ++ pub ticks: u64, ++ /// Sources that deserved echoes but were over the cap, in steps. ++ pub echo_capped: u64, + } + + struct Job { +@@ -152,15 +230,34 @@ struct Job { + geometry: Arc, + listener: [f64; 3], + items: Vec<(Key, [f64; 3], usize)>, ++ echoes: Vec, + } + struct Done { + seq: u64, + entries: Vec<(Key, Entry)>, ++ echoes: Vec<(Key, EchoEntry, u64)>, + cells: usize, + unknown: usize, + } + type Key = (Cell, Cell); + ++/// A block's cell, for the echo keys: the trace is made from the cells' centres, so that it is the ++/// same whatever the exact positions were. ++fn block_of(p: [f64; 3]) -> Cell { ++ (p[0].floor() as i32, p[1].floor() as i32, p[2].floor() as i32) ++} ++fn centre(c: Cell) -> [f64; 3] { ++ [f64::from(c.0) + 0.5, f64::from(c.1) + 0.5, f64::from(c.2) + 0.5] ++} ++ ++/// Traces the echoes for a pair of block cells; the time is in microseconds. ++fn trace_echo(geometry: &dyn Geometry, key: Key, budget: &mut usize, clock: u64) -> (EchoEntry, usize, usize, u64) { ++ let start = std::time::Instant::now(); ++ let t = echo::trace(geometry, centre(key.0), centre(key.1), budget); ++ let us = start.elapsed().as_micros() as u64; ++ (EchoEntry { taps: t.taps, min: t.min, max: t.max, used: clock }, t.cells, t.unknown, us) ++} ++ + 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) + } +@@ -201,6 +298,10 @@ pub struct Acoustics { + in_flight: Option, + worker: Option<(Sender, Receiver)>, + epoch: u32, ++ echo_cache: HashMap, ++ /// What the mixer was last told, and the changes it has not been told yet. ++ echo_applied: HashMap>, ++ echo_pending: Vec<(u32, Vec)>, + pub stats: Stats, + } + +@@ -217,6 +318,9 @@ impl Acoustics { + in_flight: None, + worker: None, + epoch: 0, ++ echo_cache: HashMap::new(), ++ echo_applied: HashMap::new(), ++ echo_pending: Vec::new(), + stats: Stats::default(), + } + } +@@ -225,6 +329,9 @@ impl Acoustics { + pub fn reset(&mut self) { + self.cache.clear(); + self.applied.clear(); ++ self.echo_cache.clear(); ++ self.echo_applied.clear(); ++ self.echo_pending.clear(); + self.log.clear(); + // A job in flight returns into a world that is gone: its sequence number is stale. + self.seq += 1; +@@ -240,13 +347,21 @@ impl Acoustics { + let mut budget = JOB_CELLS; + let (mut cells, mut unknown) = (0, 0); + let mut entries = Vec::with_capacity(job.items.len()); ++ let mut echoes = Vec::with_capacity(job.echoes.len()); ++ let mut echo_budget = ECHO_JOB_CELLS; + 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() { ++ for key in job.echoes { ++ let (e, c, u, us) = trace_echo(&*job.geometry, key, &mut echo_budget, 0); ++ cells += c; ++ unknown += u; ++ echoes.push((key, e, us)); ++ } ++ if done_tx.send(Done { seq: job.seq, entries, echoes, cells, unknown }).is_err() { + break; + } + } +@@ -261,6 +376,9 @@ impl Acoustics { + self.epoch = changes.epoch; + self.stats.invalidated += self.cache.len() as u64; + self.cache.clear(); ++ self.echo_cache.clear(); ++ self.echo_applied.clear(); ++ self.echo_pending.clear(); + self.log.clear(); + self.seq += 1; + self.floor = self.seq; +@@ -272,6 +390,7 @@ impl Acoustics { + let before = self.cache.len(); + self.cache.retain(|_, e| !e.touches(s)); + self.stats.invalidated += (before - self.cache.len()) as u64; ++ self.echo_cache.retain(|_, e| !box_touches(e.min, e.max, s)); + self.log.push_back((self.seq, s)); + } + while self.log.len() > LOG { +@@ -290,6 +409,22 @@ impl Acoustics { + } + } + ++ fn insert_echo(&mut self, key: Key, mut entry: EchoEntry) { ++ entry.used = self.clock; ++ self.echo_cache.insert(key, entry); ++ if self.echo_cache.len() > ECHO_CACHE { ++ let mut ages: Vec = self.echo_cache.values().map(|e| e.used).collect(); ++ ages.sort_unstable(); ++ let cut = ages[ages.len() / 4]; ++ self.echo_cache.retain(|_, e| e.used > cut); ++ } ++ } ++ ++ /// The echo taps that changed since the last call, by sound id. ++ pub fn take_echoes(&mut self) -> Vec<(u32, Vec)> { ++ std::mem::take(&mut self.echo_pending) ++ } ++ + /// 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. +@@ -313,9 +448,20 @@ impl Acoustics { + 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; ++ self.stats.dropped_late += (d.entries.len() + d.echoes.len()) as u64; + continue; + } ++ for (key, e, us) in d.echoes { ++ self.stats.echo_traces += 1; ++ self.stats.echo_us += us; ++ self.stats.echo_us_max = self.stats.echo_us_max.max(us); ++ if self.log.iter().any(|(s, c)| *s >= d.seq && box_touches(e.min, e.max, *c)) { ++ self.stats.dropped_late += 1; ++ } else { ++ self.stats.echo_taps += e.taps.len() as u64; ++ self.insert_echo(key, e); ++ } ++ } + for (key, e) in d.entries { + if self.log.iter().any(|(s, c)| *s >= d.seq && e.touches(*c)) { + self.stats.dropped_late += 1; +@@ -332,6 +478,7 @@ impl Acoustics { + .collect(); + self.stats.sources = live.len(); + self.applied.retain(|id, _| sources.iter().any(|s| s.id == *id)); ++ self.echo_applied.retain(|id, _| sources.iter().any(|s| s.id == *id)); + let Some(geometry) = geometry else { + return self.settle(&live, listener, None); + }; +@@ -369,7 +516,9 @@ impl Acoustics { + job.push((key, s.at, want)); + } + } +- if !job.is_empty() && !self.sync { ++ let echo_keys = self.settle_echoes(&**geometry, &live, &order, listener); ++ let job_empty = job.is_empty() && echo_keys.is_empty(); ++ if !job_empty && !self.sync { + if self.worker.is_none() { + self.spawn(); + } +@@ -379,7 +528,7 @@ impl Acoustics { + self.seq += 1; + let seq = self.seq; + if tx +- .send(Job { seq, geometry: geometry.clone(), listener, items: job }) ++ .send(Job { seq, geometry: geometry.clone(), listener, items: job, echoes: echo_keys }) + .is_ok() + { + self.in_flight = Some(seq); +@@ -390,6 +539,58 @@ impl Acoustics { + self.settle(&live, listener, Some(lcell)) + } + ++ /// Gives the loudest echo sources their taps from the cache, traces the missing ones at once ++ /// (a film: counted cells) or returns their keys for the worker (live). ++ fn settle_echoes(&mut self, geometry: &dyn Geometry, live: &[Source], order: &[usize], listener: [f64; 3]) -> Vec { ++ let lcell = block_of(listener); ++ let mut want: Vec = Vec::new(); ++ let mut budget = ECHO_FILM_CELLS; ++ let mut served = 0; ++ for &i in order { ++ let s = &live[i]; ++ if !s.echo { ++ continue; ++ } ++ if served >= ECHO_VOICES { ++ self.stats.echo_capped += 1; ++ continue; ++ } ++ served += 1; ++ let key = (block_of(s.at), lcell); ++ if let Some(e) = self.echo_cache.get_mut(&key) { ++ e.used = self.clock; ++ self.stats.echo_hits += 1; ++ } else if self.sync { ++ if budget == 0 { ++ continue; ++ } ++ let (e, cells, unknown, us) = trace_echo(geometry, key, &mut budget, self.clock); ++ self.stats.echo_traces += 1; ++ self.stats.echo_cells += cells as u64; ++ self.stats.unknown_cells += unknown as u64; ++ self.stats.echo_taps += e.taps.len() as u64; ++ self.stats.echo_us += us; ++ self.stats.echo_us_max = self.stats.echo_us_max.max(us); ++ self.insert_echo(key, e); ++ } else if want.len() < ECHO_JOB && !want.contains(&key) { ++ want.push(key); ++ continue; ++ } else { ++ continue; ++ } ++ let taps = self.echo_cache[&key].taps.clone(); ++ let changed = match self.echo_applied.get(&s.id) { ++ Some(prev) => *prev != taps, ++ None => !taps.is_empty(), ++ }; ++ if changed { ++ self.echo_applied.insert(s.id, taps.clone()); ++ self.echo_pending.push((s.id, taps)); ++ } ++ } ++ want ++ } ++ + /// 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)); +@@ -440,7 +641,7 @@ mod tests { + } + } + fn src(id: u32, x: f64) -> Source { +- Source { id, at: [x, 70.5, 0.5], loud: 0.5 } ++ Source { id, at: [x, 70.5, 0.5], loud: 0.5, echo: false } + } + + #[test] +@@ -540,4 +741,95 @@ mod tests { + wait(&mut a, 2, &moved); + assert_eq!(a.stats.traces, 1, "the game thread traced only the new source"); + } ++ ++ /// Blocks for the echo tests: solid where the predicate holds. ++ struct Blocks(Box bool + Send + Sync>); ++ impl Geometry for Blocks { ++ fn walker(&self) -> Box Option + '_> { ++ Box::new(move |x, y, z| Some(if (self.0)(x, y, z) { 1.0 } else { 0.0 })) ++ } ++ } ++ /// Ground at y <= 60, a wall at x = -39 (its face) and one at x = 55 if `right`. ++ fn gorge(right: bool) -> Arc { ++ Arc::new(Blocks(Box::new(move |x, y, _| y <= 60 || x <= -40 || (right && x >= 55)))) ++ } ++ fn shot(id: u32, z: f64) -> Source { ++ Source { id, at: [0.5, 66.5, z], loud: 0.5, echo: true } ++ } ++ const LISTENER: [f64; 3] = [0.5, 66.5, 30.5]; ++ ++ #[test] ++ fn an_echo_sound_gets_its_taps_once_and_a_destroyed_wall_changes_them() { ++ let mut a = Acoustics::new(true); ++ let sources = [shot(1, 0.5), Source { echo: false, ..shot(2, 3.5) }]; ++ a.step(Some(&gorge(true)), LISTENER, &sources, &Changes::default()); ++ let first = a.take_echoes(); ++ assert_eq!(first.len(), 1, "only the sound that deserves echoes"); ++ assert_eq!(first[0].0, 1); ++ assert_eq!(first[0].1.len(), 2); ++ // Nothing new: the cache answers and the mixer is not told again. ++ a.step(Some(&gorge(true)), LISTENER, &sources, &Changes::default()); ++ assert!(a.take_echoes().is_empty()); ++ assert!(a.stats.echo_hits >= 1 && a.stats.echo_traces == 1); ++ // A change elsewhere keeps it. ++ a.step(Some(&gorge(true)), LISTENER, &sources, &Changes { sections: vec![(90, 4, 90)], ..Default::default() }); ++ assert!(a.take_echoes().is_empty() && a.stats.echo_traces == 1); ++ // The right wall is destroyed (section x = 3 holds block 55): one echo is left. ++ let change = Changes { sections: vec![(3, 4, 0)], ..Default::default() }; ++ a.step(Some(&gorge(false)), LISTENER, &sources, &change); ++ let after = a.take_echoes(); ++ assert_eq!(after.len(), 1); ++ assert_eq!(after[0].1.len(), 1, "{:?}", after[0].1); ++ assert!(after[0].1[0].dir[0] < 0.0); ++ } ++ ++ #[test] ++ fn open_ground_sends_no_taps_and_a_new_world_forgets_them() { ++ let mut a = Acoustics::new(true); ++ let field: Arc = Arc::new(Blocks(Box::new(|_, y, _| y <= 60))); ++ a.step(Some(&field), LISTENER, &[shot(1, 0.5)], &Changes::default()); ++ assert!(a.take_echoes().is_empty()); ++ let mut b = Acoustics::new(true); ++ b.step(Some(&gorge(true)), LISTENER, &[shot(1, 0.5)], &Changes::default()); ++ assert_eq!(b.take_echoes().len(), 1); ++ b.step(Some(&gorge(true)), LISTENER, &[shot(1, 0.5)], &Changes { epoch: 4, ..Default::default() }); ++ assert_eq!(b.take_echoes().len(), 1, "told again after the epoch moved"); ++ } ++ ++ #[test] ++ fn only_the_loudest_few_sounds_get_echoes_and_the_cells_are_counted() { ++ let mut a = Acoustics::new(true); ++ // Twenty shots in a row along the gorge, each in a cell of its own. ++ let sources: Vec = (0..20) ++ .map(|i| Source { loud: 0.2 + 0.02 * i as f32, ..shot(i, 0.5 + f64::from(i) * 1.5) }) ++ .collect(); ++ a.step(Some(&gorge(true)), LISTENER, &sources, &Changes::default()); ++ assert_eq!(a.stats.echo_traces, ECHO_VOICES as u64); ++ assert_eq!(a.stats.echo_capped, 20 - ECHO_VOICES as u64); ++ assert!(a.stats.echo_cells <= (ECHO_FILM_CELLS) as u64); ++ let told = a.take_echoes(); ++ // Those near the listener have no echo worth the name; the others are among the loudest. ++ assert!(told.iter().all(|(id, _)| *id >= 12), "{:?}", told.iter().map(|t| t.0).collect::>()); ++ } ++ ++ #[test] ++ fn the_worker_traces_the_echoes_off_the_game_thread() { ++ let mut a = Acoustics::new(false); ++ let g = gorge(true); ++ let s = [shot(1, 0.5)]; ++ a.step(Some(&g), LISTENER, &s, &Changes::default()); ++ assert!(a.take_echoes().is_empty(), "not on the game thread"); ++ assert_eq!(a.stats.echo_traces, 0); ++ for _ in 0..400 { ++ a.step(Some(&g), LISTENER, &s, &Changes::default()); ++ let told = a.take_echoes(); ++ if !told.is_empty() { ++ assert_eq!(told[0].1.len(), 2); ++ assert_eq!(a.stats.echo_traces, 1); ++ return; ++ } ++ std::thread::sleep(std::time::Duration::from_millis(5)); ++ } ++ panic!("the worker did not answer"); ++ } + } +diff --git a/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md b/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md +index 9c756af88..134c0e754 100644 +--- a/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md ++++ b/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md +@@ -21,3 +21,15 @@ terms of the GPL-3.0 for the whole program (source on request, same licence for + 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. ++ ++## Stage 2: `spr_bounce.rs` ++ ++`spr_bounce.rs` is derived from the same commit and licence. What is taken: from `SoundPhysics.java` ++the directions of the environment rays (a Fibonacci spiral, `longitude = i * phi * 2 pi`, ++`latitude = asin(2 i / n - 1)`) and the energy a bounce sends towards the player ++(`0.25 * (reflectivity * 0.75 + 0.25)`); from `ReflectivityConfig.java` the reflectivity of the block ++groups (0.5 by default, stone and the like 1.5, netherrack 1.1, metal 1.25, glass 0.75, wood 0.4, ++grass, gravel and dirt 0.3, sand 0.2, snow 0.15, wool, moss and honey 0.1). What SPR does with the ++rays (it spreads their energy over four reverb slots) is not used: the echo taps in `echo.rs` ++(image sources, delays from the path length, near-reflection cutoff, selection) are our own and ++MIT, as is the mixer side. +diff --git a/crates/horizon-client/src/acoustics/echo.rs b/crates/horizon-client/src/acoustics/echo.rs +new file mode 100644 +index 000000000..5f77f5345 +--- /dev/null ++++ b/crates/horizon-client/src/acoustics/echo.rs +@@ -0,0 +1,403 @@ ++//! AirDef fork: acoustics stage 2 (#96): echo taps. A loud one-shot (a blast, a launch, a shot) ++//! answers from the walls that are really there, a copy of itself that arrives later by the ++//! longer way: source to wall to listener. ++//! ++//! Method: rays (SPR's Fibonacci directions, `spr_bounce`) go out from the source and from the ++//! listener and find the planes of the walls round them (block faces are axis-aligned, so a hit is ++//! a plane). For each plane the image of the source across it gives the exact specular path, whose ++//! length is the straight line from the image to the listener; the echo is kept only if the point ++//! where that line crosses the plane is a solid block, and the way source-wall and wall-listener is ++//! free. So the delay is the geometry's, to the sample, and a wall that is not there gives nothing. ++//! ++//! Level is the bounce's (SPR's `energyTowardsPlayer`) times the spreading of the longer way. The ++//! sound plays at the time the direct sound arrives, so a tap's delay is the extra path over the ++//! speed of sound. A tap closer than [`NEAR_CUT`] behind the direct sound is dropped and one up to ++//! [`NEAR_FULL`] is faded in: a wall beside the player would only comb the sound, not echo it. ++//! Of the planes found, the strongest [`MAX_TAPS`] that are at least [`SPACING`] apart are kept. ++use super::{AIR, Geometry, run_occlusion, spr_bounce}; ++use std::collections::BTreeMap; ++ ++/// Rays from each end. ++pub const RAYS: usize = 32; ++/// How far a ray looks for a wall, in blocks. ++pub const RAY_LEN: f64 = 300.0; ++/// Blocks a second. ++pub const SOUND_SPEED: f64 = 343.0; ++/// Echoes less than this many seconds behind the direct sound are dropped (they would comb). ++pub const NEAR_CUT: f64 = 0.030; ++/// And are at full level from here. ++pub const NEAR_FULL: f64 = 0.050; ++/// The longest delay kept, in seconds. ++pub const MAX_DELAY: f64 = 2.0; ++/// Taps a sound gets. ++pub const MAX_TAPS: usize = 6; ++/// Taps are at least this many seconds apart, or they are one echo. ++pub const SPACING: f64 = 0.040; ++/// Quieter taps (relative to the direct sound) are not kept. ++pub const MIN_LEVEL: f32 = 0.02; ++/// Planes tried per source, at most (each costs two walks). ++const TRIES: usize = 24; ++/// The most one wall gives back (amplitude). ++const MAX_REFLECT: f32 = 0.85; ++ ++/// One echo of a sound. ++#[derive(Clone, Copy, Debug, PartialEq)] ++pub struct Tap { ++ /// Seconds behind the direct sound. ++ pub delay: f32, ++ /// The length of the way source-wall-listener, for the distance gain. ++ pub path: f32, ++ /// Amplitude relative to the sound at its own distance, before the distance gain of `path`. ++ pub level: f32, ++ /// The gain of the highs the air leaves over the extra way (1: none). ++ pub cut: f32, ++ /// Unit vector from the listener to where the echo comes from (the point on the wall). ++ pub dir: [f32; 3], ++} ++ ++/// What a trace found. ++#[derive(Clone, Debug, Default)] ++pub struct Traced { ++ pub taps: Vec, ++ /// The box (blocks) of the source, the listener and the wall points the taps rest on. ++ pub min: [i32; 3], ++ pub max: [i32; 3], ++ pub cells: usize, ++ pub unknown: usize, ++} ++ ++struct Hit { ++ axis: usize, ++ /// +1 or -1: which way the face looks. ++ normal: i32, ++ /// The plane's coordinate along `axis`. ++ coord: i64, ++ refl: f32, ++} ++ ++type Surface<'a> = Box Option<(f32, f32)> + 'a>; ++ ++/// The first solid block along `dir` from `from` (not the block `from` is in), within `len`. ++fn first_hit( ++ surface: &mut Surface<'_>, ++ from: [f64; 3], ++ dir: [f64; 3], ++ len: f64, ++ budget: &mut usize, ++ unknown: &mut usize, ++) -> Option { ++ let mut at = [from[0].floor() as i32, from[1].floor() as i32, from[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 dir[a] > 0.0 { ++ step[a] = 1; ++ t_delta[a] = 1.0 / dir[a]; ++ t_max[a] = (f64::from(at[a]) + 1.0 - from[a]) / dir[a]; ++ } else if dir[a] < 0.0 { ++ step[a] = -1; ++ t_delta[a] = -1.0 / dir[a]; ++ t_max[a] = (from[a] - f64::from(at[a])) / -dir[a]; ++ } ++ } ++ loop { ++ 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] > len || *budget == 0 { ++ return None; ++ } ++ at[a] += step[a]; ++ t_max[a] += t_delta[a]; ++ *budget -= 1; ++ match surface(at[0], at[1], at[2]) { ++ None => *unknown += 1, ++ Some((occ, refl)) if occ > 0.0 => { ++ let coord = i64::from(at[a]) + i64::from(step[a] < 0); ++ return Some(Hit { axis: a, normal: -step[a], coord, refl }); ++ } ++ Some(_) => {} ++ } ++ } ++} ++ ++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() ++} ++ ++/// The echo taps of a sound at `src` heard at `lis`. `budget` is the number of block cells the ++/// walks may look at (the work stops, keeping what it has, when it is spent). ++pub fn trace(geometry: &dyn Geometry, src: [f64; 3], lis: [f64; 3], budget: &mut usize) -> Traced { ++ let start = *budget; ++ let mut surface = geometry.surface(); ++ let mut unknown = 0usize; ++ let direct = dist(src, lis); ++ // The planes of the walls round both ends. A map, so the order never depends on a hash. ++ let mut planes: BTreeMap<(usize, i32, i64), f32> = BTreeMap::new(); ++ for end in [src, lis] { ++ for i in 0..RAYS { ++ let dir = spr_bounce::fibonacci_dir(i, RAYS); ++ if let Some(h) = first_hit(&mut surface, end, dir, RAY_LEN, budget, &mut unknown) { ++ planes.entry((h.axis, h.normal, h.coord)).or_insert(h.refl); ++ } ++ } ++ } ++ // Candidates: the planes both ends stand in front of, with a delay worth an echo. ++ struct Cand { ++ axis: usize, ++ normal: i32, ++ coord: f64, ++ path: f64, ++ delay: f64, ++ level: f32, ++ } ++ let mut cands: Vec = Vec::new(); ++ for (&(axis, normal, coord), &refl) in &planes { ++ let (c, n) = (coord as f64, f64::from(normal)); ++ if (src[axis] - c) * n <= 0.0 || (lis[axis] - c) * n <= 0.0 { ++ continue; ++ } ++ let mut image = src; ++ image[axis] = 2.0 * c - src[axis]; ++ let path = dist(image, lis); ++ let delay = (path - direct) / SOUND_SPEED; ++ if !(NEAR_CUT..=MAX_DELAY).contains(&delay) { ++ continue; ++ } ++ let rho = (2.0 * spr_bounce::energy_towards_player(refl)).min(MAX_REFLECT); ++ let spread = (direct.max(4.0) / path).min(1.0) as f32; ++ let near = ((delay - NEAR_CUT) / (NEAR_FULL - NEAR_CUT)).clamp(0.0, 1.0) as f32; ++ let level = rho * spread * near; ++ if level < MIN_LEVEL { ++ continue; ++ } ++ cands.push(Cand { axis, normal, coord: c, path, delay, level }); ++ } ++ cands.sort_by(|a, b| b.level.total_cmp(&a.level).then(a.delay.total_cmp(&b.delay))); ++ let mut taps: Vec = Vec::new(); ++ let pad = 1.0; ++ let mut lo = [0, 1, 2].map(|a| (src[a].min(lis[a]) - pad).floor() as i32); ++ let mut hi = [0, 1, 2].map(|a| (src[a].max(lis[a]) + pad).floor() as i32); ++ let mut tries = 0; ++ for c in cands { ++ if taps.len() >= MAX_TAPS || tries >= TRIES || *budget == 0 { ++ break; ++ } ++ if taps.iter().any(|t| (f64::from(t.delay) - c.delay).abs() < SPACING) { ++ continue; ++ } ++ tries += 1; ++ let n = f64::from(c.normal); ++ let mut image = src; ++ image[c.axis] = 2.0 * c.coord - src[c.axis]; ++ // Where the line from the image to the listener crosses the plane. ++ let t = (c.coord - image[c.axis]) / (lis[c.axis] - image[c.axis]); ++ let q = [0, 1, 2].map(|a| image[a] + (lis[a] - image[a]) * t); ++ // The wall is there: the block behind the face at that point is solid. ++ let mut inner = q; ++ inner[c.axis] = c.coord - n * 0.5; ++ *budget = budget.saturating_sub(1); ++ let solid = surface(inner[0].floor() as i32, inner[1].floor() as i32, inner[2].floor() as i32) ++ .is_some_and(|(occ, _)| occ > 0.0); ++ if !solid { ++ continue; ++ } ++ // And the way there and back is free. ++ let mut out = q; ++ out[c.axis] = c.coord + n * 0.02; ++ let (mut free, mut u) = (true, 0); ++ for (a, b) in [(src, out), (out, lis)] { ++ let (o, un) = run_occlusion(|x, y, z| surface(x, y, z).map(|s| s.0), a, b, budget); ++ u += un; ++ free &= o < 0.2; ++ } ++ unknown += u; ++ if !free { ++ continue; ++ } ++ let len = dist(q, lis).max(1e-9); ++ taps.push(Tap { ++ delay: c.delay as f32, ++ path: c.path as f32, ++ level: c.level, ++ cut: AIR.powf((c.path - direct) as f32), ++ dir: [0, 1, 2].map(|a| ((q[a] - lis[a]) / len) as f32), ++ }); ++ for a in 0..3 { ++ lo[a] = lo[a].min((q[a] - pad).floor() as i32); ++ hi[a] = hi[a].max((q[a] + pad).floor() as i32); ++ } ++ } ++ taps.sort_by(|a, b| a.delay.total_cmp(&b.delay)); ++ Traced { taps, min: lo, max: hi, cells: start - *budget, unknown } ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ /// Blocks for tests: occlusion 1 where the predicate holds. ++ struct Blocks(Box bool + Send + Sync>); ++ impl Geometry for Blocks { ++ fn walker(&self) -> Box Option + '_> { ++ Box::new(move |x, y, z| Some(if (self.0)(x, y, z) { 1.0 } else { 0.0 })) ++ } ++ } ++ fn blocks(f: impl Fn(i32, i32, i32) -> bool + Send + Sync + 'static) -> Blocks { ++ Blocks(Box::new(f)) ++ } ++ /// Seconds behind the direct sound of the specular path off the plane `axis = coord`. ++ pub(crate) fn expected(src: [f64; 3], lis: [f64; 3], axis: usize, coord: f64) -> f64 { ++ let mut image = src; ++ image[axis] = 2.0 * coord - src[axis]; ++ (dist(image, lis) - dist(src, lis)) / SOUND_SPEED ++ } ++ ++ /// Ground at y <= 60 and two walls: the left face at x = -39, the right one at x = 55. ++ pub(crate) fn gorge() -> impl Geometry { ++ blocks(|x, y, _| y <= 60 || x <= -40 || x >= 55) ++ } ++ ++ #[test] ++ fn a_gorge_gives_one_tap_per_wall_at_the_delay_of_its_path() { ++ let (src, lis) = ([0.5, 66.5, 0.5], [0.5, 66.5, 30.5]); ++ let mut budget = 1_000_000; ++ let t = trace(&gorge(), src, lis, &mut budget); ++ let want = [expected(src, lis, 0, -39.0), expected(src, lis, 0, 55.0)]; ++ let mut want = want.to_vec(); ++ want.sort_by(f64::total_cmp); ++ assert_eq!(t.taps.len(), 2, "{:?}", t.taps); ++ for (tap, w) in t.taps.iter().zip(&want) { ++ assert!((f64::from(tap.delay) - w).abs() < 1.0 / 48_000.0, "{} vs {w}", tap.delay); ++ assert!(tap.level > 0.05 && tap.level < MAX_REFLECT); ++ assert!(tap.cut < 1.0 && tap.cut > 0.5, "{}", tap.cut); ++ } ++ // About 0.2 s each: tens of blocks of extra path. ++ assert!(want[0] > 0.1 && want[1] < 0.5, "{want:?}"); ++ // The echo of the left wall comes from the left (-x), the right wall's from the right. ++ let left = t.taps.iter().find(|t| t.dir[0] < 0.0).expect("left"); ++ let right = t.taps.iter().find(|t| t.dir[0] > 0.0).expect("right"); ++ assert!(left.delay != right.delay); ++ assert!(budget < 1_000_000 && t.cells == 1_000_000 - budget); ++ } ++ ++ #[test] ++ fn open_ground_answers_with_nothing() { ++ // Just the ground: its reflection is a few blocks of extra path, a comb and no echo. ++ let (src, lis) = ([0.5, 62.5, 0.5], [90.5, 62.5, 40.5]); ++ let mut budget = 1_000_000; ++ let t = trace(&blocks(|_, y, _| y <= 60), src, lis, &mut budget); ++ assert!(t.taps.is_empty(), "{:?}", t.taps); ++ // Nothing at all, not even ground: the sky. ++ let t = trace(&blocks(|_, _, _| false), src, lis, &mut budget); ++ assert!(t.taps.is_empty()); ++ } ++ ++ #[test] ++ fn an_enclosed_room_gives_a_short_echo() { ++ let room = blocks(|x, y, z| !((0..24).contains(&x) && (60..70).contains(&y) && (0..24).contains(&z))); ++ let (src, lis) = ([4.5, 64.5, 12.5], [20.5, 64.5, 12.5]); ++ let mut budget = 1_000_000; ++ let t = trace(&room, src, lis, &mut budget); ++ assert!(!t.taps.is_empty()); ++ // The side walls (z = 0 and z = 24) are 40 ms behind; the end walls and the ceiling are ++ // closer than that to the direct sound and are dropped. ++ let side = expected(src, lis, 2, 0.0); ++ assert!(side > NEAR_CUT, "{side}"); ++ for tap in &t.taps { ++ assert!(f64::from(tap.delay) < 0.15, "short: {}", tap.delay); ++ } ++ assert!(t.taps.iter().any(|tap| (f64::from(tap.delay) - side).abs() < 1.0 / 48_000.0)); ++ for (axis, coord) in [(0, 0.0), (0, 24.0), (1, 70.0), (1, 60.0)] { ++ assert!(expected(src, lis, axis, coord) < NEAR_CUT); ++ } ++ } ++ ++ #[test] ++ fn a_wall_beside_the_listener_does_not_comb() { ++ // Listener 2.5 blocks from a wall, a shot 50 blocks away: the echo would be 15 ms behind. ++ let wall = blocks(|x, y, _| y <= 60 || x <= -3); ++ let (src, lis) = ([50.5, 66.5, 0.5], [0.5, 66.5, 0.5]); ++ assert!(expected(src, lis, 0, -2.0) < NEAR_CUT); ++ let mut budget = 1_000_000; ++ assert!(trace(&wall, src, lis, &mut budget).taps.is_empty()); ++ // Just past the cutoff it fades in, not in a step. ++ let at = |x: f64| { ++ let mut b = 1_000_000; ++ trace(&blocks(move |xx, y, _| y <= 60 || f64::from(xx) <= x), [50.5, 66.5, 0.5], lis, &mut b).taps ++ }; ++ let far = at(-12.0); ++ assert_eq!(far.len(), 1); ++ let mid = at(-7.0); ++ if let Some(m) = mid.first() { ++ assert!(m.level < far[0].level, "{} {}", m.level, far[0].level); ++ } ++ } ++ ++ #[test] ++ fn a_wall_that_is_not_where_the_ray_found_it_gives_no_tap() { ++ // A pillar at x = 55..56 only on z in 20..24: rays find its face, but the specular point ++ // for this pair is at z ~ 15, in the open. ++ let pillar = blocks(|x, y, z| y <= 60 || ((55..56).contains(&x) && (20..24).contains(&z))); ++ let (src, lis) = ([0.5, 66.5, 0.5], [0.5, 66.5, 30.5]); ++ let mut budget = 1_000_000; ++ let t = trace(&pillar, src, lis, &mut budget); ++ assert!(t.taps.is_empty(), "{:?}", t.taps); ++ } ++ ++ #[test] ++ fn a_screen_in_the_way_blocks_the_echo_path() { ++ // The right wall is there, but a screen stands between the listener and its echo point. ++ let g = blocks(|x, y, z| y <= 60 || x <= -40 || x >= 55 || ((20..22).contains(&x) && (20..30).contains(&z))); ++ let (src, lis) = ([0.5, 66.5, 0.5], [0.5, 66.5, 30.5]); ++ let mut budget = 1_000_000; ++ assert_eq!(trace(&gorge(), src, lis, &mut budget).taps.len(), 2); ++ let screened = trace(&g, src, lis, &mut budget); ++ assert_eq!(screened.taps.len(), 1, "{:?}", screened.taps); ++ assert!(screened.taps[0].dir[0] < 0.0, "only the left wall is left"); ++ } ++ ++ #[test] ++ fn the_budget_is_counted_and_the_result_is_deterministic() { ++ let (src, lis) = ([0.5, 66.5, 0.5], [0.5, 66.5, 30.5]); ++ let mut small = 500; ++ let t = trace(&gorge(), src, lis, &mut small); ++ assert!(t.cells <= 500 && small < 500); ++ let run = || { ++ let mut b = 1_000_000; ++ trace(&gorge(), src, lis, &mut b).taps ++ }; ++ assert_eq!(run(), run()); ++ } ++ ++ /// The cost of one trace in release: `cargo test -p horizon-client --lib --release ++ /// acoustics::echo::tests::bench -- --ignored --nocapture`. ++ #[test] ++ #[ignore] ++ fn bench_traces() { ++ let (src, lis) = ([0.5, 66.5, 0.5], [0.5, 66.5, 30.5]); ++ let cases: Vec<(&str, Box)> = vec![ ++ ("gorge", Box::new(gorge())), ++ ("open field", Box::new(blocks(|_, y, _| y <= 60))), ++ ("room", Box::new(blocks(|x, y, z| !((-20..40).contains(&x) && (60..80).contains(&y) && (-20..50).contains(&z))))), ++ ]; ++ for (name, g) in cases { ++ let n = 2000; ++ let (mut cells, mut taps) = (0, 0); ++ let start = std::time::Instant::now(); ++ for i in 0..n { ++ let mut b = 1_000_000; ++ let t = trace(&*g, [src[0] + (i % 7) as f64 * 0.1, src[1], src[2]], lis, &mut b); ++ cells += t.cells; ++ taps += t.taps.len(); ++ } ++ let ms = start.elapsed().as_secs_f64() * 1e3 / n as f64; ++ println!("{name:11} {ms:.3} ms per trace, {} cells, {} taps", cells / n, taps / n); ++ } ++ } ++} +diff --git a/crates/horizon-client/src/acoustics/spr_bounce.rs b/crates/horizon-client/src/acoustics/spr_bounce.rs +new file mode 100644 +index 000000000..05f4ca3e3 +--- /dev/null ++++ b/crates/horizon-client/src/acoustics/spr_bounce.rs +@@ -0,0 +1,79 @@ ++// 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 ++// (the Fibonacci-sphere ray directions and `energyTowardsPlayer` of evaluateEnvironment) ++// common/src/main/java/com/sonicether/soundphysics/config/ReflectivityConfig.java (the table) ++// Ported to Rust for Horizon's own echo taps, not line by line: SPR spreads the energy of its ++// rays over four reverb slots, we use the same rays, the same energy and the same table to ++// place discrete echoes (see `echo.rs`, ours). See THIRD_PARTY_SPR.md next to this file. The rest ++// of this crate is MIT; this file is GPL-3.0-only. ++//! The bounce side of SPR: where its environment rays point, how much a bounce keeps, and how ++//! reflective each block group is. ++ ++/// `defaultBlockReflectivity`. ++pub const DEFAULT_REFLECTIVITY: f32 = 0.5; ++ ++/// The `i`-th of `n` directions on a sphere by a Fibonacci spiral (`longitude = i * phi * 2 pi`, ++/// `latitude = asin(2 i / n - 1)`, phi the golden ratio's fraction), as SPR aims its 32 ++/// environment rays. ++pub fn fibonacci_dir(i: usize, n: usize) -> [f64; 3] { ++ let phi = 5f64.sqrt() * 0.5 + 0.5 - 1.0; ++ let longitude = i as f64 * phi * std::f64::consts::TAU; ++ let latitude = (2.0 * i as f64 / n as f64 - 1.0).clamp(-1.0, 1.0).asin(); ++ [latitude.cos() * longitude.cos(), latitude.sin(), latitude.cos() * longitude.sin()] ++} ++ ++/// What a bounce off a block of this reflectivity sends towards the player ++/// (`energyTowardsPlayer = 0.25 * (reflectivity * 0.75 + 0.25)`). ++pub fn energy_towards_player(reflectivity: f32) -> f32 { ++ 0.25 * (reflectivity * 0.75 + 0.25) ++} ++ ++/// The reflectivity of a block group (`ReflectivityConfig`), by the group name of the block's ++/// step sound (`minecraft:block.stone.step` is `stone`). ++pub fn group_reflectivity(group: &str) -> f32 { ++ match group { ++ "stone" | "deepslate" | "deepslate_bricks" | "deepslate_tiles" | "polished_deepslate" ++ | "tuff" | "tuff_bricks" | "polished_tuff" | "basalt" | "calcite" | "bone_block" ++ | "nether_bricks" => 1.5, ++ "netherrack" => 1.1, ++ "metal" | "copper" | "copper_bulb" | "copper_grate" | "anvil" | "netherite_block" => 1.25, ++ "glass" => 0.75, ++ "wood" | "nether_wood" | "cherry_wood" | "bamboo_wood" => 0.4, ++ "grass" | "gravel" | "dirt" | "rooted_dirt" => 0.3, ++ "sand" => 0.2, ++ "snow" | "powder_snow" => 0.15, ++ "wool" | "moss" | "honey_block" => 0.1, ++ _ => DEFAULT_REFLECTIVITY, ++ } ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn the_directions_are_unit_and_cover_the_sphere() { ++ let n = 32; ++ let dirs: Vec<[f64; 3]> = (0..n).map(|i| fibonacci_dir(i, n)).collect(); ++ for d in &dirs { ++ assert!((d.iter().map(|v| v * v).sum::() - 1.0).abs() < 1e-9); ++ } ++ // Up and down are both covered, and no hemisphere is empty. ++ assert!(dirs.iter().any(|d| d[1] < -0.9) && dirs.iter().any(|d| d[1] > 0.9)); ++ for a in 0..3 { ++ assert!(dirs.iter().any(|d| d[a] > 0.3) && dirs.iter().any(|d| d[a] < -0.3)); ++ } ++ } ++ ++ #[test] ++ fn stone_keeps_more_than_grass_and_wool() { ++ assert!((energy_towards_player(1.5) - 0.34375).abs() < 1e-6); ++ assert!(energy_towards_player(group_reflectivity("stone")) > energy_towards_player(group_reflectivity("grass"))); ++ assert!(group_reflectivity("wool") < group_reflectivity("glass")); ++ assert_eq!(group_reflectivity("coral_block"), DEFAULT_REFLECTIVITY); ++ } ++} +diff --git a/crates/horizon-client/src/audio.rs b/crates/horizon-client/src/audio.rs +index 9ef116839..640206803 100644 +--- a/crates/horizon-client/src/audio.rs ++++ b/crates/horizon-client/src/audio.rs +@@ -43,6 +43,159 @@ struct Channel { + voice: Option, + /// Acoustics (AirDef fork, #96): what stands between the source and the listener. + occlusion: Option, ++ /// Acoustics stage 2: the echoes of the sound, copies of it delayed by the longer way. ++ echo: Option, ++} ++/// The echo taps of a one-shot (AirDef fork, #96 stage 2): each reads the sound again `delay` ++/// seconds behind the direct one, through a low-pass for the air it travelled, panned to where ++/// the echo comes from. A tap changes level over about 50 ms, so a new estimate is no click, and a ++/// sound that has ended plays on until its last tap is done. ++#[derive(Clone, Debug, Default)] ++struct Echo { ++ taps: Vec, ++} ++#[derive(Clone, Debug)] ++struct EchoTap { ++ delay: f32, ++ path: f32, ++ level: f32, ++ level_to: f32, ++ cut: f32, ++ dir: [f32; 3], ++ /// The low-pass state. ++ lp: f32, ++} ++impl Echo { ++ /// New taps: one that matches a tap already sounding (within 3 ms) keeps its delay and moves ++ /// its level; one without a match comes in from silence; one that is gone goes out. ++ fn set(&mut self, taps: &[crate::acoustics::Tap]) { ++ for t in &mut self.taps { ++ t.level_to = 0.0; ++ } ++ let mut claimed = vec![false; self.taps.len()]; ++ for n in taps { ++ let found = self ++ .taps ++ .iter() ++ .enumerate() ++ .position(|(i, t)| !claimed[i] && (t.delay - n.delay).abs() < 0.003); ++ match found { ++ Some(i) => { ++ claimed[i] = true; ++ let t = &mut self.taps[i]; ++ (t.path, t.level_to, t.cut, t.dir) = (n.path, n.level, n.cut, n.dir); ++ } ++ None => self.taps.push(EchoTap { ++ delay: n.delay, ++ path: n.path, ++ level: 0.0, ++ level_to: n.level, ++ cut: n.cut, ++ dir: n.dir, ++ lp: 0.0, ++ }), ++ } ++ if found.is_none() { ++ claimed.push(true); ++ } ++ } ++ } ++} ++/// One tap for one buffer. ++struct TapPlan { ++ /// The delay in source frames. ++ delay_src: f64, ++ /// Left, right and plain gain at the buffer's start and end. ++ g: [[f32; 3]; 2], ++ a: f32, ++ lp: f32, ++ idx: usize, ++} ++/// The one-pole coefficient for a tap whose highs (at 5 kHz) are `cut` of the sound. ++fn echo_lp(cut: f32, rate: u32) -> f32 { ++ if cut >= 0.999 { ++ return 1.0; ++ } ++ let g = cut.max(0.001); ++ let fc = 5000.0 / (1.0 / (g * g) - 1.0).sqrt(); ++ (1.0 - (-std::f32::consts::TAU * fc / rate as f32).exp()).clamp(0.0, 1.0) ++} ++/// The taps of a channel for a buffer of `seconds`, panned by `pan` (unit vector from the ++/// listener to a tap's source of sound); the sound's own gain and the distance gain of the tap's ++/// path apply, the direct path's occlusion does not (the echo comes another way). ++fn plan_echo( ++ echo: &mut Option, ++ gain: f32, ++ linear: Option, ++ relative: bool, ++ seconds: f32, ++ rate: u32, ++ step: f64, ++ pan: &dyn Fn([f32; 3]) -> [f32; 2], ++) -> Vec { ++ let Some(e) = echo.as_mut().filter(|_| !relative) else { ++ return Vec::new(); ++ }; ++ let alpha = 1.0 - (-seconds / 0.05).exp(); ++ let base = gain.clamp(0.0, 1.0); ++ e.taps ++ .iter_mut() ++ .enumerate() ++ .map(|(idx, t)| { ++ let l0 = t.level; ++ t.level += (t.level_to - t.level) * alpha; ++ if t.level_to == 0.0 && t.level < 1e-3 { ++ t.level = 0.0; ++ } ++ let g = base * distance_gain(linear, t.path); ++ let [pl, pr] = pan(t.dir); ++ let (g0, g1) = (g * l0, g * t.level); ++ TapPlan { ++ delay_src: f64::from(t.delay) * f64::from(rate) * step, ++ g: [[g0 * pl, g0 * pr, g0], [g1 * pl, g1 * pr, g1]], ++ a: echo_lp(t.cut, rate), ++ lp: t.lp, ++ idx, ++ } ++ }) ++ .collect() ++} ++/// Keeps the taps' filter states, and drops the taps that are done. ++fn finish_echo(echo: &mut Option, plans: &[TapPlan]) { ++ if let Some(e) = echo.as_mut() { ++ for p in plans { ++ if let Some(t) = e.taps.get_mut(p.idx) { ++ t.lp = p.lp; ++ } ++ } ++ e.taps.retain(|t| t.level > 0.0 || t.level_to > 0.0); ++ } ++} ++/// How far past the end of the sound the last tap still sounds, in source frames. ++fn echo_tail(plans: &[TapPlan]) -> f64 { ++ plans.iter().map(|p| p.delay_src).fold(0.0, f64::max) + 2.0 ++} ++/// The sum of the taps at source position `pos`, `t` of the way through the buffer. ++#[inline] ++fn run_taps(plans: &mut [TapPlan], samples: &[f32], pos: f64, t: f32) -> [f32; 3] { ++ let mut out = [0.0f32; 3]; ++ for p in plans { ++ let q = pos - p.delay_src; ++ if q < 0.0 { ++ continue; ++ } ++ let i = q as usize; ++ if i + 1 >= samples.len() { ++ continue; ++ } ++ let f = (q - i as f64) as f32; ++ let x = samples[i] * (1.0 - f) + samples[i + 1] * f; ++ p.lp += p.a * (x - p.lp); ++ for k in 0..3 { ++ out[k] += p.lp * (p.g[0][k] + (p.g[1][k] - p.g[0][k]) * t); ++ } ++ } ++ out + } + /// 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 +@@ -85,6 +238,25 @@ impl Occlusion { + (self.low2 + cut * (x - self.low2)) * (g[0] + (g[1] - g[0]) * t) + } + } ++/// A sound this loud and this far-carrying, once and not a loop, gets echoes (a blast, a launch, ++/// a gunshot; not footsteps, not a hundred engines). The numbers are a guess nobody has listened to. ++const ECHO_GAIN: f32 = 0.5; ++const ECHO_RANGE: f32 = 96.0; ++/// The lengths of sound, in seconds, that are one-shots worth an echo. ++const ECHO_SECONDS: (f32, f32) = (0.1, 12.0); ++fn deserves_echo(c: &Channel) -> bool { ++ let Some((samples, rate)) = &c.samples else { ++ return false; ++ }; ++ let seconds = samples.len() as f32 / (*rate).max(1) as f32; ++ !c.looping ++ && !c.relative ++ && c.voice.as_ref().is_none_or(|v| !v.releasing) ++ && c.gain >= ECHO_GAIN ++ && c.linear.is_some_and(|r| r >= ECHO_RANGE) ++ && (ECHO_SECONDS.0..=ECHO_SECONDS.1).contains(&seconds) ++ && c.position < f64::from(seconds) * f64::from(*rate) * 0.7 ++} + /// What makes a channel a mod source's voice. + #[derive(Clone, Debug)] + struct Voice { +@@ -347,6 +519,7 @@ impl Backend for MixerBackend { + relative: false, + voice: None, + occlusion: None, ++ echo: None, + }, + ); + Some(id) +@@ -568,6 +741,11 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) { + forward[0] * up[1] - forward[1] * up[0], + ]; + let dot = |a: [f64; 3], b: [f64; 3]| (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) as f32; ++ // Where an echo comes from, as a pair of pan gains. ++ let echo_pan = |d: [f32; 3]| { ++ let d = [f64::from(d[0]), f64::from(d[1]), f64::from(d[2])]; ++ stereo_gains(dot(d, right), dot(d, up), dot(d, forward)) ++ }; + // Gains and pans per source for this buffer. + let mut voices: Vec<(ChannelId, [f32; 3], f64)> = Vec::new(); + for (id, c) in &mixer.channels { +@@ -648,10 +826,16 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) { + 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)); ++ let mut plans = plan_echo(&mut c.echo, c.gain, c.linear, c.relative, buffer_seconds, rate, step, &echo_pan); ++ let tail = echo_tail(&plans); + for (k, frame) in data.chunks_mut(channels).enumerate() { ++ let mut past = false; + if c.position >= end { + if looping { + c.position = c.position - region.1 + region.0 + region.2; ++ } else if !plans.is_empty() && c.position < end + tail { ++ // The sound is over, its echoes are not. ++ past = true; + } else { + c.state = State::Stopped; + break; +@@ -666,53 +850,63 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) { + c.state = State::Stopped; + break; + } +- let s = voice_sample(&samples, c.position, looping, region) * v.env; ++ let s = if past { 0.0 } else { voice_sample(&samples, c.position, looping, region) * v.env }; + let t = k as f32 / frames as f32; ++ let echoes = if plans.is_empty() { [0.0; 3] } else { run_taps(&mut plans, &samples, c.position, t) }; + 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); +- frame[1] += s * lerp(from[1], right); ++ frame[0] += s * lerp(from[0], left) + echoes[0] * v.env; ++ frame[1] += s * lerp(from[1], right) + echoes[1] * v.env; + } else { +- frame[0] += s * lerp(from[2], gain); ++ frame[0] += s * lerp(from[2], gain) + echoes[2] * v.env; + } + c.position += step; + } ++ finish_echo(&mut c.echo, &plans); + v.last = Some([left, right, gain]); + v.age += buffer_seconds; + continue; + } + let shelf = c.occlusion.as_mut().map(|o| o.buffer(buffer_seconds, rate)); + let frames = (data.len() / channels.max(1)).max(1); ++ let mut plans = plan_echo(&mut c.echo, c.gain, c.linear, c.relative, buffer_seconds, rate, step, &echo_pan); ++ let tail = echo_tail(&plans); + for (k, frame) in data.chunks_mut(channels).enumerate() { + let mut i = c.position as usize; ++ let mut past = false; + if i + 1 >= samples.len() { + if c.looping && samples.len() > 1 { + c.position = 0.0; + i = 0; ++ } else if !plans.is_empty() && c.position < samples.len() as f64 + tail { ++ // The sound is over, its echoes are not. ++ past = true; + } else { + c.state = State::Stopped; + break; + } + } + let t = (c.position - i as f64) as f32; +- let s = samples[i] * (1.0 - t) + samples[i + 1] * t; ++ let s = if past { 0.0 } else { samples[i] * (1.0 - t) + samples[i + 1] * t }; ++ let echoes = if plans.is_empty() { [0.0; 3] } else { run_taps(&mut plans, &samples, c.position, k as f32 / frames as f32) }; + 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; ++ frame[0] += s * left + echoes[0]; ++ frame[1] += s * right + echoes[1]; + } else { + // A mono output has no panning: the source's own gain. +- frame[0] += s * gain; ++ frame[0] += s * gain + echoes[2]; + } + c.position += step; + } ++ finish_echo(&mut c.echo, &plans); + } + // Limit the whole buffer together, preserving waveform and stereo balance + // instead of flattening each overloaded sample; release over 200 ms +@@ -1091,6 +1285,7 @@ impl Audio { + id: *id as u32, + at, + loud: c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance), ++ echo: deserves_echo(c), + } + }) + .collect(); +@@ -1107,28 +1302,40 @@ impl Audio { + if self.acoustics.is_none() { + return; + } ++ let started = std::time::Instant::now(); + 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; ++ let echoes = acoustics.take_echoes(); ++ if !applied.is_empty() || !echoes.is_empty() { ++ 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)), + } +- None => c.occlusion = Some(Occlusion::new(a.gain, a.cutoff)), ++ } ++ } ++ for (id, taps) in echoes { ++ if let Some(c) = m.channels.get_mut(&(id as ChannelId)) { ++ c.echo.get_or_insert_with(Echo::default).set(&taps); + } + } + } + } ++ if let Some(a) = self.acoustics.as_mut() { ++ let us = started.elapsed().as_micros() as u64; ++ a.stats.ticks += 1; ++ a.stats.tick_us += us; ++ a.stats.tick_us_max = a.stats.tick_us_max.max(us); ++ } + } + /// `SoundManager.pauseAllExcept(MUSIC, UI)`. + pub fn pause(&mut self) { +@@ -1274,6 +1481,14 @@ impl Audio { + "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, ++ "echo": { ++ "traces": t.echo_traces, "hits": t.echo_hits, "taps": t.echo_taps, ++ "cells": t.echo_cells, "ms": t.echo_us as f64 / 1e3, ++ "ms_max": t.echo_us_max as f64 / 1e3, "capped": t.echo_capped, ++ "voices": crate::acoustics::ECHO_VOICES, ++ }, ++ "tick_ms_total": t.tick_us as f64 / 1e3, "tick_ms_max": t.tick_us_max as f64 / 1e3, ++ "ticks": t.ticks, + }); + } + if let Ok(cache) = self.backend.cache.lock() { +@@ -1386,6 +1601,7 @@ mod tests { + relative: false, + voice: None, + occlusion: None, ++ echo: None, + } + } + +@@ -1757,7 +1973,7 @@ mod tests { + 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 }; ++ let source = Source { id: 1, at: [60.5, 70.5, 0.5], loud: 0.88, echo: false }; + 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)); + } +@@ -1767,6 +1983,195 @@ mod tests { + } + } + ++ /// A tap for the tests, at full level at once. ++ fn echo_with(taps: &[(f32, f32)]) -> Echo { ++ let mut e = Echo::default(); ++ let list: Vec = taps ++ .iter() ++ .map(|&(delay, level)| crate::acoustics::Tap { delay, path: 30.0, level, cut: 1.0, dir: [0.0, 0.0, -1.0] }) ++ .collect(); ++ e.set(&list); ++ for t in &mut e.taps { ++ t.level = t.level_to; ++ } ++ e ++ } ++ /// The energy of the left and right together in frames `from..to` of a stereo track. ++ fn energy(x: &[f32], from: usize, to: usize) -> f32 { ++ x.chunks(2).skip(from).take(to - from).map(|f| f[0] * f[0] + f[1] * f[1]).sum() ++ } ++ ++ #[test] ++ fn an_echo_comes_at_its_delay_and_outlives_the_sound() { ++ // 0.1 s of tone, a tap 0.2 s behind: nothing between, the tap after, then the channel ends. ++ for voiced in [false, true] { ++ let mut c = if voiced { ++ let mut c = voice_channel(tone(400.0, 4_800), [0.0, 0.0, 10.0], false, 0.005, 0.0, 0.0); ++ c.voice.as_mut().unwrap().env = 1.0; ++ c ++ } else { ++ channel(tone(400.0, 4_800), Some(500.0), [0.0, 0.0, 10.0], false) ++ }; ++ c.echo = Some(echo_with(&[(0.2, 0.5)])); ++ let mut m = Mixer::default(); ++ m.channels.insert(1, c); ++ let out = render(&mut m, 60, 480); ++ let direct = energy(&out, 0, 4_700); ++ assert!(direct > 1.0, "{voiced}: the direct sound plays {direct}"); ++ assert!(energy(&out, 5_200, 9_300) < direct * 1e-6, "{voiced}: silence between"); ++ let echo = energy(&out, 9_600, 14_600); ++ assert!(echo > direct * 0.05 && echo < direct, "{voiced}: the echo plays {echo} vs {direct}"); ++ // And it starts on its frame, to the sample. ++ let first = out.chunks(2).skip(5_000).position(|f| f[0].abs() + f[1].abs() > 1e-4).unwrap() + 5_000; ++ assert!((first as i64 - 9_600).abs() <= 3, "{voiced}: first echo frame {first}"); ++ assert_eq!(m.channels[&1].state, State::Stopped, "{voiced}: over once the last tap is"); ++ // Without the tap the channel ends with its sound. ++ let mut plain = Mixer::default(); ++ let mut c = channel(tone(400.0, 4_800), Some(500.0), [0.0, 0.0, 10.0], false); ++ c.echo = Some(Echo::default()); ++ plain.channels.insert(1, c); ++ let out = render(&mut plain, 60, 480); ++ assert!(energy(&out, 5_200, 28_800) == 0.0 && plain.channels[&1].state == State::Stopped); ++ } ++ } ++ ++ #[test] ++ fn echo_taps_slide_in_without_a_click_and_a_dropped_tap_goes_out() { ++ let mut c = channel(tone(300.0, 48_000), Some(500.0), [0.0, 0.0, 10.0], false); ++ c.echo = Some(echo_with(&[(0.1, 0.5)])); ++ let mut m = Mixer::default(); ++ m.channels.insert(1, c); ++ let before = render(&mut m, 20, 480); ++ // A second tap comes, the first goes. ++ let list = [crate::acoustics::Tap { delay: 0.3, path: 60.0, level: 0.4, cut: 0.6, dir: [1.0, 0.0, 0.0] }]; ++ m.channels.get_mut(&1).unwrap().echo.as_mut().unwrap().set(&list); ++ let after = render(&mut m, 40, 480); ++ let all: Vec = before.iter().chain(&after).copied().collect(); ++ assert!(max_step(&all) < 0.2, "no click: {}", max_step(&all)); ++ let e = m.channels[&1].echo.as_ref().unwrap(); ++ assert_eq!(e.taps.len(), 1, "the dropped tap is gone: {:?}", e.taps); ++ assert!((e.taps[0].delay - 0.3).abs() < 1e-6 && e.taps[0].level > 0.35); ++ } ++ ++ #[test] ++ fn echo_rendering_is_deterministic() { ++ let run = || { ++ let mut m = Mixer::default(); ++ for id in 1..=3u64 { ++ let mut c = voice_channel(tone(150.0 * id as f32, 9_600), [id as f32, 0.0, 9.0], false, 0.01, 0.0, 0.0); ++ c.echo = Some(echo_with(&[(0.05 * id as f32, 0.4), (0.3, 0.3)])); ++ m.channels.insert(id, c); ++ } ++ render(&mut m, 60, 333) ++ }; ++ assert_eq!(run(), run()); ++ } ++ ++ /// Writes the track of a click in a gorge, an open field and a room, through the real ++ /// acoustics and mixer, and the delays (ms) the geometry says, for `tools/acoustics_check.py ++ /// --echo`: `HORIZON_ACOUSTICS_OUT=/dir cargo test -p horizon-client --lib ++ /// audio::tests::export_echo_tracks -- --ignored`. ++ #[test] ++ #[ignore] ++ fn export_echo_tracks() { ++ use crate::acoustics::{Acoustics, Changes, Geometry, Source}; ++ struct Blocks(fn(i32, i32, i32) -> bool); ++ impl Geometry for Blocks { ++ fn walker(&self) -> Box Option + '_> { ++ Box::new(move |x, y, z| Some(if (self.0)(x, y, z) { 1.0 } else { 0.0 })) ++ } ++ } ++ let Some(dir) = std::env::var_os("HORIZON_ACOUSTICS_OUT") else { ++ return; ++ }; ++ // The delay of the specular path off the plane `axis = coord`, in ms. ++ let ms = |src: [f64; 3], lis: [f64; 3], axis: usize, coord: f64| { ++ let d = |a: [f64; 3], b: [f64; 3]| ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt(); ++ let mut image = src; ++ image[axis] = 2.0 * coord - src[axis]; ++ (d(image, lis) - d(src, lis)) / 343.0 * 1e3 ++ }; ++ let (gs, gl) = ([0.5, 66.5, 0.5], [0.5, 66.5, 30.5]); ++ let (rs, rl) = ([4.5, 66.5, 12.5], [20.5, 66.5, 12.5]); ++ type Scene = (&'static str, fn(i32, i32, i32) -> bool, [f64; 3], [f64; 3], Vec); ++ let scenes: [Scene; 3] = [ ++ ("gorge", |x, y, _| y <= 60 || x <= -40 || x >= 55, gs, gl, vec![ms(gs, gl, 0, -39.0), ms(gs, gl, 0, 55.0)]), ++ ("field", |_, y, _| y <= 60, gs, gl, vec![]), ++ // The side walls are the only ones far enough behind the direct sound to be an echo. ++ ("room", |x, y, z| !((0..24).contains(&x) && (60..70).contains(&y) && (0..24).contains(&z)), rs, rl, vec![ms(rs, rl, 2, 0.0)]), ++ ]; ++ let mut expected = String::new(); ++ for (name, solid, src, lis, want) in scenes { ++ let geometry: Arc = Arc::new(Blocks(solid)); ++ // A click: 2 ms of noise, then silence. ++ let mut seed = 99u32; ++ let click: Vec = (0..9_600) ++ .map(|i| { ++ seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); ++ if i < 96 { ((seed >> 8) as f32 / 8_388_608.0 - 1.0) * (1.0 - i as f32 / 96.0) } else { 0.0 } ++ }) ++ .collect(); ++ let mut m = Mixer::default(); ++ m.listener = lis; ++ m.channels.insert(1, channel(click, Some(500.0), [0.5, 66.5, 0.5], false)); ++ let mut acoustics = Acoustics::new(true); ++ let source = Source { id: 1, at: src, loud: 0.94, echo: true }; ++ acoustics.step(Some(&geometry), lis, &[source], &Changes::default()); ++ for (id, taps) in acoustics.take_echoes() { ++ let mut e = Echo::default(); ++ e.set(&taps); ++ for t in &mut e.taps { ++ t.level = t.level_to; ++ } ++ m.channels.get_mut(&(id as ChannelId)).unwrap().echo = Some(e); ++ } ++ 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(); ++ let list: Vec = want.iter().map(|v| format!("{v:.2}")).collect(); ++ expected.push_str(&format!("{name} {}\n", list.join(","))); ++ } ++ std::fs::write(std::path::Path::new(&dir).join("expected.txt"), expected).unwrap(); ++ } ++ ++ /// The cost of the echo taps in release: 100 looping voices and 8 one-shots, without and with ++ /// 6 taps on each of the 8: `cargo test -p horizon-client --lib --release audio::tests::bench_echo ++ /// -- --ignored --nocapture`. ++ #[test] ++ #[ignore] ++ fn bench_echo_taps() { ++ let make = |taps: 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); ++ m.channels.insert(id, c); ++ } ++ for id in 101..=108u64 { ++ let mut c = voice_channel(tone(90.0 + id as f32, 960_000), [id as f32, 1.0, 40.0], false, 0.0, 0.0, 0.0); ++ c.linear = Some(500.0); ++ if taps { ++ c.echo = Some(echo_with(&[(0.05, 0.3), (0.12, 0.3), (0.2, 0.3), (0.31, 0.2), (0.45, 0.2), (0.7, 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, taps) = (time(make(false)), time(make(true))); ++ println!("108 voices, 512 frames: {plain:.3} ms without taps, {taps:.3} ms with 8 x 6 taps ({:+.3} ms); buffer is 10.67 ms", taps - plain); ++ } ++ + /// The cost of the shelf for 100 voices, in release: `cargo test -p horizon-client --lib + /// --release audio::tests::bench -- --ignored --nocapture`. + #[test] +diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs +index 735f26d21..0fcb43243 100644 +--- a/crates/horizon-client/src/game.rs ++++ b/crates/horizon-client/src/game.rs +@@ -12501,6 +12501,12 @@ impl Game<'_> { + self.streaming + .extra + .extend(waiting.into_iter().map(|chunk| (chunk, 0))); ++ // Chunks mods asked for (`render.load-chunks`), while their ticket lasts. ++ let now = self.ticks; ++ self.mods.chunk_tickets.retain(|t| t.1 > now); ++ self.streaming ++ .extra ++ .extend(self.mods.chunk_tickets.iter().map(|t| (t.0, 0))); + let (uploads, unloaded) = + horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world)); + self.streaming.extra.truncate(areas); +diff --git a/crates/horizon-client/src/game/acoustics.rs b/crates/horizon-client/src/game/acoustics.rs +index e4c8cc400..fe2c26de5 100644 +--- a/crates/horizon-client/src/game/acoustics.rs ++++ b/crates/horizon-client/src/game/acoustics.rs +@@ -2,7 +2,7 @@ + //! 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_client::acoustics::{Changes, Geometry, Snapshot, spr_bounce, spr_occlusion}; + use horizon_world::registry::BlockStateId; + use std::sync::Arc; + +@@ -12,24 +12,25 @@ 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>)>, ++ table: Option<(u32, usize, Arc>, 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> { ++ fn acoustic_table(&mut self) -> (Arc>, Arc>) { + let epoch = self.world.epoch; + let facts = self.res.facts(); +- if let Some((e, n, t)) = &self.mods.acoustic.table ++ if let Some((e, n, t, r)) = &self.mods.acoustic.table + && *e == epoch + && *n == facts.states.len() + { +- return t.clone(); ++ return (t.clone(), r.clone()); + } + let cached = self.collision.cached_shapes(); + let mut table = vec![0.0f32; facts.states.len()]; ++ let mut reflect = vec![spr_bounce::DEFAULT_REFLECTIVITY; facts.states.len()]; + for (i, f) in facts.states.iter().enumerate() { + let state = BlockStateId(i as u32); + if facts.is_air(state) { +@@ -51,11 +52,12 @@ impl Game<'_> { + } + let group = spr_occlusion::group_of(&f.sounds.step); + let v = spr_occlusion::group_occlusion(group); ++ reflect[i] = spr_bounce::group_reflectivity(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 ++ let (table, reflect) = (Arc::new(table), Arc::new(reflect)); ++ self.mods.acoustic.table = Some((epoch, facts.states.len(), table.clone(), reflect.clone())); ++ (table, reflect) + } + + /// The snapshot of the chunks round the listener, made again when a block changed or the +@@ -70,7 +72,7 @@ impl Game<'_> { + { + return s.clone(); + } +- let table = self.acoustic_table(); ++ let (table, reflect) = self.acoustic_table(); + let (cx, cz) = ((at[0].floor() as i32) >> 4, (at[2].floor() as i32) >> 4); + let chunks = self + .world +@@ -82,6 +84,7 @@ impl Game<'_> { + let snapshot = Arc::new(Snapshot { + chunks, + table, ++ reflect, + epoch: self.world.epoch, + revision: self.world.revision, + tick: self.ticks as u64, +diff --git a/crates/horizon-client/src/game/integrated.rs b/crates/horizon-client/src/game/integrated.rs +index df40ff377..8a17590a3 100644 +--- a/crates/horizon-client/src/game/integrated.rs ++++ b/crates/horizon-client/src/game/integrated.rs +@@ -90,6 +90,10 @@ impl Integrated { + pub fn published(&self) -> Option { + Some(self.flags.published.load(Ordering::Relaxed)).filter(|&p| p != 0) + } ++ /// `MinecraftServer.execute`: run `f` on the server thread between two ticks, without waiting. ++ pub fn execute(&self, f: impl FnOnce(&mut Game<'_>) + Send + 'static) { ++ let _ = self.tasks.send(Box::new(f)); ++ } + /// `executeBlocking`: run `f` on the server thread between two ticks and + /// wait for its answer; `None` once the server has stopped. + pub fn call( +diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs +index 83fdaf4be..a28efb006 100644 +--- a/crates/horizon-client/src/game/lan.rs ++++ b/crates/horizon-client/src/game/lan.rs +@@ -2041,6 +2041,15 @@ impl Game<'_> { + .filter(|g| host.hub.here(g)) + .map(|g| (g.chunk(), radius)) + .collect(); ++ // The chunks guests' mods asked for (`render.load-chunks`): loaded as their own small areas. ++ let now = host.hub.ticks; ++ self.streaming.extra.extend( ++ host.hub ++ .guests ++ .iter() ++ .filter(|g| host.hub.here(g)) ++ .flat_map(|g| g.mod_chunks.iter().filter(|t| t.1 > now).map(|t| (t.0, 0))), ++ ); + } + + /// The guests' models, for the host to see them. +diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs +index edece10f7..8a710054a 100644 +--- a/crates/horizon-client/src/game/mods.rs ++++ b/crates/horizon-client/src/game/mods.rs +@@ -37,6 +37,12 @@ use { + winit::{event::MouseButton, keyboard::KeyCode}, + }; + ++/// Chunks mods may have loaded at once, and how long one is held after its last request (20 s). ++#[cfg(feature = "mods")] ++const CHUNK_TICKETS: usize = 64; ++#[cfg(feature = "mods")] ++const CHUNK_TICKET_TICKS: u64 = 400; ++ + #[derive(Default)] + pub(super) struct ModsState { + /// The acoustics' view of the world (AirDef fork, #96). +@@ -45,6 +51,9 @@ pub(super) struct ModsState { + pub(super) world_id: Option, + #[cfg(feature = "mods")] + pub(super) mods: Mods, ++ /// The chunks mods asked to have loaded (`render.load-chunks`, AirDef fork) and the game ++ /// tick each is held until; at most `CHUNK_TICKETS`, the oldest request goes first. ++ pub(super) chunk_tickets: Vec<((i32, i32), u64)>, + /// Capability `input`: a claimed item's use while the button is down. + #[cfg(feature = "mods")] + pub(super) using: Option, +@@ -689,10 +698,11 @@ impl Game<'_> { + return; + } + self.tick_mod_use(); ++ self.take_mod_chunk_requests(); + for (i, columns) in self.mods.mods.take_ground_queries() { +- let ground = columns ++ let ground: Vec<(i32, i32, i32)> = columns + .into_iter() +- .map(|(x, z)| (x, z, self.rain_height(x, z))) ++ .map(|(x, z)| (x, z, self.mod_ground(x, z))) + .collect(); + self.mods.mods.answer_ground(i, ground); + } +@@ -717,6 +727,57 @@ impl Game<'_> { + self.show_mod_output(); + self.sync_mod_sources(); + } ++ /// `render.load-chunks` (API level 22): the chunks mods asked for become tickets held for ++ /// `CHUNK_TICKET_TICKS`; `update_world` keeps them loaded as extra areas of the streaming, ++ /// whose workers generate or read them. Nothing where the chunks come from a server. ++ fn take_mod_chunk_requests(&mut self) { ++ let asked = self.mods.mods.take_chunk_requests(); ++ if asked.is_empty() && self.mods.chunk_tickets.is_empty() { ++ return; ++ } ++ // The window's own server thread holds and sends them (`Hub::set_mod_chunks`); a server of someone ++ // else's, or a LAN host's, is not ours to load for. ++ let own = self.integrated.is_some(); ++ if (self.remote.is_some() && !own) || (self.streaming.remote && !own) { ++ self.mods.chunk_tickets.clear(); ++ return; ++ } ++ if own && !asked.is_empty() { ++ let chunks = asked.clone(); ++ if let Some(server) = &self.integrated { ++ server.execute(move |game| { ++ let owner = game.serving.as_ref().map(|s| s.owner); ++ if let (Some(owner), Some(host)) = (owner, game.lan.as_mut()) { ++ host.hub.set_mod_chunks(owner, &chunks); ++ } ++ }); ++ } ++ } ++ let until = self.ticks + CHUNK_TICKET_TICKS; ++ let tickets = &mut self.mods.chunk_tickets; ++ for chunk in asked { ++ match tickets.iter_mut().find(|t| t.0 == chunk) { ++ Some(t) => t.1 = until, ++ None => tickets.push((chunk, until)), ++ } ++ } ++ // The least recently asked go when over the limit. ++ if tickets.len() > CHUNK_TICKETS { ++ tickets.sort_by_key(|t| std::cmp::Reverse(t.1)); ++ tickets.truncate(CHUNK_TICKETS); ++ } ++ } ++ /// `render.query-ground`'s answer: the heightmap, or `i32::MIN` for a column whose chunk a ++ /// mod asked to load and which is not there yet. ++ fn mod_ground(&self, x: i32, z: i32) -> i32 { ++ let key = (x >> 4, z >> 4); ++ if !self.world.chunks.contains_key(&key) ++ && self.mods.chunk_tickets.iter().any(|t| t.0 == key) ++ { ++ return i32::MIN; ++ } ++ self.rain_height(x, z) ++ } + /// The mods' persistent sound sources (API level 20): their changes go to the mixer, and the + /// sources of a mod that no longer runs are released. The generation the mods read is the + /// audio's: whenever it released every source (world closed, film begun), they start again. +diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs +index 89df0616b..0c0add753 100644 +--- a/crates/horizon-client/src/game/remote.rs ++++ b/crates/horizon-client/src/game/remote.rs +@@ -1111,10 +1111,12 @@ impl Game<'_> { + /// view centre, `calculateStorageRange` (the server's radius, at least + /// two, plus three) chunks each way. + pub(super) fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool { +- self.remote.as_ref().is_none_or(|r| { +- let range = r.view_distance.max(2) + 3; +- (x - r.chunk_center.0).abs() <= range && (z - r.chunk_center.1).abs() <= range +- }) ++ // A chunk a mod asked the window's own server for (`render.load-chunks`) is kept for its ticket. ++ self.mods.chunk_tickets.iter().any(|t| t.0 == (x, z)) ++ || self.remote.as_ref().is_none_or(|r| { ++ let range = r.view_distance.max(2) + 3; ++ (x - r.chunk_center.0).abs() <= range && (z - r.chunk_center.1).abs() <= range ++ }) + } + /// The columns a moved or resized storage no longer holds. + fn drop_remote_chunks_out_of_range(&mut self) { +diff --git a/crates/horizon-client/src/gfx/hd/light.wgsl b/crates/horizon-client/src/gfx/hd/light.wgsl +index 6505c5c76..92a971956 100644 +--- a/crates/horizon-client/src/gfx/hd/light.wgsl ++++ b/crates/horizon-client/src/gfx/hd/light.wgsl +@@ -528,18 +528,24 @@ fn fs_light(in: FullOut) -> LightOut { + let beam_len = select(1100.0, length(ps), d > 0.0); + air_s += spot_beams(view_s, beam_len, ign(in.position.xy)); + } ++ var halos = vec3(0.0); + for (var k = 0u; k < halo_count; k++) { + let l = lights[select(tiles[halo_start + k], k, halo_all)]; + // Nearest point of the view ray to the light, and the glow along the ray in front of it. + let t0 = dot(l.pos.xyz, view_s); + let hh = length(l.pos.xyz - view_s * t0) + 0.5; +- if hh > l.pos.w * 0.5 { continue; } ++ let reach = l.pos.w * 0.5; ++ if hh > reach { continue; } + let a0 = atan((0.0 - t0) / hh); + let a1 = atan((far - t0) / hh); ++ // It fades out before its reach: no hard edge round a bright light. ++ let fade = 1.0 - smoothstep(reach * 0.25, reach, hh); + // Thicker air glows more round a light, but only so far: the mist at dusk and many fires + // at once would otherwise wash the picture out. +- air_s += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + min(density * 300.0, 0.9)); ++ halos += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + min(density * 300.0, 0.9)) * fade; + } ++ // However many blasts at once: a glow in the air, never a white wall. ++ air_s += halos / (1.0 + max(max(halos.r, halos.g), halos.b) / 1.2); + var out: LightOut; + if water { + out.lit = vec4(c, 0.5); +diff --git a/crates/horizon-client/src/gfx/hd/spot.wgsl b/crates/horizon-client/src/gfx/hd/spot.wgsl +index 726bfe312..a208fd9bf 100644 +--- a/crates/horizon-client/src/gfx/hd/spot.wgsl ++++ b/crates/horizon-client/src/gfx/hd/spot.wgsl +@@ -4,7 +4,9 @@ + // `frame.vfog.w`. The shader that includes this defines `spot_air(pos) -> f32`, the air's + // extinction per block at a camera-relative point. + +-const SPOT_BEAM_GAIN: f32 = 260.0; ++const SPOT_BEAM_GAIN: f32 = 170.0; ++// The sum of all beams per pixel is soft-limited to about this: any number overlapping stays a beam. ++const SPOT_BEAM_LIMIT: f32 = 0.5; + const SPOT_BEAM_STEPS: i32 = 8; + // Even clear air shows a faint beam: extinction per block, at least. + const SPOT_AIR_FLOOR: f32 = 0.0009; +@@ -25,7 +27,7 @@ fn spot_light(j: i32, p: vec3, n: vec3) -> vec3 { + // The range is where the beam is free: the light stops there over a few blocks. + let window = clamp((a.w - dist) / 3.0, 0.0, 1.0); + let facing = max(dot(n, l) * 0.9 + 0.1, 0.0); +- return c.rgb * (c.w / (dist * dist + 1.0)) * edge * window * facing; ++ return c.rgb * (c.w / (dist * dist + 400.0)) * edge * window * facing; + } + + // The part of the ray from the camera along v, within [0, len], that is inside spot j's cone and +@@ -92,7 +94,7 @@ fn spot_beams(v: vec3, len: f32, jitter: f32) -> vec3 { + // Looking into the lamp the beam scatters forward: brighter. + let cos_s = dot(b.xyz, -v); + let g = 0.55; +- let phase = 0.4 + 0.25 * min((1.0 - g * g) / pow(1.0 + g * g - 2.0 * g * cos_s, 1.5), 8.0); ++ let phase = 0.4 + 0.12 * min((1.0 - g * g) / pow(1.0 + g * g - 2.0 * g * cos_s, 1.5), 5.0); + // The air between the camera and the beam's first point. + let first = v * span.x; + let through0 = exp(-spot_air(first) * span.x); +@@ -112,5 +114,5 @@ fn spot_beams(v: vec3, len: f32, jitter: f32) -> vec3 { + } + sum += c.rgb * (acc * ds * phase * k.y * SPOT_BEAM_GAIN) * through0; + } +- return sum; ++ return sum / (1.0 + max(sum.x, max(sum.y, sum.z)) / SPOT_BEAM_LIMIT); + } +diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs +index cc53eeaed..6b846c746 100644 +--- a/crates/horizon-client/src/lan/guest.rs ++++ b/crates/horizon-client/src/lan/guest.rs +@@ -151,6 +151,9 @@ pub struct Guest { + pub center: Option<(i32, i32)>, + pub sent_chunks: crate::fast_hash::FxHashSet<(i32, i32)>, + pub chunk_queue: Vec<(i32, i32)>, ++ /// Chunks the player's mod asked to have loaded beyond the view (AirDef fork, `render.load-chunks`), and ++ /// the hub tick each is held until: sent like the view's chunks, forgotten when the ticket ends. ++ pub mod_chunks: Vec<((i32, i32), u64)>, + /// PlayerChunkSender: batches sent but not yet acknowledged, how many + /// may be (1 until the client's first acknowledgement, then 10), the + /// rate the client asks for and the chunks the next batch may hold. +@@ -317,6 +320,7 @@ impl Guest { + center: None, + sent_chunks: Default::default(), + chunk_queue: Vec::new(), ++ mod_chunks: Vec::new(), + unacknowledged: 0, + max_unacknowledged: 1, + chunks_per_tick: 9.0, +diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs +index 8f1845de1..dda7931aa 100644 +--- a/crates/horizon-client/src/lan/hub.rs ++++ b/crates/horizon-client/src/lan/hub.rs +@@ -294,6 +294,10 @@ fn entity_type( + + /// ChunkTrackingView.contains: the chunks a player at (cx, cz) with view + /// distance `r` is sent (isWithinDistance with the neighbours' buffer). ++/// Chunks a player's mod may have loaded beyond the view at once, and how long (20 s) one is held. ++pub const MOD_CHUNKS: usize = 64; ++pub const MOD_CHUNK_TICKS: u64 = 400; ++ + fn tracked(cx: i32, cz: i32, r: i32, x: i32, z: i32) -> bool { + let dx = i64::from(((x - cx).abs() - 2).max(0)); + let dz = i64::from(((z - cz).abs() - 2).max(0)); +@@ -1363,6 +1367,7 @@ impl Hub { + for i in 0..self.guests.len() { + if self.here(&self.guests[i]) { + self.update_view(i); ++ self.update_mod_chunks(i); + if let Some(batch) = self.next_batch(i, world) { + batches.push((i, batch)); + } +@@ -1422,6 +1427,49 @@ impl Hub { + } + } + ++ /// The chunks a player's mod asked for (`set_mod_chunks`): queued (after the view's) while their ticket ++ /// lasts, forgotten when it ends unless the view holds them. ++ fn update_mod_chunks(&mut self, i: usize) { ++ let now = self.ticks; ++ let g = &mut self.guests[i]; ++ if g.mod_chunks.is_empty() { ++ return; ++ } ++ let (cx, cz) = g.chunk(); ++ let r = g.view_distance; ++ let ended: Vec<(i32, i32)> = g.mod_chunks.iter().filter(|t| t.1 <= now).map(|t| t.0).collect(); ++ g.mod_chunks.retain(|t| t.1 > now); ++ for c in ended { ++ if !tracked(cx, cz, r, c.0, c.1) && g.sent_chunks.remove(&c) { ++ g.send(&C::ForgetLevelChunk(play::ForgetLevelChunk { pos: [c.0, c.1] })); ++ } ++ g.chunk_queue.retain(|q| *q != c || tracked(cx, cz, r, c.0, c.1)); ++ } ++ let wanted: Vec<(i32, i32)> = g.mod_chunks.iter().map(|t| t.0).collect(); ++ for c in wanted { ++ if !g.sent_chunks.contains(&c) && !g.chunk_queue.contains(&c) { ++ g.chunk_queue.push(c); ++ } ++ } ++ } ++ ++ /// A player's mod asked for these chunks (`render.load-chunks`): held [`MOD_CHUNK_TICKS`] from now, at ++ /// most [`MOD_CHUNKS`] at once (the latest requests stay). ++ pub fn set_mod_chunks(&mut self, uuid: u128, chunks: &[(i32, i32)]) { ++ let until = self.ticks + MOD_CHUNK_TICKS; ++ let Some(g) = self.guests.iter_mut().find(|g| g.uuid == uuid) else { return }; ++ for &c in chunks { ++ match g.mod_chunks.iter_mut().find(|t| t.0 == c) { ++ Some(t) => t.1 = until, ++ None => g.mod_chunks.push((c, until)), ++ } ++ } ++ if g.mod_chunks.len() > MOD_CHUNKS { ++ g.mod_chunks.sort_by_key(|t| std::cmp::Reverse(t.1)); ++ g.mod_chunks.truncate(MOD_CHUNKS); ++ } ++ } ++ + /// ChunkMap.move's view change: the new centre, the chunks that left + /// the view forgotten and the ones that entered it queued, closest first. + fn update_view(&mut self, i: usize) { +@@ -1443,7 +1491,7 @@ impl Hub { + .sent_chunks + .iter() + .copied() +- .filter(|&(x, z)| !tracked(cx, cz, r, x, z)) ++ .filter(|&(x, z)| !tracked(cx, cz, r, x, z) && !g.mod_chunks.iter().any(|t| t.0 == (x, z))) + .collect(); + for (x, z) in forget { + g.sent_chunks.remove(&(x, z)); +diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit +index 5bf3fa665..a055af8bd 100644 +--- a/crates/horizon-mod-sdk/wit/horizon.wit ++++ b/crates/horizon-mod-sdk/wit/horizon.wit +@@ -680,8 +680,18 @@ interface render { + query-ground: func(columns: 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. ++ /// blocking heightmap; -64 where the chunk isn't loaded; -2147483648 ++ /// (`i32::MIN`) where it isn't loaded yet but `load-chunks` has it on its ++ /// way: ask again in a few ticks. + ground: func() -> list>; ++ /// Has the game load these chunks (chunk x, chunk z) for a while (AirDef ++ /// fork, API level 22): generated or read from disk on the game's worker ++ /// threads, meshed never, and kept loaded for 20 seconds after the last ++ /// call that named them. At most 64 chunks are wanted at once (the ++ /// oldest requests fall away first; 64 a call). On a server, in a world ++ /// the game does not simulate itself, or with nothing running, it does ++ /// nothing, and `ground` keeps answering -64 for the unloaded. ++ load-chunks: func(chunks: list>); + /// What a block is to a query (AirDef fork): the three policies differ + /// on purpose, so a sensor and a missile may disagree about a pane of + /// glass. +diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs +index ee826712f..8894fad5b 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 = 21; ++pub const LEVEL: u32 = 22; + + /// 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); 83] = [ ++pub const FUNCTIONS: [(&str, u32); 84] = [ + ("log.write", 0), + ("game.get-player", 0), + ("game.get-clock", 0), +@@ -108,6 +108,8 @@ pub const FUNCTIONS: [(&str, u32); 83] = [ + ("render.sound-sources", 20), + // AirDef fork: whether the game muffles sounds by what is between them and the listener. + ("render.acoustics", 21), ++ // AirDef fork: the game loads chunks for a mod for a while. ++ ("render.load-chunks", 22), + ]; + + /// 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 182ea7f3f..4b31c8dce 100644 +--- a/crates/horizon-mods/src/runtime.rs ++++ b/crates/horizon-mods/src/runtime.rs +@@ -513,6 +513,8 @@ struct State { + model_table: ModelSlot, + /// `render.query-ground`'s columns, answered before the next tick. + ground_queries: Vec<(i32, i32)>, ++ /// `render.load-chunks`: the chunks asked for since the game last took them. ++ chunk_requests: Vec<(i32, i32)>, + /// The game's answers to them. + ground: Vec<(i32, i32, i32)>, + /// `render.query-rays`' segments, answered before the next tick. +@@ -782,6 +784,7 @@ impl Mods { + models: Vec::new(), + model_table: model_table.clone(), + ground_queries: Vec::new(), ++ chunk_requests: Vec::new(), + ground: Vec::new(), + ray_queries: Vec::new(), + ray_over: Vec::new(), +@@ -1548,6 +1551,10 @@ impl Mods { + } + /// The columns each running mod asked the ground height of, taken. + pub fn take_ground_queries(&mut self) -> Vec<(usize, Vec<(i32, i32)>)> { ++ // The last answers are read once: a mod that asked nothing since gets none (not the same again). ++ for run in self.entries.iter_mut().filter_map(|e| e.run.as_mut()) { ++ run.store.data_mut().ground.clear(); ++ } + self.entries + .iter_mut() + .enumerate() +@@ -1557,6 +1564,14 @@ impl Mods { + }) + .collect() + } ++ /// The chunks the running mods asked the game to load (`render.load-chunks`), taken. ++ pub fn take_chunk_requests(&mut self) -> Vec<(i32, i32)> { ++ self.entries ++ .iter_mut() ++ .filter_map(|e| Some(std::mem::take(&mut e.run.as_mut()?.store.data_mut().chunk_requests))) ++ .flatten() ++ .collect() ++ } + /// The first running mod's post pass (version, WGSL) and its parameters. + pub fn post(&self) -> Option<(u64, &str, &[f32])> { + self.entries.iter().find_map(|e| { +@@ -2663,6 +2678,10 @@ impl render::Host for State { + let room = 512usize.saturating_sub(self.ground_queries.len()); + self.ground_queries.extend(columns.into_iter().take(room)); + } ++ fn load_chunks(&mut self, chunks: Vec<(i32, i32)>) { ++ let room = 64usize.saturating_sub(self.chunk_requests.len()); ++ self.chunk_requests.extend(chunks.into_iter().take(room)); ++ } + fn ground(&mut self) -> Vec<(i32, i32, i32)> { + self.ground.clone() + } +diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs +index ccd1f6527..f586ce0ec 100644 +--- a/crates/horizon-mods/tests/ui_guest/src/lib.rs ++++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs +@@ -269,6 +269,7 @@ impl Mod for Guest { + "source" | "source_keep" => { + let g = render::sound_source_generation(); + let _ = render::acoustics(); ++ render::load_chunks(&[(0, 0)]); + let op = |kind| render::SourceOp { + kind, + id: 1, +diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md +index bcbd91ebe..9ce18e77a 100644 +--- a/docs/api/mods-reference.md ++++ b/docs/api/mods-reference.md +@@ -806,7 +806,8 @@ Where a capture is. + | `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. | ++| `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; -2147483648 (`i32::MIN`) where it isn't loaded yet but `load-chunks` has it on its way: ask again in a few ticks. | ++| `load-chunks` | `func(chunks: list>)` | Has the game load these chunks (chunk x, chunk z) for a while (AirDef fork, API level 22): generated or read from disk on the game's worker threads, meshed never, and kept loaded for 20 seconds after the last call that named them. At most 64 chunks are wanted at once (the oldest requests fall away first; 64 a call). On a server, in a world the game does not simulate itself, or with nothing running, it does nothing, and `ground` keeps answering -64 for the unloaded. | + | `query-rays` | `func(rays: list)` | Traces segments through the loaded blocks (AirDef fork): each is walked block by block on the host, against the real collision shapes (`policy`), and answered by `rays` from the next tick on. At most 256 segments a tick, each at most 512 blocks long (longer ones are answered `unknown` past that); over the budget they are answered `unknown` too, never `clear`. A game with no chunk at a place answers `unknown` for it. | + | `rays` | `func() -> list` | The answers to the last tick's `query-rays`, in the order asked. | + | `query-sections` | `func(policy: policy, sections: list>)` | Asks what these sections hold (AirDef fork), for a mod that traces its own paths against a cache of them: at most 64 a tick, answered by `sections` from the next tick on. Over the budget they are not answered: ask again. | +diff --git a/docs/api/mods.md b/docs/api/mods.md +index 73c3cf780..9adccabf8 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 22 (AirDef fork, capability `render`): `load-chunks` has the game load chunks for 20 seconds after the last ++ request that names them (at most 64 at once): generated or read from disk on the worker threads, not meshed. `ground` ++ answers `i32::MIN` for a column of such a chunk that is not there yet, -64 for one that is not coming. Nothing happens ++ on a server or in a LAN-hosted world. + - 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 +-- +2.54.0 (Apple Git-157) + diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..f3b44a5 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +# The version the fork and the mod are built with (tools/airdef.py): rustup reads this file for +# every folder under the repo, horizon/ and airdef-mod/ included. Bump it deliberately, with the fork. +channel = "1.99.1" +targets = ["wasm32-unknown-unknown"] diff --git a/tools/acoustics_check.py b/tools/acoustics_check.py index 9f745b5..9f4f95d 100644 --- a/tools/acoustics_check.py +++ b/tools/acoustics_check.py @@ -9,6 +9,14 @@ Для каждой дорожки считается энергия левого канала в четырёх полосах (до 300 Гц, 300-1500, 1500-5000, выше 5000) и разница в дБ между дорожками. Преграда должна срезать верхи (выше 5 кГц не меньше чем на 15 дБ) и оставить низы (до 300 Гц теряют не больше 8 дБ). Коды выхода: 0 — в порогах, 1 — нет, 2 — ошибка аргументов. +Режим `--echo` (стадия 2): в дорожке с одним щелчком ищутся пики огибающей после прямого звука, их задержки +(мс от прямого) сравниваются с ожидаемыми от геометрии: + + python3 tools/acoustics_check.py --echo gorge.raw --expect 187.3,254.1 [--tol 1.0] + python3 tools/acoustics_check.py --echo field.raw --expect none + python3 tools/acoustics_check.py --echo-dir папка # gorge/field/room и expected.txt из export_echo_tracks + +Дорожки пишет `audio::tests::export_echo_tracks` (HORIZON_ACOUSTICS_OUT=папка). Коды выхода те же. Только стандартная библиотека. """ import argparse @@ -53,19 +61,75 @@ def bands(path, rate, channels): return out +def echo_peaks(path, rate, channels, min_gap_ms=10.0, rel=0.02): + """Delays (ms from the first peak) of the peaks of the smoothed envelope of a track.""" + raw = array.array("h") + with open(path, "rb") as f: + data = f.read() + raw.frombytes(data[: len(data) // 2 * 2]) + frames = [sum(abs(raw[i + c]) for c in range(channels)) / 32768.0 for i in range(0, len(raw) - channels + 1, channels)] + win = max(1, rate // 1000) # 1 ms + env = [] + acc = 0.0 + for i, v in enumerate(frames): + acc += v + if i >= win: + acc -= frames[i - win] + env.append(acc / win) + first = max(range(min(len(env), rate // 20)), key=env.__getitem__) # the direct sound: loudest in the first 50 ms + top = env[first] + gap = int(min_gap_ms * rate / 1000) + peaks, i = [], first + int(0.02 * rate) + while i < len(env): + if env[i] >= rel * top: + j = max(range(i, min(len(env), i + gap)), key=env.__getitem__) + peaks.append((j - first) * 1000.0 / rate) + i = j + gap + else: + i += 1 + return peaks + + +def check_echo(path, expect, tol, rate, channels): + peaks = echo_peaks(path, rate, channels) + print(f"{path}: peaks at {', '.join(f'{p:.1f}' for p in peaks) or 'none'} ms; expected {', '.join(f'{e:.1f}' for e in expect) or 'none'} ms") + ok = len(peaks) == len(expect) and all(abs(p - e) <= tol for p, e in zip(peaks, expect)) + print(f" {'ok' if ok else 'FAIL'} (tolerance {tol} ms)") + return ok + + 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("open", nargs="?") + p.add_argument("wall", nargs="?") + p.add_argument("--echo", help="track with one click: check the delays of its echoes") + p.add_argument("--echo-dir", help="folder written by export_echo_tracks: check gorge, field and room") + p.add_argument("--expect", default="none", help="expected delays in ms, comma separated, or none") + p.add_argument("--tol", type=float, default=1.0, help="ms a peak may be off") 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() + if a.echo_dir: + import os + ok = True + with open(os.path.join(a.echo_dir, "expected.txt")) as f: + for line in f: + name, _, vals = line.strip().partition(" ") + want = [float(v) for v in vals.split(",") if v] + # The ms in the room are for the walls the geometry says; only the first peak is compared there. + ok &= check_echo(os.path.join(a.echo_dir, name + ".raw"), want, a.tol, a.rate, a.channels) + return 0 if ok else 1 + if a.echo: + want = [] if a.expect == "none" else [float(v) for v in a.expect.split(",")] + return 0 if check_echo(a.echo, want, a.tol, a.rate, a.channels) else 1 + if not a.open or not a.wall: + p.error("give open.raw and wall.raw, or --echo / --echo-dir") 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) diff --git a/tools/airdef.py b/tools/airdef.py index 257650c..79f0506 100644 --- a/tools/airdef.py +++ b/tools/airdef.py @@ -7,17 +7,21 @@ python3 tools/airdef.py install # положить мод в папку игры и включить python3 tools/airdef.py play [аргументы игры] # запустить форк на папке игры python3 tools/airdef.py all # всё по порядку: fork, game, mod, install + python3 tools/airdef.py check # совпадают ли форк, упаковщик, уровень API мода и ресурсы Переменные окружения: HORIZON_HOME (папка игры), HORIZON_RESOURCES (подготовленные ресурсы Minecraft 26.3), AIRDEF_GAME (готовый horizon-client вместо собранного). """ import glob import hashlib +import json import os import platform +import re import shutil import subprocess import sys +import zipfile from pathlib import Path ROOT = Path(__file__).resolve().parent.parent @@ -165,11 +169,122 @@ def play(args): run([exe, "--resources", resources(), *args], cwd=home) +def fork_api(): + """Новейший уровень API форка и его таблица функций (horizon-mods/src/api_level.rs); None без форка.""" + try: + text = (FORK / "crates" / "horizon-mods" / "src" / "api_level.rs").read_text(encoding="utf-8") + except OSError: + return None + try: + level = int(re.search(r"pub const LEVEL: u32 = (\d+);", text).group(1)) + table = text[text.index("pub const FUNCTIONS"):] + table = table[:table.index("];")] + except (AttributeError, ValueError): + return None + return level, [(name, int(lvl)) for name, lvl in re.findall(r'\("([a-z.\-]+)", (\d+)\)', table)] + + +def needed_level(wasm: bytes, table) -> tuple: + """Уровень, который нужен модулю: самая новая функция, которую он импортирует (как api_level::needed). + + В компоненте имя функции записано как 0x00, длина, UTF-8 имя; по этим байтам и ищем.""" + need = (0, None) + for name, level in table: + part = name.split(".", 1)[1].encode() + if level > need[0] and b"\x00" + bytes([len(part)]) + part in wasm: + need = (level, name) + return need + + +def game_level(): + """Уровень, который игра записала в себя (метка horizon-mod-api-level:NNNN;); None, если игры нет. + Игра без метки считается уровня 0, как в api_level::read_mark.""" + try: + exe = built_game() + except SystemExit: + return None + if not exe.exists(): + return None + found = re.search(rb"horizon-mod-api-level:(\d{4});", exe.read_bytes()) + return int(found.group(1)) if found else 0 + + +def check(): + """Говорит прямо, совпадают ли форк, упаковщик, уровень API мода с игрой и ресурсы. Код 0 — да, 1 — нет.""" + commit = (ROOT / "HORIZON_COMMIT").read_text().strip() + patches = sorted((ROOT / "horizon-patches").glob("*.patch")) + stamp = fork_stamp(commit, patches) + problems = [] + + def line(name, ok, text): + print(f"[{'ok' if ok else 'нет'}] {name}: {text}") + if not ok: + problems.append(name) + + stamp_file = FORK / ".git" / "airdef-stamp" + fork_ok = stamp_file.exists() and stamp_file.read_text().strip() == stamp + line("форк", fork_ok, f"коммит {commit[:12]}, патчей {len(patches)}" if fork_ok else + "horizon/ не собран из HORIZON_COMMIT и horizon-patches/: python3 tools/airdef.py fork") + + tool = shutil.which("cargo-horizon-mod") or str(Path.home() / ".cargo" / "bin" / ("cargo-horizon-mod" + EXE)) + packer_stamp = FORK / ".git" / "airdef-packer-stamp" + packer_ok = Path(tool).exists() and packer_stamp.exists() and packer_stamp.read_text().strip() == stamp + line("упаковщик", packer_ok, "собран из этого форка" if packer_ok else + "не установлен или собран из другого форка: python3 tools/airdef.py mod") + + api = fork_api() + try: + mod_file = hmod() + except SystemExit: + mod_file = None + if api is None: + line("уровень API", False, "нет или не читается horizon/crates/horizon-mods/src/api_level.rs: python3 tools/airdef.py fork") + elif mod_file is None: + line("уровень API", False, "нет airdef.hmod: python3 tools/airdef.py mod") + else: + level, table = api + with zipfile.ZipFile(mod_file) as z: + wasm = z.read("mod.wasm") if "mod.wasm" in z.namelist() else b"" + need, function = needed_level(wasm, table) + game = game_level() + text = f"мод нужен {need} ({function}), форк {level}, игра " + ("не собрана" if game is None else str(game)) + if game is None: + line("уровень API", False, text + ": python3 tools/airdef.py game") + else: + # Сравнивается уровень, не код: одинаковый уровень не доказывает одинаковые патчи. + supported = need <= game and game == level + line("уровень API", supported, text if supported else + text + ": игра не поддерживает мод или собрана из другого форка") + + try: + res = resources() + except SystemExit as e: + res = None + line("ресурсы", False, str(e.code)) + if res is not None: + try: + preparer = (FORK / "crates" / "horizon-prepare" / "src" / "lib.rs").read_text(encoding="utf-8") + schema = int(re.search(r"pub const SCHEMA: i64 = (\d+);", preparer).group(1)) + except (OSError, AttributeError): + schema = None + try: + manifest = json.loads((res / "horizon-resources.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + manifest = {} + ok = schema is not None and manifest.get("schema") == schema and manifest.get("minecraft") == "26.3" + line("ресурсы", ok, f"{res}, схема {schema}, Minecraft 26.3" if ok else + f"{res}: схема {manifest.get('schema')}, Minecraft {manifest.get('minecraft')}; нужны схема {schema} и Minecraft 26.3") + + if problems: + sys.exit("Не совпадает: " + ", ".join(problems) + ".") + print("Всё совпадает: форк, упаковщик, уровень API мода и ресурсы.") + + def main(): if len(sys.argv) < 2: sys.exit(__doc__) cmd, rest = sys.argv[1], sys.argv[2:] - steps = {"fork": fork, "game": game, "mod": mod, "install": install} + steps = {"fork": fork, "game": game, "mod": mod, "install": install, "check": check} if cmd == "play": play(rest) elif cmd == "all": diff --git a/tools/gen_models.py b/tools/gen_models.py index def963e..23cb473 100644 --- a/tools/gen_models.py +++ b/tools/gen_models.py @@ -5,14 +5,21 @@ Поворачивает и масштабирует их движок форка (render.set-models), так что ракурсы больше не нужны. Координаты элементов — в пределах -16..32 (иначе Horizon отбрасывает модель). -Запуск: python3 tools/gen_models.py +Запуск: python3 tools/gen_models.py [--check] + +Скрипт пишет во временный каталог и только потом переносит результат в airdef-mod/. Список созданных им файлов +с sha256 лежит в airdef-mod/generated-manifest.txt (формат sha256sum). Удаляются и перезаписываются только файлы +из этого списка и только если их не правили вручную; чужой файл на пути генерируемого останавливает запуск. +--check ничего не пишет и выходит с кодом 1, если airdef-mod/ отличается от того, что дал бы запуск. """ +import hashlib import json import math import os import sys import random import shutil +import tempfile import zlib from PIL import Image @@ -1692,13 +1699,10 @@ def write_json(path, obj): json.dump(obj, f, ensure_ascii=False, indent=1) -def main(): - # Только своё: звуки (tools/gen_sounds.py) и sounds.json не трогаем. - for sub in ("blockstates", "items", "lang", "models", "textures"): - shutil.rmtree(os.path.join(ROOT, "assets", NS, sub), ignore_errors=True) - shutil.rmtree(os.path.join(ROOT, "data"), ignore_errors=True) - a = os.path.join(ROOT, "assets", NS) - d = os.path.join(ROOT, "data", NS, "horizon") +def generate(out): + # Пишет весь набор в каталог out (зеркало airdef-mod/). Звуки (tools/gen_sounds.py) и sounds.json не трогаем. + a = os.path.join(out, "assets", NS) + d = os.path.join(out, "data", NS, "horizon") os.makedirs(os.path.join(a, "textures", "block")) os.makedirs(os.path.join(a, "textures", "item")) lang = {} @@ -1738,7 +1742,111 @@ def main(): lang[f"item.{NS}.cam_{name}"] = title write_json(os.path.join(a, "lang", "en_us.json"), lang) write_json(os.path.join(a, "lang", "ru_ru.json"), lang) - print(f"{len(MODELS)} моделей, {len(ITEMS)} предметов, {len(TEXTURES)} текстур -> {ROOT}") + return len(MODELS), len(ITEMS), len(TEXTURES) + + +MANIFEST = os.path.join(ROOT, "generated-manifest.txt") + + +def file_sha(path): + with open(path, "rb") as f: + return hashlib.sha256(f.read()).hexdigest() + + +def read_manifest(): + # Строки " <путь от airdef-mod>", как у sha256sum. Только файлы, которые записал этот скрипт. + if not os.path.exists(MANIFEST): + return {} + entries = {} + with open(MANIFEST, encoding="utf-8") as f: + for line in f: + digest, rel = line.rstrip("\n").split(" ", 1) + if rel.startswith("/") or ".." in rel.split("/"): + sys.exit(f"{MANIFEST}: недопустимый путь {rel}") + entries[rel] = digest + return entries + + +def prune_empty(path): + d = os.path.dirname(path) + while d != ROOT and os.path.isdir(d) and not os.listdir(d): + os.rmdir(d) + d = os.path.dirname(d) + + +def main(): + args = sys.argv[1:] + if any(arg != "--check" for arg in args): + sys.exit("Использование: python3 tools/gen_models.py [--check]") + check = "--check" in args + old = read_manifest() + with tempfile.TemporaryDirectory() as stage: + counts = generate(stage) + new = {} + for dirpath, dirs, names in os.walk(stage): + dirs.sort() + for name in sorted(names): + path = os.path.join(dirpath, name) + new[os.path.relpath(path, stage).replace(os.sep, "/")] = file_sha(path) + # create: файла нет; update: файл записан генератором и с тех пор не менялся; same: уже совпадает; + # conflict: на пути лежит чужой или вручную правленный файл, поэтому ничего не пишем. + create, update, same, conflict = [], [], [], [] + for rel in sorted(new): + target = os.path.join(ROOT, rel) + if not os.path.exists(target): + create.append(rel) + elif not os.path.isfile(target): + conflict.append(rel) + else: + cur = file_sha(target) + if cur == new[rel]: + same.append(rel) + elif rel in old and old[rel] == cur: + update.append(rel) + else: + conflict.append(rel) + # remove: генератор больше не создаёт файл и он не менялся; keep: не создаёт, но правлен вручную, оставляем. + remove, keep = [], [] + for rel in sorted(old): + target = os.path.join(ROOT, rel) + if rel in new or not os.path.isfile(target): + continue + (remove if file_sha(target) == old[rel] else keep).append(rel) + if conflict: + for rel in conflict: + print(f"КОНФЛИКТ: {rel} занят файлом, которого нет в manifest (или он правлен вручную)") + sys.exit("Ничего не записано. Уберите или переименуйте эти файлы и запустите снова.") + manifest_text = "".join(f"{new[rel]} {rel}\n" for rel in sorted(new)) + manifest_old = None + if os.path.exists(MANIFEST): + with open(MANIFEST, encoding="utf-8") as f: + manifest_old = f.read() + manifest_changed = manifest_text != manifest_old + for rel in keep: + print(f"ВНИМАНИЕ: {rel} больше не генерируется, но правлен вручную: оставлен, из manifest убран") + for label, rels in (("новый", create), ("обновить", update), ("удалить", remove)): + for rel in rels: + print(f"{label}: {rel}") + if manifest_changed: + print("manifest: обновить") + if check: + differs = create or update or remove or manifest_changed + print("различий нет" if not differs else "различия с генератором: см. выше") + sys.exit(1 if differs else 0) + for rel in create + update: + dst = os.path.join(ROOT, rel) + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copyfile(os.path.join(stage, rel), dst) + for rel in remove: + path = os.path.join(ROOT, rel) + os.remove(path) + prune_empty(path) + if manifest_changed: + with open(MANIFEST, "w", encoding="utf-8", newline="\n") as f: + f.write(manifest_text) + models, items, textures = counts + print(f"{models} моделей, {items} предметов, {textures} текстур -> {ROOT}: " + f"создано {len(create)}, обновлено {len(update)}, удалено {len(remove)}, без изменений {len(same)}") if __name__ == "__main__":