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

950 lines
81 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Дневник агента: где я и что дальше
Живой документ. Обновляю по ходу работы, чтобы после автоматического
уплотнения контекста ничего не терялось. Коротко: состояние, замеры, следующий
шаг, грабли.
Обновлён: 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 <n>/<m>`.
- **Проверки:** `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`), скелет и анимации — следующий срез.
Как включается и как проверять:
```bash
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` + картовые тесты.