# Производительность и полнота высокоуровневого API `$`

Актуальный широкий периодический стенд и таблица: [API_PERFORMANCE.md](API_PERFORMANCE).
Этот документ сохраняет исторические замеры отдельных оптимизаций.

Аудит от 2026-10-06. Предмет — **JS-слой `$`** (`src/highlevel/*.js`, ~26 500 строк):
цикл кадра, селекторы и обёртки, подсистемы, сборка батча. C-ядро (SDL_GPU, Box2D,
SDL3_mixer, RmlUi) затрагивается только там, где `$` зовёт его на каждый узел.

> **Статус (2026-10-08).** Новая схема движка **C → `$`**: игре виден только
> `$`, покадровые проходы по узлам — в C. Итоги в Release — **§0.5–§0.8**:
> на 2000 узлов «JS итого» падает в 2–4,5 раза (спрайты 6,9 → 2,6 мс, твины
> 10,3 → 2,9, `churn` 14,5 → 3,2, текст 7,1 → 1,8). Ниже — история аудита.
>
> Пункты **P0**, **P1** и весь остаток §5 (§3.1–3.4, §3.6–3.7,
> §3.9) внедрены в тот же день; фактические замеры «до/после» и разбор по
> файлам — в **§0.1** (P0), **§0.2** (P1) и **§0.3** (пункты 12, 14, 15).
> Разделы §2 и §3 ниже — снимок «до» на момент аудита, он оставлен как есть,
> чтобы числа и рассуждения можно было перепроверить. Открытой осталась только
> архитектурная часть — **P2** (§5).

Документ отвечает на три вопроса задания:

1. **сколько стоит** сам `$` и как эта цена масштабируется с числом объектов;
2. **покрывает ли `$`** потребности игры как API движка (и что осталось за бортом);
3. **что именно влияет** на производительность и **как это исправить** — с
   приоритетами, оценкой эффекта и ссылками на код.

Замеры воспроизводимы одной командой:

```bash
python3 tools/bench_highlevel.py                 # быстрый набор, ~1 минута
python3 tools/bench_highlevel.py --full          # все виды на всех размерах
python3 tools/bench_highlevel.py --only sprite,query --ns 100,1000 --repeat 3
python3 tools/bench_highlevel.py --only churn,batch --ns 5000 --repeat 3   # цена $.batch
```

Стенд живёт в `tests/fixtures/bench/`, инструмент — `tools/bench_highlevel.py`,
числа берутся из встроенного профайлера (`engine.profile()`, `src/profile.c`).

---

## 0. Короткий ответ

**Производительность.** На пустой сцене `$` съедает **0,7–0,8 мс кадра**. На
1000 простых узлов (прямоугольников без физики, без игры) — **38,5 мс** только на
стороне JS при бюджете 16,7 мс на 60 FPS: движок не тянет тысячу сущностей не
из-за GPU и не из-за Box2D (0,01 и 0,01 мс), а из-за JS-слоя. Две главные причины:

* **`worldEvents`** (`src/highlevel/world.js:371-403`) — 4 операции с `Map` на
  числовой ключ **на каждый узел каждый кадр**: ≈20 мс при 1000 узлах, это
  половина всей цены кадра. В QuickJS `Map.get/set` с числовым ключом на карте в
  1000 записей стоит **≈6,2 мкс** против 0,19 мкс у свойства объекта — то есть
  «сравнить hp с прошлым кадром» дороже, чем всё остальное в узле вместе взятое.
* **Селекторы** (`src/highlevel/core.js:662-782`) — **любой** поиск, включая
  `$('#hero')`, это полный перебор всех узлов, а `matchesSelector` заново
  разбирает строку селектора **на каждом узле** пятью регулярками. Цена одного
  `$('.mob')` при 1000 узлах — **15,4 мс**, одного `$('#mob42')` — **14,2 мс**.
  Классический игровой цикл `$('.enemy').each(...)` в кадре убивает FPS сам по себе.
  Это не только синтетика: **штатный платформер из `game/`** тратит **55,4 мс
  кадра** на 169 узлах, потому что его `update` зовёт `$('#hero')`, `$('.walker')`
  и `distanceTo('#hero')` внутри `each` (см. §2.4).

Сверху — **около двадцати полных проходов по реестру `ctx.nodes` за кадр**
(`tickWidgets`, `tickTriggers`, `tickLayers`, `collectCounters`, `tickI18n`,
`tickParticles`, `tickTilemap`, `applyControls`, `animateSprites`, `tickEffects`,
`ui._tick`, сортировка отрисовки…), хотя большинство подсистем в кадре не делает
ничего. Это ещё ≈7 мс на 1000 узлов.

**Полнота.** `$` — это 30+ подсистем и около 30 тыс. строк: для 2D-игры среднего
размера API покрыт хорошо. Из **168** уникальных биндингов `engine.*` обёрнуто
**138 (82 %)**; не обёрнуто 30, но по-настоящему нужен игре из них **один** —
`engine.keyName` (без него `$.input.on('key')` отдаёт код числом). Остальное —
геттеры состояния звука/физики, BSP и легаси-отрисовка.

**Настоящих дыр в игровом API больше нет** — то, что перечислялось здесь раньше,
закрыто (проверено по коду, см. §4.4): пользовательские шейдеры, слои коллизий,
фигурный свип (`engine.castShape`), `Curve`/`Gradient` как ресурсы, сеть,
скелет (`.zone()` и `$.mesh`), render target, мипмапы, обрезка (scissor).
Отдельно — **мёртвый код и врущие доки** (см. §4.4).

**Что делать.** Восемь правок уровня «несколько строк» снимают ≈85 % цены кадра:
`worldEvents` → поля узла вместо `Map`; fast-path `#id` и компиляция селектора в
предикат; ранние выходы и реестры по типам вместо двадцати сканов; ленивый
`collectCounters`; переиспользуемый массив в отрисовке без объекта `{x,y,w,h}` на
узел; ранний выход в `emit`; флаг включения для профайлера. Оценка: **38,5 → 6–8 мс**
на 1000 спрайтов и **55 → 10 мс** для наивного игрового цикла. План — в §5.
**Статус: P0 внедрён, фактические числа — в §0.1.**

---

## 0.1. Статус: P0 внедрён

Все восемь правок §5 сделаны в тот же день. Ниже — фактические замеры тем же
стендом (Debug, `--repeat 3`, медиана) и перекрёстным прогоном двух бинарников,
собранных из одного дерева: эталонного (исходный JS) и оптимизированного
(`tools/bench_highlevel.py --binary build/russiano2d-base|russiano2d`).

| Сцена | N | До | После | Комментарий |
|---|---:|---:|---:|---|
| пустая сцена | 0 | 0,39–0,81 | **0,25–0,45** | профайлер выключен, окно не опрашивается |
| спрайты | 100 | 2,59 | **1,41** | |
| спрайты | 1000 | 39,11 | **12,84** | логика 28,1 → 1,5 мс, батч 11,0 |
| тела (динамические) | 1000 | 40,61 | **12,87** | |
| `$('.mob').each()` каждый кадр | 1000 | 55,80 | **13,80** | 42 мс из цены — один селектор |
| обход кэшированного массива | 1000 | 39,77 | **13,22** | |
| один `$('#id')` за кадр | 1000 | 54,77 | **12,93** | поиск по id: 14,2 → ≈0,01 мс |
| твины | 1000 | 48,21 | **21,80** | остаток — сами твины (P1) |
| частицы (один эмиттер) | 1000 | 10,07 | **9,86** | цена в батче, не в логике |
| интерфейс (`ui.label`) | 1000 | 33,40 | **10,67** | |
| тайлмап (5670 тайлов) | 10 000 | 14,80 | **14,66** | узлов нет, цена в батче |

Отдельно — **штатный платформер из `game/`** (169 узлов, тот же профайлер,
`--game game --scene platformer --agent --headless`): JS-цена кадра
**65,4 → 3,6 мс**, из них код самой игры (метка `«окно»`, теперь
`«логика игры»`) — **55–58 → 0,5 мс**, батч 1,8 мс. Игра не менялась ни на
строку: её спасли быстрый путь `#id` и компиляция селектора, то есть именно те
правки, которых аудит требовал для «наивного» кода из справочника (§2.4).

Что именно изменилось в коде:

| Правка §5 | Файл | Как сделано |
|---|---|---|
| 1. `worldEvents` | `src/highlevel/world.js` | прошлые `hp`/`visible` — поля узла `_hp_seen`/`_vis_seen`; `Map` по `uid` и ленивая чистка удалены |
| 2. `collectCounters` | `src/highlevel/pool.js` | `tickPool()` больше не считает снимок; `$.debug.counters()` считает по запросу |
| 3. fast-path `#id` | `src/highlevel/core.js` | `query()` при `'#id'` без комбинаторов идёт в `ctx.byId` (O(1)) |
| 4. компиляция селектора | `src/highlevel/core.js` | `compileSelector(sel)` → предикат, кэш на 512 строк; `Set` только при запятой; `#id` в предикате отсекается полем `node.id` |
| 5. ранние выходы подсистем | `core.js` + 8 модулей | `touchRegistry()`/`registrySummary()`: сводка на версию реестра; выход на первой строке в `tickWidgets`, `$.ui._tick`, `tickTriggers`, `tickLayers`, `tickParticles`, `tickTilemap`, `tickAnim`, `animateSprites`, `applyControls` |
| 6. `emit`/`dispatchGlobal` | `core.js`, `api.js` | при пустом `globals` объект события, обёртки и строки `'entity:…'` не строятся |
| 7. профайлер | `api.js`, `debug.js` | флаг `$.debug.profiler.on(true)` (по умолчанию выключен), метка ставится **до** измеряемого отрезка; отрезок кода игры переименован `«окно»` → `«логика игры»`, остальные ключи теперь называют свой код (в старом отчёте они были сдвинуты, см. Приложение А) |
| 8. `tickWindow` | `window.js` | состояние окна читается, только если есть подписки; снимок для сравнения берётся в `on()` |

Дополнительно (по ходу, вне таблицы §5): `destroy()` чистит `ctx.byId` —
быстрый путь по `#id` не отдаёт удалённый узел.

Что осталось из §5 (P0-остатки и весь P1): цена **сборки батча** — теперь это
11 из 12,8 мс на 1000 спрайтов (пункты 9–11: переиспользуемый массив и
компаратор в `sortedNodes`, отказ от объекта `{x,y,w,h}` на узел, числовой
`blend`-id в узле, UI-проход по списку ui-узлов), твины (пункт 12 и цена самого
`update` твинов), `tickEffects`/i18n без ранних выходов, аллокации `Wrapper` на
узел в `each()` (пункт 12), ленивые контейнеры `Node` (пункт 13) и батч-API
спавна (пункт 15).

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

---

## 0.2. Статус: P1 внедрён

Второй заход закрыл пункты 9–11, 13 и 16 плана, а также P0-остатки (ранние
выходы `tickEffects` и `tickI18n`) и мелочи кадра. Замеры — тем же стендом.

| Сцена | N | Аудит | После P0 | **После P0+P1** |
|---|---:|---:|---:|---:|
| пустая сцена | 0 | 0,39–0,84 | 0,25 | **0,18** |
| спрайты | 100 | 2,59 | 1,41 | **1,10** |
| спрайты | 1000 | 39,11 | 12,84 | **8,97** |
| тела (динамические) | 1000 | 40,61 | 12,87 | **8,69** |
| `$('.mob').each()` каждый кадр | 1000 | 55,80 | 13,80 | **9,80** [^q] |
| обход кэшированного массива | 1000 | 39,77 | 13,22 | **9,07** |
| один `$('#id')` за кадр | 1000 | 54,77 | 12,93 | **8,85** |
| твины | 1000 | 48,21 | 21,80 | **17,57** |
| частицы (один эмиттер) | 1000 | 10,07 | 9,86 | **9,57** |
| интерфейс (`ui.label`) | 1000 | 33,40 | 10,67 | **10,22** |
| тайлмап (5670 тайлов) | 10 000 | 14,80 | 14,66 | **13,35** |
| платформер `game/` (169 узлов) | — | 65,4 | 3,6 | **3,1** |

[^q]: В сцене `query` колбэк был записан как `.each((el) => …)`, где `el` —
индекс, поэтому сцена ничего не двигала. В §0.3 это исправлено на
`.each((i, el) => …)`, и с реальной работой сцена стоит 12,1 мс (логика 4,4).
Числа «до P0» в этой строке относятся к прежней, ничего не делающей сцене —
они по-прежнему показывают цену самого `$('.mob').each()`.

Сборка батча на 1000 спрайтов: 11,3 → **7,7 мс**. Остаток — почти целиком
`engine.submitSprites` (в Debug-сборке C считает медленнее) и сам проход по
узлам; логика кадра теперь 1,3 мс.

| Пункт §5 | Файл | Как сделано |
|---|---|---|
| 9. `sortedNodes` | `render.js` | переиспользуемый массив, компараторы уровня модуля; сортировка пропускается, если состав реестра не менялся и массив всё ещё неубывающий (проверка O(N) вместо сортировки O(N log N) с интерпретируемым компаратором) |
| 10. `nodeTransform` | `render.js` | встроенные теги получают переиспользуемый прямоугольник; свой объект — только чужим отрисовщикам и отложенному свету (он его сохраняет) |
| 11. батч | `render.js` | `blendId` с кэшем на одно имя (было `Map.get` по строке на спрайт), список ui-узлов кэширован на версию реестра, обход по индексу, вынесенный хук ysort |
| 13. ленивые контейнеры | `core.js` (+`api.js`, `prefab.js`, `pool.js`) | `listeners`, `data_store`, `tags_extra` создаются при первой записи (`node.dataMap()`); `classes` остался жадным — его читают селекторы в горячем цикле |
| 16. мелочи кадра | `state.js`, `tilemap.js`, `triggers.js`, `acoustics.js`, `script.c` | `Array.from(machines)` → переиспользуемый массив; `ctx.nodes.indexOf` в шаге карт → флаг `node.in_registry`; `rectOf` пишет в два переиспользуемых прямоугольника; `state.target` переиспользуется; `engine.contacts()` отдаёт `JS_NULL`, когда событий нет (пустой массив в C на каждый кадр) |
| P0-остаток: `tickEffects` | `tween.js` (+`api.js`, `pool.js`) | счётчик узлов с активным эффектом; точное значение пересчитывается в конце прохода (самолечение), установщики лишь поднимают флаг |
| P0-остаток: `tickI18n` | `i18n.js`, `core.js` | ранний выход, если ни у одного узла нет `attrs.tr`; `set('tr', …)` отмечает реестр изменённым |

**Сознательно не сделано** (и почему):

* **пункт 12** — `each()` без обёртки на узел. Колбэк получает `(i, el)`, где
  `el` — обёртка; на этом построены и справочник, и штатная игра
  (`$('.walker').each((i, e) => e.distanceTo('#hero'))`). Отдавать в колбэк сам
  узел — это смена публичного контракта, а не оптимизация: код игры молча
  сломался бы. Переиспользовать одну обёртку на все итерации тоже нельзя:
  колбэк вправе сохранить ссылку (`list.push(el)`), и все сохранённые ссылки
  указывали бы на последний узел.
* **пункт 14, вторая половина** — удаление из реестра пометкой и одной уборкой
  за кадр. `ctx.nodes.splice` остаётся O(N) на узел, но отложенная уборка
  меняет то, что видят подсистемы и агентский снимок в текущем кадре: половина
  обходов не проверяет `removed`. Это отдельная правка с прогоном всех
  подсистем, а не «мелочь». Сделана безопасная часть: `destroy()` чистит
  `ctx.byId`, а проверки «узел в реестре» стали O(1) через `in_registry`.
* **пункт 15** — `$.batch(fn)` и переиспользование тела в пуле: это новая
  функциональность (публичный API + документация + тесты), а не ускорение
  существующего пути; счётчик версии реестра уже O(1), поэтому выигрыш от
  батча спавна сейчас невелик.

Проверка после P0+P1: `python3 tools/run_tests.py` — ok 34, fail 0, skip 0;
`tests/js/*_test.mjs` — зелёные (правки тестов: пул — под новое поведение
`tickPool`, prefab — `dataMap()` вместо прямого `data_store`, anim — терпимость
к ленивому `listeners`).

---

## 0.3. Статус: пункты 12, 14 и 15 закрыты

Третий заход добил три пункта, которые в §0.2 были помечены «сознательно не
сделано». Сделаны они так, чтобы существующий код игры продолжал работать:
публичный контракт `.each((i, el))` не менялся.

### Обход без обёртки (пункт 12)

* `.each((i, el) => …)` остался прежним: `el` — обёртка, как в справочнике и во
  всех демках. Кэшировать обёртку в узле **нельзя**: поле `_wrapper` замыкает
  цикл «узел → обёртка → узел», и `JSON.stringify(node)` (агент, `$.store`,
  отладка) падает с `circular reference`; кэш в `Map`/`WeakMap` экономит
  0,1 мкс из 0,8 (замер в QuickJS), то есть не стоит усложнения.
* Добавлен **`.eachNode((i, node) => …)`** — колбэк получает сам узел, обёртка
  не создаётся вовсе.
* `each`/`eachNode` переехали из `api.js` в класс `Wrapper` в `core.js`: теперь
  их видят и модули-подсистемы, и юнит-тесты qjs, где `api.js` не поднимается.
* **97 цепных методов ядра** переведены на `eachNode` — `$('.enemy').damage(10)`,
  `.alpha()`, `.at()` и любой другой цепочный метод больше не создают обёртку на
  узел.

Замеры на 1000 узлов (новые сцены стенда, медиана трёх прогонов), колонка
«логика»:

| Обход за кадр | Сцена | Логика |
|---|---|---:|
| `$('.mob').each((i, el) => el.x += 0.1)` | `query` | 4,87 мс |
| `$('.mob').alpha(1)` — цепной метод ядра | `chain` | 4,03 мс |
| `$('.mob').eachNode((i, n) => n.x += 0.1)` | `fast` | **3,59 мс** |

Разница `query` − `fast` ≈ **1,3 мс на 1000 узлов** — это и есть цена обёртки на
узел; цепные методы ядра её уже не платят.

### Массовый спавн и удаление (пункты 14 и 15)

* **`$.batch(fn)`** (`core.js` + `api.js`): внутри пакета `destroy()` и возврат
  в пул только помечают узел, а реестр чистится одной компактификацией в конце.
  K удалений стоят O(K + N) вместо O(K·N); вложенные пакеты дают одну уборку.
* Селекторы и списки отрисовки не находят помеченные узлы: `removed`
  проверяется и в скомпилированном предикате, и в `query('*')`, и в кэшах
  ui-узлов и сортировки.
* Узел, вернувшийся в мир тем же пакетом (пул), отменяет своё удаление.
* **Пул переиспользует тело** (`pool.js`, `physics.c/h`, `script.c`): на
  `release` тело не уничтожается, а выключается (`b2Body_Disable`), на `spawn`
  включается обратно. Новые биндинги `engine.setBodyEnabled(body, on)` и
  `engine.bodyEnabled(body)`.

Новые сцены стенда `churn` (пачка спавна и удаления каждый кадр) и `batch`
(то же через `$.batch`):

| Сцена | N | Узлов за кадр | Без пакета | С пакетом |
|---|---:|---:|---:|---:|
| `churn` / `batch` | 1000 | 100 + 100 | 23,73 | **22,42** |
| `churn` / `batch` | 5000 | 500 + 500 | 153,75 | **117,16** |

На 5000 узлах пакет снимает **36 мс логики кадра (−37 %)**: цена удаления
перестаёт зависеть от размера мира.

Демки перешли на новый API там, где пачки действительно есть: «Типичная ночь в Мытищинском лесу»
(отжившие трассеры), `physics` (снос и постройка уровня, пачки ящиков),
`shooter_witch` (гибель зомби). В сцене стенда `query` заодно исправлен колбэк
`.each((el) => …)` → `.each((i, el) => …)`: раньше `el` был индексом, и сцена
ничего не двигала (та же ошибка, что в примерах справочника — §4.4).

Проверка: `python3 tools/run_tests.py` — ok 34, fail 0, skip 0;
`tests/js/*_test.mjs` (45 файлов, включая новый `batch_test.mjs`) — зелёные.

---

## 0.4. Статус: P2 внедрён — индекс реестра

Четвёртый заход закрыл архитектурный блок §5. Формулировка аудита («один обход
на кадр, который раздаёт узлы подсистемам») реализована как **индекс реестра**:
один проход по `ctx.nodes` на версию реестра строит карты `byTag`/`byClass` и
срезы по признакам (`ui`, `tr`, `controls`, `anim`, `clip`, `parallax`,
`zones`, `body`), а подсистема читает готовый срез. Диспетчер с обратным
вызовом на узел (буквальная альтернатива) отклонён осознанно: он вызывал бы
десяток замыканий на **каждый** узел, тогда как срез — один проход и O(1) на
чтение, а набор признаков у подсистем разный.

Цифры (Debug, `--repeat 3`, медиана; колонка «логика», мс на кадр):

| Сцена | N | До P2 | **После P2** | Δ |
|---|---:|---:|---:|---:|
| пустая сцена | 0 | 0,132 | **0,133** | 0 % |
| спрайты | 1000 | 1,312 | **0,854** | **−35 %** |
| спрайты | 5000 | 6,047 | **4,349** | **−28 %** |
| тела (динамические) | 1000 | 2,514 | **2,498** | −1 % |
| `$('.mob').each()` каждый кадр | 1000 | 4,613 | **2,489** | **−46 %** |
| то же, но по срезу (`fast`) | 1000 | 3,271 | **1,369** | **−58 %** |
| цепной метод (`chain`) | 1000 | 3,543 | **1,670** | **−53 %** |
| кэшированный массив | 1000 | 1,725 | **1,179** | **−32 %** |
| один `$('#id')` за кадр | 1000 | 1,411 | **0,848** | **−40 %** |
| интерфейс (`ui.label`) | 1000 | 6,009 | **5,877** | −2 % |
| твины | 1000 | 9,778 | **9,405** | −4 % |
| частицы (один эмиттер) | 1000 | 1,629 | **1,626** | 0 % |
| тайлмап (5670 тайлов) | 10 000 | 0,106 | **0,104** | −2 % |
| `$('.mob').each()` каждый кадр | 5000 | 21,890 | **12,431** | **−43 %** |
| кэшированный массив | 5000 | 7,842 | **6,104** | **−22 %** |
| то же, но по срезу (`fast`) | 5000 | 16,809 | **7,568** | **−55 %** |
| спавн/удаление пачкой (`churn`) | 1000 | 12,441 | **12,691** | +2 % |
| то же через `$.batch` | 1000 | 11,565 | **11,582** | 0 % |
| `churn` | 5000 | 93,149 | **91,498** | −2 % |
| `batch` | 5000 | 58,341 | **57,163** | −2 % |

Что именно изменилось в коде:

| Правка | Файл | Как сделано |
|---|---|---|
| Индекс реестра | `core.js` | `buildRegistryIndex()` за один проход строит `all`, `byTag`, `byClass` и срезы-массивы по признакам; живёт на версию реестра, срезы — снимки (новые массивы), поэтому обход среза не ломается от создания/удаления узлов внутри |
| Чтение срезов | `core.js` | `nodesByTag`, `nodesByClass`, `nodesWithFacet`, `facetCount`, `liveNodes` — экспорты для подсистем |
| Подсистемы | `anim.js`, `api.js`, `i18n.js`, `layers.js`, `particles.js`, `render.js`, `tilemap.js`, `triggers.js`, `ui.js`, `widgets.js`, `world.js` | вместо `registrySummary('key', countFn)` + прохода по `ctx.nodes` — срез: `nodesWithFacet('clip')`, `nodesByTag('particles')`, `nodesWithFacet('ui')` и т. д. `world.sync` ходит по срезу `body` |
| `query()` | `core.js` | структурный селектор (`.mob`, `enemy.mob`, `*`) — готовая выборка из индекса (кэш на версию); сложный — кандидаты по якорю (ведущий тег/класс), решение по предикату. Селекторы по изменяемым без версии полям (`:alive`, `[hp<5]`) считаются по узлам |
| Дешёвая перестройка | `core.js` | одноэлементный кэш «имя → список» для тега и класса (в однородной сцене Map-обращений почти нет); зоны-по-классу добираются из готового `by_class['trigger']`, а не `Set.has` на каждом узле |
| Сводка виджетов | `widgets.js` | `{ui, anchored}` кэшируется на версию реестра, как прежде, но считается по срезу ui-узлов, а не по всему миру |

**Цена решения.** Сцена, где узлы рождаются и умирают каждый кадр
(`churn`/`batch`), платит за индекс перестройкой на версию реестра. Первый
замер после перевода подсистем давал на 1000 узлов +0,98 мс (`churn`) и
+0,80 мс (`batch`); две правки перестройки (одноэлементный кэш «имя → список»
и зоны-по-классу из готового `by_class` вместо `Set.has` на каждом узле) свели
это к **+0,25 мс (+2 %)** и **+0,02 мс (0 %)**. На 5000 узлов `churn` и `batch`
после этих правок даже чуть быстрее, чем до P2 (−2 %): перестройка дешевле,
чем выигрыш от класс-индекса в `$('.churn')`. Это осознанный размен:
селекторные сцены дешевеют на 40–58 %, а `churn` и без того упирается в O(K·N)
удаления из реестра (лечится `$.batch`, §0.3).

**Пункт 21 (типизированные массивы) — измерен и отложен.** Аудит предлагал
перевести частицы и пули на `Float32Array`. Микрозамер QuickJS
(`tools/bench_storage.mjs`, 5000 частиц × 300 шагов, три раскладки одного закона
движения, машина без нагрузки, два прогона сходятся) даёт: массив объектов
0,72 мкс/частица-шаг, SoA на `Float32Array` 0,70 (−3 %), SoA на обычном массиве
чисел 0,60 (−18 %). То есть в QuickJS без JIT выигрыш даёт раскладка SoA, а не
сам типизированный массив, и на сцене `particles` (1000 частиц) 18 % — это
≈0,2 мс, меньше цены переписывания хранилища частиц вместе с публичным
`$.particles.at()` и двумя наборами тестов. Решение отложено явно, а не
«забыто»; инструмент замера лежит в репозитории и повторяется одной командой.

Проверка: `python3 tools/run_tests.py` — **ok 35, fail 0, skip 0** (35 тестов,
117 с); `tests/js/*_test.mjs` (47 файлов, включая новый `registry_test.mjs`) —
зелёные.

---

## 0.5. Статус: C → `$`, нативные проходы кадра (Release)

Пятый заход — новая схема движка: игре виден только `$` (фаза 1,
[highlevel/native.md](highlevel/native)), а покадровые проходы по узлам `$`
идут в C (`src/nodes.c`). **Замеры теперь в Release** (`-O3 -DNDEBUG`, Apple M4,
`--repeat 3`, медиана, «JS итого» = логика + батч, мс на кадр); база —
коммит `39aa3f0`.

Решение «хранилище узлов в C (SoA) или проходы по JS-объектам» принято
замером, а не интуицией: C читает свойство обычного JS-объекта по заранее
созданному атому за **4,2 нс** и пишет за **2,9 нс**; 13 полей на 1000 узлов —
0,054 мс. SoA сэкономил бы на этом < 0,05 мс, но превратил бы поля узла в
аксессоры (8 нс на чтение из JS против 2 нс) и поменял бы их семантику во всех
46 тыс. строк `$`. Поэтому узлы — прежние JS-объекты, а в C ушли проходы.

| Сцена | N | До | После | Δ |
|---|---:|---:|---:|---:|
| спрайты | 1000 | 3,68 | **2,07** | −44 % |
| спрайты | 2000 | 6,91 | **2,13** | −69 % |
| тела (динамические) | 2000 | 6,62 | **2,31** | −65 % |
| `$('.mob').each()` каждый кадр | 2000 | 7,75 | **2,50** | −68 % |
| один `$('#id')` за кадр | 2000 | 6,71 | **2,17** | −68 % |
| твины | 2000 | 10,25 | **4,20** | −59 % |
| мировой текст | 2000 | 7,15 | **3,65** | −49 % |
| `$.signal.emit` | 2000 | 8,58 | **3,11** | −64 % |
| цепной метод | 2000 | 6,93 | **2,41** | −65 % |
| спавн/удаление пачкой (`churn`) | 2000 | 14,47 | **6,24** | −57 % |
| то же через `$.batch` | 2000 | 13,49 | **5,11** | −62 % |
| частицы, интерфейс, тайлмап | 1000 | — | — | в пределах шума (±15 %, повторный замер ×5) |

Что дало эти числа:

* **наведение мыши** (`tickWorldHover`) — 1,49 → 0,18 мс на 1000 статичных
  узлов: JS звал `cameraTransform()` (новый объект) на каждый узел каждый кадр;
* **сборка батча** — проход мира в C (`drawWorld`): 2,0 → 0,4–0,9 мс на 1000
  спрайтов; сортировка и проверка порядка тоже в C;
* **Y-sort хук tilemap** ставился всегда и выключал любой быстрый путь: теперь
  он работает, только если есть карта с `ysort` (`_ysortActive`);
* синк тел и автособытия мира — в C, без изменения порядка событий.

Проверка: `tests/agent/native_passes_test.py` (кадр C и JS совпадает до
байта, те же наведение, события мира и синк тела), `python3 tools/run_tests.py`
— ok 94, `tests/js` — зелёные.

---

## 0.6. Статус: реестр, индекс и создание узлов

Шестой заход — цена жизни узла: пачки спавна и удаления (пули, волны,
осколки). Разбор цикла «200 узлов родились и умерли» на 2000 узлах мира
(Release, `engine.now()` внутри агентского eval):

| Что | До | После |
|---|---:|---:|
| цикл 200 спавнов + `$('.churn').remove()` | 4,43 мс | **2,0 мс** |
| создание 200 узлов с классом | 1,82 мс | **0,98 мс** |
| удаление 200 узлов (`.remove()` по выборке) | 2,57 мс | **0,65 мс** |
| удаление 2000 узлов одним `.remove()` | 16,6 мс | **3,0 мс** |
| перестройка индекса реестра, 2200 узлов | 1,31 мс | **0,47 мс** |

Что сделано:

* **индекс реестра строит C** (`engine.nodes.buildIndex`, `src/nodes.c`):
  тот же `all`/`by_tag`/`by_class`/срезы за один нативный проход; классы C
  читает из `node.class_list` — массива, который ядро ведёт рядом с Set
  `classes` (итерировать Set из C нельзя);
* **конструктор узла** — 4,2 → 2,7 мкс: неизменяемые умолчания (`tint`,
  `listeners`, `hitbox`, …) живут на `Node.prototype` (каждое собственное
  свойство в QuickJS — переход формы, ≈28 нс), цвета тега считаются один раз
  на тег, `velocity_cache` ленивый, `addClass` с одним именем — без
  regexp/split/filter, разбор строки цвета кэшируется;
* **`.remove()` по нескольким узлам** — одна уборка реестра в конце вызова (как
  `$.batch`): раньше O(N²), к возврату реестр чист, снаружи разницы нет;
* **одиночное удаление** ищет узел с конца реестра (`lastIndexOf`): свежие
  узлы умирают первыми.

| Сцена (Release, «JS итого», мс) | N | База `39aa3f0` | После §0.5 | **После §0.6** |
|---|---:|---:|---:|---:|
| спавн/удаление пачкой (`churn`) | 2000 | 14,47 | 6,24 | **3,39** |
| то же через `$.batch` | 2000 | 13,49 | 5,11 | **3,42** |
| `churn` | 1000 | 6,91 | 3,11 | **2,41** |

Сверка индекса C и JS — в `tests/agent/native_passes_test.py` (те же выборки
по тегу, классу, признакам и комбинаторам).

---

## 0.7. Статус: твины и эффекты

| Сцена (Release, мс) | N | До | После |
|---|---:|---:|---:|
| `$.tween(node).property('x', …).loops(-1)` — «JS итого» | 2000 | 10,25 | **2,93** |
| то же | 1000 | 5,19 | **2,54** |
| простые твины `.tween({ x, alpha })` по кругу (`move`) — тик «время» | 2000 | 1,55 | **0,39** |
| `move` — «JS: логика» | 2000 | 4,83 | **1,74** |

* **Простые твины** (`.tween/.moveTo/.fadeTo/.scaleTo/.rotateTo`) со
  встроенной плавностью — нативная лента в C: состояние, плавность (таблица
  `EASES` портирована один в один) и запись в узел; 2000 твинов — 0,04 мс за
  кадр. JS разрешает Promise по id. Своя функция плавности и `attrs` — прежняя
  JS-лента.
* **Сценарии `$.tween(target)`** остались в JS, но без лишней работы в кадре:
  полная длительность считается только на конце прохода, способ записи в цель
  выбирается один раз при старте твинера, `for…of` в горячих функциях заменён
  индексными циклами (итератор в QuickJS — объект на каждый проход). Тик 2000
  сценариев в qjs: 2,94 → 1,51 мс.
* **Таймеры эффектов** (тряска, вспышка, неуязвимость) — `engine.nodes.tickEffects`.
* `src/nodes.c` собирается без FMA-слияния — иначе C расходился с QuickJS в
  последнем знаке; это поймал тест «C против JS».

Сцена стенда `move` добавлена в `tools/bench_highlevel.py --full`.

---

## 0.8. Статус: текст, тайлы, частицы и HUD

| Сцена (Release, «JS итого», мс) | N | База `39aa3f0` | **Сейчас** |
|---|---:|---:|---:|
| мировой текст `<text>` | 2000 | 7,15 | **1,76** |
| тайлмап | 2000 | 1,92 | **1,04** |
| частицы (один эмиттер) | 2000 | 3,16 | **2,34** |
| интерфейс `ui.label` | 2000 | 4,72 | **2,69** |

* **Текст** рисует `drawWorld`: замер строки (`r2d_font_measure`) только при
  смене текста/кегля/семейства — по трём полям `_tm_*`, а не по склеенной
  строке-ключу на каждом кадре; глифы — `r2d_font_draw` в тот же момент обхода.
* **Тайлмап**: статичный слой — `drawTiles`, видимые клетки пишутся в батч
  без `push.sprite` на каждую (анимированные слои и Y-sort — прежним путём).
* **Частицы**: `drawParticles` — та же математика, рампы цвета/альфы/размера
  и `lerpColor` один в один; сборка батча 2000 частиц 2,7 → 0,4 мс.
* **HUD**: `drawUI` рисует `ui.label` и `ui.panel`, прочее — `drawUINode`
  через колбэк; очередь подписей в C, порядок сохраняется. Тик виджетов
  берёт кандидатов (якоря, контейнеры, темы) нативным фильтром.

**Оговорка о замере «логики».** У сцены частиц «JS: логика» в C-режиме
выросла (0,46 → 1,0–1,7 мс), хотя код симуляции не менялся и куча не растёт
(`$.debug.memory()`). Причина — частота CPU: кадр стал короче, поток больше
ждёт swapchain, и ОС снижает частоту ядра. Проверено: та же сцена с
искусственной загрузкой 2,3 мс в `$.render` даёт тик частиц 0,39–0,42 мс,
без неё — 0,58–0,74. Сравнивайте «JS итого» и одну и ту же конфигурацию
нагрузки, а не одну зону.

---

## 1. Методика

### 1.1. Чем мерили

В движке уже есть профайлер кадра (`src/profile.c`): CPU-зоны меряются
`SDL_GetPerformanceCounter` вокруг вызовов JS, GPU — по fence. Зоны, которые
важны здесь:

| Зона | Что внутри |
|---|---|
| `JS: логика` | весь `engine.setUpdate(...)`: синк физики, контакты, сцена, время, **`$.update` игры**, все подсистемы (`api.js:1166-1235`) |
| `JS: сборка батча` | `engine.setRender(...)`: `$.render`, `ctx.gfx._render()` — сортировка, отсечение, `submitSprites` (`api.js:1237-1248`) |
| `физика (Box2D)` | шаг мира Box2D |
| `GPU: кадр` | время кадра на GPU по fence |

Замеры снимаются в агентском режиме (`--agent --headless --fixed-dt 0.0166666667`),
поэтому они **не зависят от vsync, окна и загрузки машины**: игра идёт
детерминированными кадрами, а зоны меряются счётчиком производительности.
`--headless` не отключает рендер — команды отрисовки и GPU-работа настоящие.

### 1.2. Стенд

`tests/fixtures/bench/main.js` — сцена, вид работы и количество объектов
задаются через `--scene "<вид>:<N>"`:

| Вид | Что делает |
|---|---|
| `none` | пустая сцена: фиксированная цена цикла кадра |
| `sprite` | N статических прямоугольников: перебор узлов + сборка батча |
| `body` | N динамических тел: синхронизация физики + перебор узлов |
| `query` | N узлов + каждый кадр `$('.mob').each(...)` — как в примерах доков |
| `id` | N узлов + один `$('#mob<k>')` за кадр (цена поиска по id) |
| `cached` | та же работа, что в `query`, но по массиву узлов из `$.ready` |
| `tween` | N параллельных циклических твинов |
| `particles` | один эмиттер на N частиц |
| `ui` | N узлов интерфейса (`ui.label`) |
| `text` | N мировых надписей |
| `tilemap` | карта N тайлов (настоящий тайлсет 32×32: `assets/tiles.png` стенда) |
| `signal` | N рассылок `$.signal.emit` за кадр |
| `move` | N простых твинов `.tween({ x, alpha })` по кругу (цена твина и его Promise) |

Прогон: 30 кадров прогрева (QuickJS интерпретирует код, кэши и пулы
наполняются), затем 90 кадров замера — флаги `--warm 30 --frames 90`; по
умолчанию инструмент берёт 40 и 120. Числа в таблицах — **среднее по окну
замера, миллисекунды на кадр**.

Машина: Apple M4, macOS 27.0.1, сборка `build/russiano2d` (Debug, 2026-10-06).
Снимок исходников, к которому относятся ссылки на строки, — коммит `69d29a7`
(`src/highlevel` в том же состоянии, что и собранный бинарник).
QuickJS — **интерпретатор без JIT**: цена любой операции на порядок выше, чем в
V8/JSC, и это важно для чтения выводов (см. §3.8).

### 1.3. Оговорка про метки профайлера (исправлено в P0, см. §0.1)

На момент аудита встроенный профайлер подсистем (`$.debug.profiler`) **врал на
одну подсистему**: метка называла не тот отрезок, который измерила.
`prof('имя')` в `api.js:1157-1164` закрывала *предыдущий* отрезок и записывала
его под *старым* именем, а имена ставились **после** кода (`tickParticles(dt);
prof('частицы');`, `api.js:1221`). Проверено экспериментом: busy-loop на 5 мс
внутри `$.update` попадал в отчёт под меткой **«окно»**, а не «логика игры».

Правка №7 плана это устранила: метка ставится **до** своего отрезка, поэтому
имя метки = имя следующего за ней кода, и соответствие из Приложения А больше
не нужно. Заодно профайлер выключен по умолчанию (`$.debug.profiler.on(true)`) —
24 метки за кадр стоили 24 вызова `engine.now()` и 24 поиска в `Map` по строке
в каждом кадре релизной игры.

---

## 2. Замеры

### 2.1. Главная таблица

`JS логика`, `JS батч` — зоны профайлера; `JS итого` — их сумма (цена кадра на
стороне JS); `физика` — шаг Box2D; `GPU` — кадр на GPU.

| Вид работы | N | JS логика | JS батч | **JS итого** | физика | GPU |
|---|---:|---:|---:|---:|---:|---:|
| пустая сцена | 0 | 0,70 | 0,13 | **0,84** | 0,04 | 3,0 |
| спрайты | 100 | 1,87 | 1,41 | **3,28** | 0,02 | 1,1 |
| спрайты | 1000 | 27,70 | 10,79 | **38,49** | 0,01 | 0,9 |
| тела (динамические) | 1000 | 28,74 | 9,51 | **38,25** | 0,59 | 0,9 |
| `$('.mob').each()` каждый кадр | 1000 | 43,94 | 10,87 | **54,81** | 0,01 | 1,3 |
| обход кэшированного массива | 1000 | 28,13 | 10,91 | **39,05** | 0,01 | 1,6 |
| один `$('#id')` за кадр | 1000 | 41,75 | 10,83 | **52,57** | 0,01 | 1,3 |
| твины (1000 твинов) | 1000 | 35,94 | 10,82 | **46,76** | 0,01 | 1,1 |
| частицы (один эмиттер) | 1000 | 1,64 | 8,01 | **9,65** | 0,01 | 0,7 |
| интерфейс (`ui.label`) | 1000 | 28,55 | 3,95 | **32,50** | 0,01 | 1,4 |
| тайлмап (5670 спрайтов тайлов) | 10 000 | 0,26 | 14,29 | **14,55** | 0,01 | 1,8 |

Бюджет кадра при 60 FPS — **16,67 мс**. Уже 1000 статических прямоугольников
превышают его в 2,3 раза, а типовой игровой цикл с поиском по классу — в 3,3 раза.
GPU при этом свободен: 0,7–1,8 мс.

Что видно из таблицы:

* цена **линейна по N** с чудовищным коэффициентом: 100 → 1000 узлов даёт рост в
  15–17 раз (сверхлинейность — эффект аллокаций и GC, см. §3.5);
* **наивный селекторный цикл дороже всей остальной игры**: `query` и `id`
  добавляют к «пустому» кадру 15–16 мс при 1000 узлах;
* **тайлмап — самый дешёвый способ нарисовать много**: 5670 спрайтов тайлов стоят
  14,5 мс против 38,5 мс у 1000 отдельных узлов (у тайлмапа нет узлов, нет
  сортировки, нет синка физики — только `push.sprite`);
* **частицы** почти не стоят в логике (1,6 мс на 1000 частиц), но 8 мс в батче —
  это те же 1000 спрайтов;
* **физика Box2D не при чём**: 0,6 мс на 1000 тел.

### 2.2. Куда уходит кадр при 1000 узлах

Разбор зон для `sprite:1000` (и совпадающие с ним `cached`, `body`) — в порядке
убывания. Имена подсистем даны **с поправкой на сдвиг меток** (§1.3).

| Что реально измерено | Метка в отчёте | мс/кадр |
|---|---|---:|
| `ctx.world.sync()` → `worldEvents`: `Map` на каждый узел | синк физики | **19,8–20,3** |
| `tickWidgets`: якоря, раскладка, темы, ввод, мышь | слои | **2,7–4,2** |
| `tickTriggers`: снимок мира + зоны | виджеты | 1,6–1,8 |
| `tickPool` → `collectCounters()` каждый кадр | i18n | **1,0** |
| `tickLayers`: два прохода + поддеревья слоёв | префабы | 0,8–0,9 |
| `animateSprites()` + `applyControls()` | логика игры | 0,6–0,7 |
| `tickTime` + `tickWindow` (6 вызовов окна) | время | 0,4–0,5 |
| `tickParticles` | vfx | 0,3 |
| `tickAnim` | анимация+ввод | 0,2 |
| `tickTilemap` (скан реестра + `indexOf`) | диалоги | 0,2 |
| `ui._tick` (hit-test по всем узлам) | акустика | 0,2 |
| всё остальное (15 подсистем) | — | ≈0,1 |
| **итого JS логика** | | **27,7** |

Плюс батч (10,8 мс): сортировка всего реестра, объект `{x,y,w,h}` на узел,
`Map.get` по тегу и по имени blend на спрайт, `engine.rgba` на UI-узел.

Для `id:1000` в метке «окно» (то есть в **коде игры**) видно 14,2 мс — это один
поиск `$('#mob<k>')`; для `query:1000` — 15,4 мс на `$('.mob').each(...)`;
для `tween:1000` — 9,0 мс в `tickTime` (тысяча активных твинов);
для `signal:1000` — 4,6 мс на тысячу рассылок `$.signal.emit` за кадр.

### 2.3. Микрозамеры: почему это так дорого

Замеры внутри живого JS-контекста движка (QuickJS), наносекунды на операцию:

| Операция | нс/оп | Комментарий |
|---|---:|---|
| чтение/запись свойства объекта | 186 | база сравнения |
| чтение `node.x` у экземпляра класса | 187 | столько же |
| запись элемента массива | 237 | |
| `Map.get` со **строковым** ключом (1000 записей) | 537 | терпимо |
| `new Map()` / `new Set()` (пустые) | 485 / 497 | **12 malloc на узел** (см. §3.5) |
| создание объекта `{x,y,w,h}` | 816 | столько стоит `nodeTransform` на узел |
| вызов `engine.now()` (граница JS→C) | 284 | 24 вызова за кадр |
| `RegExp.exec` на короткой строке | 1 896 | `matchesSelector` — до 5 регулярок на узел |
| `String.replace(re) + split` (разбор селектора) | 8 199 | |
| **`Map.get` с числовым ключом (1000 записей)** | **6 215** | строка `worldEvents` |
| **`Map.set` с числовым ключом (1000 записей)** | **6 177** | строка `worldEvents` |
| **`Set.has` с числовым ключом (1000 записей)** | **6 198** | |
| `Map.get` с числовым ключом, карта на 10 записей | 426 | размер карты решает |
| `Float64Array[uid]` | 198 | в 30 раз дешевле `Map.get` по числу |
| обычный массив по индексу | 197 | |

Вывод, который определяет половину плана исправлений: **в QuickJS числовой ключ в
`Map`/`Set` — самый дорогой способ связать данные с узлом**. Полный проход по
1000 узлов с двумя `Map.get` + двумя `Map.set` (это ровно `worldEvents`) — 12,8 мс
по микрозамеру и 19,8 мс в живом кадре.

### 2.4. Реальная игра: платформер из `game/` — 55 мс на кадр при 169 узлах

Стенд — синтетика; чтобы проверить выводы на «живом» коде, тем же профайлером
измерен **штатный платформер движка** (`game/main.js` → `game/scenes/platformer.js`,
ровно та игра, по которой учатся):

```bash
./build/russiano2d --game game --scene platformer --agent --headless --fixed-dt 0.0166666667
```

| Метрика | Значение |
|---|---:|
| узлов в сцене | 169 (150 `brick`, 2 `enemy`/`.walker`, 15 `.coin`, игрок, 2 панели UI) |
| спрайтов в кадре | 170 |
| JS логика | **60,6 мс** |
| из неё — код игры (`update` сцены, метка «окно») | **55,4 мс** |
| все подсистемы `$` вместе | ≈5 мс |
| JS сборка батча | 1,8 мс |
| физика Box2D | 0,07 мс |
| GPU | 1,4 мс |

То есть **сама игра стоит в 3,6 раза дороже бюджета кадра, а движок — нет**.
Причина — в `update` сцены (`game/scenes/platformer.js:150-175`):

```js
update(dt, $) {
    const hero = $('#hero');                              // скан всех узлов
    $('#hud-hp').text(...);                               // скан
    $('#hud-coins').text(...);                            // скан
    $('.walker').each((i, e) => {                         // скан + обёртка на врага
        ...
        if (e.distanceTo('#hero') < 34) touching_hero = true;   // скан НА КАЖДОГО врага
    });
    $('.coin').each((i, c) => {
        if (c.distanceTo('#hero') < 30) c.emit('pickup');       // скан НА КАЖДУЮ монету
    });
```

Замеры на этой же сцене: один `$('#hero')` — **2,45 мс**, один `$('.coin')` —
**2,49 мс**, и тело `update` целиком — **56,5 мс** (21 полный проход по 169 узлам:
3 id + 2 классовых селектора + 17 `distanceTo('#hero')` внутри `each`). Это ровно
тот код, который документация и туториал предлагают писать, — и он квадратичен по
числу сущностей: каждая новая монета добавляет ещё один скан всей сцены.

**Практический вывод:** быстрый путь для `#id` (правка №3 плана) и компиляция
селектора (№4) превращают эти 56 мс в ≈1–2 мс **без единой правки в игре**. Пока
их нет, игру спасает только «кэшировать узлы в `$.ready` и не звать `$('#id')`
внутри `each`» — но это не то, чему учит справочник.

---

## 3. Что влияет на производительность

### 3.1. `worldEvents`: `Map` на каждый узел каждый кадр — 20 мс из 38 *(исправлено в P0, см. §0.1)*

`src/highlevel/world.js:371-403`, вызывается из `world.sync()` (там же, строка 296):

```js
function worldEvents(dt) {
    for (const node of ctx.nodes) {
        const key = node.uid;
        const was = prev_hp.get(key);          // Map.get по числу
        if (was === undefined) {
            prev_hp.set(key, node.cur_hp);     // Map.set
            prev_visible.set(key, node.visible);
            continue;
        }
        ...
        const was_visible = prev_visible.get(key);
        if (was_visible !== node.visible) { prev_visible.set(key, node.visible); ... }
        prev_hp.set(key, node.cur_hp);         // Map.set
    }
```

Смысл кода — заметить изменение `hp`/`visible` и разослать события `hit`/`heal`/
`death`/`show`/`hide`. Плата — **4 операции с числовым ключом на узел за кадр**, то
есть ≈25 мкс на узел там, где всё остальное вместе стоит ≈2 мкс. При 1000 узлах —
**20 мс кадра**, ровно половина JS-времени.

**Как исправить** (в порядке предпочтения):

1. **Сравнивать с полем самого узла** — `node._hp_seen`, `node._vis_seen`
   (≈0,2 мкс вместо 25 мкс, ×100). Тогда `worldEvents` становится циклом
   сравнения двух чисел, а карты `prev_hp`/`prev_visible` и их ленивая чистка
   (`world.js:398-402`) удаляются вовсе.
2. **Ещё лучше — рассылать события в точке изменения**: `.damage()`, `.heal()`,
   `.hp()`, `.visible()` уже знают, что значение изменилось; `worldEvents` тогда
   не нужен как класс. Это заодно убирает ложные события у узлов, которые никто
   не менял, и делает порядок событий предсказуемым.
3. Если оставлять проход — держать данные в **разреженном массиве по uid**
   (`Float64Array`) или в полях узла, но не в `Map` с числовым ключом.

### 3.2. Селекторы: O(N) на любой поиск + разбор строки на каждом узле *(исправлено в P0, см. §0.1)*

`src/highlevel/core.js:662-782`. Любой селектор, кроме `'*'`, идёт через
`ctx.nodes.filter(n => matchesSelector(n, sel))` (`core.js:766`) — **полный перебор
реестра**. Быстрый путь по `byId` есть только внутри матчера (`core.js:719`), то
есть `$('#hero')` тоже сканирует все узлы. `matchesSelector` на **каждом** узле:

```js
const attrMatch = /\[...\]/.exec(sel);                    // core.js:668
const pseudo = /:([a-zA-Z][\w]*)(\(([^)]*)\))?/.exec(sel); // core.js:684
const cleaned = sel.replace(/\[[^\]]*\]/g, '')             // core.js:713
                   .replace(/:[a-zA-Z][\w]*(\([^)]*\))?/g, '');
for (const part of cleaned.split(/(?=[.#])/)) { ... }      // core.js:716
```

Регулярки кэшируются как объекты, но `exec`/`replace`/`split` создают строки и
массивы **на каждый узел**. Плюс `query()` безусловно создаёт `Set` для
уникализации (`core.js:751`) и копирует массив в обёртке (`core.js:634`).

Замеры: пустой кадр 0,84 мс → с одним `$('.mob').each()` при 1000 узлах 54,8 мс.
Один `$('#mob42')` стоит **14,2 мс**; цена одного вызова селектора — 15,4 мс
(1000 узлов × ≈15 мкс).

Псевдоклассы `:first/:last/:even/:odd/:eq` используют `ctx.nodes.indexOf(node)`
(`core.js:696-700`) — это уже **O(N²)** на запрос.

**Как исправить:**

1. **Fast-path `#id`**: `query()` при `sel[0] === '#'` без пробелов/запятых
   возвращает `ctx.byId.get(sel.slice(1))` — O(1) вместо O(N).
2. **Компилировать селектор один раз на вызов, а не на узел**: разобрать строку
   в предикат-замыкание (`compileSelector(sel) → (node) => boolean`) и
   прогнать его по узлам. Убирает 5 регулярок × N с каждого запроса.
3. **Индексы по тегу и классу**: `ctx.byTag = Map<tag, Set<Node>>` и
   `Map<class, Set<Node>>` с версией реестра; `.class` и `tag`-селекторы станут
   O(числа совпадений). Реестр `byTag` можно поддерживать в `Node` при создании
   и в `addClass/removeClass/destroy`.
4. **Кэш результата** `Map<строка, {версия, массив}>` — но только после 1–3:
   кэш без индексов маскирует проблему и врёт при мутациях.
5. `Set` для уникализации создавать **только если селектор содержит запятую**;
   `sort`/`filter` по общим правилам — см. §3.4.

Тот же класс проблемы — `ctx.nodes.indexOf(node)` в `destroy()`
(`core.js:535-536`), `detach()` (`api.js:1264`), `pool.attachNode` и
`tilemap.tick` (`tilemap.js:1283`): удаление K узлов из N даёт O(K·N), а массовое
удаление пуль/врагов — типовой сценарий. Лечится флагом `removed` (он уже есть) и
одной уборкой реестра за кадр, а не `indexOf` на каждый узел.

### 3.3. Двадцать полных проходов по реестру за кадр *(частично исправлено в P0, см. §0.1)*

Кадр `$` устроен так, что **каждая подсистема сама обходит все узлы**, проверяя
«а есть ли тут мои?». Полный список таких мест, которые выполняются каждый кадр:

| Место | Что обходит |
|---|---|
| `world.sync` + `worldEvents` (`world.js:284`, `:372`) | все узлы |
| `animateSprites` (`api.js:1426`) | все узлы |
| `applyControls` (`api.js:1348`) | все узлы |
| `tickEffects` (`tween.js:280`) | все узлы — shake/tint/iframes |
| `tickAnim` (`anim.js:524`) | все узлы — ищет клипы |
| `tickParticles` (`particles.js:559`) | все узлы — ищет эмиттеры |
| `tickTilemap` (`tilemap.js:1281`) | все узлы — ищет карты |
| `tickLayers` (`layers.js:671`, `:684`) | все узлы — дважды |
| `tickWidgets`: `applyAnchors` 475, `layoutTree` 1310, `applyThemes` 677, `syncInput` 1788, `tickMouse` 1570 | все узлы — **пять раз** |
| `tickTriggers` → `collectFrame` (`triggers.js:155-166`) | все узлы |
| `tickI18n` (`i18n.js:317`) | все узлы (при `auto`) |
| `tickPool` → `collectCounters` (`pool.js:334`) | все узлы |
| `ui._tick` (`ui.js:90`) | все узлы |
| `sortedNodes` (`render.js:1026`) | все узлы (filter) + сортировка |
| UI-проход (`render.js:1326`) | все узлы — второй раз за кадр |

Итого ≈20 проходов. При 1000 узлах это ≈7–8 мс, при 5000 — уже 35–40 мс, причём
почти вся работа — впустую: в сцене с одними спрайтами ни одной зоны, ни одной
карты, ни одного ui-узла, ни одного эмиттера нет.

Отдельно стоит **`collectCounters()`** (`pool.js:319-347`, вызывается из
`tickPool`, `pool.js:521-523`): объект счётчиков и полный обход реестра **каждый
кадр**, а результат кладётся в `ctx.counters`, который **никто не читает**
(`$.debug.counters()` считает всё заново, `debug.js:56`). Это чистая потеря
≈1 мс на 1000 узлов.

**Как исправить:**

1. **Реестры по типам вместо сканов**: поддерживать в ядре
   `ctx.byTag: Map<tag, Set<Node>>` (он же решает задачу §3.2) и отдельные
   списки для «горячих» групп: ui-узлы, эмиттеры, карты, слои, узлы с
   эффектами (shake/tint/iframes), управляемые узлы, узлы с `attrs.tr`.
   Подсистема без своих узлов выходит на первой строке — как уже сделано в
   `tickNav` (`nav.js:1590`) и `tickHttp` (`http.js:258`).
2. **Ранние выходы там, где реестра не хватает**: `tickWidgets` — по флагу
   `any_ui`, `tickTriggers` — по непустому списку зон, `tickLayers` — по флагу
   «есть слои/параллакс», `tickI18n` — по счётчику непереведённых узлов.
3. **`collectCounters()` убрать из кадра** (или считать по требованию и
   кэшировать с версией реестра).
4. **Дешёвые локальные правки**: `state.js:768` (`Array.from(machines)` каждый
   кадр), `tilemap.js:1280-1283` (`stale = []` + `indexOf`), `tween.js:280`
   (перейти на список), `widgets.js:1310` и `:1570` (`ctx.nodes.slice()` дважды
   за кадр), `triggers.js:57` (два объекта на каждую пару «зона × цель»),
   `window.js:44-54` (6 вызовов C ради объекта, который никто не читает),
   `acoustics.js:293` (новый объект `state.target` каждый кадр),
   `api.js:1391` (`engine.contacts()` создаёт пустой массив в C до проверки
   длины).

### 3.4. Сборка батча: 10,8 мс на 1000 спрайтов *(исправлено в P1, см. §0.2)*

`src/highlevel/render.js`:

```js
function sortedNodes() {
    const list = ctx.nodes.filter((n) => !n.attrs.ui);   // :1026 — массив каждый кадр
    list.sort((a, b) => { ... });                        // :1028 — замыкание каждый кадр
    return list;
}
function nodeTransform(node, cam) {
    return { x: sx, y: sy, w: ..., h: ... };             // :886 — объект на узел
}
```

Плюс `node_renderers.get(node.tag)` — `Map.get` по строке на узел (`:908`),
`blendId(name)` — `Map.get` по строке на **спрайт** (`:233`),
`packColor(node.color, node.alpha)` → **вызов `engine.rgba` на каждый UI-узел**
(`:973`), второй полный проход по реестру для UI (`:1326`).

Измерено: 1000 спрайтов → 10,8 мс батча, тайлмап на 5670 спрайтов → 14,3 мс
(≈2,5 мкс на спрайт).

**Как исправить:**

1. `sortedNodes`: переиспользуемый массив (заполнять `length = 0`), компаратор —
   функция уровня модуля, а не новое замыкание; при `layer/depth` без изменений
   список можно не пересортировывать (dirty-флаг на `world`).
2. `nodeTransform` → не создавать объект: считать `sx/sy/w/h` прямо в
   `drawWorldNode` либо писать в один переиспользуемый объект (для отложенного
   света `deferred_lights` копировать поля, а не ссылку).
3. `blendId`: хранить числовой id режима в узле при `.blend()`, а не искать строку
   в `Map` на каждый спрайт.
4. UI: отдельный список ui-узлов вместо второго прохода по всему реестру.
5. `packColor`: при `node.alpha === 1` возвращать уже упакованный цвет без вызова
   `engine.rgba` — сейчас это FFI на каждый UI-узел каждый кадр.
6. Тайлмап: рисует тайлы через `push.sprite` — это уже хорошо; но вызов идёт через
   обёртку `gfx.push.*` с `setView(cameraTransform())`; `tileScreenPoint` создаёт
   объект `{x,y,zoom}` **на тайл** (`tilemap.js:730-744`) — при 5670 тайлах это
   5670 объектов за кадр.

### 3.5. Аллокации и сборщик мусора

QuickJS — интерпретатор с mark-sweep GC; каждый мелкий объект — malloc и работа
для GC. Что аллоцируется за кадр при 1000 узлов (по коду):

* `nodeTransform` — 1000 объектов `{x,y,w,h}`;
* `sortedNodes` — массив на 1000 элементов + замыкание-компаратор;
* `wrapOne` в `each()` и в цепных методах — `new Wrapper` + массив **на узел**
  (`core.js:635`, `api.js:357`): `$('.mob').each(cb)` = 2001 аллокация;
* `matchesSelector` — до 5 регулярок + 3–5 строк/массивов **на узел** на каждый
  запрос: `$('.mob')` = ≈8000 аллокаций;
* `emit` — объект события + `list.slice()` + обёртка, причём `dispatchGlobal`
  (`core.js:577` → `api.js:321-347`) строит объект события и **три `wrapOne`**
  даже когда глобальных подписок нет вообще;
* `collectCounters` — объект + `wrapOne` на каждую `<particles>`;
* `tickWidgets` — строки `containerSig` на контейнер и на каждого ребёнка,
  `JSON.stringify` для `themeSig` на ui-узел, два `ctx.nodes.slice()`.

Оценка мусора для типового цикла `$('.mob').each(cb)` при 1000 узлах — **≈10–11
тыс. аллокаций и ≈1 МБ мусора за кадр** (≈60 МБ/с при 60 FPS). Отсюда и
сверхлинейность: 100 узлов — 2,6–3,3 мс, 1000 — 38,5 мс (рост в 12–15 раз на 10×
объектов).

Крупные и дешёвые меры: **не создавать `Wrapper` на узел** (отдавать в колбэк сам
`node`, обёртку — вторым аргументом; в ядре этот приём уже применён в
`anim.js:282-288` с комментарием «не полагаемся на `Wrapper.prototype.each`»),
**ранний выход в `emit`/`dispatchGlobal`**, **ленивые `Set`/`Map` в `Node`**
(`classes`, `tags_extra`, `listeners`, `data_store` создаются в конструкторе
всегда — это 12 malloc на узел, `core.js:178-180`, `:257-258`).

### 3.6. Создание и удаление узлов

`$('<tag>', {...})` — ≈20–28 аллокаций JS (из них 12 — внутренности четырёх
хеш-контейнеров) + тело Box2D + два реестра (`ctx.nodes.push`, `ctx.byId.set`).
Массовый спавн (сотни узлов за кадр) платит ещё и за пересоздание тела при
`.size()`/`.collision()` (`syncBodySize` → `setBody` → `destroyBody`+`createBody`,
`core.js:521-526`).

Пул (`pool.js`) снимает часть цены, но `attachNode`/`detachNode` используют
`ctx.nodes.indexOf` (`pool.js:211-230`) — то есть O(N) на каждое возвращение в пул.
При стрельбе очередями это дороже, чем сам выстрел.

**Как исправить:** батч-операции (`$.batch(fn)`): внутри — отложенные вставки и
удаления, один пересчёт индексов и одна сортировка в конце кадра; `destroy()` —
пометка `removed` + удаление из `byId` (сейчас `byId` в `destroy()` не чистится
вовсе, `core.js:528-546`, мёртвые записи вычищают ленивые свипы в `prefab.js:547`,
`save.js:396`, `scene.js:192`).

### 3.7. Фиксированная цена пустого кадра *(частично исправлено в P0, см. §0.1)*

Пустая сцена — **0,7–0,8 мс** (4–5 % бюджета 60 FPS). Складывается из:

* 22 вызова подсистем, каждая что-то проверяет (см. §2.2, ≈0,3 мс);
* `tickWindow` — 6 вызовов C и объект состояния каждый кадр (`window.js:44-54`);
* `dispatchContacts` — `engine.contacts()` создаёт пустой массив в C каждый кадр
  (`api.js:1391`, `script.c:750-754`);
* `ctx.gfx._render()` — `cameraTransform()` (объект), `pushPost()` (≈20 аргументов
  в C), новый объект `stats`, filter+sort пустого реестра (`render.js:1254-1259`);
* **24 метки профайлера**: `engine.now()` (0,28 мкс) + `Map.get` по строке на
  каждую — ≈20 мкс; и всё это **всегда включено**, хотя профайлер нужен только
  при отладке (`api.js:1157-1164`, `debug.js:130-183`).

Не смертельно, но на 120 FPS (8,3 мс бюджета) это уже 10 %, а на слабом железе
(интерпретатор QuickJS на ARM-планшете/консоли) — больше.

### 3.8. Особенности QuickJS, которые надо учитывать

* **Нет JIT.** Всё, что в V8 «бесплатно», здесь стоит наносекунды-микросекунды:
  регулярка — 1,9 мкс, создание объекта — 0,8 мкс.
* **Числовой ключ в `Map`/`Set` — 6,2 мкс** на карте в 1000 записей (в 30 раз
  дороже массива/`Float64Array` и в 12 раз дороже строкового ключа). Все
  «узел → данные» через `Map` по `uid`/`body` надо переводить на массивы и поля.
* **Граница JS→C дешёвая** (0,28 мкс), поэтому дробить работу на много мелких
  вызовов `engine.*` не страшно — но 1000 вызовов на кадр это уже 0,3 мс, а
  `engine.getVelocity` ещё и **создаёт массив из двух чисел** в C
  (`script.c:884-887`), что дороже самого вызова.
* **Каждая аллокация — malloc.** Отсюда приоритет «не создавать объект на узел».

### 3.9. Ошибки в измерительном инструменте *(1 и 2 исправлены в P0, см. §0.1)*

1. **Метки `$.debug.profiler` были сдвинуты на одну подсистему**
   (`api.js:1157-1164`): `prof('имя')` ставилась после кода и закрывала
   предыдущий отрезок — оптимизацию по такому отчёту вели не туда.
   Исправлено: метка ставится **до** своей работы, двойная метка
   `prof('интерфейс'); prof(null);` разобрана.
2. **Профайлер был включён всегда** — 24 перехода в C и 24 `Map.get` по строке
   за кадр в релизной игре. Исправлено: `$.debug.profiler.on(true)` включает
   покадровые метки, по умолчанию они не ставятся (`debug.js`, `api.js`).
3. **Замеры делаются в Debug-сборке.** Часть цены (проверки, `-O0` в C) в Release
   другая; для отчёта важны относительные величины и структура, но абсолютные
   числа на релизе будут ниже. Рекомендация: гонять стенд на **обеих** сборках.

---

## 4. Покрытие: `$` как API движка

### 4.1. Сколько обёрнуто

Биндинги `engine.*` регистрируются в трёх файлах: `src/script.c` (150 вызовов),
`src/render.c` (14), `src/http.c` (6) — **168 уникальных имён**. Обёрнуто в `$` —
**138 (82 %)**, не обёрнуто — **30 (18 %)**; `$.engine` не существует, то есть
необёрнутое доступно только через глобальный `engine`.

| Группа | Всего | Обёрнуто | Не обёрнуто |
|---|---:|---:|---:|
| `engine.window` → `$.window` | 24 | 24 | 0 |
| `engine.audio` → `$.sound`/`$.audio` | 36 | 30 | 6 |
| `engine.ui` → `$.ui` | 15 | 14 | 1 |
| `engine.fs` → `$.fs`/`$.store` | 5 | 5 | 0 |
| `engine.http` → `$.http` | 6 | 5 | 1 |
| `engine.light` → `$.gfx.light` | 2 | 1 | 1 |
| `engine.bsp` | 7 | 0 | 7 |
| `engine.viewport` | 8 | 8 | 0 |
| плоские `engine.*` | 65 | 59 | 6 |
| **Итого** | **168** | **138** | **30** |

### 4.2. Что не обёрнуто и насколько это важно

**P1 — нужно обычной игре:**

* `engine.keyName` — **закрыто**: `input.js` зовёт биндинг движка и кеширует
  имена, поэтому `$.input.on('key')` отдаёт `'Space'`, а не число
  (проверка `tests/agent/highlevel_keyname_test.py`). Ниже — что осталось:
  дыра в покрытии.

**P2 — полезно:**

* геттеры живого звука: `channelVolume/channelPan/channelPitch/channelEffect`
  (`script.c:2712-2720`) — сеттеры используются (`audiobus.js:398`,
  `acoustics.js:389`, `sound.js:40`), геттеров нет; `$.sound.volume()` знает
  только мастер и может расходиться с реальным состоянием канала;
* физика: `getAngularVelocity` (`script.c:2650`), `bodyMass` (`:2657`),
  `r2d_physics_is_awake` (`physics.h:144`) — есть только сеттеры;
* `r2d_pad_pressed` (`app.h:167`) — у клавиатуры и мыши фронты есть, у геймпада
  нет, JS эмулирует их сам (`input.js:365-370`);
* **BSP обёрнут в `$.world.bsp`** — прежнее утверждение об отсутствии
  обёртки удалено; см. [bsp.md](highlevel/bsp).

**P3 — служебное:** `drawSprite`/`drawRect` (вытеснены `$.gfx.push`),
`getGravity`, `http.active`, `light.maxPoints`, `ui.iconCode`,
`audio.groupCount/groupEffect`. Render target **реализован**:
`$.viewport` работает поверх `engine.viewport.*` (привязка текстуры на кадр,
спрайт прошлого кадра, блит на экран — см. [render.md](highlevel/render) §3).

### 4.3. Дыры как игрового API (сверка с Godot 4.x, 2D)

Закрыто с прошлого аудита (`docs/TASKS.md`): анимация и `AnimationPlayer`,
тайлмапы, частицы, навигация и A*, префабы, шины звука с эффектами, слои и
parallax, UI-контролы (контейнеры, скролл, фокус, ввод текста, чекбоксы,
слайдеры, списки, диалоги), локализация, состояния/потоки, экраны, шрифты,
сохранения, CSV/сетки, сигналы. Это 30+ подсистем и около 30 тыс. строк.

**Закрыто с этого аудита** (проверено по коду, а не по доке):

* **пользовательские шейдеры** — `$.gfx.defineShader(name, { frag })` компилирует
  фрагментный шейдер в рантайме, `.shader(name)`/`.shaderParam()` работают
  (при `R2D_ENABLE_LIVE_SHADERS=ON`);
* **слои коллизий** — `.mask()/.layerBits()/.collidesWith()` работают;
* **фигурный свип/CastShape** — `engine.castShape` и `$.world.castShape` есть;
* **`Curve`/`Gradient` как ресурсы** — виды `curve` и `gradient` в `$.resource`
  (resource.md §1.1);
* **скелет** — `$.mesh` (кости, веса, UV) и зоны тела `.zone()`;
* **импорт атласа** — `$.atlas`, включая слайсы Aseprite с пивотами;
* **NinePatchRect** — nine-slice у узла;
* **render target** — `$.viewport` (render.md §3);
* **мипмапы и обрезка** — `engine.loadTexture(..., { mipmaps: true })`,
  `$.gfx.clip` / `.clip()`.

**Сверка 2026-10-08:** прежние остатки этого раздела закрыты: `$.mesh.ik`,
наследование `visible`/`alpha`, `.depthRelative(true)`, звук seek и приоритеты.
Текущие задачи — [TASKS.md](TASKS). Замеры выше остаются историческими.

Для «2D-игры среднего размера» вердикт: **покрытие достаточное**; перечисленное
выше — удобства, а не блокеры.

### 4.4. Сопровождение документации

Исторические находки этого раздела закрыты и удалены из текущего списка.
Покрытие всех 78 high-level модулей документацией и проверками подтверждается
`tests/doc_coverage_test.py`. `tests/doc_claims_test.py` ловит известные
устаревшие отрицания; он не заменяет чтение кода. Текущая сверка —
[TASKS.md](TASKS).

---

## 5. План исправлений

Приоритеты: **P0** — дёшево и снимает больше всего; **P1** — важно, но требует
аккуратности; **P2** — по остаточному принципу. Оценки эффекта — по замерам §2
для сцены в 1000 узлов.

### P0. Убрать паразитную работу — **внедрено** (см. §0.1)

Фактический итог: 39,1 → 12,8 мс на 1000 спрайтов, 55,8 → 13,8 мс на
наивном селекторном цикле; пустая сцена 0,39 → 0,25 мс. Столбец
«Ожидаемый эффект» оставлен как оценка аудита.

| # | Правка | Где | Ожидаемый эффект |
|---|---|---|---|
✅ | 1 | `worldEvents` — сравнение с полями узла (`node._hp_seen`, `node._vis_seen`) вместо `Map` по `uid`; карты и их чистку удалить | `world.js:371-403` | **−19,8 мс** (20,3 → ≈0,3) |
✅ | 2 | `collectCounters()` убрать из кадра (или сделать ленивым с версией реестра) | `pool.js:521-523` | **−1,0 мс** |
✅ | 3 | Fast-path `#id` в `$()` через `ctx.byId` | `core.js:745-766`, `api.js:156` | поиск id: 14,2 мс → **≈0,01 мс** |
✅ | 4 | Компиляция селектора в предикат **один раз на вызов** (не на узел); `Set` только при запятой | `core.js:662-782` | `$('.mob')`: 15,4 → **≈1–2 мс** (без индексов), до ≈0,3 с индексами |
✅ | 5 | Реестры по типам (`byTag`, ui-узлы, эмиттеры, карты, слои, узлы с эффектами/клипами) + ранние выходы подсистем | `core.js` (реестр), `widgets.js:1761`, `triggers.js:155`, `layers.js:667`, `particles.js:556`, `tween.js:279`, `i18n.js:310`, `ui.js:85`, `tilemap.js:1279`, `api.js:1347`, `api.js:1423` | **−7 мс** на 1000 узлов; на 5000 — кратно больше |
✅ | 6 | `emit`/`dispatchGlobal`: ранний выход, если слушателей нет; не создавать объект события, `wrapOne`, `list.slice()` и строки `'entity:'+name` заранее | `core.js:566-579`, `api.js:321-347` | 1000 рассылок за кадр: 4,6 мс → ≈0,5 мс; на каждое узловое событие — минус 6–9 аллокаций |
✅ | 7 | Профайлер: флаг включения + метки ставить **до** кода (устранить сдвиг) | `api.js:1157-1164`, `debug.js:130-183` | −20 мкс/кадр, зато отчёты перестанут врать |
✅ | 8 | `tickWindow`: не читать состояние окна, если нет подписчиков и запросов | `window.js:44-54`, `:180` | −6 вызовов C за кадр |

### P1. Разгрузить кадр и аллокации — **внедрено полностью** (12, 14 и 15 — см. §0.3)

| # | Правка | Где | Эффект |
|---|---|---|---|
✅ | 9 | `sortedNodes`: переиспользуемый массив, компаратор уровня модуля, пропуск сортировки без изменений `layer/depth` | `render.js:1022-1035` | ≈−2,7 мс |
✅ | 10 | `nodeTransform` без объекта: считать координаты в `drawWorldNode`; отложенный свет — копировать поля | `render.js:877-892`, `:935` | ≈−1,2 мс |
✅ | 11 | `blendId` — числовой id в узле; `packColor` без `engine.rgba` при `alpha === 1`; UI-проход по списку ui-узлов | `render.js:233`, `:973`, `:1326` | ≈−1 мс |
| ✅ 12 | `each()` и цепные методы: отдавать `node`, а не `wrapOne(node)`; ядро уже так делает в `anim.js:282-288` | `api.js:356-391` | −2000 аллокаций на вызов при 1000 узлах |
✅ | 13 | Ленивые `classes`/`tags_extra`/`listeners`/`data_store` в `Node` | `core.js:178-180`, `:252-258` | −12 malloc на узел |
| ✅ 14 | `destroy()`: чистить `byId` *(сделано в P0)*, удалять из реестра пометкой + одной уборкой за кадр; `detach`/пул — без `indexOf` | `core.js:528-546`, `api.js:1262`, `pool.js:211-230` | массовое удаление: O(K·N) → O(K+N) |
| ✅ 15 | Батч-API `$.batch(fn)` для спавна/удаления пачек; в пуле — переиспользовать тело, а не пересоздавать | `world.js`, `pool.js` | сотни узлов за кадр перестают «дробить» кадр |
✅ | 16 | Мелочи кадра: `Array.from` в `state.js:768`, `stale`/`indexOf` в `tilemap.js:1280-1283`, `rectOf` без объектов в `triggers.js:57`, `state.target` в `acoustics.js:293`, `engine.contacts()` → `JS_NULL` при отсутствии событий (`script.c:750`) | по списку | ≈−1 мс суммарно |

### P2. Архитектурно (когда целитесь в 5000+ сущностей) — **внедрено**, см. §0.4

| # | Правка | Где | Что вышло |
|---|---|---|---|
| ✅ | 17 | **Индекс реестра вместо N проходов подсистем.** Один обход `ctx.nodes` на версию реестра строит карты `byTag`/`byClass` и срезы по признакам; подсистема читает готовый срез | `core.js` (индекс), 11 модулей | `nodesByTag`/`nodesByClass`/`nodesWithFacet`/`facetCount`/`liveNodes`; каждый tick ходит по своему срезу, а не по всему миру |
| ✅ | 18 | **Кэш выборок по селектору на версию реестра.** Структурный селектор (`.mob`, `enemy.mob`) — готовый срез; сложный (`:alive`, `[hp<5]`) — по якорю (ведущий тег/класс) с предикатом | `core.js` (`query`, `querySingle`) | `query`-сцена 1000 узлов: логика 4,61 → **2,82 мс** |
| ✅ | 19 | **Индексы как часть API подсистем**: `nodesByTag`, `nodesByClass`, `nodesWithFacet`, `facetCount`, `liveNodes`, `registryVersion` | `core.js`, `docs/highlevel/_CONTRACT.md` | контракт модуля обновлён: новый срез вместо собственного `count*`-прохода |
| ✅ | 20 | **Срез `body` для кадрового синка физики**: `world.sync` ходит по узлам с телом, а не по всему миру | `core.js`, `world.js` | в сцене без физики проход исчез |
| ⚖ | 21 | **Данные массовых сущностей в типизированных массивах** (пули, частицы) | `particles.js` | **измерено и отложено**: микрозамер QuickJS (5000 частиц × 300 шагов) даёт −3 % на `Float32Array` и −18 % на обычном массиве чисел против массива объектов, то есть ~0,2 мс на 1000 частиц; цена — переписывание хранилища частиц с сохранением публичного `$.particles.at()` и двух наборов тестов. Это отдельная задача, а не полировка (§0.4, `tools/bench_storage.mjs`) |

Сознательно **не** делалось: `ctx.byTag`/`ctx.byClass` как поля `ctx` (аудит
называл их так) — вместо этого функции-экспорты `nodesByTag()`/`nodesByClass()`.
Причина: поле-`Map` в `ctx` приглашает писать в индекс руками; функции отдают
только чтение, а объекты-срезы уже помечены в контракте как read-only.

**Что осталось за P2.** Отложенное удаление из реестра (вторая половина пункта
14) — по-прежнему нет: `churn` без `$.batch` стоит O(K·N), и это осознанно
(§0.2). Ответ для игры — `$.batch`, он снимает 37 % на 5000 узлах (§0.3).

### Что даст в сумме (оценка по замерам)

| Сцена | Прогноз аудита | Факт |
|---|---:|---:|
| пустая сцена | ≈0,3 | **0,18** |
| **платформер из `game/` (169 узлов)** | ≈3 | **3,1** |
| 1000 спрайтов | 6–8 | **8,97** |
| 1000 спрайтов + `$('.mob')` в кадре | 8–10 | **9,80** |
| 1000 ui-узлов | ≈8 | **10,22** |
| 5670 тайлов | ≈9 | **13,35** |

Остаток на 1000 спрайтов — сборка батча (7,7 мс), и он почти весь в C
(`engine.submitSprites` в Debug) плюс сам проход по узлам; логика кадра — 1,3 мс.
Тайлмап и интерфейс упираются в ту же цену C на спрайт (5670 тайлов — 13,3 мс,
то есть 2,3 мкс на спрайт), а не в JS-слой.

То есть цель «1000 живых сущностей на 60 FPS с запасом на логику игры»
достижима правками P0+P1, без переписывания рендера и без трогания C — а штатная
игра перестаёт упираться в селекторы уже после P0.

---

## 6. Как проверить результат

```bash
# 1. Собрать (Debug — как в отчёте; для релизных чисел — build-release)
cmake --build build -j

# 2. Быстрый набор: 11 прогонов, ~1 минута
python3 tools/bench_highlevel.py

# 3. Сравнить с эталоном (JSON со всеми зонами)
python3 tools/bench_highlevel.py --full --repeat 3 --json build/bench_after.json

# 3а. Штатная игра: сколько стоит её собственный update
python3 - <<'PY'
import os, sys; sys.path.insert(0, "tools")
from agent_client import Agent
with Agent(game="game", scene="platformer", seed=7, start_timeout=60) as a:
    a.step(120)
    a.cmd("eval", code="engine.profileReset(); "
                           "$.debug.profiler.on(true); $.debug.profiler.reset()")
    a.step(120)
    z = {r["name"]: r["ms"] for r in a.eval("engine.profile()")["zones"]}
    print("JS логика %.2f мс, батч %.2f мс" % (z["JS: логика"], z["JS: сборка батча"]))
    print("код игры (метка «логика игры»): %.2f мс"
          % a.eval("$.debug.profiler.report()['логика игры'].avg_ms"))
PY

# 4. Разбор по подсистемам для конкретной сцены (метки точны — см. §0.1)
python3 - <<'PY'
import sys, os; sys.path.insert(0, "tools")
from agent_client import Agent
with Agent(game="tests/fixtures/bench", scene="sprite:1000", seed=1) as a:
    a.step(30)
    a.cmd("eval", code="engine.profileReset(); "
                           "$.debug.profiler.on(true); $.debug.profiler.reset()")
    a.step(90)
    for name, v in sorted(a.eval("$.debug.profiler.report()").items(),
                          key=lambda kv: -kv[1]["avg_ms"]):
        print("%-22s %7.3f мс" % (name, v["avg_ms"]))
PY

# 5. Полный набор агентских тестов (ничего не должно сломаться)
python3 tools/run_tests.py
```

Фактические «зелёные» ориентиры после P0 (тот же стенд, Debug, медиана трёх
прогонов): `sprite:1000` — JS логика 1,5 мс, всего 12,8 мс; `query:1000` — 13,8;
`id:1000` — 12,9; пустая сцена — 0,25; тайлмап 5670 тайлов — 14,7.
Остаток на 1000 спрайтов — сборка батча (11 мс), это P1 (§3.4).
Порогов в тестах нет намеренно: замер зависит от машины, а тест с секундами
в качестве условия — источник ложных падений.

Сравнение «до/после» на одной машине: соберите эталонный бинарник из исходного
JS и прогоните оба перекрёстно (`--binary`):

```bash
# эталон: исходный JS и принудительная перегенерация встроенной таблицы
# (cmake не увидит правку, если вернуть файлы копией с сохранением mtime)
git stash push src/highlevel
rm -f build/generated/r2d_js_data.h && cmake --build build -j
cp build/russiano2d build/russiano2d-base
git stash pop
rm -f build/generated/r2d_js_data.h && cmake --build build -j

python3 tools/bench_highlevel.py --binary build/russiano2d-base --repeat 3 --json build/ab_base.json
python3 tools/bench_highlevel.py --binary build/russiano2d      --repeat 3 --json build/ab_new.json
```

---

## 7. Что уже сделано хорошо (не ломать)

* **Батчинг отрисовки**: спрайты пишутся в `Float32Array`/`Uint32Array`, в C уходит
  один `submitSprites` на непрерывный участок по режиму смешивания (`render.js:216-251`).
* **Zero-copy трансформы**: `engine.getTransforms()` отдаёт тот же `Float32Array`,
  без копии в JS (`script.c:855-862`).
* **Переиспользуемые снимки вместо новых массивов**: `animplayer.js:1043`,
  `all_list`/`body_list`/`zone_list` в `triggers.js:148-166`. С P2 к этому
  добавились срезы индекса реестра (§0.4): `tickAnim` больше не собирает
  `tick_list`, а идёт по срезу `clip`.
* **Ранние выходы** у половины подсистем: `tickNav`, `tickHttp`, `tickI18n` (без
  `auto`), `tickScreen`, `tickDialog`, `tickFx`, `tickTweens`, `tickTweenObjects`,
  `tickCameraAnimations`, `tickInput`, `debug._render`.
* **Кэш-подписи вместо пересчёта**: `_wsig`/`_tsig` в виджетах, `anchors_dirty`.
* **Пулы частиц** с обменом последним элементом, лимиты на отрисовку и ленту.
* **Отсечение по камере** (`render.js:915-918`) и ленивая пересборка тайлмапа
  (правки только ставят `dirty`).
* **Акустика**: движок дёргается только при сдвиге параметра > 0,002.
* **Навигация**: репасинг A* по таймеру и только при сдвиге цели.

---

## Приложение А. Соответствие зон профайлера и подсистем

**Историческая справка.** Таблица ниже описывала сдвиг меток, который был в
аудите; в P0 метки починены (§0.1, правка №7), и теперь имя метки — это имя
измеренного кода. Таблица оставлена, чтобы можно было читать старые отчёты и
замеры §2 этой версии документа.

| Метка в отчёте | Что измерено |
|---|---|
| синк физики | `ctx.world.sync()` |
| контакты | `dispatchContacts()` |
| сцена | `ctx.scene._tick()` |
| время | `tickTime()` + `tickWindow()` |
| окно | `$.ready` + `scene.update` + хуки `$.update` |
| логика игры | `animateSprites()` + `applyControls()` |
| анимация+ввод | `tickAnim()` |
| анимация | `tickAnimPlayer()` |
| плеер анимации | `tickState()` |
| состояния | `tickFlow()` |
| последовательности | `tickScreen()` |
| экраны | `tickDialog()` |
| диалоги | `tickTilemap()` |
| tilemap | `tickFx()` |
| vfx | `tickParticles()` |
| частицы | `tickNav()` |
| навигация | `tickPrefab()` |
| префабы | `tickLayers()` |
| слои | `tickWidgets()` |
| виджеты | `tickTriggers()` |
| триггеры | `tickI18n()` |
| i18n | `tickPool()` |
| пулы | `tickViewport()` |
| вьюпорты | `tickHttp()` |
| http | `tickAudiobus()` |
| шины звука | `tickAcoustics()` |
| акустика | `ctx.ui._tick()` |
| интерфейс | ничего (метка сразу закрывается) |

## Приложение Б. Ограничения аудита

* Замеры — в **Debug**-сборке на Apple M4; структура расходов верна и на релизе,
  абсолютные числа будут ниже (проверять — на `build-release`).
* Быстрый набор — по одному прогону на точку (`--repeat 1`); для точных
  сравнений до/после берите `--repeat 3` (медиана).
* Профиль кадра в стенде включает **всю** сцену, поэтому в зону «JS логика»
  попадают и подсистемы, и код игры; разложение по подсистемам — в §2.2 и
  §1.3 (с поправкой на сдвиг меток).
* Аудит покрытия считает «обёрнутым» биндинг, который встречается в
  `src/highlevel/*.js`; часть из них обёрнута тонко (без валидации и умолчаний) —
  это оценивалось отдельно и в таблицы не попало.
* Оценки эффекта в §5 — арифметика по измеренным зонам и микрозамерам, а не
  результат уже сделанных правок: код движка в рамках аудита **не менялся**.
