# Закреплённый движок AirDef Движок: https://gitea.maoniao.team/chexit/horizon-airdef, ветка `airdef`. AirDef хранит точный SHA как gitlink `horizon/`; новый push движка не меняет старые сборки мода. История upstream и всех 44 прежних патчей сохранена в форке. Старые patch-файлы и HORIZON_COMMIT доступны в истории AirDef и больше не являются вторым источником движка. Baseline описан в AIRDEF-FORK.md. ## Windows: обновление и запуск ```powershell git fetch origin git switch kimi-work git pull --ff-only python tools/airdef.py all python tools/airdef.py play ``` Первое клонирование: `git clone --recurse-submodules https://gitea.maoniao.team/chexit/airdef.git`. Обычный clone тоже поддерживается: `python tools/airdef.py fork` или команда submodule выше. `all` инициализирует зависимость сам, включая переход со старой папки `horizon/`. Для чистого checkout можно отдельно выполнить `git submodule update --init --recursive`. Нужны Git, Python и Rust согласно INSTALL.md. Сборка клиента и мод устанавливаются в обычное место; ресурсы Minecraft готовятся отдельно по INSTALL.md. При первом переходе со старого отдельного clone `horizon/` скрипт сохраняет его Git-историю и ветки, переводит в сабмодуль и получает закреплённый SHA. Если там есть изменения при другом SHA, обновление останавливается с объяснением: сначала закоммитьте/сохраните свои изменения. Скрипт не выполняет reset --hard или clean. Чистый checkout может остаться detached HEAD — это нормально для зависимости. ## Изменение движка 1. В `horizon/` создать рабочую ветку от закреплённого SHA. Проверить изменение и push в репозиторий движка. 2. В AirDef выполнить `git add horizon`, проверить мод/игру, коммитить обновление gitlink отдельно. 3. Push AirDef выполняется после движка. Не коммитить Git URL с токенами. 4. Для возврата выбрать прежний commit AirDef и выполнить `git submodule update --init --recursive`. Упаковщик собирается repo-local в `.horizon-cache/packer//`, глобальный `cargo-horizon-mod` не используется. Изменения tracked-исходников движка входят в identity. Рядом с airdef.hmod записывается build-identity.json: SHA AirDef, pin/identity движка, SHA256 WIT и артефакта. API-диагностика остаётся в `python tools/airdef.py check`. Нативные тесты не заменяют отдельную игровую проверку графики, звука и экспорта. ## Проверка настоящего WASM `python tools/airdef.py test-wasm` собирает `.hmod` и запускает `tests/*.toml` в настоящем runtime закреплённого движка. Используется временное storage; игровые миры не нужны. Сценарий проверяет загрузку/регистрацию команды, два комплекса, налёт, tick/frame, выход из мира и перезагрузку мода. Действуют лимиты runtime: 64 MiB linear memory, 300 млн fuel на tick, 10 млн на frame, 1 млрд на event, 2 млрд на init. Это проверка ABI и fallback при отсутствии render replies; она не измеряет GPU, не подтверждает акустику/MP4 и не заменяет максимальный боевой полигон. В CI проверка assets входит в лёгкий build. Wasmtime tier вынесен в ручной workflow `wasm-contract`, чтобы не компилировать тяжёлый runtime на каждом push слабого общего runner. ## Готовность объёмного дыма (API 26) `render.smoke-status` разделяет доступность API, включённый режим, принятую отправку этого мода и готовность smoke/fog пути последнего подготовленного основного кадра. Возвращаются поколение мира и причина (`awaiting-frame`, `disabled`, `path-unavailable`, `ready`). AirDef убирает обычный дым только когда выполнены все четыре условия; после смены мира старые сегменты и часы очищаются. Это подтверждение выбранного пути рендера, а не завершения GPU или видимости каждого сегмента. Сам `smoke-status` не подтверждает акустику и capture; их отдельные статусы добавлены в API 27 ниже. ## Акустика и владение захватом (API 27) Текущий pin: `278f356aa604fa21c2d15d0081823bbe0f891df2` (API 30; поверх API29 — нормали моделей и очистка неудачного захвата). Акустические `render.acoustics-status` и `render.capture-owner-status` возвращают поколение, `available`, `enabled`, `request-accepted`, `prepared` и причину. В игре всё это показывает `/airdef capabilities`. Отсутствие запроса, выключение, ожидание backend и ошибка различаются. Для акустики `prepared` означает выполненный шаг обработки с геометрией при доступном live/offline микшере. Шаг может поставить worker job или принять результаты; это **не** подтверждение результата для каждого источника, завершения worker, слышимого звука или точного отражения. В режиме no-audio без offline-микшера готовность не наступает. AirDef подтверждает текущее глобальное предпочтение, не меняя его, и сохраняет собственные дальние варианты звука до подтверждённой подготовки. Для capture запрос принадлежит конкретному запуску мода и поколению мира. `prepared` соответствует его стадиям Recording/Encoding/Done; состояние файла и ошибка по-прежнему читаются из `capture-status`. AirDef не принимает чужие Recording/Done за свой экспорт. Ожидание первоначальной подготовки ограничено 600 тиками. Остановка проверяет владельца; смена мира отменяет модовый захват и сбрасывает готовность. Уже переданный writer файл на Encoding может всё же завершиться: это запрос отмены, не гарантия удаления файла. Проверено на реальном экспорте 4 ноября (pin `57f8b2853`; очистка ошибки в `73b9d3070`): - Смена мира доходит до движка только через снимок мода (`set_snapshot`). На экране заголовка тики не вызываются, поэтому мод без хука `frame` не сообщает о закрытии мира, и захват продолжает писать заголовок. AirDef теперь просит `frame` и завершает показ по `game.world-id = none`. - При ошибке энкодера временные файлы этого захвата (`<имя>.video.mp4`, `<имя>.audio.raw`, `<имя>.ffmpeg.log`) удаляются, а последние строки лога переносятся в текст ошибки: ссылки на уже удалённый лог больше нет. Кодировщик всегда пишет `<имя>.video.mp4`, а `<имя>.mp4` появляется только после успешного mux или переименования, поэтому неудачный, отменённый или беззвучный захват не перезаписывает и не удаляет готовый фильм прежнего экспорта с тем же именем. Исправлено в `73b9d307048fc44d7f21fa3c829fc5a855d144a6` (`game/film.rs`), проверено реальной ошибкой ffmpeg и контрольным MP4. ## Отдельный тяжёлый WASM tier ```sh python3 tools/airdef.py test-budgets python3 tools/test_budget_report.py python3 tools/test_airdef_build.py ``` Сценарий `airdef-mod/tests/budgets/wasm_flat_budget.toml` запускается в изолированном временном `tests/`, поскольку CLI читает только непосредственные `tests/*.toml`. Обычный `test-wasm` остаётся лёгким. Ручной workflow `.gitea/workflows/wasm-budget.yml` не добавляет нагрузку на каждый push. Исходный профиль запрашивал 64 комплекса и две волны по 80 целей на 4000 блоках, 602 вызова tick и 300 frame, 69 шагов. Позднее обнаружено: второй заказ во время подготовки первого отклонялся, а fixture не проверял ответ. Поэтому тот прогон **не доказывает перекрытие 160 целей**; сохранённый JSON помечен `coverage_correction`. Новый сценарий проверяет принятие обоих заказов и отличает requested counts от неизвестного пика в воздухе. Это не полный лимит расстановки (2000) и не нагрузочный тест GPU, mixer voices или всех физических world replies: sandbox не выполняет настоящий рендер/звук. Текущий default заменён на flat render-world: ground/sections/rays отвечают для плоскости y=64, GPU не имитируется. Проверяются два принятых налёта по 40 «Орешников» и 64 ПВО; в середине включаются HD и grid. На тике 2102 assertion подтверждает **184 цели в воздухе**, а не пик за всё время. Прогон `2932364` прошёл: 271 шаг, 2102 tick и 2052 вызова frame стенда. У AirDef нет собственного frame hook; эти вызовы не являются нагрузкой на GPU или измерением 10M frame fuel мода. Точный отчёт: [flat-world budget](measurements/flat-wasm-raid-budget.json). С 4 ноября (#122) fixture расширен хвостом умных приказов через UI-путь: 4 РЭБ, `airdef preset 1` (та же `apply()`, что у кнопки готового состава) и два `airdef raid` — это та же функция `launch_wave`, что у кнопки ПУСК (`ui_select`), с боевым журналом разведки и EW; оба приказа приняты (`expect.chat = []`), итого 4 принятых приказа, финальный `/stats` закреплён строкой. Прогон исправления #122: 283 шага, 3302 tick; max fuel tick 182413686/300M, command 6939810/1e9, ошибок 0. Отчёт: [flat-world budget](measurements/flat-wasm-raid-budget.json). Fixture копируется из тех же байтов, чей SHA записан до сборки. Проверенные 2 принятия и точка 184/2102 заносятся только после success и совпадения `BUDGET_ASSERTED_SHA256` с точной версией файла. После изменения fixture нужно повторно проверить её и обновить SHA/метаданные; простой список запрошенных целей не заменяет assertions. Пик остаётся null, если он не измерен. Отчёт `.horizon-cache/wasm-budget-report.json` содержит SHA/dirty/diff AirDef, pin/identity движка, API/WIT/артефакт, проверку build identity, сценарий, Wasmtime и исходные memory/fuel limits. `AIRDEF_BUDGET_REPORT` меняет путь отчёта. Stale identity запрещает запуск, ошибки сборки/тестов записываются как failed и дают ненулевой exit code. Лимиты берутся из исходников: memory 64 MiB; fuel init/tick/frame/event — 2B/300M/10M/1B. Начиная с продолжения 4 ноября `test-budgets` включает `--fuel-profile` и сохраняет `measured_fuel.callbacks`: число вызовов, максимум guest fuel, бюджет и ошибки отдельно по callback. Отсутствие запрошенных счётчиков делает проверку неуспешной, даже если сценарий завершился. Это не время host/GPU и не peak memory; невызывавшиеся callbacks, включая init до включения профиля, не получают выдуманного нуля. Первый прогон: tick 2102 вызова, max 106132646/300000000; command 69 вызовов, max 1013370/1000000000; ошибок нет. Прогон исправления #122 (283 шага, включая два умных UI-приказа): tick 3302 вызова, max 182413686/300M; command 79 вызовов, max 6939810/1e9; ошибок нет. Исторический отчёт без измерения fuel: [API27 budget report с поправкой покрытия](measurements/api27-wasm-budget.json). Актуальный чистый API29-прогон с измерением: [271 шаг, fuel и build identity](measurements/api29-wasm-budget.json). Актуальный прогон с умными UI-приказами #122 и build identity: [flat-world budget](measurements/flat-wasm-raid-budget.json). Проверены реальный WASM guest с переходами состояний/владельца/мира, сборка клиента и два коротких offscreen-профиля Metal. При `HORIZON_ACOUSTICS=1` + `HORIZON_NO_AUDIO=1` в игре получено enabled/request=true, prepared=false, awaiting-backend; HD smoke переходит в ready после подготовки основного кадра. Это не замер производительности и не прослушивание. API28/29 добавляют отдельные `point-lights-status` и `sound-sources-status`: свет подтверждается после cache/main-view HD обработки, звук — после source-manager apply с live/offline output. Заявки привязаны к запуску мода и поколению мира; звук также к source epoch. Clear/reload/выход снимают подтверждение. Это не GPU fence, загрузка сэмпла, наличие голоса или слышимость. Камеры и их targets изолированы по `(run_serial, local_id)` вместо глобального ID. API30 добавляет `camera-status` для камер `set-views` вызывающего запуска: доступность API, включение пути картинок в последнем отмеченном кадре, принятие заявки поколения и поштучная готовность каждой камеры. `prepared` означает, что рендерер закодировал кадр камеры в её texture target, либо кадр мира отрендерен с неё как main-view **этого** запуска: если одинаковый local ID выбрали два мода, экран использует выбор первого, и milestone получает только он; чужая картинка с тем же ID чужую камеру не готовит. `enabled` — последний отмеченный кадр был кадром мира (не заголовок/загрузка). Это не регистрация target и не GPU fence: видимый пиксель, представление кадра и обход ротации per-frame бюджета не гарантируются. Пустая `set-views` снимает заявку и готовность; смена мира очищает заявку, выбор main-view и готовность (камеры другого мира не рисуются), перезагрузка мода начинает заново, а повторная отправка и камера, отданная в main-view, ждут нового кадра (у такой камеры нет своего picture target). Проверено двумя настоящими Wasmtime-гостями с одинаковым local ID, пустой заявкой, сменой мира и reload. `/airdef capabilities` показывает строку камер. ### GPU-приёмка картинки камер (Metal/offscreen) Итоговый engine `278f356aa` с AirDef API30 проверен в запущенной игре, а не только в песочнице: macOS, Apple M5 (Metal, IntegratedGpu), `--offscreen`, `HORIZON_NO_AUDIO=1`, чистый `HORIZON_HOME=/tmp/airdef-camera-accept` и копии тестовых миров из scratch-папки прежних прогонов (`AirDefenseLab`, `Polygon`), ресурсы лаунчера v0.2.91. `.hmod` — из этого дерева (`python3 tools/airdef.py mod`, API30), не из основного дерева. ``` tools/airdef.py install # HORIZON_HOME= horizon-ctl --socket $H/ctl.sock launch --game horizon/target/fast/horizon-client \ --world AirDefenseLab --creative --offscreen --wait --resources <26.3> # мод-команды клиентские: ctl command/chat уходят в консоль сервера, поэтому — через поле чата horizon-ctl key t tap; horizon-ctl type "/airdef capabilities"; horizon-ctl key enter tap horizon-ctl screenshot ``` Наблюдения (строки `/airdef capabilities`, `поколение` = generation): | Когда | Камеры | | --- | --- | | камер нет | `API=true, включено=true, запрос=false, подготовлено=false, поколение=1, not-requested, main=нет, []` | | `/airdef deploy s400` + `/airdef monitor` | `... запрос=true, подготовлено=true, поколение=1, ready, main=нет, [32:ready]` | | `/airdef monitor` (пустая заявка) | `... запрос=false, ... not-requested, main=нет, []` | | `/airdef replay film best` | `... main=1996488705, [1996488705:ready]`, затем `main=1996488707` на следующем плане | | другой мир (AirDefenseLab → Polygon) | `поколение=2, запрос=false, ... not-requested, main=нет, []` | | там же deploy+monitor | `поколение=2, запрос=true, подготовлено=true, ready, [32:ready]` | Снимки подтверждают, что `prepared` — нарисованная картинка, а не регистрация (число различных цветов, выборка каждого 6-го пикселя): при взгляде в небо (кадр однородный) включение монитора добавляло панель и picture-область — 477→4867 цветов на кадре; в том же мире A/B «монитор выключен/включён» в области панели: 199→2080 цветов, доля тёмной панели 1.2%→48.7%, а в области картинки 110→1771 цветов с доминирующим небом (рисуется сцена с камеры С-400, а не плоская заглушка «НЕТ СВЯЗИ»). В film-режиме экран показывал карточку «Готовлю кадры…» (фильм готовит планы), при этом main-view milestone был `ready` — это API-отметка и отрисовка кадров рендерером, не проверка пикселей фильма. `title → тот же мир` не меняет поколение: заявка и milestone сохраняются (смена поколения — это смена мира), а `enabled` сбрасывается кадром заголовка (note с `world=false`), поэтому `ready` требует нового кадра мира после возврата; он и наступает (наблюдалось). 8 из 8 запросов при работающем мониторе дали `ready`. `set_views`/`set_main_view` вызываются модом каждый tick, то есть каждый tick начинает ожидание нового кадра: выборка между tick и note может честно показать `awaiting-frame` по порядку вызовов — это не flicker, а порядок tick → render → note. Остаток этой приёмки: визуальная оценка человеком, Windows/другие GPU, два запуска с одинаковым local ID на GPU (покрыто Wasmtime-гостями), reload мода на GPU (покрыт host-тестом), fence/представление кадров не проверялись. Остаток #102: полные query/voice/peak budgets, GPU/готовый MP4 и платформенная приёмка. Эти результаты текущими тестами не объявляются выполненными. ## Измеренный fuel и массовые взрывы В runtime добавлен выключенный по умолчанию профиль вызовов. У тестового CLI флаг `test --fuel-profile` выводит mod/callback, число вызовов, максимальный израсходованный guest fuel, бюджет и число ошибок; результат печатается и при неуспехе сценария. Init до включения профиля и предыдущие поколения после reload в эти значения не входят. Это гостевые инструкции, не время CPU, не heap peak и не работа GPU/host. Обычный `test-budgets` без этого флага по-прежнему не выдаёт свои исходные caps за измеренный расход. С профилем воспроизведён остановившийся tick на двух максимальных составах меню и HD: полный обход height-cache при каждом взрыве доводил его до 300M. `1f28f83` заменяет обход удалением только затронутого квадрата. В контрольном диагностическом WASM-прогоне после исправления 3602 тика прошли, max tick fuel = 135273690/300M, failures=0. Лимиты не менялись. Диагностический гость содержит только временный способ запуска точного меню-состава; это не исходный пользовательский Windows-сеанс. [Данные и воспроизведение](RAID-STABILITY.md). С профилем воспроизведён и второй класс — остановка в **событии UI** (не тике) на втором крупном приказе: переснимок `known_units` на каждом дроне приказа давал `дроны × журнал × группировка` за одно событие, 1e9 исчерпаны ровно (max command fuel 1000000000/1e9, failures=1; состояние: 13172 записей журнала, 144 известных живых комплекса). Исправление (#122, одним снимком на приказ и O(журнал+группировка) за проход) доводит тот же сценарий до max command fuel 32946177/1e9, failures=0, `/stats` на тике 2602 совпал побайтово. Это гостевые инструкции, не прирост FPS; приёмка Windows-сеанса внешняя. [Данные и воспроизведение](RAID-STABILITY.md).