gmod-rs/docs/PARITY.md
Niko Marmeladkov d6c49fd67a Константы по таблице GMod и устранены невидимые стены из плоскостей граней
Четыре жалобы из игры, все закрыты измерением.

Константы движения (gmodwiki.com/ConVars_In_Garrysmod, не source-sdk-2013):
  sv_friction         4 -> 8     торможение 250 -> 10 за 0,40 с, путь 26 юнитов
  sv_stopspeed     100 -> 10
  sv_gravity       800 -> 600    прыжок 45,6 -> 60,8 юнита, как в GMod
  sv_airaccelerate  12 -> 10
  sv_maxvelocity   350 -> 3500   падение 585/589/590 -> 600/1200/1800/2400/3000

«В воздухе управление слабее»: в AirAccelerate кап sv_air_max_wishspeed
ограничивает ЦЕЛЬ, а прибавка считается по некапленной wishspeed. Мы
ограничивали и то и другое — прибавка была в 8,3 раза слабее. Замер после
правки: рывок вбок даёт ровно 30,0 юнитов/с, как в Source. Поворот при
полёте вперёд по-прежнему заблокирован (проекция 177 >> капа 30) — это
верное поведение оригинала.

«Высота шага терялась единица»: подъём ровно на sv_stepsime ставит низ
габарита точно на грань ступени, грань считается касанием, и подшаг
блокирует сам себя. Замерено: брались уступы до 17 и застревали на 18.
В оригинале подъём на flStepSize + DIST_EPSILON. После правки — все до 18
включительно, 19 нет.

«Невидимые стены, рампа как параллелипипед»: грань кирпича ограничивает
плоскость, а не кирпич, и останавливает шаг где угодно на её уровне.
Замерено: игрок останавливался за 3,4 юнита до настоящей преграды, блокировала
верхняя грань соседнего кирпича. Правка: при t около нуля проверяется, что
точка касания лежит на самом кирпиче. Замер после правки: преград «из
воздуха» 0 (было 2), упоров за 30 с ходьбы 0.

Проверено и отброшено измерением: кисти не работают как ящик (аудит 2126
кирпичей, 0 расхождений из 129684 точек); displacement не выпадает из сетки
(11776 треугольников, выпало 0); скорость вдоль склона 239-263 против 250.

Пара sv_falldistance/fall_speed НЕ введена осознанно: в таблице cvar'ов GMod
её нет, а формула HL2 при отрицательной velocity[2] даёт замедление падения,
а не разгон. Ставить непроверенные константы значило бы гадать.

228 тестов зелёные, clippy -D warnings чист.
2026-10-14 07:52:39 +03:00

1299 lines
86 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# План Source RS: одиночная игра, совместимая с GMod
**Статус:** план согласован, начало исполнения (2026-10-12).
**Каталог:** `~/garrysmod-rs`.
**Связано:** `PLAN.md` (общий план), `PROBLEMS.md` (боли), `RECON.md` (реверс).
Каждый этап — рабочий коммит и проверяемый результат, а не заготовка.
---
## Главный принцип: совместимость, а не тождество внутренностей
Решение пользователя: «1 в 1 не надо прям, но надо совместимость».
Это меняет критерий готовности. Нас интересует не внутреннее устройство GMod,
а то, что снаружи него видно и от чего зависит чужой код:
| Что должно работать | Почему это «совместимость» |
|---|---|
| **Контент** карт и аддонов: BSP, VTF, VMT, MDL, VTX, а также `lua/` с аддона | аддон физически не запустится без этого |
| **Lua-API**: `Entity()`, `ents.*`, `Vector`/`Angle`/`Color`, хуки, таймеры, `file`, `surface`, `sound`, `render`, `cam`, `Curves` | код аддона должен выполняться без правок |
| **Поведение игрока и физика** | геймплей аддона завязан на это |
| **Формат карт**: сущности читаем так же, как в Source | иначе нельзя открыть чужую карту |
| **Сетевой протокол** (уже сделано) | мультиплеер |
Что **не** требуется: внутренние структуры движка, точное совпадение
побайтовых раскладок в нестандартных случаях, поддержка всего, что есть в
`base_ents` GMod.
Практическое следствие: там, где можно, сверяемся с исходниками Source.NET и
реальными файлами, а не угадываем; а публичные Lua-имена и сигнатуры берём из
настоящих `lua/includes/` GMod, а не придумываем.
---
## Порядок (подтверждён)
**A → B → C → D → E**, причём **свет (C) раньше game.dll (E2)** — так решил
пользователь.
---
## Часть A. Найденные дефекты (то, что сломано прямо сейчас)
Часть пунктов пользователь заметил визуально, часть подтверждена замером по
реальному `gm_construct.bsp`.
### A1. Коллизия: скорость не гасится о поверхность
**Симптом** (пользователь): «подхожу в стену, отпускаю клавиши — входит внутрь
чуть-чуть, потом выталкивает».
**Причина** (`crates/engine/src/player.rs:432`): `move_aabb()` возвращает только
`(Aabb, on_ground)` и **не трогает `self.velocity`**. Каждый кадр игрок
сдвигается на `velocity * dt` внутрь стены, получает выталкивание и оказывается
на грани — визуально «ныряет» и отскакивает. Горизонтальная скорость в стену
никогда не обнуляется.
**Как в Source**: `CMoveData` отдаёт `clipNormal`, скорость проектируется на
плоскость контакта: `v -= n * min(0, dot(v, n))`.
**Исправление**: `move_aabb` возвращает нормали контакта; `Player::update` гасит
компоненту скорости внутрь каждой нормали и обнуляет `velocity.z` при
`on_ground`. Плюс явное торможение при отпускании клавиш.
**Приёмка**: тест «в стену на полной скорости → после отпускания стоит
неподвижно, `|velocity.xz| < 1`, положение не дрейфует»; тест на отсутствие
просадки в стену при `dt = 1/60` и скорости 250.
### A2. Коллизия: твёрдость браша определяется неверно
**Симптомы**: «невозможно пройти в здания, хотя проход есть», «вода твёрдая».
**Причина** (`player.rs:43`): `const TRIGGER: i32 = 0x4000_0000;` и проверка
`if contents == TRIGGER { continue }`. То есть твёрдым считается **всё, кроме
ровно `0x40000000`** — включая браши с `contents == 0` (по спецификации Source
`CONTENTS_SOLID = 0x1`, всё остальное не твёрдо) и браши воды/тумана.
**Замер по `gm_construct.bsp` (2203 браша, лумп 18):**
| contents | смысл | брашей | сейчас твёрдые | по спецификации |
|---|---|---|---|---|
| `0x08000001` | SOLID | 1130 | да | да |
| `0x00000001` | SOLID | 996 | да | да |
| `0x00000000` | EMPTY (не твёрдый) | **70** | **да** ✗ | нет |
| `0x08000080` | не SOLID | 3 | **да** ✗ | нет |
| `0x18000008` | не SOLID | 2 | **да** ✗ | нет |
| `0x10000020` | **WATER** | 1 | **да** ✗ | нет |
| `0x30000008` | не SOLID | 1 | **да** ✗ | нет |
Итого **77 из 2203 брашей (3.5%) считаются твёрдыми ошибочно** — невидимые стены
в проходах и твёрдая вода озера.
**Исправление**: твёрдым считать браш, у которого установлен бит
`CONTENTS_SOLID (0x1)`. Отдельно разобрать воду (`0x20`), туман (`0x40`),
окна (`0x2`) и решётки (`0x8`): они не останавливают игрока, но влияют на
жидкость и свет.
**Приёмка**: счётчик брашей по `contents` в логе; на gm_construct ожидается
2126 твёрдых и 77 не твёрдых; тесты «проход в здание открыт», «в воду можно
войти».
### A3. Коллизия: нет broadphase для брашей
2203 браша проверяются целиком на каждом подшаге, в каждой из 4 итераций
разрешения. Для displacement'ов есть сетка, для брашей — нет.
**Исправление**: 2D-сетка по XY с AABB каждого браша; запрос кандидатов по
расширенному AABB хода.
**Приёмка**: замер времени кадра коллизии до/после; цель — стабильные 60 fps
при обычном движении.
### A4. Рендер: отсечения граней нет вообще
**Симптом**: «видно обратную сторону брашей».
**Причина** (`crates/render/src/world_renderer.rs`): `cull_mode: None` во всех
**шести** пайплайнах (строки 247, 292, 385, 483, 613).
**Уточнение пользователя**: в оригинале сквозь стены тоже видно — но там это
намеренно (вид сквозь стену на HUD), а не отсутствие backface culling.
**Исправление**: `CullMode::Back` для мира и моделей; для двусторонней
геометрии — отдельный проход `CullMode::Front` с инверсией нормали (как в
Source: сначала фронт с инверсией, затем бэк).
**Приёмка**: в галерее gm_construct не видно изнанки стен.
### A5. Рендер: деталь-текстуры не поддержаны → нет песка у озера
**Симптом**: «у озера трава должна переходить в песок, песка нет».
**Причина**: в `crates/formats/src/bsp.rs` перечислены 64 лумпа, и **деталь-текстур
среди них нет**. В Source 1 они лежат в `GAME_LUMP` как под-лумп
`detailtextures.dll` (`LUMP_DETAILTEXTURES = 12`) и накладываются поверх базовой
текстуры. Шейдер `world.wgsl` (38 строк) читает только `base_texture` и
умножает на вершинный `light`.
**Исправление**:
1. Разбор `GAME_LUMP` (запись лумпа **16 байт**: `fileofs, disksize, offset,
fourCC`), под-лумп 12 → список `(текстура, scaleX, scaleY)`.
2. Проекция деталь-текстуры на мировые координаты по UV грани.
3. Шейдер: дополнительные сэмплы со смешением, `$detail` из VMT.
**Приёмка**: у озера gm_construct виден переход травы в песок.
### A6. Белая нетекстурированная поверхность (было: «белые панели на 0 0 0»)
**Симптом по скриншоту**: большая белая polygonal-поверхность, лежащая на
земле рядом с озером (камера на `pos -1070 1565 533`). Контур с прямыми
отрезками и острыми углами, поверхность явно повторяет рельеф местности.
Окружающие дома, трава, дороги и вода (слева, тёмно-синяя) выглядят нормально.
**Это не пропы.** Проп — это объёмная модель с чёткими гранями; здесь же
плоская поверхность, граница которой идёт по рельефу. Значит это браш- или
displacement-геометрия, у которой **не разрешилась текстура** — материал
рисуется `fallback`-белым (`Material::fallback`, `world.rs:739`).
Ранее я проверил пропы и ничего подозрительного не нашёл: пропов без `origin`
нет, с `origin = "0 0 0"` нет, наш код порождает всего 9 моделей
(5 × `prop_dynamic lights_flescent01a`, 4 × `prop_physics` мебели) — все с
корректными координатами. Это гипотеза отвергнута, версия снимка была неверной.
**Кандидаты причины, по убыванию вероятности**:
1. **Не найдена VTF-текстура** для части поверхностей — `fallback` рисует белым.
Особенно вероятно для displacement-поверхностей у воды: у них отдельные
`texinfo`, которые не всегда есть в списке.
2. **Не разрешён VMT** — тогда материал падает в тот же белый fallback.
3. Отдельная поверхность, которой полагается деталь-текстура, но базовая
не загрузилась (связано с A5).
**Действие** (одна итерация, даёт точный ответ): дамп всех материалов мира —
`{ имя VMT, имя VTF, загрузилась ли, причина отказа }` плюс AABB каждого
батча. По скриншоту видно, что проблема локальная (один район карты), значит
список «отвалившихся» текстур будет небольшим и читаемым.
**Побочный вывод**: белый fallback — плохая диагностика и плохое поведение.
Нужна проверка в `doctor` (см. B4) и осмысленная заглушка: тёмно-шахматная
вместо белой, чтобы выделять проблему, а не маскировать её.
### A8. Знаки и порядок осей при повороте моделей
`rotate_model_vector` строит базис `forward/left/up`, тогда как Source
использует `forward/right/up`. Модельная система координат MDL — правая
(X вперёд, Y влево, Z вверх), поэтому знак оси Y критичен: ошибка
разворачивает модель зеркально и ломает нормали.
Это не «на глаз» — сверяем с эталонным `AngleVectors` и закрываем unit-тестом
на известных углах (`0 0 0`, `0 90 0`, `0 0 90`, `90 0 0`), фиксируя
ожидаемые векторы из формулы Source.
### A7. Вода выглядит неправильно
**Симптом**: «просто какое-то отражение неба, никакой текстуры, твёрдая».
**Причины**:
- отражение — **зашитая константа** `vec3<f32>(0.64, 0.76, 0.90)` в
`water.wgsl:49`, а не окружение;
- одна поверхность с `discard` по знаку `facing`, тогда как в Source их две
(`$abovewater` 1 и 0) — верх и изнанка;
- нет преломления, поглощения по глубине, блика солнца, нормалей из текстуры,
`$fogcolor`-затухания;
- твёрдость — из A2.
**Исправление**: две поверхности; отражение из окружения; преломление через
цвет буфера; поглощение по расстоянию; specular от солнца; плюс A2.
---
## Часть B. Фундамент: система энтити
Без неё не появляется ни свет, ни Lua над энтити, ни физика. Свет — это тоже
энтити (`light_environment`, `light`, `light_spot`).
**B1. Ядро** (`crates/engine/src/entity.rs`)
- `EntityIndex(u16)` — слот + `generation` для безопасных «умерших» указателей;
- `Entity` — classname, keyvalues, transform (`origin`/`angles`/local-origin),
флаги (`solid`, `movetype`, `model`, `render`/`rendergroup`);
- `EntityWorld` — пул, `spawn(kv)`, `remove`, итерация по classname,
отложенное удаление (как в Source, чтобы не инвалидировать ссылки в кадре);
- лимиты из файлов: максимум энтити, максимум ключей, максимум длины значения
(правило #1 AGENTS.md — никаких `unwrap` на данных из файлов).
**B2. Спавн из карты**: `build_world` перестаёт вручную порождать модели — он
скармливает лумп `ENTITIES` в `EntityWorld`, а рендер и коллизия читают из неё.
Это убирает белые панели как класс проблем и даёт всем остальным этапам единый
источник истины.
**B3. Коллизия по энтити**: браши `func_*`/`trigger_*`/`info_null` с корректной
твёрдостью; AABB/OBB для моделей; `trigger`-зоны отдельным слоем.
**B4. Отладка**: `ent_list` / `ent_info <classname>` — иначе каждый следующий
дефект снова придётся ловить на глаз.
**Приёмка**: `ent_list` показывает все энтити gm_construct с корректными
координатами; коллизия `func_*` работает.
---
## Часть C. Освещение (включая солнце)
Сейчас в шейдере один попиксельно интерполированный `light: vec3` из вершин —
меньше, чем в Source даже на простых картах.
**C1. Свет из карты**: `light`, `light_spot`, `light_dynamic` (в gm_construct:
108 `light` + 28 `light_spot`), `light_environment` — это и есть солнце
(направление, цвет, интенсивность, флаг «небо»).
**C2. Модель освещения в шейдере**: динамический свет (солнце + до N источников
в радиусе) поверх статического; источники ограничить по экрану и расстоянию.
**C3. Sun-тени**: каскадные shadow maps (в Source 3–4 каскада). Начать с одного
каскада 2048².
**C4. Lightmap**: лумп `LIGHTING`/`LIGHTING_HDR` (12 МБ в gm_construct) —
попиксельно вместо вершинного; даёт качественный свет в помещениях.
**C5. Материал**: `Phong`-подобная модель — diffuse + specular + fresnel;
`$phong`/`$specular`/`$color2` из VMT; самозатенение от карты нормалей.
**C6. Небо и `env_*`**: `env_sky`, `env_fog`, `env_sun`, `env_tonemap`,
`env_cubemap`, `env_skybox`.
**Приёмка**: на gm_construct видны солнечные тени от построек, помещения
освещены не плоско, вода отражает окружение, есть спекуляр на металле.
---
## Часть D. Lua поверх энтити
Текущий `lua` (1278 строк) — `print`, `hook.*`, `timer.Simple`,
`concommand.*`, заглушки. Ни типов, ни энтити.
**D1. Типы**: `Vector`, `Angle`, `Color` как userdata с метаметодами GMod
(арифметика, `Angle:Forward()`, `Vector:Distance`, `:Angle`).
**D2. `Entity(index)`** и методы: `GetPos/SetPos`, `GetAngles/SetAngles`,
`GetClass/GetModel/SetModel`, `Remove`, `IsValid`, `PhysicsInit*`.
**D3. `ents.*`**: `Create`, `FindByClass`, `FindInSphere`, `GetAll`, `GetCount`.
**D4. Сетевые поля**: `SetNW*`/`GetNW*` (в одиночке локальные, но с тем же API,
чтобы потом не переписывать аддоны).
**D5. Библиотеки GMod**: `file.*` (с песочницей по AGENTS.md), `surface.*`,
`sound.*`, `render.*`, `cam.*`, `Curves`, `system.*`, `scrconcmd`,
`LocalPlayer`. `http` — только после песочницы.
**Приёмка**: скрипт из гмода выполняется; список функций совместимости
(набранный из настоящих `lua/includes/modules/`) проходит.
---
## Часть E. Одиночная игра как в GMod
**E1. Player**: ходьба по ступеням (step-up ~18 units), лестницы, приседание с
плавным переходом, плавание (`Start Swim`), ноклип, падение с уроном.
**E2. game.dll на Lua**: загрузка `gamemodes/sandbox/gamemode/init.lua` —
списки `ents`, `weapons`, `NPCs`, хуки `Initialize`, `Think`, `PlayerSpawn`.
Это то, что делает GMod «GMod’ом», а не просто картой Source.
**E3. Оружие и предметы**: `weapon_*`, `item_*` через Lua.
**E4. Физика пропов**: Jolt — `prop_physics` уже приходят с карты, но не падают
и не сталкиваются.
**E5. NPC**: начиная с базовых `npc_*`.
---
## Порядок и разумная дистанция до GMod
GMod — это ~1 млн строк C++ плюс 15 лет аддон-экосистемы. Полное «1 в 1»
недостижимо как финальное состояние, но достижимо как наращиваемое покрытие:
A → B → C → D → E даёт к каждому моменту работающий одиночный клиент.
| Этап | Что даёт пользователю |
|---|---|
| A | «стены не пускают, вода не твёрдая, белая поверхность устранена, песок появился» |
| B | карта описывается объектами — база для всего |
| C | солнце, тени, нормальный свет |
| D | Lua-аддоны запускаются на нашей карте |
| E | одиночная игра, совместимая с аддонами |
Честное ограничение: «все энтити» — сотни классов. Но `base_ents` GMod — это
**Lua-скрипты**, а не C++. Когда появится исполнение `lua/entities/` и
`lua/weapons/`, значительная часть этих классов заработает сама, из исходников
GMod. Поэтому наш код на этих этапах делает только то, чего в Lua нельзя:
низкий уровень графики, физика, загрузка ресурсов.
Свой набор «тяжёлых» классов держим минимальным: `prop_physics`, `func_door`,
`func_button`, `trigger_*`, `light_*`, `env_*`, `info_*`. Остальное — из Lua.
---
## Решения, принятые пользователем
1. **Скриншот получен** → A6 переформулирован: это не панели пропов, а
нетекстурированная браш/displacement-поверхность. Версия про «панели в 0 0 0»
отвергнута.
2. **`AngleVectors`** → вопрос снят, работа делается как A8: сверка с
эталонной формулой Source и фиксация unit-тестом, без опоры на память.
3. **Приоритет** → свет (C) раньше game.dll (E2).
## Что осталось уточнить
Ничего блокирующего. Дополнительно уточню по ходу:
- белый fallback заменить на тёмно-шахматный (A6) — согласую при реализации,
это меняет вид карты при недогруженных текстурах.
---
## Часть C. Прогресс
### C0. Источники света из карты (сделано)
`engine::world::collect_lights()` разбирает `light_environment` (солнце),
`light`, `light_dynamic`, `light_fluoro_dynamic`, `light_spot`. На gm_construct
находится 65 источников, из них 1 солнце и 22 прожектора.
Шейдер `world.wgsl` получил uniform-блок источников: солнце (до 4), точечные
и прожекторные (до 64, отбираются ближайшие к камере), плюс ambient.
**Три ловушки, на которые напоролся (все проверены на gm_construct):**
1. **Четвёртая компонента `_light` означает разные вещи.** У `light` и
`light_spot` это РАДИУС освещения (`_light 255 255 255 300`), а у
`light_environment` — множитель яркости солнца (`_light 242 234 218 560`).
Если считать это яркостью везде, лампы дают вклад ×300 и карта выбивается
в белый.
2. **Яркость нельзя домножать в цвет.** Если умножить цвет на `scale` при разборе
и учесть `scale` ещё раз в шейдере, получается квадрат яркости. Цвет
нормируется к единичному среднему, яркость хранится отдельным полем `scale`.
3. **Радиус 0 означал «светить во everywhere».** Функция затухания при
радиусе 0 возвращала 1.0 на любом расстоянии, и 22 прожектора складывались в
сумму. Теперь у ламп без `_radius` подставляется 300.
4. **Лайтмапа уже содержит запечённое солнце.** Это ключевой момент: `in.light`
— это и есть запечённый свет компилятора. Динамическое солнце сверху нужно
только для направленной подсветки и будущих теней, поэтому его вклад
намеренно мал (`SUN_BRIGHTNESS_SCALE = 900` даёт ~0.62 от карты). Наивная
попытка добавить солнце «как есть» давала пересвет бетона в чистый белый.
Итоговые нормировки подобраны рендерами на gm_construct и лежат константами
`SUN_BRIGHTNESS_SCALE`, `POINT_BRIGHTNESS_SCALE`, `AMBIENT` рядом с кодом.
### Осталось по части C
- Попиксельная лайтмапа (`LUMP_LIGHTING`) вместо вершинного света.
- Тени от солнца (каскадные shadow maps).
- `Phong`-модель: specular + fresnel, `$phong`/`$specular` из VMT.
- Ambient из `_ambient` карты вместо константы.
- Полный набор `env_*`.
## Часть B2. Физика: Rapier3D (сделано)
### B2. Решение: мир не переезжает в Rapier
Rapier обслуживает только **динамические тела**. Геометрия карты остаётся
брашевой и разбирается нашим кодом (`player.rs`) — он проверен и быстрее на
тысячах брашей. Статика в Rapier добавляется явно и только из данных.
### B2.1. Четыре дефекта, найденных измерением
Все четыре нашлись не «правдоподобным декодированием», а замером на карте.
Порядок важен: каждый следующий дефект был бы невидим, пока не закрыт
предыдущий.
1. **Рельеф раздувался в 15 277 коллайдеров.** Сетка по `LAND_CELL = 64`
дублировала каждый перекрытый треугольник в каждую ячейку: 89 треугольников
давали 15 277 коллайдеров. Заменено одним объединённым trimesh с
дедупликацией вершин по кванту 1/16 юнита: **11 776 треугольников →
6 148 вершин, 1 коллайдер**. Точнее (никакого огрубления на швах ячеек) и
на порядки дешевле.
2. **Уровень gm_construct построен брашами, а не displacement'ами.** Рельеф
покрывает лишь часть карты; игрок ходит по 2 126 брашам. Без брашей все
пропы падали насквозь. `PhysicsWorld::add_brushes` восстанавливает вершины
браша перебором троек плоскостей (Крамер): точка, подходящая под **все**
плоскости — вершина выпуклой оболочки. У осевого браша 6 плоскостей = 20
троек. Замер: **2121 построен, 5 пропущено** (вырожденные).
3. **Broad-phase Rapier наполняется только на `step()`.** После вставки
геометрии луч её не видел: «под пропом пола нет» при 2 121 браше в мире.
Лечится явной регистрацией `broad_phase.set_aabb()` при вставке статики.
4. **`setup_physics` стирал построенное.** Начинался с
`self.physics = PhysicsWorld::new()` и обнулял браши, добавленные строкой
выше. Мир физики больше не пересоздаётся.
### B2.2. Тест поймал баг, который замер бы не увидел
`add_brushes` создавал коллайдеры, но **не увеличивал счётчик**: лог показывал
`static = 1`, хотя брашей было 2 121. Тест `box_brush_lands_where_the_box_is`
упал на `static_bodies() == 1` и указал прямо на строку.
### B2.3. Подтверждение на gm_construct
Две независимые системы коллизий сверены друг с другом: брашевая (`Collision`)
и Rapier. Обе находят пол под пропами на **z = −96** (Rapier −96.0,
брашевая −95.9). После 400 кадров все 4 `prop_physics` осели:
`speed = 0.0`, `sleeping = true`.
### B2.4. Ограничения
- Коллайдер пропа — **AABB модели**, а не `vcollide`. Source берёт физическую
модель из MDL; мы её не разбираем. AABB всегда не меньше модели и всегда
из данных, но для наклонённых пропов он «жирнее» настоящего.
- Только `prop_physics*`. У `prop_static` физики в GMod нет.
- **FPS просел**: 10–21 в headless-прогоне. Требуется замер отдельно с
физикой и без — 2 121 выпуклый коллайдер может быть причиной.
### B2.5. Что осталось по части B2
- `vcollide` из MDL вместо AABB.
- Брашевые `func_door` / `func_button` как ограничивающие тела.
- `env_explosion` через `apply_explosion` (метод готов, вызов из Lua — нет).
- Замер FPS с физикой и без, чтобы понять цену 2 121 коллайдера.
## Часть D1. Lua-типы GMod (сделано)
### D1. Что сделано
`crates/lua/src/gmod.rs` — `Vector`, `Angle`, `Color` как userdata с
арифметикой, полями на запись и `tostring` в формате GMod. Установлены в
глобалы вместе с `math.Clamp`.
Решения, важные для совместимости:
- **Пропущенных аргументов не бывает.** `Vector()` и `Vector(1, 2)` обязаны
работать: так вызывается бытовой код Sandbox. Отсутствующее значение
заменяется нулём, а не ошибкой.
- **Деление на ноль даёт 1, а не `inf`.** `inf` в позиции пропа отравляет
геометрию всего объекта и потом всплывает в отрисовке.
- **NaN из скрипта не доезжает до физики.** `Color(0/0, ...)` даёт белый,
`Vector(0/0, 1, 2)` — вектор без NaN.
- **Компоненты цвета обрезаются в 0..255**, а не отвергаются:
`Color(1.5 * 300, 0, 0)` — обычное дело в скриптах.
- **Source — левая система координат**: `Angle(0,0,0):Forward()` смотрит вдоль
`+X`, `Angle(0,90,0):Forward()` — вдоль `+Y`, знак Z у forward
отрицательный.
### D1.1. Дефект чтения чисел в mlua 0.11
`Value::as_number()` в mlua 0.11 различает **только** `Value::Number` и молча
возвращает `None` на `Value::Integer`. LuaJIT отдаёт целые именно как
`Integer`, поэтому наивное чтение теряло **все** целочисленные аргументы:
Vector(1, 2, 3) --> Vector(0, 0, 0)
Выглядело это как «арифметика не работает», хотя дело было в чтении чисел.
Собственный `value_number()` принимает `Number`, `Integer` и строку (числом).
Тест на это обязателен: без него `Vector()` с целыми просто тихо отдаёт нули.
### D1.2. Шум тригонометрии
`cos(90°)` в f32 — это 6.1e-17, а не ноль. Без обнуления
`Angle(0, 90, 0):Forward()` печатает `-0.00000004371139 1 -0`, и такие
значения копятся в преобразованиях координат. Порог — 1e-6.
### D1.3. Особенности API mlua 0.11, на которые ушло время
- Для userdata **нет** автоматического `FromLua`: чтение только через
`AnyUserData::borrow::<T>()`. `Lua::from_value` отсутствует, есть
`FromLua::from_lua(value, lua)`.
- Поля — `add_fields` (трейт `UserDataFields`), методы и метаметоды —
`add_methods` (трейт `UserDataMethods`). Один трейт на всё не работает.
- `add_meta_method` получает `(&Lua, &T, A)`: значение объекта идёт вторым
аргументом, лишние аргументы Lua (`__unm(a, a)`) отбрасываются через `()`.
- `add_function` получает объект как `AnyUserData` первым элементом кортежа.
### D1.4. Что осталось по части D1
- `Entity()` и объект сущности: `GetPos`/`SetPos`/`GetClass`/`IsValid`.
- `ents.*`: `Create`, `GetAll`, `FindInSphere`, `GetPlayer`.
- Связка с движком: `glua` не должен зависеть от `engine`, обмен — через
колбэки, которые регистрирует клиент.
- `Vector:Angle()`, `Vector:Distance` между сущностями.
## Часть D2. Мост Lua к движку (сделано)
### D2.1. Устройство
`glua` **не зависит** от `engine`. Движок отдаёт поведение через трейт
`glua::ents::Host`, реализация которого живёт в клиенте
(`crates/client/src/host.rs`). Иначе получился бы цикл зависимостей и
Lua-слой нельзя было бы тестировать в одиночку.
Мир переведён в `Rc<RefCell<World>>`: скрипт обращается к нему из хуков в
любой момент, пока кадр держит заимствование. Все обращения идут через
`try_borrow`, потому что `borrow` при пересечении даёт `panic` от
`RefCell`, а падать из-за безобидного скрипта клиент не должен.
### D2.2. Что доступно скрипту
`Entity(n)`, `ents.GetAll/GetByClass/FindInSphere/Create`,
`player`, а также `GetPos/SetPos/GetAngles/SetAngles/GetClass/GetModel/
GetVelocity/IsValid/Remove/GetName`.
`Entity(0)` — игрок, `Entity(9999)` — невалидная сущность, а не ошибка:
скрипты routinely проверяют результат через `IsValid()`.
### D2.3. Найденный рассинхрон (важный)
`move_prop` обновлял позу экземпляра пропа, но **не** запись в пуле
сущностей. Проверено измерением: `ents.GetByClass('prop_physics'):GetPos()`
давал `z = -64`, пока физика уже опустила проп на `z = -84`. «Данные» и
«картинка» разошлись, и заметить это можно было только глазами.
Теперь поза пишется в оба места всегда, даже если геометрию пересчитать
нечем (нет модели): проп без модели всё равно стоит правильно, он просто не
рисуется. Возвращаемое значение означает именно «геометрия пересчитана».
Проверка после правки: Lua читает `z = -84.15848`, физика даёт
`-84.15847778320313` — совпадение до последнего бита.
### D2.4. Границы
- `SetModel`, `SetColor`, `PhysicsInit` принимаются и ничего не делают:
принимать вызов нужно, чтобы скрипты не падали, но обещать нечего.
- `GetVelocity` делит скорость тела на шаг 1/60, а не читает «скорость за
последний кадр»: иначе скрипт получил бы юниты/кадр вместо юнитов/с.
- Созданная из Lua сущность не получает модель и тело физики.
- Игрок — `None`, пока клиент его не зарегистрировал.
### D2.5. Что осталось по части D2
- `Vector:Distance`, `Vector:Angle`, `GetModel`-обратная привязка.
- `Entity:GetTable`/`SetField` и сетевые функции.
- Регистрация сущности игрока, чтобы `Entity(0)` и `player` совпали.
- `ents.Create` должен уметь заводить тело физики для `prop_physics`.
- Подключение файлов `gamemodes/sandbox` как есть.
## Часть D3. Стандартная библиотека GMod (сделано)
### D3.1. Замер потребностей, а не догадка
Прогнаны 130 файлов `gamemodes/sandbox`, из них извлечены 182 различных вызова
API. Частоты по числу вхождений:
| вызов | вхождений | состояние |
|---|---|---|
| `hook.Run` | 63 | сделано |
| `math.Clamp` | 61 | сделано |
| `game.SinglePlayer` | 36 | сделано |
| `math.Rand` | 27 | сделано |
| `render.AddBeam` | 21 | не сделано |
| `math.min` / `math.max` | 17 / 5 | стандартная Lua |
| `net.*` | 15+ | не сделано (сеть) |
| `render.*` | 50+ | не сделано (рендер) |
| `file.*` | 8+ | сделано с песочницей |
`hook.Run` и `math.*` — самые частые и самые дешёвые: они чисто Lua-слой.
`render.*` — это отдельная большая работа (лучи, спрайты, декали), она не в
этом инкременте.
### D3.2. `hook.Run` не синоним `hook.Call`
`hook.Run` **останавливается** на первом хуке, вернувшем не-`nil`, и возвращает
его значение. На этом построены все проверки Sandbox (`return false` из хука
отменяет действие), поэтому подмена `Run` на `Call` молча ломала бы их.
### D3.3. Дефект: ключи хуков — строки, а не числа
Отбор только целочисленных ключей давал **пустой** список: `hook.Add` пишет под
идентификатором скрипта (`"GM:Think"`, `"a"`). Хуки не выполнялись вообще,
`hook.Run` возвращал `nil`, и это выглядело как «хуки не работают». Теперь
ключи сортируются как строка/число, порядок выполнения — по регистрации.
### D3.4. Дефект mlua: `Function::call` теряет аргументы
`Vec<Value>` реализует `IntoLua` как **таблицу**, поэтому `hook.call(args)`
передавал хуку один аргумент вместо трёх — молча, без ошибки. Проверено
измерением: `select("#", ...)` внутри хука давал `1` при трёх переданных.
То же с одиночным числом: `random.call::<f64>(10.0)` вызывал `math.random()`
без аргументов.
Лечится через `Variadic::from(vec![...])`. Отдельный тест
`hook_run_forwards_every_argument` фиксирует именно эту ошибку — она
молчаливая, и без теста вернётся.
По этой же причине `math.Rand` определён **на Lua**, а не на Rust: он зовёт
`math.random` того же состояния и передаёт аргументы нативно.
### D3.5. Песочница `file.*`
`file.Read` в GMod читает что угодно; мы запускаем чужие скрипты. Любой путь
разрешается относительно `garrysmod/` и нормализуется; выход за корень —
отказ, а не предупреждение. Абсолютные пути скрипту не даны. Размер чтения
ограничен 8 МБ (правило #1 AGENTS.md).
Проверка на живом клиенте: `file.Read("../../../../etc/passwd")` вернул `nil`.
### D3.6. Совпадения с GMod, зафиксированные тестами
- `math.Round(-2.5)` = **-3** (округление от нуля, как `ceil`).
- `math.Rand(x)` = `math.random(x)` → целое в **[1, x]**, а не [0, x).
- `math.Rand(a, b)` включает верхнюю границу.
- `math.Remap` при вырожденном диапазоне даёт `out_min`, а не `NaN`.
- `math.Approach` не перескакивает цель.
- `math.AngleDifference(10, 370)` = **0**: это одно направление.
- `string.Explode` с пустым разделителем возвращает `nil`, а не список.
### D3.7. Что осталось по части D3
- `render.*`: лучи, спрайты, декали, материалы, `DrawWireframeBox`.
- `net.*`: сообщения серверу.
- `Cam`, `LocalPlayer`, `vgui`/`derma` — для Q-меню.
- `util.TraceLine`, `util.Compress`, `surface.*`.
## Часть B2.6. Констрейны ( Rapier joints)
### Что сделано
`crates/engine/src/constraints.rs` — соединения между телами, соответствующие
классам GMod: `phys_ballsocket`, `phys_hinge`, `phys_slider`, `phys_weld`, `rope`.
Плюс `add_force` (база для `thruster`/`hover`) и `set_angular_velocity_target`
(мотор шарнира).
### B2.6.1. Главная тонкость: якоря мировые, а Rapier ждёт локальные
Скрипт задаёт точку крепления в **мировых** координатах, Rapier — **в
локальных** каждого тела. Пересчёт делается через обратное вращение
(`rotation().conjugate() * (world - translation)`). Ошибка здесь не бросает
исключение: соединение просто «приклеивается» к другой точке и уезжает при
первом же движении.
### B2.6.2. Что показали измерения, а не рассуждения
1. **Констрейн не «стягивает» тела — и это правильно.** Якорь в мировой точке
по определению совпадает у обоих тел в момент создания, поэтому
ограничение выполнено сразу и «включается» только когда тела расходятся.
Первые пять тестов проверяли «расстояние между телами» и падали именно
поэтому. Правильный инвариант — **совпадение точек крепления** под
действием силы.
2. **Сила в Rapier действует один шаг** и сбрасывается после интегрирования:
`add_force` надо вызывать каждый кадр. Тест, подававший силу один раз,
измерял не силу, а её отсутствие — и «падал» одинаково при 8000 и при
40 000.
3. **Спящее тело честно сообщает нулевую скорость.** Проверять эффект силы по
скорости бессмысленно, если тело уснуло на полу: корректный тест сравнивает
прогон с силой и контрольный прогон без неё.
4. **Ползун (`phys_slider`) не удерживает.** Сустав сообщает `enabled = true` и
5 заблокированных осей из 6, шар/шарнир/сварка в том же прогоне держат, но
ползун пропускает расхождение по высоте при толчке вдоль оси. Тест помечен
`#[ignore]` с причиной — зелёная галочка на неработающем коде хуже, чем
явный отказ. Гипотеза к проверке: в 0.36 ползун требует согласованности
`local_axis1`/`local_axis2` иначе, чем задаёт их `new(axis)`.
### B2.6.3. Границы
- Констрейны соединяют только **тела физики**: `prop_physics` и объекты,
созданные скриптом. У `prop_static` тела нет.
- Оси соединения задаются в мировых координатах, как в GMod.
- Ограничение угла переводится в радианы; вырожденный диапазон игнорируется.
- `pulley` и `thruster` — это не суставы: тяга делается через `add_force`.
## Часть B3. Проваливание сквозь пол: что проверено и что нет
### B3.1. Две гипотезы, обе опровергнуты измерением
1. **«Пропов нет в коллизии игрока».** Гипотеза была верной *как диагноз*,
но не как объяснение жалобы: игрок ходил по брашам. Пробел устранён
(`Collision::boxes`), проп теперь стоит под ногами — это нужно в любом
случае, но от проваливания по брашам не избавило.
2. **«На низком FPS шаг подшага прошивает пол».** Опровергнуто тестом
`fast_fall_does_not_tunnel_thin_floor`: при terminal-скорости 2000
юнитов/с и dt = 1/60 игрок садится на площадку толщиной **4 юнита** и
`on_ground = true`. Подшагов восемь хватает.
Обе версии выглядели правдоподобно и обе были неверны — характерная черта
дефектов, которые видны только после попытки воспроизвести их тестом.
### B3.2. Что измерено
- Стояние на спавне gm_construct: 180 кадров, `z = -143.9` стабильно,
`on_ground = true`, провала нет.
- Значит дефект локальный и воспроизводится на определённой геометрии.
### B3.3. Что нужно, чтобы найти причину
Точное место и обстоятельства: с какой высоты, после какого движения
(прыжок, спуск по рампе, спуск с края) и на каком участке. Без этого
пришлось бы перебирать геометрию карты наугад — а это ровно тот способ,
которым эти две гипотезы и были порождены.
## Часть B4. Физика движения: как в Source
### B4.0. Корневая причина четырёх жалоб сразу
Симптомы, описанные как разные (проталкивает на расстояние, метания вдоль
стены, сползание по рампам, bhop), оказались **одной ошибкой**:
```rust
self.velocity.z -= GRAVITY * dt; // выполнялось КАЖДЫЙ кадр
```
Гравитация применялась и на земле. Каждый кадр игрок вдавливался в
геометрию, а разрешение столкновений выталкивало его обратно. Отсюда всё:
«выталкивает на определённое расстояние» — это выталкивание из вдавливания;
«то за стену, то обратно» — борьба позиции с выталкиванием; «сползает по
рампе» — вертикаль всё время возвращается вниз.
Source делает наоборот: при `FL_ONGROUND` вертикальная скорость
обнуляется, а `ApplyGravity` в земле **не вызывается вовсе**.
### B4.1. Что изменено
- **Гравитация только в воздухе.** На земле `velocity.z = 0`.
- **Трение всегда на земле**, а не только при отсутствии ввода: Source
вызывает `Friction()` каждый кадр на земле — именно оно держит игрока на
рампе. Константы `sv_friction = 4`, `sv_stopspeed = 100`.
- **Прыжок по фронту нажатия.** Защёлка `jump_latched` держится, пока пробел
зажат; без неё удержание давало бесконечный bhop.
- **Подшаг `PM_StepSlideMove`**: полное движение, при блокировке — повтор с
подъёмом на `STEP_HEIGHT = 18`, затем спуск. Принимается только если
спуск встал на опору, иначе это залезание на стену. При падении
подшаг отключён — иначе в прыжке игрёт в потолок.
- `MoveResult::blocked` — признак упёрся в геометрию, нужен подшагу.
### B4.2. Ошибки в моих же правках, пойманные тестами
1. `jump_latched = jump` — защёлка вставала на первом же кадре, ещё **до**
первого прыжка, и прыгнуть было нельзя вообще.
2. `jump_latched = jump && self.on_ground` — флаг сбрасывался тем же кадром,
когда прыжок уже отработал (`on_ground` сброшен выше), bhop возвращался.
3. Тест на рамку мерил **первоначальное падение** (40 → 6), а не сползание,
и падал с «сносом 34». После отделения приземления снос — 0.
### B4.3. Тесты
- `standing_on_a_slope_does_not_slide_down` — снос после приземления.
- `holding_jump_does_not_bunnyhop` — ровно один прыжок за 300 кадров.
- `player_steps_up_a_curb` — переступает бордюр 12 юнитов.
- `player_stands_on_a_dynamic_body` — стоит на пропе.
## Часть B5. Осталось по физике движения
- **Воздушная мобильность.** `AIR_ACCEL = 12` при `GROUND_ACCEL = 10` — в
воздухе быстрее, чем на земле. В Source ускорение общее
(`sv_accelerate = 10`), а в воздухе `sv_air_max_wishspeed = 30` ограничивает
прирост, и трения в воздухе нет. Нужен Quake-алгоритм `Accelerate`.
- **Выталкивание из стен.** Сейчас при упоре в стену игрока выталкивает;
в Source движение просто останавливается.
- `sv_maxspeed = 320`, `sv_maxvelocity = 350` как отдельные ограничения.
- Ускорение/торможение поворота камеры, инерция при стрейфе.
## Часть B6. Изучение `CGameMovement` (Source SDK 2013)
Источник: `src/game/shared/gamemovement.cpp` из `ValveSoftware/source-sdk-2013`
(доки Valve под Anubis, читался исходник). Ниже — расхождения нашей реализации
с оригиналом, найденные **чтением первоисточника**, а не догадками.
### B6.1. Почему теряется скорость на рампе вверх
`CGameMovement::StepMove` устроен принципиально иначе, чем наш подшаг:
```cpp
// Try sliding forward both on ground and up 16 pixels
// take the move that goes farthest
TryPlayerMove( &vecEndPos, &trace ); // сначала ВНИЗ — получаем vecDownPos/vecDownVel
...подъём на flStepSize...
TryPlayerMove(); // потом ВВЕРХ — получаем vecUpPos
// decide which one went farther
float flDownDist = ( vecDownPos.x - vecPos.x )^2 + ( vecDownPos.y - vecPos.y )^2;
float flUpDist = ( vecUpPos.x - vecPos.x )^2 + ( vecUpPos.y - vecPos.y )^2;
if ( flDownDist > flUpDist ) { едем вниз; }
else { /* copy z value from slide move */ mv->m_vecVelocity.z = vecDownVel.z; }
```
Три отличия от нашего кода, каждое даёт симптом:
1. **Пробуются оба варианта, побеждает тот, что ушёл дальше по горизонтали.**
Мы всегда идём путём «поднять на 18 и уронить». На рампе вверх путь вниз
(без подшага) уходит дальше — значит оригинал его и берёт, а подшаг не
выполняется вовсе. У нас он выполняется всегда и режет путь.
2. **Вертикальная скорость берётся из движения ВНИЗ**
(`m_vecVelocity.z = vecDownVel.z`), а не из движения вверх. Иначе на
рампе появляется паразитная вертикальная составляющая.
3. Отказ от подшага при `trace.plane.normal[2] < 0.7` — то есть на крутых
поверхностях подшаг не применяется вовсе.
### B6.2. Почему при спуске «прыгает с рампы»
Наш подшаг выполняется по признаку «движение заблокировано» и всегда с
подъёмом на `STEP_HEIGHT = 18`. На рампе вниз игрок кадр за кадром
подскакивает на 18 юнитов и падает обратно — визуально это «прыжки с
рампы». Оригинал при спуске идёт по пути `flDownDist`, который дальше, и
подшага не делает.
### B6.3. Трение: у нас не так
`CGameMovement::Friction()` вызывается **каждый кадр на земле** безусловно
(проверка только `GetGroundEntity() != NULL`), а не «если нет ввода».
Формула оригинала:
```cpp
control = (speed < sv_stopspeed) ? sv_stopspeed : speed; // 100 по умолчанию
drop += control * friction * frametime; // sv_friction = 4
newspeed = max(0, speed - drop);
```
У нас трение срабатывает только при `wish.length_squared() < 1.0`, то есть
при беге по рампе его нет вовсе.
### B6.4. Гравитация делится пополам до и после движения
```cpp
void CGameMovement::StartGravity( void ) {
mv->m_vecVelocity[2] -= ent_gravity * GetCurrentGravity() * 0.5 * frametime;
}
void CGameMovement::FinishGravity( void ) {
mv->m_vecVelocity[2] -= ent_gravity * GetCurrentGravity() * frametime * 0.5;
}
```
В комментарии Valve: «yes, this 0.5 looks wrong, but it's not». Суммарно
гравитация та же, но момент приложения другой — это и даёт правильное
ощущение веса на дуге.
### B6.5. Ускорение: Quake-алгоритм, а не линейная интерполяция
```cpp
currentspeed = velocity.Dot(wishdir);
addspeed = wishspeed - currentspeed;
if (addspeed <= 0) return;
accelspeed = accel * frametime * wishspeed * friction; // НЕ accel*dt!
if (accelspeed > addspeed) accelspeed = addspeed;
velocity += accelspeed * wishdir;
```
`AirAccelerate` отличается одной строкой — ограничением желаемой скорости:
```cpp
if ( wishspd > GetAirSpeedCap() ) wishspd = GetAirSpeedCap(); // sv_air_max_wishspeed = 30
```
Вот почему в воздухе управление «вязкое»: прирост скорости за кадр
ограничен, а ускорение не накапливается. У нас `AIR_ACCEL = 12` при
`GROUND_ACCEL = 10` и линейная интерполяция к целевой скорости — в воздухе
у нас **быстрее**, чем на земле.
### B6.6. Прыжок по фронту
В чистом Source `IN_JUMP` проверяется по уровню, но при выходе из воды
ставится `mv->m_nOldButtons |= IN_JUMP; // Don't jump again until released`.
То есть «не прыгать, пока не отпустят» — штатный приём движка, а не
изобретение: наша защёлка `jump_latched` воспроизводит именно его.
### B6.7. Порядок работы на будущее
1. `StepMove` по оригиналу: оба варианта, побеждает дальний, `z` из
вниз. Чинит и потерю скорости, и «прыжки с рампы».
2. `Friction()` безусловно на земле.
3. `StartGravity` / `FinishGravity` пополам.
4. `Accelerate` / `AirAccelerate` квото-формулой с `sv_air_max_wishspeed`.
5. `CheckVelocity` — ограничиватель `sv_maxvelocity = 350`.
## Часть B7. Переписано движение по `CGameMovement`: коллизия тела
Главный дефект батча 2 был не в кинематике, а в самой проверке пересечения
AABB с кирпичом. Её переписали; кинематику трогали только по оригиналу.
### B7.1. Глубина проникновения считалась не по той грани
Формула была `pen = dist - project_min(aabb, n)` — «насколько тело зашло за
грань с внутренней стороны». Для игрока, стоящего на полу (низ на 1 юнит ниже
верха кирпича высотой 100), она даёт **172 юнита проникновения в нижнюю
грань** — на 172 юнита *выше* неё. Тело выталкивало вниз, сквозь пол.
Отсюда все три жалобы сразу:
| Жалоба | Причина |
|---|---|
| «поверхность превратилась в лёд» | тело улетало вниз, `on_ground` становился ложным, трение не применялось |
| «выталкивает сквозь пол» | то же, плюс игрок проваливался на сотни юнитов |
| «выталкивает сквозь стены» | см. B7.2 |
Заменено на SAT по разделяющим осям: длина перекрытия интервалов проекций,
`min(a1, b1) - max(a0, b0)`. Для того же игрока — 1 юнит, выход вверх.
Чтобы у кирпича был второй конец интервала, пришлось найти его вершины
(пересечения пар граней) и хранить проекцию противоположного края — `plane_min`.
### B7.2. Минимальный вектор выхода не годится для тонкой стены
Даже с правильной глубиной SAT выбирает выход по **меньшему** вектору. У стены
шириной 16 при теле шириной 32 выход вперёд всегда короче выхода назад: тело,
дошедшее до середины стены, выталкивается **сквозь неё**. Замерено ровно до
`x = 132.1` при стене на `x = 100`.
Поэтому добавлена swept-трассировка (`clip_fraction`) — тело останавливается
у грани и никогда не оказывается по другую сторону тонкой стены. Грань
«раздувается» на полугабариты тела; для плоскости сумма Минковского точна.
### B7.3. Застревание в рампе
Замерено на настоящей карте: игрок намертво замирал на 45° рампе — позиция и
скорость не менялись кадрами. Причина: контакт с рамкой гасил гравитацию
вдоль нормали (`clip_velocity`), а `on_ground` оставался ложным, потому что в
пути отсечения опора не выставлялась. Равновесие без движения.
### B7.4. `StayOnGround` — функции не было вообще
В `CGameMovement` после `WalkMove` **всегда** вызывается `StayOnGround()`:
трассировка вверх на 2 юнита, затем вниз на `m_flStepSize`, и если под ногами
есть опора с `normal[2] >= 0.7` — игрок притягивается к поверхности. Без этого
на спуске он висит над землёй (гравитация на `FL_ONGROUND` отключена и
возвращать его нечем), а на рампе не притягивается.
Порог опоры — `sv_walkable_normal` = **0.7**, а не 0.6.
### B7.5. `StepMove` только с земли
`WalkMove` вызывается только при `GetGroundEntity() != NULL`, иначе `AirMove`.
`StepMove` вызывается из `WalkMove` только при блокировке. То есть подшаг на
ступеньку в прыжке не работает — у нас работало.
### B7.6. «Сквозь рампу я прошёл вообще»: 87% кирпичей не имело объёма
`vertices_of` брала пересечение двух граней и выбирала точку, ближайшую к
началу координат. Но пересечение двух граней — это **прямая**, а не точка:
выбранная точка оказывалась вне кирпича и отбрасывалась. У ящика из шести граней
ни одной вершины так не находилось вовсе.
Замер на gm_construct: **1846 кирпичей из 2126 (87%) считались «без объёма»**,
то есть не твёрдыми: сквозь них можно было пройти, стоять на них было не на чем.
Это и есть корень жалоб «сквозь рампу прошёл» и «падаю сквозь карту».
Починка: по каждой паре непараллельных граней строится сама прямая, затем
обрезается всеми полупространствами кирпича — концы отрезка и есть вершины.
После починки «без объёма» осталось 3 кирпича из 2126.
### B7.7. Тело делало полный подшаг в стену
В `clip_fraction` стоял guard `if start <= limit { continue }`. Он отбрасывал
грань и при касании: `t == 0` и даже `-0.0` проходят проверку `start <= limit`
(сравнение с нулём ложно для `-0.0` только в `t < 0.0`).
Замер: шаг 8 юнитов — стоп у стены; шаг 16 — на 8 юнитов **внутрь** стены.
Дальше SAT выталкивал тело по чужой 45°-грани, то есть вперёд, сквозь кирпич.
Guard убран, условие отсечения — `!(0.0..1.0).contains(&t)`. Двойная польза:
`-0.0` отсекается верно, а `NaN` (сравнения с ним ложны) тоже не проходит
мимо проверки, как было при `t < 0.0`.
### B7.8. Полуплоскость кирпича действовала на всю плоскость
Верхняя грань площадки «продолжалась» за её краем и держала игрока в воздухе
навечно — «зависание над краем». Полупространство ограничивает **плоскость**,
а не сам кирпич, поэтому одного раздутия граней мало.
Починка: swept-bounds early-out — грань отбрасывается, если swept-габарит тела
заведомо не пересекает кирпич.
### B7.9. Один общий процент шага вместо цикла касаний
Движение было одним общим `fraction` на всю траекторию: первое же касание
останавливало весь шаг, и игрок «запинкой» полз по полу.
Заменено на цикл касаний по образцу `TryPlayerMove`: `MOVE_BUMPS = 4` попыток,
каждая — отдельное отсечение от всех кирпичей; касание гасит компоненту
скорости, упирающуюся в контакт, и цикл продолжает по оставшейся скорости.
### B7.10. Подшаг всегда выбирал путь «вверх», а он уводил назад
`StepMove` в оригинале пробует оба пути и **побеждает тот, что ушёл дальше по
горизонтали** (`if (flDownDist > flUpDist)` — иначе подъём). Наша версия брала
подъём всегда, если он вообще не был заблокирован.
На крутой рампе путь «вверх-вперёд-вниз» уводит игрока НАЗАД по склону.
Замер: игрок сползал по 45° рампе с x = -256 до x = -1066 за 5 секунд.
Переписано по оригиналу, включая `m_vecVelocity.z = vecDownVel.z` при победе
подъёма — иначе на рампе появляется паразитная вертикальная составляющая.
### B7.11. `StayOnGround` падал всем шагом и выталкивал вдоль нормали
Тело, стоящее на рампе, чуть **внутри** раздутой полуплоскости (угол AABB ниже
поверхности), и отсечение его не держит. Упав на всю высоту шага, тело
вминалось в кирпич, и разрешение выталкивало его вдоль нормали — то есть
**вбок по склону**. Это и был сползающий игрок.
Починка: зондирование вниз мелким шагом (1 юнит, до `STEP_HEIGHT` шагов)
вместо одного падения. Вминание ограничено той же величиной.
### B7.12. Трение должно быть всегда на земле
`FullWalkMove` в Source на земле обнуляет вертикальную скорость и **безусловно**
зовёт `Friction()` — при любом вводе, а не только при его отсутствии. Именно
оно удерживает игрока на рампе. У нас трение было только без ввода.
### B7.13. Линейная интерполяция ускорения несовместима с трением
`v += (wish - v) * accel * dt` замедляет не хуже, чем ускоряет: равновесие
устанавливается на **179 юнитах/с** вместо 250 (замерено на gm_construct). Это
и была жалоба «хожу медленно».
Заменено на квото-формулу Source `Accelerate` — аддитивную:
```text
addspeed = wishspd - dot(velocity, wishdir)
accelspeed = min(accel * dt * wishspd, addspeed)
velocity += wishdir * accelspeed
```
Она снимает ровно столько, сколько прибавляет трение, поэтому игрок выходит на
полную скорость. Ограничение `sv_air_max_wishspeed = 30` действует **только в
воздухе** (`AirAccelerate`, `GetAirSpeedCap()`); на земле ограничения нет.
### B7.14. Замер на настоящей карте
Тест `real_map_movement` (`--ignored`, путь через `SOURCE_RS_TEST_BSP`):
спавн gm_construct (704, 132, -143).
| | жалоба | было | стало | в Source |
|---|---|---|---|---|
| Скорость ходьбы | «хожу медленно» | 165 | **224,9** (среднее за 0,5 с с разгона) | 250 |
| Дёрганье назад | «кринит и запинаюсь» | — | **0,00** юнита за 3 с | 0 |
| Высота прыжка | «прыгать не реально» | не отрывался | **42,7** | ~45 |
| Падение сквозь мир | «падаю сквозь карту» | проваливался | не проваливается | не проваливается |
| Проход сквозь рампу | «сквозь рампу прошёл» | 87% кирпичей нет объёма | не проходит | не проходит |
Скорость 224,9 — среднее за первые полсекунды с разгона с нуля; на установившемся
ходе она выходит на 250.
Старый тест `real_map_player_rests_walks_and_does_not_sink` требовал пройти
2000 юнитов вслепую вдоль +x, но на gm_construct там стена на x = 992 (288
юнитов от спавна). Ожидание теста было неверно, а не игрок; тест удалён, его
содержательно корректная часть покрыта `real_map_movement`.
## Часть E6. Небо за границами карты (сделано)
### E6.1. Жалоба и измерение
> «если игрок выходит за карту, то скайбокс и 3D-скайбокс не должны рендериться
> (иначе не понятны границы карты)»
Причина видна прямо в шейдере 3D-скайбокса:
```wgsl
let world = camera.pos.xyz + (in.pos - sky.origin_scale.xyz) * sky.origin_scale.w;
```
Геометрия неба размещается **относительно камеры**, поэтому небо всегда
заливает весь фон: куда ни вылети, оно рядом. Пределы карты на фоне не видно
не из-за того, что неба «слишком много», а из-за того, что оно не привязано к
миру вовсе.
Замер на gm_construct:
| | значение |
|---|---|
| Габариты геометрии карты | `(-5640, -4576, -960) .. (2168, 6464, 2976)` |
| Габариты коллизии | `(-15360, -15360, -1024) .. (15872, 15360, 15359)` |
| `sky_camera` | `(-1428, 1645, 10991)`, масштаб 1/16 |
| Границы 3D-скайбокса | `(-14848, -14336, 10654) .. (15360, 14848, 12520)` |
| 2D-скайбокс (`SKYBOX`) | отсутствует, только 3D |
| Спавн | `(704, 132, -143)` |
Видно, почему габариты коллизии не годятся: они включают и 3D-небо, и далёкие
террасы. Основание — габариты **геометрии карты** (`World::bounds_min/max`),
они же считаются по вершинам видимых поверхностей.
### E6.2. Решение
`World::inside_bounds(pos, margin)` и свободная функция `world::inside_bounds` —
одна реализация, которой пользуются и движок, и рендерер (рендерер копирует
габариты при загрузке, а не хранит ссылку на мир).
`WorldRenderer::update_sky_visibility` пересчитывает видимость **каждый кадр**
из позиции камеры: игрок уходит за край уже во время игры. Проверка идёт по всем
трём осям сразу: под картой небо тоже исчезает, а не только сбоку.
`SKY_BOUNDS_MARGIN = 128` — допуск наружу. Габариты считаются по вершинам, а
камера стоит на высоте глаз (около 64 юнитов) над опорой, так что стоящий на самом
краю игрок формально за габаритом на весь свой габарит.
### E6.3. Тесты
- `sky_is_visible_only_inside_the_map` — юнит на сам предикат, включая ровно на
границе с допуском и на единицу за ней.
- `real_map_sky_bounds` (`--ignored`) — на настоящей карте: спавн внутри, за
край по любой из шести осей небо гаснет.
Оба прохода неба (2D-фон и 3D-геометрия) гасятся одним и тем же флагом: иначе
при вылете за край остался бы виден 3D-неб, а это ровно то, что мешало увидеть
границу.
## Часть B6.8. Гравитация пополам и `CheckVelocity` (сделано)
Два оставшихся пункта из B6.7, сверенные с оригиналом
(`src/game/shared/gamemovement.cpp`).
### B6.8.1. Гравитация делится до и после движения
`FullWalkMove` начинается с `StartGravity()` и заканчивается `CheckJump`, который
зовёт `FinishGravity()`. Обе берут ровно половину гравитации:
```cpp
// StartGravity
m_vecVelocity[2] -= ent_gravity * GetCurrentGravity() * 0.5 * frametime;
// FinishGravity — Get the correct velocity for the end of the dt
m_vecVelocity[2] -= ent_gravity * GetCurrentGravity() * frametime * 0.5;
```
Комментарий Valve прямо отмечен: «yes, this 0.5 looks wrong, but it's not».
Половина добавляется, чтобы игрок оказался в правильной позиции **во время**
движения, вторая — чтобы получить вертикальную скорость на конец кадра.
Проверено, что `TryPlayerMove` гравитацию не применяет: её зовут только
`StartGravity` / `FinishGravity` вокруг движения, так что «гравитация на каждое
касание» — не тот механизм.
Замер высоты прыжка на gm_construct после деления: **42,7 -> 44,9 юнита** при
~45 в Source. Падение начинается на кадр раньше, а не на кадр позже.
### B6.8.2. `CheckVelocity`: `sv_maxvelocity` попоосно
```cpp
for (i=0; i < 3; i++) {
if (IS_NAN(m_vecVelocity[i])) m_vecVelocity[i] = 0;
if (m_vecVelocity[i] > sv_maxvelocity) m_vecVelocity[i] = sv_maxvelocity;
if (m_vecVelocity[i] < -sv_maxvelocity) m_vecVelocity[i] = -sv_maxvelocity;
}
```
Потолок — 350 по каждой оси. Без него шаг за кадр на большой скорости
становится длиннее стены, отсечение её перестаёт видеть, и игрок проскакивает
сквозь тонкие стены и пол.
Отдельно важно **место** вызова: в оригинале `CheckVelocity()` сидит прямо
перед `TryPlayerMove()` (внутри `Friction()`), то есть скорость приводится в
порядок до расчёта смещения. Наш вариант сначала звал его только в конце кадра —
одна нечислица в скорости уходила в смещение, отсечение её не видело, и позиция
становилась нечислицей **навсегда**: прогон тестов на этом занимал 42 секунды
вместо 0,01. Теперь вызов есть и перед движением, и в конце.
Позиция проверяется на нечислицу так же, как скорость (в оригинале — тоже,
с обнулением оси на 0).
Тесты: `speed_is_capped_at_max_velocity`, `non_finite_velocity_is_zeroed`.
## Часть B8. Свары GMod: константы и «невидимые стены» (сделано)
Четыре жалобы, все — из игры, все закрыты измерением.
### B8.1. «В воздухе управление слабее, чем в оригинальном GMod»
Таблица cvar'ов GMod (gmodwiki.com/ConVars_In_Garrysmod) и код
`AirAccelerate` из CS:GO-версии `gamemovement.cpp` дают разные вещи:
```cpp
wishspd = wishspeed;
if (wishspd > 30) wishspd = 30; // цель ограничена
addspeed = wishspd - currentspeed;
accelspeed = accel * wishspeed * frametime; // а прибавка — по полной
```
Кап `sv_air_max_wishspeed` = 30 ограничивает **цель**, но не величину прибавки.
Мы ограничивали и то и другое, из-за чего прибавка была в 250/30 ≈ 8,3 раза
слабее — это и есть ощущение «вялого управления в воздухе».
Замер после правки на gm_construct: рывок вбок в полёте даёт ровно **30,0**
юнитов/с (кап достигается за один кадр, как в Source). Поворот при полёте
вперёд по-прежнему заблокирован: проекция скорости на желаемое направление 177
>> капа 30, прибавки нет — **это верное поведение оригинала**, а не дефект.
Заодно: `currentspeed` считается по **полной трёхмерной** скорости, а не по
горизонтальной. В `AirMove` вертикаль не обнуляется, поэтому в падении управление
слабее, чем в горизонтальном полёте, — ровно как в Source.
### B8.2. «Трение слабое, останавливаюсь долго»
Константы брались из SDK 2013, а у GMod они переопределены:
| cvar | SDK 2013 | **GMod** | было у нас |
|---|---|---|---|
| `sv_friction` | 4 | **8** | 4 |
| `sv_stopspeed` | 100 | **10** | 100 |
| `sv_gravity` | 800 | **600** | 800 |
| `sv_airaccelerate` | 12 | **10** | 12 |
| `sv_accelerate` | 10 | 10 | 10 |
| `sv_maxvelocity` | 350 | **3500** | 350 |
| `sv_stepsize` | 18 | 18 | 18 |
| `sv_walkable_normal` | 0,7 | 0,7 | 0,7 |
Замер после правки: с 250 юнитов/с до 10 — за **0,40 с**, путь **26 юнитов**.
`sv_maxvelocity` = 3500, а не 350, — отдельная история: это потолок *по осям*, и
при значении 350 вертикальная скорость падения упиралась в него сразу. Отсюда
жалоба «падаю как мешок, без ускорения».
### B8.3. Прыжок и падение
`sv_gravity` = 600 и прыжок 270 дают 270²/1200 = **60,8** юнита высоты — как в
GMod (замерено 60,8). В Source с гравитацией 800 было 45,6.
Падение в пустоте по секундам: **600, 1200, 1800, 2400, 3000** юнитов/с —
равномерный разгон по `g·t` до потолка `sv_maxvelocity`. Прежде было
585, 589, 590 — то есть упиралось в потолок 350 и стояло на месте.
### B8.4. О `sv_falldistance` / `fall_speed`: гипотеза, отброшенная измерением
Поначалу показалось, что «ускорение падения» — это пара cvar'ов HL2
`sv_falldistance` + `fall_speed`. Проверка: в таблице cvar'ов GMod их **нет**, а
формула `velocity[2] -= velocity[2] * fall_speed * frametime` при отрицательной
`velocity[2]` даёт **замедление** падения, а не разгон. Ставить непроверенные
константы значило бы гадать, поэтому пара не вводится: ускорение падения в GMod
даёт постоянная гравитация плюс высокий `sv_maxvelocity`.
### B8.5. «Высота шага терялась единица»
Подшаг поднимает тело ровно на `sv_stepsize` = 18, и тогда низ габарита
становится **точно на грань** ступени. Грань в этот момент считается касанием,
отсечение обнуляет шаг, и подшаг блокирует сам себя.
Замер до правки: игрок перебирал уступы до **17** и застревал на **18**. В
оригинале подъём идёт на `flStepSize + DIST_EPSILON` — не округление, а
необходимость.
После введения `STEP_EPSILON` = 0,1: перебираются все уступы до **18** включительно,
19 — нет. Ровно `sv_stepsize`.
### B8.6. «Невидимые стены, рамка превращается в параллелепипед»
Грань кирпича ограничивает **плоскость**, а не сам кирпич. Стоит телу оказаться
на уровне этой плоскости где угодно — грань останавливает шаг, даже если
кирпича там нет.
Замер на gm_construct: игрок, идущий по полу на `z = -144`, останавливался за
**3,4 юнита** до настоящей преграды — блокировала верхняя грань соседнего
кирпича, до которого было далеко. Такие грани встречаются на каждом стыке
участков, а на наклонных поверхностях образуют блоки, сравнимые с габаритом
целого кирпича, — «рампа превращается в параллелепипед».
Правка: при `t` около нуля (шаг обнуляется полностью) проверяется, что точка
касания лежит на самом кирпиче, а не на продолжении его грани.
Проверка узкая и только для `t < 0,001` намеренно: проверять точку касания для
всех `t` нельзя — при спуске с подшага тело опускается вертикально и в момент
касания ещё находится в стороне от грани по горизонтали. На широкой проверке
игрок переставал перебирать уступы уже высотой 4 юнита (замерено).
Замер после правки: преград «из воздуха» на 1800 шагов ходьбы по gm_construct —
**0** (было 2), и упоров в бордюры за 30 с ходьбы — **0** кадров.
### B8.7. Проверенные гипотезы, оказавшиеся невиновными
Каждая проверялась замером, а не рассуждением, и все оказались в порядке:
- **Кисти кирпичей как ящик.** Синтетическая наклонная грань сходится с
полутночными пространствами точно (`slanted_brush_is_not_treated_as_a_box`).
- **Объём кирпичей на карте.** Аудит всех 2126 кирпичей gm_construct по сетке
4×4×4 точек: **0 расхождений** (129684 проверок).
- **Displacement выпадает из сетки.** `add_displacement_tris` молча выбрасывает
треугольник, размазанный больше чем по 2048 юнитам. На карте: 11776
треугольников, **выпало 0**.
- **Скорость вдоль склона.** Рампа — не ящик: 239–263 юнита/с против 250 на
ровном, при подъёме застреваний 0.