HIGH_LEVEL_API_PERF.md 91,1 КБ
Newer Older
Nikitos's avatar
Nikitos включено в состав коммита
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# Производительность и полнота высокоуровневого API `$`

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

> **Статус.** Пункты **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 узлов.

62
**Полнота.** `$` — это 30+ подсистем и около 30 тыс. строк: для 2D-игры среднего
Nikitos's avatar
Nikitos включено в состав коммита
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
размера API покрыт хорошо. Из **168** уникальных биндингов `engine.*` обёрнуто
**138 (82 %)**; не обёрнуто 30, но по-настоящему нужен игре из них **один** —
`engine.keyName` (без него `$.input.on('key')` отдаёт код числом). Остальное —
геттеры состояния звука/физики, BSP, легаси-отрисовка и заглушки render target.
Настоящие дыры как игрового API (не как «необёрнутый биндинг»): пользовательские
шейдеры, слои коллизий, фигурный свип, `Curve`/`Gradient`, сеть, скелет/IK,
render target. Отдельно — **три мёртвых куска и врущие доки** (см. §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 %)**: цена удаления
перестаёт зависеть от размера мира.

Nikitos's avatar
Nikitos включено в состав коммита
265
Демки перешли на новый API там, где пачки действительно есть: «Типичная ночь в Мытищинском лесу»
Nikitos's avatar
Nikitos включено в состав коммита
266
267
268
269
270
271
272
273
274
275
(отжившие трассеры), `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`) — зелёные.

---

276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
## 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`) —
зелёные.

---

Nikitos's avatar
Nikitos включено в состав коммита
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
## 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` за кадр |

Прогон: 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 | 0 | 8 |
| плоские `engine.*` | 65 | 59 | 6 |
| **Итого** | **168** | **138** | **30** |

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

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

* `engine.keyName` (`script.c:2759`). Таблица `KEY_NAMES` (`input.js:390-395`)
  **никем не заполняется**, `keyNameOf()` — заглушка-тождество (`input.js:439`),
  поэтому `$.input.on('key')` отдаёт **код числом**. Без этого нельзя показать
  «нажмите Пробел» и нельзя сделать ребинд. Это единственная реально болезненная
  дыра в покрытии.

**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 не обёрнут вообще** (7 биндингов, `script.c:2735-2741`): единственный
  потребитель — `demos/bsp/index.js:128-257`. Либо `$.world.bsp`, либо честно
  объявить `engine.bsp` расширенным API.

**P3 — служебное:** `drawSprite`/`drawRect` (вытеснены `$.gfx.push`),
`getGravity`, `http.active`, `light.maxPoints`, `ui.iconCode`,
`audio.groupCount/groupEffect`, `engine.viewport.*` (честная заглушка — render
target не поддержан by design; `$.viewport` только проверяет
`engine.viewport.supported`, `viewport.js:84-95`, и дальше работает на своей
JS-реализации).

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

Закрыто с прошлого аудита (`docs/GAP_ANALYSIS.md`): анимация и `AnimationPlayer`,
тайлмапы, частицы, навигация и A*, префабы, шины звука с эффектами, слои и
parallax, UI-контролы (контейнеры, скролл, фокус, ввод текста, чекбоксы,
слайдеры, списки, диалоги), локализация, состояния/потоки, экраны, шрифты,
902
сохранения, CSV/сетки, сигналы. Это 30+ подсистем и около 30 тыс. строк.
Nikitos's avatar
Nikitos включено в состав коммита
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981

**Осталось незакрытым:**

* **пользовательские шейдеры** — `.shader()`/`.shaderParam()` заглушки
  (`api.js:633-640`), конвейеры фиксированные;
* **слои коллизий** — `.mask()/.layerBits()/.collidesWith()` пустышки
  (`api.js:806-808`); в `GAP_ANALYSIS.md` §2 при этом написано «layerBits/mask
  есть у тел» — неверно;
* **фигурный свип/CastShape** — в биндингах нет (есть только луч и точечный
  запрос);
* **`Curve`/`Gradient` как ресурсы** — рампы зашиты внутри `particles.js:226-228`;
* **сеть/мультиплеер**, **скелет/IK**, **render target** (заглушка,
  `render.c:1438-1448`), **NinePatchRect и импорт атласа**,
  `z_as_relative` и наследование `visible`/`modulate` родителем, приоритеты и
  stealing голосов, `seek` у звука.

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

### 4.4. Врущие доки и мёртвый код

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

1. **`installControls` импортирован, но не вызывается** (`api.js:20`,
   `input.js:442`): `def('controls')` регистрируется дважды (`api.js:1119` и
   `input.js:443`), живым остаётся вариант из ядра — без `fire:'mouse'` и
   `speed_axis`, которые обещает документация подсистемы ввода.
2. **Примеры `.each()` в справочнике не работают**: `docs/HIGH_LEVEL_API.md:21`
   и `:827` пишут `.each(e => e.moveTowards(...))`, тогда как колбэк получает
   `(i, el)` — `e` это **индекс** (`api.js:356-358`; правильная форма — строка 164
   того же документа). Код в примере молча ничего не делает.
3. **`def()` перезаписывает метод молча** (`core.js:621-623`) — так и появился
   двойной `controls`.
4. **`_CONTRACT.md` §5** перечисляет 227 имён, в живом API их 281 (+русские
   алиасы, `fsm`, `fsmSend`, `textStyle`).
5. **`GAP_ANALYSIS.md` §2** устарел в столбце «чего не хватает» для восьми
   подсистем (анимация, тайлмап, частицы, навигация, префаб, шины, слои, UI — всё
   уже реализовано и задокументировано).
6. Нет `docs/highlevel/<имя>.md` у 13 модулей и агентских тестов у 23 подсистем.

---

## 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 мс суммарно |

982
983
984
985
986
987
988
989
990
991
992
993
994
995
### 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.
Nikitos's avatar
Nikitos включено в состав коммита
996

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

Для ускорения просмотра не вся история отображается Просмотреть всю вину