# История изменений

## Не выпущено — SDK: все инструменты спецификации, новая тема, рабочие кнопки (2026-10-09)

- **Исправлено «кнопки не нажимаются»:** у контейнеров с `overflow: auto` не было стилей полос прокрутки,
  RmlUi резервировал под полосу всю ширину, и содержимое (каталог инструментов, списки) схлопывалось до
  нуля — кнопки «Открыть» были недоступны. Стили полос — в `sdk/ui/theme.rcss`.
- **Движок:** виртуальная мышь агента (`mouseMove`, `mouse`, `wheel`) теперь доходит до RmlUi: агент
  наводит курсор, нажимает кнопки интерфейса и крутит списки (раньше RmlUi получал только настоящие
  события SDL). Тест `tests/agent/ui_virtual_mouse_test.py`, описание в `docs/AGENT_API.md` §3.5.
- **SDK, оформление:** собственная тема в стиле сайта (`sdk/ui/theme.rcss`, `shell.rcss`, `studio.rcss`),
  навигация по группам, экран «Шаблоны» и «Сборки движка», Debug и Package.
- **SDK, недостающие инструменты:** Tilemap Studio, Particle Studio, Collision / Physics Tools, Parallax
  Tools, Font / Text Tools, Audio Tools, Input Tools, RmlUi Studio и DevTools; все предпросмотры — настоящий
  рантайм (`$.tilemap`, `$.particles`, Box2D, `$.layers`, `$.font`, `$.audio`, `$.input`, RmlUi). Форматы —
  JSON с `version: 1`, содержимое которого игра читает `$.fs.readJSON`; описание — `docs/SDK.md` §13–§17.
- Re2D Baker: импорт Wavefront OBJ + MTL (`bake-re2d model.obj`, `*.obj` в реестре); тест `sdk_obj_test.py`.
- `r2d-sdk`: команды `templates`, `new`, `engines`; нативные валидаторы `tilemap`, `particles`, `collision`,
  `layers`, `fonts`, `audio`, `input`, `rmlui.document`, `rmlui.style`; `build` создаёт каталог результата;
  Asset Browser не показывает служебные `.r2d-*` черновики.
- Тесты: `sdk_studios_test.py` (все студии настоящей мышью), `sdk_data_parity_test.py` (JS ↔ C, 2912 правок,
  0 расхождений), `sdk_kinds_test.mjs`, `sdk_rml_test.mjs`.
- Тесты `sdk_shell_test` и `sdk_classic2d_test` обновлены: карточки проверяются по id (буфер `getText` 8 КБ),
  рамка кадра — по серединам пикселей (не зависит от дробной раскладки RmlUi).

## Не выпущено — завершение SDK и контрольный аудит (2026-10-08)

- Добавлен отдельный периодический стенд `$` API: 75 компонентных и 20 кадровых нагрузок, сырая базовая линия и таблица с условиями измерения; сравнение учитывает оборудование и сборку.

- Re2DSprite: авторинг clips/emotions/variants/equipment, timeline seek и undo/redo.
- Baker: выражения VRM, dense/sparse morphs, material/UV binds, humanoid mapping, атомарная запись отдельных файлов.
- World Studio: RmlUi-авторинг `.re2dmap`, native compile, runtime preview и многоэтажность.
- Automation: C batch/agent, паритет 19 команд, RmlUi UI и CI проверки.
- Уточнены ограничения MVP и статус исходников относительно готовых пакетов 0.1.22.
- Документация и списки задач сверены с кодом; убраны системные файлы и временные артефакты.


## SDK: Character / VRM в Re2D Baker (Phase 5)

- `r2d-sdk bake-re2d --type character`: VRM 0.x и 1.0 → псевдоскелет Re2DSprite (10 костей,
  сокеты кистей), владение частями по весам скина, `*.character.json` + `spin`/`walk` + отчёт.
  Неоднозначное владение считается и сообщается (`SDK_BAKE_SKIN_AMBIGUOUS`), выражения VRM
  сопоставляются эмоциям Re2DSprite; выбранное `--expression` запекается в PNG.
- Загрузчик glTF читает узлы, скины (inverseBind, JOINTS_0/WEIGHTS_0) и расширения VRM.
- Re2D Baker (GUI): тип Character, `*.vrm` в реестре, панель VRM/владения/выражений.
- Проверено на синтетических VRM 0.x/1.0 и реальном Seed-san. Это не проверка всех экспортёров VRoid.

## SDK: Re2D Baker MVP — GLB/glTF → Re2DSprite (Phase 4)

- `r2d-sdk bake-re2d`: GLB/glTF (внешний `.bin`, `data:`-URI, иерархия узлов, текстуры
  baseColor) → PNG v2 + `*.character.json` + анимация `spin` + машинно-читаемый отчёт.
  Пресет Prop; Auto Unwrap и Use Existing UV; авто-вписывание Coordinate Fit; origin
  center/feet; PNG 1024/2048/4096; детерминированный результат.
- Re2D Baker в SDK (RmlUi): параметры, панель Coordinate Fit, отчёт, диагностика, превью
  запечённой модели настоящим `$.re2dSprite`, переход в Re2DSprite Studio.
- Тестовые low-poly props: crate, barrel, lamp, chair (+ негативные GLB/glTF).

## SDK: Re2DSprite Studio (Phase 3)

- Re2DSprite Studio: просмотр модели настоящим `$.re2dSprite`, yaw/pitch мышью и
  числами, клипы/эмоции/варианты/стиль, отладочные виды карт PNG v2 (материал, ID
  части, владелец, X/Y/Z, покрытие, группа, перекрытие), осмотр отсчёта, правка
  скелета и сокетов с undo/redo, сохранение тем же отступом, hot reload.
- `r2d-sdk`: валидатор `re2dsprite.character` (описание, анимации, PNG v2) и команды
  `re2d-info`, `re2d-debug`, `re2d-sample`; паритет с рантаймом проверяет тест на 82 правках.
- Исправлено: `$.re2dSprite.from('/абсолютный/путь.character.json')` теряло ведущий
  «/» у атласа и не находило PNG (`relativeAsset`).

## SDK: Sprite Studio и Animation Studio, Classic 2D срез (Phase 2)

- Sprite Studio и Animation Studio в `sdk/`: атлас `*.atlas.json` (Aseprite-
  совместимый формат `$.atlas`): кадры, пивот-слайсы, длительности, теги-
  анимации, метаданные, undo/redo, просмотр настоящим рантаймом.
- `r2d-sdk`: `atlas-grid`, `atlas-format`, `atlas-info`, валидатор `sprite.atlas`
  (стабильные коды `SDK_ATLAS_*`); канонический вид файла — одна строка на кадр.
- Движок: hot reload теперь следит и за `*.atlas.json` в каталоге игры
  (раньше — только `.js`).

## SDK: оболочка, реестр инструментов и нативный бэкенд (Phase 1)

- `sdk/` — приложение SDK на R2D (RmlUi): проекты, Asset Browser, каталог
  инструментов из корневого `sdk_tools.json`, запуск и сборка игры,
  документация, панель диагностик. Запуск: `./build/russiano2d --game sdk`.
- `r2d-sdk` (`sdk/native`, чистый C): `tools`, `assets`, `project`, `projects`,
  `validate`, `run`, `build`; один JSON-объект на команду, диагностики со
  стабильными кодами. Описание — `docs/SDK.md`.
- `$.sdk` — мост инструментов (только при `"toolHost": true` в `project.json`):
  запуск `r2d-sdk` и движка фоновыми процессами без shell.
- RmlUi-интеграция: `$.ui.doc().value/setValue/content/attr/rect/click`,
  обработчики получают цель события (`targetKey`, `targetId`) — делегирование
  списков одним обработчиком. Прежние игры не затронуты.

## Документация под C → $

- Конституция, архитектура, правила агентов и справочник описывают схему
  C → `$`; справочник ядра — внутренний `docs/internal/NATIVE.md` (вместо
  `docs/API.md`, в релизный AGENTS.md не входит).
- В `$` добавлено то, что игра раньше брала из `engine.*`: `.angularVelocity()`,
  `.mass()`, `.allowSleep()`, `$.sound.channel(ch)`, `$.debug.render()`.
  Остаток сверки — `docs/TASKS.md` §13.

## Текст, тайлы, частицы и HUD в C

- `<text>`, статичные слои `<tilemap>`, частицы, `ui.label`/`ui.panel` и
  очередь подписей HUD собираются в C; кадр совпадает с JS-путём до байта.
- Release, 2000: текст 7,2 → 1,8 мс, `ui.label` 4,7 → 2,7, тайлмап 1,9 → 1,0,
  частицы 3,2 → 2,3. Тик виджетов — без обходов всего реестра.
- `$.debug.memory()` — факты о JS-куче (утечка или шум замера).

## Твины в C

- Простые твины (`.tween/.moveTo/.fadeTo/.scaleTo/.rotateTo`) со встроенной
  плавностью считает C: 2000 твинов — 0,04 мс за кадр, значения побитово как
  в JS. Таймеры тряски/вспышки/неуязвимости — тоже в C.
- Сценарии `$.tween(target)` без лишней работы в кадре: 2000 сценариев
  10,3 → 2,9 мс («JS итого», Release). Новая сцена стенда `move`.

## Дешёвые спавн и удаление узлов

- Индекс реестра строит C (`engine.nodes.buildIndex`); конструктор узла
  4,2 → 2,7 мкс (умолчания на прототипе, кэш цветов тега, быстрый `addClass`).
- `.remove()` по нескольким узлам чистит реестр одной уборкой; одиночное
  удаление ищет узел с конца. Пачка 200 спавнов и удалений на 2000 узлах —
  4,4 → 2,0 мс, `churn:2000` 14,5 → 3,4 мс (`HIGH_LEVEL_API_PERF.md` §0.6).

## C → $: нативные проходы кадра

- Синк тел, автособытия мира, наведение мыши, сортировка и сборка батча
  обычных узлов идут в C (`src/nodes.c`, `engine.nodes.*`) прямо над узлами `$`;
  особые узлы C отдаёт обратно в JS — кадр совпадает до байта.
- Release, 2000 узлов: спрайты 6,9 → 2,1 мс, тела 6,6 → 2,3, твины 10,3 → 4,2,
  `$.batch` 13,5 → 5,1 (`docs/HIGH_LEVEL_API_PERF.md` §0.5).
- Y-sort хук tilemap работает только при карте с `ysort` — раньше он
  обходил все карты перед каждым узлом.
- `$.debug.nativePasses(false)` — вернуть JS-проходы для сверки.

## C → $: игре виден только `$`

- `globalThis.engine` убирается после установки `$`; модули `src/highlevel`
  берут ядро из приватного `native.js`, загрузчик не отдаёт игре `r2d/*`,
  кроме `'r2d'`. Агентский `eval` видит `engine` на время вызова.
- Новое в `$` вместо прямых вызовов: `$.startScene`, `$.gfx.white`,
  `$.time.perfNow()`. Демки, `game/`, веб-сцена и фикстуры переведены на `$`.
- **Миграция:** `engine.startScene` → `$.startScene`, `engine.whiteSprite` →
  `$.gfx.white`, `engine.width/height` → `$.gfx.size()`, `engine.time` →
  `$.time.realNow()`, `engine.setCursor` → `$.input.cursor`, `engine.drawSprite`
  → `$.gfx.push.sprite`, `engine.log` → `$.log` (таблица — `docs/highlevel/native.md`).

## Имя технологии Re2DSprite

- Основной API $.re2dSprite и методы .re2d*, сцена re2dsprite.
- Название в демо и документации, руководство RE2DSPRITE_GUIDE.md.
- Старые API/сцена/пути/PNG совместимы; миграция данных не требуется.

## Изготовка Re2DSprite и полное руководство

- Двухзвенные руки: локти, ID предплечий 20/21, новая holdRifle,
  направление ствола вперёд и совмещение обеих кистей с сокетами АК.
- Модельный PNG-шаблон и руководство RE2DSPRITE_GUIDE.md: рисунок,
  карты, сетки, скелеты, клипы, подмены, сборка, сокеты и ограничения.
- Загрузка PNG 3072 согласована с компилятором и контрактом v2.

## Re2DSprite JSON, анимации и предметы

- Пользовательские поверхности samples/grid/loft, JSON скелеты и клипы,
  C-проекция по матрицам частей без зависимости от человеческих ID.
- High-level from/equip/rotAttach/rotDetach, сокеты, JSON-слои мимики и удержания,
  hot reload описания и анимаций. Шаблоны предмета и четвероногого животного.
- Отдельные PNG/JSON АК-47, пистолета и дробовика; G переключает предмет в демо.
- Native, JS, authoring и реальная проверка креплений/перезагрузки/владения.
  Формат и ограничения: [RE2DSPRITE_JSON.md](RE2DSPRITE_JSON).

## Re2DSprite v1 — прототип головы из развёртки всего тела

- Один строгий PNG-атлас всего персонажа (`8N×8N`): базовый шаблон 512×512
  для разработчиков и заполненный по той же раскладке атлас Руси-тян.
- `$.re2dSprite`, `<rotsprite>`, `.re2dSpriteAtlas(path)`, `.re2dPose(yaw,pitch)`:
  нативный синтез головы и ушей в спрайт 64×64, полный yaw, pitch ±75°,
  nearest, pixel snapping, переиспользование текстуры и освобождение при
  удалении/hot reload. Области тела есть в PNG, но ещё не проецируются.
- Демо `--game demos --scene re2dsprite`: общий атлас, вращение стрелками,
  автоповорот и проверки pitch. Весь интерфейс — RmlUi.
- Проверки C с ASan/UBSan, JS и детерминированный агентский тест по пикселям.
  Спецификация: [RE2DSPRITE_V1.md](RE2DSPRITE_V1).

## Экспорт в веб: движок в браузере (WebGPU)

Движок впервые собирается и **играется в браузере**: Emscripten + SDL_GPU с
бэкендом WebGPU (ветка PR libsdl-org/SDL#16020), шейдеры на WGSL, интерфейс
RmlUi работает как обычно. Полный разбор, грабли и ограничения —
[docs/WEB_EXPORT.md](WEB_EXPORT).

### Добавлено
- **`web/export.py`** — экспорт игры в веб: `--game`, `--out`, `--index`,
  `--custom_loader` (своя HTML-страница), `--custom_image` (картинка экрана
  загрузки), `--force_play` (старт без кнопки «Играть»).
- **`web/shell.html`** — загрузочная страница в стиле Godot: маскот, полоса
  прогресса, «Инициализация P2D», кнопка «Играть» (она же жест, без которого
  браузер не включает звук), фуллскрин, сцена 16:9 и журнал в DOM для автотестов.
- **`tests/web/smoke.py`** — дымовой прогон веб-сборки: сервер, страница,
  вердикт по маячкам движка (`first-frame`, `shutdown: …`, ошибки WebGPU).
- **`shaders/wgsl/`** — WGSL-варианты всех встроенных шейдеров: WebGPU не
  принимает ни SPIR-V, ни MSL.
- **`third_party/patches/rmlui-webgpu.patch`** — две правки бэкенда RmlUi:
  WGSL-ветка (без неё `Invalid shader format` и интерфейса нет вовсе) и
  порядок освобождения transfer-буфера (без неё атлас глифов пуст — интерфейс
  рисует геометрию без текста). Патч применяется сам на конфигурации веба.
- **`site/play/`** — играбельные демо на сайте, страница `/play/`.

### Изменено
- **Шрифт по умолчанию — Open Sans** (`assets/fonts/OpenSans-Regular.ttf`):
  семейство выбирается по наличию кириллицы, а не «первый файл по алфавиту» —
  из-за этого русские подписи молча не рисовались.
- **Лаунчер демо — на RmlUi и адаптивный**: размеры в `vh`, карточки и кнопки
  тянутся flex'ом, вёрстка одинакова на 1280×720 и 1920×1080. Платформер из
  меню убран (запускается как `--scene platformer`), фон меню и музыка —
  новые (`witch_lobby.png`, `lobby_groove.mp3`).
- **Заглушки подсистем** пополнены (`shader_live_stub.c`, `r2d_gui_load_markup`),
  `http.h`/`render.h` берут типы QuickJS из `quickjs.h` — сборки с выключенными
  подсистемами и конфигурации с nan-boxing больше не падают.
- **Длительность звука в журнале** считается в секундах, а не в кадрах/1000.

### Проверено
- нативная сборка и `tools/run_tests.py`: 82 теста, все зелёные;
- веб: демо-меню и «Типичная ночь в Мытищинском лесу» играются в Chrome,
  интерфейс RmlUi виден, ошибок WebGPU в журнале нет.
## DevTools: инспектор сущностей на RmlUi

Первый срез инструмента разработчика (ROADMAP, фаза 8): список сущностей →
выбор → свойства → копирование селектора. Открывается по **F2**.

### Добавлено
- **`$.devtools`** — панель на **RmlUi**: `open()/close()/toggle()/isOpen()`,
  `select(uid)` / `selectBy(sel)`, `selector()`, `panel()` (машиночитаемое
  состояние), `refresh()`. Данные — из `$.agent.nodes('*')`, той же инспекции,
  что у агента и команд `query`/`inspect`.
- **`engine.ui.loadMarkup(name, markup)`** — документ RmlUi из строки разметки.
  Нужен инструментам, которые строят интерфейс кодом и не кладут `.rml` в игру.

### Как устроено
- панель собирается кодом (маркап в памяти), ImGui не используется — закон UI
  соблюдён ([UI_RMLUI_LAW.md](UI_RMLUI_LAW));
- обработчики вешаются один раз при открытии: RmlUi уносит слушателей вместе с
  элементом, поэтому разметка не перерисовывается — обновляются тексты и
  классы;
- содержимое обновляется раз в 6 кадров; закрытая панель не стоит ничего,
  кроме проверки F2;
- панель только читает мир и не становится источником истины.

### Проверено
- `tests/js/devtools_test.mjs` — состояние и выбор без GUI;
- `tests/agent/devtools_test.py` — в движке: документ создаётся, F2
  переключает, выбор даёт селектор, мир не тронут.

## Реактивные запросы: `$.watch`

Вход и выход по **составу выборки** (ROADMAP, фаза 7): узел попал под селектор
или перестал подходить — удалён, сменил класс, вышел из `within()`.

### Добавлено
- **`$.watch(sel, { onEnter, onLeave, immediate })`** — наблюдение; возвращает
  handle `{ stop, size, active, selector }`; `$.watch.count/list/clear` —
  диагностика и снятие.
- Сравнение по `uid`: пересозданный узел — новое вхождение, удалённый не
  путается с чужим. По умолчанию первый кадр молчит (`immediate: true` — иначе).
- Кадровый шаг встроен в цикл `$` после триггеров; кадр без наблюдений ничего
  не стоит.

### Проверено
- `tests/js/watch_test.mjs` — вход/выход, `immediate`, пересоздание узла,
  `list/count/clear`;
- `tests/agent/watch_test.py` — в движке: удаление тела даёт выход, новый враг —
  вход, `clear()` прекращает события.

## Утверждения в понятиях мира: `$.expect`

Тест и агент формулируют ожидания селектором, а не самодельным `eval`
(ROADMAP, фаза 6).

### Добавлено
- **`$.expect(селектор)`** — утверждения: `.exists()`, `.empty()`, `.count(n)`,
  `.hp(n)`, `.prop(имя, значение)`, `.positionNear(x, y, eps?)`,
  `.state(значение)`. Каждое идёт через `$.test.check`, поэтому попадает и в
  общий счётчик (`results()`/`report()`), и в снимок агента.
- **Структурные детали провала**: `$.test.results().details` и
  `state.tests.details` содержат `{ message, subject, prop?, expected, actual }` —
  падающий тест оставляет разбираемый артефакт, а не строку в логе.

### Проверено
- `tests/js/expect_test.mjs` — 5 наборов без движка (успех, провал, детали,
  пустая выборка, разные виды субъекта);
- `tests/agent/expect_test.py` — в движке: утверждения, общий счётчик, детали
  в снимке агента, продолжение работы после провала.

## Запросы в радиусе: `$().within()` и нативный `engine.queryCircle`

Первый нативный конвейер запросов (ROADMAP, фазы 1–2): выборка «кто рядом с
целью» больше не перебирает все узлы в JS.

### Добавлено
- **`$('.enemy').within('#hero', 500)`** — метод обёртки: узлы, чей центр не
  дальше радиуса от цели. Цель — селектор, узел, обёртка или точка `{x, y}`.
  Узлы с телом отбирает `engine.queryCircle` (broadphase Box2D), узлы без тела
  (спрайты, зоны, свет) проверяются по координатам и не теряются. Порядок
  результата — порядок выборки; пустая цель и нечисловой радиус дают пустую
  выборку, а не исключение.
- **`engine.queryCircle(x, y, radius, mask)`** — низкий уровень: `Int32Array`
  id тел, отсортированный по расстоянию (при равенстве — по id): результат
  воспроизводим и не зависит от порядка обхода broadphase.
- **`engine.queryStats()`** и **`$.debug.queryStats()`** — диагностика
  последнего запроса: `{ calls, candidates, results, ms, cap, truncated }`.
- **`withinRadius()`** в ядре — чистая фильтрация, проверяемая без движка.
- **Команды агентского протокола `query` и `inspect`** (ROADMAP, фаза 3):
  `{"cmd":"query","sel":".enemy","limit":10}` → список сущностей,
  `{"cmd":"inspect","sel":"#hero"}` → одна сущность (или `null`). Селектор
  разбирает тот же JS-код, что и `$.agent.node/nodes`, — движок только перевозит
  строку, поэтому у агента, DevTools и игры одна реализация поиска.
- **`engine.setAgentQuery(fn)`** — хук, через который игра отдаёт эту
  инспекцию; высокоуровневое API ставит его само.
- **`$.agent.nodes(sel, limit)`** — предел списка.
- **Обёртки клиента** `Agent.query(sel, limit)`, `Agent.inspect(sel)` и
  `Agent.profile(sel, x, y, radius)`.
- **Команда `profile`** (ROADMAP, фаза 4): `{"cmd":"profile","sel":".enemy",
  "x":100,"y":100,"radius":500}` → число сущностей по селектору, статистика
  нативного поиска (`candidates`, `results`, `ms`, `cap`, `truncated`), зоны
  кадра и честный `allocations: null` (движок аллокации не измеряет).
- **`--record <файл>` и `--replay <файл>`** (ROADMAP, фаза 5): запись ввода
  кадра и воспроизведение вместо настоящего ввода. Формат — JSON-строки с
  версионированным заголовком (версия движка, игра, `--fixed-dt`, `--seed`);
  файл чужой версии отвергается с объяснением. Мир не сериализуется: при том
  же зерне и шаге симуляция обязана прийти туда же. Записаны клавиши, мышь и
  колесо; касания, геймпады и текст IME — нет (честное ограничение).

### Изменено
- **`engine.queryPoint`, `engine.queryBox`, `engine.keysPressed`,
  `engine.keysReleased` возвращают настоящий `Int32Array`** — как и было
  обещано в internal/NATIVE.md; раньше это был обычный массив. Для кода с индексным
  доступом и `.length` ничего не меняется.
- **Кадр ImGui больше не начинается, пока оверлей скрыт.** Раньше он шёл
  каждый кадр ради очереди текста, которая давно мертва (игровой текст рисуется
  спрайтами глифов). Теперь ImGui существует только когда разработчик открыл
  оверлей по `F1`/`--overlay`/`$.debug.on()` — как и обещает
  [UI_RMLUI_LAW.md](UI_RMLUI_LAW).
- **`engine.drawText.length` = 9** — по числу объявленных параметров
  (регистрация биндинга отставала: 6).

### Добавлено
- **`--version`** — печатает версию движка и выходит (её же печатает `--help`).
- **`engine.pixel_width` / `engine.pixel_height`** — физический размер буфера
  кадра (Retina/HiDPI); в доке они были обещаны, но не выставлялись.

### Исправлено
- **Версия движка одна на проект.** `R2D_VERSION_STRING` приходит из CMake
  (`PROJECT_VERSION`), а не зашита в `r2d.h` как `"0.1.0"`; строка идёт в
  `--help`, заголовок окна, событие `ready` агента и User-Agent HTTP.
- **`engine.seed` без `--seed` равен `12345`** — ровно тому зерну, от которого
  работает `$.random` (было `0`: заголовок реплея расходился с фактическим
  миром).

### Документация
- **Появились законы и направляющие документы:** [docs/PHILOSOPHY.md](PHILOSOPHY),
  [docs/UI_RMLUI_LAW.md](UI_RMLUI_LAW),
  [docs/AGENT_IMPLEMENTATION_RULES.md](AGENT_IMPLEMENTATION_RULES),
  [docs/ROADMAP.md](ROADMAP), [docs/TESTING.md](TESTING),
  [docs/DEVTOOLS.md](DEVTOOLS), [docs/RECORD_REPLAY.md](RECORD_REPLAY),
  [docs/R2D_R3D_CONVENTIONS.md](R2D_R3D_CONVENTIONS). Все они
  автоматически попадают в релизный `AGENTS.md`; правила агентов идут в нём
  первыми (`AGENTS_DOC_ORDER` в [tools/release.py](https://github.com/Nikide/russiano2d/blob/main/tools/release.py)).
- **Гейт документации требует правила агентов**: `docs/AGENT_IMPLEMENTATION_RULES.md`
  добавлен в обязательный список (`tools/dsh-russiano2d-docs-gate`).
- **Пройдена сверка документации с кодом** (четыре независимых аудита):
  починены устаревшие числа и версии, восстановлены сигнатуры `$.world.spawn/
  castShape/query/background`, `.collisionLayer` заменён на существующие
  `.layerBits()/.mask()`, примеры `map/filter/every/some` приведены к
  `(индекс, обёртка)`, цепочка `$.store.file().save().load()` разделена,
  снят остаток заглушки в `docs/highlevel/render.md` §3, обновлён список
  занятых имён в `docs/highlevel/_CONTRACT.md`, добавлены разделы про зоны и
  недостающие вызовы (`$.agent.install()`, `.sleeping()`, `.bullet()`,
  `engine.audio.*`, `engine.pixel_width/height`).

### Проверено
- полный прогон `python3 tools/run_tests.py` — **79/79 ok, 0 fail, 0 skip**
  (283.8 с), включая новые тесты ниже;
- `tests/js/*_test.mjs` — 79 наборов под QuickJS, 0 провалов;
- `tests/agent/highlevel_within_test.py` — 28 проверок в движке (радиус и
  граница, узлы без тела, цели точка/узел/обёртка, нативный порядок,
  диагностика, `remove()` поверх выборки), фикстура `tests/fixtures/within`;
- `tests/agent/agent_query_test.py` — проверки команд `query`/`inspect`/`profile`
  (селекторы, предел, порядок реестра, совпадение с `$.agent.node/nodes`,
  диагностика запроса, понятные ошибки);
- `tests/agent/replay_cli_test.py` — запись ввода, версионированный заголовок,
  воспроизведение в ту же позицию, отказ на чужую версию формата
  (фикстура `tests/fixtures/input_walk`);
- `tests/js/within_test.mjs` — 6 проверок чистой фильтрации без движка.

## Lightmap: свет в отдельной текстуре, мягкая кромка и независимость от порядка

Свет перестал быть треугольниками внутри сцены: он копится в отдельной текстуре
половинного разрешения и накладывается на кадр одним полноэкранным проходом.
Отсюда сразу три вещи — независимость от порядка отрисовки, мягкая кромка без
лишних лучей и половинная стоимость заливки.

### Добавлено
- **`$.gfx.light.map({ on, intensity, soft })`** — световая карта: свет в
  отдельной текстуре. `soft` добавляет размытие (полутень на кромке тени),
  `intensity` — множитель силы света. Есть `$.gfx.light.mapSupported()` и
  запрос состояния без аргументов.
- **`engine.submitLightTriangles(vertices, count?, blend?)`** — низкоуровневый
  канал света: тот же формат вершин, что у `submitTriangles`, но свой список и
  свой проход.
- **`engine.lightMap(on?, intensity?, soft?)`** — режим карты света для C-стороны.
- **`shaders/light_map.frag.glsl`** — композит карты света (аддитивное
  смешивание); в `cmake/Shaders.cmake` добавлена одна строка регистрации.
- Свет в карте опционален: выключенная карта (или кадр в render target игры)
  рисует свет прямо в сцену, как раньше, — свет не теряется ни в одном режиме.

### Изменено
- `<light>` и `<lightarea>` с включённой картой уходят в отдельный слой
  (`submitLightTriangles`). `$.gfx.draw.glow` остаётся в сцене: это эффект, а не
  источник света.
- Порядок кадра: свет → текстура света → проход сцены → композит света →
  интерфейс. С включённой картой `.punch(true)` не нужен — свет и так ложится
  поверх тумана и темноты.
- `r2d_render_draw_world` умеет дорисовать свет из своего списка, если карта не
  применилась: потерять свет нельзя.
- `r2d_batch_triangles` и `r2d_batch_light_triangles` делят общую раскладку
  вершин (`r2d__pack_triangles`), а пакеты треугольников рисует один помощник
  (`r2d__draw_tri_batches`).
- Документация: `docs/highlevel/render.md` §3.0.5 (световая карта),
  `docs/internal/NATIVE.md` (новые биндинги).

### Тесты
- `tests/agent/highlevel_light_test.py`: карта включается и выключается, свет в
  ней не теряется (207 против 207), тени работают, проход считается
  (`renderInfo().passes`), размытие смягчает кромку (1.6 → 3.8), свет с картой
  прорезает темноту.

---
## Свет: кэш границ, отсечение по радиусу, темнота и мягкие тени

Свет перестал считать одно и то же каждый кадр, а у сцены появился слой
темноты: ночь, подвал и пещера теперь делаются через `$.gfx.light.ambient`, а
свет с `.punch(true)` её прорезает.

### Добавлено
- **`$.gfx.light.ambient({ level, color })`** — темнота поверх кадра
  (multiply). Рисуется после мира, тумана и свечения фонарей, но **до** света с
  `.punch(true)`: получается ночной город, где всё в полутени, а лампы светят в
  полную силу. Есть `.off()`, `.params()`, `.on()`.
- **`.shadowSoft(rays, deg)`** у `<light>` и `<lightarea>` — мягкая кромка тени:
  источник считается не точкой, а маленьким диском, поэтому на кромке
  препятствия появляется полутень. По умолчанию 1 — прежняя резкая кромка.
- **`$.gfx.light.stats()` / `.resetStats()`** — счётчики за кадр: сколько
  границ посчитано (`built`), сколько взято из кэша (`cached`), сколько отрезков
  дошло до света из всех, что лежат в реестре (`culled` / `considered`).
- **`engine.light.prepare(segments)`, `preparedCount()`, `preparedMaxPoints()`,
  `visibilityPrepared(x, y)`** — подготовленный набор препятствий: разрезание
  O(n²) делается один раз, а не на каждый вызов полигона; оценка буфера
  становится линейной (40 против 152 на четырёх отрезках коробки).

### Изменено
- **Границы теней кэшируются на узле.** Они зависят от положения источника,
  веера, набора препятствий и версии реестра, но **не от камеры**, — поэтому
  статический фонарь считается один раз, а не каждый кадр. Площадной свет
  кэширует каждую свою точку отдельно.
- **Препятствия разложены по клеткам** (128 единиц мира): свету достаются только
  те отрезки, что попали в его радиус, а не весь реестр уровня. Индекс
  перестраивается при изменении реестра.
- **`lightBoundary()` принимает `count` и `soft`** — набор отрезков читается из
  переиспользуемого буфера без копии, а `soft` даёт полутень. Дальность каждого
  подлуча проецируется на главное направление, поэтому ровная стена перед
  светом не «раздувается».
- **Веер света без лишней тригонометрии**: косинусы, синусы и кромка конуса
  считаются один раз на веер, а не на каждое кольцо (было 7× на луч). Буферы
  переиспользуются — в кадре нет `new Float64Array` на каждый свет.
- **`$.gfx.light.polygon(x, y)`** готовит набор по версии реестра и считает
  полигон из подготовленного набора, а не режет отрезки заново.
- Документация: `docs/highlevel/render.md` §3.0.3–3.0.4 (темнота и
  производительность света), `docs/internal/NATIVE.md` §12 (подготовленный набор).

### Тесты
- `tests/js/light_test.mjs`: индекс по клеткам (выборка, длинные отрезки, дубли,
  пустой реестр), отсечение по `count`, мягкая кромка и ширина полутени через
  `spread` — 44 проверки.
- `tests/agent/highlevel_light_test.py`: кэш границ (`built == 0` на прогретой
  сцене), отсечение далёких препятствий по радиусу, полутень по пикселям,
  темнота и свет, который её прорезает, подготовленный набор из C.

---
## Свои шейдеры в рантайме: таблица §28 закрыта

Последний пункт раздела «Ограничения (честно)» из `docs/HIGH_LEVEL_API.md`
закрыт: игра может компилировать собственные фрагментные шейдеры во время
работы. Таблица §28 заменена списком ограничений по устройству движка.

### Добавлено
- **`$.gfx.defineShader(имя, исходник)`** — компиляция GLSL в рантайме:
  glslang (GLSL → SPIR-V) и SPIRV-Cross (SPIR-V → MSL для Metal). Те же
  библиотеки, которыми `cmake/Shaders.cmake` собирает встроенные шейдеры,
  теперь линкуются в движок (`src/shader_live.cpp`).
- **Шапка шейдера фиксирует контракт**: `u_texture` (set 2 binding 0),
  `NodeParams { vec4 p; vec4 c; } u` (set 3 binding 0), входы `v_texcoord` /
  `v_color`, выход `o_color`. Показать её игре — `$.gfx.shaderPreamble()`.
- **Свой шейдер работает как встроенный эффект**: `.shader(имя)`,
  `.shader(имя, { p1, p2, p3, color })`, `.shaderParam(...)`, общая пакетная
  отрисовка. Конвейеры создаются на все четыре режима смешивания.
- **Диагностика**: `$.gfx.shaderError()` возвращает текст glslang,
  `$.gfx.shadersSupported()` — есть ли компилятор в сборке,
  `$.gfx.userShaders()` — список зарегистрированных имён. Повторная
  регистрация с тем же именем перекомпилирует шейдер (hot reload).
- **Опция сборки `R2D_ENABLE_LIVE_SHADERS`** (`ON` по умолчанию): `OFF`
  убирает компилятор из бинарника, оставляя встроенные эффекты узла.

### Изменено
- `engine.defineSpriteFx(index, kind, p1, p2, p3, color, userShader)` — седьмой
  аргумент: слот пользовательского шейдера (0 — встроенный эффект).
- `docs/HIGH_LEVEL_API.md` §28: вместо таблицы «чего нет» — список
  ограничений по устройству (DXIL/D3D12, только фрагментные шейдеры,
  синхронная компиляция, render target как цель кадра).
- Тесты: юнит-тесты `tests/js/sprite_fx_test.mjs` (разбор параметров
  пользовательского шейдера, проверка тела до компилятора) и агентские
  проверки в `tests/agent/highlevel_render_test.py` — компиляция, ошибка
  компиляции, перекраска узла своим шейдером (6400 px зелёным).

---

## Честный bloom, шейдеры узлов, render target, DSP-эффекты и реверб-шины

Закрыты оставшиеся пункты таблицы «Ограничения (честно)» из
`docs/HIGH_LEVEL_API.md` §28: свечение, шейдеры на узел, render target игры и
канальные DSP-эффекты с реверб-шинами. В таблице осталась одна строка —
рантайм-компиляция произвольного GLSL.

### Добавлено
- **Честный bloom** (`shaders/bloom_pre.frag.glsl`, `shaders/bloom_blur.frag.glsl`):
  яркий проход с понижением разрешения (порог с мягким коленом), два
  разделяемых размытия и композит в пост-обработке. Раньше свечение было
  восемью выборками в одном проходе и потому выглядело контуром, а не светом.
  Параметры `bloom_threshold` и `bloom_radius` у `$.gfx.post({...})`,
  признаки `bloom_ready`/`bloom_buffers` у `engine.getPost()` и
  `engine.renderInfo()`. Если буферы не создались, движок честно откатывается
  на прежний однопроходный вариант.
- **Шейдеры узлов** (`.shader('flash' | 'dissolve' | 'chroma' | 'wave', {…})`,
  `.shaderParam(имя[, значение])`, `$.gfx.fxKinds()`): эффекты поверх спрайта
  отдельными конвейерами (`shaders/sprite_fx.frag.glsl`) и таблицей параметров
  на кадр. Узлы с одинаковым эффектом и параметрами рисуются одним вызовом;
  узлы без шейдера идут прежним конвейером.
- **Render target игры** (`$.viewport`): `create/size/bind/bound/sprite/draw/
  destroy/count`, `engine.viewport.*`. Кадр рисуется в свою текстуру (target),
  прошлый кадр остаётся в history-текстуре и доступен как спрайт — так делают
  шлейфы, накопление и порталы.
- **DSP-эффекты каналов и шин** (`src/audio_fx.c`): кроме `lowpass` и `echo`
  появились `highpass`, `tremolo`, `bitcrush`, `ringmod` и `reverb`.
  `$.audio.effects()` берёт список из движка.
- **Реверб-шина с посылом**: у шины (группы) или канала может быть свой хвост,
  в который уходит доля `send` её микса; остальные шины и мастер остаются
  сухими (`engine.audio.setGroupReverb/setChannelReverb`,
  `$.audio.effect(name, 'reverb', { send, room, damp, width })`).
- **Тесты**: офлайн-тест DSP `tests/audio/fx_test.c` (29 проверок, собирается в
  `r2d_audio_fx_test`), юнит-тесты `tests/js/sprite_fx_test.mjs` и
  `tests/js/shape_spec_test.mjs`, пиксельные проверки bloom и шейдера узла в
  `tests/agent/highlevel_render_test.py`, проверки эффектов и реверб-шин в
  `tests/agent/highlevel_audiobus_test.py`, render target там же.

### Изменено
- **`r2d_render_draw_world/draw_ui/draw` принимают командный буфер**: юниформы
  шейдеров узлов пушатся во время отрисовки, а не после.
- **`engine.submitSprites(transforms, colors, count, blend, fx)`**: пятый
  аргумент — индексы шейдеров узлов; без него поведение прежнее.
- **`bootstrap.js` показывает настоящую причину падения установки `$`**:
  раньше исключение внутри `createApi()` уносило контекст, и в журнале
  оставалось «bootstrap.js не выставил globalThis.$».

---

## Коллизии по слоям, свип формы, частицы-цели и игровое время (§28 справочника)

Закрыты шесть пунктов таблицы «Ограничения (честно)» из
`docs/HIGH_LEVEL_API.md` §28: слои и маски коллизий, свип формы, частицы как
цели, габарит агента у `<tilemap>`, виброотклик, пауза и масштаб времени для
клипов. Остались шейдеры, render target, bloom и DSP-шины — это этап C.

### Добавлено
- **Слои и маски коллизий** (`b2Filter`): `.layerBits(bits)`, `.mask(bits |
  узел | селектор)`, `.collidesWith(цель[, true|false])` — раньше это были
  заглушки в `api.js`, которые возвращали обёртку и ничего не делали. Маски и
  группа Box2D живут на теле, меняются без пересоздания (`r2d_physics_set_filter`,
  `engine.setBodyFilter/getBodyFilter`), переживают `.size()` и prefab.
  Запросы получили слои: `$.world.raycast/bodyAt/bodiesIn/raycastAll/lineOfSight`
  и `engine.raycast/queryPoint/queryBox` принимают `mask`; `.onFloor()`/`.onWall()`
  уважают маску узла.
- **Свип формы (аналог `ShapeCast2D`)**: `r2d_physics_cast_shape` на
  `b2World_CastShape`, `engine.castShape(opts)`, `$.world.castShape(from, to,
  {w,h} | {radius} | {capsule:[r,halfH]})` и метод узла `.sweepTo(цель, opts)`.
  Луч отвечает «что на линии», свип — «пролезет ли объём»: им проверяют проёмы
  и задевание углов плечом.
- **Частицы как цели**: `$.particles.at/inBox/raycast/hit`,
  `$.world.particlesAt/particlesIn` и флаг `{ particles: true }` у
  `$.world.raycast`. Попадание убивает частицу (`hit(..., {kill:false})` —
  если судьбу решает игра).
- **Габарит агента у `<tilemap>`**: `.agentRadius(r)`, `.agentSize(w, h)`,
  `.fitsAt(x, y, opts)` и `.sample(x, y, opts)` — проверка «пролезу ли я сюда
  телом» по сетке, без физики; тела коллизий остаются клетка в клетку.
- **Виброотклик**: `engine.padConnected/padRumble/padRumbleTriggers` на
  `SDL_RumbleGamepad`, `$.input.rumble(opts)`/`stopRumble()`/`rumbleSupported()`.
  Вызов возвращает `false`, если геймпада нет: игра узнаёт, что тряска ушла в
  пустоту, а не считает, что игрока тряхнуло.
- **Юнит-тесты** (qjs): `tests/js/shape_spec_test.mjs`,
  `tests/js/particle_hit_test.mjs`, `tests/js/tilemap_agent_test.mjs`,
  `tests/js/input_rumble_test.mjs`.
- **Агентские тесты**: `tests/agent/highlevel_collision_test.py` (слои, маски,
  свип, prefab), `tests/agent/highlevel_rumble_test.py`; проверки частиц-целей
  в `highlevel_particles_test.py`, габарита — в `highlevel_tilemap_test.py`,
  времени — в `highlevel_anim_test.py` и `highlevel_animplayer_test.py`.

### Изменено
- **Клипы, машины состояний и таймлайны тикают игровым временем**
  (`$.time.delta()`), а не сырым `dt`: `$.time.pause()` и `$.time.scale()`
  теперь останавливают и ускоряют анимацию персонажа, а не только твины.
  Интерфейс (экраны, диалоги, виджеты) остался на реальном времени — кнопки
  обязаны работать на паузе.
- **`opts.ignore` у лучей и свипов** наконец принимает узел, обёртку и массив,
  а не только селектор: прежний код прогонял аргумент через `query()`, который
  понимает лишь строку, поэтому `ignore: $('#hero')` молча давал пустой список.
- **`$.world`**: `bodyAt`/`bodiesIn` принимают `opts.mask`,
  `raycastAll` фильтрует узлы по слоям.
- **prefab/save**: `layerBits`, `collisionMask` и `collisionGroup` пишутся и
  читаются через `set()`, поэтому фильтр доезжает и до тела.

---

## Индекс реестра: один проход вместо двадцати (P2 аудита `$`)

### Изменено
- **Индекс реестра в ядре `$`** (`src/highlevel/core.js`): один обход
  `ctx.nodes` на версию реестра строит карты `byTag`/`byClass` и срезы по
  признакам (`ui`, `tr`, `controls`, `anim`, `clip`, `parallax`, `zones`,
  `body`). Раньше каждая подсистема отвечала на вопрос «есть ли в мире мои
  узлы?» собственным проходом по реестру: сводка кэшировалась на версию
  реестра, но в сцене, где узлы рождаются и умирают каждый кадр, версия
  меняется каждый кадр — и десяток подсистем делал десяток полных проходов.
  Срезы — снимки, поэтому обход не ломается от создания и удаления узлов
  внутри него.
- **Тики подсистем ходят по своим срезам, а не по всему миру**:
  `tickAnim`, `tickParticles`, `tickTilemap`, `tickLayers`, `tickTriggers`,
  `tickWidgets`, `tickI18n`, `$.ui._tick`, `animateSprites`, `applyControls`,
  `world.sync` (срез `body` — в сцене без физики прохода больше нет).
- **`query()`**: структурный селектор (`.mob`, `enemy.mob`, `*`) берётся из
  индекса целиком и кэшируется на версию реестра, сложный — идёт по якорю
  (ведущий тег/класс терма), а не по всему миру. Селекторы по полям, которые
  меняются без изменения реестра (`:alive`, `[hp<5]`), по-прежнему считаются
  по узлам, а не по кэшу.
- **Контракт подсистем** (`docs/highlevel/_CONTRACT.md`) переписан на новый
  индекс: `nodesByTag`, `nodesByClass`, `nodesWithFacet`, `facetCount`,
  `liveNodes`. `registrySummary()` остался для собственных признаков модуля.

### Добавлено
- **Юнит-тесты индекса** — `tests/js/registry_test.mjs` (8 проверок):
  совпадение срезов с честным проходом по реестру, снимки, удалённые узлы
  (в том числе внутри `$.batch`), поведение `query` с якорем и с `:alive`.
- **Микрозамер раскладки данных** — `tools/bench_storage.mjs`: массив
  объектов против `Float32Array`-SoA и обычного массива на законе движения
  частицы. Нужен, чтобы решать про пункт 21 плана P2 по числу, а не на глаз
  (вывод — в `docs/HIGH_LEVEL_API_PERF.md` §0.4).
- **§0.4 в `docs/HIGH_LEVEL_API_PERF.md`**: замеры «до/после», таблица правок
  и цена решения (перестройка индекса в сценах массового спавна/удаления).

### Производительность (Debug, стенд `tools/bench_highlevel.py`, логика кадра)
- `$('.mob')` в кадре, 1000 узлов: 4,61 → 2,49 мс; обход среза: 3,27 → 1,37;
  цепной метод: 3,54 → 1,67; `$('#id')`: 1,41 → 0,85.
- 1000 спрайтов: 1,31 → 0,85 мс; 5000 спрайтов: 6,05 → 4,35.
- На 5000 узлов: `$('.mob')` 21,9 → 12,4 мс, обход среза 16,8 → 7,6.
- Цена: сцены массового спавна/удаления (`churn`/`batch`) платят за
  перестройку индекса — +0,25 мс и +0,02 мс на 1000 узлов, на 5000 после
  правок перестройки даже −2 %; удаление без `$.batch` по-прежнему O(K·N),
  и это лечится `$.batch` (пункты 14–15, §0.3). Подробности — §0.4.

## Три демо вместо девяти, скриншоты и CI публикации

### Изменено
- **Демо-проект сокращён до трёх сцен** — по одной на слой движка:
  `platformer` (Box2D и анимация), `shooter_witch` (свет, частицы, волны),
  `russi_vn` (`$.timeline` и интерфейс на RmlUi). Удалены `arena`, `bsp`,
  `gallery`, `light`, `physics`, `shooter25d` вместе со своими `.rml`/`.rcss`:
  они дублировали то, что показывают оставшиеся, и тянули ассеты.
- **Лаунчер** больше не показывает под-сцены: меню «Ведьмы» (`witch_menu`)
  скрыто из списка, а её кнопка ведёт в это меню (в бой — `--scene shooter_witch`).
  Подсказки под кнопками укорочены и больше не вылезают за панель.
- **Скриншоты в README и docs/demos.md** пересняты под три демо и меню;
  старые (удалённых сцен) убраны. Обновляются одной командой —
  `tools/make_screenshots.py` (агентский режим, без рук).
- **У каждой демки в папке — README** «что внутри и как собрать такое же»
  (`demos/platformer/README.md`, `demos/shooter_witch/README.md`,
  `demos/russi_vn/README.md`): правило проекта для всех новых демо.

### Добавлено
- **CI публикации без сборки**: `.gitverse/workflows/publish-dist.yaml` и
  `.gitlab-ci.yml` берут архивы из `dist/` и создают релиз — ни компиляции,
  ни зависимостей. На GitLab релиз делает встроенный `release-cli`
  (авторизация — штатный `CI_JOB_TOKEN`, секретов не нужно); на GitVerse
  нужен `RELEASE_API_KEY`, и без него job не падает, а сообщает, что
  публиковать нечем. Сборка остаётся локальной (`tools/autobuild.py`).

## Визуальная новелла: подсистема `$.timeline` (AnimatedTimelineScene2d)

### Добавлено
- **`$.timeline` — таймлайн-сцены для диалогов и визуальных новелл**
  (`src/highlevel/timeline.js`). Сцена описывается одним массивом «битов»:
  реплика, выбор, смена локации, поза героя, вход/выход, акцент, тряска
  экрана, вспышка, затемнение, наезд камеры, музыка, флаг, ветка `if`,
  метка `goto` и концовка. Текст, печатная машинка и выборы отданы `$.dialog`
  — таймлайн отвечает за то, что происходит вокруг текста.
  Справочник — [docs/highlevel/timeline.md](highlevel/timeline).
- **`$.animatedTimelineScene2d(spec)`** — то же объявление литеральным именем
  типа сцены: возвращает объект управления прогоном.
- **Управление и наблюдаемость**: `$.timeline.play/stop/next/choose/goto/
  location/pose/auto/speed`, события `start/beat/say/choice/location/ending/end`
  (и они же глобально как `timeline:*`), снимок `$.timeline.state()` для
  агента и тестов: локация, поза, ожидание, число битов, флаги, концовка.
- **Концовки**: бит `{ ending }` ставит флаг `ending:<id>` в `$.store`,
  показывает полноэкранную карточку и переживает перезапуск — прогресс новеллы
  не теряется.
- **Демо «Руси-тян: Бака!»** (`demos/russi_vn/`): одна героиня, пять
  локаций, семь поз, дыхание и акценты, три выбора с флагами, две концовки,
  тряска экрана, вспышки, «руси-метр» и авто-режим чтения. Кнопка в меню демо,
  `--scene russi_vn`. Прогресс новеллы лежит в своём файле `demos/vn_save.json`
  и не подмешивается в сохранение основной игры. Инструкция «как собрать такую же новеллу» —
  [demos/russi_vn/README.md](demos/russi_vn).
- **Инструменты**: `tools/make_vn_sprites.py` (альфа, общая рамка поз —
  смена позы не двигает героя), `tools/make_vn_backgrounds.py` (локации
  процедурно + CC0-кадры), `tools/vn_playthrough.py` (агент проходит новеллу
  до концовки и снимает скриншоты ключевых моментов).
- **Реплика может рисоваться RmlUi** (`dialogView`): перенос строк по ширине
  блока, шрифт, рамка и `:hover` — на стороне RmlUi, а печатная машинка,
  выборы и клавиатура остаются за `$.dialog`. Демо-новелла переведена на
  RmlUi целиком: реплика (`demos/ui/vn-dialog.rml`), руси-метр, индикатор
  авто-режима, подсказка и табличка локации (`demos/ui/vn-hud.rml`).
- **Озвучка реплик** (опция `voice`): файл на реплику, имя = id её узла
  (`<dir>/<id>.mp3`), нет файла — реплика идёт молча. Список реплик для записи
  голоса отдаёт `$.timeline.lines()`. Для новеллы подготовлены
  `demos/russi_vn/voice/lines_for_minimax.txt` (49 реплик) и карта `lines.json`.
- **Озвучка новеллы записана и подключена**: 49 реплик Руси-тян (MiniMax,
  `speech-2.8-hd`) разложены по `demos/russi_vn/voice/tlN.mp3`, 3,3 МБ. В
  авто-режиме реплика не перебивается, пока голос звучит.
- **Нарезка озвучки**: `tools/vn_voice_slice.py` — режет сгенерированный файл
  по паузам, сверяет число кусков с числом реплик и раскладывает их как
  `<id>.mp3`; при несовпадении ничего не пишет и показывает, что подкрутить.
  Для записи подготовлены `demos/russi_vn/voice/lines_for_minimax.txt`
  (реплики через паузу `<#2.5#>`) и `lines_clean.txt` (без маркеров).
- **Музыка новеллы**: два трека автора проекта (`vn_tension.mp3`,
  `vn_afternoon.mp3`, из WAV 28 и 29 МБ сжаты в 2,9 МБ) привязаны к локациям.
- **Исправлен перенос строк в диалогах движка**: `$.font.measure` получал от
  `engine.measureText` пару `[ширина, высота]` и прогонял её через `num()` —
  выходило `NaN → 0`, из-за чего `wrapDialogText` считал, что любая строка
  помещается, и реплики уезжали за панель. Форма ответа теперь разбирается
  в одном месте (`measureTextWidth`), у `$.dialog` запасной путь тоже.
- **Тесты**: `tests/js/timeline_test.mjs` — 24 проверки логики модуля: порядок
  битов, паузы, выборы и флаги, ветки `if/else`, метки внутри веток, акценты,
  тряска, концовки, авто-режим, тема панели диалога, состояние прогона.
- **Ассеты**: позы Руси-тян (обработаны из присланных картинок), пять
  локаций — класс и небо взяты кадрами из CC0-работ «Classroom 002» (Midnight68)
  и «40 game backgrounds» (rubberduck), остальные сгенерированы процедурно;
  лицензии — в [demos/assets/CREDITS.md](demos/assets/CREDITS).

### Изменено
- `docs/HIGH_LEVEL_API.md` — `$.timeline` в таблице подсистем после аудита.
- `docs/demos.md` — раздел про новеллу «Руси-тян» и строку в таблице сцен.
- `README.md` — демо «Руси-тян (ВН)» в списке демо-проекта.

## Оптимизация кадра `$`: весь план аудита (P0, P1 и остаток §5)

### Изменено
- **Внедрён P0 из `docs/HIGH_LEVEL_API_PERF.md`** — восемь правок, снимающих
  паразитную работу JS-слоя. Замеры — стенд `tools/bench_highlevel.py`
  (`--repeat 3`, медиана, Debug, та же машина), миллисекунды JS на кадр при
  бюджете 60 FPS 16,7 мс:

  | Сцена | До | После |
  |---|---:|---:|
  | пустая сцена | 0,39 | 0,25 |
  | 1000 статических спрайтов | 39,11 | 12,84 |
  | 1000 динамических тел (Box2D) | 40,61 | 12,87 |
  | 1000 узлов + `$('.mob').each()` каждый кадр | 55,80 | 13,80 |
  | 1000 узлов + один `$('#id')` за кадр | 54,77 | 12,93 |
  | 1000 твинов | 48,21 | 21,80 |
  | 1000 ui-узлов | 33,40 | 10,67 |
  | тайлмап на 5670 тайлов | 14,80 | 14,66 |

  Штатный платформер из `game/` (169 узлов, код игры не менялся): JS-цена кадра
  **65,4 → 3,1 мс**, из них код самой игры 55–58 → 0,5 мс.

  Вторая часть плана (P1) добавила к этому: 1000 спрайтов 12,84 → **8,97 мс**,
  `$('.mob')` в кадре 13,80 → **9,80**, твины 21,80 → **17,57**, тайлмап
  14,66 → **13,35**, пустая сцена 0,25 → **0,18**. Итог против аудита —
  39,11 → 8,97 мс на 1000 спрайтов (в 4,4 раза).

- **P1: сборка батча разгружена** (`src/highlevel/render.js`): список узлов мира
  переиспользуется, компараторы сортировки подняты на уровень модуля, а
  сортировка пропускается, если состав реестра не менялся и массив всё ещё
  неубывающий (проверка O(N) вместо O(N log N)); встроенные теги получают
  переиспользуемый прямоугольник вместо объекта `{x,y,w,h}` на узел;
  `blendId` кэширует режим на одно имя вместо `Map.get` по строке на каждый
  спрайт; список ui-узлов кэширован на версию реестра; обход по индексу.
- **P1: ленивые контейнеры узла** (`core.js`): `listeners`, `data_store` и
  `tags_extra` создаются при первой записи (`node.dataMap()` для хранилища),
  `classes` остался жадным — его читают селекторы в горячем цикле.
- **Обход без обёртки** (`core.js`, `api.js`): `.each((i, el))` остался прежним
  (в `el` обёртка), добавлен `.eachNode((i, node))` — колбэк получает сам узел.
  Все **97 цепных методов ядра** перешли на `eachNode`, поэтому
  `$('.enemy').damage(10)` больше не создаёт обёртку на узел; на 1000 узлов это
  ≈1,3 мс логики кадра (сцены стенда `query` / `chain` / `fast`). `each` и
  `eachNode` объявлены в классе `Wrapper` в ядре — их видят и модули-подсистемы,
  и юнит-тесты qjs без `api.js`. Кэш обёртки в узле не делается сознательно:
  поле `_wrapper` замыкает цикл «узел → обёртка → узел» и ломает
  `JSON.stringify` узла (агент, `$.store`).
- **`$.batch(fn)`** (`core.js`, `api.js`): массовый спавн и удаление одним
  вызовом. Внутри пакета `destroy()` и возврат в пул только помечают узел, а
  реестр чистится одной компактификацией — K удалений стоят O(K + N) вместо
  O(K·N); вложенные пакеты дают одну уборку. Селекторы и списки отрисовки не
  находят помеченные узлы. Замер: 500 спавнов + 500 удалений за кадр при 5000
  узлах — 153,8 → **117,2 мс** кадра (логика 96,6 → 60,4).
- **Пул переиспользует тело** (`pool.js`, `src/physics.c/h`, `src/script.c`):
  на `release` тело не уничтожается, а выключается (`b2Body_Disable`), на
  `spawn` включается обратно — вместо `destroyBody` + `createBody` на каждый
  выстрел. Новые биндинги `engine.setBodyEnabled(body, on)` и
  `engine.bodyEnabled(body)`.
- **Стенд** (`tests/fixtures/bench`, `tools/bench_highlevel.py`): новые сцены
  `chain` (цепной метод ядра), `fast` (`.eachNode`), `churn` и `batch` (пачка
  спавна/удаления за кадр) — пункты 12, 14 и 15 стали измеримыми; в сцене
  `query` исправлен колбэк `.each((el) => …)` → `.each((i, el) => …)` (`el` был
  индексом, и сцена ничего не двигала).
- **Демки**: `arena` (отжившие трассеры), `physics` (снос/постройка уровня,
  пачки ящиков) и `shooter_witch` (гибель зомби) ходят через `$.batch`;
  примеры `.each(e => …)` в `docs/HIGH_LEVEL_API.md` и `docs/ARCHITECTURE.md`
  исправлены на рабочие `(i, e)`.
- **P1: мелочи кадра**: `Array.from(machines)` в `state.js` → переиспользуемый
  массив; `ctx.nodes.indexOf` в шаге тайлмапа → флаг `node.in_registry`;
  `rectOf` в триггерах пишет в два переиспользуемых прямоугольника;
  `state.target` в акустике переиспользуется; `engine.contacts()` отдаёт
  `JS_NULL`, когда событий нет (пустой массив в C на каждый кадр); ранние
  выходы `tickEffects` (счётчик активных эффектов) и `tickI18n` (счётчик узлов
  с `attrs.tr`).

- **`worldEvents` без `Map` по числовому ключу** (`src/highlevel/world.js`):
  прошлые `hp`/`visible` узла хранятся в его же полях `_hp_seen`/`_vis_seen`.
  Числовой ключ в `Map` в QuickJS стоит ≈6,2 мкс, и четыре такие операции на
  узел съедали 20 мс кадра из 38; карты прошлых значений и их ленивая чистка
  удалены — данные умирают вместе с узлом.
- **Селекторы компилируются один раз на вызов** (`src/highlevel/core.js`):
  строка разбирается в замыкание-предикат (`compileSelector`, кэш на 512 строк),
  а не пятью регулярками **на каждом узле**; у `'#id'` — быстрый путь через
  `ctx.byId`; `Set` для уникализации создаётся только для селекторов с запятой.
  Один `$('#mob42')` при 1000 узлах: 14,2 → ≈0,01 мс; `$('.mob')`: 15,4 → ≈0,4 мс.
  `registerSelector` сбрасывает кэш разобранных селекторов.
- **Версия реестра и сводки подсистем** (`core.js`: `touchRegistry()`,
  `registrySummary()`, `countUiNodes()`): подсистема выходит на первой строке,
  если её узлов в мире нет, а сводка пересчитывается только при изменении
  реестра. Ранние выходы получили `tickWidgets`, `$.ui._tick`, `tickTriggers`,
  `tickLayers`, `tickParticles`, `tickTilemap`, `tickAnim`, `animateSprites` и
  `applyControls` — вместо ≈20 полных проходов по `ctx.nodes` за кадр.
  Контракт для авторов подсистем — `docs/highlevel/_CONTRACT.md` §2.
- **`collectCounters()` убран из кадра** (`pool.js`): снимок считался каждый
  кадр и клался в `ctx.counters`, который никто не читал; `$.debug.counters()`
  считает по запросу, как и раньше.
- **`emit`/`dispatchGlobal` выходят рано** (`core.js`, `api.js`): если
  глобальных подписок нет, объект события, три обёртки `$('*')` и строки
  `'entity:…'` не строятся.
- **Профайлер подсистем выключен по умолчанию и больше не врёт** (`api.js`,
  `debug.js`): метка ставится ПЕРЕД своим отрезком, поэтому ключ отчёта —
  это имя измеренного кода (раньше отчёт был сдвинут на одну подсистему:
  «анимация» мерила `tickAnimPlayer`, «акустика» — `$.ui._tick`). Отрезок кода
  игры переименован `«окно»` → `«логика игры»`. Включается
  `$.debug.profiler.on(true)`, состояние — `$.debug.profiler.isOn()`.
- **`tickWindow`** не читает состояние окна (6 вызовов C за кадр), пока на
  события окна никто не подписан; снимок для сравнения берётся в момент
  подписки, поэтому ложных `resize`/`focus` на первом кадре нет.
- **`destroy()` чистит `ctx.byId`** (`core.js`) — быстрый путь поиска по id не
  отдаёт удалённый узел; раньше запись жила до ленивого свипа в
  `prefab.js`/`save.js`/`scene.js`.

### Проверено
- `python3 tools/run_tests.py` — 34 теста, ok 34, fail 0, skip 0.
- `tests/js/*_test.mjs` (44 файла, qjs) — зелёные; тест кадрового шага пула
  обновлён под новое поведение (`tickPool` больше не пишет `ctx.counters`).
- Строгий A/B: эталонный и оптимизированный бинарники собраны из одного дерева
  и прогнаны перекрёстно (`--binary`, `--repeat 3`).

## Проверка движка и высокоуровневого API, свет в стиле Candle, новые подсистемы

### Добавлено
- **Свет в стиле Candle** (`src/highlevel/render.js`): тени внутри `<light>`
  (`.shadows(true)` — лучи из центра, каждый упирается в препятствие, по этим
  расстояниям строится веер: градиент мягкий, кромка тени резкая), конус
  (`.cone(deg, soft)`, направление — угол узла), мерцание
  (`.flicker(amount, speed)`), площадной источник `<lightarea>` (несколько
  точек вдоль полосы, у каждой свой контур тени) и туман — узел `<fog>` и
  экранный слой `$.gfx.fog({...})`. Реестр препятствий:
  `$.gfx.light.occluders/addOccluders/clearOccluders/count/segments/tiles/
  polygon/debug`, у узла — `.occluders([...])` и `.punch(true)` (свет поверх
  тумана). Документация — `docs/highlevel/render.md` §3.0.
- **GPU-время в профайлере и таблица зон в оверлее F1**: в SDL3 нет
  timestamp-запросов у SDL_GPU (проверено на 3.4.16 и main), поэтому кадр
  меряется по fence — от `SubmitGPUCommandBufferAndAcquireFence` до сигнала,
  опрос в отдельном потоке, выборка со сдвигом 1–2 кадра, кадр не блокируется.
  Строка «GPU: кадр» попадает в `r2d_prof_rows()`, в окно F1 (таблица «зона /
  среднее / пик / доля %», итог кадра, неучтённый остаток, кнопка сброса) и в
  `engine.profile()` (`gpu_ms`, `gpu_available`, `gpu_frames`, `rows[].gpu`).
  Без поддержки — «н/д» без ассертов.
- **Новые подсистемы `$`** (каждая — свой файл `src/highlevel/<имя>.js` и своя
  документация `docs/highlevel/<имя>.md`): `$.save` (слоты, версии, миграции,
  автосейв, экспорт строкой), `$.resource` (реестр ресурсов с ленивой загрузкой
  и счётчиком ссылок), `$.math`, `$.random` (детерминированный ГПСЧ и шум),
  `$.grid`, `$.csv` (+ безопасный JSON), `$.signal`, `$.state` (FSM, методы
  узла `.fsm()`/`.fsmSend()`), `$.flow`, `$.anim.player` (таймлайны, события,
  микширование клипов), `$.dialog`, `$.screen`, `$.font`.
- **Русские имена API** (`src/highlevel/ru.js`, `$.ru`): русские теги, атрибуты
  конструктора, методы узлов и пространства имён — ссылками на существующие
  (`$.мир === $.world`, `.цвет()` — та же функция, что `.color()`). Узел
  создаётся с каноническим тегом, поэтому отрисовка, селекторы и агентский
  снимок видят обычный `<light>`. Свои псевдонимы — `$.aliasTag('камень',
  'wall')`. Документация — `docs/highlevel/ru.md`.
- **Аудит производительности `$`** — `docs/HIGH_LEVEL_API_PERF.md`: замеры зон
  кадра на стенде `tests/fixtures/bench/` (1000 узлов = 38,5 мс JS при бюджете
  16,7 мс; GPU и Box2D при этом свободны), разбор причин (`worldEvents` — 20 мс
  из-за `Map` по числовому ключу в QuickJS, селекторы — O(N) на вызов с разбором
  строки на каждом узле, ~20 полных проходов по реестру за кадр, аллокации
  обёрток) и приоритизированный план правок с оценкой эффекта (38,5 → 6–8 мс).
  Отдельно измерена штатная игра: платформер из `game/` тратит 55 мс кадра на
  169 узлах, потому что `update` сцены зовёт `$('#hero')`, `$('.walker')` и
  `distanceTo('#hero')` внутри `each` — быстрый путь для `#id` и компиляция
  селектора убирают это без правок в игре.
  Там же — проверка полноты API: 138 из 168 биндингов `engine.*` обёрнуто,
  перечислено, что осталось за бортом. Инструмент — `tools/bench_highlevel.py`
  (агентский режим, `--fixed-dt`, замеры не зависят от vsync), тест —
  `tests/agent/highlevel_perf_test.py`.

### Исправлено
- **`$.fs.remove()` врал об успехе**: `SDL_RemovePath` в SDL 3.4.16 сообщает
  успех и для несуществующего пути (проверено отдельной C-пробой: missing →
  `true`, ошибка только на запрет доступа), поэтому `$.fs.remove('нет-файла')`
  возвращал `true`, а `$.save.remove(слот)` «удалял» пустой слот. Теперь
  существование проверяется до удаления — «удалил» и «и так нет» различимы.
- **Первый пример справочника падал**: `.speed(250)` не существовал как метод
  обёртки (только атрибут конструктора) — добавлен.
- **Событие `'collision'` из документации не срабатывало**: движок шлёт
  `'collide'` — имена-псевдонимы (`collision`/`contact` → `collide`,
  `separation` → `separate`) приведены в `core.js`.
- **Умолчания скорости тегов не доходили до управления**: `<enemy>` с
  `.controls()` ехал 150 вместо 90 — `TAGS.speed` теперь применяется в
  конструкторе узла.
- **`$.pool.tickPool()` и `$.input.rumble()` отсутствовали**, хотя упомянуты в
  документации: первый выставлен на пространстве имён, второй стал честной
  заглушкой с одним предупреждением (движок не пробрасывает
  `SDL_RumbleGamepad`).
- **`build_test` валился на macOS**: тест полагался на платформенное умолчание
  шифрования груза (на macOS оно выключено — файл переподписывают `codesign`).
  Теперь гарантия проверяется явным `--encrypt`, а умолчание — отдельной
  проверкой с учётом платформы.
- **Примеры в документации, которые не собирались**: Promise-цепочки
  `rotateTo().scaleTo().fadeTo()` и `$.fn.flashAndDie`, `$('<label>')` вместо
  `<ui.label>`, `t.chain().interval().callback()`, `$.attr()`, `EASES`,
  `$('<weapon>')`, `.attr('x')` против `.pos()`, `$.world.count(sel)`,
  потолок `max_particles`, `gap` у `<ui.scroll>`.
- **Список занятых имён в `docs/highlevel/_CONTRACT.md` отставал на 86 имён** —
  перевыпущен с живого движка (227 имён) и снабжён командой для обновления.

### Проверено
- `cmake --build build -j8` — без новых ошибок; 5 C-тестов (`json`, `crypto`,
  `payload`, `reverb`, `profile`) и 43 qjs-теста подсистем — зелёные.
- Новые агентские тесты: `highlevel_light_test` (тени/конус/площадной свет/
  туман — по пикселям двух скриншотов), `highlevel_ru_test` (все 32 русских
  тега), `highlevel_docsapi_test` (обещания справочника), плюс тесты подсистем.
- Аудит `docs/HIGH_LEVEL_API.md` ↔ `src/highlevel/*.js`: 30 тегов и 217 цепных
  методов сверены с рантаймом; расхождения вынесены в отчёт и исправлены
  (см. «Исправлено»).

## Экран загрузки в высокоуровневом API

- `$.loading.show/progress/label/title/hide/visible/value` и `$.loading.run(steps, done)` —
  полноэкранная панель с полосой прогресса; шаги выполняются по одному за кадр
  (`src/highlevel/loading.js`, документация — `docs/highlevel/loading.md`).
- Демо «Ведьма» использует его на переходе меню → игра.

## Меню и сцены демо «Ведьма»

- Меню — отдельная сцена `witch_menu` (арт, название, Играть/Настройки/Выход,
  музыка `menu_whimsy.mp3`), игра — сцена `shooter_witch` (трек `zombie_plinko.mp3`).
  Переход идёт через `$.scene.load`, в бою Esc или P возвращает в меню.
- Иконки пунктов меню вынесены отдельными метками (`$.ui.setIcon`) — строка
  больше не «ездит» относительно центра кнопки.


Все заметные изменения проекта Russiano2D.
Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
версии — [семантическое версионирование](https://semver.org/lang/ru/).

## [Unreleased]

Раздел для изменений, которые уже лежат в рабочем дереве, но на момент
составления файла ещё не попали в историю коммитов. При выпуске версии
записи переезжают в соответствующий раздел.

### Добавлено
- **Профайлер кадра в движке** (`src/profile.c`, `$.debug.profile()`): зоны
  измеряются монотонным счётчиком SDL — JS-логика, сборка батча, физика,
  ожидание swapchain, загрузка VB/IB, draw-команды, интерфейс, прочее; плюс
  `frame_ms`, `unaccounted_ms` (реальное время минус сумма зон) и пики за
  120 кадров. Внутрикадровые замеры на JS переведены на новый `engine.now()`
  (монотонные миллисекунды): `engine.time` идёт шагами по кадру и для этого
  не годится. Подсистемы `$` размечены и видны в `$.debug.profiler.report()`.
- **DOOM-стайл HUD** в демо «Ведьма»: нижняя панель с лицом героини (лист
  4 выражения × 6 состояний здоровья), числом и полосой HP, иконкой и
  названием оружия, полосой опыта и счётом.

### Исправлено
- **Подсистемы `$` тикали дважды за кадр**: при разметке профайлера блок
  тиков был вставлен второй копией — теперь один (это стоило ~6 мс на кадр).
- **Кровь в демо лилась непрерывно**: у эмиттеров не было `one_shot`/`rate: 0`,
  восемь эмиттеров сыпали по 58 частиц в секунду каждый ещё до первого
  выстрела — теперь только залпом.
- **Мировой слой рисовался в экранных координатах**: `$.fx.*` (ленты, молнии,
  ударные волны, вспышки, поля сил) и `$.gfx.draw.*` передают координаты в
  мире, а батч движка принимает экранные — в итоге весь VFX уезжал на
  расстояние камеры: трассеры летели «не из персонажа», а вспышки и пульсы
  появлялись в углу экрана. Добавлен перевод world → screen
  (`setView`, включается на время `_fxFlush` и внутри `$.gfx.draw.*`), плюс
  масштабирование толщин и радиусов. Демо `light` с полигонами видимости это
  же и лечило: свет больше не «ходит за камерой».
- **Режим смешивания `add` игнорировал альфу вершины**: факторы были
  `ONE, ONE`, поэтому полупрозрачное свечение, вспышка или трассер били в
  полную яркость — свет фонарей превращался в белые круги, а альфа вершин в
  аддитивном режиме не работала вообще. Теперь `SRC_ALPHA, ONE` (и
  `ZERO, ONE` для альфа-канала): альфа честно ослабляет вклад.

### Добавлено
- **Мягкое свечение как примитив движка**: `$.gfx.draw.glow(x, y, radius, color,
  { segments, rings, falloff, inner, blend })` — радиальный градиент с цветом и
  альфой на каждой вершине. Узел `<light>` переписан на него: параметры
  `radius`, `intensity`, `color`, `falloff`, `inner`, `segments`, `rings` и
  `.blend('add')`; градиент гладкий на любом радиусе (раньше на больших
  радиусах были видны кольца).
- **Релизная сборка** (`build-release`, `-DCMAKE_BUILD_TYPE=Release`): демо в
  Debug шло 8 кадров/с, в Release — втрое быстрее.
- **Камерные пресеты пост-обработки** (`$.gfx.postPreset`): `adventure`
  (тёплый мультик), `forest_night`, `horror` (почти ч/б, зерно, кровь по
  краям), `bloodmoon`, `retro`, `noir`, `dream`. Переключение плавное
  (`{ ms }`), поверх пресета можно точечно менять поля через `$.gfx.post()`.
  В шейдер добавлены цветокоррекция (насыщенность, контраст, яркость, оттенок),
  постеризация и «кровавая» вигнетка.
- **Глушение звука в чаще** (`$.audio.obstacles`, `$.audio.damping`,
  `$.audio.densityAt`): каждое препятствие (дерево, колонна) рядом с линией
  «слушатель → источник» добавляет глухости и опускает срез фильтра — в
  плотном лесу выстрел звучит глухо, на поляне чисто. Материал `grass` для
  акустических зон под открытый воздух.
- **HUD демо «Ведьма»**: строка счётчика кадра (FPS, мс, спрайты, тела, звуки,
  трассеры, статистика VFX по `$.fx.stats()`), трассеры выстрелов рисуются от
  дула оружия (`muzzle` в описании оружия) и уходят под спрайт героя.
- **Демо «Ведьма» переделано в ночной лес**: земля из тайлсета с автотайлом
  тропы (террейн `path`), деревья из CC0-набора Kenney с коллизиями и тенями,
  фонари — единственный источник света (плюс слабая свеча игрока), волны зомби,
  опыт, карты апгрейдов, кровь и лужи. Ассеты собираются скриптом
  `tools/make_forest_assets.py` из исходников Kenney (RPG pack и RPG Urban
  pack, оба CC0), лицензии лежат рядом.
- **Суб-эмиттеры частиц** (`on_death`): когда частица умирает, из её точки бьёт
  залп другого эффекта — искры догорают в дым, дым оседает пеплом. Вложенный
  эмиттер один на родителя (не по узлу на частицу) и ровно одного уровня
  вложенности, чтобы не было бесконечной цепочки.
- **Пост-обработка кадра** (`$.gfx.post`): сцена рисуется в offscreen-текстуру,
  а на экран её накладывает полноэкранный проход `shaders/post.*` — свечение,
  вигнетка, хроматическая аберрация, зерно, скан-линии и линза (экранное
  искажение UV по полю `~1/r²`, основа «чёрной дыры» и взрывной волны).
  Интерфейс рисуется после обработки (`engine.markUI()`), поэтому HUD остаётся
  чистым. См. `docs/highlevel/render.md` §3.1.
- **VFX своими руками** (`$`-модуль `fx`, `src/highlevel/fx.js`): ленты и трассеры,
  молнии с ветвями, ударные волны, вспышки в точке, поля сил (аттрактор и вихрь)
  для частиц, hit-stop и готовый `$.fx.impact`. Никаких сторонних библиотек —
  всё рисуется батчем `$.gfx.push`. См. `docs/highlevel/fx.md`.
- **Демо «Ведьма»** (`demos/shooter_witch`): top-down шутер, который показывает
  акустику комнат (зал 20×20, кладовка 5×5, коридор), три оружия с разным pitch,
  «чёрную дыру» на полях сил, окклюзию звука за стеной и мягкий свет игрока.
- **Акустика помещений.** Реверберация (Freeverb, public domain) на последнем
  шаге микшера плюс модель комнат: `$.audio.zone(name, { rect, height,
  material })`, `$.audio.room(...)`, `$.audio.listener(...)`,
  `$.audio.acoustics(flag)`, `$.audio.occlusion(flag)`. Параметры считаются по
  формуле Сабина (RT60) и плавно едут между зонами, поэтому выстрел в комнате
  5×5 и в зале 20×20 звучит по-разному. См. `docs/highlevel/audiobus.md` §8.
- **Настоящие аудио-шины**: каждая шина `$.audio.bus(...)` заводит группу
  SDL_mixer (`MIX_CreateGroup`), каналы приписываются к ней
  (`MIX_SetTrackGroup`), а эффект шины обрабатывает пост-микс группы
  (`MIX_SetGroupPostMixCallback`) — то есть действует и на звуки, запущенные
  позже.
- **3D-позиция звука**: `$.audio.spatial('sdl')` отдаёт координаты источников
  SDL_mixer (`MIX_SetTrack3DPosition`); слушатель у него всегда в нуле, поэтому
  передаются координаты относительно слушателя. По умолчанию остаётся
  JS-панорама (`$.audio.spatial('js')`).
- **Pitch звука и музыки**: `$.sound.play(..., { pitch })`,
  `$.sound.music(..., { pitch })`, `$.sound.musicPitch(v)` —
  `MIX_SetTrackFrequencyRatio` в движке.
- **Окклюзия и живое позиционирование**: источник за стеной глушится фильтром,
  `$.sound.playAt` ведёт панораму и громкость, пока звук играет;
  `$.audio.listener(...)` принимает узел или селектор, а не только координаты.
- `$`-модуль `i18n`: словари и подстановка строк интерфейса.
- `$`-модуль `pool`: переиспользуемые объекты без мусора в кадре.
- `$`-модуль `triggers`: зоны-триггеры со входом, выходом и повторным срабатыванием.
- Юнит-тесты под `qjs`: `i18n_test.mjs`, `triggers_test.mjs`,
  `tween_test.mjs`, `input_deadzone_test.mjs`, `sprite_size_test.mjs`,
  `sound_test.mjs`, `color_test.mjs`, `scene_test.mjs`, `camera_test.mjs`,
  `window_test.mjs`, `ui_test.mjs`, `debug_test.mjs`, `ctor_attrs_test.mjs`
  (грабли конструктора узла: `{ src }`, `{ frames }`, чтение свойств через `.attr()`),
  `acoustics_test.mjs` (RT60, материалы, зоны), `fx_test.mjs` (времена жизни
  эффектов, лента за целью, поля сил, отсечение по камере).
- C-тест `r2d_reverb_test`: хвост реверберации затухает в маленькой комнате и
  держится в зале (это и есть разница «5×5 против 20×20»).
- C-тест `r2d_payload_test`: разбор контейнера груза под ASan/UBSan (двойное
  освобождение буфера и выход за границы на битом грузе).

### Добавлено
- **Профайлер кадра в движке** (`src/profile.c`, `$.debug.profile()`): зоны
  измеряются монотонным счётчиком SDL — JS-логика, сборка батча, физика,
  ожидание swapchain, загрузка VB/IB, draw-команды, интерфейс, прочее; плюс
  `frame_ms`, `unaccounted_ms` (реальное время минус сумма зон) и пики за
  120 кадров. Внутрикадровые замеры на JS переведены на новый `engine.now()`
  (монотонные миллисекунды): `engine.time` идёт шагами по кадру и для этого
  не годится. Подсистемы `$` размечены и видны в `$.debug.profiler.report()`.
- **DOOM-стайл HUD** в демо «Ведьма»: нижняя панель с лицом героини (лист
  4 выражения × 6 состояний здоровья), числом и полосой HP, иконкой и
  названием оружия, полосой опыта и счётом.

### Исправлено
- Мёртвая зона аналогового ввода в `$`-модуле `input`.
- Уточнения в расчёте времени и твинов (`time`, `tween`).
- `.sprite('картинка.png')` больше не оставляет узел 32×32: размер кадра
  запоминается при создании спрайта, и узел с шириной по умолчанию
  подхватывает размер текстуры (раньше `spriteSize()` всегда возвращал `[0,0]`).
- `$.sound.mute(false)` возвращает прежнюю громкость, а не жёстко 1.0:
  уровень, выставленный `$.sound.volume(...)`, больше не теряется.
- `.alpha()` снова действует на `ui.panel`, `ui.button`, `ui.image` и фон
  `ui.bar`: `packColor()` больше не отбрасывает альфу у цвета-числа.
- `$.scene.load(name, { keepUI: true })` сохраняет интерфейс и при переходе
  с затемнением — `_tick()` больше не затирает опции загрузки.
- Исключение в обработчике `$.window.on()` больше не валит кадр: в `fire()`
  вызывался несуществующий `ctx.reportError`, и собственный `catch` падал.
- `$.camera.follow(узел)` и `follow(обёртка)` работают, а не молча ничего не
  делают (раньше принимались только селектор и индекс).
- `$.layers.fadeTo()` уважает `$.time.pause()` и масштаб времени вместо
  `rawDelta()`.
- Счётчик `tweens` в `$.debug.stats()` и `$.debug.counters()` больше не 0.
- `$.ui.doc(path).unload()` убирает документ из кэша обёрток.
- `$.i18n` в режиме `auto` не держит уничтоженные узлы: `WeakSet` вместо `Set`.
- Груз игры: битый контейнер больше не освобождает буфер вызывающего
  (двойное освобождение кучи) и не читает за границей буфера из-за
  переполнения проверки `offset + size` в футере.
- `--screenshot`: автоматический снимок делается один раз, а не каждый кадр
  (раньше после `--screenshot-at` кадр снимался и записывался на каждом кадре).
- HTTP (встроенный сокетный бэкенд): chunked-ответ больше не обрезается по
  первому нулевому байту, разбор статуса не выходит за буфер, у ответа есть
  предел размера, а CR/LF в запросе отклоняются (request splitting).
- Эхо в `$.audio` больше не пишет за границу буфера на источниках с более
  чем двумя каналами (5.1): линия задержки индексируется по двум каналам.
- `russiano2d build`: переподпись на macOS идёт через `posix_spawn`, а не через
  `system()` — путь из `--out` больше не может выполнить команду оболочки.
- Курсор окна кэшируется (не течёт при частых сменах), логический размер окна
  обновляется при ресайзе, а слоты RmlUi-документов переиспользуются после
  `unload()` — UI больше не умирает после 64 циклов открыть/закрыть.
- Агентский протокол: команда длиннее 64 КиБ больше не разрезается на две
  (одна строка — один ответ), короткая запись ответа досылается до конца.
- Сборка с `-DR2D_ENABLE_IMGUI=OFF` снова линкуется, и без ImGui очередь
  текста очищается каждый кадр.
- Приведение `double` к `int` в JSON и манифесте проекта ограничивается по
  диапазону: `1e300` из команды агента больше не даёт UB.
- Разбор заданий QuickJS: исключение из асинхронного задания забирается и
  логируется, а не остаётся висеть в контексте.
- `$('<sprite>', { src: 'hero.png' })` грузит текстуру, как `.sprite()`:
  раньше `src` оседал в `attrs` и картинка молча не появлялась. То же для
  `{ frames: { src, cols, rows, cw, ch } }` — лист больше не теряется.
- `.attr('id')`, `.attr('hp')`, `.attr('x')` читают свойства узла, а не только
  `attrs` (раньше возвращали `undefined`, хотя `.attr('src')` работал).
  Также `.attr('class')`/`.attr('tag')` отдают добавленные метки.
- `.tag('friendly')` добавляет тег, а не класс: `.tag()` был псевдонимом
  `.addClass()`, хотя документирован как тег.
- `.sprite(path)` и `.frames({ src })` синхронизируют `attrs.src`, поэтому
  `.sprite('tile.png').region(...)` действительно вырезает область.
- `$.world.raycast` проходит сквозь сенсоры: зона-триггер больше не блокирует
  линию видимости и проверку «стою на земле» (Box2D v3 не исключает сенсоры
  сам — теперь они отсеиваются в колбэке луча; начальное перекрытие при этом
  игнорируется, как во встроенном `b2RayCastClosestFcn`, иначе `onFloor()` и
  `onWall()` ловили бы собственное тело узла).
- `$.world.raycast`/`raycastAll`/`lineOfSight` принимают точку и массивом
  `[x, y]` — как `$.sound.playAt` и `$.gfx.draw.*`. Раньше массив молча
  превращался в (0, 0), и луч уходил из угла мира.
- `engine.createBody(null)` и `engine.createJoint(null)` бросают TypeError
  вместо «успеха» с висящим в контексте исключением.
- Ошибка загрузки модуля (не встроен / не прочитан) доходит до JS: раньше
  описательное исключение тут же забиралось из контекста, и игрок видел
  исключение без значения. `last_error` при провале загрузки `$` больше не
  подменяется строкой «неизвестная ошибка».
- `russiano2d build --add <файл>` действительно добавляет файл; при
  переполнении списка файлов прочитанные данные освобождаются; рантайм
  QuickJS освобождается, если не удалось создать контекст.
- Мышь: кнопка вне диапазона (0, отрицательная, больше X2) больше не даёт
  undefined behavior в `SDL_BUTTON_MASK` и не читает чужой бит.
- GUI: дескрипторы файлов из груза отличаются явным набором объектов, а не
  чтением поля внутри чужого `FILE*` через `reinterpret_cast`; при горячей
  перезагрузке документы RmlUi снимаются вместе с JS-контекстом, поэтому
  старые слушатели не вызывают чужой обработчик.
- Утечки: пайплайны и сэмплер при неудачной инициализации рендера,
  transfer-буфер снимка при ошибке отправки командного буфера, `base_path`
  окна при завершении, расшифрованный груз в агентском режиме.
- `$.debug.profiler` копит статистику между кадрами: `start()` каждый кадр
  больше не обнуляет `total`/`calls`/`max`, поэтому `report()` показывает
  прогон целиком, а не последний кадр.
- `$.world.raycast(..., { ignore })` работает: раньше `query(...).toArray()`
  падал с TypeError (query возвращает массив), а теперь список игнорируемых
  тел уходит в движок, и луч ищет следующее препятствие за ними.
  `engine.raycast` принимает необязательный массив id тел для пропуска.
- HTTP: CMake действительно определяет `R2D_HTTP_WITH_CURL` — libcurl
  линковался, но не компилировался, поэтому `$.http` молча терял https и
  редиректы, обещанные в README.
- Длительность звука снова в секундах: `MIX_GetAudioDuration` отдаёт
  сэмпл-кадры, а не миллисекунды, поэтому файл на 74 с показывал 3274 с.
- Демо `light`, `bsp` и `shooter25d`: кнопки RmlUi снова работают —
  `$.ui.doc().on()` подписывает конкретный элемент, а не документ целиком,
  поэтому обработчики повешены на каждый id.
- Демо `shooter25d`: стрелки вверх/вниз больше не дают двойную скорость
  (их уже собирал `$.input.vec`), ЛКМ действительно стреляет, а подпись
  прицела стала `ui.label` и не уезжает вместе с камерой.
- Игра: касание врага наносит урон один раз за кадр, а не по 10 HP с каждого
  врага; в демо физики искры не превышают лимит.
- Тесты: `demos_test` проверяет слушателей кнопок RmlUi; убраны всегда-истинные
  проверки в `agent_protocol_test` и `highlevel_physics_test`; в CI job релиза
  windows-артефакт стал необязательной зависимостью.
- Тряска камеры, узлов и тайлов берёт случайные числа из отдельного
  seeded-генератора, а не из `Math.random()`: при `--seed` и `--fixed-dt`
  прогон снова даёт одинаковые кадры.
- `$.scene.load(name, { ms: 0 })` переключает сцену мгновенно: раньше ноль
  считался «значением по умолчанию» и тихо превращался в 300 мс.
- `$.pool`: свободные (предсозданные) узлы больше не держат физических тел —
  раньше каждый такой узел висел в мире в точке (0,0) и сталкивался, хотя его
  не было ни в `ctx.nodes`, ни в счётчиках.
- `$.tilemap.tileSize()` после первого рендера режет лист заново: вычисленные
  `cols`/`rows` больше не принимаются за явно заданные.
- `$.prefab.save()` не падает на циклическом массиве в `attrs`/`data_store`:
  `sanitize()` охраняет и массивы, а не только объекты.
- `$.anim.stateMachine()` при повторном объявлении снимает слушателей прошлой
  машины (раньше обработчики копились), а `eventsBetween()` возвращает все
  повторы события, попавшие в интервал, — при лаге кадра события циклов
  больше не теряются.
- Циклический `extends` у тем `$.ui` (a→b→a) больше не уводит `resolveTheme`
  в бесконечную рекурсию: цикл рвётся пустой темой и предупреждением.
- `$.tween`: `.delay()` действует и на мгновенный property-твинер — значение
  откатывается и применяется после задержки, а не сразу.

## [0.1.0] — 2026-10-05

Первый публичный релиз движка: ядро на C11, игровая логика на JavaScript,
точка входа — объект `$`. Репозиторий и релизы — на hub.mos.ru. Лицензия авторская: свободное
использование, изменение и распространение (см. `LICENSE`).

### Добавлено
- **Ядро движка.** Окно, ввод, тайминги и GPU-устройство на SDL3 + SDL_GPU
  (Vulkan / Metal / DirectX 12); пакетная отрисовка спрайтов и текстур
  (`src/render.c`), текстовые подсказки поверх сцены (`src/text.c`).
- **Скриптовый слой.** QuickJS-ng 0.10, ES2023 и ES-модули, объект `engine`
  для низкоуровневых вызовов; hot reload `.js` без перезапуска.
- **Высокоуровневое API `$`.** Одна точка входа для игровой логики;
  высокоуровневые модули встраиваются в бинарник и работают и в релизной
  сборке, и без единого `.js` рядом.
- **Физика.** Обёртка над Box2D v3.1: тела, формы, лучи и запросы
  (`src/physics.c`).
- **2D BSP-дерево.** Своя реализация упорядочивания отрезков без z-буфера
  (`src/bsp.c`), демо на произвольной геометрии.
- **2D-свет и тени.** Полигоны видимости на `trylock/visibility` с C-обёрткой
  (`src/light.cpp`), API света и треугольников.
- **Звук и музыка.** SDL3_mixer 3.2: WAV, OGG и MP3, каналы, петли,
  затухания и стриминг музыки.
- **Игровой GUI.** RmlUi 6.3: HUD, меню и инвентарь на HTML/CSS-подобной
  разметке.
- **Отладочный оверлей.** Dear ImGui (ветка docking) — включается по F1.
- **Иконки.** Шрифт Material Design Icons (2235 иконок) встроен в бинарник,
  доступен из скриптов и разметки.
- **Протокол агента.** Режим `--agent`: команды JSON на stdin, ответы на
  stdout; виртуальный ввод, `eval`, снимок состояния, скриншоты, перезапуск.
  Клиент — `tools/agent_client.py`, описание — `docs/AGENT_API.md`.
- **Сборка игры в один файл.** Команда `build` компилирует скрипты в байткод
  QuickJS, упаковывает проект в контейнер и шифрует груз
  ChaCha20-Poly1305 (RFC 8439); режимы `--append` и `--relink`,
  переподпись на macOS, защита от подмены. См. `docs/BUILD.md`.
- **Аудит `$` и восемь подсистем.** Анимация, TileMap, частицы, навигация,
  prefab, аудио-шины, слои и UI-контролы — с документацией и тестами
  (`docs/highlevel/`, `tests/js/`).
- **Демо.** Меню-лаунчер и семь демо: `gallery`, `platformer`, `physics`,
  `bsp`, `light`, `arena`, `shooter25d` (`demos/`, `docs/demos.md`).
- **Тесты.** Агентские тесты `tests/agent/*_test.py` под раннером
  `tools/run_tests.py` (исходы `ok` / `fail` / `skip`), C-тесты разбора JSON и
  ChaCha20-Poly1305 с санитайзерами, юнит-тесты логики `$` под `qjs`.
- **Документация.** `README.md`, `docs/internal/NATIVE.md`, `docs/HIGH_LEVEL_API.md`,
  `docs/AGENT_API.md`, `docs/BUILD.md`, `docs/GAP_ANALYSIS.md`,
  туториалы по платформеру и меню.

### Изменено
- Движок доведён до рабочего состояния: устранены падения на командах агента,
  добавлены недостающие проверки и документация.

### Добавлено
- **Профайлер кадра в движке** (`src/profile.c`, `$.debug.profile()`): зоны
  измеряются монотонным счётчиком SDL — JS-логика, сборка батча, физика,
  ожидание swapchain, загрузка VB/IB, draw-команды, интерфейс, прочее; плюс
  `frame_ms`, `unaccounted_ms` (реальное время минус сумма зон) и пики за
  120 кадров. Внутрикадровые замеры на JS переведены на новый `engine.now()`
  (монотонные миллисекунды): `engine.time` идёт шагами по кадру и для этого
  не годится. Подсистемы `$` размечены и видны в `$.debug.profiler.report()`.
- **DOOM-стайл HUD** в демо «Ведьма»: нижняя панель с лицом героини (лист
  4 выражения × 6 состояний здоровья), числом и полосой HP, иконкой и
  названием оружия, полосой опыта и счётом.

### Исправлено
- Z-тест билбордов относительно стен в демо `shooter25d`.

[Unreleased]: https://hub.mos.ru/dem4ev48/russiano2d/-/compare/v0.1.0...HEAD
[0.1.0]: https://hub.mos.ru/dem4ev48/russiano2d/-/releases/v0.1.0

- Re2DSprite v2: атлас 4096×4096 с ID/глубиной/покрытием/координатами;
  мимика, тело 128×128, подмены частей, hot reload PNG и доноров,
  процедурная ходьба/бег, плечи/колени, интерактивное демо Руси-тян
  в костюмах горничной, купальнике и полиции. v1 сохранён.

- Re2DSprite: уменьшено разрастание тёмных контуров при дискретной проекции;
  выборка представительного texel блока сохраняет исходную палитру,
  совпадающие отсчёты выбираются устойчиво без предпочтения тёмного цвета.
  Увеличены пропорции головы в режиме тела.

- Re2DSprite: opt-in anime projection with continuous atlas patches, supersampled coverage, linear filtering and anime mascot material sources; demo defaults to anime, pixel/v1 remain compatible.

- Re2DSprite anatomy prototype: sculpted anime nose/lips/chin in PNG maps, one-sided facial details, torso/limb volumes and consistent shoulder anchors for profile turns.

- Re2DSprite: непрерывные полосы материала горничной, более спокойные волосы,
  отдельные белые банты и изогнутый хвост; регрессия целостности карт одежды.

- Re2DSprite: восстановлены нижняя челюсть и подбородок; проверка длины
  поверхности кожи ниже рта в готовом атласе.
