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