gmod-rs/docs/DIARY.md
Niko Marmeladkov b564de5b25
Some checks are pending
CI / checks (push) Waiting to run
Зеркало func_reflective_glass: отражённая камера и клип по плоскости
- сборка мира: модель зеркала помечается материалом-меткой `<mirror>` до
  резолва материала (у reflectiveglass001 нет VTF, иначе грань выпадала),
  из её граней строится плоскость MirrorPlane (origin, normal);
- рендер: проход отражённой камерой в текстуру Rgba16Float, клип по плоскости
  зеркала (vec4 в uniform камеры, discard в шейдере), грань зеркала сэмплирует
  отражение по экранным координатам;
- отражение привязано на слот лайтмапы (группа 3) отдельным bind group:
  у устройства max_bind_groups = 4, пятая группа не создаётся;
- обычный проход пропускает зеркальные батчи (иначе чекер-заглушка
  перекрывала отражение), зеркало рисуется после непрозрачного мира;
- найден и исправлен пропуск слота `sun` в Camera у world.wgsl (из-за него
  fog_color читался со сдвигом).

Проверено: 25 сьютов и 29 картовых тестов зелёные; UV-градиент на грани
подтвердил плоскость и шейдер, отражение показывает стену и потолок.
2026-10-23 19:26:00 +03:00

34 KiB
Raw Blame History

Дневник агента: где я и что дальше

Живой документ. Обновляю по ходу работы, чтобы после автоматического уплотнения контекста ничего не терялось. Коротко: состояние, замеры, следующий шаг, грабли.

Обновлён: 2026-10-23, день.


0. HDR и попиксельный свет (2026-10-23, ветка feat/hdr)

Сделано по жалобе «свет резко идёт по полигонам» и задаче HDR:

  • A10, попиксельная лайтмапа. LUMP_LIGHTING теперь раскладывается в атлас Rgba16Float (полочная упаковка, рамка 1 тексель на блок), у вершины появился lightmap_uv, шейдер мира читает свет на каждый пиксель с билинейной фильтрацией. На gm_construct: 6891 грань, 751 718 люкселей, атлас 2048² (32 МиБ). Если атлас не собрался — пайплайн с fs_main_vertex, вершинный свет.
  • HDR. Мир, небо и вода рисуются в Rgba16Float, затем композит: экспозиция → ACES → bloom (порог яркости в ½ разрешения, два прохода «горизонт+вертикаль», аддитивно). UI/диагностика — отдельным проходом уже в sRGB-поверхность, как раньше.
  • Тени. PCF 9 отсчётов (центр + два кольца) с поворотом по мировой позиции и нормальным смещением; SOURCE_RS_DEBUG_SHADOW=1 выводит коэффициент тени — по кадру видно мягкий край и отсутствие acne.
  • Переключатели: SOURCE_RS_NO_HDR=1 (старый путь), SOURCE_RS_EXPOSURE (1.0), SOURCE_RS_BLOOM (0.5), SOURCE_RS_BLOOM_THRESHOLD (1.0), SOURCE_RS_DEBUG_SHADOW, SOURCE_RS_NO_VSYNC, SOURCE_RS_LOG_FPS=1.
  • Замер (Xvfb, 1280×720, камера E11 -399.5 -1604.5 67.9, 6 окон по 120 кадров без vsync, A/B на одном и том же бинарнике и на коммите до правок): до правок 23,6–26,5 fps (в среднем ~24,5), после с HDR 22,1–26,4 (~23,7), при SOURCE_RS_NO_HDR=1 23,0–25,7 (~24,4). Разница в пределах разброса прогонов; на 320×180 та же сцена даёт 87–108 fps. 60 fps на 1280×720 в Xvfb не было и до правок — узкое место ~2661 draw call на проход (плюс столько же в теневом), а не HDR/лайтмапа; это следующий шаг.
  • Кадры: /tmp/opencode/hdr_before_tunnel.png → hdr_final_tunnel.png (градиент на кирпичной стене вместо плоского света), hdr_final_e11.png и hdr_final_e11_nohdr.png (HDR/старый путь), hdr_shadowdbg_e11.png (PCF).
  • Тесты: lightmap_atlas_maps_face_block, half_conversion_known_values (+ все прежние сьюты зелёные).

0.1. Итог сессии 2026-10-23: все три ветки слиты

  • feat/anim (d42d646) — RAWROT/RAWROT2/RAWPOS, выбор анимации по состоянию, покачивание вьюмодели и fire. Слито 3de8b89.
  • feat/android (b28d6f9) — APK под Android 12+ (arm64, minSdk 31), SDL3 из исходников, сенсорное управление, сборка без Gradle (android/build.sh). Слито 2edea01; пакет target/android/gmod-rs-debug.apk.
  • feat/hdr (dec8b67) — попиксельная лайтмапа в атласе Rgba16Float, HDR (ACES + bloom), PCF-тени. Слито 1f42bb9.

Перед слиянием в main также вошли: коллизия пропов из настоящего меша (выпуклая оболочка для динамических, тримеш для статических — a491010) и вьюмодель в камерном базисе с доворотом в координатах модели (a491010).

Состояние: 25 сьютов, 29 картовых тестов, 15 тестов world_real — зелёные; clippy чистый.

0.2. Проверка Android-сборки после слияния

Пересобраны оба APK (android/build.sh): arm64 — target/android/gmod-rs-debug.apk (6,3 МБ, sha256 e029c330…), x86_64 — gmod-rs-debug-x86_64.apk.

На эмуляторе API 35 (SwiftShader) установлен x86_64-вариант: gm_construct загружается, HUD и сенсорные подписи на месте, FPS 8–18 (программный рендер), вьюмодель рисуется. Пурпурные текстуры на эмуляторе — отсутствующие материалы HL2: туда залит только garrysmod/, без sourceengine/. Проверено на десктопе с тем же неполным набором ассетов — картина идентична (пурпурный пистолет), значит на телефоне с полным server/ всё будет текстурировано.

Важно: adb uninstall чистит /sdcard/Android/data/<пакет>/files — после переустановки ассеты надо заливать заново.

0.3. Размер APK и встроенные ассеты

Замер fs pack для gm_construct: 335 файлов из VPK (27,0 МБ: 277 материалов, 23 модели, 35 Lua) + карта 35,0 МБ = 62,1 МБ сырых, 31 МБ в tar.gz. Собранный APK со встроенным набором — 38 МБ (83409194 байта в распаковке, 344 файла) против 6,3 МБ без ассетов. MainActivity распаковывает assets/gmod при первом запуске; adb push не нужен. Каталог android/assets/ не коммитится.

Проверено на эмуляторе API 35 с чистого листа (uninstall → install APK 38 МБ → запуск): распаковка заняла 1,24 с, детект ассетов прошёл, мир собран с missing=0, карта и пистолет полностью текстурированы, FPS 9 на SwiftShader.

Грабли: tier::paths::detect признаёт корень по garrysmod_dir.vpk или garrysmod/gameinfo.txt — без маркера клиент говорил «ассеты не найдены», хотя файлы на месте; fs pack теперь пишет gameinfo.txt.

0.4. Цветокор, зеркало, освещение — что выяснено

  • Цветокор реализован: LUT карты читается из materials/colorcorrection/<карта>.raw (32³ RGB, 98 304 байта), грузится в 3D-текстуру Rgba8Unorm и применяется в композите после ACES (sRGB→LUT→sRGB). Замер: файл gm_construct.raw нейтральный (диагональ 0/66/132/195/255), то есть у gm_construct грейда нет by design; другие карты подхватятся сами. Отключение: SOURCE_RS_NO_COLORCORRECTION=1.
  • Зеркало на карте есть: func_reflective_glass (1 шт.) — это кисть с реальным отражением в Source. Нужен отдельный проход: отражённая камера → текстура → грань зеркала с экранными UV. Задача записана, не начата.
  • Освещение моделей: вершины моделей получают lightmap_uv белого текселя атласа, то есть ambient карты + солнце с тенями + точечные источники. Чего нет — запечённого ambient в точке модели (тёмный угол не затемняет проп). План: при спавне искать ближайшую грань BSP и брать средний цвет её блока лайтмапы как light модели.
  • Прочее из «визуального потенциала»: infodecal ×19 (декали не рисуются), env_sprite/glow нет, env_skypaint не читается (небо из sky-материалов), env_tonemap_controller не используется (экспозиция своя).

0.5. Зеркало (func_reflective_glass) — сделано

  • в сборке мира модель зеркала помечается материалом-меткой <mirror> (грани рисуются отдельным конвейером); у reflectiveglass001 в игре нет VTF, поэтому пометка ставится до резолва материала, иначе грань выпадала из мира;
  • из граней модели строится плоскость (MirrorPlane { origin, normal }): у gm_construct — точка (-2064, -2067, -286), нормаль (0,1,0);
  • рендер: отдельный проход отражённой камерой в текстуру Rgba16Float, клип по плоскости зеркала (плоскость едет в uniform камеры, discard в шейдере); грань зеркала сэмплирует отражение по экранным координатам. Группа 4 для отражения не подошла: max_bind_groups = 4, поэтому текстура отражения биндится на слот лайтмапы (группа 3) в отдельном bind group;
  • зеркало рисуется после непрозрачного мира (обычный проход пропускает зеркальные батчи, иначе чекер перекрывал отражение), тени от зеркала не считаются.

Проверено кадрами: UV-градиент на грани показал, что зеркальная плоскость занимает всю стену и шейдер работает; отражение показывает противоположную кирпичную стену и потолок. Дальше — сглаживание границы отражения и исключение зеркала из прохода теней.

1. Что просит пользователь (приоритеты)

Ускоренный путь до Sandbox-UX (его слова): «быстрее дойти до того, как смогу спавнить через Q-меню, иметь норм модель игрока, таскать физганом, Weapon всё сделать». Поэтому очередь переставлена (docs/PARITY.md A25):

  1. A23 оружие (SWEP из Lua + вьюмодель + аттачменты) — сюда же A20 (модель игрока: один и тот же рендер моделей).
  2. A21 физика взаимодействия: физган (луч → проп → таскание, вращение, заморозка), без полной гибернации.
  3. A24 спавн: сначала консольные команды на данных из Lua (gm_spawn, gm_give, режимы gmod_tool), потом окно Q.
  4. A22 Sandbox целиком: undo/cleanup/duplicator.
  5. Визуальная точность (A10 свет на пиксель, A11–A12 вода, A16 трава/декали, A18 скайбокс) — параллельно, Sandbox не блокирует.

2. Где я сейчас (в работе)

A20/A23, первый срез сделан: модель игрока в мире. Рендер тот же, что у пропов (статический меш PropMesh), скелет и анимации — следующий срез.

Как включается и как проверять:

SOURCE_RS_PLAYER_MODEL= DISPLAY=:99 SDL_VIDEODRIVER=x11 cargo run -q -p client -- \
  map --assets ~/gmod_server/server gm_construct --hidden --frames 4 --noclip \
  --player-pos="-2438 -966 48" --flythrough /tmp/opencode/pl4 --flythrough-frames 4 \
  --route="-2438 -880 75|6 270; -2438 -1180 75|6 90"

Пустое значение — модель по умолчанию (models/player/police.mdl), иначе — путь к модели. Курс разворачивается на 180° (у моделей Source ось Y влево).

Движок: World::spawn_model_instance и World::move_model_instance, ModelInstance (экземпляр без сущности). Грабли: индексы модели локальные — при вставке в общий буфер к ним обязательно прибавляется first_vertex (без этого был мусор в кадре).

Спавн пропа из игры сделан (ядро A24). World::spawn_prop (геометрия из append_model_geometry, сущность prop_physics, тело в физике, экземпляр пропа) + в клиенте клавиша G (спавн по взгляду) и аргумент --spawn <модель> (отладка и скрипты). Проверено кадром: арбуз заспавнен и отрисован (--spawn models/props_junk/watermelon01.mdl, камера у -2500 -1050 60|5 0).

Грабли: буферы рендера созданы на сборке мира — после спавна рендерер пересобирается целиком (медленно, но работает); позже нужна до-аллокация вершин под спавн.

Физган, первый срез (A21/A23). ЛКМ — взять ближайшее тело под прицелом (луч из глаз, расстояние 8..260 юнитов, допуск 40 в стороны), пока держим — тело каждый кадр телепортируется на луч взгляда на взятом расстоянии; отпустил — свободно. SOURCE_RS_PHYSGUN=1 держит ближайшее тело без мыши (для скриптов).

Нужна интерактивная проверка физгана: мои скриптовые кадры упираются в стены — камеру без глаз не навести. Проверять так: встать у кучи пропов (она в районе -2930 -1370 -64), посмотреть на проп, зажать ЛКМ.

Синхронизация физики и картинки (жалоба «позиция рендера обновляется только при спавне нового»). Причина найдена: мир двигал вершины в памяти, а буфер рендера заливался один раз при сборке — потому спавн (пересборка рендерера) и «лечил». Сделано: World::moved_ranges + take_moved_ranges() (диапазоны заполняет rewrite_instance), WorldRenderer::upload_vertex_ranges (точечная заливка queue.write_buffer) и вызов в кадре клиента.

Не сходится (диагностика probe_moving_prop_reports_vertex_ranges): коробка в тесте задана в мировых координатах, а меш обязан быть в локальных — видно по двойному смещению вершины; плюс список диапазонов почему-то пуст после step_physics (проверить is_sleeping и ранний выход по совпадению позы — вероятно, тело «спит» после телепорта). Проверять в интерактиве: заспавнить арбуз, посмотреть, что он падает на экране.

Найдены и закрыты две поломки по жалобе «физика висит на экране»:

  1. move_prop остался со своей копией перезаписи вершин и не запоминал диапазоны — теперь он идёт через rewrite_instance (замер: было «диапазонов 0» каждый шаг, стало «диапазонов 1 [(39585, 8)]»).
  2. Буфер вершин мира создавался без COPY_DST — заливка падала с ошибкой wgpu (у пользователя это роняло клиент). Теперь VERTEX | COPY_DST, для индексов INDEX | COPY_DST плюс upload_index_ranges.

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

Открытый вопрос: вьюмодель. Создание работает (модель в буфере, вершины +1000, батч есть, габарит ±15), но в кадре её нет — при этом модель игрока рисуется. Разница в том, что модель игрока в том тесте не двигалась (кадры пролёта), а вьюмодель обязана двигаться каждый кадр. Значит сломано именно звено «перенос позы каждый кадр + заливка на GPU». Включается SOURCE_RS_VIEW_MODEL (по умолчанию физган, есть откат на монтировку — v_physgun.mdl в ассетах не нашёлся, грузится v_crowbar.mdl).

Вьюмодель: СДЕЛАНО (первый срез). Механизм проверен кадром: пистолет (SOURCE_RS_VIEW_MODEL=models/weapons/v_pistol.mdl) стоит в кадре. Две причины, почему раньше не рисовалась: (1) разворот на 180° уводил ствол за камеру — у моделей оружия Source геометрия вынесена вперёд по оси модели; (2) у моделей оружия геометрия смещена от начала координат (у пистолета центр габарита ≈(0,0,47), у монтировки −52 по Y), поэтому привязываться надо к центру габарита модели (engine::model::rotate_model_vector(centre, ...)), а не к её origin. Посадка: глаз + 20 вперёд + 6 вправо − 10 вниз − центр.

Что осталось по вьюмодели: v_physgun.mdl в ассетах нет (GMod-модель), поэтому по умолчанию пистолет; отдельный FOV вьюмодели и анимации — позже.

Старые факты (сохранены): Материал вьюмодели в мире — индекс 54, имя crowbar_cyl (виден в --debug-materials). Рендер одним этим материалом (--only-material 54) даёт пустой кадр — то есть дело не в позе: батч вьюмодели не доходит до отрисовки. Проверять по порядку: (1) попал ли батч в renderer.batches после refresh_batches (он копирует список только когда moved_index_ranges непуст, а они забираются в блоке физики — возможно, диапазоны берутся ДО того, как refresh_batches увидит их); (2) совпадает ли batch.material с позицией материала в world.materials (дедупликация по имени); (3) --only-material фильтрует батчи, но не мешает ли ему что-то ещё.

2026-10-23, сессия по жалобам пользователя. Закрыто коммитом f58eafe:

  • падение wgpu на втором спавне («Copy at offset … overrunning the bounds»): буферы GPU не росли — добавлены ensure_capacity (рост вдвое с перезаливкой) и refresh_materials (bind group для новых материалов, иначе новые батчи молча не рисовались);
  • вьюмодель: обновляется после player.update и camera.pos (раньше отставала на кадр — «плывёт при повороте»), по умолчанию w_pistol.mdl, курс −90° (у модели ствол вдоль +Y, а не +X), дистанция 26, анимация idle01;
  • bone_world_transforms обходит кости рекурсивно (порядок в файле не гарантирован);
  • HUD: отладочные строки (verts/tris, лог Lua) скрыты по умолчанию, клавиша `.

Коллизия пропов (2026-10-23). Коллайдер-коробка ставилась от нуля модели, хотя геометрия часто смещена (у куклы центр AABB на 13,5 юнита выше нуля) — пропы висели на невидимых выступах. Добавлен Physics::add_prop_offset (смещение коллайдера в координатах модели, поворачивается вместе с телом), оба места создания тел (пропы карты и спавн) передают bounds_center. Коробки пропов для коллизии игрока теперь тоже учитывают центр и поворот (World::prop_collision_boxes, AABB описанной коробки по модулю матрицы поворота).

Замер: тест real_map_props_rest_on_ground — после 10 с физики низ коробки каждого пропа лежит на опоре (допуск 4 юнита, луч от центра вниз); кукла остаётся с зазором 10 юнитов, потому что лежит краем на столе — центровой луч опору не видит.

Известная проблема: v_pistol.mdl рисуется плоским «листом» (2157 вершин, 2 меша, 1 материал, скиннинг при этом цел — тест real_view_model_skin_stays_coherent даёт максимальный сдвиг 4 юнита). Похоже на проблему загрузки геометрии именно этой модели; до выяснения по умолчанию стоит w_pistol.mdl.

Запущены параллельно (воркдеревья от f58eafe): feat/android — APK под Android 12+; feat/hdr — HDR, тонмаппинг, bloom и мягкие тени/свет; feat/anim — RAWROT/RAWPOS/RAWROT2, выбор анимации по состоянию (идёт/бежит/приседает/ прыжок), покачивание вьюмодели.

A26, состояние на 2026-10-17: сделаны секвенции (с определением шага записи по данным), кости и веса вершин, слияние включённых моделей (у игрока 465 секвенций), чтение .ani (7,1 МБ, 1563 анимации, 247 блоков), декодер кадров (mstudioanim_t, проверен: 280 кадров, 51 дорожка), сборка кватерниона с нормировкой 1/32768 (выбрана замером: умножение на rot_scale давало 1,63 — для кватерниона невозможно) и CPU-скиннинг (виден кадром).

Следующий шаг — проигрывание. План, чтобы не искать заново:

  1. PropMesh (или ModelInstance) должен держать данные анимации: .ani (7 МБ) + список блоков + описания анимаций, ссылкой Arc, — сейчас они читаются при загрузке модели и выбрасываются;
  2. в движке pose_model_instance(index, origin, angles, bone_quats: &[[f32;4]]): локальные кватернионы костей в мировые через родителей (world[i] = world[parent] · (T(pos_i) · R(q_i) · T(−pos_i))), затем скиннинг теми же весами (функция скиннинга уже есть — сейчас её ведёт синус);
  3. в клиенте: выбрать секвенцию (@idle_all_01 через последовательность activity), взять кадр по времени (t · fps, циклом), собрать кватернионы костей из дорожек и позвать pose_model_instance;
  4. затем выбор по состоянию (ACT_MP_*) и блендинг, и снять синус.

A26 «анимации» — план записан (docs/PARITY.md A26). Ключевое открытие: в formats скелет и веса уже читаются (mdl.bones(), vvd.vertices(), vtx.body_parts()), не хватает разбора секвенций/.ani, костей в ModelVertex и CPU-скиннинга. Заливка вершин на GPU для этого уже готова — тот же путь moved_ranges, что у движущихся пропов.

A26, сделано 2026-10-23 (этот заход): постоянные дорожки RAWROT/RAWROT2/RAWPOS разобраны по compressed_vector.h (Quaternion48 6 байт, Quaternion64 8, Vector48 — три half-float). Замеры: v_pistol @idle01 — 10 дорожек, 7 RAWROT + 2 RAWPOS; m_anm @taunt_muscle_base — 53 дорожки, 24 RAWROT2, кватернионы единичные, неизвестных флагов нет. @taunt теперь двигает 3145 из 3147 вершин между кадрами 0 и 30.

Выбор анимации по состоянию — engine::animation: idle_all_01, a_WalkN, a_RunN, @cidle_all, a_CwalkN, @jump_fist (первая существующая), восемь направлений относительно взгляда. Фаза walk↔run сохраняется, смена типа — сброс. Клиент играет выбранное имя; вьюмодель качается при ходьбе и отстаёт при повороте, ЛКМ без захвата проигрывает fire. Проверки: real_animation_raw_flags_decode, real_player_taunt_moves_vertices_with_raw_tracks, юнит-тесты engine::animation.

Следующее: (а) до-аллокация вершин под спавн вместо пересборки рендерера; (б) блендинг анимаций игрока и вьюмодели; (в) вращение пропа колесом и заморозка у физгана; (г) оружие в руках связывает @idle_*/@jump_* и вьюмодель; (д) консольные команды спавна на данных из Lua (gm_spawn).

3. Что уже сделано и лежит в main

Последние коммиты (свежие сверху):

  • 4ad0405 очередь: A23 оружие, A24 Q-меню, A25 ускоренный порядок;
  • 7a8a750 очередь: A20 модель игрока, A21 полная физика, A22 Sandbox через GLua;
  • 257ac07, d35efef A13 туман сделан целиком (чтение + шейдер);
  • 72bad60 A19: подшаг не гасит скорость о склон (35°: 65 → 147);
  • 5bdbdbf, e638607 A19: замер по углам + найден flRedirectCoeff в движке;
  • 3bfd078, d88988f рельеф: треугольники отсекают шаг, нормаль наружу (стены по рёбрам полигонов ушли);
  • b74824b склон: догон земли трассировкой (рампы 242–250 вместо 197,8);
  • 23c5034 присед: 250/150/520/216,7 по спеке, камера 46 → 28 (была выше хитбокса);
  • aa11713, d220b5a рампы и стыки: отсечение по последней входной грани, касание ребром не преграда;
  • 86a6cb6, 5380184 инструменты: слой коллизий, дамп кирпича, обход карты.

Все 23 сьюта и 28 картовых тестов зелёные.

4. Замеры, которые нельзя потерять

  • A19 (скорость на склоне): замер probe_slope_angles (синтетический клин). Было: 5° 239, 10° 232, 15° 196, 20° 152, 25° 116, 30° 88, 35° 65, 40° 49, 44° 39. Стало: 239 / 233 / 226 / 214 / 195 / 175 / 147 / 132 / 107. Цель — 250 до 44°. Остаток теряется на прямом (скользящем) пути: иногда он выигрывает выбор у подшага. Инструмент: SOURCE_RS_TRACE_STEP=1 печатает, какой путь победил (step_move).
  • Механизм движка для скорости на склоне: gamemovement.cpp, ClipVelocity (строка ~3144) — блок flRedirectCoeff: out *= (len + |v·n|·coeff)/len. Наивный перенос копит бонус за кадр (15° → 442 юнита/с) — откачен.
  • Туман карты gm_construct: цвет (0,557 0,643 0,698), start 1024, end 250000, max_density 1,0, farz −1 (дальний, на ближних дистанциях нейтрален).
  • Габариты/линейки Source: тело 72/36 (стоя/присед), глаза 64/28 (VEC_VIEW/VEC_DUCK_VIEW), sv_walkable_normal 0,7 (45,6°), DIST_EPSILON 1/32, sv_stepsize 18, воздушный потолок желаемой скорости 30.
  • Коллизия gm_construct: 2118 кирпичей; 140 за габаритами карты (3D-скайбокс, часть с несогласованными плоскостями — см. E19.2); 11776 треугольника рельефа, у всех намотка «гео внутрь» (нормаль разворачиваем в clip_triangle).
  • Присед: обрезка HandleDuckingSpeedCrop = 0.33333333 только в приседе и на земле, желаемая скорость 450 (бег 650), максимум 250 / 520.

5. Инструменты диагностики (не переизобретать)

  • SOURCE_RS_DEBUG_COLLISION=<радиус> — слой коллизий: красные рёбра кирпичей, жёлтые габариты, голубой рельеф, зелёное тело.
  • probe_brush_dump (SOURCE_RS_TEST_BSP_AT="x,y,z,радиус") — полный дамп кирпича: содержимое с расшифровкой, владелец-сущность, каждая грань с текстурой, габарит, попадание в коллизию.
  • probe_ramp_movement — движение из точки: 8 направлений, рывки, залипания, кто мешает (кирпич + текстуры).
  • probe_slanted_surface — лучи по наклонным кирпичам против настоящей формы.
  • probe_slope_angles — таблица скорости по углам (приёмка A19).
  • probe_phantom_walls_scan, probe_collision_contents — обход карты и состав коллизии.
  • real_map_* — 28 картовых тестов, требуют SOURCE_RS_TEST_BSP.
  • Запуск клиента: DISPLAY=:99 SDL_VIDEODRIVER=x11 cargo run -q -p client -- map --assets ~/gmod_server/server gm_construct ..., для кадров нужен --flythrough <dir>.

6. Грабли (уже наступал)

  • Vec3 без Index: писать покомпонентно.
  • %[fx:mean*255] в ImageMagick без имени канала врёт — считать mean.r/g/b.
  • Тесты с картой имеют свой VirtualFs::new() (189 материалов) — индексы материалов не как в рантайме (54).
  • Правки кода делать edit, не sed/python, кроме больших перестановок блоков — и после каждой проверять cargo clippy и тесты.
  • Перед коммитом всегда: cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace + картовые тесты.