gmod-rs/docs/PARITY.md
Niko Marmeladkov 6fd44ba014 Замеры по «белым плитам» и «вывернутой траве»: три гипотезы опровергнуты
Гипотеза Gemini (нормаль из dv.vec) не подтвердилась: сырые байты LUMP_DISP_VERTS
читаются как f32 с шагом 20 и дают осмысленные значения (vec=(0,0,1),
dist=18.145). Формат разобран верно.

Ориентация рельефа в порядке: 110 плоскостей вверх, 0 вниз, намотка не
перевёрнута (0 из 11776). У спавна рельеф плоский — z от -151.4 до -148.0,
поэтому «наклонены 104 вершины» это свойство карты, а не баг.

Найдена ошибка в моей же сверке: справочник лампов читался со stride 48 вместо
16. Из-за этого LUMP_NODES выглядел навмешем, а лампы 6 и 18 менялись местами.
Выводы части E14 про LUMP_NODES недействительны.

«Белые плиты» разобраны: у материала color/white всего 9 граней, и это не
ящик, а тонкие пластины 64x4 и 128x4 с нормалями ±X/±Y на четырёх высотах —
то, что на глаз читается как «оси координат 3д». Большая белая поверхность в
центре — другая: бетонная плита 2480x1936 над спавном, продублированная
(9379==9385, 9380==9386), дубль есть в самой карте.

Чёрные области проверены экспериментом: отключение тенев и отсечения задних
граней меняет картину, значит чёрное приходит из лайтмапы, а не из теней.

Подробности и следующие шаги в PARITY.md E15.
2026-10-16 08:21:12 +03:00

140 KiB
Raw Blame History

План Source RS: одиночная игра, совместимая с GMod

Статус: план согласован, начало исполнения (2026-10-12). Каталог: ~/garrysmod-rs. Связано: PLAN.md (общий план), PROBLEMS.md (боли), RECON.md (реверс).

Каждый этап — рабочий коммит и проверяемый результат, а не заготовка.


Главный принцип: совместимость, а не тождество внутренностей

Решение пользователя: «1 в 1 не надо прям, но надо совместимость».

Это меняет критерий готовности. Нас интересует не внутреннее устройство GMod, а то, что снаружи него видно и от чего зависит чужой код:

Что должно работать Почему это «совместимость»
Контент карт и аддонов: BSP, VTF, VMT, MDL, VTX, а также lua/ с аддона аддон физически не запустится без этого
Lua-API: Entity(), ents.*, Vector/Angle/Color, хуки, таймеры, file, surface, sound, render, cam, Curves код аддона должен выполняться без правок
Поведение игрока и физика геймплей аддона завязан на это
Формат карт: сущности читаем так же, как в Source иначе нельзя открыть чужую карту
Сетевой протокол (уже сделано) мультиплеер

Что не требуется: внутренние структуры движка, точное совпадение побайтовых раскладок в нестандартных случаях, поддержка всего, что есть в base_ents GMod.

Практическое следствие: там, где можно, сверяемся с исходниками Source.NET и реальными файлами, а не угадываем; а публичные Lua-имена и сигнатуры берём из настоящих lua/includes/ GMod, а не придумываем.


Порядок (подтверждён)

A → B → C → D → E, причём свет (C) раньше game.dll (E2) — так решил пользователь.


Часть A. Найденные дефекты (то, что сломано прямо сейчас)

Часть пунктов пользователь заметил визуально, часть подтверждена замером по реальному gm_construct.bsp.

A1. Коллизия: скорость не гасится о поверхность

Симптом (пользователь): «подхожу в стену, отпускаю клавиши — входит внутрь чуть-чуть, потом выталкивает».

Причина (crates/engine/src/player.rs:432): move_aabb() возвращает только (Aabb, on_ground) и не трогает self.velocity. Каждый кадр игрок сдвигается на velocity * dt внутрь стены, получает выталкивание и оказывается на грани — визуально «ныряет» и отскакивает. Горизонтальная скорость в стену никогда не обнуляется.

Как в Source: CMoveData отдаёт clipNormal, скорость проектируется на плоскость контакта: v -= n * min(0, dot(v, n)).

Исправление: move_aabb возвращает нормали контакта; Player::update гасит компоненту скорости внутрь каждой нормали и обнуляет velocity.z при on_ground. Плюс явное торможение при отпускании клавиш.

Приёмка: тест «в стену на полной скорости → после отпускания стоит неподвижно, |velocity.xz| < 1, положение не дрейфует»; тест на отсутствие просадки в стену при dt = 1/60 и скорости 250.

A2. Коллизия: твёрдость браша определяется неверно

Симптомы: «невозможно пройти в здания, хотя проход есть», «вода твёрдая».

Причина (player.rs:43): const TRIGGER: i32 = 0x4000_0000; и проверка if contents == TRIGGER { continue }. То есть твёрдым считается всё, кроме ровно 0x40000000 — включая браши с contents == 0 (по спецификации Source CONTENTS_SOLID = 0x1, всё остальное не твёрдо) и браши воды/тумана.

Замер по gm_construct.bsp (2203 браша, лумп 18):

contents смысл брашей сейчас твёрдые по спецификации
0x08000001 SOLID 1130 да да
0x00000001 SOLID 996 да да
0x00000000 EMPTY (не твёрдый) 70 да ✗ нет
0x08000080 не SOLID 3 да ✗ нет
0x18000008 не SOLID 2 да ✗ нет
0x10000020 WATER 1 да ✗ нет
0x30000008 не SOLID 1 да ✗ нет

Итого 77 из 2203 брашей (3.5%) считаются твёрдыми ошибочно — невидимые стены в проходах и твёрдая вода озера.

Исправление: твёрдым считать браш, у которого установлен бит CONTENTS_SOLID (0x1). Отдельно разобрать воду (0x20), туман (0x40), окна (0x2) и решётки (0x8): они не останавливают игрока, но влияют на жидкость и свет.

Приёмка: счётчик брашей по contents в логе; на gm_construct ожидается 2126 твёрдых и 77 не твёрдых; тесты «проход в здание открыт», «в воду можно войти».

A3. Коллизия: нет broadphase для брашей

2203 браша проверяются целиком на каждом подшаге, в каждой из 4 итераций разрешения. Для displacement'ов есть сетка, для брашей — нет.

Исправление: 2D-сетка по XY с AABB каждого браша; запрос кандидатов по расширенному AABB хода.

Приёмка: замер времени кадра коллизии до/после; цель — стабильные 60 fps при обычном движении.

A4. Рендер: отсечения граней нет вообще

Симптом: «видно обратную сторону брашей».

Причина (crates/render/src/world_renderer.rs): cull_mode: None во всех шести пайплайнах (строки 247, 292, 385, 483, 613).

Уточнение пользователя: в оригинале сквозь стены тоже видно — но там это намеренно (вид сквозь стену на HUD), а не отсутствие backface culling.

Исправление: CullMode::Back для мира и моделей; для двусторонней геометрии — отдельный проход CullMode::Front с инверсией нормали (как в Source: сначала фронт с инверсией, затем бэк).

Приёмка: в галерее gm_construct не видно изнанки стен.

A5. Рендер: деталь-текстуры не поддержаны → нет песка у озера

Симптом: «у озера трава должна переходить в песок, песка нет».

Причина: в crates/formats/src/bsp.rs перечислены 64 лумпа, и деталь-текстур среди них нет. В Source 1 они лежат в GAME_LUMP как под-лумп detailtextures.dll (LUMP_DETAILTEXTURES = 12) и накладываются поверх базовой текстуры. Шейдер world.wgsl (38 строк) читает только base_texture и умножает на вершинный light.

Исправление:

  1. Разбор GAME_LUMP (запись лумпа 16 байт: fileofs, disksize, offset, fourCC), под-лумп 12 → список (текстура, scaleX, scaleY).
  2. Проекция деталь-текстуры на мировые координаты по UV грани.
  3. Шейдер: дополнительные сэмплы со смешением, $detail из VMT.

Приёмка: у озера gm_construct виден переход травы в песок.

A6. Белая нетекстурированная поверхность (было: «белые панели на 0 0 0»)

Симптом по скриншоту: большая белая polygonal-поверхность, лежащая на земле рядом с озером (камера на pos -1070 1565 533). Контур с прямыми отрезками и острыми углами, поверхность явно повторяет рельеф местности. Окружающие дома, трава, дороги и вода (слева, тёмно-синяя) выглядят нормально.

Это не пропы. Проп — это объёмная модель с чёткими гранями; здесь же плоская поверхность, граница которой идёт по рельефу. Значит это браш- или displacement-геометрия, у которой не разрешилась текстура — материал рисуется fallback-белым (Material::fallback, world.rs:739).

Ранее я проверил пропы и ничего подозрительного не нашёл: пропов без origin нет, с origin = "0 0 0" нет, наш код порождает всего 9 моделей (5 × prop_dynamic lights_flescent01a, 4 × prop_physics мебели) — все с корректными координатами. Это гипотеза отвергнута, версия снимка была неверной.

Кандидаты причины, по убыванию вероятности:

  1. Не найдена VTF-текстура для части поверхностей — fallback рисует белым. Особенно вероятно для displacement-поверхностей у воды: у них отдельные texinfo, которые не всегда есть в списке.
  2. Не разрешён VMT — тогда материал падает в тот же белый fallback.
  3. Отдельная поверхность, которой полагается деталь-текстура, но базовая не загрузилась (связано с A5).

Действие (одна итерация, даёт точный ответ): дамп всех материалов мира — { имя VMT, имя VTF, загрузилась ли, причина отказа } плюс AABB каждого батча. По скриншоту видно, что проблема локальная (один район карты), значит список «отвалившихся» текстур будет небольшим и читаемым.

Побочный вывод: белый fallback — плохая диагностика и плохое поведение. Нужна проверка в doctor (см. B4) и осмысленная заглушка: тёмно-шахматная вместо белой, чтобы выделять проблему, а не маскировать её.

A8. Знаки и порядок осей при повороте моделей

rotate_model_vector строит базис forward/left/up, тогда как Source использует forward/right/up. Модельная система координат MDL — правая (X вперёд, Y влево, Z вверх), поэтому знак оси Y критичен: ошибка разворачивает модель зеркально и ломает нормали.

Это не «на глаз» — сверяем с эталонным AngleVectors и закрываем unit-тестом на известных углах (0 0 0, 0 90 0, 0 0 90, 90 0 0), фиксируя ожидаемые векторы из формулы Source.

A7. Вода выглядит неправильно

Симптом: «просто какое-то отражение неба, никакой текстуры, твёрдая».

Причины:

  • отражение — зашитая константа vec3<f32>(0.64, 0.76, 0.90) в water.wgsl:49, а не окружение;
  • одна поверхность с discard по знаку facing, тогда как в Source их две ($abovewater 1 и 0) — верх и изнанка;
  • нет преломления, поглощения по глубине, блика солнца, нормалей из текстуры, $fogcolor-затухания;
  • твёрдость — из A2.

Исправление: две поверхности; отражение из окружения; преломление через цвет буфера; поглощение по расстоянию; specular от солнца; плюс A2.


Часть B. Фундамент: система энтити

Без неё не появляется ни свет, ни Lua над энтити, ни физика. Свет — это тоже энтити (light_environment, light, light_spot).

B1. Ядро (crates/engine/src/entity.rs)

  • EntityIndex(u16) — слот + generation для безопасных «умерших» указателей;
  • Entity — classname, keyvalues, transform (origin/angles/local-origin), флаги (solid, movetype, model, render/rendergroup);
  • EntityWorld — пул, spawn(kv), remove, итерация по classname, отложенное удаление (как в Source, чтобы не инвалидировать ссылки в кадре);
  • лимиты из файлов: максимум энтити, максимум ключей, максимум длины значения (правило #1 AGENTS.md — никаких unwrap на данных из файлов).

B2. Спавн из карты: build_world перестаёт вручную порождать модели — он скармливает лумп ENTITIES в EntityWorld, а рендер и коллизия читают из неё. Это убирает белые панели как класс проблем и даёт всем остальным этапам единый источник истины.

B3. Коллизия по энтити: браши func_*/trigger_*/info_null с корректной твёрдостью; AABB/OBB для моделей; trigger-зоны отдельным слоем.

B4. Отладка: ent_list / ent_info <classname> — иначе каждый следующий дефект снова придётся ловить на глаз.

Приёмка: ent_list показывает все энтити gm_construct с корректными координатами; коллизия func_* работает.


Часть C. Освещение (включая солнце)

Сейчас в шейдере один попиксельно интерполированный light: vec3 из вершин — меньше, чем в Source даже на простых картах.

C1. Свет из карты: light, light_spot, light_dynamic (в gm_construct: 108 light + 28 light_spot), light_environment — это и есть солнце (направление, цвет, интенсивность, флаг «небо»).

C2. Модель освещения в шейдере: динамический свет (солнце + до N источников в радиусе) поверх статического; источники ограничить по экрану и расстоянию.

C3. Sun-тени: каскадные shadow maps (в Source 3–4 каскада). Начать с одного каскада 2048².

C4. Lightmap: лумп LIGHTING/LIGHTING_HDR (12 МБ в gm_construct) — попиксельно вместо вершинного; даёт качественный свет в помещениях.

C5. Материал: Phong-подобная модель — diffuse + specular + fresnel; $phong/$specular/$color2 из VMT; самозатенение от карты нормалей.

C6. Небо и env_*: env_sky, env_fog, env_sun, env_tonemap, env_cubemap, env_skybox.

Приёмка: на gm_construct видны солнечные тени от построек, помещения освещены не плоско, вода отражает окружение, есть спекуляр на металле.


Часть D. Lua поверх энтити

Текущий lua (1278 строк) — print, hook.*, timer.Simple, concommand.*, заглушки. Ни типов, ни энтити.

D1. Типы: Vector, Angle, Color как userdata с метаметодами GMod (арифметика, Angle:Forward(), Vector:Distance, :Angle).

D2. Entity(index) и методы: GetPos/SetPos, GetAngles/SetAngles, GetClass/GetModel/SetModel, Remove, IsValid, PhysicsInit*.

D3. ents.*: Create, FindByClass, FindInSphere, GetAll, GetCount.

D4. Сетевые поля: SetNW*/GetNW* (в одиночке локальные, но с тем же API, чтобы потом не переписывать аддоны).

D5. Библиотеки GMod: file.* (с песочницей по AGENTS.md), surface.*, sound.*, render.*, cam.*, Curves, system.*, scrconcmd, LocalPlayer. http — только после песочницы.

Приёмка: скрипт из гмода выполняется; список функций совместимости (набранный из настоящих lua/includes/modules/) проходит.


Часть E. Одиночная игра как в GMod

E1. Player: ходьба по ступеням (step-up ~18 units), лестницы, приседание с плавным переходом, плавание (Start Swim), ноклип, падение с уроном.

E2. game.dll на Lua: загрузка gamemodes/sandbox/gamemode/init.lua — списки ents, weapons, NPCs, хуки Initialize, Think, PlayerSpawn. Это то, что делает GMod «GMod’ом», а не просто картой Source.

E3. Оружие и предметы: weapon_*, item_* через Lua.

E4. Физика пропов: Jolt — prop_physics уже приходят с карты, но не падают и не сталкиваются.

E5. NPC: начиная с базовых npc_*.


Порядок и разумная дистанция до GMod

GMod — это ~1 млн строк C++ плюс 15 лет аддон-экосистемы. Полное «1 в 1» недостижимо как финальное состояние, но достижимо как наращиваемое покрытие: A → B → C → D → E даёт к каждому моменту работающий одиночный клиент.

Этап Что даёт пользователю
A «стены не пускают, вода не твёрдая, белая поверхность устранена, песок появился»
B карта описывается объектами — база для всего
C солнце, тени, нормальный свет
D Lua-аддоны запускаются на нашей карте
E одиночная игра, совместимая с аддонами

Честное ограничение: «все энтити» — сотни классов. Но base_ents GMod — это Lua-скрипты, а не C++. Когда появится исполнение lua/entities/ и lua/weapons/, значительная часть этих классов заработает сама, из исходников GMod. Поэтому наш код на этих этапах делает только то, чего в Lua нельзя: низкий уровень графики, физика, загрузка ресурсов.

Свой набор «тяжёлых» классов держим минимальным: prop_physics, func_door, func_button, trigger_*, light_*, env_*, info_*. Остальное — из Lua.


Решения, принятые пользователем

  1. Скриншот получен → A6 переформулирован: это не панели пропов, а нетекстурированная браш/displacement-поверхность. Версия про «панели в 0 0 0» отвергнута.
  2. AngleVectors → вопрос снят, работа делается как A8: сверка с эталонной формулой Source и фиксация unit-тестом, без опоры на память.
  3. Приоритет → свет (C) раньше game.dll (E2).

Что осталось уточнить

Ничего блокирующего. Дополнительно уточню по ходу:

  • белый fallback заменить на тёмно-шахматный (A6) — согласую при реализации, это меняет вид карты при недогруженных текстурах.

Часть C. Прогресс

C0. Источники света из карты (сделано)

engine::world::collect_lights() разбирает light_environment (солнце), light, light_dynamic, light_fluoro_dynamic, light_spot. На gm_construct находится 65 источников, из них 1 солнце и 22 прожектора.

Шейдер world.wgsl получил uniform-блок источников: солнце (до 4), точечные и прожекторные (до 64, отбираются ближайшие к камере), плюс ambient.

Три ловушки, на которые напоролся (все проверены на gm_construct):

  1. Четвёртая компонента _light означает разные вещи. У light и light_spot это РАДИУС освещения (_light 255 255 255 300), а у light_environment — множитель яркости солнца (_light 242 234 218 560). Если считать это яркостью везде, лампы дают вклад ×300 и карта выбивается в белый.

  2. Яркость нельзя домножать в цвет. Если умножить цвет на scale при разборе и учесть scale ещё раз в шейдере, получается квадрат яркости. Цвет нормируется к единичному среднему, яркость хранится отдельным полем scale.

  3. Радиус 0 означал «светить во everywhere». Функция затухания при радиусе 0 возвращала 1.0 на любом расстоянии, и 22 прожектора складывались в сумму. Теперь у ламп без _radius подставляется 300.

  4. Лайтмапа уже содержит запечённое солнце. Это ключевой момент: in.light — это и есть запечённый свет компилятора. Динамическое солнце сверху нужно только для направленной подсветки и будущих теней, поэтому его вклад намеренно мал (SUN_BRIGHTNESS_SCALE = 900 даёт ~0.62 от карты). Наивная попытка добавить солнце «как есть» давала пересвет бетона в чистый белый.

Итоговые нормировки подобраны рендерами на gm_construct и лежат константами SUN_BRIGHTNESS_SCALE, POINT_BRIGHTNESS_SCALE, AMBIENT рядом с кодом.

Осталось по части C

  • Попиксельная лайтмапа (LUMP_LIGHTING) вместо вершинного света.
  • Тени от солнца (каскадные shadow maps).
  • Phong-модель: specular + fresnel, $phong/$specular из VMT.
  • Ambient из _ambient карты вместо константы.
  • Полный набор env_*.

Часть B2. Физика: Rapier3D (сделано)

B2. Решение: мир не переезжает в Rapier

Rapier обслуживает только динамические тела. Геометрия карты остаётся брашевой и разбирается нашим кодом (player.rs) — он проверен и быстрее на тысячах брашей. Статика в Rapier добавляется явно и только из данных.

B2.1. Четыре дефекта, найденных измерением

Все четыре нашлись не «правдоподобным декодированием», а замером на карте. Порядок важен: каждый следующий дефект был бы невидим, пока не закрыт предыдущий.

  1. Рельеф раздувался в 15 277 коллайдеров. Сетка по LAND_CELL = 64 дублировала каждый перекрытый треугольник в каждую ячейку: 89 треугольников давали 15 277 коллайдеров. Заменено одним объединённым trimesh с дедупликацией вершин по кванту 1/16 юнита: 11 776 треугольников → 6 148 вершин, 1 коллайдер. Точнее (никакого огрубления на швах ячеек) и на порядки дешевле.

  2. Уровень gm_construct построен брашами, а не displacement'ами. Рельеф покрывает лишь часть карты; игрок ходит по 2 126 брашам. Без брашей все пропы падали насквозь. PhysicsWorld::add_brushes восстанавливает вершины браша перебором троек плоскостей (Крамер): точка, подходящая под все плоскости — вершина выпуклой оболочки. У осевого браша 6 плоскостей = 20 троек. Замер: 2121 построен, 5 пропущено (вырожденные).

  3. Broad-phase Rapier наполняется только на step(). После вставки геометрии луч её не видел: «под пропом пола нет» при 2 121 браше в мире. Лечится явной регистрацией broad_phase.set_aabb() при вставке статики.

  4. setup_physics стирал построенное. Начинался с self.physics = PhysicsWorld::new() и обнулял браши, добавленные строкой выше. Мир физики больше не пересоздаётся.

B2.2. Тест поймал баг, который замер бы не увидел

add_brushes создавал коллайдеры, но не увеличивал счётчик: лог показывал static = 1, хотя брашей было 2 121. Тест box_brush_lands_where_the_box_is упал на static_bodies() == 1 и указал прямо на строку.

B2.3. Подтверждение на gm_construct

Две независимые системы коллизий сверены друг с другом: брашевая (Collision) и Rapier. Обе находят пол под пропами на z = −96 (Rapier −96.0, брашевая −95.9). После 400 кадров все 4 prop_physics осели: speed = 0.0, sleeping = true.

B2.4. Ограничения

  • Коллайдер пропа — AABB модели, а не vcollide. Source берёт физическую модель из MDL; мы её не разбираем. AABB всегда не меньше модели и всегда из данных, но для наклонённых пропов он «жирнее» настоящего.
  • Только prop_physics*. У prop_static физики в GMod нет.
  • FPS просел: 10–21 в headless-прогоне. Требуется замер отдельно с физикой и без — 2 121 выпуклый коллайдер может быть причиной.

B2.5. Что осталось по части B2

  • vcollide из MDL вместо AABB.
  • Брашевые func_door / func_button как ограничивающие тела.
  • env_explosion через apply_explosion (метод готов, вызов из Lua — нет).
  • Замер FPS с физикой и без, чтобы понять цену 2 121 коллайдера.

Часть D1. Lua-типы GMod (сделано)

D1. Что сделано

crates/lua/src/gmod.rs — Vector, Angle, Color как userdata с арифметикой, полями на запись и tostring в формате GMod. Установлены в глобалы вместе с math.Clamp.

Решения, важные для совместимости:

  • Пропущенных аргументов не бывает. Vector() и Vector(1, 2) обязаны работать: так вызывается бытовой код Sandbox. Отсутствующее значение заменяется нулём, а не ошибкой.
  • Деление на ноль даёт 1, а не inf. inf в позиции пропа отравляет геометрию всего объекта и потом всплывает в отрисовке.
  • NaN из скрипта не доезжает до физики. Color(0/0, ...) даёт белый, Vector(0/0, 1, 2) — вектор без NaN.
  • Компоненты цвета обрезаются в 0..255, а не отвергаются: Color(1.5 * 300, 0, 0) — обычное дело в скриптах.
  • Source — левая система координат: Angle(0,0,0):Forward() смотрит вдоль +X, Angle(0,90,0):Forward() — вдоль +Y, знак Z у forward отрицательный.

D1.1. Дефект чтения чисел в mlua 0.11

Value::as_number() в mlua 0.11 различает только Value::Number и молча возвращает None на Value::Integer. LuaJIT отдаёт целые именно как Integer, поэтому наивное чтение теряло все целочисленные аргументы:

Vector(1, 2, 3)  -->  Vector(0, 0, 0)

Выглядело это как «арифметика не работает», хотя дело было в чтении чисел. Собственный value_number() принимает Number, Integer и строку (числом). Тест на это обязателен: без него Vector() с целыми просто тихо отдаёт нули.

D1.2. Шум тригонометрии

cos(90°) в f32 — это 6.1e-17, а не ноль. Без обнуления Angle(0, 90, 0):Forward() печатает -0.00000004371139 1 -0, и такие значения копятся в преобразованиях координат. Порог — 1e-6.

D1.3. Особенности API mlua 0.11, на которые ушло время

  • Для userdata нет автоматического FromLua: чтение только через AnyUserData::borrow::<T>(). Lua::from_value отсутствует, есть FromLua::from_lua(value, lua).
  • Поля — add_fields (трейт UserDataFields), методы и метаметоды — add_methods (трейт UserDataMethods). Один трейт на всё не работает.
  • add_meta_method получает (&Lua, &T, A): значение объекта идёт вторым аргументом, лишние аргументы Lua (__unm(a, a)) отбрасываются через ().
  • add_function получает объект как AnyUserData первым элементом кортежа.

D1.4. Что осталось по части D1

  • Entity() и объект сущности: GetPos/SetPos/GetClass/IsValid.
  • ents.*: Create, GetAll, FindInSphere, GetPlayer.
  • Связка с движком: glua не должен зависеть от engine, обмен — через колбэки, которые регистрирует клиент.
  • Vector:Angle(), Vector:Distance между сущностями.

Часть D2. Мост Lua к движку (сделано)

D2.1. Устройство

glua не зависит от engine. Движок отдаёт поведение через трейт glua::ents::Host, реализация которого живёт в клиенте (crates/client/src/host.rs). Иначе получился бы цикл зависимостей и Lua-слой нельзя было бы тестировать в одиночку.

Мир переведён в Rc<RefCell<World>>: скрипт обращается к нему из хуков в любой момент, пока кадр держит заимствование. Все обращения идут через try_borrow, потому что borrow при пересечении даёт panic от RefCell, а падать из-за безобидного скрипта клиент не должен.

D2.2. Что доступно скрипту

Entity(n), ents.GetAll/GetByClass/FindInSphere/Create, player, а также GetPos/SetPos/GetAngles/SetAngles/GetClass/GetModel/ GetVelocity/IsValid/Remove/GetName.

Entity(0) — игрок, Entity(9999) — невалидная сущность, а не ошибка: скрипты routinely проверяют результат через IsValid().

D2.3. Найденный рассинхрон (важный)

move_prop обновлял позу экземпляра пропа, но не запись в пуле сущностей. Проверено измерением: ents.GetByClass('prop_physics'):GetPos() давал z = -64, пока физика уже опустила проп на z = -84. «Данные» и «картинка» разошлись, и заметить это можно было только глазами.

Теперь поза пишется в оба места всегда, даже если геометрию пересчитать нечем (нет модели): проп без модели всё равно стоит правильно, он просто не рисуется. Возвращаемое значение означает именно «геометрия пересчитана».

Проверка после правки: Lua читает z = -84.15848, физика даёт -84.15847778320313 — совпадение до последнего бита.

D2.4. Границы

  • SetModel, SetColor, PhysicsInit принимаются и ничего не делают: принимать вызов нужно, чтобы скрипты не падали, но обещать нечего.
  • GetVelocity делит скорость тела на шаг 1/60, а не читает «скорость за последний кадр»: иначе скрипт получил бы юниты/кадр вместо юнитов/с.
  • Созданная из Lua сущность не получает модель и тело физики.
  • Игрок — None, пока клиент его не зарегистрировал.

D2.5. Что осталось по части D2

  • Vector:Distance, Vector:Angle, GetModel-обратная привязка.
  • Entity:GetTable/SetField и сетевые функции.
  • Регистрация сущности игрока, чтобы Entity(0) и player совпали.
  • ents.Create должен уметь заводить тело физики для prop_physics.
  • Подключение файлов gamemodes/sandbox как есть.

Часть D3. Стандартная библиотека GMod (сделано)

D3.1. Замер потребностей, а не догадка

Прогнаны 130 файлов gamemodes/sandbox, из них извлечены 182 различных вызова API. Частоты по числу вхождений:

вызов вхождений состояние
hook.Run 63 сделано
math.Clamp 61 сделано
game.SinglePlayer 36 сделано
math.Rand 27 сделано
render.AddBeam 21 не сделано
math.min / math.max 17 / 5 стандартная Lua
net.* 15+ не сделано (сеть)
render.* 50+ не сделано (рендер)
file.* 8+ сделано с песочницей

hook.Run и math.* — самые частые и самые дешёвые: они чисто Lua-слой. render.* — это отдельная большая работа (лучи, спрайты, декали), она не в этом инкременте.

D3.2. hook.Run не синоним hook.Call

hook.Run останавливается на первом хуке, вернувшем не-nil, и возвращает его значение. На этом построены все проверки Sandbox (return false из хука отменяет действие), поэтому подмена Run на Call молча ломала бы их.

D3.3. Дефект: ключи хуков — строки, а не числа

Отбор только целочисленных ключей давал пустой список: hook.Add пишет под идентификатором скрипта ("GM:Think", "a"). Хуки не выполнялись вообще, hook.Run возвращал nil, и это выглядело как «хуки не работают». Теперь ключи сортируются как строка/число, порядок выполнения — по регистрации.

D3.4. Дефект mlua: Function::call теряет аргументы

Vec<Value> реализует IntoLua как таблицу, поэтому hook.call(args) передавал хуку один аргумент вместо трёх — молча, без ошибки. Проверено измерением: select("#", ...) внутри хука давал 1 при трёх переданных.

То же с одиночным числом: random.call::<f64>(10.0) вызывал math.random() без аргументов.

Лечится через Variadic::from(vec![...]). Отдельный тест hook_run_forwards_every_argument фиксирует именно эту ошибку — она молчаливая, и без теста вернётся.

По этой же причине math.Rand определён на Lua, а не на Rust: он зовёт math.random того же состояния и передаёт аргументы нативно.

D3.5. Песочница file.*

file.Read в GMod читает что угодно; мы запускаем чужие скрипты. Любой путь разрешается относительно garrysmod/ и нормализуется; выход за корень — отказ, а не предупреждение. Абсолютные пути скрипту не даны. Размер чтения ограничен 8 МБ (правило #1 AGENTS.md).

Проверка на живом клиенте: file.Read("../../../../etc/passwd") вернул nil.

D3.6. Совпадения с GMod, зафиксированные тестами

  • math.Round(-2.5) = -3 (округление от нуля, как ceil).
  • math.Rand(x) = math.random(x) → целое в [1, x], а не [0, x).
  • math.Rand(a, b) включает верхнюю границу.
  • math.Remap при вырожденном диапазоне даёт out_min, а не NaN.
  • math.Approach не перескакивает цель.
  • math.AngleDifference(10, 370) = 0: это одно направление.
  • string.Explode с пустым разделителем возвращает nil, а не список.

D3.7. Что осталось по части D3

  • render.*: лучи, спрайты, декали, материалы, DrawWireframeBox.
  • net.*: сообщения серверу.
  • Cam, LocalPlayer, vgui/derma — для Q-меню.
  • util.TraceLine, util.Compress, surface.*.

Часть B2.6. Констрейны ( Rapier joints)

Что сделано

crates/engine/src/constraints.rs — соединения между телами, соответствующие классам GMod: phys_ballsocket, phys_hinge, phys_slider, phys_weld, rope. Плюс add_force (база для thruster/hover) и set_angular_velocity_target (мотор шарнира).

B2.6.1. Главная тонкость: якоря мировые, а Rapier ждёт локальные

Скрипт задаёт точку крепления в мировых координатах, Rapier — в локальных каждого тела. Пересчёт делается через обратное вращение (rotation().conjugate() * (world - translation)). Ошибка здесь не бросает исключение: соединение просто «приклеивается» к другой точке и уезжает при первом же движении.

B2.6.2. Что показали измерения, а не рассуждения

  1. Констрейн не «стягивает» тела — и это правильно. Якорь в мировой точке по определению совпадает у обоих тел в момент создания, поэтому ограничение выполнено сразу и «включается» только когда тела расходятся. Первые пять тестов проверяли «расстояние между телами» и падали именно поэтому. Правильный инвариант — совпадение точек крепления под действием силы.

  2. Сила в Rapier действует один шаг и сбрасывается после интегрирования: add_force надо вызывать каждый кадр. Тест, подававший силу один раз, измерял не силу, а её отсутствие — и «падал» одинаково при 8000 и при 40 000.

  3. Спящее тело честно сообщает нулевую скорость. Проверять эффект силы по скорости бессмысленно, если тело уснуло на полу: корректный тест сравнивает прогон с силой и контрольный прогон без неё.

  4. Ползун (phys_slider) не удерживает. Сустав сообщает enabled = true и 5 заблокированных осей из 6, шар/шарнир/сварка в том же прогоне держат, но ползун пропускает расхождение по высоте при толчке вдоль оси. Тест помечен #[ignore] с причиной — зелёная галочка на неработающем коде хуже, чем явный отказ. Гипотеза к проверке: в 0.36 ползун требует согласованности local_axis1/local_axis2 иначе, чем задаёт их new(axis).

B2.6.3. Границы

  • Констрейны соединяют только тела физики: prop_physics и объекты, созданные скриптом. У prop_static тела нет.
  • Оси соединения задаются в мировых координатах, как в GMod.
  • Ограничение угла переводится в радианы; вырожденный диапазон игнорируется.
  • pulley и thruster — это не суставы: тяга делается через add_force.

Часть B3. Проваливание сквозь пол: что проверено и что нет

B3.1. Две гипотезы, обе опровергнуты измерением

  1. «Пропов нет в коллизии игрока». Гипотеза была верной как диагноз, но не как объяснение жалобы: игрок ходил по брашам. Пробел устранён (Collision::boxes), проп теперь стоит под ногами — это нужно в любом случае, но от проваливания по брашам не избавило.
  2. «На низком FPS шаг подшага прошивает пол». Опровергнуто тестом fast_fall_does_not_tunnel_thin_floor: при terminal-скорости 2000 юнитов/с и dt = 1/60 игрок садится на площадку толщиной 4 юнита и on_ground = true. Подшагов восемь хватает.

Обе версии выглядели правдоподобно и обе были неверны — характерная черта дефектов, которые видны только после попытки воспроизвести их тестом.

B3.2. Что измерено

  • Стояние на спавне gm_construct: 180 кадров, z = -143.9 стабильно, on_ground = true, провала нет.
  • Значит дефект локальный и воспроизводится на определённой геометрии.

B3.3. Что нужно, чтобы найти причину

Точное место и обстоятельства: с какой высоты, после какого движения (прыжок, спуск по рампе, спуск с края) и на каком участке. Без этого пришлось бы перебирать геометрию карты наугад — а это ровно тот способ, которым эти две гипотезы и были порождены.

Часть B4. Физика движения: как в Source

B4.0. Корневая причина четырёх жалоб сразу

Симптомы, описанные как разные (проталкивает на расстояние, метания вдоль стены, сползание по рампам, bhop), оказались одной ошибкой:

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 устроен принципиально иначе, чем наш подшаг:

// 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), а не «если нет ввода». Формула оригинала:

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. Гравитация делится пополам до и после движения

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-алгоритм, а не линейная интерполяция

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 отличается одной строкой — ограничением желаемой скорости:

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 — аддитивную:

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-скайбокса:

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(). Обе берут ровно половину гравитации:

// 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 попоосно

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 дают разные вещи:

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 нормаль плоскости бралась с обратным знаком:

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 (лам<D0B0><D0BC> 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 (лам<D0B0><D0BC> 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. Дубль есть в самой карте (две одинаковые плиты), это не наш баг.

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 этой плиты, вероятно, не видно (она внутри комнаты, а мы видим её сверху).
  2. Разобраться с плоской травой: снять кадр GMod из точки ref_p4 (setpos 0 0 2200, setang 180 55) и сравнить рельеф.
  3. sun_shadow без проверки ndc.z — точка за ближней плоскостью карты теней получает depth < 0 и трактуется как затенённая. Сейчас это не видно глазом, но диапазон не проверяется, и это лазейка для чёрных пятен.