# Веб-экспорт: движок в браузере (Emscripten + WebGPU)

Документ описывает **рабочий спайк** веб-сборки: движок запускается в браузере,
поднимает SDL_GPU на WebGPU, грузит игру из виртуальной ФС и рисует кадры.
Здесь же — все грабли, на которые мы наступили, чтобы их не искать заново.

Статус на момент написания: **игра играется в браузере**. Работает
`GPU-бэкенд: webgpu`, загрузка игры из виртуальной ФС, физика Box2D, QuickJS,
шрифты (кириллица), спрайтовый батчер, пост-обработка и bloom — всё на
WGSL-вариантах шейдеров. **RmlUi тоже работает**: игровое меню демо рисуется в
Chrome как обычно (для этого понадобился патч из §3.7 — без него бэкенд RmlUi на
WebGPU не поднимался вовсе, а после него пропадал текст). Веб-сборку можно
собирать и раздавать одной командой — [web/export.py](https://github.com/Nikide/russiano2d/blob/main/web/export.py) (§2.4).

Проверено на этом спайке:

* движок грузит `/demos/main.js`, строит сцену и рисует кадры (демо
  «Типичная ночь в Мытищинском лесу» игралось в видимом окне Chrome);
* шрифты, иконки Material Design и текст интерфейса RmlUi видны;
* нативная сборка и `tests/agent/*` остаются зелёными (см. §7).

Чего нет — в разделе «Что ещё не сделано».

---

## 1. Почему это вообще возможно именно так

У движка нет своего рендера: вся отрисовка идёт через **SDL_GPU** (см.
[ARCHITECTURE.md](ARCHITECTURE), [internal/NATIVE.md](internal/NATIVE) §7). Значит, веб-сборка
упирается не в движок, а в один вопрос: **есть ли у SDL_GPU бэкенд для браузера**.

Проверка фактов (на момент спайка):

* в апстриме SDL (ветка `main`, релиз 3.4.x) каталог `src/gpu` содержит только
  `d3d12`, `metal`, `vulkan` и `xr` — **WebGPU-бэкенда нет**;
* заявка на него — [libsdl-org/SDL#10768](https://github.com/libsdl-org/SDL/issues/10768)
  (открыта, milestone «3.x»);
* рабочий бэкенд живёт в открытом PR
  [libsdl-org/SDL#16020](https://github.com/libsdl-org/SDL/pull/16020)
  («Experimental WebGPU SDLGPU Backend», форк `TheeStickmahn/SDL_wgpu`).
  Автор прогнал на нём 34/34 примера SDL_GPU, включая веб (Chrome/Firefox/Safari),
  и подключил официальный порт Emscripten `emdawnwebgpu`;
* браузерный WebGPU принимает **только WGSL**: ни SPIR-V, ни MSL, ни DXIL.

Отсюда весь план: берём SDL из PR #16020, собираем движок под Emscripten и
переводим шейдеры на WGSL. Ничего в самом SDL мы не правим (и не должны: у
проекта SDL прямо запрещено принимать AI-сгенерированный код — см.
`.web/sdl-wgpu/AGENTS.md`).

---

## 2. Как собрать

### 2.1. Один раз: Emscripten

```bash
git clone --depth 1 https://github.com/emscripten-core/emsdk.git /tmp/r2d-web/emsdk
cd /tmp/r2d-web/emsdk && ./emsdk install latest && ./emsdk activate latest
source /tmp/r2d-web/emsdk/emsdk_env.sh
```

Спайк собран на Emscripten 6.0.11. Тулчейн ставится **вне репозитория**
(каталог `.web/` и `/tmp/r2d-web/` в `.gitignore`): это сотни мегабайт чужих
исходников.

### 2.2. Один раз: SDL3 с WebGPU-бэкендом

```bash
git clone --depth 1 https://github.com/TheeStickmahn/SDL_wgpu.git .web/sdl-wgpu

source /tmp/r2d-web/emsdk/emsdk_env.sh
emcmake cmake -S .web/sdl-wgpu -B /tmp/r2d-web/sdl-build -G Ninja \
  -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_INSTALL_PREFIX=/tmp/r2d-web/sdl-install \
  -DSDL_WEBGPU=ON -DSDL_WEBGPU_EMSCRIPTEN=ON \
  -DSDL_TESTS=OFF -DSDL_EXAMPLES=OFF -DSDL_SHARED=OFF -DSDL_STATIC=ON
cmake --build /tmp/r2d-web/sdl-build --target install -j8
```

Что должно получиться в отчёте конфигурации:

```
-- Enabled backends:
--   Video drivers: dummy emscripten offscreen
--   GPU drivers: openxr webgpu
```

`SDL_WEBGPU_EMSCRIPTEN=ON` добавляет к SDL3 tarball-зависимость
`--use-port=emdawnwebgpu`; она уезжает в `SDL3staticTargets.cmake` и дальше
подхватывается при линковке движка автоматически.

### 2.3. Сборка движка под веб

```bash
source /tmp/r2d-web/emsdk/emsdk_env.sh
SRC=$PWD
DEPS=$SRC/build/_deps        # нативные исходники зависимостей — чтобы не качать заново
emcmake cmake -S "$SRC" -B /tmp/r2d-web/build -G Ninja \
  -DCMAKE_BUILD_TYPE=RelWithDebInfo \
  -DCMAKE_PREFIX_PATH=/tmp/r2d-web/sdl-install \
  -DFETCHCONTENT_SOURCE_DIR_SDL3_IMAGE="$DEPS/sdl3_image-src" \
  -DFETCHCONTENT_SOURCE_DIR_QUICKJS="$DEPS/quickjs-src" \
  -DFETCHCONTENT_SOURCE_DIR_BOX2D="$DEPS/box2d-src" \
  -DFETCHCONTENT_SOURCE_DIR_STB="$DEPS/stb-src" \
  -DFETCHCONTENT_SOURCE_DIR_VISIBILITY="$DEPS/visibility-src" \
  -DFETCHCONTENT_SOURCE_DIR_SDL3_MIXER="$DEPS/sdl3_mixer-src" \
  -DR2D_WEB_PRELOAD="web/game@/game;demos@/demos;assets@/assets" \
  -DR2D_WEB_SHELL="$SRC/web/shell.html"

cmake --build /tmp/r2d-web/build -j8
```

На выходе в `/tmp/r2d-web/build`:

| Файл | Что это |
|---|---|
| `russiano2d.html` | страница-обёртка (`web/shell.html`) |
| `russiano2d.js` | загрузчик Emscripten |
| `russiano2d.wasm` | движок: QuickJS, Box2D, SDL3, SDL3_image, SDL3_mixer |
| `russiano2d.data` | груз: то, что перечислено в `R2D_WEB_PRELOAD` (MEMFS) |
| `russiano2d-mascot.png`, `russiano2d.png` | картинки самой страницы |

### 2.4. Экспорт игры (страница, картинка загрузки, старт без кнопки)

Собирать можно и вручную (команда выше), но для «экспорта» есть обёртка —
[web/export.py](https://github.com/Nikide/russiano2d/blob/main/web/export.py). Она готовит конфигурацию, собирает и
раскладывает готовые файлы:

```bash
# демо со стандартной страницей (маскот + кнопка «Играть»)
python3 web/export.py --game demos --out dist/web-demos

# своя страница, своя картинка, старт без кнопки
python3 web/export.py --game demos --out dist/web-demos \
    --custom_loader my/loader.html --custom_image my/logo.png --force_play
```

| Флаг | Что делает | Переменная сборки |
|---|---|---|
| `--custom_loader <html>` | своя HTML-страница вместо `web/shell.html` | `R2D_WEB_SHELL` |
| `--custom_image <png>` | своя картинка экрана загрузки (по умолчанию маскот из README) | `R2D_WEB_LOADER_IMAGE` |
| `--force_play` | начинать игру сразу после загрузки, без кнопки «Играть» | `R2D_WEB_FORCE_PLAY` |
| `--game <каталог>` | какую игру упаковывать (монтируется в MEMFS) | `R2D_WEB_PRELOAD` |
| `--preload "a@/a;b@/b"` | свой список груза | `R2D_WEB_PRELOAD` |
| `--out <каталог>` | куда положить готовые файлы (без него только собираем) | — |
| `--emsdk`, `--sdl-prefix` | где лежат Emscripten и SDL3 с WebGPU | — |
| `--precompress` | рядом с `.wasm/.js/.html/.data` положить `.gz` (и `.br`, если есть модуль `brotli`) для серверов со статическим сжатием; в конце печатается таблица размеров | — |
| `--debug` | `RelWithDebInfo` вместо `Release` (с именами функций в wasm-стеке) | `CMAKE_BUILD_TYPE` |
| `--clean` | снести каталог сборки перед сборкой | — |

При копировании в `--out` картинка экрана загрузки сжимается до палитры в 256
цветов (нужен Pillow; без него остаётся как есть): штатный маскот — 1,4 МБ →
0,24 МБ без видимой разницы, а грузится она раньше полосы прогресса.

Из груза автоматически исключаются файлы, которые игре не нужны: `*.md`, `*.py`,
исходники редакторов (`*.psd`, `*.kra`, `*.xcf`, `*.aseprite`, `*.blend`),
`*.bak`, `.DS_Store`. Список — `R2D_WEB_PRELOAD_EXCLUDE` в
[cmake/Web.cmake](https://github.com/Nikide/russiano2d/blob/main/cmake/Web.cmake) (`emcc --exclude-file`). Данные, которые
игра читает в рантайме, в него добавлять нельзя.

Про `--force_play` честно: кнопка «Играть» существует не для красоты — браузер
не даёт открыть аудиоустройство без действия пользователя и **запускает звук
только после клика**. С `--force_play` движок стартует сразу, и звук в браузере
может не заиграть до первого клика по странице. Если игра со звуком — оставляйте
кнопку.

В свою страницу (`--custom_loader`) можно вставлять те же подстановки, что и в
штатную: `@R2D_WEB_LOADER_IMAGE_NAME@` (имя картинки рядом со страницей) и
`@R2D_WEB_FORCE_PLAY_JS@` (`true`/`false`). Обязательный элемент один —
`{{{ SCRIPT }}}`: на его место Emscripten подставляет загрузчик.

### 2.5. Запуск и проверка

```bash
python3 tests/web/smoke.py --build-dir /tmp/r2d-web/build --headed      # посмотреть глазами
python3 tests/web/smoke.py --build-dir /tmp/r2d-web/build               # headless-проверка
python3 tests/web/smoke.py --build-dir /tmp/r2d-web/build \
    --engine-args="--game,demos" --frames 600 --headed
```

`tests/web/smoke.py` поднимает HTTP-сервер над каталогом сборки, открывает
страницу с `?autostart=1` и собирает **маячки прогресса** со страницы
(`/__r2d_progress`). Вердикт ставится по журналу самого движка, а не по
пикселям canvas:

| Признак | Что значит |
|---|---|
| `first-frame` | движок отрисовал первый кадр (страница по нему гасит экран загрузки) |
| `stopped: R2D-WEB-SMOKE: …` | движок дошёл до лимита кадров и остановился; в строке — справочная статистика кадра |
| `shutdown: <шаг>` | шаги завершения (gpu-idle → script-exit → audio → app → done): видно, если страница «залипает» на выходе |
| `abort`, `uncaptured error`, `memory access out of bounds` | провал |

Страница шлёт маячки `/__r2d_progress` **только** при `?autostart=1` или
`?beacon=1` (смок передаёт оба): на обычном хостинге такого адреса нет, и без
этого каждая строка журнала была бы запросом с ответом 404. Если WebGPU у
браузера нет (`navigator.gpu`), страница сразу пишет об этом и не показывает
кнопку «Играть» — вместо минуты ожидания и падения движка. (Сам груз при этом
всё равно докачивается: загрузчик Emscripten стартует из `{{{ SCRIPT }}}`.)

Пиксельная статистика (`colors`, `bright`) печатается справочно: `drawImage` по
WebGPU-canvas в некоторых состояниях отдаёт пустое изображение, поэтому как
единственный критерий она не годится. Подробности и флаги —
`tests/web/README.md`.

Готовый пример такой сборки для сайта — `site/play/` (меню демо на странице
`r2d.nikiniki.ru/play`), собирается той же командой с `--index`.

---

## 3. Что именно пришлось сделать в движке

### 3.1. Шейдеры: WGSL вместо SPIR-V/MSL

Сейчас движок собирает шейдеры на этапе сборки: GLSL → SPIR-V (glslang) →
MSL (spirv-cross), оба варианта вкомпилированы в `r2d_shaders.h`
([cmake/Shaders.cmake](https://github.com/Nikide/russiano2d/blob/main/cmake/Shaders.cmake)). Браузеру нужен третий вариант —
WGSL, и SPIRV-Cross его не умеет, поэтому WGSL написан руками:
[shaders/wgsl/](https://github.com/Nikide/russiano2d/blob/main/shaders/wgsl) — девять файлов, по одному на пару
«шейдер + стадия».

Что важно при написании WGSL под SDL_GPU:

* **группы привязок**: у вершинной стадии ресурсы в группе 0, юниформы в 1;
  у фрагментной — ресурсы в 2, юниформы в 3. Это конвенция SDL_GPU, а не
  WebGPU: бэкенд разбирает WGSL-текст и строит по нему layout;
* **текстура и сэмплер идут парами, в порядке слотов**: `binding(0)` — текстура,
  `binding(1)` — её сэмплер, `binding(2)` — вторая текстура, `binding(3)` — её
  сэмплер (так у `post.frag`, где сцена и bloom). В GLSL это были два
  `sampler2D`;
* **точка входа** — всегда `main` (SDL подставляет это имя в `entrypoint`);
* `const` в WGSL требует константного выражения: то, что в GLSL было `const float x = clamp(u.p.y, …)`,
  в WGSL пишется `let`.

Сборка WGSL — отдельный режим `-DR2D_SHADERS_WGSL_ONLY=ON`: он не тянет
glslang и spirv-cross вовсе (в браузере они не нужны), а `EmbedShader.cmake`
кладёт в блоб только текст WGSL. Нативная сборка этим режимом не пользуется.

В [src/render.c](https://github.com/Nikide/russiano2d/blob/main/src/render.c) выбор формата расширен: WGSL — последний в
списке предпочтений, после SPIR-V, MSL и DXIL. Порядок такой намеренно: если
нативный WebGPU когда-нибудь научится принимать SPIR-V, менять ничего не надо.

### 3.2. ASYNCIFY — без него браузер виснет намертво

Самая дорогая находка спайка. WebGPU-бэкенд SDL ждёт асинхронные ответы
браузера (адаптер, устройство) **циклом**:

```c
while (!waitInfo.completed) {
    wgpuInstanceWaitAny(renderer->instance, 1, &waitInfo, 0);
    SDL_DelayNS(100);              // на Emscripten это emscripten_sleep()
}
```

В браузере такой цикл блокирует очередь событий: колбэк адаптера не может
выполниться, `SDL_CreateGPUDevice` не возвращается никогда. `emscripten_sleep`
отдаёт управление браузеру **только** в сборке с Asyncify. Требование записано
и в самом PR: `-sASYNCIFY=1` в `examples/CMakeLists.txt` с комментарием
«The WebGPU SDLGPU backend requires Asyncify».

Поэтому [cmake/Web.cmake](https://github.com/Nikide/russiano2d/blob/main/cmake/Web.cmake) линкует с `-sASYNCIFY=1`. Цена —
рост `.wasm` (у нас 15.6 МБ → 17.3 МБ) и замедление вызовов. Альтернативы на
будущее: JSPI (Chrome) или неблокирующий путь создания устройства в бэкенде.

Как это ловилось: сначала зависал движок, потом — изолированный щуп без единой
строчки движка ([web/probe/gpu_probe.c](https://github.com/Nikide/russiano2d/blob/main/web/probe/gpu_probe.c)): он показал,
что виснет именно `SDL_CreateGPUDevice`, а с Asyncify рисует 120 кадров.

### 3.3. Главный цикл: `while` в браузере нельзя

`src/main.c` крутил кадры в `while (app.running)`. В браузере это блокирует
вкладку, поэтому кадры отданы `emscripten_set_main_loop_arg`
(функция `r2d__web_frame`), а завершение приложения вынесено в общий
`r2d__shutdown()` — один порядок остановки для нативного цикла и для
браузерного колбэка. Лимиты `--frames` / `--seconds` работают в обеих сборках:
на них строится дымовой прогон.

### 3.4. Отложенный старт: `-sINVOKE_RUN=0` + `Module.callMain`

Браузер не даёт открыть аудиоустройство без действия пользователя, а движок
поднимает звук в `r2d_app_init`. Поэтому `main()` больше не запускается сам:
страница показывает экран загрузки с кнопкой «Играть», и движок стартует по
клику. Для автотестов есть `?autostart=1`.

### 3.5. WebGPU строже остальных бэкендов: состояние прохода = состояние конвейера

Вторая по стоимости находка. WebGPU требует, чтобы **состояние вложения
render-прохода совпадало с конвейером точно**: если пайплайн объявлен с форматом
глубины, проход обязан нести depth-вложение того же формата. Vulkan и Metal к
такому расхождению снисходительны, поэтому движок его допускал:

* конвейеры спрайтов создаются с `depth_stencil_format = D32_FLOAT`
  (`src/render.c`), потому что z-буфер включён по умолчанию;
* интерфейс (`r2d_render_draw_ui`) рисуется **теми же** спрайтовыми
  конвейерами;
* а HUD-проход открывался вообще без depth-вложения — и пост-обработка
  рисовалась в одном проходе с HUD.

На WebGPU это даёт `Attachment state of [RenderPipeline] is not compatible with
[RenderPassEncoder]`, командный буфер становится невалидным — и **кадр не
рисуется вообще**, хотя движок бодро считает кадры. Симптом в игре: чёрный
экран и `colors=1` в проверке кадра.

Починка в `src/main.c`: HUD-проход получил то же depth-вложение (с
`LOADOP_LOAD`, чтобы не стирать посчитанную глубину), а пост-обработка и
интерфейс разъехались на два прохода — у конвейера поста глубины нет, и
мешать его с HUD нельзя.

### 3.6. Экран загрузки: ждём готовности, потом отдаём кадр

Страница ([web/shell.html](https://github.com/Nikide/russiano2d/blob/main/web/shell.html)) сделана по образцу веб-экспорта
Godot: маскот из README, полоса прогресса, подпись «Инициализация P2D» и кнопка
«Играть». Две неочевидные детали:

* **движок стартует по клику** (`-sINVOKE_RUN=0` + `Module.callMain`) — браузер
  не даёт открыть аудиоустройство без жеста пользователя, а движок поднимает
  звук при старте;
* **экран не исчезает по клику**: он гаснет только после ПЕРВОГО кадра
  (`window.__r2dOnFirstFrame`, зовётся из `src/main.c`), и до того же момента
  у canvas стоит `pointer-events: none`.

Второе — не украшение, а лечение падения: пока движок поднимает
WebGPU-устройство, он «спит» в Asyncify (`emscripten_sleep`), и любое событие
браузера, зашедшее в wasm в этот момент, рушит кучу. Именно это ловилось как
`memory access out of bounds` в `SDL_PushEvent` из `Emscripten_HandleResize` и
`sdlEventHandlerPointerEnter` — а приходило оно ровно потому, что раньше
страница при старте меняла раскладку (прятала оверлей, показывала canvas) и
слала resize и pointer-события в спящий wasm. Теперь раскладка стабильна с
первого кадра, сцена стоит в DOM всегда, а ввод включается после первого кадра.

### 3.7. RmlUi в вебе: два дефекта, один патч

Интерфейс движка — это RmlUi ([UI_RMLUI_LAW.md](UI_RMLUI_LAW)), поэтому без
него веб-сборка бессмысленна. Сам RmlUi под wasm собирается штатно (у него даже
есть готовый `FindFreetype` для Emscripten — шрифт берётся из порта
`-sUSE_FREETYPE=1`). На WebGPU мешали две вещи, обе — в его бэкенде под SDL_GPU.

**Дефект 1: бэкенд не знает WGSL.** В таблице форматов у него всего три —
SPIR-V, MSL и DXIL, — и на WebGPU-устройстве он честно пишет
`Invalid shader format` и не поднимается. Шейдеров всего три
(`Backends/RmlUi_SDL_GPU/shader_*.{vert,frag}`: вершинный с матрицей и
переносом, цветной и текстурный), поэтому:

* WGSL-тексты этих трёх шейдеров лежат **в движке** —
  [src/rmlui_wgsl.c](https://github.com/Nikide/russiano2d/blob/main/src/rmlui_wgsl.c). Они попадают в бинарник, в рантайме
  ничего не читается;
* в бэкенд добавлена ветка «если устройство умеет WGSL — спросить у движка».

**Дефект 2: атлас глифов не доезжал до GPU.** RmlUi освобождал transfer-буфер
**до** закрытия копирующего прохода:

```c
SDL_UploadToGPUTexture(copy_pass, …);
SDL_ReleaseGPUTransferBuffer(device, transfer_buffer);   // ← слишком рано
SDL_EndGPUCopyPass(copy_pass);
SDL_SubmitGPUCommandBuffer(command_buffer);
```

На Vulkan и Metal такой порядок прощался, а WebGPU копирование кодирует при
закрытии прохода — буфер к этому моменту уже освобождён, текстура остаётся
пустой. Симптом был издевательский: интерфейс рисовал **геометрию** (панели,
кнопки), но ни одной буквы — текстуры у RmlUi пустые, а цветные квады рисуются
шейдером без текстуры. Движок в своём коде делает правильно: сначала
`SDL_EndGPUCopyPass` и отправка, потом `SDL_ReleaseGPUTransferBuffer`
([src/render.c](https://github.com/Nikide/russiano2d/blob/main/src/render.c)).

Обе правки лежат одним патчем в репозитории:
[third_party/patches/rmlui-webgpu.patch](https://github.com/Nikide/russiano2d/blob/main/third_party/patches/rmlui-webgpu.patch).
Он применяется **автоматически на конфигурации веб-сборки**
(`cmake/Dependencies.cmake`), идемпотентно (по маркеру; если дерево осталось с
прошлой версией патча — файл возвращается к состоянию из репозитория RmlUi и
патч кладётся заново), а если контекст не совпал — сборка падает с понятным
текстом, а не собирается без интерфейса. Обе правки заперты под
`#if defined(__EMSCRIPTEN__)`: нативная сборка компилирует тот же файл и ведёт
себя как раньше.

Отдельная тонкость: RmlUi добавляет свой Emscripten-каталог с `FindFreetype` к
`CMAKE_MODULE_PATH` только когда собирается как корневой проект. Мы встраиваем
его через FetchContent, поэтому каталог добавляем сами (`cmake/Dependencies.cmake`),
иначе конфигурация веба падает на «Freetype could not be found».

### 3.8. Шрифт по умолчанию — с кириллицей

Это не веб-специфичная правка, но нашлась она именно на веб-сборке: текст
интерфейса рисовался латиницей, а русские слова молча пропадали. Причина —
`autoload_default_font` в [src/font.c](https://github.com/Nikide/russiano2d/blob/main/src/font.c) брал **первый файл по
алфавиту** из `assets/fonts`, а им оказывался `LatoLatin-Regular.ttf` — шрифт
без кириллицы (глифов нет — рисовать нечего, ошибки нет).

Теперь семейство по умолчанию выбирается так:

1. явный список предпочтений (сейчас `Open Sans` — он же лежит в
   `assets/fonts/OpenSans-Regular.ttf` вместе с `LICENSE-OpenSans.txt`);
2. иначе — первый шрифт каталога, в котором **действительно есть кириллица**
   (проверка по глифу «А», U+0410);
3. иначе — первый по алфавиту плюс честное предупреждение в журнале.

Список кандидатов сортируется, поэтому выбор не зависит от порядка файлов в
каталоге. Шрифты — SIL OFL 1.1, уведомления обновлены в
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES).

### 3.9. Бытовые грабли, которые стоили времени

| Грабли | Что было |
|---|---|
| Emscripten ищет пакеты только в своём sysroot | `CMAKE_FIND_ROOT_PATH_MODE_PACKAGE=ONLY` в тулчейне: SDL3 из `/tmp/r2d-web/sdl-install` не находился, и движок **молча** собирался с апстримом SDL — без WebGPU вообще. Лечится `set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE BOTH)` плюс проверкой, что в найденном `SDL_gpu.h` есть `SDL_GPU_SHADERFORMAT_WGSL` (cmake/Web.cmake) |
| `JSValue` — не всегда структура | В QuickJS-ng при `JS_NAN_BOXING` (включён для wasm32) `JSValue` — это `uint64_t`. Самодельные `typedef struct JSValue JSValue;` в [src/http.h](https://github.com/Nikide/russiano2d/blob/main/src/http.h) и [src/render.h](https://github.com/Nikide/russiano2d/blob/main/src/render.h) разъезжались с `quickjs.h`, и сборка падала на его inline-функциях. Теперь оба заголовка берут типы из `<quickjs.h>`; для нативных конфигураций с nan-boxing это тоже исправление |
| Заглушки были неполными | При `R2D_ENABLE_LIVE_SHADERS=OFF` не линковались `r2d_live_shader_*` (добавлен [src/shader_live_stub.c](https://github.com/Nikide/russiano2d/blob/main/src/shader_live_stub.c)), а в `gui_stub.c` не было `r2d_gui_load_markup` |
| `--preload-file` и относительные пути | Путь разрешается от каталога линковки: `-DR2D_WEB_PRELOAD="web/game@/game"` падало с «does not exist». Относительные пути теперь переводятся в абсолютные от корня репозитория |
| Правка оболочки не пересобирала страницу | `--shell-file` — опция линковки, а не зависимость: ninja отвечал «no work to do». Добавлен `LINK_DEPENDS` |
| `emdawnwebgpu` требует C++ | Линковать надо `em++`, а не `emcc`: «emdawnwebgpu requires C++». У движка C++ и так есть, а вот C-щуп пришлось собирать `em++` |
| Base path в браузере | `R2D_GAME_DIR`/cwd/`SDL_GetBasePath()` дают `/`; поэтому груз монтируется в `/game`, `/demos`, `/assets`, а движок запускается с `--game demos` (или `--game game` для тестовой сцены) |

### 3.10. Что выключено в веб-сборке и почему

Всё это — штатные опции движка ([cmake/Web.cmake](https://github.com/Nikide/russiano2d/blob/main/cmake/Web.cmake));
вместо выключенных подсистем работают существующие заглушки, поэтому
скриптовый слой не обрастает `#ifdef`.

| Опция | Почему выключена |
|---|---|
| `R2D_ENABLE_NET` | Сеть движка — датаграммы (UDP); браузер их не даёт. Нужен транспорт поверх WebSocket/WebRTC |
| `R2D_ENABLE_HOTRELOAD` | В браузере файлы не меняются: следить за mtime нечего |
| `R2D_ENABLE_HTTP` | libcurl в wasm не собирается; нужен бэкенд на fetch/XHR (и CORS) |
| `R2D_ENABLE_LIVE_SHADERS` | Компилятор собирает SPIR-V, а WebGPU его не принимает: `$.gfx.defineShader()` честно сообщает «не поддержано», встроенные эффекты работают |
| `R2D_EMBED_SCRIPTS` | Упаковщик — хост-инструмент; в вебе скрипты и так лежат в MEMFS |

---

## 4. Размеры и производительность

Две конфигурации, обе измерены на этом спайке (RmlUi, QuickJS-ng, Box2D v3,
SDL3 + SDL3_image + SDL3_mixer, FreeType из порта — всё внутри):

| Артефакт | Release (так собирает `web/export.py`) | RelWithDebInfo (отладка) |
|---|---|---|
| `russiano2d.wasm` | **9.4 МБ** | ~51 МБ (с DWARF) |
| `russiano2d.js` | 0.3 МБ | 0.3 МБ |
| `russiano2d.data` (демо целиком) | 51 МБ | 53 МБ |
| `russiano2d.data` (только игра + `assets`) | ~4 МБ | 3.8 МБ |

Отладочный профиль раздувает `.wasm` впятеро — это debug-символы и
`--profiling-funcs` (он включён намеренно: без имён функций wasm-ловушка
«RuntimeError: unreachable» не говорит, где упало). Для раздачи нужен Release.

Что ещё можно ужать, если понадобится: `-O3`/`-Os`, `--closure 1` для `.js`,
сжатие `.data`, отключение неиспользуемых подсистем. Спайк этого не делал.

Производительность: сцена демо в Chrome идёт без просадок на M-series, но
точных замеров спайк не делал — Asyncify замедляет вызовы, и это первое, что
стоит измерить, если понадобится держать 60 FPS на слабых машинах.

---

## 5. Что ещё не сделано

По убыванию важности:

1. **Звук в браузере.** Сборка с SDL3_mixer для Emscripten проходит, движок
   поднимает аудиоустройство («аудио готово: 48000 Гц, 2 канала»), но проверить
   звук в headless-браузере нельзя; кнопка «Играть» как жест пользователя для
   аудио уже сделана — нужен ручной прогон.
2. **Агентский режим и тесты.** `tests/agent/*` управляют движком через
   stdin/stdout, которых у страницы нет. Нужен мост на WebSocket, иначе
   автотесты остаются только нативными.
3. **Скриншоты и readback.** `--screenshot` пишет PNG в MEMFS; для CI нужен
   путь наружу (скачивание или отправка на сервер). Именно поэтому дымовой
   прогон веб-сборки проверяет кадр по статистике пикселей canvas, а не файлом.
4. **Сеть.** UDP в браузере нет: нужен WebSocket-транспорт под тот же интерфейс
   `net.h` (или признание, что мультиплеер — не для веба).
5. **Файлы и сохранения.** Сейчас MEMFS живёт до перезагрузки страницы;
   `$.store`/`$.save` в вебе требуют IDBFS или localStorage.
6. **Загрузка по требованию.** `.data` сейчас содержит весь груз; для больших
   игр нужен стриминг ассетов по fetch из `$.fs`.
7. **Размер сборки.** 51 МБ `.wasm` в RelWithDebInfo — это RmlUi целиком,
   QuickJS, Box2D, SDL3, FreeType и отладочные символы. Release-профиль
   (`web/export.py` по умолчанию), `-O3`, `--closure 1`, отключение DWARF и
   сжатие `.data` — отдельная работа, которую спайк не делал.
8. **JSPI вместо Asyncify.** Уменьшит размер и ускорит вызовы, но это Chrome-фича.
9. **Слежение за PR #16020.** Бэкенд экспериментальный: не потокобезопасен,
   обработка ошибок редкая, парсер WGSL понимает не весь язык. Как только он
   войдёт в апстрим, зависимость от форка уйдёт; если не войдёт — придётся
   решать, держать ли свой форк SDL.

Отдельным фронтом идёт **доведение демо-интерфейсов до RmlUi** (задание
проекта): лаунчер демо уже переведён (адаптивная вёрстка на `vh`, проверено на
1280×720, 1600×900 и 960×540), «ведьма» ещё рисует HUD узлами `<ui.*>`,
платформер — тоже (хотя `demos/ui/platformer-hud.rml` и `platformer-pause.rml`
уже лежат в репозитории и ждут подключения), новелла уже на RmlUi.

---

## 6. Соседние задачи, не относящиеся к вебу

* **Пустой запуск движка** (без груза игры) должен показывать маскота из README
  и подпись внизу по центру: «Ты забыл вставить игру, Дурка». Речь про нативную
  сборку без игры: сейчас движок открывает пустое окно с предупреждением в
  журнале.

---

## 7. Проверка нативного пути

Веб-специфичный код заперт за `EMSCRIPTEN` / `R2D_SHADERS_WGSL_ONLY`, но часть
правок общая, и её нужно проверять нативной сборкой:

* `JSValue`/`JSContext` берутся из `quickjs.h` в `src/http.h` и `src/render.h`
  (иначе сборка падает на конфигурациях QuickJS с nan-boxing);
* заглушки `src/shader_live_stub.c` и `r2d_gui_load_markup` в `gui_stub.c` —
  сборка с выключенными подсистемами обязана линковаться;
* `r2d__shutdown` в `src/main.c` — общий путь завершения для нативного цикла и
  веб-колбэка;
* `SDL_GPU_SHADERFORMAT_WGSL` в списке форматов — под `#ifdef`, потому что в
  апстриме SDL такой константы нет;
* шрифт по умолчанию (`autoload_default_font`) — общий для всех платформ;
* патч RmlUi компилируется и нативно (обе правки под `#if defined(__EMSCRIPTEN__)`).

Что прогнано после всех правок:

| Проверка | Результат |
|---|---|
| `cmake --build build` (нативная сборка) | ok |
| `python3 tools/run_tests.py` (полный набор `tests/agent/*`) | 82 теста, все зелёные |
| `python3 tests/agent/demos_test.py` | платформер, «ведьма», новелла и лаунчер — все проверки пройдены |

Тест демо обновлён вместе с интерфейсом: лаунчер теперь проверяется как документ
RmlUi (`$.ui.doc(...).listeners()` + «документ виден»), а не как набор узлов
`<ui.*>`; платформер из меню убран, но остаётся рабочей сценой и по-прежнему
проверяется (`--scene platformer`).
