Files
airdef/docs/RAID-STABILITY.md
chexit 367c7f4ac3 Bound recorded SAM poses under the WASM memory cap (#122)
Thin old SAM keyframes in place, reduce allocation capacity and adapt later sampling when a recording exceeds its fixed pose budget. Preserve recent poses and cover the full ten-minute window in native and real WASM stress tests.

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

16 KiB
Raw Permalink Blame History

Два крупных налёта: проверка стабильности

Пользовательский отчёт: AirDef часто перестаёт работать при двух больших налётах, резкого обвала FPS перед этим не замечено. RTX 3060 + Ryzen 5600X — около 50 FPS во время налёта. Пользователь уточнил: игра продолжает работать, отключается только AirDef; режим эффектов — HD. Точный build и старый лог не предоставлены. Horizon архивирует предыдущие логи в logs/YYYY-MM-DD-N.log.gz, поэтому запись может сохраниться и без ручного копирования. Задача: #122.

Воспроизведены и исправлены три конкретные причины отключения в этом классе нагрузки. Первая — тик и кэш высот (1f28f83, ниже). Вторая — событие UI и второй приказ (order_raid, см. раздел «Второй налёт из UI: событие, не тик»). Третья — исчерпание 64 MiB линейной памяти WASM полными ключами поз техники в реплеях. GPU/FPS отдельно не измерялись.

OOM при записи поз техники

Точный лог Windows: rust_oom ← RawVec<SamKey>::grow_one ← Recording::note_sams ← Sim::step во время обычного tick, с оставшимся fuel. Это не повтор прежнего OutOfFuel.

До исправления нативная регрессия с 64 непрерывно вращающимися РЛС на полной записи 12000 тиков создавала 384000 SamKey: 27 MiB только в этих ключах, без учёта ёмкости Vec, траекторий и событий. Активная запись и три сохранённых реплея могли превысить лимит 64 MiB.

Recording теперь имеет общий бюджет 32768 ключей поз. При превышении старые ключи каждой трассы прореживаются вдвое без потери первой и последней позы, освобождённая ёмкость возвращается, а интервал следующих samples удваивается. Запись не обрывается: регрессия проверяет бюджет длины и реально выделенной ёмкости, а также наличие свежей позы в конце окна. Для обычных коротких записей частота не меняется; при нехватке памяти точность старого движения снижается.

Проверки: core release 233 + 8 сценариев (8 ignored), adapter 68 + 1 boundary + 5 сценариев; настоящий WASM contract 30 шагов. Дополнительный flat-world WASM-стресс: 64 РЛС, 60 волн по 4 Shahed на 4000 блоках, 24002 тика и два полных окна записи, 185 шагов; max tick fuel 137346889/300M, command 129151/1B, failures=0. Лимит памяти 64 MiB не увеличивался. Это синтетический host без GPU и не приёмка Windows GUI.

Второй налёт из UI: событие, не тик

Точный лог Windows: Horizon: mod airdef stopped in UI event (fuel budget 1000000000, remaining Some(0)); backtrace: ew::Sim::known_units ← raid::Sim::order_raid ← airdef::AirDef::launch_wave ← ui_select. Прежний fix 1f28f83 — другой класс: тогда мод останавливался в тике на 300M.

Причина. order_raid для каждого дрона заново вычислял снимок известных комплексов known_units() (choose_target), а сам known_units() на каждую запись журнала разведки пересканировал весь список комплексов и список уже принятых. Журнал держит каждый пуск, каждое раскрытие РЭБ и каждую emitting-РЛС за последние 6000 тиков. Стоимость второго большого приказа росла как дроны × журнал × размер группировки внутри одного UI-события.

Воспроизведение (настоящий WASM, измеренный fuel по callback, диагностический гость — trigger.patch гоняет тот же обработчик ui_select(BTN_LAUNCH) → launch_wave → order_raid с максимальным составом меню; метка callback — command, бюджет тот же 1e9): разнесённая группировка 172 комплекса (40 кластеров × 4 тяжёлых + 12 РЭБ, игрок двигается шагами сценария — стек в одной точке умирает от одной боеголовки и не воспроизводит), первый налёт 111 дронов отбит, второй приказ на тике ~2600 при revealed=13172, known=144 живых. До fix: второй приказ исчерпывает ровно 1e9 и останавливает мод (before-trap.txt); тот же приказ из 11 дронов в том же состоянии — 247805865 (before-scaling.txt). После fix тот же сценарий проходит целиком: max command fuel 32946177/1e9, max tick 236581288/300M, ошибок 0; /stats на тике 2602 совпал побайтово до/после, включая счётчики мира.

Фикс. Один снимок known_units на весь приказ, передаётся в radar_strikes и choose_target; сам known_units — один проход O(журнал + группировка) через множества; price_grid_on переиспользует свой снимок. Семантика планирования не изменилась: нативный тест сверяет новый снимок с прежним предикатом (дубликаты, погибшие, устаревшие, чужие id, порядок «новые первыми»). Лимиты не менялись, HD/запись не отключались, размер волны не ограничивался.

Регрессии. Нативные: эквивалентность снимка и «второй умный приказ с толстым журналом принят и запущен». Закреплённые WASM: тяжёлый fixture расширен — два умных приказа через функцию кнопки ПУСК (airdef preset 1 + airdef raid — тот же launch_wave, что и у ui_select) с РЭБ и боевым журналом, оба приняты (expect.chat = []), 4 принятых приказа в отчёте; лёгкий контракт проверяет preset и отказ пустой волны. Данные и точный сценарий: summary.json, trigger.patch, до, после. Patch — только для диагностического гостя, в мод не входит.

Приёмка конкретного Windows-сеанса остаётся внешней: нужны build identity и строки latest.log вокруг остановки. Измерение fuel — не прирост FPS.

Первая причина и фикс (тик, 1f28f83)

Два максимальных состава меню (11 видов × 40, два принятых приказа), 64 ПВО, расстояние 300, HD и grid, обслуживаемый плоский мир: tick 1835 исчерпал ровно 300M fuel и мод остановился. Grid, запись и обновление дыма не были необходимы для этого spike: исследовательские абляции не убирали остановку. В производственном исправлении эти функции не отключаются.

Ground::forget при каждом взрыве делал retain по всему кэшу известных высот. Тот же полный обход выполнялся при обработке просроченных blast replies. Когда приходило много взрывов, затраты росли как произведение количества взрывов и размера/ёмкости таблицы высот.

1f28f83 удаляет непосредственно координаты прежнего квадрата: при радиусе 8 это 17×17 ключей, а не просмотр всей таблицы. Вызов world.touched сохранён. Тест сравнивает весь результат со старым predicate, включая отрицательные координаты и границы; правила урона и лимиты не менялись.

Контрольный диагностический гость с полными записью, дымом, HD и grid, без phase markers: 421 шаг, 3602 тика — PASS; max tick fuel 135273690/300M, command 92658271/1B, failures=0. Финальные показатели: 0 в воздухе, 209 сбито, 3435 поражений, 15 столкновений. На тике 1834 до прежней остановки вывод /stats до/после совпал побайтово, включая счётчики мира. Это не утверждение о равенстве каждого скрытого поля симуляции.

Для воспроизведения огромного меню-состава использован временный hook команды mix, вызывающий тот же WaveConfig/order_raid; сам hook не входит в мод. Исходник — 10f24b5, после фикса добавлен только новый forget. Данные, hook и точный сценарий: summary.json, trigger.patch, scenario.toml, до, после. Patch предназначен для отдельного диагностического worktree, а не для обычной игры. Два checkpoint-лога намеренно заканчиваются несовпадением с probe, чтобы получить /stats; это не реальные traps. Успешный полный прогон отдельно в after.txt.

Публичный native-тест two_maximum_menu_raids_with_answered_flat_terrain использует настоящий максимальный RaidOrder без hook, terrain replies и запись. Он также прошёл.

Найденные пробелы старых тестов

В первом wasm_budget.toml два приказа отправлялись без тика между ними. order_raid отклоняет второй во время подготовки первого; ответ не проверялся. Кроме того, TestGame не отвечал на render ground/sections/rays: пуски могли ждать неизвестную землю вместо полноценного полёта. 69 успешных шагов не подтверждали 160 одновременно летящих целей. Старый отчёт сохранён с поправкой.

Что проверено теперь

  • Native: точный состав двух меню-пресетов «Массированный удар», 61 комплекс, плоский мир с ответами геометрии и встроенной записью. Оба приказа приняты, все 72 цели запущены, перекрытие проверено по числу одновременно живых целей.
  • Native: два приказа 40+40 «Орешников» с разделением, многими суббоеприпасами и окончанием записи.
  • Actual WASM: 64 комплекса, два принятых приказа 40+40 «Орешников», плоскость y=64 с ответами ground/sections/rays. В середине включаются shader hd и grid. 271 шаг прошёл; на тике 2102 проверено 184 цели в воздухе. Пик за весь прогон не измеряется.
  • Кадры чередуются с тиками. 2052 вызова frame стенда не означают GPU-рендер: у AirDef нет собственной реализации frame hook, основная работа идёт в tick и событиях.

Отчёт clean 2932364, engine ed7615d36: flat-wasm-raid-budget.json. Memory 64 MiB и fuel init/tick/frame/event 2B/300M/10M/1B не увеличивались. Отчёт фиксирует исходные лимиты, не измеренный расход fuel/peak memory.

python3 tools/airdef.py test-budgets
cargo test --release --manifest-path airdef-core/Cargo.toml --test scenarios overlapping_

Это проверка гостевого кода и контрактов с синтетическим host-миром. Она не воспроизводит RTX-драйвер, реальный рельеф/чанки, звук и стоимость HDR, shadow/light/smoke на GPU пользователя. Включение HD в fixture проверяет путь подготовки модовых данных, а не графическую производительность.

Диагностика следующего воспроизведения

В latest.log после остановки теперь записываются mod ID, callback, заданный и оставшийся fuel, полная цепочка ошибки. Сообщение OutOfFuel больше не объявляет тяжёлую работу бесконечным циклом. Проверка настоящим WASM /spin подтверждает, что при fuel trap останавливается только нарушивший мод. Это не доказывает, что пользовательский случай имеет ту же причину.

Нужны build identity и строки Windows latest.log вокруг остановки, состав двух налётов, HD/обычный профиль, открытые grid/monitor/replay и ответ: закрылась ли игра целиком. До этих данных не подменять диагноз увеличением fuel, отключением записи или обрезанием целей. Измерение FPS вести отдельно от поиска trap: стабильный FPS не исключает лимит WASM, а прошедший WASM-тест не исключает ошибку GPU или native host.