# Сборка и распространение игры

Движок умеет собирать проект в **самостоятельный исполняемый файл**: папка с
`main.js` и ассетами больше не нужна рядом с игрой. Сборка встроена в сам
движок; SDK использует тот же builder. Если движок использует динамические
библиотеки, builder копирует соседний `lib/`: распространяйте его вместе с игрой.

```bash
# Движок лежит там же, где собирался
./build/russiano2d build --project . --entry game/main.js --out mygame

# Запуск: папки проекта рядом нет, всё внутри
./mygame
```

> **macOS: переподпишите собранный файл.** Сборка дописывает груз в копию
> бинарника, и подпись исходного движка перестаёт совпадать:
>
> ```bash
> codesign --force --sign - ./mygame
> ```
>
> Без этого система может отказаться запускать файл («повреждён» или
> «killed: 9»). Сообщение сборки `переподписать не удалось` — это ожидаемое
> поведение, а не ошибка: подпись делается вручную отдельной командой.

Проверить сборку без запуска окна можно в агентском режиме:

```bash
./mygame --agent --headless --fixed-dt 0.0166666667
```

Движок ответит JSON-строками на команды со stdin — так собранную игру
проверяют тесты (см. [AGENT_API.md](AGENT_API)).

## Что попадает внутрь

| Слой | Как | Где искать источник |
|---|---|---|
| Скрипты | компилируются в байткод QuickJS по графу `import` от точки входа | `--entry`, по умолчанию `main.js` |
| Ассеты | каталоги `assets/` и каталог точки входа (`game/` либо корень проекта для `main.js`) | `--add <путь>` добавляет ещё |
| — | расшифровываются в память при старте | читаются через виртуальную файловую систему |

Известное ограничение: файлы, скрипты которые незаметны для движка (данные
уровней, таблицы, тексты), автоматически не подбираются — их добавляют флагом
`--add`. Перебираются все файлы, кроме `.js` (они и так в байткоде) и служебных
каталогов (`build`, `.git`).

## Своё имя окна и размер

Имя окна, стартовый размер и версию игра описывает сама — файлом
`project.json` рядом с точкой входа (там же, где `main.js`):

```json
{
  "title":   "Моя игра",
  "version": "1.0.0",
  "width":   1600,
  "height":  900
}
```

Все поля необязательны. Этот файл попадает в груз вместе с остальным проектом,
поэтому **собранная игра берёт имя окна оттуда** — движок пересобирать не нужно.

Старшинство при запуске:

| Источник | Когда применяется |
|---|---|
| `--title "…"`, `--width`, `--height` | всегда старше остальных (быстрая проверка) |
| `project.json` проекта | обычный случай |
| значения движка | если ничего не задано: `Russiano2D <версия>`, 1280×720 |

Тот же манифест читается и из груза собранной игры, и с диска при `--game`.

Уже во время игры окно меняется из скриптов — `$.window` (см. `docs/HIGH_LEVEL_API.md`):

```js
$.window.title('Уровень 2');
$.window.fullscreen(true);
$.window.cursor('hidden');
$.window.on('resize', ({ w, h }) => relayout(w, h));
```

## Выбор GPU-бэкенда

Движок рисует через SDL3 GPU и включает все три формата шейдеров (SPIR-V, MSL,
DXIL); конкретный бэкенд SDL выбирает сам. Если нужно назвать его вручную — при
отладке драйверов или чтобы обойти проблемный бэкенд:

```bash
# Что собрано в этой сборке
./build/russiano2d --list-gpu

# Выбрать бэкенд
./build/russiano2d --gpu metal
./build/russiano2d --gpu vulkan
R2D_GPU=vulkan ./build/russiano2d      # то же переменной окружения
```

Имена: `metal`, `vulkan`, `direct3d12` (принимается и привычное `d3d12`).
Если бэкенд недоступен, движок **не открывает пустое окно**, а пишет, что
именно не получилось, и перечисляет собранные бэкенды:

```text
[error] SDL_CreateGPUDevice("vulkan"): SDL_HINT_GPU_DRIVER vulkan unsupported!
[error] бэкенд "vulkan" недоступен в этой сборке или на этой машине.
        Собранные бэкенды: metal, vulkan
```

Отдельно про **D3D12**: он работает только с DXIL, поэтому нужна сборка с
поддержкой DXIL (DXC). Без неё отказ дополняется подсказкой:

```text
[error] D3D12 работает только с DXIL: нужна сборка движка с поддержкой DXIL
        (DXC) либо другой бэкенд — --gpu vulkan или --gpu metal
```

Почему так, а не «просто не запуститься». Раньше при неудаче печаталась одна
строка `SDL_CreateGPUDevice: …` без имени бэкенда и без списка доступных: на
машине с одним рабочим бэкендом приходилось угадывать. Теперь отказ называет
бэкенд, перечисляет альтернативы и не оставляет окна-зомби.

**Имя бэкенда задаётся при инициализации приложения** (`r2d_app_init` получает
его аргументом): структура приложения обнуляется внутри инициализации, поэтому
записать поле до вызова было бы недостаточно.

## Режимы сборки

**`--append` (по умолчанию).** Билдер копирует движок и приписывает к копии
контейнер с грузом и футер из 128 байт. Компилятор не нужен, работает быстро.
Минус: на macOS дописанные байты ломают подпись, поэтому билдер сам
переподписывает файл ад-хок подписью (`codesign --force --sign -`). Если
переподписать не удалось, команда печатается — выполните её вручную, иначе
система откажется запускать файл.

**`--relink <папка>`.** Билдер генерирует C-файл с грузом, который затем
вкомпилируется в движок:

```bash
./build/russiano2d build --project . --entry game/main.js --relink build-payload
cmake -S . -B build-payload -DR2D_PAYLOAD_FILE=build-payload/r2d_payload_data.c
cmake --build build-payload -j
./build-payload/russiano2d
```

Нужен компилятор и несколько минут, зато получается обычный бинарник: его можно
подписать настоящей подписью разработчика и распространять в сторе.

## Шифрование: что оно даёт и чего не даёт

Груз шифруется **ChaCha20-Poly1305** (RFC 8439). Реализация своя, без внешних
зависимостей; корректность подтверждается тест-векторами стандарта
(`r2d_crypto_selftest`, `tests/agent/build_test.py`). Выбран ChaCha20, а не
AES-256-GCM, потому что он проще в корректной реализации (нет таблиц AES и
GHASH), быстр на ARM и сразу даёт аутентификацию.

**Главное, что нужно понимать: это обфускация, а не защита.** Ключ обязан
лежать в исполняемом файле, поэтому настойчивый исследователь его достанет —
ломают не шифр, а ищут ключ. AES-256 против этого не помогает: математика шифра
не является слабым местом. Что реально сделано:

1. **Шифруется байткод, а не исходники.** Даже после расшифровки читать нечего:
   в байткоде нет ни исходного текста, ни отладочных данных
   (`JS_WRITE_OBJ_STRIP_SOURCE | JS_WRITE_OBJ_STRIP_DEBUG`).
2. **Ключ не лежит в файле целиком.** В футере хранится блок, из которого ключ
   получается вместе с постоянной солью внутри движка: нужно найти и понять оба.
3. **Ключ уникален для каждой сборки** и берётся из системного источника
   случайных чисел (`/dev/urandom`, `BCryptGenRandom`).
4. **Подмена обнаруживается.** Тег Poly1305 считается по грузу; изменение хотя
   бы одного байта приводит к отказу загрузки со словами «файл повреждён или
   подменён», а не к тихой поломке.
5. **Расшифрованное стирается из памяти** при завершении и освобождается
   `r2d_secure_zero`.

Итог: стоимость копирования растёт с «перетащил папку» до «нужен отладчик и
время». Защиты от целенаправленного взлома это не даёт и дать не может —
единственная настоящая защита игровой логики это сервер.

### Включить и выключить

| Платформа | По умолчанию |
|---|---|
| Linux, Windows | **шифрование включено** |
| macOS | **выключено** |
| любая | перебивается флагом |

```bash
russiano2d build --project . --entry mygame/main.js --out mygame           # по умолчанию
russiano2d build ... --encrypt                                            # включить принудительно
russiano2d build ... --no-encrypt                                         # выключить принудительно
```

**Почему на macOS иначе.** Собранный файл на macOS всё равно переподписывают
(`codesign --force --sign - имя`), иначе система откажется его запускать, а
любая правка уже слинкованного бинарника подпись сбрасывает. Поэтому на маке
шифрование по умолчанию выключено, чтобы не делать лишний шаг; когда нужен
именно зашифрованный груз — просто добавь `--encrypt`.

Флаг `--no-encrypt` оставляет только байткод. Смысл в нём есть: размер меньше
(нет накладных расходов шифра и тега) и нет иллюзии защиты.

## Раскладка собранного файла

```
[ байты движка ][ контейнер ][ футер 128 байт ]
```

Футер: магия `R2DF`, версия, смещение и размер контейнера, 12-байтный
одноразовый номер, 16-байтный тег, 32-байтный замаскированный ключ, флаг
шифрования и дубль магии в конце — чтобы обрезанный файл не приняли за
собранную игру.

Контейнер: магия `R2DP`, версия, точка входа, число файлов и сами файлы
(путь, размер, данные). Скрипты лежат байткодом, остальное — как есть.

## Проверка собранной игры

```bash
./mygame --agent --headless --fixed-dt 0.0166666667   # тот же агентский протокол
```

Собранная игра отвечает на все команды протокола (см. `docs/AGENT_API.md`),
поэтому её проверяет тот же раннер: `python3 tools/run_tests.py --fast`.

Автотест сборки: `tests/agent/build_test.py` — собирает игру, проверяет, что
исходников в файле не осталось, что подмена байта ломает запуск и что собранная
игра отвечает на агентские команды.

Тест самого шифра: `./build/tests/r2d_crypto_test` — три вектора RFC 8439 и
круговой прогон на 301 длине под AddressSanitizer и UBSan. Собирается вместе с
движком (`-DR2D_BUILD_TESTS=ON`, по умолчанию включено).

## Частые вопросы

**`не удалось прочитать модуль ...`** — модуль не попал в груз. Билдер собирает
статический граф `import` от точки входа, а затем **дополнительно компилирует все
`.js` каталога точки входа** — именно поэтому динамический
`import('./сцена/index.js')` в собранной игре работает. Не попадут только модули
из других каталогов: их добавляют флагом `--add`, помня, что имя модуля в грузе
должно совпасть с тем, что запрашивает код.

**`no function filename for import()`** — движок собирался со срезанной
отладочной информацией в байткоде. Не срезайте её: без имени модуля QuickJS не
может разрешить динамический импорт. Исходный текст при этом всё равно вырезан
(`JS_WRITE_OBJ_STRIP_SOURCE`).

**`груз: тег не сошёлся`** — файл изменён после сборки (в том числе дописан
вирусом или отредактирован hex-редактором). Соберите заново.

**На macOS игра не запускается после сборки** — не прошла переподпись:
`codesign --force --sign - mygame`.

**Игра запускается, но ассеты «не найдены»** — путь в коде и путь в грузе не
совпали. Движок сверяет по хвосту пути, но если ассет лежит в каталоге, который
билдер не обходил, добавьте его: `--add <каталог>`.

---

## Сборка под веб (WebGPU)

Та же игра собирается **в браузерную страницу**: движок компилируется под
Emscripten, графика идёт через SDL_GPU с бэкендом WebGPU, интерфейс RmlUi
работает как обычно. Готовый каталог — `index.html` + `.js` + `.wasm` + `.data`.

```bash
# Первый раз: emsdk и SDL3 с WebGPU-бэкендом (ветка PR libsdl-org/SDL#16020)
# — пошагово в docs/WEB_EXPORT.md, там же все грабли.

# Дальше — одной командой:
python3 web/export.py --game demos --out dist/web-demos
```

| Флаг экспорта | Что делает |
|---|---|
| `--game <каталог>` | какую игру упаковывать (монтируется в MEMFS) |
| `--custom_loader <html>` | своя страница вместо `web/shell.html` |
| `--custom_image <png>` | своя картинка экрана загрузки |
| `--force_play` | начинать игру без кнопки «Играть» |
| `--index` | назвать страницу `index.html` (для выкладки в каталог сайта) |
| `--out <каталог>` | куда положить готовые файлы (без него только собираем) |
| `--emsdk`, `--sdl-prefix` | где лежат Emscripten и SDL3 с WebGPU |

Что уже работает в вебе: спрайты и батчер, свет и тени, пост-обработка и bloom,
физика Box2D, скрипты QuickJS, звук, интерфейс RmlUi, загрузочная страница с
маскотом. Чего нет: сети (UDP браузер не даёт), HTTP из игры, агентского режима
через stdin/stdout и сохранений между перезагрузками страницы. Полный разбор и
причины — [WEB_EXPORT.md](WEB_EXPORT).

Готовый пример такой сборки — `site/play/` (демо-меню на сайте).

### Библиотеки пакетного runtime

Если движок содержит соседний `lib/` с dylib/so, builder копирует библиотеки
рядом с output. Игра распространяется каталогом: executable + `lib/`.
Конфликтующий файл библиотеки отклоняется — выберите пустой каталог вывода.
Пакетный SDK проверяется распаковкой настоящего архива, не только запуском из checkout.
