Compare commits

..

1 Commits

Author SHA1 Message Date
chexit c525feb465 Merge pull request 'AirDef v0.3.x: enemy intelligence, cold launch, MP4 and stability' (#131) from kimi-work into main
build / mod (push) Failing after 23s
release / windows (push) Failing after 2s
2026-11-06 15:41:19 +00:00
7 changed files with 20 additions and 130 deletions
+5 -21
View File
@@ -143,11 +143,6 @@ pub fn draw(sim: &Sim, player: DVec3, yaw: f32) -> Vec<Shape> {
s.push(square(x, y, 1.5, 0xFFFFFFA0));
}
}
// The player is below target blips: a close target must not disappear under the white arrow.
// Minecraft's yaw 0 looks south (+z, down the scope).
let face = yaw.to_radians();
let tip = |a: f32, l: f32| pt(cx - (face + a).sin() * l, cy + (face + a).cos() * l);
s.push(quad(tip(0.0, 6.0), tip(2.5, 4.0), pt(cx, cy), tip(-2.5, 4.0), 0xFFFFFFFF));
let mut far = 0;
// The air picture is what the systems' radars hold, as they last measured it (a track older than a second
// is a forecast, dimmer); with no system deployed, the sky as it is. Nothing else is read (#93).
@@ -181,6 +176,11 @@ pub fn draw(sim: &Sim, player: DVec3, yaw: f32) -> Vec<Shape> {
}
}
}
// The player: a white arrow the way they look.
// Minecraft's yaw 0 looks south (+z, down the scope).
let face = yaw.to_radians();
let tip = |a: f32, l: f32| pt(cx - (face + a).sin() * l, cy + (face + a).cos() * l);
s.push(quad(tip(0.0, 6.0), tip(2.5, 4.0), pt(cx, cy), tip(-2.5, 4.0), 0xFFFFFFFF));
// Labels.
s.push(text(format!("РЛС · {}", distance(RANGE)), cx, SIZE + 2.0, 0xFF80FF90, true));
s.push(text("С".into(), cx, 3.0, 0xFF80FF90, true));
@@ -189,19 +189,3 @@ pub fn draw(sim: &Sim, player: DVec3, yaw: f32) -> Vec<Shape> {
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sim::{DroneKind, Out};
#[test]
fn target_blips_are_drawn_over_the_player_arrow() {
let mut sim = Sim::new(1);
sim.launch(DroneKind::Shahed, DVec3::new(0.0, 64.0, -10.0), DVec3::ZERO, DVec3::ZERO, &mut Out::default());
let shapes = draw(&sim, DVec3::ZERO, 0.0);
let arrow = shapes.iter().rposition(|s| matches!(s, Shape::Quad(q) if q.color == 0xFFFFFFFF)).unwrap();
let target = shapes.iter().rposition(|s| matches!(s, Shape::Fill(f) if f.color == 0xFFFF4040)).unwrap();
assert!(target > arrow, "the player arrow covers the target blip");
}
}
+2 -62
View File
@@ -155,8 +155,6 @@ impl Recording {
objects: Vec::new(),
sam_tracks: Vec::new(),
sam_index: HashMap::new(),
sam_keys: 0,
sam_step: STEP,
ticks: Vec::new(),
times: Vec::new(),
quiet: 0,
@@ -187,8 +185,6 @@ pub const STEP: u32 = 2;
/// The longest recording (10 minutes) and how many are kept.
const LONGEST: u32 = 12_000;
const KEPT: usize = 3;
/// SAM poses share a fixed memory budget; under pressure old poses are thinned and new ones slow down.
const MAX_SAM_KEYS: usize = 32_768;
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum What {
@@ -385,8 +381,6 @@ pub struct Recording {
/// it pointed, whether it was still whole).
pub sam_tracks: Vec<SamTrack>,
sam_index: HashMap<u32, usize>,
sam_keys: usize,
sam_step: u32,
/// The game's tick number as sampled with the clock: (recording tick, game tick). A replay
/// shows the world's blocks as of this time.
ticks: Vec<(u32, u64)>,
@@ -595,38 +589,14 @@ impl Recording {
objects: sim.targets.clone(),
sam_tracks: Vec::new(),
sam_index: HashMap::new(),
sam_keys: 0,
sam_step: STEP,
ticks: Vec::new(),
times: Vec::new(),
quiet: 0,
}
}
fn thin_sam_tracks(&mut self) {
for track in &mut self.sam_tracks {
let len = track.keys.len();
let mut kept = 0;
for read in (0..len).step_by(2) {
track.keys[kept] = track.keys[read];
kept += 1;
}
if len % 2 == 0 && len > 0 {
track.keys[kept] = track.keys[len - 1];
kept += 1;
}
track.keys.truncate(kept);
track.keys.shrink_to_fit();
}
self.sam_keys = self.sam_tracks.iter().map(|track| track.keys.len()).sum();
self.sam_step = self.sam_step.saturating_mul(2).min(LONGEST);
}
/// Notes the systems' poses at the recording's tick `now`: a pose only when it changed, with
/// the pose it held marked before it so a replay doesn't glide through the stillness.
fn note_sams(&mut self, sams: &[Sam], now: u32) {
if now % self.sam_step != 0 {
return;
}
for sam in sams {
let key = SamKey::of(sam, self.origin, now);
let i = *self.sam_index.entry(sam.id).or_insert_with(|| {
@@ -634,21 +604,16 @@ impl Recording {
self.sam_tracks.len() - 1
});
let track = &mut self.sam_tracks[i];
let before = track.keys.len();
match track.keys.last().copied() {
None => track.keys.push(key),
Some(last) if last.same(&key) => {}
Some(last) => {
if last.at + self.sam_step < now {
track.keys.push(SamKey { at: now - self.sam_step, ..last });
if last.at + STEP < now {
track.keys.push(SamKey { at: now - STEP, ..last });
}
track.keys.push(key);
}
}
self.sam_keys += track.keys.len() - before;
}
if self.sam_keys > MAX_SAM_KEYS {
self.thin_sam_tracks();
}
}
/// Actual launch, if observed during this recording. Missing is not proof of no launch:
@@ -1078,8 +1043,6 @@ mod tests {
objects: Vec::new(),
sam_tracks: Vec::new(),
sam_index: HashMap::new(),
sam_keys: 0,
sam_step: STEP,
ticks: Vec::new(),
times: Vec::new(),
quiet: 0,
@@ -1206,29 +1169,6 @@ mod tests {
}
}
#[test]
fn full_recording_bounds_sam_keys_without_freezing_their_poses() {
let mut r = recording();
let mut sams: Vec<_> = (0..64)
.map(|id| Sam::new(id, SamKind::Radar, DVec3::ZERO, 0.0))
.collect();
for now in (0..LONGEST).step_by(STEP as usize) {
for sam in &mut sams {
sam.dish = now as f64 * 0.01;
}
r.note_sams(&sams, now);
}
let keys: usize = r.sam_tracks.iter().map(|track| track.keys.len()).sum();
let allocated: usize = r.sam_tracks.iter().map(|track| track.keys.capacity()).sum();
assert_eq!(keys, r.sam_keys);
assert!(keys <= MAX_SAM_KEYS, "a full recording retained {keys} SamKeys");
assert!(allocated <= 2 * MAX_SAM_KEYS, "SamKey allocations reached {} MiB", allocated * size_of::<SamKey>() / (1024 * 1024));
assert!(r.sam_step > STEP, "the rotating radars never engaged adaptive sampling");
let last = r.sam_tracks[0].pose(LONGEST as f64).unwrap();
assert!(last.at >= LONGEST - 64, "poses stopped at tick {}", last.at);
assert!(last.dish > 100.0, "the final radar pose was not recorded");
}
#[test]
fn a_system_keeps_its_pose_through_stillness_and_moves_when_it_moved() {
let mut r = recording();
+2 -2
View File
@@ -63,8 +63,8 @@ runtime закреплённого движка. Используется вре
## Акустика и владение захватом (API 27)
Текущий pin: `ed751714e0d24d02062976ee615e994186284170` (API 30; staging, cascades,
previous-frame motion и feature-gated DLSS SR backend #124). Акустические
Текущий pin: `278f356aa604fa21c2d15d0081823bbe0f891df2` (API 30; поверх API29 — нормали моделей
и очистка неудачного захвата). Акустические
`render.acoustics-status` и `render.capture-owner-status` возвращают поколение,
`available`, `enabled`, `request-accepted`, `prepared` и причину. В игре всё это показывает
`/airdef capabilities`. Отсутствие запроса, выключение, ожидание backend и ошибка различаются.
+6 -17
View File
@@ -58,12 +58,8 @@ cd .. && rm -f horizon-patches/*.patch && git -C horizon format-patch -q $(cat H
возвращается. Рисуются секции террейна в коробке дальнего каскада (`hd_shadow_visible`) и сущности-тенеотбрасыватели
(`shadow_casters`: без оверлеев 4/9/10/40 — 40 это светящиеся модели мода).
2. **G-буфер** (свой depth): albedo `Rgba8Unorm` (a = эмиссия), нормаль `Rgba16Float` (мировая, из `dpdx/dpdy`),
уровни света `Rgba8Unorm` (x = небо, y = блоки — сырые координаты лайтмапа /240), motion `Rg16Float`
(current→previous в render-пикселях, обе проекции с jitter). Террейн + дальние LOD-тайлы + сущности.
Terrain/ванильные сущности пишут camera/static motion; `motion_models_pass` перезаписывает mod-модели
previous-frame transforms с read-only depth и тем же alpha cutout (`7a73e8d26`). Ветер остаётся camera-only.
`HORIZON_HD_DEBUG_MOTION=1` показывает R/G displacement и validity вместо обычного TAA-кадра.
Полупрозрачное в G-буфер **не** попадает (идёт ванильным путём); вода — отдельный путь, см. п. 3 и «Вода».
уровни света `Rgba8Unorm` (x = небо, y = блоки — сырые координаты лайтмапа /240). Террейн + дальние LOD-тайлы +
сущности. Полупрозрачное в G-буфер **не** попадает (идёт ванильным путём); вода — отдельный путь, см. п. 3 и «Вода».
3. **Вода** (`water_pass`, только если на экране есть полупрозрачные секции, нет `frame.underwater` и не `HORIZON_HD_WATER=0`):
те же секции ещё раз с `fs_water` (terrain.wgsl), который отбрасывает всё, кроме слоёв спрайтов `water_still`/`water_flow`
(номера слоёв передаются в `Renderer::new` из `sprite_layers` и ставятся override-константами `WATER_STILL/WATER_FLOW`; стекло,
@@ -90,9 +86,7 @@ cd .. && rm -f horizon-patches/*.patch && git -C horizon format-patch -q $(cat H
5. **Пост-проход мода** `lit` → `fx` (`effects.wgsl`: взрывы, дым, вспышки, свечение; при HD его собственная
«подсветка огнём» выключена — свет даёт HD).
6. **TAA** — после пост-прохода мода (`fx`, либо `lit` без него), до bloom: история пинг-понгом, в альфе истории —
дистанция для отбраковки дизокклюзии; телепорт > 64 блоков сбрасывает историю. Если world/G-buffer depth
совпадают, history UV = current UV + motion/render_size; вода, прозрачность и небо используют прежний
depth/camera fallback. Джиттер добавляется к
дистанция для отбраковки дизокклюзии; телепорт > 64 блоков сбрасывает историю. Джиттер добавляется к
`view_projection` только при HD (публичная матрица без джиттера); рука и HUD рисуются после tonemap и не дрожат.
**TAAU**: если мир меньше окна (`HORIZON_HD_SCALE=0.67`), тот же проход (`fs_taau`, в таймингах `hd taau`) восстанавливает
полное разрешение. История, bloom и tonemap — в разрешении окна. Для каждого пикселя выхода берётся 3×3 выборки мира
@@ -251,13 +245,8 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
TAA-проход восстанавливает полное разрешение: история в полном разрешении, текущий кадр семплируется с учётом
джиттера (Catmull-Rom/Lanczos-веса по субпиксельной позиции), клиппинг окрестности как в TAA. Настройка
масштаба (`HORIZON_HD_SCALE` или поле в `HdSettings`). Цель: при 0.67 картинка близка к нативной, FPS заметно
выше. #124 развивает этот путь в общий temporal backend: `ed751714e` добавляет optional `dlss` feature
и `HORIZON_DLSS=1`, previous-frame motion, NGX+HDR bootstrap и display↔linear conversion. Submit:
world+linearize → NGX → delinearize; native TAAU остаётся fallback. По умолчанию DLSS рендерит world 2/3,
`HORIZON_HD_SCALE` переопределяет это. Сборка требует `DLSS_SDK` (v310.9.1), `VULKAN_SDK`, clang и
`--features dlss`; runtime DLL/SO и NVIDIA notices нужны рядом с приложением, не в Git. Windows `cargo check`
прошёл; MinGW link несовместим с MSVC `.lib`, MSVC link и реальный RTX/Vulkan runtime ещё не проверены.
Ванильные сущности/ветер остаются camera-only. MetalFX — отдельный Metal backend, FG отдельно.
выше. Альтернатива на Mac — MetalFX через wgpu-hal (нативный код, только Metal) — отдельной опцией потом. DLSS
нереален для нашей цели (только NVIDIA через Streamline, нет в wgpu).
2. **Вода** — СДЕЛАНО (патч 19, см. «Вода» ниже). Исходное ТЗ: отдельный G-буфер-путь для воды (маска + нормаль с анимированными волнами), SSR (трассировка по
глубине, откат на небо/туман), преломление (смещение по нормали), затухание по глубине воды (цвет биома),
блики солнца (GGX-подобный), френель. Подводный вид не ломать (`frame.underwater`).
@@ -415,7 +404,7 @@ ffmpeg 9.0.1. `/airdef replay export` с записанного налёта (4
## Цветные тени, рябь, кино-пост: что знать преемнику
- **Цветные тени.** Ближний каскад (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 блока размывают листву в «пятна». Дальний каскад по-прежнему рисует листву полностью; с `17382445a` near/far depth filters смешиваются на последних 6 блоках перед границей 36,8, а tint затухает по той же smoothstep — круглого шва нет. Цвет не доходит до лучей тумана, воды и дальнего каскада. Цена: `hd shadow colour` 0.25–0.35 мс (Mac, малая сцена).
- **Цветные тени.** Ближний каскад (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, малая сцена).
- **Рябь.** `ripple_gradient` берёт часы воды (`water.y`, часы фильма): частоты — целые циклы на 600 с, сетка 160 клеток на 256 блоков, поэтому рисунок не прыгает на обороте. Гаснет по размеру пикселя, только под открытым небом (`nt.a`). Амплитуда 0.012 не подбиралась.
- **Кино-пост.** `Hd::film` между `taa` и `finish` (`HdSource::Film`), только при `film_quality()`; цели создаются при первом фильме. Motion blur — только от движения камеры (глубина + `prev_view_proj`, затвор 180°, мёртвая зона 1–2.5 px из-за джиттера; движущиеся объекты не размываются; нет после склейки/телепорта: `taa.w`). DoF — диск 24 отсчёта, фокус авто (гармоническое среднее 9 глубин у центра) или `HORIZON_HD_DOF_FOCUS`. Детерминизм: единственное время в шейдере — `water.y` (часы фильма) для шума. Проверять на реальном фильме `/airdef replay film`; `HORIZON_HD_FILM_QUALITY=1` принудительно включает качество фильма для проб. DoF в игре был слишком сильным и дорогим; параметры уменьшены без повторного снимка. Motion blur на снимке (поворот 40° за 1.5 с) не разобран.
- Из Photon (MIT) ничего нового не переносилось: THIRD_PARTY_PHOTON.md не менялся.
+1 -3
View File
@@ -50,9 +50,7 @@
| #119 | Три read-only луча в известной геометрии ослабляют непрямой blast для корпуса/узлов/объектов; проёмы дают частичное воздействие; прямой контакт сохранён | Unknown/stale: прежний урон без защиты, нулевое ожидание; partial blocks считаются занятыми кубами; GUI-проверка разрушения укрытия ещё нужна |
| #120 | Конечное горение, powered/coast, сопротивление и снижение доступного манёвра, один остаточный pk по фактической скорости; swept-встреча; события burnout/energy loss; согласованное прекращение факела/дыма/звука | Игровой профиль, без аэродинамики и реальных параметров; сравнительная выборка 12 рейдов и расширенная сетка 48 рейдов (6 вариантов × 8 seed, батареи/дальности/классы) записаны в measurements; без подтверждённого дефекта параметры не менялись; прослушивание остаётся |
| #121 | On/Off в команде и карте, save/replay; Off не создаёт own measurements и сохраняет сетевой приём; раскрытие работающего радара наблюдателем; SEAD использует last-known и возраст | Условный наблюдатель по расстоянию, без радиотехнической модели и ошибки пеленга; выбор на карте ещё не проверен в GUI |
| #122 | Воспроизведены три класса: tick fuel exhaustion на двух максимальных составах, HD (полный обход height-cache на каждом взрыве заменён удалением нужных ключей; max135.3M/300M); остановка в UI event на втором крупном приказе (переснимок known_units на каждом дроне заменён одним снимком на приказ и O(журнал+группировка); max command 32.9M/1e9, `/stats` до/после совпал); OOM в `note_sams` (до: 384000 SamKey/27 MiB в одной полной записи 64 РЛС; после: бюджет 32768, адаптивное прореживание без остановки записи; WASM 24002 тика/64 РЛС/60 малых волн прошёл с прежним лимитом 64 MiB); нативные регрессии и callback diagnostics/profile | Требуется приёмка на Windows; без подтверждённого build identity нельзя утверждать ту же причину пользовательского сеанса; FPS/GPU не измерялись |
| #124 | Motion + object transforms + feature-gated DLSS SR PoC: engine `ed751714e`; motion в render-пикселях, previous-frame mod-models, `HORIZON_HD_DEBUG_MOTION`; NGX+HDR device bootstrap, долгоживущий SDK/context, display↔linear conversion и ordered submit; native TAAU fallback. Windows `cargo check --features dlss` прошёл; 24 HD + layout, mods/no-mods, обычный Metal кадр проверены | Это PoC, не подтверждённая поддержка: MinGW link несовместим с MSVC `.lib`, MSVC link и реальный RTX/Vulkan runtime не проверялись; ванильные сущности/ветер camera-only; reactive/transparency, FSR3/MetalFX и FG отдельно |
| #130 | Первый небольшой backport: upstream `f7b77fbc8` перенесён как engine `94b572cd2`; oversized staging allocations округляются до степени двойки и переиспользуются, копируются только фактические байты; 3 upload/GPU readback-теста прошли на Metal | Полная миграция на Nightly не начата; VSync/pacing, дальний рельеф и другие кандидаты остаются; Windows/Vulkan и долгий игровой RSS-профиль не проверялись |
| #122 | Воспроизведены два класса: tick fuel exhaustion на двух максимальных составах, HD (полный обход height-cache на каждом взрыве заменён удалением нужных ключей; max135.3M/300M) и остановка в UI event на втором крупном приказе (переснимок known_units на каждом дроне заменён одним снимком на приказ и O(журнал+группировка); max command 32.9M/1e9, `/stats` до/после совпал); нативные тесты эквивалентности и второго приказа; WASM fixture с 4 принятыми приказами; callback diagnostics/profile | Требуется приёмка на Windows; без подтверждённого build identity нельзя утверждать ту же причину пользовательского сеанса; FPS/GPU не измерялись |
| #127 | Этапы 1–2. **Картина:** журнал раскрытий заменён картой контактов (`Intel.contacts`: последнее «пуск/излучение/глушение», место, время, метка эмиссии, наблюдённая гибель, счётчик увиденных пусков), уверенность — функция возраста (Confirmed/Probable/Doubtful, TTL 6000), угроза и цены читают только published-параметры и факты наблюдений (launcher-only без донора «слеп»), уничтожение учится только из BSA. **Structured intent:** `Intent` (Plan/Sead/React/Replace) и `Aim` (юнит, since, switches, confidence) отделены от random-выбора; throttle `SWITCH_HOLD=160`, `SWITCH_CAP=3`, `ASSIGN_PER_TICK=16`, `ASSIGN_PER_CONTACT=6`, цена смены `SWITCH_GAIN=120`; в полёте цель меняется только когда картинка её убила/забыла. **Durable queue:** автосейв пишет версионированную секцию `air 1` — дроны, очередь запуска, задания (id, дрон, цель, юнит, версия, приоритет, намерение, версия рельефа), контакты, RLE-рельеф; очередь ограничена 256, устаревшие/фантомные/дублирующие задания отменяются при загрузке (`JOB_TTL=1200`), картина задания восстанавливается на свою версию, старые saves читаются (пустое небо), сценарии воздух не несут, Live/Replay не смешиваются. **Неизвестная земля не бесплатна** (`Relief.unknown=6` у повторного плана). **Объяснимость:** `enemy_picture()` дополнен aims/pending reason/aimed_by/pending_jobs без русского текста в core. Метаморфные тесты скрытых изменений (включая нагрев/замок РЭБ), save/restore посреди незавершённой очереди, детерминизм seed, 40+ массовый ретаргет. Замеры: `docs/measurements/issue-127-intent-queue-{baseline,result}.json` | GUI поверх `enemy_picture()`, игровая приёмка сохранения налёта на Windows/Linux, оценка баланса ew-adaptive (kills 59→58/59→58/56→63, потери 3→3/3→4/4→2 — следствие throttle, параметры не менялись) |
По #127 реализованы карта контактов, структурированное намерение с throttle и durable-очередь:
+3 -24
View File
@@ -7,31 +7,10 @@
поэтому запись может сохраниться и без ручного копирования.
Задача: [#122](https://gitea.maoniao.team/chexit/airdef/issues/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.
(`order_raid`, см. раздел «Второй налёт из UI: событие, не тик»). Без старого лога не
утверждается, что Windows-сеанс имел именно их; GPU/FPS отдельно не измерялись.
## Второй налёт из UI: событие, не тик
+1 -1
Submodule horizon updated: ed751714e0...278f356aa6