End the show when its world closes and verify the MP4 lifecycle (#89)

A frame hook ends an active film or export when the world is gone. The game
stops ticking at the title screen but keeps rendering, so an export used to
keep recording the title with its playback clock frozen (a sec=10 request
produced 3898 frames) and went on after re-entering the world. Session
cleanup runs once with WorldChange and cancels the capture.

Real short exports on a scratch home and world (pin 57f8b2853, API 29,
Metal): h264+AAC both 2.07 s with non-silent audio, reported frames equal
ffprobe nb_frames; cancel, SDK refusal, encoder failure and restart all
leave no capture running; 1920x1080 works. The engine still leaves
*.audio.raw and *.ffmpeg.log behind on an encoder error (game/film.rs).

Co-Authored-By: Kimi Code <noreply@moonshot.ai>
This commit is contained in:
2026-11-04 17:39:36 +03:00
parent 632061d2ad
commit 675967ce42
6 changed files with 92 additions and 2 deletions
+12 -1
View File
@@ -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<Option<String>, String> {
let w: Vec<&str> = args.split_whitespace().collect();
let mut out = Out::default();
+34
View File
@@ -286,6 +286,16 @@ impl<P, E> Session<P, E> {
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<Cleanup> {
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<Cleanup> {
@@ -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 [
+10
View File
@@ -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 по-прежнему не проверены.
+9
View File
@@ -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
+26
View File
@@ -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, малая сцена).
+1 -1
View File
@@ -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% |