gmod-rs/docs/PARITY.md
Niko Marmeladkov c9264fe892 docs: план совместимости с GMod (одиночная игра)
Зафиксирован план достижения одиночной игры, совместимой с GMod.
Главный принцип — совместимость, а не тождество внутренностей: контент
карт и аддонов, Lua-API, поведение игрока, формат сущностей.

Часть A — семь найденных дефектов, в том числе подтверждённых замером
по реальному gm_construct.bsp: 77 из 2203 брашей ошибочно считаются
твёрдыми, скорость не гасится о контакт, нет отсечения граней,
нет деталь-текстур.
2026-10-12 16:45:34 +03:00

22 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) — согласую при реализации, это меняет вид карты при недогруженных текстурах.