diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 76520b5..dc6585e 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -1839,7 +1839,7 @@ impl Mod for AirDef { me.apply_far(); // Subcommands, typed arguments and the saved scenarios' names for the chat box (#98). commands::register(&scenario::saved_names()); - Ok((me, Registration::new().command("airdef").on_tick())) + Ok((me, Registration::new().command("airdef").on_tick().on_frame())) } fn tick(&mut self, tick: u64) { @@ -2280,6 +2280,17 @@ impl Mod for AirDef { self.menu = None; } + /// A frame runs outside a world too (the title screen), where no tick does: a film or an + /// export still active there is ended, so its capture cannot go on recording the title. + fn frame(&mut self, _seconds: f32) { + if self.presentation.is_live() { + return; + } + if let Some(cleanup) = self.presentation.world_left(world_identity().as_deref()) { + cleanup.apply(self); + } + } + fn command(&mut self, _name: &str, args: &str) -> Result, String> { let w: Vec<&str> = args.split_whitespace().collect(); let mut out = Out::default(); diff --git a/airdef-mod/src/presentation.rs b/airdef-mod/src/presentation.rs index 251a205..386b79a 100644 --- a/airdef-mod/src/presentation.rs +++ b/airdef-mod/src/presentation.rs @@ -286,6 +286,16 @@ impl Session { Some(Cleanup { actions }) } + /// The world is gone (the title screen): the game stops ticking there, so an active export + /// would otherwise go on recording the title with its playback clock frozen. Nothing to do + /// in Live, and a world still open is not a leave. + pub fn world_left(&mut self, world: Option<&str>) -> Option { + if self.is_live() || world.is_some() { + return None; + } + self.end(ExitCause::WorldChange) + } + /// A world change invalidates live work too. Active playback uses the normal cleanup path, /// which advances generation exactly once; Live advances generation without host cleanup. pub fn world_changed(&mut self) -> Option { @@ -466,6 +476,30 @@ mod tests { assert_ne!(session.generation(), first); } + #[test] + fn a_gone_world_ends_an_active_export_once() { + let mut session = Session::<(), ()>::default(); + assert!(session.world_left(None).is_none(), "Live has nothing to clean up"); + session.replay(()); + assert!(session.world_left(Some("world:w")).is_none(), "the world is still open"); + let State::Replay(playback) = session.take() else { + panic!("replay state lost") + }; + session.export(playback, ()); + let cleanup = session.world_left(None).expect("the title screen ends the export"); + assert!(cleanup.actions.contains(&CleanupAction::CancelCapture)); + + let mut host = HostFixture::default(); + host.apply(&cleanup); + let count = host.actions.len(); + assert!(session.world_left(None).is_none(), "a second frame sees Live"); + assert_eq!(host.actions.len(), count, "cleanup is not repeated"); + assert_eq!( + host.actions.iter().filter(|a| **a == CleanupAction::CancelCapture).count(), + 1 + ); + } + #[test] fn end_and_restart_emit_cleanup_once_for_stop_unload_failure_and_success() { for cause in [ diff --git a/docs/CONTINUATION-2026-11-04.md b/docs/CONTINUATION-2026-11-04.md index eb8f450..d7b284d 100644 --- a/docs/CONTINUATION-2026-11-04.md +++ b/docs/CONTINUATION-2026-11-04.md @@ -60,3 +60,13 @@ query/voice/peak-memory бюджеты. Старый допуск broadband RT60 Обновление Windows прежнее: `git switch kimi-work`, `git pull --ff-only`, `python tools/airdef.py all`, `python tools/airdef.py play`. + +## Обновление #89 (4 ноября, после этого прохода) + +Живой короткий MP4 получен и проверен на pin `57f8b2853` (API 29) на scratch `HORIZON_HOME`/мире: +h264 + AAC, отчёт мода совпадает с `ffprobe nb_frames`, отмена/ошибка SDK/ошибка энкодера/повторный +запуск проходят. Найден и исправлен дефект: выход в заголовок во время экспорта оставлял захват +записывать заголовок (запрос `sec=10` дал 3898 кадров); теперь показ завершается по кадровому хуку. +Подробности и ограничения (очистка временных файлов движком при ошибке энкодера) — +[HD-RENDERER](HD-RENDERER.md) (раздел про кинорежиссёра) и [ENGINE](ENGINE.md) (раздел про +захват). 10-минутный экспорт и Windows/Linux по-прежнему не проверены. diff --git a/docs/ENGINE.md b/docs/ENGINE.md index 1570b01..bfe8db4 100644 --- a/docs/ENGINE.md +++ b/docs/ENGINE.md @@ -81,6 +81,15 @@ AirDef не принимает чужие Recording/Done за свой эксп сбрасывает готовность. Уже переданный writer файл на Encoding может всё же завершиться: это запрос отмены, не гарантия удаления файла. +Проверено на реальном экспорте 4 ноября (pin `57f8b2853`): + +- Смена мира доходит до движка только через снимок мода (`set_snapshot`). На экране заголовка тики + не вызываются, поэтому мод без хука `frame` не сообщает о закрытии мира, и захват продолжает + писать заголовок. AirDef теперь просит `frame` и завершает показ по `game.world-id = none`. +- При ошибке энкодера писатель удаляет только отменённые файлы: `<имя>.audio.raw` и + `<имя>.ffmpeg.log` остаются. Успешная и отменённая записи временных файлов не оставляют. + Это ограничение движка, а не мода; исправление — в `game/film.rs`. + ## Отдельный тяжёлый WASM tier ```sh diff --git a/docs/HD-RENDERER.md b/docs/HD-RENDERER.md index 42deb96..d8faed0 100644 --- a/docs/HD-RENDERER.md +++ b/docs/HD-RENDERER.md @@ -368,6 +368,32 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home - Мод-HUD (`set-hud`) теперь рисуется и при `set-main-view` (титры, леттербокс фильма); ванильный HUD по-прежнему скрыт. - Режиссура: `airdef-mod/src/film.rs` (план по записи, варианты ракурсов, проверка рельефа по высотам `Ground::land_state`, ускорение/замедление времени). +### Проверенный жизненный цикл MP4 (4 ноября, pin `57f8b2853`, API 29) + +Реальный короткий экспорт на scratch `HORIZON_HOME`/мире (не на сохранениях игрока), Apple M5, Metal, +ffmpeg 9.0.1. `/airdef replay export` с записанного налёта (4 цели, 4 перехвата), 640×360: + +- готовый файл: h264 + AAC 48 кГц stereo, длительность видео = длительность звука; звук не пустой + (mean −29.4 dB, max −18.0 dB); 2 с при 30 fps — 62 кадра, `sec=1` — 32, `noaudio` — 33 кадра без + звуковой дорожки, `sec=0.01` — 3 кадра; 1920×1080 `sec=1` — 32 кадра. Число кадров в отчёте мода + всегда совпадает с `ffprobe nb_frames`: очередь не теряет кадры на остановке. +- 2–3 кадра сверх `sec` — это кадры, уже находившиеся в полёте на момент остановки (движок их + дописывает); это не потеря хвоста. Отчёт и файл сходятся. +- отмена во время записи (`/airdef replay stop`): сообщение «Экспорт отменён», файла и процессов + ffmpeg не остаётся; сразу после отмены новый экспорт проходит. +- отказ SDK (`size=10000x10000`): сообщение об ошибке, захват не остаётся, следующий экспорт проходит. +- ошибка энкодера (подставной `HORIZON_FFMPEG`, отвечающий на `-encoders`, но падающий при записи): + «Экспорт не удался: … Broken pipe», файла и процесса нет; новый экспорт после неё проходит. + Движок при этом оставляет `<имя>.audio.raw` и `<имя>.ffmpeg.log` — очистка временных файлов при + ошибке писателя находится в `horizon/crates/horizon-client/src/game/film.rs` (вне этого репозитория). +- **выход в заголовок во время экспорта** (до исправления): игра перестаёт тикать на экране + заголовка, но продолжает кадры, поэтому захват писал заголовок; запрос `sec=10` дал 3898 кадров + (129.9 с), временные `video.mp4`/`audio.raw` и ffmpeg оставались, а после возврата в тот же мир + экспорт продолжался. Мод получает кадры и вне мира (`Mod::frame`), поэтому `Session::world_left` + завершает показ с `ExitCause::WorldChange` и одним `CancelCapture`: после исправления при выходе + в заголовок ffmpeg и временные файлы исчезают, повторный вход и следующий экспорт проходят. + Пропуск мира виден по `game.world-id = none`; тик мода вне мира не вызывается. + ## Цветные тени, рябь, кино-пост: что знать преемнику - **Цветные тени.** Ближний каскад (c = 0) рисуется `fs_shadow_near` (листва `MAT_LEAVES` отброшена), потом проход `hd shadow colour` (`Hd::shadow_tint_pass`, цель `Shadows::tint`, очистка в белое) рисует листву (`shadow_tint_leaves`, твёрдые квады, `SHADOW_SOLID`) и полупрозрачные квады секций (`shadow_tint_glass`, по отсортированным индексам; вода не рисуется) тем же `fs_shadow_tint`: rgb умножаются (blend `Dst*src`), альфа = `(расстояние от света − 320)/160` с blend Min — ближайший такой квад. Приёмник в `shadow_tint` (`light.wgsl`, привязка 22) окрашивается, если квад ближе к свету, чем он; 8 точек Vogel радиусом 0.4 блока размывают листву в «пятна». Дальний каскад по-прежнему рисует листву полностью (на границе 36 блоков шов: ближе листва светлее). Цвет не доходит до лучей тумана, воды и дальнего каскада. Цена: `hd shadow colour` 0.25–0.35 мс (Mac, малая сцена). diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index 3cf4bdb..7a8dfb8 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -15,7 +15,7 @@ | #72 | Upstream Horizon 0.2.97 объединён с форком; сохранена история; локальные тесты и Metal-полигон | Windows/Linux, native HDR, сравнительная производительность | | #87 | Каноническое SDK-free ядро `airdef-core`, используется модом и сценариями | Дальнейшее отделение текстового/визуального вывода домена от адаптера | | #88 | Типизированный журнал измеренных переходов; read-only Snapshot для Smoke/Shown; записанные пожары, позы, пуски и зажигание | Перевести оставшиеся model/audio adapters и режиссёра на общий снимок; замер полной 10-минутной записи | -| #89 | Session владеет Live/Replay/Export; явные часы/поколения; единый cleanup; задержанный звук следует часам показа | Полный игровой цикл MP4 с отменой/ошибкой энкодера и проверкой хвоста очереди | +| #89 | Session владеет Live/Replay/Export; явные часы/поколения; единый cleanup; задержанный звук следует часам показа; выход в заголовок завершает показ одним cleanup (хук `frame`, `Session::world_left`) | Реальный короткий MP4 проверен 4 ноября (звук/видео, отмена, ошибка SDK и энкодера, повторный запуск, совпадение кадров и хвоста очереди). Остаётся: очистка `*.audio.raw`/`*.ffmpeg.log` движком при ошибке энкодера; 10-минутный экспорт; приёмка Windows/Linux | | #91 | Общий каталог доступности и потребители меню/инвентаря; явный RM48U-профиль; корректные подписи очередей | Единый источник всех чисел, звуков и Python-регистраций; проверка вариантов комплексов | | #92 | Authored overrides, проверка ссылок/PNG/границ, manifest, безопасная транзакционная генерация; registry/texture/icon modules | Дальнейшее ручное улучшение контента относится к #115 | | #96 | Акустические пути; измеренные синтетические occlusion/echo/reverb fixtures; лицензии и исходные notices в архивах | Прослушивание, игровой массовый звук/CPU, акустика реального MP4; точность RT60 пока по допуску 35%, не 15% |