Files
airdef/docs/ENGINE.md
T
chexit 3db1b73d64 Reuse oversized renderer staging buffers (#130)
Advance the engine to 94b572cd2, backporting upstream f7b77fbc8 with its GPU growth/readback regression. Keep API30 unchanged.

Co-Authored-By: Kimi Code <noreply@moonshot.ai>
2026-11-14 17:27:17 +03:00

257 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Закреплённый движок 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/<engine-identity>/`, глобальный
`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: `94b572cd24e2a7e0960ab6f801ddbe223eef17a7` (API 30; поверх API29 — нормали моделей,
очистка неудачного захвата и power-of-two oversized staging allocations из #130). Акустические
`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=<scratch>
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 <png>
```
Наблюдения (строки `/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).