gmod-rs/docs/DIARY.md
Niko Marmeladkov 1713c06fc1
Some checks failed
CI / checks (push) Has been cancelled
UI: TTF-атлас вместо bitmap, крупные подписи, больше пропов, immersive
- Текст HUD и меню теперь из атласа Liberation Sans (SIL OFL) 30 px:
  ячейки 40×40, bbox-метрики в font_metrics.rs, глифы сглажены и
  пропорциональны. Advance остался 8 px, поэтому вёрстка Lua не менялась;
  bitmap 8×8 («не Derma») удалён, font8x8 из зависимостей убран.
- Подписи сенсорных кнопок масштабируются от размера кнопки (до 3.2×),
  а не фиксированные 10 px.
- fs pack: +20 стандартных пропов (канистры, взрывная бочка, бетонный блок,
  забор, скамья, колонна, дверь, стеллаж, комоды, шкаф, ванна, матрас,
  столы/ящики №2, дверное колесо, кнехт). В APK 39 моделей, в спавнлисте
  45 записей, иконки без заглушек.
- Android: hideSystemBars() прячет статус-бар и навигацию (API 30+ через
  WindowInsetsController, старые — SYSTEM_UI_FLAG_*), повторно на
  onWindowFocusChanged. ASSET_VERSION=4.
- Проверки: clippy -D warnings, cargo test --workspace — зелёные.
2026-10-28 17:13:15 +03:00

81 KiB
Raw Permalink Blame History

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

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

Обновлён: 2026-10-25, утро.


0.16. Мобильное управление и настоящий Q-меню в APK (2026-10-26)

  • Почему на телефоне было «непонятное меню». В APK лежали только 35 Lua-файлов (lua/autorun), без gamemodes/ — Sandbox-меню собрать было не из чего, и клиент показывал резервное меню. В android/assets добавлены полные lua/ (277 файлов), gamemodes/ (320), settings/ и resource/language; ASSET_VERSION в MainActivity поднят до 3, чтобы при обновлении APK старые распакованные ассеты перезаписались.
  • Спавнлист под мобильный набор. В APK теперь 21 модель: пропы карты, оружие, физган и 13 частых пропов (oildrum001, woodbarrel001, bluebarrel001, wood_crate001a/002a, furniturechair001a, furnituretable001a, furnituredrawer001a, consolebox01a, furniturebed001a, cactus, metal_plate1). fs pack расширен списком EXTRA_MODELS (записывает модели и их материалы), а settings/spawnlist_default отфильтрован по существующим моделям: 31 запись в 7 категориях вместо 7473 битых иконок.
  • Сенсорные кнопки. Полноценный слой: Q (открыть/закрыть настоящий Sandbox), NOCLIP, LMB, RMB, JUMP, DUCK; полупрозрачные рамки с подписями, стик с подсказкой базы. LMB/RMB превращаются в настоящие MouseButtonDown/Up (тот же код физгана/оружия), noclip — в переключение player.set_noclip. Кнопка закрытия Q (X) рисуется поверх меню, остальные под меню скрыты. В меню свайп вверх/вниз листает панели (колесо).
  • Lua-шим. list.Get для неизвестного списка возвращает пустую таблицу (падал Init SpawnmenuNPCSidebarToolbox: table index is nil); добавлены panel:Call/SetHTML/AddFunction/QueueJavascript (таймеры Dupes/Saves больше не сыпят ошибками).
  • Шрифт и вид меню. Прежний bitmap 8×8 выглядел «не Derma». Текст теперь рисуется из атласа TTF: assets/font_atlas.r8 (640×320, 16×8 ячеек по 40 px, Liberation Sans 30 px, SIL OFL), метрики bbox в render/src/font_metrics.rs. Квады глифов масштабируются по 8-пиксельной строке, поэтому вся вёрстка Lua (advance 8 px) не менялась, а буквы стали сглаженными и пропорциональными. Прямоугольники по-прежнему используют служебный сплошной глиф.
  • Кнопки. Подписи масштабируются от высоты кнопки (0.42·h/8, ограничение по ширине), а не фиксированные 10 px — на телефоне читаются.
  • Android. MainActivity.hideSystemBars(): на API 30+ прячет статус-бар и навигацию через WindowInsetsController, на старых — SYSTEM_UI_FLAG_*; повторно вызывается на onWindowFocusChanged. Кадры /tmp/opencode/font_qmenu.png, /tmp/opencode/font_touch.png.
  • Ещё пропы. В EXTRA_MODELS добавлены канистры, взрывная бочка, бетонный блок, забор, скамья, колонна, дверь, стеллаж, комод, шкаф, ванна, матрас, стол/ящик №2, колесо двери и кнехт — ассет-пак и спавнлист пересобираются тем же fs pack.
  • Проверки. Кадры /tmp/opencode/touch_ui.png (кнопки) и /tmp/opencode/mobile_qmenu2.png (меню из APK-ассетов); юнит-тесты раскладки кнопок и noclip.

0.15. Физган: чистая пружина, угол от камеры, модель; игрок толкает пропы (2026-10-25)

  • Пружина без feed-forward. Скорость цели больше не прибавляется к пружине: она складывалась с реакцией на ту же ошибку (двойной учёт) и на круговых движениях мышью давала перелёт. Осталось v = ошибка / timeToArrive с ограничением maxSpeed; при 1200 юнитов/с проп отстаёт на v*0.05 = 60 юнитов — как в GMod. Перелёта нет по построению: за шаг проходится frac ≤ 1 ошибки. Тест circular_target_does_not_overshoot.
  • Угол захвата. Grab хранит не только точку (local_offset), но и ориентацию пропа относительно камеры (local_quat, кватернион камера⁻¹ * проп). Каждый кадр цель = камера * local_quat: при повороте взгляда проп поворачивается вместе с ним, сохраняя угол, под которым его взяли. E домножает кватернион слева (доворот в осях камеры). Тест grab_keeps_angle_relative_to_camera: камера 90°→135° даёт проп 10°→55°, E +20° при камере 90° даёт 30°.
  • Модель физгана. Вьюмодель — c_superphyscannon.mdl с анимацией idle (bind-поза разваливается). «Телефото» (distance 80) оказалось хуже: разброс глубины модели при scale 2.12 делал ближние части огромными, а сама модель всё равно стояла в мире и уходила в пол. Теперь у рендерера есть отдельный конвейер вьюмодели (depth_compare: Always, без записи глубины): батчи вьюмодели исключаются из мира/теней и рисуются последними поверх кадра. Посадка снова близкая: distance 34, scale 0.7, right 20, up −15 — ружьё не режется полом и стенами. Кадр /tmp/opencode/pg_depthless.png.
  • Игрок ↔ пропы. В физике появилась кинематическая капсула игрока (add_character, высота 72: capsule_z(20, 16), центр = ступни + 36), она едет за игроком через set_next_kinematic_translation. Пропы больше не проходят сквозь игрока, в том числе брошенные. Луч физгана исключает Character-тела (иначе начинался внутри своей капсулы и не видел цели). Игрок толкает пропы: sandbox::push_props получает желаемое направление из ввода (фактическая скорость обнуляется контроллером, упёршимся в проп, — иначе толкание не работало), проверяет пересечение AABB (а не круг по максимальному габариту — от него игрок казался «жирным») и задаёт скорость с sqrt(10/mass): лёгкий ящик отлетает, тяжёлый еле едет. Замороженные и удерживаемые физганом не толкаются; в noclip капсула уводится под карту, толкание не вызывается вовсе.
  • Lua. DTextEntry:IsMultiline/GetMultiline/GetEnterAllowed/SetEnterAllowed и заглушки panel_meta — исправлен OnKeyCodeTyped: attempt to call method 'IsMultiline'; OpenURL больше не роняет таймеры меню.
  • Проверки. cargo clippy -D warnings, cargo test --workspace зелёные; новые тесты: капсула не пропускает проп (prop_does_not_pass_through_player_capsule), луч не цепляет свою капсулу (raycast_ignores_player_capsule), угловая ориентация, круговое движение.

0.13. Физган: почему отставал, управление как в GMod, модель, CCD (2026-10-24)

  • Корень отставания пропа найден замером, а не пружиной: в rapier 0.36 integration_parameters.normalized_max_linear_velocity по умолчанию 400, и любая заданная скорость выше обрезалась на шаге (1200 → 400, чистое тело без пола/трения/гравитации). Поднято до 100 000 в PhysicsWorld::new; тест-страж body_with_set_velocity_actually_moves (было 400, стало 1200). Телепорт-костыль откачен.
  • Демпфирование GMod (вики ConVars_In_Garrysmod): physgun_DampingFactor не указан (0), но physgun_maxSpeedDamping и physgun_maxAngularDamping = 10000 — ограничение изменения скорости, а не самой скорости. Реализовано damp_towards: 166 юнитов/с за кадр 1/60, до 5000 тело разгоняется за 0.5 с. Демпфирование поверхности у пропов по scripts/surfaceproperties.txt — 0.0.
  • Управление: колесо теперь тянет/толкает вдоль луча (dist, шаг 16, минимум 24), а не вращает; вращение — зажатый E, мышь крутит проп, камера стоит. Держится полная ориентация (pitch/yaw/roll), угловая скорость — по кватерниону кратчайшей дуги (angular_velocity_towards), а не только yaw.
  • Undo как в оригинале: Ctrl+Z отменяет только спавн. Перемещение и заморозка в историю не пишутся; SandboxAction::Move/Freeze удалены.
  • Модель физгана: scripts/weapons/weapon_physgun.txt → models/weapons/c_superphyscannon.mdl (не v_physgun, которого в ассетах нет). Анимация idle обязательна: bind-поза модели разваливается, поэтому refresh_animations ищет idle запасным вариантом. Посадка: right 25, up −25, dist 34, scale 0.9, без доворота (ствол +X). Кадр /tmp/opencode/pg_anim_0.png — пушка в правом нижнем углу, ствол вперёд.
  • Коллизия: пропы получили CCD (ccd_enabled(true)), max_ccd_substeps 4, num_solver_iterations 8. Тест fast_prop_does_not_tunnel_through_floor (5000 юнитов/с в пол — не пролетает).
  • Проверка: 40 клиентских + 122 движковых теста, real_model_meshes_are_coherent расширен v_superphyscannon/c_superphyscannon (0 вырожденных, рёбра в габарите).

0.12. Физган: дефолты GMod и захват за точку меша (2026-10-24, ветка feat/physgun)

Жалобы: проп «разгонялся квадратично» и брался за origin, а не за точку под прицелом. Разобрано по спецификации GMod/Source, реализация своя.

  • Дефолты GMod (wiki.facepunch.com/gmod/ConVars_In_Garrysmod, physgun_*): physgun_maxrange 4096, physgun_timeToArrive 0.05, physgun_maxSpeed 5000, physgun_maxAngular 5000, physgun_maxSpeedDamping 10000, physgun_maxAngularDamping 10000, physgun_wheelspeed 10, physgun_rotation_sensitivity 0.05, physgun_teleportDistance 0, physgun_DampingFactor 0. Отдельного конвара массы у физгана GMod нет; 500 — верхняя граница из физики Source (как у гравипушки) и прежнего поведения клиента.
  • Пружина. Было экспоненциальное приближение с k=12 и пределом 900, стало v = clamp(error / timeToArrive, maxSpeed) при timeToArrive=0.05: скорость строго линейна по рассогласованию (20→400, 50→1000, 100→2000 юнитов/с) и упирается в 5000. Длинный кадр не проскакивает цель: приближение за шаг не больше 100% ошибки. Гравитация на удержании выключена, тело сталкивается с миром. Отдельного демпфирования нет: скорость задаётся напрямую (в GMod physgun_DampingFactor по умолчанию 0).
  • Захват за точку меша. PhysicsWorld::raycast трассирует луч по коллайдерам и возвращает тело + точку попадания; статика карты даёт окклюзию. Grab хранит local_offset = inverse_rotate(angles) * (hit - origin). Цель удержания — точка на луче на расстоянии захвата, а желаемый центр тела считается как target - rotate(angles, local_offset). Туда же смещены крест и конец луча. При довороте колесом линейная скорость компенсирует ω × r, поэтому проп вращается вокруг захваченного угла.
  • Колесо/freeze. Шаг колеса 15° (physgun_wheelspeed), угловая скорость ограничена 5000°/с, ПКМ по-прежнему фиксирует позу и замораживает тело.
  • Проверки. Юнит-тесты: закон скорости и кламп (spring_speed_is_linear_then_clamped), длинный кадр (spring_step_never_overshoots_on_long_frame), локальная точка при повороте (grab_point_stays_on_target_when_body_rotates), raycast по коллайдеру и окклюзия статикой в engine::physics. Кадры Xvfb: до — луч в центр дивана (physgun_couch_before.png), после — луч и крест на правом краю, диван довёрнут вокруг этой точки (physgun_couch.png); SOURCE_RS_TEST_PHYSGUN=1 на 40-м кадре по-прежнему замораживает проп.

0.12. Вьюмодель: посадка по модели и встроенный пистолет (2026-10-24, ветка feat/viewmodel)

Пистолет в реестре. weapon_pistol в HL2 — движковое оружие, поэтому в lua/weapons ассетов его нет, и слот 1 был занят только флечетганом. В glua добавлен встроенный SWEP (builtin_weapon_pistol.lua): Slot=1, SlotPos=1, Spawnable, PrintName="Pistol", магазин 18, одиночный огонь, PrimaryAttack уменьшает магазин и зовёт заглушки EmitSound/SendWeaponAnim. Регистрация идёт через общий с файловыми скриптами путь load_weapon_source, поэтому оружие видно и в list.Get("Weapon"), и в weapons.Get. Если пользователь положил свой lua/weapons/weapon_pistol.lua, встроенный не загружается (правило «оригинал важнее»). ViewModel — w_pistol.mdl: у v_pistol в текущем загрузчике второй меш (руки) висит отдельно от оружия; посадка для него всё равно описана в таблице.

Посадка. Общие константы (дистанция 26, view_yaw=-90) подходили только w_pistol: у v_pistol/c_smg1 ствол вдоль +X, а не +Y. В client/src/viewmodel.rs появилась таблица model → {right, up, distance, scale, angles}; значения подобраны кадрами:

модель right up distance scale yaw
w_pistol 14 −9 20 1.0 −90°
v_pistol 8 −10 34 0.8 0°
c_smg1 14 −10 26 1.0 0°

Масштаб поддержан в движке: ModelInstance.scale, set_model_instance_scale, play_model_animation_oriented/move_model_instance_oriented (полный pitch/yaw/roll в координатах модели). Отладочный лог SOURCE_RS_VIEW_DEBUG печатает габарит уже скинненной позы в осях камеры и число вершин ближе near-плоскости; для всех трёх моделей ближе_near=0.

Поля SWEP. ViewModelFOV пересчитывается в множитель масштаба (опорный 54°), ViewModelOffset (Source-вектор: X вперёд, Y влево, Z вверх), ViewModelAngles и ViewModelScale перекрывают таблицу. Для этого weapon_field научился читать поля Vector/Angle (userdata), а не только таблицы.

Кулаки. c_arms.mdl по-прежнему без геометрии: spawn_model_instance возвращает ошибку, вьюмодель скрывается. Переход «кулаки → пистолет» пересоздаёт экземпляр (проверено кадром).

Проверка (Xvfb :99, --hidden, 1280×720). Кадры: /tmp/opencode/vm2/final_pistol.png, final_flech.png, final_pistol_attack.png (17/18 после выстрела), switch_flech_to_pistol.png, switch_fists_to_pistol.png, switch_pistol_to_fists.png, vpistol_d34.png. На Xvfb xdotool не доставляет клавиши в SDL-окно, поэтому для проверки слота добавлен SOURCE_RS_TEST_SLOT — синтетическое KeyDown того же кода, что и настоящая клавиша (на кадре SOURCE_RS_TEST_SLOT_FRAME, по умолчанию 30). Юнит-тест slot_keys_match_gmod проверяет обратное соответствие клавиш 0–9.

Осталось: отдельный проход вьюмодели с настоящим ViewModelFOV (сейчас компенсация масштабом — приближение); разобраться с оторванными руками v_pistol (после этого можно вернуть его во встроенный пистолет); тёмная текстура c_smg1 (ориентация и посадка уже верные).

0.14. Q-меню: hold/pin, кэш иконок и размер окна (2026-10-24, ветка feat/qmenu2)

  • Белые квадраты. Снимок и image-команды были целы; видимая сетка просто не успевала заполниться: бюджет загрузки был 8 мс на кадр, а одна иконка (MDL+текстуры+offscreen) занимает десятки мс. За 90 кадров грузилось ~60 иконок, остальные оставались заглушками. Бюджет поднят до 40 мс/12 штук за кадр; на 1280×720 и 1600×900 к 90-му кадру placeholders=0 (images = 88/104), при этом failed=0.
  • Кэш. MAX_ICONS 512 → 1024, ключ — нормализованный путь модели, живёт весь сеанс. Добавлены счётчики IconStats (cached/rendered/reused/evicted/ failed) и логи при открытии/закрытии/выходе. Проверка xdotool: первое открытие rendered=88, повторное открытие после закрытия — rendered_since_open=0, reused растёт.
  • Размер меню. Причина: прогрев ui_toggle(true) до создания окна задавал корню 1280×720, а ui_set_screen(1600,900) размер не обновлял (Rust звал несуществующий __sr_resize_to_screen). Теперь ui.set_screen и ui.sync_root_size растягивают настоящий g_SpawnMenu на ScrW×ScrH при открытии, resize и раскладке. Кадр 1600×900 — меню на весь экран с полями, как SpawnMenu с Dock(FILL).
  • Скорость. Убраны player.velocity = ZERO и sprint = false при открытии/в меню. Игрок заморожен (update не вызывается), но импульс цел: лог speed=250.45 до открытия и после закрытия, позиция в меню не меняется (pos 370 132 → 370 132 → движение после Q), noclip тоже без обнуления. Bob/анимация в меню не проигрываются.
  • Hold/pin. Q down открывает (q_hold), Q up закрывает только без взаимодействий. Клик/колесо/касание/ввод ставят pinned; тогда Q down закрывает (toggle), а Q up ничего не делает. Esc закрывает всегда. В фокусе DTextEntry Q печатается: клиент проверяет ui.text_focus(), SDL3 TextInput включается явно; OnTextEntryGetFocus теперь получает панель (исправлена передача имени хука), поэтому StartKeyFocus работает.
  • Проверки: cargo fmt, cargo clippy --workspace --all-targets -D warnings, cargo test --workspace (28 сьютов) — зелёные. Кадры: /tmp/qmenu2-final-1280f.png, /tmp/qmenu2-final-1600e.png; xdotool: hold open/close, клик+keyup оставляет меню, ввод q не закрывает, повторный Q закрывает, скорость сохраняется.

0.11. Модели: локальные индексы вершин и пол под пропами (2026-10-24, ветка feat/models)

  • Причина «взрывов» мешей: origMeshVertID в VTX — локальный индекс внутри меша, а загрузчик читал его как глобальный индекс VVD. У v_pistol два меша (оружие + руки), второй брал вершины первого: рёбра растягивались с 4.4 до 27.4 юнита, появлялись вырожденные треугольники. У c_smg1 и многоматериальных пропов — то же. Второй слой: mstudiomodel_t.vertex_index (байты, /48) задаёт начало вершин модели в VVD для разных бодипартов.
  • Разбор MDL расширен: mstudiomodel_t (172 байта) и mstudiomesh_t (116 байт), Mdl::body_part_models отдаёт material, vertex_offset, num_vertices; материал меша теперь берётся из MDL, а не из списка замен VTX (тот относится к скинам; у пропов gm_construct пуст).
  • Замеры v_pistol mesh1: было max edge 27.39 / 22 вырожденных из 816, стало 4.43 / 0. c_smg1 mesh1: 21.51 / 19 → 14.00 / 0. По props_c17: 23 многомешевых модели, у 8 старый код растягивал детали (fence01a: 156 → 4.77 юнита).
  • Физика: при создании динамического пропа низ AABB/оболочки поднимается до статической опоры (проникновение + 0.05), чтобы Rapier не выталкивал тело из пола первые кадры. Тест real_map_props_rest_on_ground теперь меряет проникновение лучом из центра с исключением собственного тела.
  • Страж: real_model_meshes_are_coherent проверяет вырожденные треугольники, максимальное ребро относительно габарита меша VVD и попадание вершин в диапазон своего меша (v_pistol, c_smg1, w_pistol, игрок, 4 пропа).

0.11. Sandbox: физган, freeze и undo (2026-10-24, ветка feat/sandbox)

Физган стал отдельным инструментом Sandbox, а не «побочным эффектом ЛКМ».

  • Выбор: F включает/выключает физган. Если SWEP не выбран, физган активен сразу (прежнее поведение). Слоты 0-9 по-прежнему выбирают SWEP и выключают физган. SOURCE_RS_PHYSGUN=1 включает принудительно и держит ближайший проп без мыши.
  • ЛКМ: захват ближайшего динамического пропа по лучу (дальность 250, допуск 40 по перпендикуляру, масса до 500, игрок и статика не берутся). Удержание — скоростью: экспоненциальное приближение к точке на луче, гравитация на время захвата выключена. Это не телепорт: проп сталкивается с миром и плавно догоняет цель.
  • Колесо: доворачивает target_yaw; тело догоняет угол угловой скоростью (до 720°/с), а не мгновенной позой.
  • ПКМ: заморозка. Удерживаемый проп фиксируется и отпускается; свободный под прицелом переключается fixed ↔ dynamic (аналог PhysgunFreeze). Замороженные пропы помечены жёлтым крестом, чтобы freeze был виден кадром.
  • Визуал: пучок из пяти линий от смещённого «дула» (справа-снизу, как вьюмодель) к пропу + крест в точке захвата. Одиночная линия из центра глаз при взгляде прямо на проп проецируется в точку и не видна.
  • Undo: стек на 32 действия, Z — откат, Shift+Z — повтор. Откатывает: спавн (удаляет тело/сущность и уводит меш под карту), перемещение/поворот физганом (возвращает позу), заморозку (возвращает состояние). Новое действие обнуляет redo.
  • Грабли удаления: вырезать вершины из общих буферов дорого и ломает индексы батчей, поэтому World::remove_prop прячет геометрию на z=-100000, а restore_prop возвращает её и пересоздаёт сущность/тело. Визуально проп исчезает, но память под его вершины остаётся — осознанный компромисс до общей системы удаления сущностей.
  • Бинды/HUD: нижняя подсказка теперь в две строки: WASD/Space/Ctrl/V/F/ЛКМ/ПКМ/колесо и G spawn | Q menu | Z undo | Shift+Z redo | 0-9 weapon | \ log. В строке режима — physgun [F] HOLD <дистанция> undo /`.
  • Проверки: SOURCE_RS_TEST_PHYSGUN=1 (спавн арбуза, авто-захват, поворот +25°, freeze на 40-м кадре) и SOURCE_RS_TEST_UNDO=1 (alive 10 → 9), SOURCE_RS_TEST_REDO=1 (9 → 10). На 35-м кадре виден пучок луча и крест на арбузе; на 60-м — жёлтый маркер freeze и speed=0. Клавиши проверены xdotool: G спавнит, Z откатывает (depth=0), Shift+Z возвращает (depth=1), F дважды пересоздаёт вьюмодель без падения.
  • Осталось: звук и эффекты физгана, физические силы/инерция как в GMod (сейчас P-регулятор скорости), серверный PhysgunFreeze, подсветка цели силуэтом, v_physgun.mdl в ассетах нет — вьюмодель откатывается на пистолет.

0.11. Q-меню: отзывчивость, слайдеры, колесо и выравнивание (2026-10-24, ветка feat/menu)

Жалобы пользователя: лаги Q-меню на release, не работают слайдеры, колесо не листает списки/дерево, текст «пляшет», часть иконок — заглушки.

  • Профиль до. SOURCE_RS_UI_PROFILE=1: снимок Lua пересобирался раз в 6 кадров, avg 6,4/4,1 мс, max 84/47 мс; 1445 команд; отрисовка 0,17 мс; FPS с меню 16,8–17,3 против 20,7–20,9 без меню (Xvfb, замеры шли параллельно и мешали друг другу).
  • Причина. Дорогой обход всего дерева и DTileLayout по тысячам иконок каждые 6 кадров; emit создавал команды для полностью обрезанных панелей; sort_children сортировал всех детей сетки; hover на каждом движении мыши звал OnCursorEntered → InvalidateLayout; снимок не знал, изменилось ли что-то на самом деле.
  • Снимок по dirty-флагу. ui.mark_dirty(layout) зовут сеттеры, ввод и таймеры; Rust каждый кадр делает ui_tick() (таймеры) и ui_dirty(), а ui_snapshot_into — только при изменении. layout_dirty отделён от отрисовки: hover не пересчитывает геометрию, колесо/перетаскивание — да. paint_panel не заходит в поддеревья вне clip, emit отбрасывает полностью невидимые команды, sort_children не сортирует панели без ZPos.
  • Профиль после. В покое снимок avg 0,26–0,5 мс, max 18–27 мс (редкая страховочная пересборка), 677 команд; отрисовка 0,13–0,17 мс. FPS с меню 18,9/20,5, без меню 17,6/16,9 — разница в пределах разброса. Первый кадр после открытия — до ~95 мс (раскладка + первая порция иконок); для этого меню прогревается на загрузке карты (SOURCE_RS_UI_NO_PREWARM=1 отключает).
  • Слайдеры. В VGUI_FILES добавлен dnumberscratch.lua; добавлены NoClipping, GetCaretPos/SetCaretPos, input.SetCursorPos. Реальные DSlider/DNumSlider теперь создаются и тянутся: OnCursorMoved получает локальные координаты, MouseCapture удерживает ползунок вне панели. Тест ui_num_slider_drags_and_updates_value тянет настоящий DNumSlider.
  • Колесо. Обработчик всплывает от кнопки/иконки к родителям до DScrollPanel/DTree, пока не вернёт true. Починен корень DTree: PerformRootNodeLayout брал высоту ChildNodes до DListLayout, из-за чего canvas был 15 px и VBar выключен; теперь размеры детей считаются раньше. Тесты ui_wheel_scrolls_scroll_panel и ui_wheel_scrolls_tree проверяют сдвиг канваса.
  • Выравнивание. surface.GetTextSize теперь совпадает с bitmap-метрикой рендера (8 px × масштаб на байт), вертикаль считается по высоте глифа. ContentAlignment разобран как в GMod (1–3 низ, 4–6 середина, 7–9 верх; внутри ряда лево/центр/право), вкладки центрируются, дерево — слева с отступом, заглушка иконки — две строки по центру с обрезкой по иконке.
  • Иконки. Очередь видимых моделей сортируется по площади на экране и грузится до 8 штук/~8 мс за кадр; кэш LRU переиспользуется при повторном открытии. Заглушки остаются только для реально отсутствующих в VFS моделей.
  • Проверка. cargo test -p glua --test ui (9 тестов, включая перетаскивание DNumSlider и колесо над DTree), кадры SOURCE_RS_QMENU=1 ... --frames 90 --screenshot, клики SOURCE_RS_UI_CLICK='Entities', колесо SOURCE_RS_UI_WHEEL=x,y,d.

0.8. Q-меню Sandbox в клиенте (2026-10-24, ветка feat/ui-render)

Настоящее меню из feat/derma подключено к клиенту; самодельный qmenu.rs остался только fallback'ом.

  • client/src/map.rs: после load_scripts вызывается lua.load_ui(<assets>/garrysmod). Признак успеха — живой g_SpawnMenu; иначе Q открывает встроенное меню (проверка: SOURCE_RS_NO_LUA_UI=1). ui_set_screen вызывается на старте и на resize — мышь не уезжает.
  • Рендер: client/src/ui_overlay.rs переводит UiCommand в push_rect (теперь это точный квад, а не растянутый глиф — тонкие линии дерева больше не превращаются в квадраты) и push_text (scale = h/12). Цвета VGUI sRGB→linear, иначе серый 128 на экране становился 188. Бюджет — 10k глифов на кадр; команды за пределами экрана отбрасываются.
  • Ввод: Q/Esc управляют меню, Esc больше не выходит из клиента при открытом меню. Пока меню открыто, игрок заморожен, ЛКМ не стреляет, мышь уходит в ui_mouse (move/down/up/wheel), колесо не крутит физган. На Android четвёртая кнопка MENU (над JUMP) открывает/закрывает меню; касания в меню переводятся из нормализованных координат SDL в пиксели и идут в ui_mouse.
  • Спавн: RunConsoleCommand в Lua теперь кладёт вызовы в очередь (take_console_commands). Клиент исполняет gm_spawn (модель из SpawnIcon.DoClick), а также gm_spawnsent/gm_spawnswep через entity_string/weapon_string. Проп появляется на полу перед игроком (qmenu::spawn_origin) и проходит обычный путь спавна/рендера.
  • Снимок: ui_snapshot_into переиспользует буфер, ui_take_errors отдаёт ошибки Paint/PerformLayout в лог клиента.
  • Отладка кадров: SOURCE_RS_QMENU=1, SOURCE_RS_UI_CLICK=<текст|x,y>, SOURCE_RS_UI_CLOSE=<кадр>.
  • Грабли: реальный DTileLayout зовёт ChildrenSize, DTree_Node — HasChildren, скин — HasFocus/GetTextSize/DrawTextEntryText; без них сетка иконок имела высоту 0 и клики не попадали, а дерево схлопывалось. Модели по-прежнему без превью (две строки имени), HTML-вкладки — заглушки, длинные подписи дерева обрезаются панелью содержимого.

0.10. 3D-иконки моделей и светлый скин Q-меню (2026-10-24, ветка feat/icons)

  • Контракт снимка расширен: UiKind::Image, UiCommand.model (путь MDL), UiCommand.clip (границы панели). canvas/ModelImage эмитят image вместо двух строк имени, SpawnIcon работает без изменений.
  • render::icons::ModelIcons: offscreen 128×128 (Rgba8UnormSrgb + depth), камера «три четверти» по габаритам вершин, материалы модели через upload_texture, LRU-кэш 512 путей, лимиты 400k вершин/24 материала. В клиенте — не больше 3 новых иконок за кадр; битые пути кэшируются как неудачные. UI-квад рисуется между командами снимка (ui_image.wgsl), поэтому фон панели остаётся под иконкой, а подписи — сверху.
  • Клиппинг на CPU: text::build_vertices обрезает каждый глиф/квад по clip с пересчётом UV; paint_panel сужает clip по дереву, а surface.DisableClipping снимает его (тени рамок).
  • Светлый скин: ui.apply_light_skin() задаёт палитру после skins/default.lua; surface.DrawTexturedRectUV подбирает светло-серый цвет по UV gwenskin (панели/кнопки/вкладки/списки), выделение — голубое. Раньше крупные белые текстуры скина подменялись тёмным фоном — отсюда была тёмная тема.
  • Проверка: SOURCE_RS_QMENU=1 ... --frames 60 --screenshot — сетка 3D-иконок на светлом фоне; SOURCE_RS_UI_CLICK=barrel спавнит gm_spawn; после SOURCE_RS_UI_CLOSE SWEP-атака/переключение работают; FPS-замер (SOURCE_RS_LOG_FPS) без меню ~14, с меню ~12–13 на Xvfb.

0.9. Настоящее Q-меню: Derma + Sandbox spawnmenu (2026-10-24, ветка feat/derma)

Q-меню больше не рисуется сеткой на Rust: crates/lua получил слой VGUI/Derma (ui.rs + ui/bootstrap.lua), поверх которого грузятся настоящие lua/derma/init.lua, lua/skins/default.lua, lua/vgui/* и lua/includes/modules/spawnmenu.lua.

  • LuaRuntime::load_ui(game_dir) собирает UI и геймод, OnGamemodeLoaded создаёт g_SpawnMenu; ui_toggle/ui_visible/ui_snapshot/ui_mouse — контракт для рендера (UiKind, UiCommand, лимит 4096 команд/32 уровня).
  • surface/draw в снимке пишут команды; клик по вкладке/дереву реально меняет состояние (OnMousePressed/Released).
  • file.Find научен GMod-семантике (файлы+каталоги, *.txt), добавлен DEFINE_BASECLASS -> local BaseClass = baseclass.Get и //-комментарии; песочница include ищет файлы без учёта регистра (Windows-скрипты на Linux).
  • Снимок на реальных ассетах: ~900 команд, вкладки/категории/модели (Construction Props, *.mdl), клик по Weapons меняет снимок.
  • Ограничения: HTML-вкладки (Dupes/Saves) — заглушки, модели не рендерятся (только имена), шрифты приблизительные; crates/client не тронут.

0. SWEP: оружие из Lua в клиенте (2026-10-24, ветка feat/swep)

Скрипты lua/weapons/*.lua (на реальных ассетах — weapon_fists, weapon_flechettegun, weapon_medkit) теперь не просто загружаются, а управляют клиентом:

  • client/src/weapon.rs: реестр из list.Get("Weapon"), фильтр Spawnable + существующий ViewModel в VFS, слоты (Slot/SlotPos), SOURCE_RS_SWEP для старта. Клавиши 1..9 выбирают слот 1..9, 0 — слот 0 (кулаки). При смене вызываются Holster старого и Deploy нового, вьюмодель пересоздаётся под SWEP.ViewModel.
  • client/src/map.rs: ViewModelState (модель, покой/выстрел, пересоздание), ЛКМ → PrimaryAttack активного SWEP, автоматический огонь по Primary.Automatic с учётом GetNextPrimaryFire, Think каждый кадр, HUD с PrintName и патронами (Clip1/ClipSize).
  • glua: база SWEP (swep_base.lua), GetOwner/GetViewModel, NetworkVar, SetNextPrimaryFire, Clip1, EmitSound; глобальные CurTime, IsValid, player.GetAll, util.TraceLine/TraceHull, trace.*, sound.*; game.SinglePlayer исправлен на функцию (как в GMod), GetConVar знает sv_defaultdeployspeed/phys_pushscale (ноль давал inf в Deploy).
  • Ошибка Lua в любом методе логируется и не роняет кадр; Think после ошибки отключается для этого оружия.

Проверка (Xvfb, --hidden): SOURCE_RS_SWEP=weapon_* + SOURCE_RS_TEST_ATTACK=1 (ЛКМ на 10-м кадре) и SOURCE_RS_TEST_SWITCH=1 (смена оружия каждые 10 кадров). Flechette за 10 кадров сделал 5 выстрелов (SetNextPrimaryFire = 0.1), ошибок Lua нет. Кадры: /tmp/opencode/swep_fists.png, swep_flechette.png, swep_medkit.png, swep_switch.png. Тесты: real_gmod_weapon_methods_run_without_errors прогоняет настоящие Deploy/PrimaryAttack/Think/Holster всех трёх SWEP.

Грабли: c_arms.mdl — скелет без геометрии (c_arms.vvd = 0 вершин), руки в GMod берутся из модели игрока. Пока bonemerge нет, вьюмодель кулаков скрывается (HUD показывает оружие), а не подменяется пистолетом. c_smg1 и c_medkit рисуются, но общая проекция камеры (FOV 100, дистанция 26) делает их мелкими: отдельный FOV/масштаб вьюмодели — следующий шаг.


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-градиент на грани показал, что зеркальная плоскость занимает всю стену и шейдер работает; отражение показывает противоположную кирпичную стену и потолок. Дальше — сглаживание границы отражения и исключение зеркала из прохода теней.

0.6. Декали (infodecal) — сделано

19 декалей карты (граффити, плакаты, следы шин) ставятся на ближайшую грань: замер показал расстояние 0 у всех, поэтому проекция точная; квадрат 32×32 строится по осям текстуры грани. Материалы decals/... рендерятся отдельным проходом с альфа-блендингом (шейдер fs_decal: цвет из общего освещения, альфа из текстуры). Проверено кадром: граффити с прозрачностью на кирпиче.

Осталось из визуального: env_skypaint (цвет неба), спрайты/glow (на gm_construct их нет), SWEP из Lua.

0.7. Q-меню спавна (2026-10-24, ветка feat/qmenu)

Сделано ядро A24 — меню спавна по клавише Q, без VGUI:

  • Список собирается при старте: list.Get("SpawnableEntities") (только Spawnable = true), list.Get("Weapon") (WorldModel, иначе ViewModel) и модели из settings/spawnlist_default/*.txt (KeyValues). Все пути проверяются vfs.exists; на текущих ассетах: 1 энтити, 3 оружия, 5656 пропов (1817 путей из spawnlist отсутствуют и отброшены). Модель ENT-скрипта, заданная только в коде (self:SetModel), берётся первым литералом models/*.mdl из исходника.
  • UI — текст поверх затемнения: вкладки 1/2/3, сетка 4×8, стрелки/Enter, мышь (наведение и клик), колесо и PageUp/PageDown, Esc/Q закрывают. Пока меню открыто, игрок не ходит, камера не вращается, ЛКМ не берёт физган; после закрытия управление и захват мыши возвращаются.
  • Спавн — в точку перед игроком: горизонтальный луч с габаритом пропа, затем луч вниз до пола; если пола нет — на уровне глаз. Внутрь игрока не ставим. Путь спавна общий с G (spawn_prop + досборка рендерера).
  • Проверки: клик/Enter спавнят (кадр interactive2.png), G по-прежнему спавнит (gspawn.png), при открытом меню позиция не меняется (frozen.png), после закрытия W снова двигает (resume.png); скриншоты меню — final_menu.png, menu_entity.png, menu_weapon.png, спавн арбуза — final_spawn.png.
  • Отладка: SOURCE_RS_QMENU=1 — открыть меню на старте; SOURCE_RS_QMENU_ITEM=<подстрока> — выбрать предмет; SOURCE_RS_QMENU_SPAWN=1 — заспавнить выбранный сразу (проверка кадром).
  • Тесты: qmenu (навигация, страницы, попадание мышью, категории), glua (поля ENT-реестра, поиск литерала модели), render (сплошной глиф).

Осталось: иконки-превью вместо текста, подкатегории spawnlist (сейчас один общий список пропов), серверный спавн настоящих ENT вместо пропа-представителя.

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 + картовые тесты.