# План 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(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 ` — иначе каждый следующий дефект снова придётся ловить на глаз. **Приёмка**: `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::()`. `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>`: скрипт обращается к нему из хуков в любой момент, пока кадр держит заимствование. Все обращения идут через `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` реализует `IntoLua` как **таблицу**, поэтому `hook.call(args)` передавал хуку один аргумент вместо трёх — молча, без ошибки. Проверено измерением: `select("#", ...)` внутри хука давал `1` при трёх переданных. То же с одиночным числом: `random.call::(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.