From ed2db8523be6c84d4fb17e9fe3198ba70c41ea0e Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:43:48 +0300 Subject: [PATCH] Shaders, profiling and the rest of the issues: GTAO and HD GPU pass timing in the fork (patch 15; #9), explosion effects culled early per pixel (#11), a native sim runner with scenario tests run in CI (#8), named scenarios (#17), /airdef why and failure counters in raid reports (#18), IRIS-T and AMRAAM models and launch sounds by kind (#28) Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/build.yml | 7 + .../airdef/blockstates/amraam_missile.json | 7 + .../airdef/blockstates/irist_missile.json | 7 + airdef-mod/assets/airdef/lang/en_us.json | 2 + airdef-mod/assets/airdef/lang/ru_ru.json | 2 + .../airdef/models/block/amraam_missile.json | 895 ++++++++++++++++++ .../airdef/models/block/irist_missile.json | 895 ++++++++++++++++++ .../airdef/textures/block/nose_dark.png | Bin 0 -> 346 bytes airdef-mod/examples/sim_run/harness.rs | 413 ++++++++ airdef-mod/examples/sim_run/main.rs | 156 +++ airdef-mod/shaders/effects.wgsl | 82 +- airdef-mod/src/lib.rs | 71 +- airdef-mod/src/monitor.rs | 7 + airdef-mod/src/replay.rs | 5 + airdef-mod/src/scenario.rs | 122 +++ airdef-mod/src/sim/mod.rs | 122 ++- airdef-mod/src/sim/orders.rs | 4 + airdef-mod/src/sim/raid.rs | 7 + airdef-mod/src/sim/record.rs | 53 ++ airdef-mod/src/sim/why.rs | 305 ++++++ airdef-mod/src/wave.rs | 29 + airdef-mod/tests/scenarios.rs | 41 + ...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 | 712 ++++++++++++++ tools/gen_models.py | 27 + 38 files changed, 3912 insertions(+), 87 deletions(-) create mode 100644 airdef-mod/assets/airdef/blockstates/amraam_missile.json create mode 100644 airdef-mod/assets/airdef/blockstates/irist_missile.json create mode 100644 airdef-mod/assets/airdef/models/block/amraam_missile.json create mode 100644 airdef-mod/assets/airdef/models/block/irist_missile.json create mode 100644 airdef-mod/assets/airdef/textures/block/nose_dark.png create mode 100644 airdef-mod/examples/sim_run/harness.rs create mode 100644 airdef-mod/examples/sim_run/main.rs create mode 100644 airdef-mod/src/scenario.rs create mode 100644 airdef-mod/src/sim/why.rs create mode 100644 airdef-mod/tests/scenarios.rs create mode 100644 horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 7cf540c..e82771b 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -28,6 +28,13 @@ jobs: # падало по таймауту, а сохранение вешало «Complete job» на пару минут. Инструмент собирается заново. - name: Форк Horizon run: nice -n 19 python3 tools/airdef.py fork + # Чистая симуляция тестируется нативно (хост-таргет раннера, без WASM): регрессии из sim/regress.rs + # и сценарии tests/scenarios.rs (NaN, налёт кончается, ТЗМ не зависают). Нужен форк: SDK подключён путём. + - name: Тесты мода + working-directory: airdef-mod + run: | + HOST=$(rustc -vV | sed -n 's/^host: //p') + nice -n 19 cargo test --target "$HOST" - name: Мод run: nice -n 19 python3 tools/airdef.py mod - name: Релиз diff --git a/airdef-mod/assets/airdef/blockstates/amraam_missile.json b/airdef-mod/assets/airdef/blockstates/amraam_missile.json new file mode 100644 index 0000000..91dcf56 --- /dev/null +++ b/airdef-mod/assets/airdef/blockstates/amraam_missile.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "airdef:block/amraam_missile" + } + } +} \ No newline at end of file diff --git a/airdef-mod/assets/airdef/blockstates/irist_missile.json b/airdef-mod/assets/airdef/blockstates/irist_missile.json new file mode 100644 index 0000000..7e26970 --- /dev/null +++ b/airdef-mod/assets/airdef/blockstates/irist_missile.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "airdef:block/irist_missile" + } + } +} \ No newline at end of file diff --git a/airdef-mod/assets/airdef/lang/en_us.json b/airdef-mod/assets/airdef/lang/en_us.json index b5be83f..76141c3 100644 --- a/airdef-mod/assets/airdef/lang/en_us.json +++ b/airdef-mod/assets/airdef/lang/en_us.json @@ -4,6 +4,8 @@ "block.airdef.geran3": "Герань-3", "block.airdef.geran3_flame": "Факел", "block.airdef.pac3": "PAC-3 MSE", + "block.airdef.irist_missile": "IRIS-T SLM", + "block.airdef.amraam_missile": "AMRAAM", "block.airdef.flame": "Факел", "block.airdef.patriot_launcher": "ПУ M903", "block.airdef.patriot_truck": "Тягач", diff --git a/airdef-mod/assets/airdef/lang/ru_ru.json b/airdef-mod/assets/airdef/lang/ru_ru.json index b5be83f..76141c3 100644 --- a/airdef-mod/assets/airdef/lang/ru_ru.json +++ b/airdef-mod/assets/airdef/lang/ru_ru.json @@ -4,6 +4,8 @@ "block.airdef.geran3": "Герань-3", "block.airdef.geran3_flame": "Факел", "block.airdef.pac3": "PAC-3 MSE", + "block.airdef.irist_missile": "IRIS-T SLM", + "block.airdef.amraam_missile": "AMRAAM", "block.airdef.flame": "Факел", "block.airdef.patriot_launcher": "ПУ M903", "block.airdef.patriot_truck": "Тягач", diff --git a/airdef-mod/assets/airdef/models/block/amraam_missile.json b/airdef-mod/assets/airdef/models/block/amraam_missile.json new file mode 100644 index 0000000..133d941 --- /dev/null +++ b/airdef-mod/assets/airdef/models/block/amraam_missile.json @@ -0,0 +1,895 @@ +{ + "ambientocclusion": false, + "textures": { + "nose_dark": "airdef:block/nose_dark", + "white": "airdef:block/white", + "nozzle": "airdef:block/nozzle", + "particle": "airdef:block/nose_dark" + }, + "elements": [ + { + "from": [ + 7.333333333333333, + 7.333333333333333, + -16.0 + ], + "to": [ + 8.666666666666666, + 8.666666666666666, + -13.666666666666666 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 1.333333333333333, + 1.333333333333333 + ], + "texture": "#nose_dark" + }, + "south": { + "uv": [ + 0, + 0, + 1.333333333333333, + 1.333333333333333 + ], + "texture": "#nose_dark" + }, + "east": { + "uv": [ + 0, + 0, + 2.333333333333334, + 1.333333333333333 + ], + "texture": "#nose_dark" + }, + "west": { + "uv": [ + 0, + 0, + 2.333333333333334, + 1.333333333333333 + ], + "texture": "#nose_dark" + }, + "up": { + "uv": [ + 0, + 0, + 1.333333333333333, + 2.333333333333334 + ], + "texture": "#nose_dark" + }, + "down": { + "uv": [ + 0, + 0, + 1.333333333333333, + 2.333333333333334 + ], + "texture": "#nose_dark" + } + } + }, + { + "from": [ + 7.0, + 7.0, + -13.666666666666666 + ], + "to": [ + 9.0, + 9.0, + -11.333333333333332 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 2.0, + 2.0 + ], + "texture": "#nose_dark" + }, + "south": { + "uv": [ + 0, + 0, + 2.0, + 2.0 + ], + "texture": "#nose_dark" + }, + "east": { + "uv": [ + 0, + 0, + 2.333333333333334, + 2.0 + ], + "texture": "#nose_dark" + }, + "west": { + "uv": [ + 0, + 0, + 2.333333333333334, + 2.0 + ], + "texture": "#nose_dark" + }, + "up": { + "uv": [ + 0, + 0, + 2.0, + 2.333333333333334 + ], + "texture": "#nose_dark" + }, + "down": { + "uv": [ + 0, + 0, + 2.0, + 2.333333333333334 + ], + "texture": "#nose_dark" + } + } + }, + { + "from": [ + 6.666666666666666, + 6.666666666666666, + -11.333333333333332 + ], + "to": [ + 9.333333333333334, + 9.333333333333334, + -9.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 2.666666666666668, + 2.666666666666668 + ], + "texture": "#nose_dark" + }, + "south": { + "uv": [ + 0, + 0, + 2.666666666666668, + 2.666666666666668 + ], + "texture": "#nose_dark" + }, + "east": { + "uv": [ + 0, + 0, + 2.333333333333332, + 2.666666666666668 + ], + "texture": "#nose_dark" + }, + "west": { + "uv": [ + 0, + 0, + 2.333333333333332, + 2.666666666666668 + ], + "texture": "#nose_dark" + }, + "up": { + "uv": [ + 0, + 0, + 2.666666666666668, + 2.333333333333332 + ], + "texture": "#nose_dark" + }, + "down": { + "uv": [ + 0, + 0, + 2.666666666666668, + 2.333333333333332 + ], + "texture": "#nose_dark" + } + } + }, + { + "from": [ + 6.5, + 6.5, + -9.0 + ], + "to": [ + 9.5, + 9.5, + 26.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 3.0, + 3.0 + ], + "texture": "#white" + }, + "south": { + "uv": [ + 0, + 0, + 3.0, + 3.0 + ], + "texture": "#white" + }, + "east": { + "uv": [ + 0, + 0, + 16, + 3.0 + ], + "texture": "#white" + }, + "west": { + "uv": [ + 0, + 0, + 16, + 3.0 + ], + "texture": "#white" + }, + "up": { + "uv": [ + 0, + 0, + 3.0, + 16 + ], + "texture": "#white" + }, + "down": { + "uv": [ + 0, + 0, + 3.0, + 16 + ], + "texture": "#white" + } + } + }, + { + "from": [ + 1.0, + 7.6, + 2.0 + ], + "to": [ + 6.5, + 8.4, + 9.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 5.5, + 0.8000000000000007 + ], + "texture": "#white" + }, + "south": { + "uv": [ + 0, + 0, + 5.5, + 0.8000000000000007 + ], + "texture": "#white" + }, + "east": { + "uv": [ + 0, + 0, + 7.0, + 0.8000000000000007 + ], + "texture": "#white" + }, + "west": { + "uv": [ + 0, + 0, + 7.0, + 0.8000000000000007 + ], + "texture": "#white" + }, + "up": { + "uv": [ + 0, + 0, + 5.5, + 7.0 + ], + "texture": "#white" + }, + "down": { + "uv": [ + 0, + 0, + 5.5, + 7.0 + ], + "texture": "#white" + } + } + }, + { + "from": [ + 9.5, + 7.6, + 2.0 + ], + "to": [ + 15.0, + 8.4, + 9.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 5.5, + 0.8000000000000007 + ], + "texture": "#white" + }, + "south": { + "uv": [ + 0, + 0, + 5.5, + 0.8000000000000007 + ], + "texture": "#white" + }, + "east": { + "uv": [ + 0, + 0, + 7.0, + 0.8000000000000007 + ], + "texture": "#white" + }, + "west": { + "uv": [ + 0, + 0, + 7.0, + 0.8000000000000007 + ], + "texture": "#white" + }, + "up": { + "uv": [ + 0, + 0, + 5.5, + 7.0 + ], + "texture": "#white" + }, + "down": { + "uv": [ + 0, + 0, + 5.5, + 7.0 + ], + "texture": "#white" + } + } + }, + { + "from": [ + 7.6, + 1.0, + 2.0 + ], + "to": [ + 8.4, + 6.5, + 9.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 5.5 + ], + "texture": "#white" + }, + "south": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 5.5 + ], + "texture": "#white" + }, + "east": { + "uv": [ + 0, + 0, + 7.0, + 5.5 + ], + "texture": "#white" + }, + "west": { + "uv": [ + 0, + 0, + 7.0, + 5.5 + ], + "texture": "#white" + }, + "up": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 7.0 + ], + "texture": "#white" + }, + "down": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 7.0 + ], + "texture": "#white" + } + } + }, + { + "from": [ + 7.6, + 9.5, + 2.0 + ], + "to": [ + 8.4, + 15.0, + 9.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 5.5 + ], + "texture": "#white" + }, + "south": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 5.5 + ], + "texture": "#white" + }, + "east": { + "uv": [ + 0, + 0, + 7.0, + 5.5 + ], + "texture": "#white" + }, + "west": { + "uv": [ + 0, + 0, + 7.0, + 5.5 + ], + "texture": "#white" + }, + "up": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 7.0 + ], + "texture": "#white" + }, + "down": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 7.0 + ], + "texture": "#white" + } + } + }, + { + "from": [ + 4.0, + 7.6, + 19.0 + ], + "to": [ + 6.5, + 8.4, + 25.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 2.5, + 0.8000000000000007 + ], + "texture": "#white" + }, + "south": { + "uv": [ + 0, + 0, + 2.5, + 0.8000000000000007 + ], + "texture": "#white" + }, + "east": { + "uv": [ + 0, + 0, + 6.0, + 0.8000000000000007 + ], + "texture": "#white" + }, + "west": { + "uv": [ + 0, + 0, + 6.0, + 0.8000000000000007 + ], + "texture": "#white" + }, + "up": { + "uv": [ + 0, + 0, + 2.5, + 6.0 + ], + "texture": "#white" + }, + "down": { + "uv": [ + 0, + 0, + 2.5, + 6.0 + ], + "texture": "#white" + } + } + }, + { + "from": [ + 9.5, + 7.6, + 19.0 + ], + "to": [ + 12.0, + 8.4, + 25.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 2.5, + 0.8000000000000007 + ], + "texture": "#white" + }, + "south": { + "uv": [ + 0, + 0, + 2.5, + 0.8000000000000007 + ], + "texture": "#white" + }, + "east": { + "uv": [ + 0, + 0, + 6.0, + 0.8000000000000007 + ], + "texture": "#white" + }, + "west": { + "uv": [ + 0, + 0, + 6.0, + 0.8000000000000007 + ], + "texture": "#white" + }, + "up": { + "uv": [ + 0, + 0, + 2.5, + 6.0 + ], + "texture": "#white" + }, + "down": { + "uv": [ + 0, + 0, + 2.5, + 6.0 + ], + "texture": "#white" + } + } + }, + { + "from": [ + 7.6, + 4.0, + 19.0 + ], + "to": [ + 8.4, + 6.5, + 25.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 2.5 + ], + "texture": "#white" + }, + "south": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 2.5 + ], + "texture": "#white" + }, + "east": { + "uv": [ + 0, + 0, + 6.0, + 2.5 + ], + "texture": "#white" + }, + "west": { + "uv": [ + 0, + 0, + 6.0, + 2.5 + ], + "texture": "#white" + }, + "up": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 6.0 + ], + "texture": "#white" + }, + "down": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 6.0 + ], + "texture": "#white" + } + } + }, + { + "from": [ + 7.6, + 9.5, + 19.0 + ], + "to": [ + 8.4, + 12.0, + 25.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 2.5 + ], + "texture": "#white" + }, + "south": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 2.5 + ], + "texture": "#white" + }, + "east": { + "uv": [ + 0, + 0, + 6.0, + 2.5 + ], + "texture": "#white" + }, + "west": { + "uv": [ + 0, + 0, + 6.0, + 2.5 + ], + "texture": "#white" + }, + "up": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 6.0 + ], + "texture": "#white" + }, + "down": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 6.0 + ], + "texture": "#white" + } + } + }, + { + "from": [ + 7.0, + 7.0, + 26.0 + ], + "to": [ + 9.0, + 9.0, + 28.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 2.0, + 2.0 + ], + "texture": "#nozzle" + }, + "south": { + "uv": [ + 0, + 0, + 2.0, + 2.0 + ], + "texture": "#nozzle" + }, + "east": { + "uv": [ + 0, + 0, + 2.0, + 2.0 + ], + "texture": "#nozzle" + }, + "west": { + "uv": [ + 0, + 0, + 2.0, + 2.0 + ], + "texture": "#nozzle" + }, + "up": { + "uv": [ + 0, + 0, + 2.0, + 2.0 + ], + "texture": "#nozzle" + }, + "down": { + "uv": [ + 0, + 0, + 2.0, + 2.0 + ], + "texture": "#nozzle" + } + } + } + ] +} \ No newline at end of file diff --git a/airdef-mod/assets/airdef/models/block/irist_missile.json b/airdef-mod/assets/airdef/models/block/irist_missile.json new file mode 100644 index 0000000..e468964 --- /dev/null +++ b/airdef-mod/assets/airdef/models/block/irist_missile.json @@ -0,0 +1,895 @@ +{ + "ambientocclusion": false, + "textures": { + "white": "airdef:block/white", + "missile_grey": "airdef:block/missile_grey", + "nozzle": "airdef:block/nozzle", + "particle": "airdef:block/white" + }, + "elements": [ + { + "from": [ + 7.333333333333333, + 7.333333333333333, + -16.0 + ], + "to": [ + 8.666666666666666, + 8.666666666666666, + -14.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 1.333333333333333, + 1.333333333333333 + ], + "texture": "#white" + }, + "south": { + "uv": [ + 0, + 0, + 1.333333333333333, + 1.333333333333333 + ], + "texture": "#white" + }, + "east": { + "uv": [ + 0, + 0, + 2.0, + 1.333333333333333 + ], + "texture": "#white" + }, + "west": { + "uv": [ + 0, + 0, + 2.0, + 1.333333333333333 + ], + "texture": "#white" + }, + "up": { + "uv": [ + 0, + 0, + 1.333333333333333, + 2.0 + ], + "texture": "#white" + }, + "down": { + "uv": [ + 0, + 0, + 1.333333333333333, + 2.0 + ], + "texture": "#white" + } + } + }, + { + "from": [ + 7.0, + 7.0, + -14.0 + ], + "to": [ + 9.0, + 9.0, + -12.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 2.0, + 2.0 + ], + "texture": "#white" + }, + "south": { + "uv": [ + 0, + 0, + 2.0, + 2.0 + ], + "texture": "#white" + }, + "east": { + "uv": [ + 0, + 0, + 2.0, + 2.0 + ], + "texture": "#white" + }, + "west": { + "uv": [ + 0, + 0, + 2.0, + 2.0 + ], + "texture": "#white" + }, + "up": { + "uv": [ + 0, + 0, + 2.0, + 2.0 + ], + "texture": "#white" + }, + "down": { + "uv": [ + 0, + 0, + 2.0, + 2.0 + ], + "texture": "#white" + } + } + }, + { + "from": [ + 6.666666666666666, + 6.666666666666666, + -12.0 + ], + "to": [ + 9.333333333333334, + 9.333333333333334, + -10.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 2.666666666666668, + 2.666666666666668 + ], + "texture": "#white" + }, + "south": { + "uv": [ + 0, + 0, + 2.666666666666668, + 2.666666666666668 + ], + "texture": "#white" + }, + "east": { + "uv": [ + 0, + 0, + 2.0, + 2.666666666666668 + ], + "texture": "#white" + }, + "west": { + "uv": [ + 0, + 0, + 2.0, + 2.666666666666668 + ], + "texture": "#white" + }, + "up": { + "uv": [ + 0, + 0, + 2.666666666666668, + 2.0 + ], + "texture": "#white" + }, + "down": { + "uv": [ + 0, + 0, + 2.666666666666668, + 2.0 + ], + "texture": "#white" + } + } + }, + { + "from": [ + 6.5, + 6.5, + -10.0 + ], + "to": [ + 9.5, + 9.5, + 24.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 3.0, + 3.0 + ], + "texture": "#white" + }, + "south": { + "uv": [ + 0, + 0, + 3.0, + 3.0 + ], + "texture": "#white" + }, + "east": { + "uv": [ + 0, + 0, + 16, + 3.0 + ], + "texture": "#white" + }, + "west": { + "uv": [ + 0, + 0, + 16, + 3.0 + ], + "texture": "#white" + }, + "up": { + "uv": [ + 0, + 0, + 3.0, + 16 + ], + "texture": "#white" + }, + "down": { + "uv": [ + 0, + 0, + 3.0, + 16 + ], + "texture": "#white" + } + } + }, + { + "from": [ + 3.0, + 7.6, + -9.0 + ], + "to": [ + 6.5, + 8.4, + -5.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 3.5, + 0.8000000000000007 + ], + "texture": "#missile_grey" + }, + "south": { + "uv": [ + 0, + 0, + 3.5, + 0.8000000000000007 + ], + "texture": "#missile_grey" + }, + "east": { + "uv": [ + 0, + 0, + 4.0, + 0.8000000000000007 + ], + "texture": "#missile_grey" + }, + "west": { + "uv": [ + 0, + 0, + 4.0, + 0.8000000000000007 + ], + "texture": "#missile_grey" + }, + "up": { + "uv": [ + 0, + 0, + 3.5, + 4.0 + ], + "texture": "#missile_grey" + }, + "down": { + "uv": [ + 0, + 0, + 3.5, + 4.0 + ], + "texture": "#missile_grey" + } + } + }, + { + "from": [ + 9.5, + 7.6, + -9.0 + ], + "to": [ + 13.0, + 8.4, + -5.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 3.5, + 0.8000000000000007 + ], + "texture": "#missile_grey" + }, + "south": { + "uv": [ + 0, + 0, + 3.5, + 0.8000000000000007 + ], + "texture": "#missile_grey" + }, + "east": { + "uv": [ + 0, + 0, + 4.0, + 0.8000000000000007 + ], + "texture": "#missile_grey" + }, + "west": { + "uv": [ + 0, + 0, + 4.0, + 0.8000000000000007 + ], + "texture": "#missile_grey" + }, + "up": { + "uv": [ + 0, + 0, + 3.5, + 4.0 + ], + "texture": "#missile_grey" + }, + "down": { + "uv": [ + 0, + 0, + 3.5, + 4.0 + ], + "texture": "#missile_grey" + } + } + }, + { + "from": [ + 7.6, + 3.0, + -9.0 + ], + "to": [ + 8.4, + 6.5, + -5.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 3.5 + ], + "texture": "#missile_grey" + }, + "south": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 3.5 + ], + "texture": "#missile_grey" + }, + "east": { + "uv": [ + 0, + 0, + 4.0, + 3.5 + ], + "texture": "#missile_grey" + }, + "west": { + "uv": [ + 0, + 0, + 4.0, + 3.5 + ], + "texture": "#missile_grey" + }, + "up": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 4.0 + ], + "texture": "#missile_grey" + }, + "down": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 4.0 + ], + "texture": "#missile_grey" + } + } + }, + { + "from": [ + 7.6, + 9.5, + -9.0 + ], + "to": [ + 8.4, + 13.0, + -5.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 3.5 + ], + "texture": "#missile_grey" + }, + "south": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 3.5 + ], + "texture": "#missile_grey" + }, + "east": { + "uv": [ + 0, + 0, + 4.0, + 3.5 + ], + "texture": "#missile_grey" + }, + "west": { + "uv": [ + 0, + 0, + 4.0, + 3.5 + ], + "texture": "#missile_grey" + }, + "up": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 4.0 + ], + "texture": "#missile_grey" + }, + "down": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 4.0 + ], + "texture": "#missile_grey" + } + } + }, + { + "from": [ + 2.0, + 7.6, + 18.0 + ], + "to": [ + 6.5, + 8.4, + 26.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 4.5, + 0.8000000000000007 + ], + "texture": "#white" + }, + "south": { + "uv": [ + 0, + 0, + 4.5, + 0.8000000000000007 + ], + "texture": "#white" + }, + "east": { + "uv": [ + 0, + 0, + 8.0, + 0.8000000000000007 + ], + "texture": "#white" + }, + "west": { + "uv": [ + 0, + 0, + 8.0, + 0.8000000000000007 + ], + "texture": "#white" + }, + "up": { + "uv": [ + 0, + 0, + 4.5, + 8.0 + ], + "texture": "#white" + }, + "down": { + "uv": [ + 0, + 0, + 4.5, + 8.0 + ], + "texture": "#white" + } + } + }, + { + "from": [ + 9.5, + 7.6, + 18.0 + ], + "to": [ + 14.0, + 8.4, + 26.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 4.5, + 0.8000000000000007 + ], + "texture": "#white" + }, + "south": { + "uv": [ + 0, + 0, + 4.5, + 0.8000000000000007 + ], + "texture": "#white" + }, + "east": { + "uv": [ + 0, + 0, + 8.0, + 0.8000000000000007 + ], + "texture": "#white" + }, + "west": { + "uv": [ + 0, + 0, + 8.0, + 0.8000000000000007 + ], + "texture": "#white" + }, + "up": { + "uv": [ + 0, + 0, + 4.5, + 8.0 + ], + "texture": "#white" + }, + "down": { + "uv": [ + 0, + 0, + 4.5, + 8.0 + ], + "texture": "#white" + } + } + }, + { + "from": [ + 7.6, + 2.0, + 18.0 + ], + "to": [ + 8.4, + 6.5, + 26.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 4.5 + ], + "texture": "#white" + }, + "south": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 4.5 + ], + "texture": "#white" + }, + "east": { + "uv": [ + 0, + 0, + 8.0, + 4.5 + ], + "texture": "#white" + }, + "west": { + "uv": [ + 0, + 0, + 8.0, + 4.5 + ], + "texture": "#white" + }, + "up": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 8.0 + ], + "texture": "#white" + }, + "down": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 8.0 + ], + "texture": "#white" + } + } + }, + { + "from": [ + 7.6, + 9.5, + 18.0 + ], + "to": [ + 8.4, + 14.0, + 26.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 4.5 + ], + "texture": "#white" + }, + "south": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 4.5 + ], + "texture": "#white" + }, + "east": { + "uv": [ + 0, + 0, + 8.0, + 4.5 + ], + "texture": "#white" + }, + "west": { + "uv": [ + 0, + 0, + 8.0, + 4.5 + ], + "texture": "#white" + }, + "up": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 8.0 + ], + "texture": "#white" + }, + "down": { + "uv": [ + 0, + 0, + 0.8000000000000007, + 8.0 + ], + "texture": "#white" + } + } + }, + { + "from": [ + 7.0, + 7.0, + 24.0 + ], + "to": [ + 9.0, + 9.0, + 28.0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 2.0, + 2.0 + ], + "texture": "#nozzle" + }, + "south": { + "uv": [ + 0, + 0, + 2.0, + 2.0 + ], + "texture": "#nozzle" + }, + "east": { + "uv": [ + 0, + 0, + 4.0, + 2.0 + ], + "texture": "#nozzle" + }, + "west": { + "uv": [ + 0, + 0, + 4.0, + 2.0 + ], + "texture": "#nozzle" + }, + "up": { + "uv": [ + 0, + 0, + 2.0, + 4.0 + ], + "texture": "#nozzle" + }, + "down": { + "uv": [ + 0, + 0, + 2.0, + 4.0 + ], + "texture": "#nozzle" + } + } + } + ] +} \ No newline at end of file diff --git a/airdef-mod/assets/airdef/textures/block/nose_dark.png b/airdef-mod/assets/airdef/textures/block/nose_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..1d34fb0fb3b80b00f44293169ac5cb2a9d03facc GIT binary patch literal 346 zcmV-g0j2(lP)@U`uG1D5m9Qb<$0d6?|ZrLyNodk)>=zX z?7FUugT=rF2R{u$61Hv2B**}B#yo=yE;$&m8id^Qf1YP5;eU-o9{j+BSy6z#_nu^s zsDxsCVHGcwj!%j6IS`>l#hmF!vvF%hNmQf i32 { + match self { + Terrain::Flat(h) => h, + Terrain::Hills { base, amp, period } => { + base + (amp * ((f64::from(x) / period).sin() + (f64::from(z) / period).cos()) / 2.0).round() as i32 + } + } + } +} + +#[derive(Clone, Debug)] +pub struct RaidSpec { + pub kinds: Vec<(DroneKind, u32)>, + pub approach: Approach, + pub aim: Aim, + pub dist: f64, + /// The tick the raid is ordered at (a raid waits for the previous one to be sent). + pub at: u64, +} + +#[derive(Clone, Debug)] +pub struct Unit { + pub kind: SamKind, + pub pos: DVec3, + pub heading: f64, + /// Starts with an empty magazine, waiting for a reload (logistics). + pub empty: bool, +} + +#[derive(Clone, Debug)] +pub struct Scenario { + pub name: String, + pub seed: u64, + pub units: Vec, + pub objects: Vec, + pub raids: Vec, + pub terrain: Terrain, + /// Hard limit of ticks (20 a second). + pub ticks: u64, + /// Stop as soon as every raid has ended. + pub stop_at_end: bool, + pub ew: bool, + /// Ticks a truck on a job may stand still before it counts as stuck. + pub stuck_after: u64, +} + +impl Scenario { + pub fn new(name: &str) -> Self { + Scenario { + name: name.into(), + seed: 1, + units: Vec::new(), + objects: vec![DVec3::new(0.0, 70.0, 0.0)], + raids: Vec::new(), + terrain: Terrain::Flat(70), + ticks: 20 * 60 * 10, + stop_at_end: true, + ew: false, + stuck_after: 600, + } + } + pub fn unit(&mut self, kind: SamKind, x: f64, z: f64) -> &mut Unit { + self.units.push(Unit { kind, pos: DVec3::new(x, 70.0, z), heading: 0.0, empty: false }); + self.units.last_mut().unwrap() + } +} + +#[derive(Clone, Debug, Default)] +pub struct Report { + pub name: String, + pub seed: u64, + pub ticks_run: u64, + pub units: usize, + pub launched: u32, + pub kills: u32, + pub hits: u32, + /// Missiles fired by air defence (sum of the systems' `fired`). + pub fired: u32, + pub ground_queries: u64, + pub ground_batches: u64, + pub tick_p50: Duration, + pub tick_p95: Duration, + pub tick_max: Duration, + pub tick_total: Duration, + /// Time spent in ticks that began with a raid being planned. + pub planning: Duration, + pub planning_ticks: u64, + pub nan_positions: u64, + /// Ids of trucks that stood on a job without progress for too long. + pub stuck_trucks: Vec, + /// Truck-ticks spent on a job or driving (shows the logistics actually ran). + pub truck_busy_ticks: u64, + pub raids_ordered: usize, + pub raids_ended: usize, + pub all_ended: bool, +} + +fn finite(v: DVec3) -> bool { + v.x.is_finite() && v.y.is_finite() && v.z.is_finite() +} + +pub fn run(sc: &Scenario) -> Report { + let mut sim = Sim::new(sc.seed); + sim.damage = false; // explosions would change the land the height function describes + for u in &sc.units { + let id = sim.id(); + let mut s = Sam::new(id, u.kind, u.pos, u.heading); + if u.empty { + s.empty = true; + s.missiles = 0; + } + sim.sams.push(s); + } + sim.targets = sc.objects.clone(); + sim.target_health = vec![100.0; sc.objects.len()]; + if sc.ew { + sim.set_ew(true); + } + let mut ground = Ground::default(); + let mut pending: Vec<(i32, i32, i32)> = Vec::new(); + let mut r = Report { name: sc.name.clone(), seed: sc.seed, units: sc.units.len(), ..Default::default() }; + let mut times: Vec = Vec::with_capacity(sc.ticks as usize); + let mut ordered = vec![false; sc.raids.len()]; + let mut launched_before = vec![0u32; sc.raids.len()]; + let mut ended = vec![false; sc.raids.len()]; + // truck id -> (last position, ticks standing while busy) + let mut standing: HashMap = HashMap::new(); + let mut out = Out::default(); + for now in 1..=sc.ticks { + // Raids come in order: one is ordered once the previous one has been sent. + for (i, spec) in sc.raids.iter().enumerate() { + if !ordered[i] && now >= spec.at && !sim.planning() && (i == 0 || ended[i - 1]) { + let order = RaidOrder { kinds: spec.kinds.clone(), approach: spec.approach, dist: spec.dist, aim: spec.aim }; + launched_before[i] = sim.launched; + sim.order_raid(&order, &sc.objects, &mut out); + ordered[i] = true; + } + } + ground.answer(std::mem::take(&mut pending), now); + let planning = sim.planning(); + out = Out::default(); + let t = Instant::now(); + sim.step(&mut ground, None, &mut out); + let wanted = ground.take_wanted(); + let dt = t.elapsed(); + times.push(dt); + if planning { + r.planning += dt; + r.planning_ticks += 1; + } + if !wanted.is_empty() { + r.ground_batches += 1; + r.ground_queries += wanted.len() as u64; + pending = wanted.into_iter().map(|(x, z)| (x, z, sc.terrain.height(x, z))).collect(); + } + r.ticks_run = now; + // NaN check on everything with a position. + let bad = sim.drones.iter().filter(|d| !finite(d.pos) || !finite(d.vel)).count() + + sim.missiles.iter().filter(|m| !finite(m.pos)).count() + + sim.sams.iter().filter(|s| !finite(s.pos)).count() + + sim.blasts.iter().filter(|b| !finite(b.at)).count() + + out.models.iter().filter(|m| !finite(m.at)).count(); + r.nan_positions += bad as u64; + // Trucks with a job or on the move, standing still. + for s in sim.sams.iter().filter(|s| s.kind == SamKind::Tzm && s.alive()) { + let busy = s.moving() || s.job().is_some(); + r.truck_busy_ticks += u64::from(busy); + let e = standing.entry(s.id).or_insert((s.pos, 0)); + if !busy || e.0.distance(s.pos) > 0.05 { + *e = (s.pos, 0); + } else { + e.1 += 1; + if e.1 == sc.stuck_after && !r.stuck_trucks.contains(&s.id) { + r.stuck_trucks.push(s.id); + } + } + } + // A raid has ended when it was ordered, planned, flown and nothing is left in the air. + let quiet = !sim.planning() && sim.queue.is_empty() && !sim.drones.iter().any(|d| d.alive()) && sim.missiles.is_empty(); + for i in 0..sc.raids.len() { + if ordered[i] && !ended[i] && quiet && sim.launched > launched_before[i] { + ended[i] = true; + } + } + if sc.stop_at_end && ended.iter().all(|e| *e) && !sc.raids.is_empty() { + break; + } + } + r.launched = sim.launched; + r.kills = sim.kills; + r.hits = sim.hits; + r.fired = sim.sams.iter().map(|s| s.fired).sum(); + r.raids_ordered = ordered.iter().filter(|o| **o).count(); + r.raids_ended = ended.iter().filter(|o| **o).count(); + r.all_ended = r.raids_ended == sc.raids.len(); + r.tick_total = times.iter().sum(); + times.sort(); + if !times.is_empty() { + let at = |q: f64| times[((times.len() - 1) as f64 * q).round() as usize]; + r.tick_p50 = at(0.5); + r.tick_p95 = at(0.95); + r.tick_max = *times.last().unwrap(); + } + r +} + +fn ms(d: Duration) -> f64 { + d.as_secs_f64() * 1000.0 +} + +impl Report { + /// Problems that fail a scenario: empty when it is healthy. + pub fn problems(&self) -> Vec { + let mut p = Vec::new(); + if self.nan_positions > 0 { + p.push(format!("{} non-finite positions", self.nan_positions)); + } + if !self.stuck_trucks.is_empty() { + p.push(format!("stuck trucks: {:?}", self.stuck_trucks)); + } + if !self.all_ended { + p.push(format!("{} of {} raids ended in {} ticks", self.raids_ended, self.raids_ended.max(self.raids_ordered), self.ticks_run)); + } + p + } + pub fn text(&self) -> String { + format!( + "scenario {name} (seed {seed}): {units} units, {ticks} ticks ({secs:.0} s of game time)\n\ + raids: {ro} ordered, {re} ended\n\ + launched {l}, shot down {k}, got through {h}, air-defence missiles fired {f}\n\ + tick time ms: p50 {p50:.3} p95 {p95:.3} max {max:.3} total {tot:.0}\n\ + planning: {pl:.1} ms over {pt} ticks\n\ + ground queries: {gq} in {gb} batches\n\ + non-finite positions: {nan}\n\ + stuck trucks: {stuck:?} (busy truck-ticks {busy})\n", + name = self.name, + seed = self.seed, + units = self.units, + ticks = self.ticks_run, + secs = self.ticks_run as f64 / 20.0, + ro = self.raids_ordered, + re = self.raids_ended, + l = self.launched, + k = self.kills, + h = self.hits, + f = self.fired, + p50 = ms(self.tick_p50), + p95 = ms(self.tick_p95), + max = ms(self.tick_max), + tot = ms(self.tick_total), + pl = ms(self.planning), + pt = self.planning_ticks, + gq = self.ground_queries, + gb = self.ground_batches, + nan = self.nan_positions, + stuck = self.stuck_trucks, + busy = self.truck_busy_ticks, + ) + } + pub fn json(&self) -> String { + format!( + "{{\"scenario\":{:?},\"seed\":{},\"units\":{},\"ticks\":{},\"raids_ordered\":{},\"raids_ended\":{},\ + \"launched\":{},\"kills\":{},\"hits\":{},\"fired\":{},\ + \"tick_ms\":{{\"p50\":{:.4},\"p95\":{:.4},\"max\":{:.4},\"total\":{:.2}}},\ + \"planning_ms\":{:.2},\"planning_ticks\":{},\"ground_queries\":{},\"ground_batches\":{},\ + \"nan_positions\":{},\"stuck_trucks\":{:?},\"truck_busy_ticks\":{}}}", + self.name, + self.seed, + self.units, + self.ticks_run, + self.raids_ordered, + self.raids_ended, + self.launched, + self.kills, + self.hits, + self.fired, + ms(self.tick_p50), + ms(self.tick_p95), + ms(self.tick_max), + ms(self.tick_total), + ms(self.planning), + self.planning_ticks, + self.ground_queries, + self.ground_batches, + self.nan_positions, + self.stuck_trucks, + self.truck_busy_ticks, + ) + } +} + +// ------------------------------------------------------------ ready-made scenarios + +fn ring(sc: &mut Scenario, kinds: &[SamKind], count: usize, r0: f64, r1: f64) { + for i in 0..count { + let a = i as f64 * 2.399_963; // golden angle: an even spread without randomness + let r = r0 + (r1 - r0) * (i as f64 / count.max(1) as f64); + let kind = kinds[i % kinds.len()]; + sc.unit(kind, a.sin() * r, a.cos() * r).heading = a; + } +} + +/// A big smart raid on 61 systems. +pub fn big_smart() -> Scenario { + let mut sc = Scenario::new("big-smart"); + sc.terrain = Terrain::Hills { base: 70, amp: 14.0, period: 90.0 }; + for (x, z) in [(0.0, 0.0), (120.0, 80.0), (-150.0, -60.0)] { + sc.objects.push(DVec3::new(x, 70.0, z)); + } + ring(&mut sc, &[SamKind::Radar], 6, 150.0, 500.0); + ring(&mut sc, &[SamKind::Ew], 2, 100.0, 300.0); + ring(&mut sc, &SamKind::ARMED, 53, 60.0, 450.0); + assert_eq!(sc.units.len(), 61); + sc.raids.push(RaidSpec { + kinds: vec![ + (DroneKind::Gerbera, 8), + (DroneKind::Shahed, 12), + (DroneKind::Geran3, 4), + (DroneKind::Lancet, 4), + (DroneKind::Kh101, 4), + (DroneKind::Kalibr, 4), + (DroneKind::Iskander, 2), + ], + approach: Approach::AllSides, + aim: Aim::Smart, + dist: 500.0, + at: 1, + }); + sc.ticks = 20 * 60 * 8; + sc +} + +/// Trucks and a depot supplying systems that start with empty magazines, under a raid. +pub fn logistics() -> Scenario { + let mut sc = Scenario::new("logistics"); + sc.unit(SamKind::Radar, 0.0, -40.0); + sc.unit(SamKind::Depot, 300.0, 300.0); + for (x, z) in [(310.0, 320.0), (290.0, 330.0)] { + sc.unit(SamKind::Tzm, x, z); + } + sc.unit(SamKind::Tzm, 280.0, 280.0); + for (i, kind) in [SamKind::Patriot, SamKind::Tor, SamKind::Buk, SamKind::Nasams, SamKind::Pantsir, SamKind::S400].into_iter().enumerate() { + let a = i as f64 * 1.05; + let u = sc.unit(kind, a.sin() * 120.0, a.cos() * 120.0); + // Every other system starts out of missiles: the trucks have work from the first second. + u.empty = i % 2 == 0; + } + sc.raids.push(RaidSpec { + kinds: vec![(DroneKind::Shahed, 16), (DroneKind::Gerbera, 8), (DroneKind::Kh101, 3)], + approach: Approach::TwoSectors, + aim: Aim::Objects, + dist: 400.0, + at: 1, + }); + sc.ticks = 20 * 60 * 6; + sc +} + +/// EW stations on and a smart enemy that adapts over three raids. +pub fn ew_adaptive() -> Scenario { + let mut sc = Scenario::new("ew-adaptive"); + sc.ew = true; + sc.unit(SamKind::Radar, 0.0, 0.0); + sc.unit(SamKind::Radar, 200.0, -200.0); + sc.unit(SamKind::Ew, 80.0, 0.0); + sc.unit(SamKind::Ew, -80.0, 60.0); + for (i, kind) in [SamKind::Pantsir, SamKind::Gepard, SamKind::Tor, SamKind::Patriot, SamKind::Buk, SamKind::Gepard].into_iter().enumerate() { + let a = i as f64 * 1.05; + sc.unit(kind, a.sin() * 150.0, a.cos() * 150.0); + } + for at in [1, 1, 1] { + sc.raids.push(RaidSpec { + kinds: vec![(DroneKind::Gerbera, 6), (DroneKind::Shahed, 10), (DroneKind::Kh101, 4), (DroneKind::Kalibr, 3)], + approach: Approach::AllSides, + aim: Aim::Smart, + dist: 450.0, + at, + }); + } + sc.ticks = 20 * 60 * 12; + sc +} + +pub fn preset(name: &str) -> Option { + match name { + "big-smart" => Some(big_smart()), + "logistics" => Some(logistics()), + "ew-adaptive" => Some(ew_adaptive()), + _ => None, + } +} +pub const PRESETS: [&str; 3] = ["big-smart", "logistics", "ew-adaptive"]; diff --git a/airdef-mod/examples/sim_run/main.rs b/airdef-mod/examples/sim_run/main.rs new file mode 100644 index 0000000..10a583b --- /dev/null +++ b/airdef-mod/examples/sim_run/main.rs @@ -0,0 +1,156 @@ +//! Native headless runner for the simulation. +//! +//! cargo run --release --example sim_run -- [options] +//! +//! Options (all optional; a preset gives the defaults, the others override it): +//! --scenario NAME big-smart | logistics | ew-adaptive (default big-smart) +//! --seed N random seed +//! --layout FILE systems from a text file, replacing the preset's; one per line: +//! `KIND X Z [HEADING_RAD] [empty]` (`#` starts a comment) +//! KIND is a system key (patriot, tor, radar, depot, tzm, ew, ...) +//! --raid SPEC replaces the preset's raids; repeatable. SPEC is +//! `kind=count,kind=count[;dist=500][;aim=smart|objects|sams][;approach=sector|two|all|waves][;at=TICK]` +//! --terrain T flat[:HEIGHT] | hills[:BASE:AMP:PERIOD] +//! --ticks N tick limit (20 a second) +//! --run-on do not stop when the raids have ended +//! --ew / --no-ew EW stations on or off +//! --json one JSON object instead of text +//! --list list scenarios, system and drone kinds +//! Exit code 1 when the run has problems (NaN positions, stuck trucks, a raid that never ended). +//! Native timings are for comparing runs on one machine, not an estimate of WASM/GPU cost. +mod harness; +use airdef::sim::{Aim, Approach, DroneKind, SamKind}; +use glam::DVec3; +use harness::*; + +fn die(msg: &str) -> ! { + eprintln!("sim_run: {msg}"); + std::process::exit(2); +} + +fn parse_raid(spec: &str) -> RaidSpec { + let mut r = RaidSpec { kinds: Vec::new(), approach: Approach::Sector, aim: Aim::Smart, dist: 400.0, at: 1 }; + for (i, part) in spec.split(';').enumerate() { + if i == 0 { + for kc in part.split(',').filter(|s| !s.is_empty()) { + let (k, c) = kc.split_once('=').unwrap_or_else(|| die(&format!("raid kind `{kc}` needs =count"))); + let kind = DroneKind::from_key(k).unwrap_or_else(|| die(&format!("unknown drone kind `{k}` (see --list)"))); + r.kinds.push((kind, c.parse().unwrap_or_else(|_| die(&format!("bad count `{c}`"))))); + } + continue; + } + let (k, v) = part.split_once('=').unwrap_or_else(|| die(&format!("bad raid option `{part}`"))); + match k { + "dist" => r.dist = v.parse().unwrap_or_else(|_| die("bad dist")), + "at" => r.at = v.parse().unwrap_or_else(|_| die("bad at")), + "aim" => { + r.aim = match v { + "smart" => Aim::Smart, + "objects" => Aim::Objects, + "sams" => Aim::ObjectsAndSams, + _ => die("aim: smart | objects | sams"), + } + } + "approach" => { + r.approach = match v { + "sector" => Approach::Sector, + "two" => Approach::TwoSectors, + "all" => Approach::AllSides, + "waves" => Approach::Waves, + _ => die("approach: sector | two | all | waves"), + } + } + _ => die(&format!("unknown raid option `{k}`")), + } + } + r +} + +fn parse_terrain(t: &str) -> Terrain { + let n: Vec<&str> = t.split(':').collect(); + let num = |i: usize, d: f64| n.get(i).map_or(d, |s| s.parse().unwrap_or_else(|_| die("bad terrain number"))); + match n[0] { + "flat" => Terrain::Flat(num(1, 70.0) as i32), + "hills" => Terrain::Hills { base: num(1, 70.0) as i32, amp: num(2, 14.0), period: num(3, 90.0) }, + _ => die("terrain: flat[:H] | hills[:BASE:AMP:PERIOD]"), + } +} + +fn parse_layout(path: &str) -> Vec { + let text = std::fs::read_to_string(path).unwrap_or_else(|e| die(&format!("{path}: {e}"))); + let mut units = Vec::new(); + for (n, line) in text.lines().enumerate() { + let line = line.split('#').next().unwrap_or("").trim(); + if line.is_empty() { + continue; + } + let w: Vec<&str> = line.split_whitespace().collect(); + let bad = || die(&format!("{path}:{}: expected `KIND X Z [HEADING] [empty]`", n + 1)); + if w.len() < 3 { + bad(); + } + let kind = SamKind::from_key(w[0]).unwrap_or_else(|| die(&format!("{path}:{}: unknown system `{}`", n + 1, w[0]))); + let f = |s: &str| s.parse::().unwrap_or_else(|_| bad()); + let heading = w.get(3).filter(|s| **s != "empty").map_or(0.0, |s| f(s)); + units.push(Unit { kind, pos: DVec3::new(f(w[1]), 70.0, f(w[2])), heading, empty: w.contains(&"empty") }); + } + units +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let mut name = "big-smart".to_string(); + let mut i = 0; + // The scenario first, so the other options override it whatever their order. + while i < args.len() { + if args[i] == "--scenario" { + name = args.get(i + 1).cloned().unwrap_or_else(|| die("--scenario needs a name")); + } + i += 1; + } + let mut sc = preset(&name).unwrap_or_else(|| die(&format!("unknown scenario `{name}`; one of {PRESETS:?}"))); + let (mut json, mut raids) = (false, Vec::new()); + let mut it = args.iter(); + while let Some(arg) = it.next() { + let mut value = || it.next().cloned().unwrap_or_else(|| die(&format!("{arg} needs a value"))); + match arg.as_str() { + "--scenario" => { + value(); + } + "--seed" => sc.seed = value().parse().unwrap_or_else(|_| die("bad seed")), + "--ticks" => sc.ticks = value().parse().unwrap_or_else(|_| die("bad ticks")), + "--layout" => sc.units = parse_layout(&value()), + "--raid" => raids.push(parse_raid(&value())), + "--terrain" => sc.terrain = parse_terrain(&value()), + "--run-on" => sc.stop_at_end = false, + "--ew" => sc.ew = true, + "--no-ew" => sc.ew = false, + "--json" => json = true, + "--list" => { + println!("scenarios: {PRESETS:?}"); + println!("systems: {:?}", SamKind::ALL.iter().map(|k| k.key()).collect::>()); + println!("drones: {:?}", DroneKind::ALL.iter().map(|k| k.spec().key).collect::>()); + return; + } + "-h" | "--help" => { + println!("{}", include_str!("main.rs").lines().take_while(|l| l.starts_with("//")).map(|l| l.trim_start_matches("//!").trim_start_matches("//").to_string() + "\n").collect::()); + return; + } + other => die(&format!("unknown option `{other}` (try --help)")), + } + } + if !raids.is_empty() { + sc.raids = raids; + } + let r = run(&sc); + if json { + println!("{}", r.json()); + } else { + print!("{}", r.text()); + } + let problems = r.problems(); + if !problems.is_empty() { + eprintln!("PROBLEMS: {}", problems.join("; ")); + std::process::exit(1); + } +} diff --git a/airdef-mod/shaders/effects.wgsl b/airdef-mod/shaders/effects.wgsl index 876862c..f71a783 100644 --- a/airdef-mod/shaders/effects.wgsl +++ b/airdef-mod/shaders/effects.wgsl @@ -59,6 +59,26 @@ fn screen_radius(world: vec3, radius: f32) -> f32 { return length((b.xy - a.xy) * frame.resolution.xy); } +// The clip-space position of a world point (what `project` starts from). +fn clip_of(world: vec3) -> vec4 { + return frame.view_proj * vec4(world - frame.camera.xyz, 1.0); +} + +// A clip position on the screen in uv. +fn clip_uv(c: vec4) -> vec2 { + let ndc = c.xy / max(abs(c.w), 1e-5); + return vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5); +} + +// `screen_radius` for a point whose clip position is known (the projection is linear, +// so the offset to the right is added in clip space): saves two projections and a matrix +// product for every effect at every pixel. +fn screen_radius_clip(c0: vec4, radius: f32) -> f32 { + let right = frame.view_inverse[0].xyz; + let cb = c0 + frame.view_proj * vec4(right * radius, 0.0); + return length((clip_uv(cb) - clip_uv(c0)) * frame.resolution.xy); +} + fn now() -> f32 { return frame.params[0].x + frame.time.y; } @@ -86,22 +106,27 @@ fn post(uv: vec2, pixel: vec2) -> vec4 { let b = frame.params[BLAST_BASE + 2 * i + 1]; let age = t_now - b.x; if age < 0.0 { continue; } + // Past both windows (the ring's second, the haze over a hot ball) it bends nothing. + let heat = 1.0 - smoothstep(0.0, 0.5, age / b.y); + if age >= 24.0 && heat <= 0.0 { continue; } let centre = a.xyz; - let p = project(centre); - if p.z <= 0.5 { continue; } - let to = px - p.xy * frame.resolution.xy; + let c0 = clip_of(centre); + if c0.w <= 0.5 { continue; } + let to = px - clip_uv(c0) * frame.resolution.xy; let dist = length(to); // The shock: a ring racing out, fading in a second. if age < 24.0 { - let ring = screen_radius(centre, age * 3.2 * (0.6 + a.w * 0.1)); + let ring = screen_radius_clip(c0, age * 3.2 * (0.6 + a.w * 0.1)); let width = 6.0 + ring * 0.08; let x = (dist - ring) / width; - let strength = (1.0 - age / 24.0) * min(a.w / 4.0, 2.0) * 10.0; - offset += normalize(to + vec2(1e-4)) * exp(-x * x) * x * strength * frame.resolution.zw; + // exp(-x * x) * x is below 1e-8 past six widths. + if abs(x) < 6.0 { + let strength = (1.0 - age / 24.0) * min(a.w / 4.0, 2.0) * 10.0; + offset += normalize(to + vec2(1e-4)) * exp(-x * x) * x * strength * frame.resolution.zw; + } } // Heat haze over a hot fireball. - let heat = 1.0 - smoothstep(0.0, 0.5, age / b.y); - let r = screen_radius(centre, a.w * 2.0); + let r = screen_radius_clip(c0, a.w * 2.0); if heat > 0.0 && dist < r { let n = noise3(vec3(px * 0.04, t_now * 0.35)) - 0.5; offset += vec2(n, -abs(n)) * heat * (1.0 - dist / r) * 6.0 * frame.resolution.zw; @@ -149,8 +174,23 @@ fn post(uv: vec2, pixel: vec2) -> vec4 { let age = t_now - b.x; if age < 0.0 { continue; } let life = b.y; + // Burnt out: no density and no fire light. + if age >= life { continue; } let k = clamp(age / life, 0.0, 1.0); let size = a.w; + // The ball and its smoke stay inside 4.5 sizes of the blast (radius 2.28 sizes, risen + // 2.2 sizes): rays that miss that sphere cannot reach the volume. Only the + // pixels' own direction is needed, no volume maths. + let rel = a.xyz - frame.camera.xyz; + let reach = size * 4.5 + 0.1; + let cc = dot(rel, rel); + let along = dot(rel, dir); + if cc > reach * reach && (along < 0.0 || cc - along * along > reach * reach) { + // Out of reach of the volume; the fire's glow still counts. + let h0 = 1.0 - smoothstep(0.05, 0.55, k); + glow += vec3(1.0, 0.5, 0.15) * h0 * h0 * size * 0.05 / (1.0 + length(rel + vec3(0.0, k * k * size * 2.2, 0.0)) * 0.03); + continue; + } let seed = vec3(a.x * 0.37, a.y * 0.11, a.z * 0.53); // The ball swells fast, then rises and spreads as smoke. let radius = size * (0.3 + 0.9 * (1.0 - exp(-age / 3.0))) * (1.0 + k * 0.9); @@ -195,16 +235,20 @@ fn post(uv: vec2, pixel: vec2) -> vec4 { let b = frame.params[BLAST_BASE + 2 * i + 1]; let age = t_now - b.x; if age < 0.0 || age > 10.0 { continue; } - let p = project(a.xyz); - if p.z <= 0.5 { continue; } + let c0 = clip_of(a.xyz); + if c0.w <= 0.5 { continue; } + let p = vec3(clip_uv(c0), c0.w); // Ray distance on both sides: `p.z` is the depth along the view axis, which is shorter // than the distance along the ray towards the screen edge. let dist = length(a.xyz - frame.camera.xyz); let visible = select(0.25, 1.0, scene_distance(p.xy) > dist - a.w); let f = exp(-age * 0.7) * min(a.w / 4.0, 2.5) * visible; - let r = screen_radius(a.xyz, a.w * 2.5) + 20.0; + let r = screen_radius_clip(c0, a.w * 2.5) + 20.0; let dd = length(px - p.xy * frame.resolution.xy) / r; - color += vec3(1.0, 0.85, 0.6) * f * (exp(-dd * dd * 3.0) * 2.5 + exp(-dd * 0.8) * 0.25); + // The bloom's tail is below 5e-4 (under 8 bits) past nine radii. + if dd < 9.0 { + color += vec3(1.0, 0.85, 0.6) * f * (exp(-dd * dd * 3.0) * 2.5 + exp(-dd * 0.8) * 0.25); + } color += vec3(1.0, 0.8, 0.55) * f * 0.12 / (1.0 + p.z * 0.01); } @@ -212,11 +256,15 @@ fn post(uv: vec2, pixel: vec2) -> vec4 { for (var j = 0; j < lights; j++) { let a = frame.params[LIGHT_BASE + 2 * j]; let b = frame.params[LIGHT_BASE + 2 * j + 1]; - let p = project(a.xyz); - if p.z <= 0.3 { continue; } - if scene_distance(p.xy) < length(a.xyz - frame.camera.xyz) - a.w * 1.5 { continue; } - let r = screen_radius(a.xyz, a.w) + 3.0; - let dd = length(px - p.xy * frame.resolution.xy) / r; + let c0 = clip_of(a.xyz); + if c0.w <= 0.3 { continue; } + let p = clip_uv(c0); + let r = screen_radius_clip(c0, a.w) + 3.0; + let dd = length(px - p * frame.resolution.xy) / r; + // Far from the glow (intensity at most 2): its tail is below 5e-4, under 8 bits. + // This is checked before the depth lookup, which most pixels then never make. + if dd > 6.0 { continue; } + if scene_distance(p) < length(a.xyz - frame.camera.xyz) - a.w * 1.5 { continue; } let flicker = 0.85 + 0.15 * noise3(vec3(a.xyz * 0.7 + vec3(t_now * 1.3))); color += b.rgb * b.w * flicker * (exp(-dd * dd * 4.0) * 1.2 + exp(-dd * 1.2) * 0.18); } diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 2a80829..8e89e9e 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -16,7 +16,8 @@ mod monitor; mod panel; mod radar; mod replay; -mod sim; +pub mod sim; +mod scenario; mod wave; use sim::{Class, DroneKind, Ground, Out, Sam, SamKind, Sim}; @@ -505,12 +506,20 @@ impl AirDef { // ------------------------------------------------ saving - fn save(&mut self) { + /// The state as text, lines of words: the autosave's format, and the body of a scenario. + /// `fresh`: every system and object whole, magazines full (a scenario to replay from the start). + fn state_text(&self, fresh: bool, view: bool) -> String { let mut text = String::new(); for s in &self.sim.sams { // Then its ammunition (missiles, bursts, out and waiting for a truck, reload ticks // left) and where it was driving, if anywhere. - let dest = s.dest.map_or("- - -".to_string(), |d| format!("{} {} {}", d.x, d.y, d.z)); + let dest = s.dest.filter(|_| !fresh).map_or("- - -".to_string(), |d| format!("{} {} {}", d.x, d.y, d.z)); + let new = Sam::new(0, s.kind, s.pos, s.heading); + let (health, cargo, missiles, bursts, empty, reloading) = if fresh { + (new.health, new.cargo, new.missiles, new.bursts, false, 0) + } else { + (s.health, s.cargo, s.missiles, s.bursts, s.empty, s.reloading) + }; text += &format!( "sam {} {} {} {} {} {} {} {} {} {} {} {} {dest}\n", s.kind.key(), @@ -519,24 +528,31 @@ impl AirDef { s.pos.z, s.heading, s.roles, - s.health, - s.cargo, - s.missiles, - s.bursts, - u8::from(s.empty), - s.reloading, + health, + cargo, + missiles, + bursts, + u8::from(empty), + reloading, ); } for (i, t) in self.sim.targets.iter().enumerate() { - let h = self.sim.target_health.get(i).copied().unwrap_or(100.0); + let h = if fresh { 100.0 } else { self.sim.target_health.get(i).copied().unwrap_or(100.0) }; text += &format!("target {} {} {} {h}\n", t.x, t.y, t.z); } text += &format!("damage {}\n", self.sim.damage); - text += &format!("far {}\n", self.far); - text += &format!("effects {} {}\n", self.sim.shader, self.hd); + if view { + text += &format!("far {}\n", self.far); + text += &format!("effects {} {}\n", self.sim.shader, self.hd); + } for (kind, roles) in &self.sim.roles { text += &format!("roles {} {}\n", kind.key(), roles); } + text + } + + fn save(&mut self) { + let text = self.state_text(false, true); // Clean only once written: a failed write is tried again after a pause. match storage::set(&self.state_key(), text.as_bytes()) { Ok(()) => { @@ -556,7 +572,12 @@ impl AirDef { fn load_from(&mut self, key: &str) { let Some(bytes) = storage::get(key) else { return }; - let text = String::from_utf8_lossy(&bytes); + self.apply_state(&String::from_utf8_lossy(&bytes)); + } + + /// Adds the state in `text` (`state_text`'s lines; unknown lines are skipped, so older and + /// newer files read alike). + fn apply_state(&mut self, text: &str) { for line in text.lines() { let w: Vec<&str> = line.split_whitespace().collect(); let num = |i: usize| w.get(i).and_then(|v| v.parse::().ok()); @@ -601,6 +622,13 @@ impl AirDef { } } Some(&"damage") => self.sim.damage = w.get(1) != Some(&"false"), + // A scenario's: the random generator's state, and the wave menu's raid. + Some(&"seed") => { + if let Some(v) = w.get(1).and_then(|v| v.parse::().ok()) { + self.sim.rng = sim::Rng::new(v); + } + } + Some(&"wave") => self.wave.parse(&w[1..]), Some(&"far") => self.far = w.get(1) != Some(&"false"), Some(&"effects") => { let shader = w.get(1) != Some(&"false"); @@ -910,7 +938,7 @@ impl AirDef { } } -const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats"; +const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | scenario save|load|list|delete | why [номер] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats"; impl Mod for AirDef { fn init() -> Result<(Self, Registration), String> { @@ -1286,6 +1314,21 @@ impl Mod for AirDef { } None } + ["scenario", rest @ ..] => Some(self.scenario_command(rest)?), + ["why", rest @ ..] => { + // The system n of the summary, else the monitored one. + let i = match rest.first() { + Some(n) => n.parse::().ok().and_then(|n| n.checked_sub(1)).ok_or("/airdef why <номер>")?, + None => self.monitor.ok_or("/airdef why <номер> (или откройте монитор K)")?, + }; + if i >= self.sim.sams.len() { + return Err("Нет такой техники".into()); + } + for c in self.sim.why(i) { + say(&format!("{} {}", if c.ok { "+" } else { "-" }, c.text)); + } + None + } ["raid"] => { self.launch_wave(&mut out)?; None diff --git a/airdef-mod/src/monitor.rs b/airdef-mod/src/monitor.rs index 5fff412..8927881 100644 --- a/airdef-mod/src/monitor.rs +++ b/airdef-mod/src/monitor.rs @@ -230,6 +230,13 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec Vec { let mut s = Vec::new(); cell(sim, index, 4.0, 4.0, 300.0, 150.0, &mut s); + // Why it fires or doesn't: the conditions the simulation decides by, worked out now for this one. + let checks = sim.why(index); + s.push(fill(4.0, 156.0, 300.0, 4.0 + 10.0 * checks.len() as f32, 0xA0080C0A)); + for (k, c) in checks.iter().enumerate() { + let color = if c.ok { 0xFF80FF90 } else { 0xFFFF9060 }; + s.push(text(fit(format!("{} {}", if c.ok { "+" } else { "-" }, c.text), 294.0), 7.0, 158.0 + 10.0 * k as f32, color)); + } s } diff --git a/airdef-mod/src/replay.rs b/airdef-mod/src/replay.rs index 7e91d24..15c6c86 100644 --- a/airdef-mod/src/replay.rs +++ b/airdef-mod/src/replay.rs @@ -692,6 +692,11 @@ impl Player { } let keys = format!("{KEYS_HELP} · колесо: время"); s.push(text(keys, gw / 2.0, gh - 12.0, 0xC0FFFFFF, true)); + // What went wrong for the air defence, as counted during the raid. + let tally = rec.tally.text(); + if !tally.is_empty() && horizon_mod_sdk::render::text_width(&tally) < gw - 24.0 - lw { + s.push(text(tally, 8.0, 6.0 + 11.0, 0xB0FFC080, false)); + } let (tx, ty, tw) = (8.0, gh - bottom + 6.0, gw - 16.0); s.push(fill(tx, ty, tw, 3.0, 0x60FFFFFF)); let len = rec.len.max(1) as f32; diff --git a/airdef-mod/src/scenario.rs b/airdef-mod/src/scenario.rs new file mode 100644 index 0000000..69c9cd6 --- /dev/null +++ b/airdef-mod/src/scenario.rs @@ -0,0 +1,122 @@ +//! Named scenarios: a snapshot of the layout (systems, objects), the wave menu's raid and the +//! random generator, kept under a name and loaded back later. The body is the autosave's text +//! (`state_text`); a header names the format's version and the world it was made in. +use super::*; + +/// The format this build writes and reads. +const VERSION: u32 = 1; +/// Names of the saved scenarios, one a line. +const INDEX: &str = "scenarios"; + +fn key(name: &str) -> String { + format!("scn-{name}") +} + +fn valid(name: &str) -> Result { + let n = name.to_lowercase(); + if n.is_empty() || n.len() > 32 || !n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { + return Err("Имя сценария: до 32 символов, латиница, цифры, - и _".into()); + } + Ok(n) +} + +fn names() -> Vec { + storage::get(INDEX).map(|b| String::from_utf8_lossy(&b).lines().map(str::to_string).filter(|l| !l.is_empty()).collect()).unwrap_or_default() +} + +impl AirDef { + /// The scenario as text. + fn scenario_text(&self, name: &str, fresh: bool) -> String { + let mut text = format!("scenario {VERSION}\nname {name}\nworld {}\n", self.state_key()); + text += &self.state_text(fresh, false); + text += &format!("seed {}\nwave {}\n", self.sim.rng.state(), self.wave.text()); + text + } + + /// Replaces the world's layout with the scenario `text`; nothing changes if it is refused. + fn apply_scenario(&mut self, text: &str, force: bool) -> Result<(), String> { + let mut lines = text.lines(); + let version = lines + .next() + .and_then(|l| l.strip_prefix("scenario ")) + .ok_or("Это не сценарий (нет строки версии)")? + .trim(); + match version.parse::() { + Ok(VERSION) => {} + _ => return Err(format!("Сценарий формата {version}: эта версия мода понимает только формат {VERSION}")), + } + let world = text.lines().find_map(|l| l.strip_prefix("world ")).unwrap_or("").trim(); + if !force && world != self.state_key() { + return Err("Сценарий сделан в другом мире: координаты могут не подойти (загрузить всё равно: добавьте force)".into()); + } + if self.replay.is_some() { + self.stop_replay(); + } + // Everything of the last layout goes: its systems with their jobs and targets, the sky, + // the cook-offs to come; ids are made anew with the new systems. + self.sim.clear(); + self.sim.sams.clear(); + self.sim.targets.clear(); + self.sim.target_health.clear(); + self.sim.roles.clear(); + self.sim.kills = 0; + self.sim.hits = 0; + self.sim.launched = 0; + self.monitor = None; + self.grid_ids.clear(); + self.moving = None; + self.pending.clear(); + render::set_models(&[]); + self.apply_state(text); + self.dirty = true; + Ok(()) + } + + /// `/airdef scenario save|load|list|delete`. + pub(crate) fn scenario_command(&mut self, args: &[&str]) -> Result { + match args { + ["save", name, flags @ ..] => { + let name = valid(name)?; + let fresh = flags.contains(&"fresh"); + let text = self.scenario_text(&name, fresh); + storage::set(&key(&name), text.as_bytes()).map_err(|e| format!("Не удалось сохранить: {e}"))?; + let mut all = names(); + if !all.contains(&name) { + all.push(name.clone()); + storage::set(INDEX, all.join("\n").as_bytes()).map_err(|e| format!("Не удалось записать список: {e}"))?; + } + Ok(format!( + "Сценарий «{name}» сохранён: комплексов {}, объектов {}{}", + self.sim.sams.len(), + self.sim.targets.len(), + if fresh { " (целые, с полным боезапасом)" } else { "" } + )) + } + ["load", name, flags @ ..] => { + let name = valid(name)?; + let bytes = storage::get(&key(&name)).ok_or(format!("Нет сценария «{name}» (/airdef scenario list)"))?; + self.apply_scenario(&String::from_utf8_lossy(&bytes), flags.contains(&"force"))?; + Ok(format!("Сценарий «{name}» загружен: комплексов {}, объектов {}", self.sim.sams.len(), self.sim.targets.len())) + } + ["list"] => { + let all = names(); + if all.is_empty() { + return Ok("Сценариев нет: /airdef scenario save <имя>".into()); + } + Ok(format!("Сценарии: {}", all.join(", "))) + } + ["delete", name] => { + let name = valid(name)?; + let mut all = names(); + if !all.contains(&name) { + return Err(format!("Нет сценария «{name}»")); + } + all.retain(|n| *n != name); + storage::delete(&key(&name)); + storage::set(INDEX, all.join("\n").as_bytes()).map_err(|e| format!("Не удалось записать список: {e}"))?; + Ok(format!("Сценарий «{name}» удалён")) + } + _ => Err("/airdef scenario save <имя> [fresh] | load <имя> [force] | list | delete <имя>".into()), + } + } +} diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index ff50ab9..4fe495c 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -18,6 +18,7 @@ mod lights; mod logistics; mod orders; mod units; +mod why; #[cfg(test)] mod regress; pub use ew::EW_RADIUS; @@ -112,6 +113,10 @@ impl Rng { pub fn new(seed: u64) -> Self { Self(seed | 1) } + /// The generator's state: `new(state())` continues the same sequence. + pub fn state(&self) -> u64 { + self.0 + } pub fn next(&mut self) -> u64 { self.0 ^= self.0 << 13; self.0 ^= self.0 >> 7; @@ -400,6 +405,17 @@ impl DroneKind { DroneKind::OreshnikBlock, DroneKind::OreshnikSub, ]; + /// The sound of a launch from the ground: a light catapult shot, a rocket booster, a heavy ballistic launch. + pub fn launch_sound(self) -> &'static str { + match self { + DroneKind::Shahed | DroneKind::Geran3 | DroneKind::Lancet | DroneKind::Gerbera => "airdef:launch.s400_eject", + DroneKind::Kh101 | DroneKind::Kalibr | DroneKind::Kh22 => "airdef:launch.patriot", + DroneKind::Zircon => "airdef:launch.s400", + DroneKind::Iskander | DroneKind::Kinzhal | DroneKind::Oreshnik | DroneKind::OreshnikBlock | DroneKind::OreshnikSub => { + "airdef:launch.s500" + } + } + } pub fn spec(self) -> &'static DroneSpec { match self { DroneKind::Shahed => &DroneSpec { @@ -818,7 +834,7 @@ impl MissileKind { rear: 32.0, }, MissileKind::IrisT => &MissileSpec { - model: "airdef:pac3", + model: "airdef:irist_missile", speed: 7.0, accel: 0.5, turn: 0.35, @@ -832,7 +848,7 @@ impl MissileKind { rear: 28.0, }, MissileKind::Amraam => &MissileSpec { - model: "airdef:pac3", + model: "airdef:amraam_missile", speed: 6.0, accel: 0.45, turn: 0.3, @@ -1179,6 +1195,8 @@ pub struct Sim { rec: Option, pub replays: Vec, recordings: u32, + /// What went wrong for the air defence, counted for the raid report and the recordings. + pub tally: record::Tally, /// What the enemy has learned. intel: ew::Intel, /// Night: guns need light (the mod sets it from the game's clock). @@ -1220,6 +1238,7 @@ impl Sim { rec: None, replays: Vec::new(), recordings: 0, + tally: record::Tally::default(), intel: ew::Intel::default(), night: false, flares: Vec::new(), @@ -1304,7 +1323,7 @@ impl Sim { let dir = DVec3::new(dir.x, dir.y.max(0.35), dir.z).normalize_or(DVec3::Y); let from = if kind.spec().ballistic.is_some() { from + DVec3::Y * 2.0 } else { from }; self.add_drone(kind, from, dir, target); - out.sound("airdef:launch.patriot", from, 1.0, 1.3); + out.sound(kind.launch_sound(), from, 1.0, 1.3); out.particles("minecraft:large_smoke", from, 0.6, DVec3::ZERO, 0.05, 30); out.particles("minecraft:flame", from, 0.3, -dir * 0.2, 0.05, 12); } @@ -1763,12 +1782,9 @@ impl Sim { // Drones by id (none come or go while the systems act), and how many systems have each as their // missile target and as their gun target: counted once, then kept up to date as targets change. let drone_at: HashMap = self.drones.iter().enumerate().map(|(k, d)| (d.id, k)).collect(); - let mut missile_on: HashMap = HashMap::new(); - let mut guns_on: HashMap = HashMap::new(); - for s in &self.sams { - retarget(&mut missile_on, None, s.target); - retarget(&mut guns_on, None, s.gun_target); - } + let (mut missile_on, mut guns_on) = why::engagement_counts(&self.sams); + // For the raid's report: tracks dropped and systems that ran dry and wait for a truck. + let (mut tracks_lost, mut ammo_waits) = (0u32, 0u32); let mut launches: Vec<(MissileKind, DVec3, DVec3, u32, u32)> = Vec::new(); let mut gun_hits: Vec<(u32, u32)> = Vec::new(); let mut tracers: Vec<(u32, DVec3, DVec3, u32)> = Vec::new(); @@ -1816,16 +1832,12 @@ impl Sim { let (had_target, had_gun) = (self.sams[i].target, self.sams[i].gun_target); let (target, gun_target) = { let (seen, own) = (&self.sams[i].seen, &self.sams[i].own); - let allowed = |d: &Drone| d.alive() && roles & d.kind.spec().class.bit() != 0 && seen.contains(&d.id); - // Interceptors a target has or will have: in flight, and other systems' launchers on it. - let claimed = |d: &Drone| { - let on = missile_on.get(&d.id).copied().unwrap_or(0) - u32::from(had_target == Some(d.id)); - d.engaged + on - }; - // Ballistic missiles get a pair; anything else one. - let needed = |d: &Drone| if d.kind.spec().class == Class::Ballistic { 2 } else { 1 }; + // The conditions are `why`'s, shared with the diagnostic. + let allowed = |d: &Drone| why::allowed(roles, seen, d); + let claimed = |d: &Drone| why::claimed(d, &missile_on, had_target); + let needed = why::needed; let target = spec.missile.as_ref().map(|load| { - let in_reach = |d: &Drone, k: f64| (load.min..load.max * k).contains(&d.pos.distance(pos)); + let in_reach = |d: &Drone, k: f64| why::in_reach(load.min, load.max, k, pos, d); let keep = had_target.filter(|t| { drone_at.get(t).is_some_and(|&k| allowed(&self.drones[k]) && in_reach(&self.drones[k], 1.1)) }); @@ -1844,7 +1856,7 @@ impl Sim { }; self.drones .iter() - .filter(|d| allowed(d) && own.contains(&d.id) && d.pos.distance(pos) < gun.track) + .filter(|d| why::gun_candidate(roles, seen, own, gun.track, pos, d)) .map(|d| { let busy = 1.0 + 2.0 * on_it(d) + if d.engaged > 0 { 1.5 } else { 0.0 }; (d.pos.distance(pos) * busy, d.id) @@ -1855,6 +1867,13 @@ impl Sim { (target, gun_target) }; if let Some(target) = target { + // A target still in the sky that the system had and now doesn't: its track was lost. + if let Some(h) = had_target + && target != Some(h) + && drone_at.get(&h).is_some_and(|&k| self.drones[k].alive()) + { + tracks_lost += 1; + } retarget(&mut missile_on, had_target, target); self.sams[i].target = target; } @@ -1871,7 +1890,12 @@ impl Sim { // combined system's crew would), not only once both are spent. let spent = (spec.missile.is_some() && sam.missiles == 0) || (spec.gun.is_some() && sam.bursts == 0); if spent && sam.reloading == 0 && !sam.empty { - if logistics { sam.empty = true } else { sam.reloading = spec.reload } + if logistics { + sam.empty = true; + ammo_waits += 1; + } else { + sam.reloading = spec.reload + } } sam.state = if sam.reloading > 0 { SamState::Reload @@ -1886,10 +1910,7 @@ impl Sim { }; let (mount, _) = sam.mount(); let lead_speed = spec.gun.as_ref().map_or(5.0, |g| g.shell); - let lead = aim.pos + aim.vel * (aim.pos.distance(mount) / lead_speed); - let to = lead - mount; - let want_yaw = heading_of(to); - let want_pitch = (to.y / to.length().max(0.01)).asin().clamp(-0.1, 1.45); + let (lead, want_yaw, want_pitch) = why::aim_wants(mount, aim, lead_speed); let cells = matches!(spec.muzzle, Muzzle::Cells); match spec.family { // Vertical launch: no aiming (the Tor's turret turns its radar to the target). @@ -1900,11 +1921,7 @@ impl Sim { _ => sam.aim_towards(want_yaw, Some(want_pitch)), } // How far the mount still is from the aim, over the axes this family aims. - let yaw_error = angle_diff(sam.yaw, want_yaw).abs(); - let aim_error = match spec.family { - Family::Trainable | Family::Vertical => yaw_error, - _ => yaw_error + (want_pitch - sam.pitch).abs(), - }; + let aim_error = why::aim_error(spec.family, sam.yaw, sam.pitch, want_yaw, want_pitch); let vertical = spec.family == Family::Vertical; sam.state = if sam.reloading > 0 { SamState::Reload @@ -1925,13 +1942,18 @@ impl Sim { if let (Some(gun), Some(gt)) = (&spec.gun, sam.gun_target) { if let Some(t) = drone_at.get(>).map(|&k| &self.drones[k]) { let dist = t.pos.distance(mount); - if aim.id == gt && aim_error < 0.1 && dist < gun.range && sam.gun_cooldown == 0 && sam.reloading == 0 && sam.bursts > 0 { + if why::gun_blocks(sam, aim.id, gt, aim_error, dist, gun.range) == 0 { sam.gun_cooldown = 2; sam.bursts -= 1; sam.flash = 2; sam.recoil = 1.0; if sam.bursts == 0 && sam.missiles == 0 { - if logistics { sam.empty = true } else { sam.reloading = spec.reload } + if logistics { + sam.empty = true; + ammo_waits += 1; + } else { + sam.reloading = spec.reload + } } let (_, rot) = sam.mount(); let s = spec.scale; @@ -1968,16 +1990,10 @@ impl Sim { } // Missiles. if let (Some(load), Some(mt)) = (&spec.missile, sam.target) { - let ready = sam.cooldown == 0 && sam.reloading == 0 && sam.missiles > 0; - let aligned = match spec.family { - Family::Trainable => { - let t = drone_at.get(&mt).map(|&k| &self.drones[k]); - // The missile turns after its boost, but the launcher must point near the target. - t.is_some_and(|t| angle_diff(sam.yaw, heading_of(t.pos - mount)).abs() < 0.15) - } - Family::Turret => aim.id == mt && aim_error < 0.15, - _ => true, - }; + let ready = why::missile_blocks(sam) == 0; + // The missile turns after its boost, but the launcher must point near the target. + let t = drone_at.get(&mt).map(|&k| &self.drones[k]); + let aligned = why::missile_aligned(spec.family, sam.yaw, mount, t, aim.id, mt, aim_error); if ready && aligned { let n = load.magazine - sam.missiles; let (muzzle, dir) = sam.muzzle(n); @@ -1994,11 +2010,20 @@ impl Sim { sam.fired += 1; sam.cooldown = load.interval; if sam.missiles == 0 && (spec.gun.is_none() || sam.bursts == 0) { - if logistics { sam.empty = true } else { sam.reloading = spec.reload } + if logistics { + sam.empty = true; + ammo_waits += 1; + } else { + sam.reloading = spec.reload + } } } } } + self.note(|t| { + t.tracks_lost += tracks_lost; + t.ammo_waits += ammo_waits; + }); for (kind, muzzle, dir, target, sam) in launches { // A launch gives the system away. if let Some(s) = self.sams.iter().find(|s| s.id == sam) { @@ -2038,6 +2063,7 @@ impl Sim { fn step_missiles(&mut self, out: &mut Out) { let mut done: Vec<(u32, Option, bool, u32)> = Vec::new(); // (missile, target, kill, sam) + let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32); let sams_fit: HashMap = self.sams.iter().map(|s| (s.id, s.fitness())).collect(); for m in &mut self.missiles { m.age += 1; @@ -2076,11 +2102,18 @@ impl Sim { 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 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)); continue; } } if m.age > spec.life || (target.is_none() && m.age > 30) { + // Its target gone (shot down by another, or lost), or its life spent chasing one. + if target.is_none() { + orphaned += 1; + } else { + expired += 1; + } done.push((m.id, None, false, m.sam)); continue; } @@ -2114,6 +2147,11 @@ impl Sim { } } } + self.note(|t| { + t.missed += missed; + t.expired += expired; + t.orphaned += orphaned; + }); for (mid, target, kill, sam) in done { if let Some(i) = self.missiles.iter().position(|m| m.id == mid) { let m = self.missiles.swap_remove(i); diff --git a/airdef-mod/src/sim/orders.rs b/airdef-mod/src/sim/orders.rs index 98d51ac..6d3321a 100644 --- a/airdef-mod/src/sim/orders.rs +++ b/airdef-mod/src/sim/orders.rs @@ -25,6 +25,10 @@ impl SamKind { } impl Sam { + /// A reload truck's job: the system or depot it is going to. + pub fn job(&self) -> Option { + self.job + } /// Packing up, driving or deploying: not fighting. pub fn moving(&self) -> bool { matches!(self.state, SamState::Packing | SamState::Moving | SamState::Deploying) diff --git a/airdef-mod/src/sim/raid.rs b/airdef-mod/src/sim/raid.rs index af8bdef..c729b4f 100644 --- a/airdef-mod/src/sim/raid.rs +++ b/airdef-mod/src/sim/raid.rs @@ -95,6 +95,7 @@ pub(super) struct WaveStats { sams: usize, objects: f64, size: u32, + tally: record::Tally, } /// The planner's grid: cells this big, at most this many a side. @@ -316,6 +317,7 @@ impl Sim { sams: self.sams.iter().filter(|s| s.alive()).count(), objects: self.target_health.iter().sum(), size: plan.total, + tally: self.tally, }); let what = if plan.smart { "умный противник" } else { plan.approach.name() }; out.messages.push(format!("ВОЗДУШНАЯ ТРЕВОГА: налёт, {} целей ({what})", plan.total)); @@ -499,6 +501,11 @@ impl Sim { "Налёт окончен: сбито {kills} из {launched} (заявлено {}), прорвалось {through}; ракет ПВО {fired}; потеряно комплексов {lost}{objects}", w.size )); + // Only what was counted; nothing is guessed for the rest. + let why = self.tally.since(w.tally).text(); + if !why.is_empty() { + out.messages.push(format!("Неудачи ПВО: {why}")); + } self.wave = None; } } diff --git a/airdef-mod/src/sim/record.rs b/airdef-mod/src/sim/record.rs index 262df78..d9b6b83 100644 --- a/airdef-mod/src/sim/record.rs +++ b/airdef-mod/src/sim/record.rs @@ -48,6 +48,48 @@ pub struct Event { pub kind: EventKind, } +/// Why interceptors and systems failed, as far as it was counted: only these are known. +#[derive(Clone, Copy, Default, PartialEq, Debug)] +pub struct Tally { + /// Interceptors that reached their fuse and did not kill (the hit chance failed). + pub missed: u32, + /// Interceptors whose life ran out with the target still flying. + pub expired: u32, + /// Interceptors whose target was gone (shot down by another, or lost) before they arrived. + pub orphaned: u32, + /// Times a system lost the track of the target it held, which was still in the sky. + pub tracks_lost: u32, + /// Times a system ran dry and began waiting for a reload truck. + pub ammo_waits: u32, +} + +impl Tally { + pub fn since(self, start: Tally) -> Tally { + Tally { + missed: self.missed.saturating_sub(start.missed), + expired: self.expired.saturating_sub(start.expired), + orphaned: self.orphaned.saturating_sub(start.orphaned), + tracks_lost: self.tracks_lost.saturating_sub(start.tracks_lost), + ammo_waits: self.ammo_waits.saturating_sub(start.ammo_waits), + } + } + /// The non-zero counts as a line, or nothing. + pub fn text(&self) -> String { + let parts: Vec = [ + (self.missed, "промахов по взрывателю"), + (self.expired, "ракет выработали ресурс"), + (self.orphaned, "ракет остались без цели"), + (self.tracks_lost, "потерь трека"), + (self.ammo_waits, "ожиданий БК"), + ] + .into_iter() + .filter(|p| p.0 > 0) + .map(|p| format!("{} {}", p.0, p.1)) + .collect(); + parts.join(", ") + } +} + pub struct Recording { /// Which raid of the session (1, 2, …). pub number: u32, @@ -57,6 +99,8 @@ pub struct Recording { pub tracks: Vec, index: HashMap, pub events: Vec, + /// The failures counted during the raid. + pub tally: Tally, /// The systems and objects as they stood: (id, kind, where). pub sams: Vec<(u32, SamKind, DVec3)>, pub objects: Vec, @@ -116,6 +160,7 @@ impl Recording { tracks: Vec::new(), index: HashMap::new(), events: Vec::new(), + tally: Tally::default(), sams: sim.sams.iter().map(|s| (s.id, s.kind, s.pos)).collect(), objects: sim.targets.clone(), quiet: 0, @@ -150,6 +195,14 @@ impl Sim { } impl Sim { + /// Counts something in the session's tally and in the recording going on. + pub(super) fn note(&mut self, f: impl Fn(&mut Tally)) { + f(&mut self.tally); + if let Some(r) = &mut self.rec { + f(&mut r.tally); + } + } + /// Something worth a replay happened. pub(super) fn log(&mut self, pos: DVec3, kind: EventKind) { if let Some(r) = &mut self.rec { diff --git a/airdef-mod/src/sim/why.rs b/airdef-mod/src/sim/why.rs new file mode 100644 index 0000000..ec605dc --- /dev/null +++ b/airdef-mod/src/sim/why.rs @@ -0,0 +1,305 @@ +//! The conditions that decide whether a system fires, as predicates: `step_sams` decides with +//! them, and `Sim::why` lists them for a chosen system (`/airdef why`, the monitor), so the +//! explanation can't drift from the behaviour. Each predicate returns the set of reasons it +//! fails (bits; 0 is a pass), so the diagnostic names the reasons and the simulation only asks +//! whether there are none. +use super::*; + +// ---------------------------------------------------------------- choosing a target + +/// Why a drone is not a candidate for a system at all. +pub(super) const DEAD: u8 = 1; +pub(super) const ROLE: u8 = 2; +pub(super) const UNSEEN: u8 = 4; + +pub(super) fn target_blocks(roles: u8, seen: &[u32], d: &Drone) -> u8 { + (if d.alive() { 0 } else { DEAD }) + | (if roles & d.kind.spec().class.bit() != 0 { 0 } else { ROLE }) + | (if seen.contains(&d.id) { 0 } else { UNSEEN }) +} + +/// Alive, of a class the system engages, and on its picture (own radar or the network's). +pub(super) fn allowed(roles: u8, seen: &[u32], d: &Drone) -> bool { + target_blocks(roles, seen, d) == 0 +} + +pub(super) const TOO_CLOSE: u8 = 1; +pub(super) const TOO_FAR: u8 = 2; + +/// Missile range: `min` to `max * k` (a target already held may stray to 1.1 × max). +pub(super) fn reach_blocks(min: f64, max: f64, k: f64, pos: DVec3, d: &Drone) -> u8 { + let dist = d.pos.distance(pos); + (if dist >= min { 0 } else { TOO_CLOSE }) | (if dist < max * k { 0 } else { TOO_FAR }) +} + +pub(super) fn in_reach(min: f64, max: f64, k: f64, pos: DVec3, d: &Drone) -> bool { + reach_blocks(min, max, k, pos, d) == 0 +} + +/// Interceptors a target has or will have: in flight, and other systems' launchers on it. +pub(super) fn claimed(d: &Drone, missile_on: &HashMap, had_target: Option) -> u32 { + let on = missile_on.get(&d.id).copied().unwrap_or(0) - u32::from(had_target == Some(d.id)); + d.engaged + on +} + +/// Ballistic missiles get a pair; anything else one. +pub(super) fn needed(d: &Drone) -> u32 { + if d.kind.spec().class == Class::Ballistic { 2 } else { 1 } +} + +/// A gun's candidates: held by the system's own radar and within the gun's tracking range. +pub(super) fn gun_candidate(roles: u8, seen: &[u32], own: &[u32], track: f64, pos: DVec3, d: &Drone) -> bool { + allowed(roles, seen, d) && own.contains(&d.id) && d.pos.distance(pos) < track +} + +/// How many systems have each drone as their missile target and as their gun target. +pub(super) fn engagement_counts(sams: &[Sam]) -> (HashMap, HashMap) { + let mut missile_on = HashMap::new(); + let mut guns_on = HashMap::new(); + for s in sams { + retarget(&mut missile_on, None, s.target); + retarget(&mut guns_on, None, s.gun_target); + } + (missile_on, guns_on) +} + +// ---------------------------------------------------------------- aiming + +/// Where the lead point is and the yaw and pitch that point the mount at it. +pub(super) fn aim_wants(mount: DVec3, aim: &Drone, lead_speed: f64) -> (DVec3, f64, f64) { + let lead = aim.pos + aim.vel * (aim.pos.distance(mount) / lead_speed); + let to = lead - mount; + let want_yaw = heading_of(to); + let want_pitch = (to.y / to.length().max(0.01)).asin().clamp(-0.1, 1.45); + (lead, want_yaw, want_pitch) +} + +/// How far the mount still is from the aim, over the axes this family aims. +pub(super) fn aim_error(family: Family, yaw: f64, pitch: f64, want_yaw: f64, want_pitch: f64) -> f64 { + let yaw_error = angle_diff(yaw, want_yaw).abs(); + match family { + Family::Trainable | Family::Vertical => yaw_error, + _ => yaw_error + (want_pitch - pitch).abs(), + } +} + +// ---------------------------------------------------------------- firing + +pub(super) const COOLDOWN: u8 = 1; +pub(super) const RELOADING: u8 = 2; +pub(super) const NO_ROUNDS: u8 = 4; + +/// What keeps a loaded launcher from firing now (aim aside). +pub(super) fn missile_blocks(s: &Sam) -> u8 { + (if s.cooldown == 0 { 0 } else { COOLDOWN }) + | (if s.reloading == 0 { 0 } else { RELOADING }) + | (if s.missiles > 0 { 0 } else { NO_ROUNDS }) +} + +/// The launcher points near enough: a trainable one only by its training, a turret by its +/// whole aim at this target, the vertical ones need nothing. +pub(super) fn missile_aligned(family: Family, yaw: f64, mount: DVec3, t: Option<&Drone>, aim_id: u32, mt: u32, aim_error: f64) -> bool { + match family { + Family::Trainable => t.is_some_and(|t| angle_diff(yaw, heading_of(t.pos - mount)).abs() < 0.15), + Family::Turret => aim_id == mt && aim_error < 0.15, + _ => true, + } +} + +pub(super) const NOT_AIMED: u8 = 8; +pub(super) const AIM_ERROR: u8 = 16; +pub(super) const OUT_OF_RANGE: u8 = 32; + +/// What keeps a gun from firing now: the turret on another target, not yet laid, out of range, +/// and the gun's own cooldown, reload and shells (`COOLDOWN`, `RELOADING`, `NO_ROUNDS` bits). +pub(super) fn gun_blocks(s: &Sam, aim_id: u32, gt: u32, aim_error: f64, dist: f64, range: f64) -> u8 { + (if aim_id == gt { 0 } else { NOT_AIMED }) + | (if aim_error < 0.1 { 0 } else { AIM_ERROR }) + | (if dist < range { 0 } else { OUT_OF_RANGE }) + | (if s.gun_cooldown == 0 { 0 } else { COOLDOWN }) + | (if s.reloading == 0 { 0 } else { RELOADING }) + | (if s.bursts > 0 { 0 } else { NO_ROUNDS }) +} + +// ---------------------------------------------------------------- the diagnostic + +/// One condition and whether it holds. +pub struct Check { + pub ok: bool, + pub text: String, +} + +fn check(ok: bool, text: impl Into) -> Check { + Check { ok, text: text.into() } +} + +impl Sim { + /// Why system `index` fires or doesn't, at its current target or at the most threatening + /// drone of a class it engages; computed on demand from the very predicates `step_sams` + /// uses. The last line is the verdict. + pub fn why(&self, index: usize) -> Vec { + let Some(sam) = self.sams.get(index) else { return vec![check(false, "Нет такой техники")] }; + let spec = sam.kind.spec(); + let mut v = vec![check(true, format!("№{} {}: {}", index + 1, spec.name, sam.state.name()))]; + if !sam.alive() { + v.push(check(false, "уничтожен")); + return v; + } + if self.hold { + v.push(check(false, "огонь запрещён (/airdef hold off)")); + return v; + } + if sam.moving() { + v.push(check(false, format!("{}: не стреляет", sam.state.name().to_lowercase()))); + return v; + } + if !sam.kind.armed() { + v.push(check(false, "нет оружия (РЛС, склад, РЭБ, прожектор)")); + return v; + } + let logistics = self.logistics(); + let (missile_on, guns_on) = engagement_counts(&self.sams); + let alive = self.drones.iter().filter(|d| d.alive()).count(); + let seen = self.drones.iter().filter(|d| d.alive() && sam.seen.contains(&d.id)).count(); + let own = self.drones.iter().filter(|d| d.alive() && sam.own.contains(&d.id)).count(); + v.push(check(alive > 0, format!("в небе {alive}, на картине {seen} (своя РЛС {own}, остальные по сети)"))); + let drone_at = |id: u32| self.drones.iter().find(|d| d.id == id); + 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) + .max_by(|a, b| a.threat(sam.pos).total_cmp(&b.threat(sam.pos))) + }); + let Some(t) = subject else { + v.push(check(false, format!("нет целей классов [{}]", roles_text(sam.roles)))); + return v; + }; + let held = sam.target == Some(t.id) || sam.gun_target == Some(t.id); + let dist = t.pos.distance(sam.pos); + v.push(check( + true, + format!("цель: {} {}, {}", t.kind.spec().name, if held { "(сопровождается)" } else { "(самая опасная)" }, crate::panel::distance(dist)), + )); + let blocks = target_blocks(sam.roles, &sam.seen, t); + v.push(check( + blocks & ROLE == 0, + format!("роль: {} {} [{}]", t.kind.spec().class.name(), if blocks & ROLE == 0 { "в ролях" } else { "не в ролях" }, roles_text(sam.roles)), + )); + let detect = match (sam.own.contains(&t.id), sam.seen.contains(&t.id)) { + (true, _) => "обнаружена своей РЛС", + (false, true) => "видна только по сети (стволы не стреляют)", + _ => "не обнаружена ни своей РЛС, ни сетью", + }; + v.push(check(blocks & UNSEEN == 0, detect)); + let fit = sam.fitness(); + v.push(check(fit >= 0.9, format!("состояние {:.0}%: поворот и точность {:.0}%", sam.health * 100.0, fit * 100.0))); + // Ammunition and its supply. + let mut rounds = String::new(); + if let Some(load) = &spec.missile { + rounds += &format!("ракет {}/{} ", sam.missiles, load.magazine); + } + if let Some(gun) = &spec.gun { + rounds += &format!("очередей {}/{} ", sam.bursts, gun.bursts); + } + let supply = if sam.reloading > 0 { + format!("перезарядка, ещё {:.1} с", sam.reloading as f64 / 20.0) + } else if sam.empty { + if logistics { "пуст, ждёт машину-заряжающую (ТЗМ) со склада".into() } else { "пуст".into() } + } else { + "боезапас есть".into() + }; + v.push(check(sam.reloading == 0 && !sam.empty && (sam.missiles > 0 || sam.bursts > 0), format!("{rounds}— {supply}"))); + let mount = sam.mount().0; + let lead_speed = spec.gun.as_ref().map_or(5.0, |g| g.shell); + let (_, want_yaw, want_pitch) = aim_wants(mount, t, lead_speed); + let err = aim_error(spec.family, sam.yaw, sam.pitch, want_yaw, want_pitch); + let vertical = spec.family == Family::Vertical; + let mut fires = false; + if let Some(load) = &spec.missile { + let reach = reach_blocks(load.min, load.max, 1.0, sam.pos, t); + 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); + let need = needed(t); + v.push(check(free < need, format!("цель занята перехватчиками: {free} из {need}{}", if free < need { "" } else { " — другие уже на ней" }))); + let mb = missile_blocks(sam); + let mut why = Vec::new(); + if mb & COOLDOWN != 0 { + why.push(format!("интервал пусков ({:.1} с)", sam.cooldown as f64 / 20.0)); + } + if mb & RELOADING != 0 { + why.push("перезарядка".to_string()); + } + if mb & NO_ROUNDS != 0 { + why.push("нет ракет".to_string()); + } + v.push(check(mb == 0, if mb == 0 { "пусковая готова".to_string() } else { format!("пусковая не готова: {}", why.join(", ")) })); + let aligned = missile_aligned(spec.family, sam.yaw, mount, Some(t), t.id, t.id, err); + let turn = if vertical { "вертикальный старт" } else { "доворот" }; + v.push(check(aligned, format!("{turn}: ошибка наведения {:.0}°{}", err.to_degrees(), if aligned { "" } else { " — ещё доворачивается" }))); + fires |= reach == 0 && free < need && mb == 0 && aligned && blocks == 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)); + let cand = gun_candidate(sam.roles, &sam.seen, &sam.own, gun.track, sam.pos, t); + v.push(check( + cand, + format!( + "орудие: сопровождение до {}, цель на {}, других орудий на ней {on_it}{}", + crate::panel::distance(gun.track), + crate::panel::distance(dist), + if cand { "" } else { " — вне сопровождения (нужна своя РЛС)" } + ), + )); + let gb = gun_blocks(sam, t.id, t.id, err, mount.distance(t.pos), gun.range); + let mut why = Vec::new(); + for (bit, name) in [ + (AIM_ERROR, format!("орудие ещё наводится ({:.0}°)", err.to_degrees())), + (OUT_OF_RANGE, format!("дальше {}", crate::panel::distance(gun.range))), + (COOLDOWN, "пауза между очередями".to_string()), + (RELOADING, "перезарядка".to_string()), + (NO_ROUNDS, "нет снарядов".to_string()), + ] { + if gb & bit != 0 { + why.push(name); + } + } + v.push(check(gb == 0, if gb == 0 { "орудие готово".to_string() } else { format!("орудие не стреляет: {}", why.join(", ")) })); + fires |= gb == 0 && cand; + } + v.push(check(fires, if fires { "итог: готова стрелять" } else { "итог: стрелять сейчас не может" })); + v + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn why_names_the_reason_a_system_does_not_fire() { + let mut sim = Sim::new(3); + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0)); + let mut ground = Ground::default(); + let mut out = Out::default(); + sim.sams[0].roles = 0; + sim.raid(DroneKind::Shahed, DVec3::new(0.0, 70.0, 0.0), 1, 200.0, &mut out); + for _ in 0..60 { + sim.step(&mut ground, None, &mut out); + } + let w = sim.why(0); + assert!(w.last().is_some_and(|c| !c.ok), "{:?}", w.iter().map(|c| &c.text).collect::>()); + assert!(w.iter().any(|c| c.text.contains("нет целей классов"))); + sim.sams[0].roles = 0xFF; + assert!(sim.why(0).iter().any(|c| c.text.starts_with("роль"))); + } +} diff --git a/airdef-mod/src/wave.rs b/airdef-mod/src/wave.rs index 71b6753..08fa474 100644 --- a/airdef-mod/src/wave.rs +++ b/airdef-mod/src/wave.rs @@ -106,6 +106,35 @@ impl WaveConfig { self.approach = p.approach; self.aim = p.aim; } + /// The words of a scenario's `wave` line: counts, approach, distance, aim, preset. + pub fn text(&self) -> String { + let counts: Vec = self.counts.iter().map(u32::to_string).collect(); + let approach = Approach::ALL.iter().position(|a| *a == self.approach).unwrap_or(0); + let aim = Aim::ALL.iter().position(|a| *a == self.aim).unwrap_or(0); + format!("{} {approach} {} {aim} {}", counts.join(","), self.dist, self.preset) + } + /// Reads `text`'s words; what does not parse keeps its value. + pub fn parse(&mut self, w: &[&str]) { + if let Some(c) = w.first() { + for (i, v) in c.split(',').take(KINDS.len()).enumerate() { + if let Ok(n) = v.parse::() { + self.counts[i] = n.min(80); + } + } + } + if let Some(a) = w.get(1).and_then(|v| v.parse::().ok()).and_then(|i| Approach::ALL.get(i)) { + self.approach = *a; + } + if let Some(d) = w.get(2).and_then(|v| v.parse::().ok()) { + self.dist = d.clamp(60.0, 800.0); + } + if let Some(a) = w.get(3).and_then(|v| v.parse::().ok()).and_then(|i| Aim::ALL.get(i)) { + self.aim = *a; + } + if let Some(p) = w.get(4).and_then(|v| v.parse::().ok()) { + self.preset = p % PRESETS.len(); + } + } /// The next number for kind `i`, back to 0 after the last. pub fn step(&mut self, i: usize) { let n = self.counts[i]; diff --git a/airdef-mod/tests/scenarios.rs b/airdef-mod/tests/scenarios.rs new file mode 100644 index 0000000..92907bb --- /dev/null +++ b/airdef-mod/tests/scenarios.rs @@ -0,0 +1,41 @@ +//! Fixed headless scenarios on the native simulation: no NaN, the raid ends, no stuck jobs. +//! Run: `cargo test --test scenarios` (also `cargo run --release --example sim_run -- --help`). +#[path = "../examples/sim_run/harness.rs"] +mod harness; + +fn check(sc: harness::Scenario) -> harness::Report { + let r = harness::run(&sc); + eprintln!("{}", r.text()); + let problems = r.problems(); + assert!(problems.is_empty(), "scenario {} failed: {problems:?}\n{}", r.name, r.text()); + assert!(r.launched > 0, "nothing was launched"); + r +} + +#[test] +fn big_smart_raid_on_61_systems() { + let r = check(harness::big_smart()); + assert_eq!(r.units, 61); + assert!(r.fired > 0, "air defence never fired"); + assert!(r.ground_queries > 0); +} + +#[test] +fn logistics_trucks_depot_and_raid() { + let r = check(harness::logistics()); + assert!(r.truck_busy_ticks > 0, "the trucks never had a job"); + assert!(r.fired > 0, "air defence never fired"); +} + +#[test] +fn ew_and_adaptive_enemy_over_three_raids() { + let r = check(harness::ew_adaptive()); + assert_eq!(r.raids_ended, 3); +} + +#[test] +fn same_seed_same_result() { + let a = harness::run(&harness::logistics()); + let b = harness::run(&harness::logistics()); + assert_eq!((a.ticks_run, a.launched, a.kills, a.hits, a.fired, a.ground_queries), (b.ticks_run, b.launched, b.kills, b.hits, b.fired, b.ground_queries)); +} 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 58412cd..9bf37be 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/14] Mod API (AirDef fork): world models, client particles, +Subject: [PATCH 01/15] 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 4a6b821..fe727fa 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/14] cargo-horizon-mod: report particles and sounds +Subject: [PATCH 02/15] 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 f4c2e87..c6378e2 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/14] Mod API (AirDef fork): full-screen post pass for mods +Subject: [PATCH 03/15] 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 057bffc..3ff351d 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/14] Mod models: draw far models as scaled impostors past +Subject: [PATCH 04/15] 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 69e4568..d7c7c09 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/14] Mod cameras: render.set-views draws the world from +Subject: [PATCH 05/15] 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 3678388..140a66f 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/14] Mods: more fuel per tick and per event (AirDef plans +Subject: [PATCH 06/15] 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 27e8f01..7a56f25 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/14] Mod cameras up to 1920x1080 (full-screen replays) +Subject: [PATCH 07/15] 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 8529557..20e5683 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/14] Mod cameras: distant terrain tiles, fog measured from +Subject: [PATCH 08/15] 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 685635e..06bee0a 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/14] Mods: game.world-id, so mods keep state per world (API +Subject: [PATCH 09/15] 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 c39976e..5d90f1e 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/14] cargo-horizon-mod: trial runs behind feature full, so a +Subject: [PATCH 10/15] 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 113cfd5..3184926 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/14] HD lighting: G-buffer pass, per-pixel mod point lights +Subject: [PATCH 11/15] 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 6207ec1..c590d44 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/14] HD lighting stage 2: sun and moon shadow cascades, the +Subject: [PATCH 12/15] 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 445cc59..1b8c257 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/14] HD: bind groups cached until a resize or a new source, +Subject: [PATCH 13/15] 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 f7d1a8d..c2d4b76 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/14] HD: lights culled per 16x16 tile (surface and halo +Subject: [PATCH 14/15] 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 new file mode 100644 index 0000000..c4aa2fa --- /dev/null +++ b/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch @@ -0,0 +1,712 @@ +From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001 +From: chexit +Date: Thu, 29 Oct 2026 16:43:36 +0300 +Subject: [PATCH 15/15] 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 +--- + crates/horizon-client/src/gfx.rs | 23 +- + crates/horizon-client/src/gfx/hd.rs | 430 +++++++++++++++++++++++++++- + 2 files changed, 443 insertions(+), 10 deletions(-) + +diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs +index f6bbe038c..e14429b03 100644 +--- a/crates/horizon-client/src/gfx.rs ++++ b/crates/horizon-client/src/gfx.rs +@@ -3725,9 +3725,20 @@ impl Renderer { + (_, Some(native)) => native.timing.as_ref(), + _ => None, + }; +- timer ++ let mut times = timer + .map(pack_timing::PassTiming::medians) +- .unwrap_or_default() ++ .unwrap_or_default(); ++ // AirDef fork: the HD lighting's passes (shadows, G-buffer, occlusion, lighting, bloom, ++ // tone curve) while it is on. ++ if let Some(hd) = &self.hd { ++ times.extend(hd.pass_times()); ++ } ++ times ++ } ++ ++ /// AirDef fork: bind groups the HD lighting made in the last frame (0 in a steady state). ++ pub fn hd_bind_groups_last_frame(&self) -> Option { ++ self.hd.as_ref().map(|hd| hd.groups_last_frame) + } + /// The dimension the shader pack was last set for. + pub fn shader_pack_dimension(&self) -> Option { +@@ -4981,6 +4992,8 @@ impl Renderer { + if let Some(settings) = frame.hd { + hd.write(&self.queue, &hd_scene, frame.hd_lights, settings); + } ++ hd.set_timing(device, &self.queue, self.pass_timing); ++ hd.timing_begin(device, &self.queue); + // The sun's (or moon's) shadow cascades: the world's globals swapped for the light's. + if shadow_strength > 0.0 { + // The quads the shadow shader would not discard (overlays 4, 9, 10 and 40: +@@ -5090,6 +5103,9 @@ impl Renderer { + pass.set_vertex_buffer(0, self.entity_buffer.slice(..)); + pass.draw_indexed(0..entity_quads * 6, 0, 0..1); + } ++ drop(pass); ++ // Contact shadows from the G-buffer's depth and normals, for the lighting. ++ hd.ao(&mut encoder); + } else if !hd_on { + self.hd = None; + } +@@ -5831,6 +5847,9 @@ impl Renderer { + if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) { + timer.submitted(&self.queue); + } ++ if hd_on && let Some(hd) = &mut self.hd { ++ hd.timing_submitted(&self.queue); ++ } + if let Some((path, buffer)) = readback { + self.screenshot_result = Some( + self.write_screenshot(&path, &buffer) +diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs +index 2776219fc..d3944e97b 100644 +--- a/crates/horizon-client/src/gfx/hd.rs ++++ b/crates/horizon-client/src/gfx/hd.rs +@@ -5,7 +5,9 @@ + //! into a high-dynamic-range target; the mod's post pass runs on that; then + //! bright parts bloom and a tone curve brings it to the screen — vanilla's + //! range (up to about 0.8) left as it is, only what is brighter rolled off. ++use super::pack_timing::PassTiming; + use glam::Mat4; ++use std::sync::atomic::{AtomicU64, Ordering}; + + /// The G-buffer's formats. + pub const ALBEDO: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +@@ -24,6 +26,25 @@ const TILE_INDICES: usize = 1 << 19; + const EVERY_LIGHT: u32 = u32::MAX; + /// Bloom's mip levels. + const BLOOM_LEVELS: usize = 6; ++/// The ambient occlusion's targets (half resolution): occlusion and the distance it was taken at. ++pub const AO_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rg16Float; ++/// The occlusion's default strength (`Hd::ao_strength`; 0 turns it off). ++pub const AO_STRENGTH: f32 = 1.0; ++/// The timed passes of bloom, by level (each is timed on its own; the report sums them). ++const BLOOM_NAMES: [&str; 2 * BLOOM_LEVELS] = [ ++ "hd bloom#0", ++ "hd bloom#1", ++ "hd bloom#2", ++ "hd bloom#3", ++ "hd bloom#4", ++ "hd bloom#5", ++ "hd bloom#6", ++ "hd bloom#7", ++ "hd bloom#8", ++ "hd bloom#9", ++ "hd bloom#10", ++ "hd bloom#11", ++]; + + /// A light for this frame, relative to the camera. + #[repr(C)] +@@ -59,7 +80,8 @@ struct HdFrame { + fog_color: [f32; 4], + /// Environmental start, end; render-distance start, end. + fog: [f32; 4], +- /// x: sky light kept in the shade; y: blocks' light; z: the air's density (rays, halos). ++ /// x: sky light kept in the shade; y: blocks' light; z: the air's density (rays, halos); ++ /// w: the ambient occlusion's strength (0 off). + ambient: [f32; 4], + /// x: shadows on; y, z: the cascades' half sizes; w: the light tiles per row. + shadow: [f32; 4], +@@ -133,6 +155,34 @@ struct Light { + // how long they are (4 words), then the lists of light indices. A length of 0xffffffff means + // every light. + @group(0) @binding(12) var tiles: array; ++// Ambient occlusion at half resolution: x the occlusion (1 open), y the distance it was taken at. ++@group(0) @binding(13) var ao_tex: texture_2d; ++ ++// The occlusion at a full-resolution pixel `dist` away: the four nearest half-resolution texels ++// weighted by closeness and by how near their distance is, so it doesn't bleed across edges. ++fn ao_at(px: vec2, dist: f32) -> f32 { ++ let size = vec2(textureDimensions(ao_tex)); ++ let f = vec2(px) * 0.5; ++ let base = vec2(floor(f)); ++ let t = f - floor(f); ++ var sum = 0.0; ++ var total = 0.0; ++ for (var j = 0; j < 2; j++) { ++ for (var i = 0; i < 2; i++) { ++ let q = clamp(base + vec2(i, j), vec2(0), size - 1); ++ let s = textureLoad(ao_tex, q, 0).xy; ++ let bw = select(1.0 - t.x, t.x, i == 1) * select(1.0 - t.y, t.y, j == 1); ++ let dz = (s.y - dist) / (0.03 * dist + 0.1); ++ let w = select(0.0, bw / (1.0 + dz * dz * dz * dz), s.y > 0.0); ++ sum += s.x * w; ++ total += w; ++ } ++ } ++ if total < 0.0001 { ++ return textureLoad(ao_tex, clamp(px / 2, vec2(0), size - 1), 0).x; ++ } ++ return sum / total; ++} + + // Interleaved gradient noise: a different offset each pixel and frame, for the ray march. + fn ign(px: vec2) -> f32 { +@@ -214,7 +264,13 @@ fn fs_light(in: FullOut) -> @location(0) vec4 { + // Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds + // direct light by the face's angle. + let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach; +- var light = max(base * frame.ambient.x, block * frame.ambient.y) + direct; ++ // Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on ++ // the mods' lights by half, not on the sun or on emission. ++ var occlusion = 1.0; ++ if frame.ambient.w > 0.0 { occlusion = ao_at(px, length(p)); } ++ let ao_ambient = mix(1.0, occlusion, frame.ambient.w); ++ let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5); ++ var light = max(base * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct; + // The mods' lights. + for (var k = 0u; k < surface_count; k++) { + let l = lights[select(tiles[surface_start + k], k, surface_all)]; +@@ -224,7 +280,7 @@ fn fs_light(in: FullOut) -> @location(0) vec4 { + let window = pow(clamp(1.0 - pow(dist / l.pos.w, 4.0), 0.0, 1.0), 2.0); + let falloff = l.color.w / (dist * dist + 1.0) * window; + let k = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0); +- light += l.color.rgb * falloff * k; ++ light += l.color.rgb * falloff * k * ao_lights; + } + c = albedo.rgb * light + albedo.rgb * albedo.a * 1.5; + // Vanilla's fog over it again. +@@ -264,6 +320,131 @@ fn fs_light(in: FullOut) -> @location(0) vec4 { + } + "#; + ++const AO: &str = r#" ++const AO_RADIUS: f32 = 1.8; ++const AO_SLICES: i32 = 3; ++const AO_STEPS: i32 = 5; ++const PI: f32 = 3.14159265; ++@group(0) @binding(0) var frame: HdFrame; ++@group(0) @binding(1) var depth_tex: texture_depth_2d; ++@group(0) @binding(2) var normal_tex: texture_2d; ++ ++// Interleaved gradient noise, shifted per frame (params.w) and per use. ++fn noise(px: vec2, shift: vec2) -> f32 { ++ let p = px + shift + vec2(5.588238 * frame.params.w); ++ return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); ++} ++ ++// The camera-relative position of a (continuous) full-resolution pixel at depth z. ++fn position_at(px: vec2, z: f32) -> vec3 { ++ let uv = px * frame.resolution.zw; ++ let h = frame.inverse_view_proj * vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, z, 1.0); ++ return h.xyz / h.w; ++} ++ ++// Ground-truth ambient occlusion (horizon based): per slice through the view ray, the highest ++// horizon on either side, integrated against the normal's cosine lobe. Half resolution: one ++// pixel of each 2x2. ++@fragment ++fn fs_ao(in: FullOut) -> @location(0) vec2 { ++ let size = vec2(textureDimensions(depth_tex)); ++ let ic = min(vec2(in.position.xy) * 2, size - 1); ++ let z = textureLoad(depth_tex, ic, 0); ++ if z <= 0.0 { return vec2(1.0, 0.0); } ++ let nrm = textureLoad(normal_tex, ic, 0).xyz; ++ let c = vec2(ic) + 0.5; ++ let p = position_at(c, z); ++ let dist = length(p); ++ if dot(nrm, nrm) < 0.01 { return vec2(1.0, dist); } ++ let n = normalize(nrm); ++ let v = -p / dist; ++ // Blocks per pixel here (the plane of equal depth is square to the view), so how far in ++ // pixels the radius reaches. ++ let per_px = max(length(position_at(c + vec2(4.0, 0.0), z) - p) / 4.0, 0.00001); ++ let reach = min(AO_RADIUS / per_px, 56.0); ++ if reach < 2.0 { return vec2(1.0, dist); } ++ let radius = reach * per_px; ++ let rot = noise(in.position.xy, vec2(0.0)); ++ let jitter = noise(in.position.xy, vec2(37.0, 17.0)); ++ var visibility = 0.0; ++ for (var s = 0; s < AO_SLICES; s++) { ++ let phi = (f32(s) + rot) / f32(AO_SLICES) * PI; ++ let d2 = vec2(cos(phi), sin(phi)); ++ // The slice's direction in space, square to the view ray, and its plane's axis. ++ let q = position_at(c + d2 * 4.0, z) - p; ++ let dir = normalize(q - v * dot(q, v)); ++ let axis = cross(dir, v); ++ let projected = n - axis * dot(n, axis); ++ let plen = length(projected); ++ if plen < 0.0001 { visibility += 1.0; continue; } ++ let cos_n = clamp(dot(projected, v) / plen, -1.0, 1.0); ++ let nang = select(-1.0, 1.0, dot(projected, dir) >= 0.0) * acos(cos_n); ++ var c_neg = -1.0; ++ var c_pos = -1.0; ++ for (var i = 0; i < AO_STEPS; i++) { ++ let t = (f32(i) + jitter) / f32(AO_STEPS); ++ let r = 1.5 + (reach - 1.5) * t * t; ++ for (var side = 0; side < 2; side++) { ++ let sg = select(-1.0, 1.0, side == 1); ++ let ip = vec2(floor(c + d2 * (r * sg))); ++ if any(ip < vec2(0)) || any(ip >= size) { continue; } ++ let sz = textureLoad(depth_tex, ip, 0); ++ if sz <= 0.0 { continue; } ++ let to = position_at(vec2(ip) + 0.5, sz) - p; ++ let len = length(to); ++ // Points on (or under) the surface's own plane don't occlude it. ++ if dot(to, n) < 0.1 * len { continue; } ++ // Far points count less, to nothing at the radius. ++ let fall = clamp((len - radius * 0.6) / (radius * 0.4), 0.0, 1.0); ++ let ch = mix(dot(to / len, v), -1.0, fall); ++ if side == 1 { c_pos = max(c_pos, ch); } else { c_neg = max(c_neg, ch); } ++ } ++ } ++ var h_neg = -acos(clamp(c_neg, -1.0, 1.0)); ++ var h_pos = acos(clamp(c_pos, -1.0, 1.0)); ++ h_neg = nang + max(h_neg - nang, -0.5 * PI); ++ h_pos = nang + min(h_pos - nang, 0.5 * PI); ++ let sn = sin(nang); ++ let arc_neg = cos_n + 2.0 * h_neg * sn - cos(2.0 * h_neg - nang); ++ let arc_pos = cos_n + 2.0 * h_pos * sn - cos(2.0 * h_pos - nang); ++ visibility += plen * 0.25 * (arc_neg + arc_pos); ++ } ++ let ao = clamp(visibility / f32(AO_SLICES), 0.0, 1.0); ++ // Far away it is too coarse to show: fade out. ++ return vec2(mix(1.0, ao, 1.0 - smoothstep(40.0, 96.0, dist)), dist); ++} ++"#; ++ ++const AO_BLUR: &str = r#" ++@group(0) @binding(0) var frame: HdFrame; ++@group(0) @binding(1) var raw_ao: texture_2d; ++ ++// A 5x5 blur that only takes texels at about the same distance (so not across an edge). ++@fragment ++fn fs_ao_blur(in: FullOut) -> @location(0) vec2 { ++ let size = vec2(textureDimensions(raw_ao)); ++ let ip = vec2(in.position.xy); ++ let centre = textureLoad(raw_ao, ip, 0).xy; ++ if centre.y <= 0.0 { return centre; } ++ let tolerance = 0.03 * centre.y + 0.1; ++ var sum = 0.0; ++ var total = 0.0; ++ for (var y = -2; y <= 2; y++) { ++ for (var x = -2; x <= 2; x++) { ++ let q = ip + vec2(x, y); ++ if any(q < vec2(0)) || any(q >= size) { continue; } ++ let s = textureLoad(raw_ao, q, 0).xy; ++ if s.y <= 0.0 { continue; } ++ let dz = (s.y - centre.y) / tolerance; ++ let w = 1.0 / (1.0 + dz * dz * dz * dz); ++ sum += s.x * w; ++ total += w; ++ } ++ } ++ return vec2(sum / max(total, 0.0001), centre.y); ++} ++"#; ++ + const BLOOM: &str = r#" + @group(0) @binding(0) var frame: HdFrame; + @group(0) @binding(1) var source: texture_2d; +@@ -434,6 +615,23 @@ pub struct Hd { + up: wgpu::RenderPipeline, + tone_layout: wgpu::BindGroupLayout, + tone: wgpu::RenderPipeline, ++ /// The ambient occlusion: its strength in the lighting, the half-resolution targets (raw, then ++ /// blurred), their passes and bind groups (made at `resize`). ++ pub ao_strength: f32, ++ ao_raw: wgpu::TextureView, ++ ao_blurred: wgpu::TextureView, ++ ao_layout: wgpu::BindGroupLayout, ++ ao_blur_layout: wgpu::BindGroupLayout, ++ ao_pipeline: wgpu::RenderPipeline, ++ ao_blur_pipeline: wgpu::RenderPipeline, ++ ao_group: Option, ++ ao_blur_group: Option, ++ /// GPU time of the HD passes while diagnostics ask for it (`set_timing`). ++ timing: Option, ++ /// Bind groups made so far, and in the last frame (a steady state makes none). ++ groups_made: AtomicU64, ++ groups_seen: u64, ++ pub groups_last_frame: u64, + } + + /// The tiles' header (4 words each) and index lists for a screen of `size`. +@@ -567,6 +765,22 @@ fn tile_lists( + } + } + ++/// The timestamps of the pass `name` while HD passes are timed. ++fn stamp<'a>( ++ timing: &'a mut Option, ++ name: &str, ++) -> Option> { ++ timing.as_mut()?.writes(name) ++} ++ ++/// `HORIZON_HD_AO=0` turns the ambient occlusion off, another number sets its strength. ++fn ao_strength_default() -> f32 { ++ std::env::var("HORIZON_HD_AO") ++ .ok() ++ .and_then(|v| v.parse().ok()) ++ .unwrap_or(AO_STRENGTH) ++} ++ + fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule { + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(label), +@@ -685,8 +899,17 @@ impl Hd { + has_dynamic_offset: false, + min_binding_size: None, + }, ++ UNFILTERED, + ]), + }); ++ let ao_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { ++ label: Some("HD ambient occlusion"), ++ entries: &entries(&[UNIFORM, DEPTH, UNFILTERED]), ++ }); ++ let ao_blur_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { ++ label: Some("HD ambient occlusion blur"), ++ entries: &entries(&[UNIFORM, UNFILTERED]), ++ }); + let bloom_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("HD bloom"), + entries: &entries(&[UNIFORM, FLOAT, SAMPLER]), +@@ -698,6 +921,26 @@ impl Hd { + let light = module(device, "HD lighting", LIGHT); + let bloom = module(device, "HD bloom", BLOOM); + let tone = module(device, "HD tone mapping", TONEMAP); ++ let ao = module(device, "HD ambient occlusion", AO); ++ let ao_blur = module(device, "HD ambient occlusion blur", AO_BLUR); ++ let ao_pipeline = fullscreen( ++ device, ++ "HD ambient occlusion", ++ &ao_layout, ++ &ao, ++ "fs_ao", ++ AO_FORMAT, ++ None, ++ ); ++ let ao_blur_pipeline = fullscreen( ++ device, ++ "HD ambient occlusion blur", ++ &ao_blur_layout, ++ &ao_blur, ++ "fs_ao_blur", ++ AO_FORMAT, ++ None, ++ ); + let add = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, +@@ -858,6 +1101,19 @@ impl Hd { + up, + tone_layout, + tone, ++ ao_strength: ao_strength_default(), ++ ao_raw: target(device, "HD AO", [1, 1], AO_FORMAT).view, ++ ao_blurred: target(device, "HD AO blurred", [1, 1], AO_FORMAT).view, ++ ao_layout, ++ ao_blur_layout, ++ ao_pipeline, ++ ao_blur_pipeline, ++ ao_group: None, ++ ao_blur_group: None, ++ timing: None, ++ groups_made: AtomicU64::new(0), ++ groups_seen: 0, ++ groups_last_frame: 0, + }; + hd.resize(device, size); + hd +@@ -875,6 +1131,28 @@ impl Hd { + self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view; + self.lit = target(device, "HD lit", size, HDR).view; + self.fx = target(device, "HD post", size, HDR).view; ++ let half = [size[0].div_ceil(2), size[1].div_ceil(2)]; ++ self.ao_raw = target(device, "HD AO", half, AO_FORMAT).view; ++ self.ao_blurred = target(device, "HD AO blurred", half, AO_FORMAT).view; ++ self.ao_group = Some(self.make_group( ++ device, ++ "HD ambient occlusion", ++ &self.ao_layout, ++ &[ ++ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()), ++ wgpu::BindingResource::TextureView(&self.depth), ++ wgpu::BindingResource::TextureView(&self.normal), ++ ], ++ )); ++ self.ao_blur_group = Some(self.make_group( ++ device, ++ "HD ambient occlusion blur", ++ &self.ao_blur_layout, ++ &[ ++ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()), ++ wgpu::BindingResource::TextureView(&self.ao_raw), ++ ], ++ )); + self.tiles = tiles_buffer(device, size); + self.tiles_x = size[0].div_ceil(TILE).max(1); + self.bloom = (0..BLOOM_LEVELS) +@@ -917,6 +1195,15 @@ impl Hd { + ); + queue.write_buffer(&self.tiles, 0, bytemuck::cast_slice(&self.tile_words)); + self.frames = self.frames.wrapping_add(1); ++ let made = self.groups_made.load(Ordering::Relaxed); ++ self.groups_last_frame = made - self.groups_seen; ++ self.groups_seen = made; ++ if PassTiming::logging() && self.frames.is_multiple_of(240) { ++ log::info!( ++ "HD bind groups made in the last frame: {} ({made} in all)", ++ self.groups_last_frame ++ ); ++ } + // The sun by day, the moon (opposite) by night, faded out round the horizon. + let (dir, strength, color) = light_of(scene); + let cascades = cascades(scene.camera, dir); +@@ -941,7 +1228,12 @@ impl Hd { + fog_color: scene.fog_color, + fog: scene.fog, + // In the shade by day a surface keeps half its sky light; at night almost all. +- ambient: [0.5 + 0.45 * night, 1.0, 0.0018 + scene.rain * 0.006, 0.0], ++ ambient: [ ++ 0.5 + 0.45 * night, ++ 1.0, ++ 0.0018 + scene.rain * 0.006, ++ self.ao_strength.max(0.0), ++ ], + shadow: [ + if strength > 0.0 { 1.0 } else { 0.0 }, + CASCADES[0], +@@ -954,13 +1246,14 @@ impl Hd { + + /// The shadow pass of cascade `c`: its depth layer cleared, nothing else. + pub fn shadow_pass<'e>( +- &self, ++ &mut self, + encoder: &'e mut wgpu::CommandEncoder, + c: usize, + ) -> wgpu::RenderPass<'e> { + encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("HD shadow cascade"), ++ timestamp_writes: stamp(&mut self.timing, ["hd shadow 0", "hd shadow 1"][c.min(1)]), + color_attachments: &[], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.shadow_layers[c], +@@ -976,7 +1269,10 @@ impl Hd { + } + + /// The G-buffer pass's attachments: albedo and normal cleared, its own depth. +- pub fn gbuffer_pass<'e>(&self, encoder: &'e mut wgpu::CommandEncoder) -> wgpu::RenderPass<'e> { ++ pub fn gbuffer_pass<'e>( ++ &mut self, ++ encoder: &'e mut wgpu::CommandEncoder, ++ ) -> wgpu::RenderPass<'e> { + let clear = |view| { + Some(wgpu::RenderPassColorAttachment { + view, +@@ -991,6 +1287,7 @@ impl Hd { + encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("HD G-buffer"), ++ timestamp_writes: stamp(&mut self.timing, "hd g-buffer"), + color_attachments: &[ + clear(&self.albedo), + clear(&self.normal), +@@ -1016,9 +1313,11 @@ impl Hd { + load: bool, + pipeline: &wgpu::RenderPipeline, + group: &wgpu::BindGroup, ++ timestamp_writes: Option>, + ) { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(label), ++ timestamp_writes, + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: to, + resolve_target: None, +@@ -1054,6 +1353,7 @@ impl Hd { + lightmap.1.clone(), + ); + if self.light_group.as_ref().is_none_or(|(k, _)| *k != key) { ++ self.groups_made.fetch_add(1, Ordering::Relaxed); + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("HD lighting"), + layout: &self.light_layout, +@@ -1110,6 +1410,10 @@ impl Hd { + binding: 12, + resource: self.tiles.as_entire_binding(), + }, ++ wgpu::BindGroupEntry { ++ binding: 13, ++ resource: wgpu::BindingResource::TextureView(&self.ao_blurred), ++ }, + ], + }); + self.light_group = Some((key, group)); +@@ -1124,10 +1428,36 @@ impl Hd { + false, + &self.light_pipeline, + group, ++ stamp(&mut self.timing, "hd lighting"), + ); + } + ++ /// A bind group of consecutive bindings 0.., counted (`groups_last_frame`). ++ fn make_group( ++ &self, ++ device: &wgpu::Device, ++ label: &str, ++ layout: &wgpu::BindGroupLayout, ++ resources: &[wgpu::BindingResource<'_>], ++ ) -> wgpu::BindGroup { ++ self.groups_made.fetch_add(1, Ordering::Relaxed); ++ let entries: Vec<_> = resources ++ .iter() ++ .enumerate() ++ .map(|(i, r)| wgpu::BindGroupEntry { ++ binding: i as u32, ++ resource: r.clone(), ++ }) ++ .collect(); ++ device.create_bind_group(&wgpu::BindGroupDescriptor { ++ label: Some(label), ++ layout, ++ entries: &entries, ++ }) ++ } ++ + fn bloom_group(&self, device: &wgpu::Device, source: &wgpu::TextureView) -> wgpu::BindGroup { ++ self.groups_made.fetch_add(1, Ordering::Relaxed); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("HD bloom"), + layout: &self.bloom_layout, +@@ -1169,6 +1499,7 @@ impl Hd { + self.prefilter_group = Some((source.clone(), group)); + } + if self.tone_group.as_ref().is_none_or(|(v, _)| *v != source) { ++ self.groups_made.fetch_add(1, Ordering::Relaxed); + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("HD tone mapping"), + layout: &self.tone_layout, +@@ -1205,6 +1536,7 @@ impl Hd { + false, + &self.prefilter, + prefilter, ++ stamp(&mut self.timing, BLOOM_NAMES[0]), + ); + for k in 1..self.bloom.len() { + Self::draw( +@@ -1214,6 +1546,7 @@ impl Hd { + false, + &self.down, + &self.down_groups[k - 1], ++ stamp(&mut self.timing, BLOOM_NAMES[k]), + ); + } + // Up: each level added onto the bigger one. +@@ -1225,9 +1558,84 @@ impl Hd { + true, + &self.up, + &self.up_groups[k - 1], ++ stamp(&mut self.timing, BLOOM_NAMES[BLOOM_LEVELS + k - 1]), + ); + } +- Self::draw(encoder, "HD tone mapping", screen, false, &self.tone, tone); ++ Self::draw( ++ encoder, ++ "HD tone mapping", ++ screen, ++ false, ++ &self.tone, ++ tone, ++ stamp(&mut self.timing, "hd tonemap"), ++ ); ++ } ++ ++ /// The ambient occlusion from the G-buffer's depth and normals: the half-resolution pass, then ++ /// its blur. Run after the G-buffer pass; nothing when the strength is 0. ++ pub fn ao(&mut self, encoder: &mut wgpu::CommandEncoder) { ++ if self.ao_strength <= 0.0 { ++ return; ++ } ++ let (Some(raw), Some(blur)) = (&self.ao_group, &self.ao_blur_group) else { ++ return; ++ }; ++ Self::draw( ++ encoder, ++ "HD ambient occlusion", ++ &self.ao_raw, ++ false, ++ &self.ao_pipeline, ++ raw, ++ stamp(&mut self.timing, "hd ao"), ++ ); ++ Self::draw( ++ encoder, ++ "HD ambient occlusion blur", ++ &self.ao_blurred, ++ false, ++ &self.ao_blur_pipeline, ++ blur, ++ stamp(&mut self.timing, "hd ao blur"), ++ ); ++ } ++ ++ /// Times the HD passes on the GPU while `on` (and the device can), else not. ++ pub fn set_timing(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, on: bool) { ++ if on != self.timing.is_some() { ++ self.timing = on.then(|| PassTiming::new(device, queue)).flatten(); ++ } ++ } ++ ++ /// At the start of the frame's passes, before the first of them. ++ pub fn timing_begin(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) { ++ if let Some(t) = &mut self.timing { ++ t.begin(device, queue); ++ } ++ } ++ ++ /// After the frame is submitted. ++ pub fn timing_submitted(&mut self, queue: &wgpu::Queue) { ++ if let Some(t) = &mut self.timing { ++ t.submitted(queue); ++ } ++ } ++ ++ /// Each HD pass's median GPU time in milliseconds (bloom's passes summed into one). ++ pub fn pass_times(&self) -> Vec<(String, f32)> { ++ let Some(t) = &self.timing else { ++ return Vec::new(); ++ }; ++ let mut out: Vec<(String, f32)> = Vec::new(); ++ for (name, ms) in t.medians() { ++ let name = name.split('#').next().unwrap_or(&name).to_owned(); ++ match out.iter_mut().find(|o| o.0 == name) { ++ Some(o) => o.1 += ms, ++ None => out.push((name, ms)), ++ } ++ } ++ out + } + } + +@@ -1286,7 +1694,13 @@ mod tests { + + #[test] + fn lighting_shader_parses() { +- for (name, body) in [("light", LIGHT), ("bloom", BLOOM)] { ++ for (name, body) in [ ++ ("light", LIGHT), ++ ("bloom", BLOOM), ++ ("tonemap", TONEMAP), ++ ("ao", AO), ++ ("ao blur", AO_BLUR), ++ ] { + let source = format!("{COMMON}{body}"); + let module = naga::front::wgsl::parse_str(&source).unwrap_or_else(|e| { + panic!("{name}: {}", e.emit_to_string(&source)); +-- +2.54.0 (Apple Git-157) + diff --git a/tools/gen_models.py b/tools/gen_models.py index 215ffdd..12f453a 100644 --- a/tools/gen_models.py +++ b/tools/gen_models.py @@ -227,6 +227,7 @@ TEXTURES = { "lamp": lambda: gradient((255, 255, 240), (255, 230, 170)), "beam": lambda: gradient((255, 250, 225), (255, 240, 200)), "antenna": lambda: noise((70, 72, 70), 3, 65), + "nose_dark": lambda: noise((58, 60, 62), 3, 66), } @@ -341,6 +342,30 @@ def pac3(): return p +def irist_missile(): + """IRIS-T SLM: тонкая белая ракета, две пары малых канардов у носа и четыре хвостовых киля.""" + p = taper("white", -16, -10, 1, 3, 1, 3, 3) + p += [el((6.5, 6.5, -10), (9.5, 9.5, 24), "white")] + p += both(el((3, 7.6, -9), (6.5, 8.4, -5), "missile_grey")) # канарды у носа + p += [el((7.6, 3, -9), (8.4, 6.5, -5), "missile_grey"), el((7.6, 9.5, -9), (8.4, 13, -5), "missile_grey")] + p += both(el((2, 7.6, 18), (6.5, 8.4, 26), "white")) # хвостовые киля + p += [el((7.6, 2, 18), (8.4, 6.5, 26), "white"), el((7.6, 9.5, 18), (8.4, 14, 26), "white")] + p += [el((7, 7, 24), (9, 9, 28), "nozzle")] + return p + + +def amraam_missile(): + """AMRAAM: белое тело с тёмным носом, четыре крыла на середине корпуса и четыре хвостовых киля.""" + p = taper("nose_dark", -16, -9, 1, 3, 1, 3, 3) + p += [el((6.5, 6.5, -9), (9.5, 9.5, 26), "white")] + p += both(el((1, 7.6, 2), (6.5, 8.4, 9), "white")) # среднекорпусные крылья + p += [el((7.6, 1, 2), (8.4, 6.5, 9), "white"), el((7.6, 9.5, 2), (8.4, 15, 9), "white")] + p += both(el((4, 7.6, 19), (6.5, 8.4, 25), "white")) # хвостовые киля + p += [el((7.6, 4, 19), (8.4, 6.5, 25), "white"), el((7.6, 9.5, 19), (8.4, 12, 25), "white")] + p += [el((7, 7, 26), (9, 9, 28), "nozzle")] + return p + + def flame(): """Факел двигателя: светящаяся «капля», хвост — на юг (+z).""" return taper("flame", -8, 26, 5, 1, 5, 1, 8) @@ -750,6 +775,8 @@ MODELS = { "geran3": (geran3, "Герань-3"), "geran3_flame": (geran3_flame, "Факел"), "pac3": (pac3, "PAC-3 MSE"), + "irist_missile": (irist_missile, "IRIS-T SLM"), + "amraam_missile": (amraam_missile, "AMRAAM"), "flame": (flame, "Факел"), "patriot_launcher": (patriot_launcher, "ПУ M903"), "patriot_truck": (patriot_truck, "Тягач"),