Зафиксирован план достижения одиночной игры, совместимой с GMod. Главный принцип — совместимость, а не тождество внутренностей: контент карт и аддонов, Lua-API, поведение игрока, формат сущностей. Часть A — семь найденных дефектов, в том числе подтверждённых замером по реальному gm_construct.bsp: 77 из 2203 брашей ошибочно считаются твёрдыми, скорость не гасится о контакт, нет отсечения граней, нет деталь-текстур.
22 KiB
План 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.
Исправление:
- Разбор
GAME_LUMP(запись лумпа 16 байт:fileofs, disksize, offset, fourCC), под-лумп 12 → список(текстура, scaleX, scaleY). - Проекция деталь-текстуры на мировые координаты по UV грани.
- Шейдер: дополнительные сэмплы со смешением,
$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 мебели) — все с
корректными координатами. Это гипотеза отвергнута, версия снимка была неверной.
Кандидаты причины, по убыванию вероятности:
- Не найдена VTF-текстура для части поверхностей —
fallbackрисует белым. Особенно вероятно для displacement-поверхностей у воды: у них отдельныеtexinfo, которые не всегда есть в списке. - Не разрешён VMT — тогда материал падает в тот же белый fallback.
- Отдельная поверхность, которой полагается деталь-текстура, но базовая не загрузилась (связано с 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 их две ($abovewater1 и 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.
Решения, принятые пользователем
- Скриншот получен → A6 переформулирован: это не панели пропов, а нетекстурированная браш/displacement-поверхность. Версия про «панели в 0 0 0» отвергнута.
AngleVectors→ вопрос снят, работа делается как A8: сверка с эталонной формулой Source и фиксация unit-тестом, без опоры на память.- Приоритет → свет (C) раньше game.dll (E2).
Что осталось уточнить
Ничего блокирующего. Дополнительно уточню по ходу:
- белый fallback заменить на тёмно-шахматный (A6) — согласую при реализации, это меняет вид карты при недогруженных текстурах.