Commit graph

98 commits

Author SHA1 Message Date
2edea0125f Слияние feat/android: APK под Android 12+, сенсорное управление
Some checks are pending
CI / checks (push) Waiting to run
Из ветки feat/android:
- клиент разделён на lib.rs (общий запуск) и main.rs; на Android собирается
  cdylib с экспортом SDL_main, паника ловится catch_unwind на FFI-границе;
- steamworks и сетевая команда отключены для Android, Lua — 5.4 vendored
  (LuaJIT не кросс-собирается), на десктопе остался LuaJIT;
- SDL3 собирается из исходников, Java-обвязка SDLActivity + MainActivity
  (ландшафт, путь к ассетам через getExternalFilesDir);
- сенсорное управление: левый стик — ходьба, правая половина — обзор,
  кнопки прыжка/приседа; короткий тап не теряется между кадрами;
- android/build.sh собирает APK без Gradle (cargo + aapt2 + d8 + zipalign +
  apksigner), поддерживает ANDROID_ABI=x86_64;
- docs/ANDROID.md и android/README.md.

APK: target/android/gmod-rs-debug.apk (arm64-v8a, minSdk 31, targetSdk 35).
Проверено на эмуляторе API 35 x86_64: установка, загрузка gm_construct,
стик, свайп-обзор, прыжок. Десктопные 25 сьютов и clippy — зелёные.
2026-10-23 14:23:42 +03:00
8d572b8190 Android: ландшафт, HUD над системными панелями, прыжок по тапу
- MainActivity фиксирует ландшафт (SDL перебивал манифест FULL_USER) и
  передаёт путь к ассетам через getExternalFilesDir;
- короткий тап по JUMP не теряется между кадрами (jump_pending);
- HUD сдвинут внутрь от статус-бара и нижней панели;
- логгер на Android собирается без неиспользуемой ветки.
2026-10-23 14:18:45 +03:00
3de8b8945f Слияние feat/anim: RAW-дорожки, анимации по состоянию, вьюмодель
Some checks are pending
CI / checks (push) Waiting to run
Из ветки feat/anim (d42d646):
- распаковка постоянных дорожек: Quaternion48 (RAWROT), Quaternion64
  (RAWROT2), Vector48 (RAWPOS) — раньше такие кости застревали в позе покоя
  (в @taunt_muscle_base 24 дорожки из 53);
- engine::animation — выбор секвенции по состоянию: стоя/шаг/бег/присед/
  воздух, 8 направлений относительно взгляда, сохранение фазы walk↔run;
- клиент: покачивание вьюмодели при ходьбе, инерция взгляда, одиночный
  выстрел fire по ЛКМ, компенсация RAWPOS-сдвига корня (у v_pistol 68 юнитов).

Разрешение конфликтов: вьюмодель осталась в камерном базисе (мои правки —
смещения через up камеры, доворот model_yaw), анимации и покачивание взяты
из ветки; play_model_animation с шестым аргументом model_yaw во всех вызовах.

Проверено: 23 сьюта, 29 картовых тестов, 15 тестов world_real — зелёные.
2026-10-23 14:08:03 +03:00
d42d6464f6 A26: постоянные дорожки RAW и выбор анимации по состоянию
- formats: Quaternion48/Quaternion64/Vector48 (RAWROT, RAWROT2, RAWPOS),
  приоритет постоянных значений в frame_pose;
- engine::animation: выбор idle/walk/run/crouch/jump по скорости, приседу,
  земле и восьми направлениям относительно взгляда, сохранение фазы walk↔run;
- клиент: модель игрока играет выбранное имя, вьюмодель качается на ходу,
  отстаёт при повороте и проигрывает fire по ЛКМ; центр анимированной позы
  компенсирует RAWPOS корня;
- тесты: RAW на v_pistol/m_anm, имена и проигрывание секвенций, чистый выбор
  и фаза; docs A26 обновлены.
2026-10-23 14:02:05 +03:00
b52da2ed2e Коллизия пропов: смещение коллайдера от центра модели
Some checks are pending
CI / checks (push) Waiting to run
Коробка-коллайдер ставилась от нуля модели, хотя геометрия часто смещена
(у куклы центр AABB на 13,5 юнита выше нуля, у стола — 9,5 вбок по X). Из-за
этого предметы висели на невидимых выступах: их держала коробка, сдвинутая
относительно меша (жалоба пользователя, кадр с парящими столом и куклой).

- Physics::add_prop_offset — смещение коллайдера в координатах модели,
  поворачивается вместе с телом (Rapier ColliderBuilder::translation);
- оба места создания тел (пропы карты и spawn_prop) передают bounds_center;
- World::prop_collision_boxes — коробки для коллизии игрока с учётом центра и
  поворота (AABB описанной коробки по модулю матрицы поворота); клиент больше
  не собирает их вручную;
- тест physics::prop_collider_honours_model_center_offset — без смещения проп
  висит на 30+ юнитов выше пола, со смещением низ ложится на пол;
- тест real_map_props_rest_on_ground — после 10 с физики низ коробки каждого
  пропа на опоре (кукла лежит краем на столе, центровой луч этого не видит).

Все сьюты зелёные.
2026-10-23 13:46:39 +03:00
c1b35b6263 Дневник: сессия по жалобам — вьюмодель, спавн, HDR/Android/анимации в работе 2026-10-23 13:23:59 +03:00
8a5f43dcd2 A26: кадры анимации играют — модель игрока и вьюмодель
Some checks failed
CI / checks (push) Has been cancelled
Спека декодера взята из настоящего bone_setup.cpp (Mapbase-зеркало SDK 2013).
Прошлые догадки были неверны, и это зафиксировано в docs/PARITY.md: каналы
дорожек — не компоненты кватерниона, а углы Эйлера (raw · rotscale + поза
покоя, затем AngleQuaternion); позиции — raw · posscale + bone.pos; значения
блоков не накапливаются дельтами (ExtractAnimValue идёт по блокам valid+1
записей, внутри блока value[k], дальше повтор последнего); флаг DELTA означает
относительные каналы без позы покоя.

Замеры на живых данных, три теста в tests/world_real.rs:
- @taunt_muscle_base (451 кадр, 53 дорожки): углы ≤ 4,75 рад, сдвиги позиций
  ≤ 28,5 юнитов, кватернионы единичные — при новой спеке нарушений нет;
- кости локальны родителю (цепочка 4,0 / 3,5 / 8,9), корень — таз на 38,6;
- police.mdl: 57 костей, своя анимация @ragdoll лежит в самом MDL
  (anim_block == 0); m_anm.mdl: 1563 анимации в models/m_anm.ani (7,1 МБ);
- модель игрока, idle_all_01: из 3147 вершин 1101 сдвинулась между кадрами 0 и
  30, высота тела 61 юнит и в покое, и в анимации — не схлопывается.

Движок: AnimData (буферы кадров под Arc), frame_pose, play_model_animation
(мировые позы через родителей, A·B⁻¹ по костям, скиннинг весами вершин);
кости меша ищутся в скелете анимации по именам — скелеты совпадают по именам,
но не по числу костей. Кадры читаются и из .ani, и из самого MDL.

Клиент: вьюмодель играет idle, модель игрока — idle_all_01; добавлен вид со
стороны --third-person (иначе свою модель не увидеть: в первом лице камера
внутри неё). Map(Box<MapArgs>) — против clippy::large_enum_variant.

Все 23 сьюта и 29 картовых тестов зелёные.
2026-10-17 12:37:45 +03:00
49ff6da4ac Дневник: точный план проигрывания анимаций
Some checks are pending
CI / checks (push) Waiting to run
Записан следующий шаг A26, чтобы не искать заново: данные анимации (.ani 7 МБ,
блоки, описания) должны жить в меше ссылкой Arc (сейчас читаются при загрузке и
выбрасываются); в движке появляется pose_model_instance с локальными
кватернионами костей, собранными через родителей; в клиенте — выбор секвенции,
кадр по времени (t · fps, цикл) и сборка кватернионов из дорожек; затем выбор по
состоянию ACT_MP_* с блендингом и снятие синуса.
2026-10-17 12:14:44 +03:00
d92b962b36 A26: замер нормировки канала поворота (unit-критерий)
Some checks are pending
CI / checks (push) Waiting to run
Кватернион требует, чтобы модуль вектора поворота не превосходил единицу у всех
костей и кадров — по этому критерию и выбран путь. Сырые значения @idle_all_01:
кость 0 — [43, 1424, 17013] при rot_scale [6,37e−5, 4,66e−5, 9,59e−5].

Умножение на rot_scale даёт 17013 · 9,59e−5 = 1,63 — больше единицы, для
кватерниона невозможно, значит rot_scale не множитель. Деление на 32768 даёт
0,52 — годится.

Записано в docs/PARITY.md A26 как первая задача следующего шага: выяснить точную
нормировку канала (похоже, rot_scale задаёт сжатие, обратное ≈1,5e4…2,1e4 по
осям), собрать кватернион с w = √(1 − x² − y² − z²) и перейти к позам костей по
родителям.

Все 23 сьюта зелёные.
2026-10-17 12:08:48 +03:00
2155bcee54 A26: декодер кадров анимации (mstudioanim_t), проверен на данных GMod
Some checks are pending
CI / checks (push) Waiting to run
Спека взята из studio.h SDK 2013 (112 КБ, скачан как спецификация): mstudioanim_t
— четыре байта (bone, flags, nextoffset), дорожки связаны списком до
nextoffset == 0; mstudioanim_valueptr_t — три смещения каналов; значения
mstudioanimvalue_t идут дельтами с накоплением (valid, total); флаги RAWROT
(Quaternion48), RAWROT2 (Quaternion64), ANIMROT/ANIMPOS (дорожки), DELTA.

Написаны Mdl::animation_tracks и Mdl::decode_track и прогнаны на настоящей
библиотеке GMod: анимация @idle_all_01 — 280 кадров, 51 дорожка, значения вида
17013, 1424, −874 — это шорты, которые при сборке кватерниона делятся на
масштаб кости.

Все 23 сьюта зелёные; docs/PARITY.md A26 обновлён.
2026-10-17 11:51:00 +03:00
2c7ec501e2 A26: чтение анимационного файла .ani (кадры)
Some checks are pending
CI / checks (push) Waiting to run
formats: mdl.animations() — описания анимаций (mstudioanimdesc_t) с определением
размера записи по данным (как у секвенций); anim_block_names() — имена
анимационных файлов; anim_blocks() — границы блоков внутри .ani.

Замеры: у m_anm.mdl 1563 анимации (@reference 101 кадр, @idle_all_01 280,
@idle_all_02 151, все 30 fps), 247 блоков, и ВСЕ имеют anim_block > 0 — то есть
кадры не в MDL, а в отдельном файле. Файл models/m_anm.ani прочитан целиком:
7,1 МБ. В конце таблицы имён блоков есть пустая запись — чтение по ней падает и
пишет предупреждение, это безвредно.

Дальше: декодирование mstudioanim_t (6 каналов на кость, дельты с накоплением,
повороты кватернионом), позы костей с учётом родителей, интерполяция между
кадрами и выбор секвенции по состоянию.

Все 23 сьюта зелёные; docs/PARITY.md A26 обновлён.
2026-10-17 11:40:57 +03:00
3c0bef5169 A26: слияние включённых моделей — у игрока 465 секвенций
Some checks are pending
CI / checks (push) Waiting to run
police.mdl включает models/m_anm.mdl (библиотека анимаций GMod). Включённая
модель грузится лёгким путём: Mdl::parse + секвенции, без геометрии и текстур —
у анимационной библиотеки их нет. Замеры: у m_anm.mdl numlocalseq=464,
numlocalanim=1563, 56 костей; после слияния у модели игрока 465 секвенций
(была одна «ragdoll»).

Находка по пути: у «единичных» (unified) моделей GMod запись секвенции длиннее,
чем у моделей карты, и мой первый набор кандидатов (184..208) не подошёл —
разбор молча возвращал ноль. Перебор расширен до 176..320 с обязательной
проверкой имён ВСЕХ секвенций: иначе шаг не принимается.

Все 23 сьюта зелёные; docs/PARITY.md A26 обновлён.
2026-10-17 11:32:37 +03:00
50ab90debc A26 шаг в: CPU-скиннинг — модель двигается на экране
Some checks are pending
CI / checks (push) Waiting to run
PropMesh теперь несёт скелет и привязку вершин, а World::skin_model_instance
пересчитывает вершины по позе костей: угол кости складывается с родительскими,
вершина смешивается по трём весам, результат пишется в общий буфер и помечается
диапазоном для заливки на GPU — тем же механизмом moved_ranges, что возят
движущиеся пропы.

Проба подключена к вьюмодели: одна кость пистолета качается по синусу.
Проверено кадром — геометрия visibly деформируется, то есть цепочка «скелет →
веса → вершины → GPU» работает целиком. Это пока проба, а не анимация:
настоящие кадры из .ani следующим шагом.

Все 23 сьюта зелёные; docs/PARITY.md A26 обновлён.
2026-10-17 11:25:15 +03:00
f8db4cf73c A26 шаг б: кости и веса вершин в модели
Some checks are pending
CI / checks (push) Waiting to run
Model теперь несёт skins: Vec<VertexSkin> — привязку вершин к костям из VVD
(до трёх костей с весами и их число), по одной записи на вершину. Индексы
глобальные (после fixup VTX), считаться по ним будет матрица скиннинга вместе
со скелетом Model::bones.

Замер на живых моделях (лог «модель загружена»): companion_doll 1471 вершин /
1471 привязок, стол 136/136, диван 556/556 — привязка есть у всех вершин; у
пропов при этом один корневой узел (bones=1). Модель игрока police.mdl — 57
костей.

Все 23 сьюта зелёные; docs/PARITY.md A26 обновлён.
2026-10-17 11:19:21 +03:00
d7796974b1 A26 шаг а: разбор секвенций анимации (mstudioseqdesc_t)
Some checks are pending
CI / checks (push) Waiting to run
Размер записи секвенции версионно-зависим (184…208 байт), поэтому шаг берётся
не на глаз: перебираются кандидаты, и выбирается тот, у которого у ВСЕХ
секвенций имя читается непустой печатной строкой. Модель движка теперь несёт
bones и sequences.

Замеры на живых моделях: пропы карты — bones=1, sequences=1, «idle»; модель
игрока models/player/police.mdl — bones=57, sequences=1 («ragdoll»). Второе не
ошибка: у игроков активности лежат во включённых моделях (mdl.include_models()),
их надо сливать — записано следующим шагом.

Все 23 сьюта зелёные; обновлён docs/PARITY.md A26.
2026-10-17 11:05:09 +03:00
46677edbaa A26: план анимаций моделей (скелет и веса уже читаются)
Some checks are pending
CI / checks (push) Waiting to run
Просьба пользователя: «по возможности делай анимации». Записан план (A26) с
тем, что уже есть и чего нет.

Уже есть в formats: mdl.bones() (скелет: родители, позиции, кватернионы),
mdl.attachments(), vvd.vertices() (веса и кости по вершинам), vtx.body_parts()
(топология). То есть данные скелета и весов читаются — не хватает переноса их в
рендер.

Чего нет: разбора секвенций (mstudioseqdesc_t) и кадров из отдельного .ani
(mstudioanimdesc_t, сжатие кривой/дельтами); костей и весов в ModelVertex;
CPU-скиннинга; выбора секвенции по состоянию (ACT_MP_*) с блендингом.

Порядок: (а) секвенции и .ani в mdl.rs, (б) кости/веса в ModelVertex,
(в) CPU-скиннинг и заливка на GPU (механизм moved_ranges уже готов — тот же
путь, что у движущихся пропов), (г) проигрывание одной секвенции с циклом,
(д) выбор по состоянию и блендинг. Приёмка: модель двигается, ходьба переходит
в бег и присед, прыжок отдельной секвенцией.
2026-10-17 10:48:29 +03:00
de126d0784 A23: вьюмодель в руках — заработала
Some checks are pending
CI / checks (push) Waiting to run
Первый срез вьюмодели: модель оружия на том же рендере, что пропы и модель
игрока. Не рисовалась по двум причинам, обе найдены кадром:

1. разворот на 180° (нужный модели игрока) уводил ствол ЗА камеру: у моделей
   оружия Source геометрия вынесена вперёд по оси модели (у пистолета ~47
   юнитов, у монтировки −52 по Y);
2. привязка шла к origin модели, а надо к центру её габарита: у пистолета центр
   ≈(0, 0, 47), поэтому ствол уезжал выше кадра. Теперь смещение считается как
   `глаз + 20 вперёд + 6 вправо − 10 вниз − повёрнутый центр габарита`.

Проверено кадром: `SOURCE_RS_VIEW_MODEL=models/weapons/v_pistol.mdl` — пистолет
стоит в кадре. По умолчанию берётся пистолет: `v_physgun.mdl` в ассетах нет
(это модель GMod, а не HL2), есть откат на монтировку.

Все 23 сьюта и 29 картовых тестов зелёные; разбор и посадка записаны в
docs/DIARY.md.
2026-10-17 10:45:10 +03:00
d700d15416 Дневник: вьюмодель не доходит до отрисовки — записаны факты и порядок проверки
Some checks are pending
CI / checks (push) Waiting to run
Замер: материал вьюмодели в мире — индекс 54, имя crowbar_cyl (виден в
--debug-materials). Рендер одним этим материалом (--only-material 54) даёт
пустой кадр, то есть геометрия и поза ни при чём — не доходит сам батч.

Записано, что проверять дальше: попал ли батч в renderer.batches после
refresh_batches (диапазоны индексов забираются в блоке физики — возможно,
раньше, чем обновление списка), совпадает ли batch.material с позицией
материала в world.materials, и не мешает ли фильтр --only-material.
2026-10-17 07:55:32 +03:00
2043ebf39a Спавн и вьюмодель: рендерер перечитывает батчи, буферы принимают заливку
Some checks are pending
CI / checks (push) Waiting to run
Ещё две поломки на пути «новой геометрии»:

1. Буфер вершин мира создавался без COPY_DST, поэтому точечная заливка
диапазонов падала с ошибкой wgpu (у пользователя это уронило бы клиент —
поймано прогоном, а не тестом). Теперь VERTEX | COPY_DST, для индексов
INDEX | COPY_DST, добавлен upload_index_ranges.

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

Вьюмодель (A23, первый срез): создаётся тем же рендером моделей, следует за
взглядом, включается SOURCE_RS_VIEW_MODEL (по умолчанию физган, откат на
монтировку — v_physgun.mdl в ассетах не нашёлся). В кадре она пока не
появляется: создание работает, а звено «перенос позы каждый кадр + заливка»
нет — записано открытым вопросом в docs/DIARY.md.

Все 23 сьюта и 28 картовых тестов зелёные.
2026-10-17 07:53:56 +03:00
c58ad8817e Физика и картинка: вершины движущихся пропов уходят на GPU
Some checks are pending
CI / checks (push) Waiting to run
Жалоба пользователя: «физика вроде работает, но позиция рендера обновляется
только при спавне нового». Причина: мир двигал вершины в памяти, а буфер
рендера заливался один раз при сборке мира — потому пересборка рендерера при
спавне и «лечила» картинку.

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

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

Все 23 сьюта и 28 картовых тестов зелёные; разбор в docs/DIARY.md.
2026-10-17 07:33:11 +03:00
03edb27a33 A21/A23: физган — взять проп под прицелом и таскать по взгляду
Some checks are pending
CI / checks (push) Waiting to run
Первый срез взаимодействия с пропами (то, что в GMod делает weapon_physgun):
ЛКМ берёт ближайшее тело под прицелом — луч из глаз, расстояние 8..260 юнитов,
допуск 40 в стороны, побеждает ближайшее; пока кнопка зажата, тело каждый кадр
телепортируется на луч взгляда на взятом расстоянии (углы сохраняются);
отпустил — тело свободно и падает само.

Добавлен переключатель SOURCE_RS_PHYSGUN=1: держит ближайшее тело без мыши —
нужен для проверки скриптом, а не руками (SDL-событий мыши в скрипте нет).

Проверка: код собирается, clippy чистый, все 23 сьюта зелёные. Интерактивно
проверить не смог — в скриптовых кадрах камера упирается в стены (наводить
нечем). Проверять у кучи пропов в районе -2930 -1370 -64: посмотреть на проп,
зажать ЛКМ. Записано в docs/DIARY.md.
2026-10-16 23:53:51 +03:00
35e04409c4 A24: спавн пропа из игры — ядро спавн-меню
Some checks are pending
CI / checks (push) Waiting to run
Движок: геометрия модели вынесена в append_model_geometry (меш, материалы,
вершины с трансформацией, батчи со смещением индексов на вершины экземпляра).
Поверх неё World::spawn_prop — то же, что делает Q-меню GMod: геометрия, сущность
класса prop_physics (её видят Lua и физика), тело в мире физики по габаритам
меша, экземпляр пропа для move_prop.

Клиент: клавиша G — спавн пропа по взгляду (модель из SOURCE_RS_SPAWN_MODEL, по
умолчанию models/props_junk/watermelon01.mdl), и аргумент --spawn <модель> для
отладки и скриптов. Проп сразу попадает в габариты коллизии игрока — их клиент
пересобирает каждый кадр.

Проверено кадром: арбуз заспавнен в 96 юнитах по взгляду и отрисован
(--player-pos="-2438 -966 48", камера у "-2500 -1050 60|5 0"). Грабли записаны
в дневник: буферы рендера собраны на сборке мира, поэтому после спавна рендерер
пересобирается целиком — позже заменим до-аллокацией вершин.
2026-10-16 23:49:43 +03:00
8a2eb4ef60 A20/A23 первый срез: модель игрока в мире
Some checks are pending
CI / checks (push) Waiting to run
Первый срез модели игрока, чтобы не блокировать Sandbox: рендер тот же, что у
пропов (статический меш PropMesh), скелет и анимации следующим срезом.

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

Клиент: модель создаётся после разбора --player-pos и едет за игроком (позиция —
ступни, курс — взгляд, развёрнутый на 180°, потому что у моделей Source ось Y
смотрит влево). Включается переменной SOURCE_RS_PLAYER_MODEL: пустое значение —
models/player/police.mdl, иначе путь к модели.

Проверено кадром: модель стоит на площадке в -2438 -966 48, текстуры на месте,
вершин в мире стало 46359 против 43212 без модели. Грабли записаны в дневник:
индексы модели локальные, при вставке в общий буфер к ним прибавляется
first_vertex (без этого кадр превращался в мусор).

Заведён docs/DIARY.md — живой дневник состояния, замеров, инструментов и
грабель, чтобы уплотнение контекста ничего не теряло.
2026-10-16 23:44:39 +03:00
4ad0405ed3 Очередь: система оружия и ускоренный порядок под Sandbox-UX
Some checks are pending
CI / checks (push) Waiting to run
Просьба пользователя: быстрее дойти до спавна из Q-меню, нормальной модели
игрока, физгана и «Weapon всё сделать».

Добавлено:

A23 — система оружия: реестр SWEP из Lua (weapons/*.lua: PrimaryAttack,
SecondaryAttack, Deploy, Holster, Think, Draw), вьюмодель в первом лице на том
же рендере моделей (скелет, скиннинг, аттачменты), переключение по слотам,
заготовка стрельбы через util.TraceLine, gmod_tool и weapon_physgun (выбор
пропа лучом, таскание, вращение, заморозка), gmod_camera и DrawHUD по мере
надобности. Приёмка: взять физган, подцепить и протащить проп; спавнить из
gmod_tool; переключать оружие; видеть вьюмодель.

A24 — Q-меню спавна: сначала консольные эквиваленты (gm_spawn, gm_give, режимы
gmod_tool) на данных из Lua (list.Get('SpawnableEntities')), затем быстрый UI на
Rust, как в P10.

A25 — ускоренный порядок: A23 оружие и вьюмодель, сюда же A20 (модель игрока —
тот же рендер моделей), затем A21 физика взаимодействия (физган без полной
гибернации), затем A24 спавн, затем A22 Sandbox целиком (undo/cleanup/
duplicator); визуальная точность (A10 свет, A11–A12 вода, A16–A18 наполнение)
идёт параллельно и не блокирует.
2026-10-16 23:24:23 +03:00
7a8a750655 Очередь: модель игрока, полная физика, Sandbox через GLua
Some checks are pending
CI / checks (push) Waiting to run
Просьба пользователя: добавить в очередь Sandbox-режим (через GLua), полную
физику и модель игрока.

A20 — модель игрока: сейчас игрок это AABB 32×32×72, визуальной модели нет.
Нужны выбор модели как в GMod (player_manager, GM:PlayerSetModel), загрузка MDL
(часть M5), activity по состоянию движения как ACT_MP_*, аттачменты оружия,
тени.

A21 — полная физика: каркас есть (Rapier, констрейны, габариты пропов, вода),
нужны твёрдые тела с массой и инерцией, полный набор констрейнов GMod,
гибернация островов (P2/M6), гравиган и +use, движущиеся платформы,
func_breakable, физика в воде. Приёмка — стресс-полигон 1000+ пропов (M4).

A22 — Sandbox через GLua, цель проекта: перенести garrysmod/gamemodes/sandbox и
дать ему API (ents, player, util, constraint, duplicator, properties, gmod_tool,
undo, cleanup, физическая пушка). Из нужного: ents.Create/Spawn/PhysicsInit,
surface/render для HUD, weapons и tool-режимы, undo/cleanup, dup-сохранения,
сеть для мультиплеера. Приёмка — на gm_construct запускается sandbox, пропы
спавнятся и вяжутся верёвками, undo отменяет, dup восстанавливается.

Порядок очереди обновлён под это.
2026-10-16 23:23:19 +03:00
257ac0792a A13: туман карты подключён к рендеру
Some checks are pending
CI / checks (push) Waiting to run
Fog читается при сборке мира (приоритет env_fog_controller, иначе worldspawn),
доля тумана считается линейно от fogstart к fogend с потолком fogmaxdensity —
как в движке. В рендере два новых vec4 в uniform камеры (цвет и параметры),
буфер камеры 112 → 144 байта; world.wgsl смешивает цвет с туманом по
расстоянию до камеры.

Замер на gm_construct: туман есть — цвет (0,557 0,643 0,698), start 1024,
end 250000, max_density 1,0, farz −1. Это дальний туман: на 2000 юнитов доля
≈ 0,4%, поэтому кадр с камеры E11 не изменился (трава 73,2, бетон 116,0,
небо 207,6, тоннель 81,1 — как в E20). Механика подключена и проверена; заметен
туман будет у карт с близким fogend.

Осталось: применить туман к воде и к моделям. Все 23 сьюта и 28 картовых
тестов зелёные. Разбор — docs/PARITY.md E24.
2026-10-16 23:18:12 +03:00
72bad60d8c A19: подшаг больше не гасит скорость о склон — 35° с 65 до 147
Some checks are pending
CI / checks (push) Waiting to run
Диагностика (покадровый замер в probe_slope_angles + трасса step_move) на 35°:
равновесие скорости было x = 66,8, z = +41,8 при разгоне 41,7 за кадр. Расчёт
«каждый кадр теряется нормальная компонента» (v·cos²) сходится с замером,
значит скорость гасилась о склон при посадке подшага.

В движке вертикальная часть шага — трассировка вниз с переносом позиции,
скорость после неё не меняется (StayOnGround; в StepMove «Move down a stair» —
трассировка, а не TryPlayerMove). У нас посадка шла через move_aabb, и её
контакты гасили скорость ещё и в update.

Правки в step_move: при победе подъёма скорость берётся без гашения о склон, и
контакты посадки наружу не отдаются.

Замер probe_slope_angles: 5° 239, 10° 233, 15° 226, 20° 214, 25° 195, 30° 175,
35° 147 (было 65), 40° 132 (было 49), 44° 107 (было 39). Цель — 250 до 44°;
остаток теряется на прямом (скользящем) пути, который иногда всё ещё выигрывает
выбор. Следующий шаг записан в docs/PARITY.md E23.8.

Все 28 картовых тестов и весь workspace зелёные.
2026-10-16 22:57:28 +03:00
5bdbdbfbf8 A19: найден механизм движка для скорости на склоне (возврат потерянного)
Some checks are pending
CI / checks (push) Waiting to run
В gamemovement.cpp (ClipVelocity, строка ~3144) есть блок flRedirectCoeff: после
отсечения вектор скорости растягивается вдоль скольжения на потерянную в
плоскость компоненту (out *= (len + |v·n|·coeff)/len). Это и есть причина, по
которой в движке на ходибельном склоне нет замедления. Коэффициент передаётся в
TryPlayerMove третьим параметром; в базовом game 2013 он нулевой, у мода может
быть своим.

Наивный перенос проверен замером и провалился: если возвращать потерянное на
каждом контакте каждого подшага, бонус копится за кадр — на 15° скорость ушла в
442 юнита/с. Правка откачена, дерево зелёное (23 сьюта, 28 картовых тестов).

Правильный перенос записан в docs/PARITY.md E23.7: возврат считается один раз
на отсечение, длина берётся до отсечения, результат ограничивается желаемой
скоростью кадра; приёмка — таблица probe_slope_angles (до 44° не ниже 95% от
250).
2026-10-16 22:39:11 +03:00
e638607ee1 Замер: скорость подъёма по склону падает с углом — ищем в StepMove
Some checks are pending
CI / checks (push) Waiting to run
Жалоба: «на рампе в 35 градусов замедление есть; чекни, как вообще физика с
наклонными поверхностями работает».

Добавлен замер probe_slope_angles: синтетический клин с углом от 5° до 44°,
шаг вверх две секунды. Скорость: 5° — 239, 10° — 232, 15° — 196, 20° — 152,
25° — 116, 30° — 88, 35° — 65, 40° — 49, 44° — 39 юнитов/с.

По движку до sv_walkable_normal (0,7 = 45,6°) замедления быть не должно: на
земле вертикальная скорость обнуляется, движение по горизонтали, а склон
догоняется StepMove и StayOnGround. Наш замер говорит, что скорость держит
скольжение по грани (при отсечении по наклонной плоскости из горизонтальной
скорости уходит нормальная компонента, а вертикаль тут же обнуляется), то есть
step_move на склоне отдаёт победу прямому ходу. В CGameMovement::StepMove из
двух кандидатов выбирается тот, кто ушёл дальше ПО ГОРИЗОНТАЛИ — потому в
гору скорость и не падает.

Разбор и приёмка записаны в docs/PARITY.md E23.6: до 44° скорость не ниже 95%
от 250 на ровном полу.
2026-10-16 22:15:48 +03:00
3bfd078c14 Рельеф: нормаль развёрнута наружу — стены по рёбрам полигонов ушли
Some checks are pending
CI / checks (push) Waiting to run
После предыдущей правки пользователь написал, что стены по полигонам
дисплейсментов остались. Замер объяснил: у ВСЕХ 11776 треугольников рельефа
нормаль из намотки смотрит вниз — намотка у нас «гео внутрь» (так её
зафиксировал фикс E18 для брашей). Построенный по такой нормали выпуклый объём
лежал **над** поверхностью, и его боковые грани вставали стеной по каждому
ребру полигона.

Рельеф — heightfield, наружу он смотрит только вверх, поэтому clip_triangle
теперь разворачивает нормаль, если она ушла вниз.

Замер (real_map_hill_walk_keeps_speed, восемь склонов 13–18°): скорость в гору
250–287 юнитов/с при 250 на ровном полу, сползание равно тормозному пути
ровного пола (26,54 юнита). Худшее из четырёх направлений в четырёх точках —
170–222 (это уклон в гору), в четырёх — 0,0–72,7: там направление упирается в
крутой соседний склон, и это ещё предстоит проверить отдельно (стена может быть
настоящей — крутизна выше sv_walkable_normal — или мнимой).

28 картовых тестов и весь workspace зелёные; разбор в docs/PARITY.md E23.5.
2026-10-16 22:12:20 +03:00
d88988fc67 Рельеф: треугольники отсекают шаг, как кирпичи — стены и сползание ушли
Some checks are pending
CI / checks (push) Waiting to run
Жалобы: «невидимые стены уже на дисплейсментах, и коллизии их не видно;
в гору скорость замедляется; прыгать рядом нельзя, но проходятся, если
подойти и прыгнуть; на наклонных дисплейсментах скатывается» — и уточнение
пользователя: «стены ровно по полигонам дисплейсментов».

Причина одна: треугольники рельефа участвовали только в выталкивании.
Тело, въехав в склон, выталкивалось по нормали треугольника, а нормаль
наклонной поверхности смотрит вниз-вбок. Отсюда всё сразу: стена на ровном
склоне, потеря скорости в гору, сползание, странные прыжки и стены по рёбрам
полигонов (выталкивание срабатывает на границе треугольника).

Как в движке: CDispCollTree трассирует треугольник как выпуклый объём —
плоскость поверхности плюс три боковые грани через рёбра.

Правка: отсечение по набору полупространств вынесено в Collision::clip_planes;
Collision::clip_triangle строит четыре плоскости (нормаль из намотки, боковые
(to − from) × n, ориентированные внутрь) и зовёт его. Треугольники добавлены в
отсечение move_aabb и в ground_trace; выталкивание осталось страховкой.

Замер (новый тест real_map_hill_walk_keeps_speed: восемь точек на ходибельных
склонах 13–18°, четыре направления, эталон на ровном полу): скорость на склоне
252,2 против 250,0 на полу; «тормозной путь» после отпускания ввода 26,40
против 26,54 — то есть мнимое сползание оказалось тем же тормозным путём, что
и на полу, сползания нет.

28 картовых тестов и весь workspace зелёные. Разбор — docs/PARITY.md E23.
2026-10-16 21:54:05 +03:00
b74824be70 Склон: догон земли без выталкивания — рампы больше не тормозят
Some checks are pending
CI / checks (push) Waiting to run
Жалоба: «по рампе взбираюсь, останавливаюсь — чуть-чуть назад проскальзываю;
и сама скорость ниже, а в оригинале на рампе не замедляет».

Спека (gamemovement.cpp, WalkMove): на земле вертикальная скорость обнуляется
до и после ускорения, движение идёт строго по горизонтали, а склон догоняется
StepMove и StayOnGround — там трассировка вниз (TracePlayerBBox на m_flStepSize)
и перенос позиции в trace.endpos, без разрешения проникновений. У нас догон был
сделан шагами по 1 юниту через move_aabb, и каждый шаг заканчивался
выталкиванием по нормали склона, а нормаль смотрит вниз-вбок — тело
проскальзывало вниз по рампе, а скорость в гору падала.

Правка: Collision::ground_trace — чистый отсев вниз одним отрезком (кирпичи и
габариты тел), return доли и нормали; stay_on_ground переносит позицию по нему
и требует нормаль не круче sv_walkable_normal (0,7). Сдвиг меньше 0,5 юнита не
применяется — как в движке, чтобы позиция не дрожала.

Замер на gm_construct после правки (ровный пол 250): #1476 — 242,4/523,0,
#1477 — 541,5/241,9, #1478 — 250,1/250,1, #1479 — 263,6/245,7, #1480 —
255,9/200,1, #1481 — 301,0/239,8, #1482 — 250,1/250,1. «Медленных» по порогу
теста (75%) не осталось ни одной, застреваний при подъёме ноль. Остаток у #1480
(80%) — съезд по краю кирпича в изоляции, а не штраф за склон.

В docs/PARITY.md A19 дописаны замеры; приёмкой осталось измерить сползание на
остановке (пройти вперёд, отпустить ввод, замерить смещение) и держать ниже
юнита.
2026-10-16 21:29:48 +03:00
23c50342fb Присед: скорость режется на треть, камера больше не выше хитбокса
Some checks are pending
CI / checks (push) Waiting to run
Две жалобы пользователя: «физика приседания неправильная — скорость вообще не
меняется» и «камера выше хитбокса при приседании».

Скорость. Спека (gamemovement.cpp, Source SDK 2013 — только как описание
поведения, по правилу из AGENTS.md): HandleDuckingSpeedCrop умножает желаемую
скорость на 0.33333333, но только когда игрок в приседе И на земле, а затем
CheckParameters ограничивает её максимальной. У нас желаемая скорость и
максимальная были слиты в одну константу, поэтому резать было нечего. Теперь
они разделены (WISH_SPEED 450 / WISH_SPEED_SPRINT 650 и WALK_SPEED 250 /
RUN_SPEED 520), и порядок как в движке: желаемая → обрезка приседом →
ограничение. Замер на ровной площадке: ходьба 250, присед 150, бег 520, бег в
приседе 216,7 — ровно spec-овские числа. В воздухе обрезки нет; замером это
отдельно не подтвердить, потому что разгон в воздухе упирается в потолок
AirMove (30), и тест проверяет сам потолок, а правило видно по земляным замерам.

Камера. Линейки Source: габарит стоя 72, в приседе 36, глаза — 64 и 28
(VEC_VIEW / VEC_DUCK_VIEW). У нас было EYE_DUCK = 46 при присевшем теле 36, то
есть камера смотрела с высоты, которой у тела нет. Исправлено на 28, добавлен
тест-инвариант eye_height_stays_inside_the_hull: камера обязана быть строго
внутри габарита и выше его середины в обоих состояниях.

В очередь записан A19 (рампа: остаточное замедление и сползание на остановке) с
разбором по спеке: WalkMove на земле обнуляет вертикальную скорость и догоняет
склон через StepMove и StayOnGround, поэтому замедления на рампе у движка нет;
наш остаток даёт догон склона шагами по 1 юниту через move_aabb, который
заканчивается выталкиванием по нормали склона (вниз-вбок). Приёмка записана:
не ниже 95% от ровного пола и смещение назад меньше юнита.
2026-10-16 21:25:32 +03:00
7b69336f83 Очередь: полный 3D-скайбокс
Some checks are pending
CI / checks (push) Waiting to run
Просьба пользователя: «в очередь ещё 3D-скайбокс сделать полноценный».

Записано в docs/PARITY.md как A18 с замером текущего состояния: sky_camera
находится (origin и scale), геометрия области скайбокса собирается в sky_mesh и
рисуется отдельным проходом, есть переключатель SOURCE_RS_NO_SKY_MESH, за
границами карты скайбокс гасится.

Чего нет: города в кадре — на камере E11 (-399.5 -1604.5 67.9) виден сплошной
травяной склон (замер E11.5: рельеф выше 1000 — ноль треугольников, то есть
закрывающая плита не рельеф, причина не найдена), а в области скайбокса живут
ещё и prop_static-модели и func_*-сущности, спавн которых для скайбокса не
делается.

Решение записано: геометрия + модели + декали области скайбокса,
преобразование по sky_camera (origin и scale 1/16), глубина и порядок
отрисовки, отсечение по границам, сверка кадра с эталоном GMod на камере E11.

Порядок в очереди обновлён: A15 → A16 → A18 → A17, параллельно A10–A14.
2026-10-16 21:06:24 +03:00
77efaace3f Очередь: полноценные пропы и наполнение карты
Some checks are pending
CI / checks (push) Waiting to run
Просьба пользователя: «пропы полноценные, полные все элементы карты —
телепорты, пропы, текстурки, трава, деревья».

В список дефектов (docs/PARITY.md, часть A) добавлены три пункта с замером
текущего состояния:

A15 — полноценные пропы и модели. Уже есть: MDL читаются, статические меши
PropMesh строятся (9 экземпляров на gm_construct), рисуются, участвуют в
физике и в коллизии игрока. Нет: скелета и скиннинга, анимаций,
$bodygroup/$texturegroup, LOD, ragdoll, флексов — то есть полного studio-рендера
этапа M5.

A16 — наполнение карты: detail-спрайты ($detail, detailsprites — трава),
смешанные материалы (WorldVertexTransition и $basetexture2 — сейчас берётся
только первая текстура, отсюда пропажа перехода травы в песок), декали и
оверлеи (infodecal, info_overlay, decal-лумпы — не рисуются вовсе), деревья
(prop_static, появятся с A15).

A17 — сущности-механики: телепорты (trigger_teleport), лестницы (func_ladder),
двери и кнопки, звуки, источники света, env_fog_controller. Каркас энтити есть,
классы добавляются по очереди, начиная с влияющих на игру, каждый — с проверкой
по спецификации и замером.

Там же записан порядок: A15 → A16 (материалы, спрайты, декали) → A17,
параллельно A10 (свет на пиксель), A11–A13 (вода, туман), A14 (присед).
2026-10-16 20:50:11 +03:00
aa11713287 Рампы: отсечение брало срезанную грань вместо настоящей поверхности
Some checks are pending
CI / checks (push) Waiting to run
Жалоба: «на рампах и наклонных поверхностях всё также по жёлтой коробке идёт
коллизия, а не по реальной форме рампы».

Причина найдена замером `probe_slanted_surface`: у кирпича-холма есть и
верхняя грань z <= 240, и наклонная 0,361x + 0,127y + 0,924z <= -380,63,
которая верхнюю срезает. Отсечение брало **первую** задетую грань, то есть
срезанную верхнюю, и останавливало тело в 9 юнитах над настоящей поверхностью.
Замер: 394 луча из 7672 по 175 наклонным кирпичам карты вставали ровно на 9,0
юнита выше формы.

Правка: отсечение по алгоритму движка (`CM_ClipBoxToBrush`) — доля входа и
выхода для каждой грани, удар по самой поздней входной грани, если она раньше
самого раннего выхода. Для выпуклого кирпича это и значит «тело вошло во все
полупространства». Если тело начало шаг уже перекрывая кирпич, отсечения нет:
выталкиванием занимается разрешение проникновений, как и было. Добавлен
DIST_EPSILON = 1/32 (зазор трассировки), проверка достижимости из E21 сохранена
и применяется к выбранной грани.

Замер после: 0 лучей из 7672 расходятся с формой больше чем на 4 юнита
(максимум 1,4 — полугабарит луча). Игрок в -2385 -2032 256 проходит 256 юнитов
до настоящей стены, в -2438 -966 48 свободны все направления кроме настоящей
стены. Синтетическая проверка: игрок залезает на ступень высотой 16 (подшаг
работает, отсечение не мешает).

В тестах уточнено два места, оба с замером: кирпичи за габаритами карты
(3D-скайбокс, 140 штук, часть с несогласованными плоскостями) считаются
отдельно в тесте лучей; порог «медленной рампы» опущен с 80% до 75%, потому
что рампа #1480 на краю даёт 197,8 против 250 при нуле застреваний — это съезд
по краю, а не стена (тест на застревание остался строгим).

Разбор — docs/PARITY.md E22.
2026-10-16 20:30:47 +03:00
d220b5a7c1 Невидимые стены на стыках: грань блокировала по касанию ребром
Some checks are pending
CI / checks (push) Waiting to run
Жалоба: «на стыках комнат, на стыках даже двух поверхностей бывают невидимые
стены, которые можно перепрыгнуть, а запрыгнуть на них нельзя». Адрес от
игрока: `-2385 -2032 256` — там он не делал ни шага в −Y.

Разбор по данным (новый `probe_brush_dump` печатает по кирпичу всё: содержимое
с расшифровкой, владельца-сущность, каждую грань с текстурой, габарит и
попадание в коллизию) нашёл виновника: кирпич #269 — плита толщиной 16 юнитов
(`z 240..256`), у которой игрок стоял вровень с верхом и вплотную к боку.
Боковая грань плиты держала шаг, хотя тело (z 256..328) её не перекрывало —
касалось верхним ребром. `clip_fraction` считала блокирующей любую грань, до
габарита которой тело «достаёт» касанием.

Правка: у блокирующей грани по оси нормали касание по-прежнему считается
достижением (иначе нельзя стоять на полу и упираться в стены), а по двум
другим осям нужен настоящий перехлёст — по габариту за весь шаг, а не в один
момент касания: при заходе в угол наклонно перехлёст по второй оси появляется
уже после касания. Промежуточный вариант (строго по моменту касания) дал 2502
«прохода» на 2118 кирпичах; с проверкой по всему ходу — 0 внутри карты.
Строгое правило только для почти осевых нормалей: у наклонных граней нулевой
перехлёст по осям законен.

Замеры после: в `-2385 -2032 256` игрок проходит 256 юнитов до настоящей
стены (#247, z 256..384) вместо нуля; на рампе `-2438 -966 48` диагональ 45°
даёт 456 юнитов без залипаний вместо 329 с двадцатью двумя.

Тесты: `edge_touch_does_not_block_but_step_and_landing_do` (касание ребром не
блокирует, ступень 16 — блокирует, падение на плиту сверху — блокируется) и
картовый `real_map_ledge_seam_does_not_block`.

Две метрики своих же тестов пришлось уточнить, и это записано как ложные
следы: «прошёл насквозь» считалось по переходу центра кирпича (у столбика 8×8
центр тела оказывается за его центром и при честном обходе боком), а
«преграда из воздуха» ловила блокировку вертикальной составляющей — то есть
опору пола, а не стену.

Разбор целиком — docs/PARITY.md E21, дефект A9 закрыт.
2026-10-16 19:48:43 +03:00
5380184dfe Замер коллизий по всей карте: мнимых стен в кирпичах нет
Some checks are pending
CI / checks (push) Waiting to run
Три диагностики, чтобы перестать гадать по жалобам про «невидимые стены».

`probe_ramp_movement` — движение у рампы из точки пользователя
(`-2438 -966 48`): роняет игрока на опору, гоняет восемь сторон и на каждом
кадре сравнивает сдвиг с ожидаемым. Рывков нет, залипания есть, и виновник
найден: кирпич #829 по `y = -1024` с настоящими текстурами бетона и кирпича —
стена видна на кадре из этой точки. Фантомного выталкивания в точке нет.

`probe_phantom_walls_scan` — обход всей карты (11200 точек, 10824 свободных)
с шагом кадра 8 юнитов ищет кирпич, который отсекает шаг, находясь дальше
8 юнитов от тела, и отдельно — ложное «тело внутри, хотя по плоскостям
снаружи». Оба счётчика нулевые. Это объясняет, почему старый обход молчал:
его сетка покрывала только центр (x −1500..600, y −800..600), запад и юг
карты не проверялись вовсе.

`probe_collision_contents` — состав коллизии по данным BSP: 2118 кирпичей из
2203, и служебных среди них нет. 7 кирпичей `func_vehicleclip` (в Source режут
только машины) исключены владельцем, триггеры — тоже; ни TOOLSTRIGGER, ни
клипов в коллизии не осталось. Текстуры кирпичей читаются прямо из
`dbrushside.texinfo`, а не подбором по плоскостям.

Вывод: у «невидимых стен» вокруг рамп и стыков источник не в наборе кирпичей.
Нужен точный адрес места, где это чувствуется: отладочный слой из прошлого
коммита покажет, есть ли у мнимой стены рёбра кирпича вообще.

В docs/PARITY.md A9 дописаны все три замера и остаток вопроса.
2026-10-16 19:04:18 +03:00
86a6cb63f9 Отладочный слой коллизий: видеть, из чего складывается стена
Some checks are pending
CI / checks (push) Waiting to run
Жалобы «невидимая стена», «стою вписанным в параллелепипед вокруг рампы»,
«на стыках комнат стена, которую можно перепрыгнуть, но не запрыгнуть»
невозможно разобрать по картинке: стена невидима по определению. Теперь
коллизию рядом с игроком можно увидеть.

`SOURCE_RS_DEBUG_COLLISION=<радиус>` (по умолчанию 500) включает слой:

- красное — рёбра самих кирпичей, то есть истинная форма по плоскостям
  (рампа выглядит рампой, а не ящиком);
- жёлтое — рёбра габаритов: именно их интервалы берёт `penetration`, поэтому
  расхождение красного с жёлтым и есть «параллелепипед вокруг рампы»;
- голубое — треугольники коллизии рельефа;
- зелёное — тело игрока.

Слой рисуется поверх мира без участия глубины (иначе невидимую стену не
отличить от настоящей) и работает как в живом окне, так и в кадрах пролёта.
Кирипчи строятся из пересечений пар граней, обрезанных остальными
полупространствами: `Brush::vertices_of` разделён на `segments_of`, и рёбра
(core) отдаёт новый `Brush::edges`.

Замеры, сделанные этим слоем и `probe_ramp_movement` (тоже в этом коммите):
в точке игрока `-2438 -966 48` (рампа #1891, 9 граней, 448x128x204 со
склоном) залипания при движении в −Y и по диагоналям объясняются настоящей
кирпичной стеной: кирпич #829 стоит по `y = -1024` и она видна на кадре.
Фантомного выталкивания в этой точке нет. Значит источник «невидимых стен»
в другом месте, и искать его надо обходом с сеткой по всей карте, а не по
центру: старая сетка обхода (x −1500..600, y −800..600) до запада и юга не
доходит вовсе.

В список дефектов (docs/PARITY.md, часть A) добавлены A9..A14: невидимый
параллелепипед у рамп и стыков, перелом света по полигонам (лайтмапа
берётся в вершинах, а не на пиксель), две беды воды (прозрачность слева и
заливка справа, мелкие одинаковые по Y волны), туман и присед без смены
скорости.
2026-10-16 18:52:33 +03:00
02b740957f Лайтмапа читалась со сдвигом строки: «+1» был повторной правкой
Some checks are pending
CI / checks (push) Waiting to run
Жалоба: на траве свет лежит плитами — резкие прямые границы светлого и
тёмного. Рельеф стал виден после E18, и на нём впервые показалась лайтмапа:
прямоугольные светлые пятна со ступенчатым краем плюс белые полосы на бетоне.

Нашлось замером данных, а не картинки. В vrad блоки граней пакуются в лампе
подряд, поэтому зазор между соседними lightofs кратен размеру блока. Новый
тест `real_map_lightmap_blocks_are_packed` меряет его двумя кандидатами:
extent[0]*extent[1]*4 (Face::lightmap_extent уже возвращает extents+1) и
(extent+1)^2*4 (то, что брал sample_light_luxel). На gm_construct из 6890
зазоров точно extent — 6527, точно extent+1 — НОЛЬ. Учтены до четырёх блоков
на грань по числу styles и выравнивание в 4 байта.

Значит единица в lightmap_extent уже была, а «+1» в ширине строки читался
второй раз: чтение уезжало вниз по блоку до ~2 люкселей, а последние строки
вылезали в блок соседней грани — отсюда и световые плиты. В коммите e91a60d
«+1» вносили по замеру подсчёта строк, и тот замер ошибку не поймал; теперь
есть замер по зазорам.

На кадре с высоты 2200 (камера E18) до правки — жёсткие прямоугольные
границы света на траве и белые полосы на бетоне, после — ровное поле.
Замер зон на камере E11 (до/после/эталон GMod): бетон 169,3 → 116,0 при
эталонных 144,0, трава 73,3 → 73,2 при 94,0, небо 207,6 при 204,2,
тоннель 81,1 при 83,1. Правка подвинула только бетон; системный разрыв на
освещённых солнцем участках ~−20% существовал и до неё и записан открытым
вопросом, а не списан на эту правку.

Все замеры в docs/PARITY.md E20, включая ложный след: %[fx:mean*255] в
ImageMagick без имени канала даёт величину другого рода (118,6 против 73,2
на одном кропе) — числа пересчитаны по mean.r/g/b.
2026-10-16 17:49:13 +03:00
440782add0 Коллизия не пропускает в одну сторону — проверено замером на трёх уровнях
Some checks are pending
CI / checks (push) Waiting to run
Жалоба про «рампы и срезы, направленные только в одну сторону»: нашёл, где
можно пройти в одну, обратно нельзя. Проверяю не правдоподобием, а замером —
тремя тестами, бьющими в неё на трёх уровнях.

`real_map_brush_blocks_every_direction` — луч из шести сторон в центр каждого
кирпича: 2118 кирпичей, 0 проходимых, полупространства замкнуты, «дырки» в
наборе плоскостей нет.

`real_map_brush_stops_a_real_step` — тело игрока 32x32x72, шаг 64, 14
направлений (шесть осевых и восемь угловых), реальный `move_aabb`: внутри
габаритов карты 0 проникновений и 0 проходов за центр кирпича. Тело стартует
вплотную снаружи по каждой оси направления — иначе для плоского кирпича в
диагональном заходе оно начинало бы уже внутри, и замер мерил бы
выталкивание, а не шаг.

`real_map_walk_never_clips_through` — настоящий `Player::update` с
гравитацией, скольжением, подшагами, прыжком раз в секунду и приседом на
второй половине прохода: 24 точки x 4 направления x 300 кадров = 28800
кадров, ни одного кадра глубже юнита внутри кирпича и ни одного внутри
рельефа (`tri_penetration` — холмы брашами не покрыты).

Попутный замер: 140 кирпичей лежат за габаритами мира (z~11300 против
z<=2976) — область 3D-скайбокса, и 12 из них проходятся телом насквозь в обе
стороны. Механизм записан в docs/PARITY.md E19.2: кирпич 14x17x23 меньше
тела, раздувание граней перекрывает его целиком, поэтому из 33-38 плоскостей
отсекает одна-две, а после скольжения по наклонной нормали остаток шага смотрит
вдоль кирпича. Согласованность данных ни при чём — нарушение n·центр <= d по
всем плоскостям не больше 1.7 юнита. Такие кирпичи считаются отдельным
счётчиком и проверку не валят: игрок туда не попадает.

Что остаётся непокрытым, записано в E19.1: пропы (их габариты приходят из
клиента) и конкретная точка карты — жалоба воспроизводится только там, где
её покажет игрок.
2026-10-16 12:00:09 +03:00
4d9a8b580f Рельеф не рисовался: намотка displacement'ов была перевёрнута
Some checks failed
CI / checks (push) Has been cancelled
Жалобы «дисплейсменты вывернуты» и «сквозь них видно только скайбокс»
оказались одной механикой. Меш 3D-скайбокса рисуется без записи глубины и
без отсечения, поэтому всегда заполнял то, что мир не нарисовал, и маскировал
пропуск: вместо травяных холмов gm_construct был виден чужой ландшафт
скайбокса, а из-под карты изнанка рельефа читалась как «трава сверху».

Замер: геометрия рельефа в буфере здоровая (0 вырожденных, 0 нефинитных,
габариты — земля карты), батч выдаётся, но `--only-material 36` даёт пусто и
только `SOURCE_RS_NO_CULL=1` возвращает `#93ff26` на экран. Замер верхней
поверхности над точкой: пол под спавном (z=-144) намотан с кросс-произведением
вниз, трава (z=-148) — вверх. Видимая сверху поверхность — та, у которой
кросс-произведение смотрит внутрь тела; браш-грани приводятся к этому в
`face_winding`, а `build_displacement` подгонял намотку вдоль нормали
плоскости, то есть наоборот.

Порядок индексов в `emit` перевёрнут; нормали вершин оставлены наружу, чтобы
динамический свет не поехал. Коллизия не задета — `add_displacement_tris`
строит нормали по рёбрам, порядок вершин для неё неважен.

Новая диагностика: переключатель `SOURCE_RS_NO_SKY_MESH` (гасит только меш
скайбокса), лог «топ-материал по объёму геометрии» и предупреждение о
батчах с индексом вне списка материалов — рендер пропускает такие молча.
Отмечу: в тестах мир строится с пустым VirtualFs, из-за чего 189 материалов
против 54 в рантайме и индексы расходятся, поэтому диагностика печатается из
сборки мира.

Тесты: real_map_top_surface_orientation (замер), real_map_displacement_is_visible_from_above
(держит правку). Все 22 теста с картой зелёные.
2026-10-16 11:12:48 +03:00
a6441ac9ad Браш-сущности в своём origin: рендер и коллизия
Some checks are pending
CI / checks (push) Waiting to run
`LUMP_FACES` хранит полигоны всех моделей карты подряд, а не только
статического мира: модель 0 — worldspawn (грани 0..9363), модели 1..12 —
`func_brush`/`func_vehicleclip`/`func_illusionary` с геометрией в локальных
координатах вокруг начала координат. Мы рисовали весь ламп подряд, и в
центре карты вырастали четыре продублированные плиты 2480 × 1936 × 8 плюс
три плиты, ложащиеся строго на оси — то, что на глаз читалось как «оси
координат 3д».

* `BrushModels` (вместо мёртвого `NavHulls`): диапазоны граней из
  `LUMP_MODELS`, привязка модели к сущности по `model "*k"`, сдвиг вершины
  на `origin`. Клипы, порталы и `StartDisabled` не рисуются.
* Коллизия делала ту же ошибку: кирпичи 2184/2185 (оба `CONTENTS_SOLID`,
  габарит `(-1240,-968,-4)..(1240,968,4)`) стояли в (0,0,0). Привязка
  «кирпич → модель» сделана обходом поддерева `dmodel_t.headnode` по
  `LUMP_LEAFBRUSHES`; плоскость пересчитывается как `d + n·origin`.
  `func_illusionary` и `func_vehicleclip` коллизии не дают — иначе это
  невидимые стены.
* `LUMP_LEAFBRUSHES` добавлен в `Geometry`; `dmodel_t` по смещению 36
  подтверждён как корень поддерева (мир — 2 168 кирпичей, модели — по 1..6
  с габаритами, совпадающими с габаритами моделей; 14 кирпичей не
  принадлежат ни одной модели и остаются в мире).

Замеры из ответа Gemini подтверждены: `LUMP_FACES` не сжат (`529 760 =
56 × 9460`), раскладка `dface_t` совпадает байт-в-байт, `startPosition` у
всех 110 displacement'ов — угол грани (худшее 0.053 юнита), `power` и
`DispVertStart` сходятся. Куда деваются 21 displacement-грань из 110 — никуда:
счётчики отсева дают `110 = 89 (мир) + 21 (3D-скайбокс)`.

Тесты: `real_map_brush_models_keep_their_origin`,
`real_map_brush_model_collision_follows_origin`,
`real_map_displacement_coverage`, `real_map_displacement_start_position`.
Документация: docs/PARITY.md E16–E17, GEMINI.md часть 2 (вопросы 5–7).
2026-10-16 09:34:49 +03:00
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
5f1fdcf5e2 Диагностика белых граней в центре карты: гипотеза про навмеш не подтвердилась
Жалоба: «в 0 0 видны белые плиты, которых нет в оригинале, причём у них
коллизия есть».

Измерено: материал GM_CONSTRUCT/COLOR_ROOM с текстурой color/white, ровно
9 граней, габарит (-1240,-968,-204)..(1240,968,204) — ящик 2480x1936x408 в
центре карты, точка спавна игрока внутри. surf flags 0x0, то есть SURF_NODRAW
не выставлен; стили [0,255,255,255] — свет не запечён.

Гипотеза «это сетка навигации» не подтвердилась: у info_node в лампе сущностей
нет ключей brushes и brushlist, и ни одна сущность на карте brushlist не имеет.
LUMP_NODES — это дерево BSP, не навмеш. Ни одна белая грань не привязалась к
кирпичу по planenum через LUMP_BRUSHSIDES.

Попутно выяснилось, что кирпичи и грани не параллельны: 2203 кирпича, 14946
сторон, 9460 граней. Отсев «границы сущности -> номера граней» не тривиален.

Добавлена заготовка NavHulls с честной пометкой, что отбор пуст и ничего не
отсекает. Подробности и следующие шаги в PARITY.md E14.
2026-10-15 11:08:16 +03:00
a1b0c03a62 Экспонента лайтмапы возвращена: тени в запечённом свете появились
Some checks are pending
CI / checks (push) Waiting to run
Ошибка была в моём же исправлении из E11. Экспоненту из четвёртого байта там
отбросили, потому что у большинства люкселей байт равен 255, что как i8 даёт
-1 и масштаб 0,5, и картинка выходила вдвое темнее. Взамен подобрали плоский
коэффициент 3,0, и по освещённым поверхностям совпало.

Но затенённые участки выходили втрое светлее эталона, и это было записано как
«не найдено». Причина: VRAD нормализует мантиссу так, что её максимум лежит в
верхней половине байта, а порядок яркости пишет в экспоненту. Отбросив её, мы
сравняли тени с солнцем.

Масштаб подобран так, чтобы освещённые участки остались: 2^(-1) * 4,47 = 2,24.
Тень с экспонентой -3 получает 2^(-3) * 4,47 = 0,56, вчетверо темнее солнца.

Та же камера, что в E11:
  бетон в солнце  GMod 143,6 -> было 147,0, стало 143,7
  тоннель         GMod  92,9 -> было 165,8, стало  86,8
  стена в тени    GMod  36,6 -> было 124,6, стало  70,8
  небо            GMod 199,7 -> 196,9 без изменений

Тоннель — то место, где затенение и должно быть сильным: 165,8 -> 86,8 при
эталонных 92,9. Это и есть появившиеся тени.

Проверено, что динамическое солнце эти участки не освещает: при
SUN_BRIGHTNESS_SCALE 900 и 2500 значения совпали до десятых.

Вода: ряд длин 46/23/11/5,5/2,7 был кратен ×0,5, из-за чего периоды фаз всех
пяти волн синхронизировались и рябь выстраивалась в правильную решётку — на
кадре это читалось как гофрированный металл. Заменено на шесть волн с
несоизмеримыми длинами 340/210/125/73/41/19.

Тест lightmap_ignores_exponent_byte переименован в
lightmap_exponent_byte_makes_shadow_darker и проверяет, что разница экспонент
на две ступени даёт вчетверо.
2026-10-15 10:45:49 +03:00
983019faa7 Тени от солнца и солнце в небе
Теней в движке не было вообще — это и было причиной того, что плоская трава
читалась как «вывернутая», хотя намотка рельефа верна: плоскости 110 граней
смотрят вверх, развёрнуто 0 из 11 776 треугольников. Проверка переворотом
подтвердила: при инверсии становится вверх 8 008 / вниз 10 353 и разворачиваются
все треугольники, то есть явно хуже.

Добавлена карта теней 2048x2048 (Depth32Float), ортографическая проекция на
область радиусом 2 600 юнитов вокруг камеры по направлению на солнце. Отдельный
пасс глубины со стороны источника; вода в него не идёт — она полупрозрачна.
В шейдере луч сравнивается с картой четырьмя сравнениями по соседним текселям
и сглаживается smoothstep, иначе край тени идёт лестницей. Смещение по
нормали на 3 юнита убирает полосу самозатенения.

Солнце в небе: направление пишется в uniform камеры шестым vec4, небо рисует
ядро 2,5 градуса и ореол до 12.

Сверка с GMod на той же камере, что в E11:
  бетон 143,6 -> 147,0 (было 87,8)
  трава 127,1 -> 129,0 (было 77,3)
  небо 199,7 -> 196,9

Расходится стена в тени: GMod 36,4, у нас 124,6. Основной свет даёт запечённая
лайтмапа, а динамическое солнце при SUN_BRIGHTNESS_SCALE = 900 вносит малую
долю, поэтому затенение этой стены у нас не гаснет. Причина не найдена,
записана в PARITY.md E12.5.

Заодно добавлен Mat4::ortho в math.
2026-10-15 09:38:35 +03:00
30be24e38e Вода: затухание по волновому числу и отбраковка по плоской поверхности
Затухание было 1/(1 + d²·0.00012) — на 900 юнитах это 0,01, и озеро с высоты
читалось зеркальной плитой. Алиасинг бьёт только по мелким волнам: дальше
пикселя. Заменено на затухание по волновому числу каждой волны отдельно,
local = 1/(1 + (k·d·0.01)²). Волна длиной 46 юнитов на 900 юнитах ещё видна
(0,4), длиной 2,7 — уже нет (0,002).

discard считался по волновой нормали: на скользящих углах dot(n, view)
проваливался в ноль и поверхность выбивалась дырками, край озера рвался
зубцом. Теперь facing берётся по плоской поверхности — это вопрос геометрии,
а волновая нормаль идёт только в френель и блик.
2026-10-15 08:57:45 +03:00
593282f661 Освещение сходится с GMod: экспонента лайтмапы читалась вдвое темнее
Сверено с настоящим GMod на одной камере: точка (-399.5, -1604.5, 67.9),
yaw 146.33, pitch 3.48, fov 100. Наш кадр снят в той же точке, ракурс совпал.

Первое сравнение: небо сошлось (0,97-1,00), а поверхности были темнее вдвое.
Причина — четвёртый байт люкселя читался как i8 и давал масштаб 2^exp; у
большинства люкселей он равен 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
Один коэффициент попал в эталон на двух материалах, значит это найденная
величина, а не подгонка.

Заодно:
- водa: сумма герстнеровских волн вместо ровной синусоиды — острые гребни и
  широкие ложбины; референсы дают прямое указание «Gerstner, not sine»;
- плавание: вход подавался как направление (-1..1), а не скорость, поэтому
  горизонтальная скорость была 0,8 юнита в секунду вместо 200;
- ноклип: скорость при отпускании клавиш не обнулялась — это инерция; теперь
  обнуляется, а пробел такой же ввод, как «вперёд»;
- добавлен --fov, по умолчанию 100 как в GMod.

Тесты на плавание и на инерцию в ноклипе добавлены.
2026-10-15 08:14:46 +03:00
0893cf12fe Лайтмапа читалась в перепутанных каналах: картинка уходила в синеву
Нашли сравнением с настоящим GMod — запустили клиент через Steam и сняли ту
же точку карты. Сравнение по зонам: небо совпадает (178,7 против 175,5), а
земля расходится (60,3 против 167,5). Канал вывода и гамма верны, расходится
геометрия.

Расходился не только уровень, но и тон: GMod (1.000, 0.973, 0.866) — тёплый
солнечный, наш (0.756, 0.960, 1.000) — холодный синий.

Люксель лайтмапы лежит в блоке как B, G, R, экспонента, а читался как R, G, B.
Сырые байты на gm_construct — [141, 208, 251, 255]: как RGB — холодный синий,
как BGR — тёплый солнечный.

Подтверждение независимое: цвет солнца из light_environment на этой же карте
тёплый, [1.046, 1.011, 0.942]. Запечённая лайтмапа обязана быть того же тона.
_ambient при этом 114 136 148 (B > G > R) — синева, и это верно: небесная
добавка холодная.

После обмена R и B тон стал (1.000, 0.960, 0.756) — совпал с GMod.

Яркость после обмена не изменилась: разрыв в 7,8 раза по линейному
пространству остаётся. Причина, судя по коду, в том, что солнце прибавляется к
лайтмапе, уже содержащей запечённое солнце, плюс домножается ambient, плюс
SUN_BRIGHTNESS_SCALE = 900. Не починено и записано в PARITY.md E10.3.

Тест light_sample_missing_is_white поправлен под новый порядок каналов.
2026-10-14 13:37:46 +03:00