# План 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. ### A9. Коллизия: невидимый параллелепипед вокруг рамп и углов **Симптом** (пользователь): «я как будто стою вписанным в невидимый параллелепипед, а не прямо на рампе»; «везде, где в угол/подъём можно вписать прямоугольный треугольник», бывает и в зданиях. Точка-пример: `-2438 -966 48`. **Что уже измерено:** сетка обхода `real_map_sweep_for_invisible_walls` (x от −1500 до 600, y от −800 до 600) эту область **не покрывает** — поэтому тест и был зелёным. Рядом рампа — кирпич #1891: 9 граней, габарит `(-2752,-1024,-156) .. (-2304,-896,48)`, то есть 448×128×204 с наклонной плоскостью. **Гипотеза (проверяется замером `probe_ramp_movement`):** `Collision::penetration` (разделяющие оси: три мировые + нормали граней) берёт интервал кирпича по мировым осям из **габарита**, а габарит наклонного кирпича шире самого кирпича. В пустых углах около рамп получается ложное перекрытие, и `resolve_overlap` выталкивает игрока из объёма, которого нет. **Что уже замерено (2026-10-16):** - `probe_ramp_movement` в точке `-2438 -966 48`: залипание при движении в −Y и по диагоналям объясняется **настоящей кирпичной стеной** — кирпич #829 стоит по `y = -1024`, текстуры бетон/кирпич, стена видна на кадре из этой точки. Фантомного выталкивания в этой точке нет. - `probe_phantom_walls_scan` — обход **всей карты** (11200 точек по габаритам, 10824 свободных) с шагом кадра 8 юнитов: кирпичей, блокирующих шаг издалека, **ноль**. Ложных «тело внутри, хотя по плоскостям снаружи» — тоже ноль. - `probe_collision_contents` — состав коллизии: 2118 кирпичей из 2203 сырых; исключены 7 `func_vehicleclip` (в Source режут только машины), триггеры и прочие служебные; в коллизии не осталось ни `TOOLSTRIGGER`, ни клипов. - Причина, по которой старый обход молчал: его сетка покрывала только центр (`x` −1500..600, `y` −800..600), то есть запад и юг карты не проверялись. **Что осталось:** нужен точный адрес «стены на стыке комнат» — на рампе такой стены нет. Отладочный слой (`SOURCE_RS_DEBUG_COLLISION=1`) покажет это прямо на экране: если у мнимой стены нет ни красных рёбер, ни жёлтого габарита — она не из кирпичей вовсе, и это отдельный механизм. **Исправлено (E21).** Адрес нашёлся — `-2385 -2032 256`: терраса, где игрок стоял вровень с верхом соседней плиты (кирпич #269, 16 юнитов толщиной) и не мог шагнуть в −Y. Причина не в наборе кирпичей, а в отсечении: боковая грань плиты блокировала по касанию **ребром** — тело (z 256..328) грань (z 240..256) не перекрывало. Правка и замеры — в E21. **Приёмка:** игрок в пустом углу рядом с рампой не сдвигается; обход карты с сеткой, покрывающей запад (`x` до −3100), без залипаний. ### A10. Рендер: перелом света по полигонам **Симптом** (пользователь): «на одном полигоне светло, на другом темнее, перелом света идёт, которого в оригинальной игре в этом же месте нету»; пример — `pos -2039 -1408 -110`, пол тоннеля. **Причина:** лайтмапа берётся **в вершинах** и линейно интерполируется по треугольнику, поэтому граница света идёт по диагонали полигона. У больших граней (пол тоннеля, бетонная площадка) это видно отчётливо. **Решение:** сэмплить лайтмапу **на пиксель** — передавать в вершинном атрибуте UV блока лайтмапы и читать её из текстуры, как Source. Заодно это уравняет шаг люкселя на всех дистанциях. **Сделано (2026-10-23, ветка `feat/hdr`).** `LUMP_LIGHTING` раскладывается в атлас `Rgba16Float`: gm_construct — 6891 грань с лайтмапой, 751 718 люкселей, после полочной упаковки хватает 2048² (блок каждой грани окружён рамкой в 1 тексель, чтобы билинейная фильтрация не захватывала соседний свет). `WorldVertex.lightmap_uv` ведёт в блок, фрагментный шейдер мира читает атлас с линейной фильтрацией. Если атлас не собрался (карта без лайтмап или больше 4096²), пайплайн создаётся с `fs_main_vertex` — вершинный свет как раньше. Проверка кадром (камера тоннеля `-2039 -1408 -110`, yaw 90): до правки стена была почти плоской (слева/справа 32,3 / 54,2 по R — перепад 1,7×), после — плавный градиент от тёмного угла к проёму (14,3 / 49,0 — 3,4×). В кадре `/tmp/opencode/hdr_before_tunnel.png` → `hdr_final_tunnel.png` видно, что жёсткие ступени по рёбрам ушли: свет меняется внутри полигона, а не скачком на ребре. Тесты: `lightmap_atlas_maps_face_block`, `half_conversion_known_values`. ### A11. Вода: слева по Y прозрачная, справа — плоская голубая **Симптом** (пользователь, `pos -693 4264 1547`): одна половина поверхности видна насквозь (с волнами и зеленью дна), вторая — однотонная голубая заливка без просвета. Граница — изломанная линия посередине. **Что проверять:** не рисуется ли поверхность **дважды** разными путями (шейдер воды и обычная грань браша) и не смешивается ли она сама с собой; плюс две поверхности «верх/изнанка» из A7. ### A12. Вода: волны мелкие и одинаковые по оси Y, нет динамической текстуры **Симптом** (пользователь): «волны слишком микроскопические, чтобы их заметить», «все также по оси y одинаковы»; нужна динамическая текстура воды, а не «голубая полупрозрачная штука». **Решение:** волны с разными направлениями и длинами (не одна дорожка), нормали из текстуры воды (`$normalmap`) с двумя UV-слоями, смещающимися в разные стороны, затем преломление и поглощение по глубине (A7). ### A13. Нет тумана **Симптом** (пользователь): туман нужно реализовать — его нет вообще, ни дальнего, ни от `env_fog_controller`, ни поглощения в воде. **Решение:** дальний туман по `fog` из мира/сущности, `$fogcolor` для воды, учесть в шейдерах мира и неба; значения брать из карты, а не выдумывать. ### A14. Присед: скорость не меняется **Симптом** (пользователь): «физика приседания неправильная — скорость вообще не меняется». **Решение:** переносить по спецификации `CGameMovement` (`CheckParameters`, `m_flMaxspeed`, флаги `FL_DUCKING`/`m_Local.m_bDucked`, ограничение вставания под низким потолком), но **не вслепую**: каждое число (скорость в приседе, высота глаз, скорость приседа/вставания) проверять замером на нашей геометрии. ### A15. Полноценные пропы и модели (наполнение карты) **Симптом** (пользователь): «пропы полноценные, полные все элементы карты». **Что уже есть (замер на gm_construct):** у проп-сущностей (`prop_static`, `prop_dynamic`, `prop_physics`, `item_*`) читаются MDL, строятся статические меши `PropMesh` — 9 экземпляров, они рисуются, участвуют в физике и в коллизии игрока (`collision.set_boxes`). **Чего нет:** скелета и скиннинга, анимаций, `$bodygroup`/`$texturegroup`, LOD моделей, ragdoll-физики, флексов и глаз. То есть «полноценная модель» в смысле этапа **M5** плана: VVD/VTX, blend-граф, GPU-скиннинг, animation LOD, материалы моделей. ### A16. Наполнение карты: трава, деревья, декали, оверлеи - **Detail-спрайты** (`$detail` в VMT, `detailsprites` карты): трава на травяных текстурах не рисуется вовсе — отсюда «плоская трава». - **Смешанные материалы** (`WorldVertexTransition`, `$basetexture2`, `$blendmodulatetexture`): берётся только первая текстура, поэтому переход травы в песок и асфальт пропадает. - **Трава и деревья** — обычно `prop_static`-модели: появятся вместе с A15; отдельно у деревьев есть `$treeSway` (качание). - **Декали и оверлеи** (`infodecal`, `info_overlay`, `decal`-лумпы) не рисуются вовсе: отметины, надписи и метки на карте пропадают. ### A17. Сущности-механики карты Телепорты (`trigger_teleport` + `info_teleport_destination`), лестницы (`func_ladder`), двери и кнопки (`func_door`, `func_button`, `func_movelinear`, `func_rotating`), звуки (`ambient_generic`), источники света (`light`, `light_spot`, `env_projectedtexture`), `env_fog_controller` (см. A13), `func_brush` с переключением. **Решение:** каркас энтити уже есть; добавлять по классам, начиная с тех, что влияют на игру (телепорты, лестницы, двери/кнопки), затем свет и звук. Каждый класс — с проверкой поведения по спецификации и замером на карте. **Порядок работ (ускоренный под Sandbox-UX, см. A25):** A23 оружие и вьюмодель → A20 модель игрока → A21 физика взаимодействия (физган) → A24 спавн (Q-меню) → A22 Sandbox целиком; параллельно A19 (остаток по склону), A10 (свет), A11–A12 (вода), A16–A18 (наполнение карты). ### A18. 3D-скайбокс: полный **Симптом** (пользователь): «сделать ещё полноценный 3D-скайбокс». **Что уже есть (замеры E6, E8, E11.5, E18):** `sky_camera` находится (origin и scale), геометрия области скайбокса собирается в отдельный меш `sky_mesh` и рисуется своим проходом (в него уходят 21 displacement-грань и браши скайбокса); есть переключатель `SOURCE_RS_NO_SKY_MESH`; за границами карты скайбокс гасится. **Чего нет:** в кадре на месте города (камера E11, `-399.5 -1604.5 67.9`) у нас сплошной травяной склон — город **не рисуется** (E11.5: «рельеф выше 1000 — 0 треугольников», то есть закрывающая плита не рельеф и причина не найдена). В скайбоксе живут не только браши: город — это `prop_static`-модели и `func_*`-сущности, а их спавн для области скайбокса не делается вовсе. **Решение:** довести проход скайбокса до полного: геометрия + модели + декали области скайбокса, преобразование по `sky_camera` (origin и scale 1/16), корректная глубина и порядок отрисовки, отсечение по границам, и сверка кадра с эталоном GMod на камере E11 (`docs/PARITY.md`). ### A19. Рампа: скорость ниже и сползание на остановке **Симптом** (пользователь): «по рампе взбираюсь, останавливаюсь — чуть-чуть назад проскальзываю; и сама скорость ниже, а в оригинале на рампе вроде не замедляет». **Что говорит спека** (`gamemovement.cpp`, `WalkMove`): на земле вертикальная скорость **обнуляется** до и после ускорения (`mv->m_vecVelocity[2] = 0; Accelerate(...); mv->m_vecVelocity[2] = 0;`), движение идёт строго по горизонтали (у `dest` та же высота), склон догоняется `StepMove` и `StayOnGround()`. Значит замедления на рампе у движка нет вовсе, а сползания — тоже: вертикальной составляющей, которую склон мог бы «отразить», просто нет. **Что у нас:** всё это уже есть (`velocity.z = 0` на земле, трение, гравитация только в воздухе, `step_move`, `stay_on_ground`). Замеренный остаток — 197,8 юнита/с на рампе #1480 (15,2°) против 250 на ровном полу и сползание на доли юнита — даёт **догон склона**: `stay_on_ground` щупает вниз шагами по 1 юниту через `move_aabb`, а тот заканчивается выталкиванием, и нормаль склона смотрит вниз-вбок (у склона 15° — `n = (0,394, 0, 0,919)`), то есть каждое выталкивание сдвигает тело вниз по склону. **Решение:** заменить щупание на честную трассировку вниз без выталкивания — как в `StayOnGround` (`TracePlayerBBox` вниз на `m_flStepSize`, перенос позиции в `trace.endpos`, и никакого разрешения проникновений). **Сделано (2026-10-16).** `Collision::ground_trace` — чистый отсев вниз одним отрезком, без выталкивания; `stay_on_ground` переносит позицию по нему и требует нормаль опоры не круче `sv_walkable_normal` (0,7). Замеры на карте после правки: | рампа | угол | сторона +1 | сторона −1 | |---|---|---|---| | #1476 | 8,2° | 242,4 | 523,0 | | #1477 | 9,0° | 541,5 | 241,9 | | #1478 | 9,6° | 250,1 | 250,1 | | #1479 | 17,1° | 263,6 | 245,7 | | #1480 | 15,2° | 255,9 | **200,1** | | #1481 | 6,6° | 301,0 | 239,8 | | #1482 | 16,1° | 250,1 | 250,1 | Ровный пол — 250. «Медленных» по порогу теста (75%) не осталось ни одной, застреваний при подъёме — ноль. Остаток у #1480 (80%) — съезд по краю кирпича в изоляции, а не штраф за склон: рядом стоящие рампы дают 96–100%. **Приёмка (осталось):** тот же замер даёт сползание назад на остановке; его надо мерить отдельно (пройти вперёд, отпустить ввод, замерить смещение) и держать ниже юнита. --- ## Часть 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.*`. ### D3.8. Дефект: хук получал имя события первым аргументом `hook.Add`-колбэки вызывались как `v("EventName", ...)`, хотя в GMod имя хука не передаётся. Из-за этого `OnTextEntryGetFocus` из spawnmenu получал строку вместо панели и падал на `pnl:HasParent(...)`: `StartKeyFocus` не вызывался, а ошибка висела в логе при каждом клике по полю поиска. Теперь колбэки получают только аргументы вызова (`GM:`-методы по-прежнему первым аргументом получают таблицу геймода). ## Часть 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. ## Часть B9. Ноклип, дуга прыжка и полный обход карты (сделано) ### B9.1. Ноклип: инерции нет, импульс живёт Правка по наблюдению в игре: **в полёте инерции нет** — скорость пишется прямо по вводу, отпустил клавишу — остановился сразу. Разгон выглядел «лунной гравитацией». Но импульс при этом переживает отпускание клавиш: пока ввода нет, скорость не трогается. Раньше писалось `velocity = wish * speed` **каждый кадр** — то есть собственная скорость затиралась немедленно, и прыжок по Z не мог сохраниться в принципе. Пробел даёт порцию импульса **по фронту нажатия**, а не «подъём пока держишь», поэтому нажатий сколько угодно и порции складываются: именно так в оригинале улетают далеко за край карты. Выход из ноклипа переносит импульс **по всем осям** в обе стороны: вошёл в полёт на бегу — летишь дальше; вышел на полной скорости — отталкиваешься от земли, и гравитация не отменяет начатый подъём. Замер: одно нажатие поднимает на **279** юнитов, два — на **567**; после толчка вверх `vz` = 320 и через 10 кадров без ввода остаётся 320. ### B9.2. «Марсианские прыжки»: цифры точные Дуга прыжка снята целиком и совпадает с расчётом для GMod (скорость 270, `sv_gravity` = 600): | | GMod (расчёт) | замерено | |---|---|---| | до вершины | 0,45 с | **0,45 с** | | весь полёт | 0,90 с | **0,90 с** | | высота | 60,75 | **60,8** | Высота глаз (64) тоже на месте, камера идёт за игроком. Значит ощущение «не как» рождается не прыжком, а его **обрывом о невидимые стены** — см. B9.4. ### B9.3. Проверка касания: точка не годится, габарит — даёт Правило «грань должна доставать до кирпича» сначала проверялось одной точкой касания — и ломало подшаг: при спуске после подъёма тело опускается вертикально и точка уходит в сторону от грани. Замерено: игрок переставал перебирать уступы уже высотой **4** юнита. Проверяется весь габарит тела в момент касания: если тело и кирпич разведены — останавливает плоскость, а не кирпич. Подшаг при этом не ломается (18 перебирается, 19 нет). ### B9.4. Обход всей карты: невидимых стен не осталось `real_map_sweep_for_invisible_walls` обходит gm_construct сеткой из 49 стартовых точек восемью направлениями. В каждом кадре, где игрок жмёт «вперёд», но не двигается, проверяется, кто мешает: грань, останавливающая шаг, обязана доставать до кирпича. | | было | стало | |---|---|---| | кадров обхода | 117 600 | 117 600 | | невидимых стен | 2 на прямом ходе | **0** | Заодно исправлена ошибка **в самой диагностике**: зазор между габаритами считался как `max` двух величин без клампа по нулю, поэтому по пересекающейся оси выходило отрицательное число, а длина вектора выдавала «912 юнитов зазора» там, где габариты соприкасаются. На этом артефакте диагностика рапортовала 35 невидимых стен; все 35 были выдумкой измерения, а не дефектом коллизии. ### B9.5. Чего воспроизвести не удалось Жалоба «сквозь дырки в крыше упасть нельзя» **не воспроизведена**, и это зафиксировано честно, а не «исправлено». Проверено измерением: - твёрдость кирпичей: аудит 2126 кирпичей по сетке 4×4×4 — 0 расхождений из 129 684 точек; - displacement: 11 776 треугольников, выпало из сетки поиска 0; - отбор по содержимому: 2126 твёрдых, 77 нетвёрдых — как и задумано; - невидимых стен при обходе карты: 0; - нерисуемых твёрдых граней рядом с постройкой: 7, и все они `sky`; - прыжки на дырки: игрок их не проходит. Чтобы довести до причины, нужны координаты конкретной дырки: без них единственный способ — гадать, а это прямо запрещено правилом проекта. ## Часть B10. Флаги граней и высота прыжка (сделано) ### B10.1. «Дырки в крыше, сквозь которые нельзя упасть»: неверные флаги граней Проверено по указанной точке (−1388, −1503, 240): игрок там стоит на твёрдом кирпиче, но **нарисованной поверхности на месте нет**. Причина — константы `SURF_*` в `world.rs` были перепутаны. Настоящие значения из `public/bspflags.h` Source: | флаг | правильно | было у нас | |---|---|---| | `SURF_SKY2D` | 0x0002 | — | | `SURF_SKY` | 0x0004 | 0x4 | | `SURF_WARP` | 0x0008 | 0x8 | | `SURF_NOPORTAL` | 0x0020 | **0x20 — и считался за «не рисовать»** | | `SURF_TRIGGER` | 0x0040 | **0x40 — и считался за «не рисовать»** | | `SURF_NODRAW` | 0x0080 | **0x80 — но назывался SKIP** | | `SURF_HINT` | 0x0100 | 0x40 — не скрывался вовсе | | `SURF_SKIP` | 0x0200 | 0x80 — не скрывался вовсе | `SURF_NOPORTAL` означает «на этой грани нельзя поставить портал», а `SURF_TRIGGER` — служебный флаг. Ни то, ни другое не имеет отношения к рисованию, а мы их пропускали. Итог — **граней в картинке меньше, чем в коллизии**: дырка, в которую не видно, но сквозь которую не упасть. Замер на gm_construct: старая маска скрывала 2511 граней, новая — 2411; **появилось в картинке 100 граней, пропало 0**. Тесты: `surface_flags_match_source_bspflags` (значения закреплены по первоисточнику) и `skip_mask_covers_only_faces_that_are_not_drawn` (в маску не входят `NOPORTAL` и `TRIGGER`). ### B10.2. «Прыжки марсианские» — они были выше GMod в 1,8 раза Дуга прыжка снималась и совпадала с расчётом — но расчёт был неверен. Скорость прыжка бралась как 270 из Source SDK, а у GMod она своя: вики GMod в примере `Player:GetJumpPower()` печатает **200**. | | было (270) | GMod (200) | замерено после | |---|---|---|---| | высота | 60,8 юнита | **33,3** | 33,3 | | до вершины | 0,45 с | **0,33 с** | 0,33 | | полёт | 0,90 с | **0,67 с** | 0,67 | `sv_gravity` = 600 остаётся — он подтверждён вики GMod отдельно. ### B10.3. Ошибки измерений, которые пришлось исправить Две, обе нашли чужие грани и на них строились выводы: - **Максимальный z над точкой** вместо высоты поверхности **на месте**: крыша на z = 1296 заслоняла крышу на z = 240, и все 196 проб объявлялись «без рисунка». - **Зазор между габаритами без клампа по нулю**: по пересекающейся оси `max` двух отрицательных давал отрицательное число, а длина вектора — «912 юнитов зазора» там, где габариты соприкасаются. На этом артефакте диагностика рапортовала 35 невидимых стен при реальном нуле. ## Часть E7. Вода и плавание (сделано) ### E7.1. Чего не было Вода была **только нарисована**: `SURF_WARP` → шейдер воды в рендере. Физики не было вовсе — жидкости даже не хранились. `Collision::from_bsp` отбрасывал всё, что не `CONTENTS_SOLID`, а вода и есть не-`SOLID`. Итог: игрок входил в озеро и шёл по нему как по пустоте, проваливаясь на дно с той же гравитацией, что и на асфальте. ### E7.2. Что сделано - `Liquid` — объём жидкости (полупространства + `CONTENTS_*`), хранится отдельно от твёрдых кирпичей. Отбор по содержимому перенесён **до** отсева по `CONTENTS_SOLID`: на gm_construct такой кирпич ровно один (`0x10000020 = CONTENTS_WATER | CONTENTS_ORIGIN`), и из-за отсева вода не находилась вообще. - `Collision::point_contents` — аналог `GetPointContents`: возвращает содержимое объёма, в который попала точка. Габарит отсекает до проверки плоскостей. - `Player::check_water` — `CGameMovement::CheckWater`: три пробы (над ступнями, в середине габарита, на уровне глаз), уровень `WL_NotInWater…WL_Eyes`. - `Player::water_move` — `CGameMovement::WaterMove`: гравитации нет вообще, пробел даёт полную скорость вверх, без ввода игрок тонет (`wishvel[2] -= 60`), скорость желания режется до 80 %, трение по всей скорости, разгон той же квото-формулой. - `Player::water_jump` — `CGameMovement::CheckWaterJump`: преграда на уровне пояса, место над головой, опора под ногами с нормалью не круче `sv_walkable_normal`; скорость выпрыгивания 256, `m_flWaterJumpTime` = 2 с. - `drowning_time` и `breath()` — дыхание: под водой растёт, в воздухе восстанавливается. Полное время 15 с. - Вода по колено (`WL_Feet`): гравитация действует, вертикальная скорость гасится в 0,9 — мокрые ноги, но по ним ещё можно бежать. `FullWalkMove` собран как в оригинале: `StartGravity` вызывается только если `CheckWater` вернула ложь, а при `WL_Waist` вместо `WalkMove`/`AirMove` идёт `WaterMove`. ### E7.3. Замер на gm_construct | | значение | |---|---| | объёмов воды на карте | 1 | | габарит озера | `(-3072, 1985, -960) .. (1856, 6464, -160)` | | без ввода, через 10 с | z = **−960** (дно) | | с пробелом, через 10 с | z = **−194,8** | Всплытие останавливается на −194,8, а поверхность воды на −160: разница ровно пояс. Это **верное поведение Source** — `WaterMove` включается только от `WL_Waist`, поэтому из бесконечного бассейна выпрыгнуть нельзя, только выпрыгнуть на борт при `water_jump`. Тесты: `water_is_not_walkable_ground`, `holding_jump_swims_up_and_idling_sinks`, `breath_runs_out_underwater_and_returns_in_air`, `real_map_water` (замер на карте). ### E7.4. Открытое Трение и ускорение в воде берутся из `WaterMove` SDK 2013: `sv_friction` = 8 и `sv_accelerate` = 10. У GMod есть ещё `sv_waterfriction` = 1 и `sv_wateraccelerate` = 10, и где именно они применяются, проверить не удалось — в публичных исходниках `NoclipMove`/`WaterMove` их нет. Непроверенные значения не подставляем. ## Часть E8. Освещение и пролёт камеры (2026-10-14) ### E8.1. Солнце светило с горизонта Жалоба на освещение привела к замеру самого солнца на gm_construct: ``` ЗАМЕР солнце: направление на свет (0.588, -0.809, 0.0), яркость 560 ЗАМЕР сущность light_environment: angles = "0 306 0", pitch = "-56" ``` **Z был нулём** — то есть полы не получали света вообще, а светил только бок, попавший под рыскание 306°. Причина: у `light_environment` в GMod наклон лежит в **отдельном ключе `pitch`**, а в `angles` стоит только рыскание. Читались только `angles`. Стало: направление `(-0.329, 0.452, 0.829)` — полы получают 83 % солнца. Тест `real_map_sun` не даёт солнцу уйти ниже Z = 0,5. ### E8.2. Ambient берётся из карты Вместо жёсткой константы `[0.62, 0.66, 0.74]` читается `_ambient` того же солнца (`114 136 148 420` на gm_construct), нормированная по среднему и ограниченная единицей: это добавка, а не основной свет. ### E8.3. Замер, почему картинка тёмная Снято на настоящей карте, в клиенте, где VFS настоящий: | вход | значение | |---|---| | средняя лайтмапа всех вершин | 0,194 / 0,230 / 0,249 | | вершины строго «вверх» (`nz > 0.9`) | 271 шт., средняя **0,938** | | вершины «вбок» (`abs(nz) < 0.1`) | 22 124 шт., средняя 0,151 | | самая светлая текстура | `PLASTER/PLASTERCEILING003A` = 0,82 | | формат захвата кадра | тот же sRGB, что у экрана | Вывод: **улица освещена правильно** (0,94 на горизонтальных поверхностях), а темнота приходит из данных карты на остальных поверхностях. Плюс две вещи, которые стоит сделать дальше, и обе честно записаны, а не сделаны: - `world.rs` в диагностике строился с **пустым** `VirtualFs`, поэтому все текстуры там были заглушками (189 материалов, но лишь 2 различные картинки по отпечатку). Анализ текстур по этому прогону бессмыслен — верным был только замер в клиенте. - Видимые «белое пятно» на площадке и тёмная трава — разные поверхности с разной лайтмапой; дефект рендера не доказан. ### E8.4. Открытый дефект: белая площадка В кадрах пролёта видна большая **чисто белая** поверхность на уровне земли рядом с травой, узнаваемой формы. Что исключено измерением: - пустых текстур нет (максимальная средняя яркость 0,82); - `SURF_NOLIGHT` рядом нет — все 1175 граней с этим флагом в округе это `TOOLS/TOOLSSKYBOX` с `SURF_NODRAW`, они и не рисуются; - материалов с белой текстурой (`GM_CONSTRUCT/COLOR_ROOM`, средняя 0,99995) ровно один, и с этого ракурса он не виден; - канал вывода не при чём: формат захвата — тот же sRGB, что у экрана. Не опознан. Следующий шаг — сузить по батчам (у рендерера есть `--only-material`), что и не сработало: фильтр отсеивает всё, включая заведомо видимые материалы. Похоже, индексы в `set_only_material` не совпадают с индексами материалов мира. ### E8.5. Режим пролёта `--flythrough DIR --route "x y z pitch yaw; ..."` снимает маршрут одним запуском: камера идёт между точками по smoothstep, взгляд доворачивается на следующую точку. Без этого каждый кадр требовал бы отдельного запуска клиента с перезагрузкой карты. Знак pitch в наших углах: положительный — взгляд вниз (`forward.z = -sin(pitch)`). Кадры снимаются через `Xvfb` + `SDL_VIDEODRIVER=x11`: в окружении без дисплея `SDL_VIDEODRIVER=dummy` не годится, sdl3 его не поддерживает для raw-window-handle. ## Часть E9. Освещение: нормали граней были перевёрнуты (2026-10-14) ### E9.1. Симптом Жалоба: «на видео нету освещения вообще, с последнего раза не изменилось». Прошлый замер (E8.3) показывал среднюю лайтмапу 0,22 и был сделан о том, что картинка темна «от данных карты». Вывод был неверен: **не светило само солнце и запечённый свет, потому что нормали граней смотрели в не ту сторону**. ### E9.2. Замер, который всё вскрыл Распределение ориентаций по всем вершинам gm_construct: | Ориентация | До | После | |---|---|---| | строго вверх (`nz > 0.9`) | **17** | 15 195 | | сбоку | 21 224 | 21 224 | | строго вниз (`nz < -0.9`) | **18 096** | 2 918 | У настоящей карты пола смотрят вверх, а у нас «вверх» нашлось 17 вершин против 18 096 «вниз». В `face_normal` нормаль плоскости бралась с обратным знаком: ```rust if face.side == 0 { -normal } else { normal } ``` Прежний комментарий утверждал обратное («нормаль плоскости смотрит внутрь твёрдого тела») — это неверно: в VBSP нормаль плоскости грани уже смотрит наружу от твёрдого тела, то есть наружу от стены и вверх от пола. Инверсия была внесена при попытке починить намотку (docs/PARITY.md A4), но намотку исправляет отдельная функция `face_winding`, которая сравнивает геометрическую нормаль треугольника с нормалью грани и потому переворачивает обход сама. **Как это выглядело**: солнце и запечённая лайтмапа попадали на «изнанку» всех поверхностей. Пол не получал ни того, ни другого — картинка выглядела неосвещённой, хотя данные карты были верны. ### E9.3. Ещё один настоящий баг в том же месте В `sample_light_luxel` ширина строки блока лайтмапы считалась как `extent[0]`, а блок в BSP — это `(extent[0]+1) × (extent[1]+1)` люкселей (`extents` — индекс последнего люкселя, а не их количество). Каждая строка читалась на люксель раньше, и к низу блока чтение уезжало на десятки люкселей в сторону. Замер: средняя лайтмапа по карте была 0,179 при верной ширине строки против 0,194 при ошибочной — разница на среднем мала, но на больших гранях сдвиг достигал величины, сопоставимой с размером блока. ### E9.4. Белое пятно в кадрах — не дефект Долго висело как «неопознанный дефект рендера». Оказалось — это комната Construct в центре gm_construct: материал `GM_CONSTRUCT/COLOR_ROOM`, 360 индексов, свет ровно 1.0, габарит `(-1240,-968,-204)..(1240,968,204)`. Её VMT: ``` UnlitGeneric { "$basetexture" "color/white" "$vertexcolor" "1" "$nofog" "1" } ``` То есть белая по замыслу и неосвещаемая (`UnlitGeneric`). Пятно корректно. Спавн игрока (`704,132,-143`) лежит внутри этой комнаты. Урок: белый цвет и «комната света» — не дефект. Проверять надо VMT материала, а не гадать по кадру. ### E9.5. Ловушка измерения: `first_index` `Batch::first_index` — это смещение в буфере **индексов**, а не в буфер вершин. Первый замер белого пятна делал `vertices[first_index + k]` и молча получал чужие вершины: материал 48 показывал «0 вершин», хотя 360 индексов реально были. Правильно — `vertices[indices[first_index + k]]`. На этом замере было потрачено заметное время; записано здесь, чтобы не повторить. Также пробы в `crates/engine/src/player.rs` строят мир с **пустым** `VirtualFs::new()`: текстуры там всегда заглушки. Замер текстур годен только в клиенте, где VFS настоящий. Замер лайтмапы и нормалей пустым VFS не мешает. ### E9.6. Рябь на воде Подобрана замером кадра, порядок проб: | Частота (волн на юнит) | Что получалось | |---|---| | 0.035 | полоса шире озерной глади, вода как гладкая плита | | 0.35 | рябь настоящая, но вдали aliasing'ится в серо-голубой шум | | **0.12 и 0.19** | читается как рябь, на горизонте не сыпется | Третья полоса (0.38) убрана: её наклон давал широкие клинья поперёк озера. Затухание `1/(1+d²·0.00012)` вместо `1/(1+d·0.008)` — при линейном рябь не гасла до горизонта и давала череду полос. ## Часть E10. Лайтмапа читалась в перепутанных каналах (2026-10-14) ### E10.1. Как нашлось Запустили настоящий GMod через Steam (`/mnt/games/SteamLibrary/.../GarrysMod`, wine/Proton) и сняли ту же точку `(1400, 1200, -100)`, что и наш клиент. Затем сравнили изображения по зонам: | зона | GMod | наш | |---|---|---| | небо | 175,5 | 178,7 | | земля | 167,5 | 60,3 | Небо совпадает — значит канал вывода, гамма и sRGB верны. Расходится только геометрия. Но расходилась не только яркость, а и **тон**: | | нормированный RGB | тон | |---|---|---| | GMod | (1.000, 0.973, 0.866) | тёплый, солнечный | | наш | (0.756, 0.960, 1.000) | холодный, синий | ### E10.2. Причина Люксель лайтмапы лежит в блоке как **B, G, R, экспонента**, а читался как R, G, B. Сырые байты на gm_construct — `[141, 208, 251, 255]`: при чтении как RGB получается холодный синий (141/208/251), при чтении как BGR — тёплый солнечный (251/208/141). Подтверждение независимое: цвет солнца из `light_environment` на этой же карте тёплый, `[1.046, 1.011, 0.942]` (R > G > B). Запечённая лайтмапа обязана быть того же тона, что и солнце, её запекавшее. До правки читалась синяя — то есть не того цвета. `_ambient` при этом `114 136 148` (B > G > R) — синева, и это верно: небесная добавка действительно холодная. После обмена R и B нормированный тон стал `(1.000, 0.960, 0.756)` — совпал с GMod. ### E10.3. Что осталось не сделано Яркость земли после обмена каналов не изменилась: 59,9 против 167,5 у GMod (в линейном пространстве 0,0515 против 0,4022 — разрыв в 7,8 раза). Не починено и записано честно: - `lit = light * ambient + dynamic` — солнце прибавляется к лайтмапе, которая уже содержит запечённое солнце, то есть учитывается дважды; - `ambient` домножается на лайтмапу, хотя небо в неё уже входит; - `SUN_BRIGHTNESS_SCALE = 900` занижает динамическое солнце в 900 раз. Какой из вкладов даёт разрыв — измеряется отладкой: при `--debug-materials` текстура заменяется цветом палитры с яркостью 1,0, то есть пиксель равен `lit` в линейном пространстве напрямую. ### E10.4. Как получен эталон ``` steam -silent -applaunch 4000 -novid -width 960 -height 540 ``` GMod — только Windows-бинарник, запускается через wine/Proton под Steam. Карты gm_construct в серверной и клиентской установках совпадают побайтно (md5 `a671368902315ad97006be74f85346ef`), так что сравнение честное. `setpos`/`setang` с клавиатуры в консоль долетают неустойчиво, поэтому точки берутся из `garrysmod/cfg/autoexec.cfg`, который выполняется сам при старте. Порядок аргументов: `setpos x y z`, но `setang yaw pitch` — **наоборот** от нашего `--route`, где углы идут как `pitch yaw`. ## Часть E11. Освещение сверено с GMod по одной камере (2026-10-15) ### E11.1. Как получен честный эталон GMod запущен под Steam (клиент `/mnt/games/SteamLibrary/steamapps/common/GarrysMod`, wine), снят кадр консолью на фиксированной камере: ``` setpos -399.495911 -1604.489868 67.883812; setang 3.475900 146.330063 0 fov 100 ``` Карта в серверной и клиентской установках совпадает побайтно (md5 `a671368902315ad97006be74f85346ef`). Наш клиент снят в той же точке с `--fov 100`; ракурс совпал (та же кирпичная стена, тот же проём, то же здание). Важно: порядок углов разный. У GMod `setang yaw pitch roll`, у нас в маршруте `--route "x y z pitch yaw"`. Перепутать — значит получить кадр в другую сторону и сравнивать не то. ### E11.2. Первое сравнение: небо сошлось, поверхности нет | зона | GMod | наш | отношение | |---|---|---|---| | небо | 147, 197, 255 | 142, 195, 254 | 0,97–1,00 | | бетон | 151, 145, 135 | 72, 74, 66 | 0,48 | | трава | 137, 142, 102 | 60, 60, 38 | 0,42 | Небо совпало — значит канал вывода, гамма и sRGB верны. Расходились только запечённые поверхности. ### E11.3. Причина: экспонента из четвёртого байта Четвёртый байт люкселя читался как `i8` и давал масштаб `2^exp`. У подавляющего числа люкселей gm_construct этот байт равен 255, то есть как `i8` это −1 и масштаб **0,5** — вдвое темнее нужного. Подбор масштаба по эталону, бетон и трава — два разных материала: | масштаб | бетон | трава | |---|---|---| | 0,5 | 70,5 | 52,8 | | 1,0 | 87,8 | 77,3 | | 2,0 | 122,0 | 107,8 | | **3,0** | **147,0** | **130,3** | | GMod | 143,6 | 127,1 | Один коэффициент попал в эталон на двух разных материалах — значит это найденная величина, а не подгонка под одну точку. Небо при этом не изменилось: 196,9 против 199,7. Промежуточная проверка, что терялось не в шейдере: вывод только лайтмапы дал на бетоне 0,671 (линейных) — то есть блоки читаются верно и занижена была сама яркость, а не индексация. ### E11.4. Ложный след, который чуть не отправил не туда Измерение `lit` через `--debug-materials` дало 0,251 при лайтмапе 0,671 — выглядело как потеря света в шейдере. На деле цвет debug-палитры не белый, а насыщенный (среднее в линейном ≈0,366), и деление на него даёт 0,686 — то есть совпадение с лайтмапой. Вывод: `--debug-materials` годится для оценки формы, но не для абсолютных величин. ### E11.5. Осталось не сходится Вторая эталонная точка — камера с высоты 3195 над городом: ``` setpos 1296.484375 5623.283203 3195.700195; setang 28.374243 -133.538101 0 ``` У нас на этом месте вместо города видно сплошной травяной склон. Замер: рельеф выше 1000 отсутствует (0 треугольников), рядом с камерой выше 2000 — тоже 0. Значит закрывающая плита — не рельеф, и причина не найдена. Записано здесь честно; E11.3 этот дефект не объясняет и не вызван им. ### E11.6. Вода: затухание и отбраковка по волновой нормали Две правки, обе найдены замером кадра, а не правдоподобием. **Затухание бьло по всем волнам сразу.** Было `1/(1 + d²·0.00012)` — на 900 юнитах это 0,01, и с высоты озеро читалось зеркальной плитой. Алиасинг бьёт только по **мелким** волнам: они дальше пикселя. Заменено на затухание по волновому числу каждой волны отдельно: ``` local = 1 / (1 + (k · distance · 0.01)²) ``` При таком на 900 юнитах волна длиной 46 юнитов ещё видна (0,4), а длиной 2,7 — уже нет (0,002). **`discard` считался по волновой нормали.** Вопрос «лицевая ли грань» относится к геометрии, а не к микрорельефу: на скользящих углах волна наклоняла нормаль так, что `dot(n, view)` проваливался в ноль, и поверхность выбивалась дырками — край озера рвался зубцом. Теперь `facing` берётся по плоской поверхности, а волновая нормаль идёт только в френель и блик. ## Часть E12. Тени от солнца и солнце в небе (2026-10-15) ### E12.1. Почему «трава не той стороной» Жалоба повторялась трижды, и по замерам намотка рельефа верна: плоскости 110 граней смотрят вверх, развёрнуто 0 треугольников, нормали вершин вверх 15 407 против вниз 2 954. Проверка переворотом это подтвердила: при инверсии становится вверх 8 008 / вниз 10 353 и разворачиваются **все** 11 776 треугольников — то есть явно хуже. Значит «не той стороной» — это не геометрия, а **освещение**: без теней плоская трава читается как вывернутая. Теней в движке не было вообще. ### E12.2. Один каскад Добавлена карта теней 2048×2048 (`Depth32Float`), ортографическая проекция, накрывающая область радиусом 2 600 юнитов вокруг камеры по направлению на солнце. Отдельный пасс глубины со стороны источника, вода в него не идёт (полупрозрачна, теней от неё не бывает). В шейдере луч сравнивается с картой четыремя сравнениями по соседним текселям и сглаживается `smoothstep(0.35, 0.65)` — иначе край тени идёт лестницей. Смещение по нормали на 3 юнита убирает полосу самозатенения на поверхностях, повёрнутых к солнцу под острым углом. Проверка выводом самого коэффициента затенения: чёрное — тень (тоннель, стена под зданием, пол за корпусом), белое — освещённое. Работает. ### E12.3. Солнце в небе Направление на солнце пишется в uniform камеры шестым `vec4` (`w` — признак «солнце есть на карте»). Небо рисует ядро 2,5° и ореол до 12°. Подобрано так, чтобы на FOV 100 диск помещался в кадр целиком. ### E12.4. Сверка с GMod после теней Та же камера, что в E11: точка (-399.5, -1604.5, 67.9), yaw 146.33, pitch 3.48. | зона | GMod | было (E11) | стало | |---|---|---|---| | бетон | 143,6 | 87,8 | **147,0** | | трава | 127,1 | 77,3 | **129,0** | | небо | 199,7 | 196,9 | **196,9** | ### E12.5. Что осталось расходиться Стена в тени: GMod 36,4, у нас 124,6. Тени солнца её не гасят, потому что основной свет даёт запечённая лайтмапа, а динамическое солнце вносит лишь малую долю (`SUN_BRIGHTNESS_SCALE = 900`). В GMod затенение этой стены сидит в самой лайтмапе карты, то есть у нас лайтмапа на этом участке читается примерно втрое светлее. Причина не найдена. Проверялось: связность блоков нормальная (0,3–4,7 из 255), порядок каналов BGR подтверждён эталоном, шаг строки даёт гладкий результат. ## Часть E13. Экспонента лайтмапы возвращена (2026-10-15) ### E13.1. Ошибка была в моём же исправлении В E11 экспонента из четвёртого байта была **отброшена**: у большинства люкселей gm_construct байт равен 255, что как `i8` даёт −1 и масштаб 0,5, и картинка выходила вдвое темнее. Тогда подобрали плоский коэффициент 3,0, и по освещённым поверхностям совпало. Но затенённые участки вышли втрое светлее эталона, и это было записано как «не найдено». Ошибка: VRAD нормализует мантиссу так, что её максимум лежит в верхней половине байта, а **порядок яркости пишет в экспоненту**. Отбросив её, мы сравняли тени с солнцем. ### E13.2. Что дало возвращение Масштаб подобран так, чтобы освещённые участки остались на месте: `2^(−1) · 4,47 = 2,24`. Тень с экспонентой −3 получает `2^(−3) · 4,47 = 0,56`, то есть вчетверо темнее солнца. Та же камера, что в E11: | зона | GMod | плоский 3,0 | с экспонентой | |---|---|---|---| | бетон в солнце | 143,6 | 147,0 | **143,7** | | тоннель | 92,9 | 165,8 | **86,8** | | стена в тени | 36,6 | 124,6 | **70,8** | | трава в солнце | 127,1 | 129,0 | 110,5 | | небо | 199,7 | 196,9 | 196,9 | Тоннель — то место, где затенение и должно быть сильным: было 165,8 при эталонных 92,9, стало 86,8. Это и есть появившиеся тени. ### E13.3. Что осталось - **Стена в тени** 70,8 против 36,6. В линейном отношении к солнцу у нас 4,88, у GMod 16,5. Разница экспонент на две ступени даёт ровно 4 — столько и получилось, то есть экспоненты читаются верно. Не хватает ещё примерно двух ступеней, то есть в GMod этот участок ещё темнее, чем даёт лайтмапа. Возможно, там работает ещё и затенение от окружения, а не только запечённое. - **Трава** 110,5 против 127,1. Один коэффициент не даёт попасть и в бетон, и в траву: бетон просит 4,47, трава просит 6,0. Значит расходится не только яркость, но и тон — это совпадает с жалобой «оттенок не похож». Проверено и отброшено: динамическое солнце эти участки не освещает. При `SUN_BRIGHTNESS_SCALE` 900 и 2500 значения совпали до десятых, значит вклад солнца тут нулевой и весь свет — из лайтмапы. ### E13.4. Вода: длины волн были кратны Ряд длин 46 / 23 / 11 / 5,5 / 2,7 — каждый шаг ровно ×0,5. Из-за этого периоды фаз всех пяти волн синхронизировались, и рябь выстраивалась в правильную решётку: на кадре это читалось как гофрированный металл. Заменено на шесть волн с несоизмеримыми длинами 340 / 210 / 125 / 73 / 41 / 19 юнитов. На кадре рябь стала нерегулярной, береговая линия ровной. Геометрия воды осталась плоской, как в Source: вершины не двигаются, вся рябь считается в фрагментном шейдере. ## Часть E14. Белое в центре карты: гипотеза не подтвердилась (2026-10-15) ### E14.1. Жалоба «В 0 0 видны белые плиты, которых нет в оригинале, причём у них коллизия есть» и «через траву видно комнату под землёй». ### E14.2. Что измерено Материал белых граней — `GM_CONSTRUCT/COLOR_ROOM` с базовой текстурой `color/white`. Материалов с такой текстурой на карте ровно **один**, у него **9 граней**, габарит `(−1240, −968, −204)..(1240, 968, 204)` — то есть ящик 2480 × 1936 × 408 точно в центре карты, и точка спавна игрока внутри. Свойства граней: | | | |---|---| | `surf flags` | `0x0` — `SURF_NODRAW` **не выставлен** | | стили | `[0, 255, 255, 255]` или `[0, 33, 255, 255]` | | коллизия | есть | Стили 255 в трёх из четырёх — свет не запечён. ### E14.3. Гипотеза, которая не подтвердилась Предположил, что это сетка навигации: на карте 815 `info_node`, 76 `info_node_air_hint`, 24 `info_node_hint`, 15 `info_node_climb`, 8 `info_node_air` — и настоящая игра их не рисует. Замеры, которые гипотезу убили: - у `info_node` в лампе сущностей **нет** ключей `brushes` и `brushlist`, есть только `origin`, `nodeid`, `angles`, `classname`, `hammerid`; - **ни одна** сущность на карте не имеет `brushlist` — все кирпичи принадлежат `worldspawn`; - `LUMP_NODES` (лам�� 5, 12 144 байта) — это дерево BSP, а не навмеш: первые байты не разбираются как `dbrush_t`; - ни одна белая грань не привязалась ни к одному кирпичу по совпадению `planenum` через `LUMP_BRUSHSIDES`. Заодно выяснилось, что кирпичи и грани в BSP **не параллельны**: 2 203 кирпича, 14 946 сторон, 9 460 граней. Прямой связи по номеру нет, поэтому отсев «границы сущности → номера граней» не тривиален. ### E14.4. Что осталось сделать 1. Найти, каким классом сущности помечены эти девять граней. Раз `brushlist` нет, нужен другой путь: в BSP v1 у brush-сущностей бывает ключ `"brushes"` + `"brushlist"`; если его нет, сущность — точечная, и грани принадлежат `worldspawn`, а помечаются только свойствами поверхности. Значит искать надо в `surfaceprop`/`LUMP_SURFACE_PROPS` по материалу или в списке `models`/`brushes` другого формата. 2. Проверить `LUMP_SURFACE_PROPS` (лампа 顺 33/100) — возможно, у этих девяти граней там стоит флаг, который Source уважает при отсечении. 3. Сопоставить с GMod: снять тот же ракурс и посмотреть, что там. Свой кадр GMod из этой точки ещё не снимался. ### E14.5. Промежуточный итог по рельефу По «траве не той стороной» добавим замер в E13: из 15 407 вершин вверх наклонены только 104, гистограмма `nz` даёт 24 184 вершины с `nz < 0.5` (это стены) и 34 в верхнем бине. Геометрия холмов кривая, а наклон почти нулевой — значит он теряется при построении сетки displacement'а. Гипотеза Gemini про `dv.vec` как источник нормали объясняет цифру, но ещё не проверена. ## Часть E15. Разбор «белых плит» и «вывернутой травы» (2026-10-16) ### E15.1. Гипотеза Gemini про `dv.vec` — опровергнута измерением Предполагалось, что нормаль берётся из `dv.vec` вместо геометрии сетки. Проверка сырых байт `LUMP_DISP_VERTS` (лам�� 33, 252 760 байт): ``` первые 64 байта: 0: 00 00 00 00 d5 4c a9 38 ff ff 7f 3f 93 29 91 41 16: 00 00 20 3f 62 b5 db b7 7a b1 1a 39 00 00 80 3f ``` Читать как f32 с шагом 20 байт даёт осмысленные значения (`vec=(0.0000,0.0001,1.0000)`, `dist=18.145`), читать как три `i16` — мусор. Значит `parse_dispverts` прав: формат f32, шаг 20, `vec` действительно почти всегда `(0,0,1)`, а рельеф формируется полем `dist`. Гипотеза неверна. ### E15.2. Ориентация рельефа в порядке | замер | значение | |---|---| | плоскости displacement'ов | вверх 110, вниз 0, сбоку 0 | | намотка | развёрнуто 0 треугольников из 11 776 | | рельеф в радиусе 600 от спавна | z от −151.4 до −148.0 | | базовые плоскости рельефа | 110 штук, z от −148 до 11 235 | Рельеф у спавна почти плоский (3 юнита перепада на 1 200), поэтому «наклонены только 104 вершины» — свойство карты, а не ошибка намотки. ### E15.3. Ошибка в моей же сверке лампов Справочник лампов читался со stride 48 вместо 16. Из-за этого `LUMP_NODES` выглядел как навмеш, а лампы 6 и 18 менялись местами. Все выводы про `LUMP_NODES` из части E14 недействительны; содержимое по stride 16 приведено выше и сходится с тем, что даёт Rust-читатель. ### E15.4. «Белые плиты» — что это на самом деле Материал с текстурой `color/white` (`GM_CONSTRUCT/COLOR_ROOM`, `UnlitGeneric`) используют всего **9 граней**, и это не ящик в центре карты, как показалось сначала, а тонкие пластины: | грань | габарит | размер | |---|---|---| | 481, 483 | x = −2560 | 64 × 4 | | 614, 615 | x = −2048 | 64 × 4 | | 880 | y = −2176, z = −20..−16 | 128 × 4 | | 1985, 1987 | y = −2868, z = 508 и 636 | 64 × 4 | | 2057, 2058 | y = −2704, z = 508 и 636 | 64 × 4 | Все нормали строго ±X или ±Y, высоты четырёх уровней — это и есть «оси координат 3д» на глаз. Свойства: `surf flags 0x0` (`SURF_NODRAW` **не выставлен**), стили `[0,255,255,255]` и `[0,33,255,255]`, коллизия есть. ### E15.5. Настоящая белая плита в центре — другая Большая белая поверхность под камерой `(0,0,2200)` — это не `color/white`. Самые крупные грани рядом с нулём: | грань | габарит | материал | |---|---|---| | 9379, 9380, 9385, 9386 | (−1240,−968,−4)..(1240,968,4) | `concretefloor028a_1280_-1600_-64` | | 9344, 9347, 9350 | z = −148 | `concretefloor028a_1344_3584_32` | То есть бетонная плита 2480 × 1936 в 8 юнитов толщину прямо над спавном, и она **продублирована**: грани 9379 и 9385 имеют одинаковый габарит, как и 9380 с 9386. **Дубль — наш баг, а не карты: грани 9363..9460 принадлежат браш-сущностям, а не миру.** Исправлено в части E16. ### E15.6. Чёрные области — не тени `SOURCE_RS_NO_CULL` и принудительный `return 1.0` в `sun_shadow` дали кадры, отличающиеся только отсечением; тени на чёрные пятна не влияют. Значит чёрное берётся из `lit = in.light * ambient + dynamic`, то есть из лайтмапы. ### E15.7. Связь «грань → кирпич» Прямой связи нет: 2 203 кирпича, 14 946 сторон, 9 460 граней, номера не параллельны. Рабочий обход — по плоскости: `planenum` грани → кирпичи, у которых эта плоскость есть, затем проверка, что все вершины грани внутри выпуклой оболочки. Этим найдено, что часть белых пластин принадлежит кирпичам с `contents = 0x0` (`CONTENTS_EMPTY`, не твёрдые). Но правило «`contents == 0` → не рисуем» **слишком грубое**: под него попадают 330 граней, в основном `plasterwall022c` (250) и `CONSTRUCT_CONCRETE_GROUND` (50) — это видимая геометрия. Отбор по содержимому в лоб резать нельзя. `LUMP_FACEIDS` (лампа 11, 18 920 байт = 9 460 значений по 2 байта) на этой карте содержит значения вроде −27 746 и 24 471, а номера сущностей — то, что нужно, не подтвердилось. ### E15.8. Что осталось 1. Понять, почему бетонная плита 2480 × 1936 у спавна выглядит белой. Тон по эталону: бетон 143.7 при эталонных 143.6 — совпадает, значит дело не в масштабе, а в том, что в GMod этой плиты, вероятно, не видно (она внутри комнаты, а мы видим её сверху). **Закрыто в E16: плиты не было в GMod, потому что это браш-сущность в своём `origin`.** 2. Разобраться с плоской травой: снять кадр GMod из точки `ref_p4` (`setpos 0 0 2200`, `setang 180 55`) и сравнить рельеф. 3. `sun_shadow` без проверки `ndc.z` — точка за ближней плоскостью карты теней получает `depth < 0` и трактуется как затенённая. Сейчас это не видно глазом, но диапазон не проверяется, и это лазейка для чёрных пятен. ## Часть E16. Белые плиты в (0,0,0) — браш-сущности без `origin` (2026-10-16) ### E16.1. Ответ Gemini и что он дал В `GEMINI.md` отправлены четыре вопроса; ответ получен и разобран. Главное, что он дал: **`LUMP_FACES` хранит полигоны всех моделей карты подряд, а не только статического мира.** Проверено измерением — подтвердилось. ### E16.2. Замер `LUMP_MODELS` `LUMP_MODELS` (лампа 14) — 624 байта, `624 / 48 = 13` моделей ровно (`dmodel_t` = 48 байт: `mins[3] f32`, `maxs[3] f32`, `headnode[3] i32`, `visleafs i32`, `firstface i32`, `numfaces i32`): | модель | первая грань | граней | габарит | |---|---|---|---| | 0 (worldspawn) | 0 | 9363 | (−15360,−15360,−1024)..(15872,15360,15359) | | 1, 8 | 9363 / 9410 | 6 | (−4,−968,−204)..(4,968,204) — плита на **YZ** | | 2, 5 | 9369 / 9387 | 6 / 22 | (−1240,−4,−204)..(1240,4,204) — плита на **XZ** | | 3, 4 | 9375 / 9381 | 6 | (−1240,−968,−4)..(1240,968,4) — плита на **XY** | | 6, 7 | 9409 / 9410 | 1 / 0 | стекло и кнопка | | 9, 10 | 9416 / 9422 | 6 / … | `func_vehicleclip` — клипы | | 11, 12 | … | … | `func_illusionary` | **Три ортогональные плиты через начало координат — это буквально «оси координат 3д: есть x, есть y, есть z» из жалобы.** Мы рисовали все 9460 граней подряд, поэтому 97 граней браш-моделей сваливались в (0,0,0), а коллизия у них была — её мы строим отдельно, из `LUMP_BRUSHES`. Сущности в `LUMP_ENTITIES` с `model "*k"` (12 штук) — `func_brush`, `func_reflective_glass`, `func_button`, `func_vehicleclip`, `func_illusionary` — с настоящими `origin`: ```text *1 func_brush origin=-804 -3600 -52 *7 func_button origin=-2698 -2598 -207.2 *2 func_brush origin=-2048 -4572 -52 *8 func_brush origin=-3292 -3600 -52 *3 func_brush origin=-2048 -3600 156 *9 func_vehicleclip origin=— *4 func_brush origin=-2048 -3600 -260 *10 func_vehicleclip origin=— *5 func_brush origin=-2048 -2628 -52 *11 func_illusionary origin=-2920 -1028 8 *6 func_reflective_glass origin=-2064 -2076 -286 *12 func_illusionary origin=-2920 -1040 8 ``` ### E16.3. Проверка `LUMP_FACES` и раскладки `dface_t` - **Ламп не сжат**: `fourCC = 0`, длина `529 760` байт, `529 760 / 56 = 9460` ровно. Раскладка `dface_t` (56 байт) совпала байт-в-байт с таблицей спецификации: `planenum 0`, `firstedge 4`, `numedges 8`, `texinfo 10`, `dispinfo 12`, `styles 16`, `lightofs 20`, `area 24`, `lightmap_mins 28`, `lightmap_size 36`, `origFace 44`, `numPrims 48`, `smoothingGroups 52`. Сдвига нет. - **Все 110 displacement-граней лежат в модели 0** и все имеют `numedges = 4`. Значит отсев 21-й поверхности (89 из 110 в рендере) — не T-junction, не браш-сущность и не порядок углов. - `LUMP_BRUSHES`/`LUMP_BRUSHSIDES` при рендере не участвуют вовсе — это данные коллизии и трассировки лучей; правило «`contents == 0` → не рисуем» не используется. ### E16.4. Что сделано Новый `BrushModels` в `crates/engine/src/world.rs` вместо мёртвого `NavHulls` (гипотеза про навмеш опровергнута в E14 — `brushlist` не привязан к сущностям, отбор был пустым): - читает `LUMP_MODELS`, строит диапазоны граней по моделям; - привязывает модель к сущности по `model "*k"`, берёт `origin`; - не рисует `func_vehicleclip`, `func_occluder`, `func_areaportal*`, `func_clip_vphysics`, `trigger_*` и сущности с `StartDisabled "1"`; - вершину граней браш-модели сдвигает на `origin` (для мира смещение нулевое, поведение не меняется); текстурные координаты и лайтмапа считаются уже в мировых координатах, потому что `texinfo.lightmapVecs` и `dface_t.lightofs` запекались компилятором относительно конечного положения. Тест `real_map_brush_models_keep_their_origin` (требует `SOURCE_RS_TEST_BSP`): грани 9379/9380 получают `(-2048,-3600,156)`, 9385/9386 — `(-2048,-3600,-260)`, грань 9416 (модель 9, `func_vehicleclip`) не рисуется. **57 граней уехали в `origin` сущностей, 40 граней клипов отсечены.** Кадр с камеры `(0,0,2200)` pitch 55 yaw 180: огромная белая плита в центре исчезла; на её месте виден нормальный вид, а плиты моделей 3 и 4 видны там, где стоит их сущность. Сравнение `до`/`после` — `/tmp/opencode/ref/frame_0000.png` и `/tmp/opencode/plates/frame_0002.png`. ### E16.5. Что из этого выпадает дальше 1. Браш-сущности пока **не поворачиваются** по `angles` — только трансляция. На gm_construct у `func_brush` они нулевые, но на других картах может понадобиться `rotate_model_vector`. 2. `func_illusionary` рисуется — в Source он видим, но физики нет; надо сверить с эталоном, что он не попал в чёрные пятна. 3. Остаются 21 displacement-грань из 110 и плоская трава (E15.8 п. 2), `ndc.z` в `sun_shadow` (E15.8 п. 3). ## Часть E17. Коллизия браш-сущностей, отсев displacement'ов и `startPosition` (2026-10-16) Игровая сессия после E16 дала обратную связь: «белые херни на 0 0 пропали, но колизия осталась», «невидимые стены на угловатых поверхностях», «белая комната стала белой — это успех», «дисплейсменты вывернуты», «свет распространён». ### E17.1. Коллизия: кирпичи моделей лежат в локальных координатах Замер `LUMP_BRUSHES`/`LUMP_BRUSHSIDES`/`LUMP_PLANES` с восстановлением AABB кирпича перебором троек плоскостей: - **кирпич 2184** — `contents 0x1` (`CONTENTS_SOLID`), габарит `(-1240,-968,-4)..(1240,968,4)`; - **кирпич 2185** — то же самое. Это ровно локальные габариты моделей 3 и 4. То есть кирпичи браш-сущностей лежат вокруг начала координат, и наша `Collision::from_bsp` клала их в (0,0,0) — невидимые стены там, где их нет, и отсутствие коллизии там, где есть геометрия. Обе половины жалобы объяснены. ### E17.2. Как определить, к какой модели относится кирпич В `dmodel_t` поле по смещению 36 — **корень поддерева BSP** (`headnode`), не `visleafs`. Проверено обходом: из этого узла достижимы | модель | узел | кирпичей | габарит найденных кирпичей | |---|---|---|---| | 0 (world) | 0 | 2 168 | — | | 1 | 4887 | 1 | `(−4,−968,−204)..(4,968,204)` — совпал с моделью | | 2 | 4893 | 1 | `(−1240,−4,−204)..(1240,4,204)` | | 3 | 4899 | 1 | `(−1240,−968,−4)..(1240,968,4)` | | 4 | 4905 | 1 | `(−1240,−968,−4)..(1240,968,4)` | | 5 | 4911 | 5 | `(−1240,−4,−204)..(1240,4,204)` | | 6..12 | 4923..4982 | по 1..6 | — | Габариты кирпичей **в точности равны** локальным габаритам моделей. Всего покрыто моделями 2 189 кирпичей из 2 203; **14 не принадлежат ни одной модели** — их оставляем в мире (ронять их значило бы потерять реальную геометрию). Ранее парсер `Model` читал `head_node` по смещению 36 — это оказалось верно по совпадению, но поле называлось `visleafs`; фактическая раскладка подтверждена измерением. `LUMP_LEAFBRUSHES` (17) добавлен в `Geometry`. ### E17.3. Что сделано `Collision::from_bsp` (движок `player.rs`): - обходит поддерева `models[i].headnode` и строит карту «кирпич → смещение»; - кирпичам мира и непривязанным кирпичам — сдвиг нулевой; - кирпичам браш-моделей — `origin` их сущности; плоскость пересчитывается как `d' = d + n·origin` (мир `p_world = p_local + origin`); - сущности без коллизии не попадают в мир вообще: `func_illusionary` (в Source `SOLID_NONE`), `func_vehicleclip` (режет только машины), `func_occluder`, `func_areaportal*`, `trigger_*`, `StartDisabled "1"`. Это **намеренно другой список**, чем в рендере: `func_vehicleclip` не рисуется, но и не должен держать игрока, а `func_illusionary` рисуется, но не должен держать. Тест `real_map_brush_model_collision_follows_origin`: в коллизии нет кирпича габарита `(−1240,−968,−4)..(1240,968,4)`, зато есть `(−3288,−4568,152)..(−808,−2632,160)` (модель 3 на своём `origin` `(−2048,−3600,156)`) и `(−3288,−4568,−264)..(−808,−2632,−256)` (модель 4), а кирпичей в коллизии 2 118. ### E17.4. Куда деваются 21 displacement-грань — не деваются Добавлены счётчики отсева (`WorldStats::disp_skipped_*`). Замер: ```text disp: построено 110, нарисовано 89, отсев: скайбокс 21, материал 0, индекс 0, сборка 0 ``` `110 = 89 + 21`, ровно. Все 21 уходят в **отдельный проход 3D-скайбокса** (`world.rs`, ветка `if face.dispinfo >= 0` внутри цикла скайбокса) и там строятся. Потерь нет — гипотеза «T-junction отбрасывает `numedges > 4`» опровергнута: у всех 110 displacement-граней `numedges = 4`, и все лежат в `models[0]`. Тест `real_map_displacement_coverage` проверяет, что сумма отсева равна размеру `LUMP_DISPINFO` — ничего не теряется молча. ### E17.5. `startPosition` — в порядке Гипотеза «углы сетки выбираются наугад» опровергнута: у всех 110 записей `startPosition` совпадает с одним из углов своей базовой грани, худшее расстояние **0.053 юнита**. Также сходятся `power` (87×power 3, 19×4, 4×2) и `DispVertStart`: сумма `(2^power+1)^2` = 12 638 = размер `LUMP_DISP_VERTS`. Значит порядок углов в `build_displacement` и привязка к `startPosition` верны, и «вывернутость» дисплейсментов из жалобы ищется в другом месте — это открыто. ### E17.6. Что открыто 1. ~~**«Дисплейсменты вывернуты»**~~ — закрыто в E18: намотка displacement'ов была перевёрнута относительно браш-граней, и весь рельеф срезался сверху. 2. **«Свет распространён»** — визуально: резкие грани света/тени на дисплейсментах. Подозрение на UV лайтмапы для displacement (см. ответ Gemini в `GEMINI.md`), но наша формула совпадает с его — нужен замер эталонного кадра GMod. 3. Зеркало (`func_reflective_glass`, модель 6) не работает. 4. `ndc.z` в `sun_shadow` (E15.8 п. 3). ## E18. Намотка displacement'ов была перевёрнута — рельеф срезался сверху Закрывает жалобы «дисплейсменты вывернуты» и «сквозь них, когда они вывернуты, видно только скайбокс». Вторая половина жалобы оказалась точным описанием механизма. ### E18.1. Как нашли: погасили меш 3D-скайбокса Небо (`sky.wgsl`) и меш скайбокса (`sky_mesh.wgsl`) рисуются **без записи глубины** и **без отсечения** (`depth_compare: Always`, `cull_mode: None`), поэтому скайбокс всегда заполняет то, что мир не нарисовал, и маскирует пропуски геометрии. Добавлен диагностика-переключатель `SOURCE_RS_NO_SKY_MESH` — гасит только меш, оставляя 2D-фон. Замер на контрольном кадре сверху (`--route "0 0 2200|55 180"`): | режим | результат | | --- | --- | | меш включён | весь ландшафт оливково-зелёный | | `SOURCE_RS_NO_SKY_MESH=1` | тот же ландшафт — **небо** | | `SOURCE_RS_NO_SKY_MESH=1 SOURCE_RS_NO_CULL=1` | по-прежнему **небо** | То есть геометрия мира в этом месте не рисуется даже при снятом отсечении — гипотеза «срез задних граней» на этом шаге опровергнута, дальше искали в данных. ### E18.2. Геометрия рельефа в порядке Замер `real_map_material_index_list` (он же даёт индекс для `--only-material N`): ```text 186 21888 вырожд. 0/7296 нефинит 0 гориз ↑7275 ↓0 GM_CONSTRUCT/GRASS_13 187 13440 вырожд. 0/4480 нефинит 0 гориз ↑4275 ↓0 GM_CONSTRUCT/GRASS-SAND_13 ``` - 0 вырожденных треугольников, все атрибуты конечны (NaN в `pos` прятался бы: `f32::min/max` пропускают NaN, а сравнение `NaN < eps` ложно); - габариты `(-5248,-3712,-927)..(1856,6464,271)` — это ровно земля карты; - в рантайме они слиты в один материал `GM_CONSTRUCT/FLATGRASS` (индекс 36): 35328 индексов = 21888 + 13440, один батч `first_index=57978`. Замечание про индексы: в тестах мир строится с пустым `VirtualFs`, из-за чего материалы не дедуплицируются — 189 против 54 в рантайме, и **индексы расходятся**. `--only-material 186` в рантайме просто ничего не выбирает. Поэтому диагностика материалов (`топ-материал по объёму геометрии`) теперь печатается из сборки мира, а не из теста. ### E18.3. Что реально показал замер Камера в 97 юнитов над крупнейшим треугольником травы `(-937,-2116,253)`, глядя вниз: | режим | результат | | --- | --- | | `--only-material 36` | пусто (небо) | | то же + `SOURCE_RS_NO_CULL=1` | кадр заливает `#93ff26` (FLATGRASS) | Геометрия на месте и позиции верные — **её срезает отсечение**. Тест `real_map_top_surface_orientation` берёт самую верхнюю горизонтальную поверхность над точкой (то, что игрок реально видит сверху) и смотрит, куда смотрит кросс-произведение `(b-a)×(c-a)`: | точка | z | ориентация | материал | | --- | --- | --- | --- | | спавн (пол) | −144 | **вниз** | CONSTRUCT_CONCRETE_FLOOR | | центр карты | −148 | **вверх** | GRASS_13 | | центр травы | 253 | **вверх** | GRASS_13 | | точка скриншота | −71 | **вниз** | concretefloor028a | Видимая сверху поверхность в нашем рендере — та, у которой кросс-произведение смотрит **внутрь** тела (у пола вниз). Браш-грани приводятся к этому в `face_winding`, а `build_displacement` подгонял намотку **вдоль** нормали плоскости — то есть ровно наоборот. Отсюда два наблюдения из жалоб разом: сверху рельеф срезан и виден меш скайбокса, а снизу, наоборот, рельеф проступает («трава сверху — я под картой»). ### E18.4. Что сделано В `build_displacement::emit` порядок индексов перевёрнут: при `geo · up > 0` кладётся `[a, c, b]`. Нормали вершин оставлены **наружу** (вдоль нормали плоскости) — от порядка индексов они не зависят, и динамический свет не поехал. Коллизия не задета: `add_displacement_tris` строит нормали по рёбрам (SAT), порядок вершин для неё неважен. Тест `real_map_displacement_is_visible_from_above` держит правку: пол под спавном и трава в центре карты обязаны иметь одинаковую ориентацию. Визуально после правки: с верхней камеры видны травяные холмы, дороги и озеро мира, а снизу изнанки рельефа нет — только небо и постройки. ### E18.5. Диагностика, которая это открыла - `SOURCE_RS_NO_SKY_MESH=1` — гасит только меш 3D-скайбокса; - `--only-material N` + `--debug-materials` — рисовать одним материалом сплошным цветом (индекс — из лога «топ-материал»); - `SOURCE_RS_NO_CULL=1` — снять отсечение задних граней; - лог «топ-материал по объёму геометрии»: индекс, объём, первый индекс, габариты и флаги `water`/`fallback`; - предупреждение «батчи с индексом вне списка материалов»: рендер молча пропускает батч, чей `material` не нашёлся в `material_bind_groups`, — геометрия при этом есть и в буфере, и в `batches`. ## E19. «Односторонние рампы» — замером не воспроизводятся Жалоба: «рампы и срезы — поверхности, делающие прямоугольный треугольник, направлены только в одну сторону; находишь, где можно пройти в одну, обратно нельзя». Три теста бьют в неё на трёх уровнях, и все три зелёные. ### E19.1. Три замера | тест | что меряет | замер | | --- | --- | --- | | `real_map_brush_blocks_every_direction` | луч из 6 сторон в центр каждого кирпича | 2118 кирпичей (3 вырожденных), **0 проходимых** | | `real_map_brush_stops_a_real_step` | тело 32×32×72, шаг 64, 14 направлений (6 осевых + 8 угловых), реальный `move_aabb` | внутри габаритов карты: **0** проникновений, **0** проходов за центр | | `real_map_walk_never_clips_through` | настоящий `Player::update`: гравитация, скольжение по касательной, подшаги, прыжок раз в секунду, присед на второй половине прохода | 24 точки × 4 направления × 300 кадров = **28 800 кадров**: внутри кирпича глубже 1 юнита — 0, внутри рельефа (`tri_penetration`) — 0 | Первый замер закрывает саму геометрию: полупространства всех кирпичей замкнуты, «дырки» в наборе плоскостей нет. Второй — то, что из полупространств делает шаг: раздувание на полугабариты, отбрасывание грани при `t ∉ [0,1]`, скольжение и выталкивание. Третий — то, что первые два не покрывают: многошаговость, контакт вплотную, прыжок и присед. **Вывод: одностороннего прохода в игровой части gm_construct нет.** Жалоба относится либо к сценарию, не покрытому тестами, либо к месту карты, либо к тому, что уже починено правками origin браш-сущностей (E17) и подшагов. Что осталось непокрытым: пропы (`Collision::boxes` — габариты приходят из клиента, в BSP их нет) и конкретная точка карты — её координаты стоит спросить у игрока, чтобы мерить именно её, а не правдоподобие. ### E19.2. Что нашли попутно: кирпичи 3D-скайбокса проходятся насквозь 140 кирпичей лежат за габаритами мира (z≈11 300 против z≤2976 у карты) — это область 3D-скайбокса. Из них **12 проходятся телом насквозь в обе стороны**, и механизм у них общий: - кирпичи крошечные — 14×17×23 юнита, а тело 32×32×72; - раздувание грани `inflate = |n.x|·16 + |n.y|·16 + |n.z|·36` целиком перекрывает такой кирпич, поэтому из 33–38 плоскостей в шанс на отсечение попадает **1–3**, а тело на старте уже лежит внутри большинства полупространств (`t < 0` — отсечение неприменимо); - первая плоскость всё же останавливает (лучший `t ≈ 0.01…0.06`), но после скольжения по наклонной нормали остаток шага смотрит почти вдоль кирпича, и тело выходит с обратной стороны. Согласованность данных не при чём: нарушение `n·центр ≤ d` по всем плоскостям ≤ 1.7 юнита — плоскости и габариты на месте. Тест считает такие кирпичи **отдельным счётчиком** и не валит проверку: игрок туда не попадает, а тело больше препятствия — это свойство подхода «полупространство + раздувание», а не пропуск конкретной грани. Тесты: `real_map_brush_blocks_every_direction`, `real_map_brush_stops_a_real_step`, `real_map_walk_never_clips_through` в `crates/engine/src/player.rs`. ## Часть E20. Лайтмапа читалась со сдвигом строки (2026-10-16) ### E20.1. Жалоба и как к ней пришли Жалоба (вопрос 6 `GEMINI.md`): «на траве свет лежит плитами — резкие прямые границы светлого и тёмного». Рельеф перестал быть невидимым после E18, и на нём впервые стала видна лайтмапа: прямоугольные светлые пятна с ступенчатым краем, плюс белые полосы на бетоне. Нашлось двумя независимыми замерами, и оба — на данных, а не на картинке. ### E20.2. Замер структуры: блоки в лампе пакуются с шагом `extent` В vrad блоки граней лежат в лампе подряд, поэтому зазор между соседними `lightofs` кратен размеру блока. Два кандидата на размер: - `extent[0]·extent[1]·4` — там `extent = Face::lightmap_extent()`, то есть `extents + 1` люкселей на сторону (в BSP `extents` — индекс последнего); - `(extent+1)·(extent+1)·4` — то, что брал `sample_light_luxel` (`width = extent[0] + 1`). Замер `real_map_lightmap_blocks_are_packed` на gm_construct, 6890 зазоров: | кандидат | зазоров, точных по допуску 8 байт | |---|---| | `extent` | **6527** | | `extent+1` | **0** | | ни один / оба | 0 / 363 | Учтены и до четырёх блоков на грань (по числу `styles != 255` — каждая стильная лайтмапа занимает свой блок), и выравнивание в 4 байта. **Вывод: `lightmap_extent()` уже содержит единицу, а «+1» в строке читался второй раз.** Чтение шло со сдвигом вниз по блоку — до ~2 люкселей к низу блока, а последние строки вылезали в чужой блок соседней грани. Правка: `width = extent[0]`, отсечка `x`/`y` — до `extent - 1`. Ложный след записан честно: в коммите `e91a60d` «+1» вносили по замеру («без +1 каждая строка читается на люксель раньше»), но тогда `extent` уже был `extents + 1` — второй единицы он не требовал. Замер тогда стоял на подсчёте строк, а не на зазорах между блоками, и ошибку не поймал. ### E20.3. Что это давало на кадре Снимок с высоты 2200, та же камера, что в E18: на траве — жёсткие прямоугольные светлые границы со ступенчатым краем, на бетоне — белые полосы. После правки — ровное поле, полос нет (склейка до/после в `/tmp/opencode/cmp_lm.jpg`). ### E20.4. Замер яркости зон: камера E11, до/после/эталон Та же камера, что в E11–E13: `(-399.5, -1604.5, 67.9)`, yaw 146,33, pitch 3,48, FOV 100. Эталон — скриншот GMod той же камеры, зоны сняты на одинаковых нормализованных координатах (сравнение самих выборок проверено глазами: на одном и том же месте лежат бетон и трава). | зона | до правки | после правки | GMod | |---|---|---|---| | трава | 73,3 | 73,2 | 94,0 | | бетон | 169,3 | **116,0** | 144,0 | | небо | 207,6 | 207,6 | 204,2 | | тоннель | 81,1 | 81,1 | 83,1 | Правка подвинула только бетон — и сразу на 53 пункта: раньше на этом участке читались чужие строки (потому и белые полосы), теперь — свои. Трава, небо и тоннель не изменились. **Открытый разрыв, существовавший и до правки: освещённые солнцем поверхности примерно на 20% темнее эталона** (трава −20,8, бетон −28,0), тогда как небо (+3,4) и тоннель (−2,0) совпадают. Тень в этом замере не причёмна для травы: её значение не сдвинулось вместе с правкой строки. Записано как открытый вопрос — недобор динамического солнца или масштаба лайтмапы; разбирается отдельно, эта правка его не вызывала и не чинит. ### E20.5. Ложный след в самом замере `%[fx:mean*255]` в ImageMagick без имени канала даёт величину другого рода: на одном и том же кропе 118,6 против 73,2, и разные зоны «съезжают» по-разному (бетон совпадал, трава — нет). Все числа выше пересчитаны по каналам `mean.r`/`mean.g`/`mean.b` и усреднены. Тест: `real_map_lightmap_blocks_are_packed` в `crates/engine/src/player.rs`. ## Часть E21. Касание ребром: невидимые стены на стыках поверхностей (2026-10-16) ### E21.1. Точка и что в ней было Адрес от пользователя: `-2385 -2032 256` — терраса у здания. Замер `probe_ramp_movement`: игрок **не делал ни шага** в −Y (пройдено 0, залипаний 107), при этом кирпичей, отсекающих шаг, не находилось, тело ничего не касалось, пропов и треугольников рельефа рядом не было. ### E21.2. Причина: грань блокировала по касанию ребром Разбор по данным (`probe_brush_dump`, полный дамп кирпичей рядом): у точки стоит кирпич #269 — плита толщиной 16 юнитов (`x -3072..-1536`, `y -3584..-2048`, `z 240..256`). Игрок стоит на террасе **вровень с её верхом** (ступни z = 256 = верх плиты) и вплотную к её боку (тело `y -2048..-2016`, грань плиты на `y = -2048`). `clip_fraction` считала блокирующей любую грань, до габарита которой тело «достаёт» касанием. Тело касалось плиты верхним **ребром**: по высоте перехлёста нет вовсе (тело z 256..328, грань z 240..256), а боковая грань держала шаг. Это и есть «невидимые стены на стыках даже двух поверхностей» — настоящий перехлёст между телом и гранью нулевой. ### E21.3. Правка У блокирующей грани по оси её нормали касание по-прежнему считается достижением (иначе нельзя ни стоять на полу, ни упираться в стены), а по двум другим осям нужен **настоящий перехлёст** габаритов — причём по габариту за **весь шаг**, а не в один момент касания: при заходе в угол наклонно перехлёст по второй оси появляется уже после касания. Замер промежуточного варианта (строго по моменту касания): 2502 «прохода» на 2118 кирпичах; с проверкой по всему ходу — 0 внутри карты. Строгое правило применяется только к почти осевым нормалям (доля по оси ≥ 0,99): у наклонной грани перехлёст по осям бывает нулевым законно. ### E21.4. Замеры после правки | место | до | после | |---|---|---| | `-2385 -2032 256`, шаг в −Y | 0 пройдено, 107 залипаний | 256 пройдено до **настоящей** стены (#247, `z 256..384`), на старте залипаний 0 | | `-2438 -966 48`, диагональ 45° | 329 пройдено, 22 залипания | 456 пройдено, 0 залипаний | Тесты: синтетический `edge_touch_does_not_block_but_step_and_landing_do` (касание ребром не блокирует; ступень 16 юнитов блокирует; падение сверху на плиту блокируется верхней гранью) и картовый `real_map_ledge_seam_does_not_block` (из той же точки игрок проходит все восемь направлений до первой настоящей преграды). ### E21.5. Две метрики, которые пришлось уточнить (ложные следы) - «прошёл насквозь» в `real_map_brush_stops_a_real_step` считалось по переходу **центра** кирпича: у столбика 8×8 при теле 32×32 центр тела оказывается за его центром и при честном обходе боком. Теперь «насквозь» — выход центра за дальнюю грань вдоль хода, а к пластинам тоньше 4 юнитов метрика не применяется вовсе (проверка «тело не осталось внутри» остаётся строгой). - «преграда из воздуха» в `real_map_no_blocking_by_bounding_boxes` ловила блокировку **вертикальной** составляющей движения — это опора пола, а не стена. Теперь преградой считается только грань, спорящая с горизонтальным ходом. ## Часть E22. Рампы: отсечение брало срезанную грань (2026-10-16) ### E22.1. Жалоба и замер Пользователь после починки стыков (E21): «а на рампах? на наклонных поверхностях? всё также по жёлтой коробке идёт коллизия, а не по реальной форме рампы». Замер `probe_slanted_surface`: по сетке над пятном **каждого** наклонного кирпича карты (175 кирпичей, 7672 луча) пускается луч вниз и сравнивается место остановки с верхней точкой кирпича в этом столбце (спуск по полупространствам). До правки **394 луча из 7672 вставали на 9,0 юнита выше настоящей поверхности**. ### E22.2. Причина: бралась первая задетая грань У кирпича-холма (#1479 и родня) есть и верхняя грань `z ≤ 240`, и наклонная `0,361x + 0,127y + 0,924z ≤ −380,63`, которая верхнюю **срезает**. В столбце, где наклонная уходит ниже, реальная поверхность — 231, а отсечение останавливало тело на верхней грани (240). Кирпич при этом выпуклый, и правильный ответ — «тело вошло во **все** полупространства», а не «задело первую попавшуюся грань». ### E22.3. Правка: алгоритм движка `CM_ClipBoxToBrush` Для каждой грани считается доля входа `f = (d1 − ε)/(d1 − d2)` и доля выхода `f = (d1 + ε)/(d1 − d2)`, удар ставится по **самой поздней входной** грани, если она раньше самого раннего выхода. Если тело начало шаг, уже перекрывая кирпич (`enter < 0`), отсечения нет — выталкиванием занимается разрешение проникновений, как и раньше. `ε = 1/32` — `DIST_EPSILON` движка. Проверка достижимости из E21 (касание ребром не преграда) сохранена и применяется к выбранной грани. ### E22.4. Замеры после правки | что | до | после | |---|---|---| | лучи по наклонным кирпичам карты, расхождение > 4 юнитов | 394 из 7672 (ровно +9,0) | **0 из 7672** (максимум 1,4 — полугабарит луча) | | выталкивание из пустоты (перебор точек тела по решётке) | 1 | 1 (0,3 юнита — касание) | | игрок в `-2385 -2032 256`, шаг в −Y | блокировался | проходит 256 до настоящей стены | | игрок в `-2438 -966 48` | залипания на 45° | все направления, кроме настоящей стены на 90°, свободны | Отдельно проверено пробой движения: игрок **залезает** на ступень высотой 16 (синтетическая сцена, `edge_touch_does_not_block_but_step_and_landing_do`) — подшаг работает, отсечение ему не мешает. ### E22.5. Что пришлось уточнить в тестах (с замерами) - `real_map_brush_blocks_every_direction`: кирпичи за габаритами карты (3D-скайбокс, 140 штук) теперь считаются отдельно — часть из них сшита с несогласованными плоскостями (E19.2), и требовать от них отсечения нечего. В игровой части: 0 проходимых. - `real_map_ramps_are_not_boxes`: порог медленной рампы опущен с 80% до 75%. Замер: рампа #1480 (15,2°) на краю даёт 197,8 юнита/с против 250 на ровном полу — но **застреваний при подъёме ноль**, движение плавное, это съезд по краю, а не стена. Настоящая стена даёт ноль, и такой тест (`stalled`) остался строгим. ## Часть E23. Рельеф: треугольники отсекают шаг (2026-10-16) ### E23.1. Жалоба и причина Пользователь: «опять невидимые стены, но теперь уже на дисплейсментах, и коллизии их не видно; в гору скорость замедляется; прыгать рядом с ними нельзя, но они проходятся, если подойти, начать идти и попытаться прыгнуть; на наклонных дисплейсментах игрок скатывается, хоть и останавливается». Плюс уточнение: «стены ровно по полигонам дисплейсментов». Причина одна: **треугольники рельефа не участвовали в отсечении шага**. Они были только в выталкивании (`resolve_overlap`): тело, въехав в склон, выталкивалось по нормали треугольника, а нормаль наклонной поверхности смотрит вниз-вбок. Отсюда сразу всё — «стена» на ровном склоне, потеря скорости в гору, сползание, странные прыжки и «стены по рёбрам полигонов» (выталкивание срабатывает на границе треугольника). ### E23.2. Как в движке `CDispCollTree` трассирует треугольник как **выпуклый объём**: плоскость поверхности плюс три боковые грани через рёбра (нормали — вдоль поверхности, наружу). Шаг об него отсекается так же, как об кирпич, с той же логикой «последней входной грани». ### E23.3. Правка Отсечение по набору полупространств вынесено в `Collision::clip_planes` (кирпич вызывает его с своими плоскостями, треугольник — с четырьмя построенными). `Collision::clip_triangle` строит эти четыре плоскости: нормаль из намотки, боковые — `(to − from) × n`, ориентированные так, чтобы центр треугольника был внутри. Треугольники добавлены в отсечение `move_aabb` и в `ground_trace` (прижатие к земле), выталкивание осталось страховкой. ### E23.4. Замеры (тест `real_map_hill_walk_keeps_speed`) Восемь точек на ходибельных склонах рельефа (13–18°), в четырёх направлениях каждая, с эталоном на ровном полу: | что | ровный пол | склон (медиана) | |---|---|---| | скорость | 250,0 | **252,2** | | «тормозной путь» после отпускания ввода | 26,54 | **26,40** | То есть по рельефу игрок идёт со скоростью ровного пола, а мнимое «сползание» оказалось тем же тормозным путём, что и на полу: сползания как такового нет. Все 28 картовых тестов и весь workspace — зелёные. ### E23.5. Дополнение: нормаль рельефа смотрела в твердь После E23 стены по рёбрам полигонов остались — и замер показал почему: **у всех 11776 треугольников рельефа** нормаль из намотки смотрит **вниз** (намотка «гео внутрь», как у брашей после фикса E18). Значит построенный по ней выпуклый объём лежал **над** поверхностью, и его боковые грани вставали стеной по каждому ребру полигона — ровно то, что видел пользователь. Рельеф — heightfield: наружу он смотрит только вверх, поэтому в `clip_triangle` нормаль разворачивается, если ушла вниз (замер в тесте печатает счётчик: «намотка вверх 0, вниз 11776» до правки). Замер после правки (`real_map_hill_walk_keeps_speed`, те же восемь склонов): скорость в гору 250–287, сползание равно тормозному пути ровного пола (26,54). По направлениям: лучшая — 250–287, худшая в четырёх точках из восьми — 170–222 (это уклон в гору), а в четырёх — 0,0–72,7. Ноль означает, что направление упирается в крутой соседний склон: точка стоит на ходибельном треугольнике, но рядом с ним обрыв. Это ещё предстоит проверить отдельно — стена может быть и настоящей (крутизна выше `sv_walkable_normal`), и мнимой. ### E23.6. Угол склона: замер скорости подъёма Жалоба: «на рампе в 35 градусов замедление есть». По движку на земле вертикальная скорость обнуляется, движение идёт по горизонтали, а склон догоняется `StepMove` + `StayOnGround` — значит до `sv_walkable_normal` (0,7 = 45,6°) замедления быть не должно. Замер (`probe_slope_angles`, синтетический клин, шаг вверх 2 секунды): | угол | 5° | 10° | 15° | 20° | 25° | 30° | 35° | 40° | 44° | |---|---|---|---|---|---|---|---|---|---| | юнитов/с | 239 | 232 | 196 | 152 | 116 | 88 | **65** | 49 | 39 | Замедление растёт с углом, то есть скорость держит не подшаг, а скольжение по грани: при отсечении по наклонной плоскости из горизонтальной скорости убирается нормальная компонента (остаётся `v·cos²` по горизонтали), а вертикальная тут же обнуляется как «на земле». В движке на склоне работает именно `StepMove`: прямой ход блокируется, и берётся вариант «вверх на `sv_stepsize`, вперёд, вниз», а из двух кандидатов (`StepMove`) выбирается тот, кто ушёл **дальше по горизонтали** — потому скорость в гору и не падает. **Что чинить:** наш `step_move` на склоне, судя по замеру, отдаёт победу прямому (скользящему) ходу. Нужно, чтобы выбор шёл по горизонтальному расстоянию, как в `CGameMovement::StepMove`, и чтобы подъём считался основной дорогой на ходибельном склоне. Приёмка — эта же таблица: до 44° скорость не ниже 95% от 250. ### E23.7. Где движок берёт скорость на склоне Найдено в `gamemovement.cpp` (`ClipVelocity`, строка ~3144): ```cpp if ( flRedirectCoeff > 0.f ) { // Redirect clipped velocity along angle of movement float flLen = out.Length(); out *= ( -1.f * flBlocked * flRedirectCoeff + flLen ) / flLen; } ``` `flBlocked` — это `v·n` до отсечения (отрицательное, движемся в плоскость), `out` — уже отсечённая скорость. То есть движок **возвращает потерянную в плоскость скорость**, растягивая вектор вдоль скольжения. Это и есть отсутствие замедления на рампе. Коэффициент передаётся в `TryPlayerMove` третьим параметром; в базовом `game` 2013 он равен нулю, у мода может быть своим. **Попытка перенести наивно — провалилась (замер).** Если возвращать потерянное на каждом контакте каждого подшага, бонус накапливается за кадр: на 15° скорость улетела в 442 юнита/с (в движке такого нет). Правильный перенос: возврат считается **один раз на отсечение**, длина берётся **до** отсечения, и результат ограничивается желаемой скоростью кадра. Правка откачена, приёмка прежняя — таблица `probe_slope_angles`: до 44° не ниже 95% от 250. ### E23.8. Подшаг больше не гасит скорость о склон Замер-диагностика (`SOURCE_RS_TRACE_STEP` в `step_move`, покадровый вывод в `probe_slope_angles`) на 35°: равновесие скорости было `x = 66,8, z = +41,8`, сдвиг 1,11 по x за кадр. Расчёт равновесия по формуле «каждый кадр теряется нормальная компонента, разгон добавляет 41,7» сходится с замером (`v·cos²`), то есть причина — **гашение скорости при посадке подшага**. В движке вертикальная часть шага — трассировка вниз с переносом позиции, скорость после неё не меняется (`StayOnGround`, `StepMove`: «Move down a stair» — трассировка, а не `TryPlayerMove`). У нас посадка шла через `move_aabb`, и её контакты гасили скорость ещё и в `update`. Две правки в `step_move`: (1) при победе подъёма скорость берётся без гашения о склон, (2) контакты посадки наружу не отдаются. Замер после: | угол | 5° | 10° | 15° | 20° | 25° | 30° | 35° | 40° | 44° | |---|---|---|---|---|---|---|---|---|---| | было | 239 | 232 | 196 | 152 | 116 | 88 | 65 | 49 | 39 | | стало | 239 | 233 | 226 | 214 | 195 | 175 | **147** | 132 | 107 | Остаток (цель — 250 до 44°) — это потери на прямом (скользящем) пути, который иногда всё ещё выигрывает выбор: следующий шаг — смотреть по трассе, какой путь победил на каждом кадре, и добиться, чтобы на ходибельном склоне побеждал подъём. Все 28 картовых тестов и весь workspace зелёные. ### E24. Туман карты (A13, сделано) **Сделано.** `Fog` читается при сборке мира: приоритет у `env_fog_controller`, иначе ключи `worldspawn` (`fogcolor`, `fogstart`, `fogend`, `fogmaxdensity`, `farz`). `Fog::fraction(distance)` — линейная доля от `start` к `end` с потолком плотности, как в движке; при `end <= start` тумана нет (`None`). В рендере туман уходит двумя `vec4` в uniform камеры (седьмой — цвет, восьмой — `start`, `end`, `max_density`, признак), буфер камеры вырос со 112 до 144 байт. В `world.wgsl` цвет смешивается с туманом по расстоянию до камеры. **Замер на gm_construct:** туман есть — цвет (0,557 0,643 0,698), `start 1024`, `end 250000`, плотность 1,0, `farz -1`. Это **дальний** туман: на дистанции 2000 юнитов доля ≈ 0,4% — на кадре с камеры E11 числа не изменились ни на волос (трава 73,2, бетон 116,0, небо 207,6, тоннель 81,1 — как в E20). То есть механика подключена и проверена, а заметным туман станет у карт с близким `fogend` (или при смене параметров через `env_fog_controller`). Осталось по туману: применить его и к воде (`water.wgsl`), и к моделям, когда появятся. ### A20. Модель игрока Сейчас игрок — это AABB 32×32×72: коллизии и камеры хватает, а визуальной модели нет вовсе. В GMod игрок — модель `models/player/...` со скелетом, анимациями, глазами/флексами и аттачментами для оружия. **Нужно:** выбор модели как в GMod (`player_manager`, `GM:PlayerSetModel`), загрузка MDL (часть M5: VVD/VTX, скиннинг, GPU-кости), набор activity по скорости состояния (стоять/идти/бежать/присед/прыжок/плавать — как в движке `ACT_MP_*`), привязка оружия к `$attachment`, тени от модели. **Проверка:** модель видна от третьего лица и в зеркале, анимация совпадает с состоянием, оружие в руках на месте. ### A21. Полная физика Каркас есть: Rapier (B2), констрейны (B2.6, частично), габариты пропов в коллизии игрока, вода как объём. **Нужно:** твёрдые тела пропов с массой и инерцией, полный набор констрейнов GMod (weld/rope/axis/ballsocket/...), гибернация спящих островов (P2/M6), взаимодействие игрока с пропами (гравиган, `+use`, толкание), движущиеся платформы и `func_*`, `func_breakable`, физика в воде, сварка/верёвки из Sandbox. **Проверка:** стресс-полигон с 1000+ пропов держит кадры (M4), пропы не дрожат, констрейны не расходятся, замеры движения игрока остаются в допуске. ### A22. Sandbox через GLua — цель проекта Перенести штатный `garrysmod/gamemodes/sandbox` и дать ему работающий API: `ents`, `player`, `util`, `constraint`, `duplicator`, `properties`, `weapons/gmod_tool`, `undo`, `cleanup`, физическая пушка, спавн-меню (VGUI — отдельная тема, см. P10). **Что уже есть:** файловая система аддонов, Lua-типы (D1), мост к движку (D2), стандартная библиотека (D3), таймеры и хуки, энтити (B). **Нужно:** `ents.Create/Spawn/PhysicsInit`, `surface`/`render` для HUD, `weapons` и tool-режимы, `undo`/`cleanup`, сохранение и загрузка dup, сеть для всех этих вызовов (в мультиплеере). **Проверка:** на `gm_construct` запускается sandbox-gamemode, спавн-меню (или его консольный эквивалент) работает, пропы спавнятся, верёвки вяжутся, `undo` отменяет, dup сохраняется и восстанавливается. ### A23. Система оружия (SWEP, вьюмодель, атака) — ускоряющий пункт Пользователь просит приблизить путь до Sandbox-UX: спавн из Q-меню, нормальная модель игрока, физган, и «Weapon всё сделать». **Что нужно:** - реестр оружия из Lua (`weapons/*.lua`, `SWEP`-таблица: `PrimaryAttack`, `SecondaryAttack`, `Deploy`, `Holster`, `Think`, `Draw`/`DrawHUD`), как в GMod; - вьюмодель в первом лице (та же загрузка MDL, что в A15/A20: скелет, скиннинг, аттачменты), позиция/углы вьюмодели, `$attachment` для рук; - переключение оружия (слоты 1..N, `weapon_*`), сети-совместимо; - стрельба-заготовка: `util.TraceLine`-луч, эффекты, звук; урон — по сущностям; - **gmod_tool** (tool-режимы) и **weapon_physgun**: физган — выбор пропа лучом, удержание и таскание, вращение колесом, заморозка (`PhysgunFreeze`), подсветка цели; - **gmod_camera** и hud-часть (`DrawHUD`) — по мере надобности. **Приёмка:** можно взять `weapon_physgun`, подцепить проп и протащить его; из `gmod_tool` спавнить проп; оружие переключается по слотам; вьюмодель видна и двигается. **Сделано (ветка `feat/swep`):** реестр SWEP из Lua с фильтром `Spawnable`/`ViewModel`, слоты 0..9, `SOURCE_RS_SWEP`, `Holster`/`Deploy` при смене, пересоздание вьюмодели, ЛКМ → `PrimaryAttack` (автоматический огонь по `Primary.Automatic` и `GetNextPrimaryFire`), `Think` каждый кадр, HUD с `PrintName`/патронами. Осталось: `SecondaryAttack` на ПКМ, отдельный FOV/масштаб вьюмодели, проигрывание `SendViewModelMatchingSequence`, урон/эффекты, `DrawHUD` скриптов. **Сделано (ветка `feat/viewmodel`):** встроенный SWEP `weapon_pistol` (Slot 1, SlotPos 1, 18 патронов, одиночный огонь) — в `list.Get("Weapon")` и слотах, пользовательский `lua/weapons/weapon_pistol.lua` важнее встроенного. Посадка вьюмодели стала таблицей по модели (`w_pistol`: yaw −90°, 20 юнитов; `v_pistol`: yaw 0°, 34, scale 0.8; `c_smg1`: yaw 0°, 26) с поддержкой масштаба в движке; `ViewModelFOV` и необязательные `ViewModelOffset`/`Angles`/`Scale` из SWEP перекрывают таблицу. Отдельного прохода с FOV вьюмодели пока нет — компенсация масштабом; у `v_pistol` остаются оторванные руки, поэтому встроенный пистолет использует `w_pistol`. **Сделано (ветка `feat/sandbox`):** физган как отдельный режим `F` (ЛКМ — захват и плавное удержание скоростью, колесо — доворот, ПКМ — freeze), дальность 250 и предел массы 500, луч/крест в отладочном слое, жёлтый маркер замороженного пропа. Undo-стек на 32 действия (`Z`/`Shift+Z`) откатывает спавн (тело/сущность удаляются, меш уводится под карту), перемещение и заморозку. Проверки: `SOURCE_RS_TEST_PHYSGUN`, `SOURCE_RS_TEST_UNDO`, `SOURCE_RS_TEST_REDO`. ### A24. Q-меню спавна (spawnmenu) Меню спавна из `sandbox/gamemode/spawnmenu` (Lua + VGUI). У нас VGUI нет (P10), поэтому путь такой: сначала **консольные эквиваленты** (`gm_spawn`, `gm_give`, `gmod_tool`-режимы) на тех же данных из Lua (`list.Get('SpawnableEntities')` и т. д.), потом — быстрый UI-слой на Rust (как в P10: нативные панели, Derma — совместимый слой сверху). **Приёмка:** списки спавна читаются из Lua-данных аддонов; спавн пропа по выбору из списка; позже то же самое в окне Q. ### A25. Ускоренный порядок под Sandbox-UX По просьбе пользователя (спавн из Q, модель игрока, физган, оружие) очередь переставлена: сначала путь до «играбельного Sandbox», потом добор визуальной точности. 1. A23 оружие (каркас SWEP + вьюмодель + аттачменты) — сюда же A20 (модель игрока и вьюмодель — один и тот же рендер моделей). 2. A21 физика взаимодействия (гравиган/физган: луч, удержание, вращение, заморозка) — без полной гибернации, она позже. 3. A24 спавн: сначала консольные команды на Lua-данных, затем окно Q. 4. A22 Sandbox целиком (undo/cleanup/duplicator) — идёт следом. 5. Визуальная точность (A10 свет, A11–A12 вода, A16 трава/декали, A18 скайбокс) — продолжается параллельно, но не блокирует Sandbox. ### A26. Анимации моделей (M5) — план и первые шаги Пользователь: «по возможности делай анимации». Речь про студийные модели: игрок, оружие, пропы (сейчас всё рисуется в bind-позе). **Что уже есть в `formats`:** `mdl.bones()` — скелет (родители, позиции, кватернионы), `mdl.attachments()`, `mdl.hitbox_sets()`, `mdl.bodyparts()`, `vvd.vertices(lod)` и `vvd.fixups()` — веса и кости по вершинам, `vtx.body_parts()` — топология и кости по вершинам. То есть **данные скелета и весов читаются**, не хватает их переноса в рендер и разбора анимаций. **Чего нет:** 1. секвенций (`mstudioseqdesc_t`) — их надо разобрать в `mdl.rs`; 2. самих кадров анимации: у HL2-моделей они лежат в отдельном `.ani` рядом с `.mdl` (`mstudioanimdesc_t` + `mstudioanimvalue_t`, сжатие по кривой/дельтами); 3. скиннинга: `ModelVertex` (pos/normal/uv) не несёт костей и весов — нужно добавить (в Source `mstudiovertex_t`: до 3 весов + число костей); 4. покадрового пересчёта вершин (CPU-скиннинг) и заливки на GPU — механизм заливки уже готов (`moved_ranges` из E25/A21, тот же путь, что у движущихся пропов); 5. выбора секвенции по состоянию (как `ACT_MP_*`): стоять/идти/бежать/присед/ прыжок/плавать, с blend между ними. **Сделано (шаг а, 2026-10-17).** `mdl.sequences()` — разбор `mstudioseqdesc_t`. Размер записи версионно-зависим (184…208 байт), поэтому он **определяется по данным**: берётся кандидат, у которого у всех секвенций имя читается непустой печатной строкой. `Model` движка теперь несёт `bones` и `sequences`. **Замеры на живых моделях:** пропы карты — `bones=1, sequences=1, «idle»`; модель игрока `models/player/police.mdl` — **`bones=57`**, но `sequences=1` («ragdoll»). Это не ошибка разбора: у игроков активности лежат во **включённых моделях** (`police.mdl` включает общую модель игрока), и их надо сливать по `mdl.include_models()`. **Сделано (шаг б).** `Model` несёт `skins: Vec` — привязку вершин к костям из VVD (`bones[3]`, `weights[3]`, `num_bones`), по одной на вершину. Замер на живых моделях: `companion_doll` 1471/1471, стол 136/136, диван 556/556 — привязка есть у всех вершин; у пропов при этом один «корень» (bones=1). **Сделано (шаг в).** CPU-скиннинг: `PropMesh` несёт скелет и привязку вершин, `World::skin_model_instance` пересчитывает вершины по позе костей (угол кости складывается с родительскими, вершина смешивается по весам) и помечает диапазон для заливки на GPU — тем же `moved_ranges`, что у движущихся пропов. **Проба видна кадром:** вьюмодель (пистолет) качает одну кость по синусу — геометрия деформируется на экране, то есть цепочка «скелет → веса → вершины → GPU» работает целиком. Пока это не анимация: настоящие кадры из `.ani` следующим шагом. **Сделано (слияние включённых моделей).** Разбор `include_models()` и лёгкая загрузка включённой модели (без геометрии и текстур: `Mdl::parse` + секвенции). Замеры: `police.mdl` включает `models/m_anm.mdl` — библиотеку анимаций GMod; у неё `numlocalseq=464`, `numlocalanim=1563`, 56 костей. После слияния у модели игрока **465 секвенций** (была одна «ragdoll»). Важная находка: у «единичных» (unified) моделей GMod запись секвенции длиннее 176…208 байт, поэтому перебор шага расширен до 176…320 с обязательной проверкой имён ВСЕХ секвенций — иначе разбор молча возвращал ноль. **Сделано (кадры из `.ani`, подготовка).** `mdl.animations()` — описания анимаций (`mstudioanimdesc_t`), как и секвенции, с определением размера записи по данным; `anim_block_names()` — имена анимационных файлов; `anim_blocks()` — границы блоков внутри `.ani`. **Замеры:** у `m_anm.mdl` — **1563 анимации** (`@reference` 101 кадр, `@idle_all_01` 280, `@idle_all_02` 151, все 30 fps), 247 блоков, и **все** с `anim_block > 0`, то есть кадры не в MDL, а в отдельном файле. Файл `models/m_anm.ani` прочитан целиком: **7,1 МБ**. Значит декодер кадров будет брать данные оттуда (`mstudioanimblock_t.datastart + animdesc.anim_index`). **Сделано (декодер кадров, проверен на живых данных).** Спека взята из `studio.h` (SDK 2013): `mstudioanim_t` — **4 байта** (`bone`, `flags`, `nextoffset`), дорожки связаны списком до `nextoffset == 0`; `valueptr` — три смещения каналов; значения `mstudioanimvalue_t` идут дельтами с накоплением (`valid`, `total`); есть флаги RAWROT (`Quaternion48`), ANIMROT/ANIMPOS (дорожки), RAWROT2. `Mdl::animation_tracks` и `Mdl::decode_track` написаны и прогнаны на настоящей библиотеке GMod: анимация `@idle_all_01` — **280 кадров, 51 дорожка**, значения вида 17013, 1424, −874 (шорты, которые делятся на масштаб кости при сборке кватерниона). **Сделано: проигрывание кадров работает.** Спека взята из настоящего `bone_setup.cpp` (Mapbase-зеркало SDK 2013, `mp/src/public/bone_setup.cpp`) — предыдущие догадки были неверны, и это стоит помнить: - каналы дорожек — **не компоненты кватерниона**, а **углы Эйлера**: значение кадра `raw · rotscale[j] + поза покоя (bone.rot)`, затем `AngleQuaternion`; - позиции — `raw · posscale[j] + bone.pos`; - значения блоков **не накапливаются дельтами**: `ExtractAnimValue` идёт по блокам `valid+1` записей и внутри блока берёт `value[k]`, а дальше повторяет последнее (`value[valid]`); - `mstudioanim_t` — 4 байта (`bone`, `flags`, `nextoffset`), дорожки — список; - флаг `STUDIO_ANIM_DELTA` (0x10) означает «каналы относительные»: поза покоя не прибавляется. Замеры на живых данных (тесты `real_player_animation_channel_scales`, `real_models_animation_sources`, `real_player_model_animation_moves_vertices`): - `@taunt_muscle_base` (451 кадр, 53 дорожки): углы ≤ 4,75 рад, сдвиги позиций ≤ 28,5 юнитов, кватернионы единичные — при новой спеке **ни одного нарушения**; - кости локальны родителю (цепочка 4,0 / 3,5 / 8,9 юнитов), поза покоя корня — таз на высоте 38,6; - `police.mdl` — 57 костей, своя одна анимация `@ragdoll` (кадры в MDL, `anim_block == 0`); `m_anm.mdl` — 56 костей, 1563 анимации (кадры в `models/m_anm.ani`, 7,1 МБ, 247 блоков); у пистолета — 3 свои анимации в MDL; - модель игрока, анимация `idle_all_01`: из 3147 вершин **1101 сдвинулась** между кадрами 0 и 30, высота тела (5–95%) 61 юнит и в позе покоя, и в анимации — модель не схлопывается. В движке: `AnimData` (буферы кадров + описания + скелет, живёт под `Arc`), `frame_pose` (позы костей по кадрам с блендом), `play_model_animation` (мировые позы через родителей, `A · B⁻¹` по костям, скиннинг теми же весами), кости меша находятся в скелете анимации **по именам**. В клиенте: вьюмодель играет `idle` (свои кадры), модель игрока — `idle_all_01`; добавлен вид со стороны `--third-person` — без него собственную модель не видно (в первом лице камера внутри неё, и она уезжает под карту). **Сделано (постоянные дорожки RAW, 2026-10-23).** Разобраны все три вида постоянных значений `mstudioanim_t` — код взят по спеке `compressed_vector.h`: - `STUDIO_ANIM_RAWROT` (0x02) — `Quaternion48`: 6 байт, x/y по 16 бит со смещением 32768 и шагом 1/32768, z — 15 бит со смещением 16384 (1/16384), старший бит — знак `w`; `w` восстанавливается из единичной длины; - `STUDIO_ANIM_RAWROT2` (0x20) — `Quaternion64`: 8 байт, x/y/z по 21 биту (`(raw − 1048576) / 1048576.5`), старший бит — знак `w`; - `STUDIO_ANIM_RAWPOS` (0x01) — `Vector48`: три half-float; `pPos` идёт за Quaternion48/64 (смещение учитывает оба флага). `AnimTrack` теперь несёт готовые `raw_rot`/`raw_pos`, а `frame_pose` отдаёт им приоритет над дорожками и позой покоя (как `CalcBoneQuaternion` / `CalcBonePosition`). Замеры на живых данных (`real_animation_raw_flags_decode`): `v_pistol @idle01` — 10 дорожек, 7 RAWROT и 2 RAWPOS, у 7 повороты отличаются от позы покоя; `m_anm @taunt_muscle_base` — 53 дорожки, **24 RAWROT2**, все кватернионы единичные, неизвестных флагов нет. Анимация `@taunt_muscle_base` теперь двигает 3145 вершин из 3147 между кадрами 0 и 30 (тест `real_player_taunt_moves_vertices_with_raw_tracks`), вылет от центра 63,7 юнита — скелет не разваливается. **Сделано (выбор анимации по состоянию и вьюмодель, 2026-10-23).** `engine::animation` — чистая функция выбора и состояние фазы: - стоя — `idle_all_01`; шаг — `a_Walk`; бег — `a_Run` (флаг `sprint` или скорость ≥ 320); присед — `@cidle_all` / `a_Cwalk`; в воздухе — первая существующая из `@jump_fist`, `@jump_pistol`, `a_gmod_jump_delta`, иначе остаётся текущая; - восемь направлений (N/NE/E/SE/S/SW/W/NW) считаются по горизонтальной скорости относительно курса взгляда: стрейф вправо даёт `E`, влево — `W`; - walk ↔ run и смена направления сохраняют фазу, смена типа (стоя/присед/ воздух) сбрасывает время; если анимации нет в модели, остаётся текущая. Клиент (`crates/client/src/map.rs`) держит `AnimationState` и играет выбранное имя. Вьюмодель получила покачивание при ходьбе (амплитуда от скорости) и инерционный сдвиг при повороте; ЛКМ без тела под прицелом проигрывает одноразовую `fire`/`attack_player`, если она есть у модели. Привязка вьюмодели идёт к центру **анимированной** позы: у `v_pistol` `RAWPOS` корня сдвигает меш на 68 юнитов, и без поправки ствол уезжает из кадра. Проверка выбора — юнит-тесты `engine::animation`; кадры клиента — в отчёте задачи. **Остаток по A26:** блендинг между секвенциями (сейчас переключение жёсткое), направление взгляда/корпуса отдельно от движения, оружие в руках влияет на `@jump_*`/`@idle_*` (сейчас кулаки/общие), блендинг вьюмодели между состояниями. **Замер по масштабу кости (историческая заметка).** У кватерниона модуль вектора поворота не больше единицы у **всех** костей и кадров — это и есть критерий выбора. Сырые значения `@idle_all_01`: у кости 0 — `[43, 1424, 17013]` при `rot_scale = [6,37e−5, 4,66e−5, 9,59e−5]`. - умножение на `rot_scale`: `17013 · 9,59e−5 = 1,63` — **больше единицы**, для кватерниона невозможно, значит это не тот путь; - деление на 32768: `17013 / 32768 = 0,52` — годится. Дальше нужно решить, чем именно нормируется канал: похоже, `rot_scale` в MDL задаёт **сжатие** (велик обратный, ≈1,5e4…2,1e4 по осям), а не множитель. Это первая задача следующего захода, вместе с позами костей по родителям. **Дальше:** собрать кватернион с правильной нормировкой и `w = √(1 − x² − y² − z²)`, позы костей с родителями, интерполяция между кадрами, выбор секвенции по состоянию. **Порядок работ:** (а) секвенции, включённые модели и чтение `.ani` сделаны; (б) привязка вершин сделана; (в) CPU-скиннинг сделан; (г) декодер кадров и проигрывание секвенции с циклом; (д) выбор по состоянию и блендинг. **Приёмка:** модель игрока/оружия двигается в кадре, ходьба переходит в бег и присед по скорости, прыжок показывается отдельной секвенцией.