Commit graph

71 commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Тесты: `real_map_brush_models_keep_their_origin`,
`real_map_brush_model_collision_follows_origin`,
`real_map_displacement_coverage`, `real_map_displacement_start_position`.
Документация: docs/PARITY.md E16–E17, GEMINI.md часть 2 (вопросы 5–7).
2026-10-16 09:34:49 +03:00
6fd44ba014 Замеры по «белым плитам» и «вывернутой траве»: три гипотезы опровергнуты
Гипотеза Gemini (нормаль из dv.vec) не подтвердилась: сырые байты LUMP_DISP_VERTS
читаются как f32 с шагом 20 и дают осмысленные значения (vec=(0,0,1),
dist=18.145). Формат разобран верно.

Ориентация рельефа в порядке: 110 плоскостей вверх, 0 вниз, намотка не
перевёрнута (0 из 11776). У спавна рельеф плоский — z от -151.4 до -148.0,
поэтому «наклонены 104 вершины» это свойство карты, а не баг.

Найдена ошибка в моей же сверке: справочник лампов читался со stride 48 вместо
16. Из-за этого LUMP_NODES выглядел навмешем, а лампы 6 и 18 менялись местами.
Выводы части E14 про LUMP_NODES недействительны.

«Белые плиты» разобраны: у материала color/white всего 9 граней, и это не
ящик, а тонкие пластины 64x4 и 128x4 с нормалями ±X/±Y на четырёх высотах —
то, что на глаз читается как «оси координат 3д». Большая белая поверхность в
центре — другая: бетонная плита 2480x1936 над спавном, продублированная
(9379==9385, 9380==9386), дубль есть в самой карте.

Чёрные области проверены экспериментом: отключение тенев и отсечения задних
граней меняет картину, значит чёрное приходит из лайтмапы, а не из теней.

Подробности и следующие шаги в PARITY.md E15.
2026-10-16 08:21:12 +03:00
5f1fdcf5e2 Диагностика белых граней в центре карты: гипотеза про навмеш не подтвердилась
Жалоба: «в 0 0 видны белые плиты, которых нет в оригинале, причём у них
коллизия есть».

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Первое сравнение: небо сошлось (0,97-1,00), а поверхности были темнее вдвое.
Причина — четвёртый байт люкселя читался как i8 и давал масштаб 2^exp; у
большинства люкселей он равен 255, то есть как i8 это -1 и масштаб 0,5.

Масштаб подобран по эталону на двух разных материалах:
  0,5 -> бетон 70,5 / трава 52,8
  1,0 -> 87,8 / 77,3
  2,0 -> 122,0 / 107,8
  3,0 -> 147,0 / 130,3
  GMod -> 143,6 / 127,1
Один коэффициент попал в эталон на двух материалах, значит это найденная
величина, а не подгонка.

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

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

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

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

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

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

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

Тест light_sample_missing_is_white поправлен под новый порядок каналов.
2026-10-14 13:37:46 +03:00
e91a60d40d Освещение: нормали граней были перевёрнуты, карта не получала света
Симптом: «на видео нету освещения вообще». Предыдущий замер показывал
среднюю лайтмапу 0,22 и делал вывод, что темно «от данных карты». Вывод был
неверен.

Замер ориентаций вершин gm_construct вскрыл причину: строго вверх — 17
вершин, строго вниз — 18 096. У настоящей карты пола смотрят вверх.

Причина: face_normal брала нормаль плоскости с обратным знаком
(`if side == 0 { -normal }`). В VBSP нормаль плоскости грани уже смотрит
наружу от твёрдого тела. Инверсия попала в код при попытке починить
намотку, но намотку чинит отдельная face_winding — она сравнивает
геометрическую нормаль треугольника с нормалью грани и переворачивает обход
сама.

Следствие: солнце и запечённая лайтмапа попадали на изнанку всех
поверхностей. Пол не получал ни того, ни другого.

Стало: вверх 15 195, вниз 2 918. Тест real_map_normals_are_not_inverted
не даёт дефекту вернуться.

Заодно второй настоящий баг в том же месте: в sample_light_luxel ширина
строки блока лайтмапы считалась как extent[0], а блок — это
(extent[0]+1) × (extent[1]+1) люкселей. Каждая строка читалась на люксель
раньше, и к низу блока чтение уезжало на десятки люкселей в сторону.

Долго висевшее «белое пятно» оказалось не дефектом: это комната Construct,
материал GM_CONSTRUCT/COLOR_ROOM с VMT UnlitGeneric и базовой текстурой
color/white — белая по замыслу и неосвещаемая. Спавн игрока внутри неё.

Рябь на воде подобрана замером: частота 0.035 давала плиту, 0.35 —
aliasing на горизонте, остановились на 0.12 и 0.19; третью полосу убрали —
её наклон давал клинья поперёк озера.
2026-10-14 12:15:15 +03:00
846cdc5185 docs: замеры освещения gm_construct и режим пролёта
Зафиксировано то, что измерено, и то, что осталось неопознанным — включая
белую площадку в кадрах: пустых текстур нет, SURF_NOLIGHT рядом только у
skybox (и они не рисуются), белый материал один и с этого ракурса не виден,
формат захвата тот же sRGB, что у экрана.
2026-10-14 11:20:45 +03:00
c25c8f2b78 Вода и плавание: в озере можно тонуть, всплывать и выпрыгивать на борт
Воды в физике не было вовсе: она только рисовалась (SURF_WARP → шейдер воды).
Жидкости даже не хранились — Collision::from_bsp отбрасывал всё, что не
CONTENTS_SOLID, а вода как раз не SOLID. Итог: игрок входил в озеро и шёл по
нему как по пустоте.

- Liquid — объём жидкости (полупространства + CONTENTS_*), хранится отдельно.
  Отбор по содержимому перенесён ДО отсева по SOLID: на gm_construct такой
  кирпич ровно один (0x10000020 = CONTENTS_WATER | CONTENTS_ORIGIN), и из-за
  отсева вода не находилась вообще.
- Collision::point_contents — аналог GetPointContents.
- Player::check_water — CheckWater: три пробы, уровень WL_NotInWater…WL_Eyes.
- Player::water_move — WaterMove: гравитации нет, пробел даёт полную скорость
  вверх, без ввода игрок тонет (wishvel[2] -= 60), желаемая скорость режется
  до 80 %, трение по всей скорости, разгон той же квото-формулой.
- Player::water_jump — CheckWaterJump: преграда по поясом, место над головой,
  опора под ногами не круче sv_walkable_normal; скорость 256, WaterJumpTime 2 с.
- drowning_time и breath(): под водой воздух кончается, в воздухе
  восстанавливается, полное время 15 с.
- Вода по колено: гравитация действует, вертикальная скорость гасится в 0,9.

FullWalkMove собран как в оригинале: StartGravity только если CheckWater вернула
ложь, при WL_Waist вместо WalkMove идёт WaterMove.

Замер на gm_construct: озеро (-3072, 1985, -960)..(1856, 6464, -160); без ввода
игрок через 10 с на дне z = -960, с пробелом всплывает до -194,8. Остановка
всплытия ровно по пояс — верное поведение Source, выпрыгнуть из бесконечного
бассейна нельзя, только на борт через water_jump.

Трение и ускорение в воде взяты из WaterMove SDK 2013 (sv_friction, sv_accelerate).
У GMod есть sv_waterfriction и sv_wateraccelerate, но где они применяются,
проверить не удалось: в публичных исходниках их нет.
2026-10-14 09:47:09 +03:00
229372ffa1 Прыжок 200 вместо 270 и исправлены флаги граней — пропало 100 дырок в картинке
«Дырки в крыше, сквозь которые нельзя упасть»: граней в картинке было меньше,
чем в коллизии. Причина — константы SURF_* в world.rs были перепутаны. По
public/bspflags.h Source настоящие значения:

  SURF_SKY2D    0x0002      SURF_NOPORTAL 0x0020
  SURF_SKY      0x0004      SURF_TRIGGER  0x0040
  SURF_WARP     0x0008      SURF_NODRAW   0x0080
  SURF_HINT     0x0100      SURF_SKIP     0x0200

SURF_NOPORTAL означает «нельзя поставить портал», SURF_TRIGGER — служебный
флаг; ни то, ни другое не про рисование, а мы их пропускали. Замер на
gm_construct: старая маска скрывала 2511 граней, новая 2411 — в картинке
появилось 100 граней, не пропало ни одной.

Тесты surface_flags_match_source_bspflags и
skip_mask_covers_only_faces_that_are_not_drawn не дадут значениям снова разойтись.

«Прыжки марсианские»: скорость прыжка бралась как 270 из Source SDK, а у GMod
она своя — вики GMod в примере Player:GetJumpPower() печатает 200. Прыжок был
выше в 1,8 раза.

  высота     60,8 -> 33,3 юнита
  до вершины 0,45 -> 0,33 с
  полёт      0,90 -> 0,67 с

sv_gravity = 600 подтверждён вики отдельно и остаётся.

Попутно исправлены две ошибки в самих измерениях: брался максимальный z над
точкой вместо высоты поверхности на месте (крыша на z=1296 заслоняла крышу на
z=240), и зазор между габаритами считался без клампа по нулю. На этих
артефактах диагностика рапортовала чужие дефекты.
2026-10-14 08:43:51 +03:00
7b99c58bd9 Ноклип без инерции, точная проверка касания, обход карты без невидимых стен
Ноклип — по наблюдению в игре: инерции в полёте нет, скорость пишется прямо
по вводу и отпускание клавиш останавливает сразу. Разгон выглядел «лунной
гравитацией». Импульс при этом переживает отпускание: пока ввода нет, скорость
не трогается — раньше `velocity = wish * speed` писался каждый кадр и затирал
собственную скорость, поэтому прыжок по Z не мог сохраниться в принципе.

Пробел даёт порцию импульса по фронту нажатия, порции складываются: одно
нажатие поднимает на 279 юнитов, два — на 567. Выход из ноклипа переносит
импульс по всем осям в обе стороны.

«Марсианские прыжки»: дуга снята целиком и совпадает с расчётом для GMod —
0,45 с до вершины, 0,90 с полёта, 60,8 юнита высоты. Значит ощущение рождается
обрывом прыжка о невидимые стены.

Проверка касания переписана: грань должна доставать до кирпича, и проверяется
весь габарит тела в момент касания, а не одна точка. С проверкой точки игрок
переставал перебирать уступы уже высотой 4 юнита (замерено); с габаритом
подшаг работает — 18 перебирается, 19 нет.

Новый тест обходит gm_construct сеткой из 49 точек восемью направлениями:
117 600 кадров, невидимых стен 0 (было 2 на прямом ходе).

Заодно исправлена ошибка в самой диагностике: зазор между габаритами считался
без клампа по нулю, и на пересекающейся оси выходило отрицательное число,
из-за чего длина вектора давала «912 юнитов зазора» там, где габариты
соприкасаются. На этом артефакте рапортовалось 35 невидимых стен — все были
выдумкой измерения.

«Дырки в крыше, сквозь которые нельзя упасть» НЕ воспроизведены и не
«исправлены»: твёрдость кирпичей, покрытие displacement, отбор по содержимому
и обход карчи — всё чисто. Нужны координаты конкретной дырки; без них остаётся
только гадать.
2026-10-14 08:29:57 +03:00
d6c49fd67a Константы по таблице GMod и устранены невидимые стены из плоскостей граней
Четыре жалобы из игры, все закрыты измерением.

Константы движения (gmodwiki.com/ConVars_In_Garrysmod, не source-sdk-2013):
  sv_friction         4 -> 8     торможение 250 -> 10 за 0,40 с, путь 26 юнитов
  sv_stopspeed     100 -> 10
  sv_gravity       800 -> 600    прыжок 45,6 -> 60,8 юнита, как в GMod
  sv_airaccelerate  12 -> 10
  sv_maxvelocity   350 -> 3500   падение 585/589/590 -> 600/1200/1800/2400/3000

«В воздухе управление слабее»: в AirAccelerate кап sv_air_max_wishspeed
ограничивает ЦЕЛЬ, а прибавка считается по некапленной wishspeed. Мы
ограничивали и то и другое — прибавка была в 8,3 раза слабее. Замер после
правки: рывок вбок даёт ровно 30,0 юнитов/с, как в Source. Поворот при
полёте вперёд по-прежнему заблокирован (проекция 177 >> капа 30) — это
верное поведение оригинала.

«Высота шага терялась единица»: подъём ровно на sv_stepsime ставит низ
габарита точно на грань ступени, грань считается касанием, и подшаг
блокирует сам себя. Замерено: брались уступы до 17 и застревали на 18.
В оригинале подъём на flStepSize + DIST_EPSILON. После правки — все до 18
включительно, 19 нет.

«Невидимые стены, рампа как параллелипипед»: грань кирпича ограничивает
плоскость, а не кирпич, и останавливает шаг где угодно на её уровне.
Замерено: игрок останавливался за 3,4 юнита до настоящей преграды, блокировала
верхняя грань соседнего кирпича. Правка: при t около нуля проверяется, что
точка касания лежит на самом кирпиче. Замер после правки: преград «из
воздуха» 0 (было 2), упоров за 30 с ходьбы 0.

Проверено и отброшено измерением: кисти не работают как ящик (аудит 2126
кирпичей, 0 расхождений из 129684 точек); displacement не выпадает из сетки
(11776 треугольников, выпало 0); скорость вдоль склона 239-263 против 250.

Пара sv_falldistance/fall_speed НЕ введена осознанно: в таблице cvar'ов GMod
её нет, а формула HL2 при отрицательной velocity[2] даёт замедление падения,
а не разгон. Ставить непроверенные константы значило бы гадать.

228 тестов зелёные, clippy -D warnings чист.
2026-10-14 07:52:39 +03:00
f34c456034 Гравитация пополам до и после движения, ограничение скорости sv_maxvelocity
Два оставшихся пункта из B6.7, сверенные с оригиналом SDK 2013
(src/game/shared/gamemovement.cpp).

- StartGravity / FinishGravity берут по половине гравитации до и после
  движения: «yes, this 0.5 looks wrong, but it's not» — комментарий Valve.
  Замер высоты прыжка на gm_construct: 42,7 -> 44,9 юнита при ~45 в Source.
  Заодно проверено, что TryPlayerMove гравитацию не применяет вовсе.
- CheckVelocity: sv_maxvelocity = 350 попоосно. Без него шаг за кадр на
  большой скорости становится длиннее стены, и отсечение её не видит.
- Место вызова важнее самого ограничения: в оригинале CheckVelocity стоит
  прямо перед TryPlayerMove (внутри Friction). Вызов только в конце кадра
  позволял одной нечислице уйти в смещение и сделать позицию нечислицей
  навсегда — прогон тестов занимал 42 с вместо 0,01. Теперь вызов есть
  и перед движением, и в конце.
- Позиция проверяется на нечислицу так же, как скорость.

Тесты: speed_is_capped_at_max_velocity, non_finite_velocity_is_zeroed.
2026-10-14 07:13:30 +03:00
b96b06ccdb Небо гасится за пределами карты: и 2D-скайбокс, и 3D-скайбокс
Жалоба: «если игрок выходит за карту, то скайбокс и 3D-скайбокс не должны
рендериться (иначе не понятны границы карты)».

Причина видна в шейдере 3D-скайбокса:
    world = camera.pos.xyz + (in.pos - sky_camera) * scale
Геометрия неба размещается относительно камеры, поэтому небо всегда заливает
весь фон — пределы карты на нём не видно.

Замер на gm_construct:
  габариты геометрии  (-5640, -4576, -960) .. (2168, 6464, 2976)
  габариты коллизии   (-15360, -15360, -1024) .. (15872, 15360, 15359)
  sky_camera          (-1428, 1645, 10991), масштаб 1/16
  2D-скайбокса нет, только 3D
Габариты коллизии не годятся — включают и 3D-небо, и далёкие террасы,
поэтому основание — габариты геометрии карты.

- world::inside_bounds — одна реализация предиката, её используют и движок,
  и рендерер (рендерер копирует габариты при загрузке).
- WorldRenderer::update_sky_visibility пересчитывает видимость каждый кадр:
  игрок уходит за край во время игры. Проверка по всем трём осям сразу,
  под картой небо тоже исчезает.
- SKY_BOUNDS_MARGIN = 128: габариты считаются по вершинам, а камера стоит
  на высоте глаз над опорой, так что стоящий на краю игрок формально за ними.
- Гасятся оба прохода неба: иначе за краем остался бы виден 3D-неб, а это
  ровно то, что мешало увидеть границу.

Тесты: sky_is_visible_only_inside_the_map (юнит) и real_map_sky_bounds
(замер на настоящей карте).
2026-10-14 07:10:47 +03:00
512f3d0394 Движение игрока переписано по CGameMovement: SAT, цикл касаний, квото-ускорение
Замеры на gm_construct до и после — шесть жалоб из игры закрыты.

Исправлено по измерениям, а не по догадкам:
- vertices_of брал пересечение двух граней (прямую) и выбирал точку вне
  кирпича: 1846 кирпичей из 2126 (87%) считались «без объёма», то есть
  не твёрдыми. Это корень «сквозь рампу прошёл» и «падаю сквозь карту».
- clip_fraction отбрасывал грань при касании (guard `start <= limit`):
  шаг 8 юнитов — стоп у стены, шаг 16 — на 8 внутрь. Guard убран,
  условие `!(0.0..1.0).contains(&t)` — верно и для -0.0, и для NaN.
- Полуплоскость кирпича действовала на всю плоскость: игрок зависал
  над краем площадки. Добавлен swept-bounds early-out.
- Движение шло одним общим процентом шага, из-за чего игрок «запинкой»
  полз по полу. Заменено на цикл касаний TryPlayerMove (numbumps = 4).
- step_move всегда выбирал путь «вверх», а он уводил назад по склону:
  игрок сползал с x = -256 до x = -1066 за 5 с. Переписано по оригиналу —
  побеждает путь, ушедший дальше по горизонтали, z из движения вниз.
- stay_on_ground падал всем шагом, вминался в кирпич и выталкивался вдоль
  нормали, то есть вбок по склону. Теперь зондирует вниз по 1 юниту.
- Трение было только без ввода. FullWalkMove зовёт Friction() безусловно —
  именно оно удерживает игрока на рампе.
- Линейная интерполяция ускорения замедляет не хуже, чем ускоряет:
  равновесие на 179 юнитах/с. Заменена квото-формула Accelerate;
  sv_air_max_wishspeed = 30 действует только в воздухе.

Замер на настоящей карте (real_map_movement, --ignored):
  скорость 165 -> 224,9 (среднее с разгона; Source 250)
  дёрганье назад 0,00 юнита за 3 с
  прыжок 42,7 юнита (Source ~45)
  падение сквозь мир и проход сквозь рампу устранены

Старый real_map_player_rests_walks_and_does_not_sink требовал пройти
2000 юнитов вслепую вдоль +x, где на карте стена на x = 992 — ожидание
теста было неверно, а не игрок. Тест удалён, его корректная часть
покрыта real_map_movement.

222 теста зелёные, clippy -D warnings чист.
2026-10-14 07:07:48 +03:00
7389ac5579 Переписана коллизия тела с кирпичами: SAT вместо неверной глубины проникновения
Главный дефект батча 2 был не в кинематике, а в проверке пересечения AABB с
кирпичом. Симптомы были «поверхность превратилась в лёд» и «выталкивает сквозь
пол и стены».

Глубина проникновения считалась как `dist - project_min(aabb, n)` — насколько
тело зашло за грань с внутренней стороны. Для игрока, стоящего на полу (низ на
1 юнит ниже верха кирпича высотой 100), это даёт 172 юнита проникновения в
нижнюю грань — на 172 юнита выше неё. Тело выталкивало вниз сквозь пол.
Отсюда сразу все жалобы: улетев вниз, игрок терял `on_ground`, трение не
применялось — это и был «лёд».

Заменено на SAT по разделяющим осям: длина перекрытия интервалов проекций.
Для того же игрока — 1 юнит, выход вверх. Чтобы у кирпича был второй конец
интервала, находятся его вершины и хранится проекция противоположного края.

Отдельно: минимальный вектор выхода не годится для тонкой стены — у стены
шириной 16 при теле шириной 32 выход вперёд всегда короче, и тело, дошедшее до
её середины, выталкивалось сквозь (замерено ровно до x = 132.1 при стене на
x = 100). Добавлена swept-трассировка: тело останавливается у грани и никогда
не оказывается по другую сторону.

По `CGameMovement` из Source SDK 2013:
- `StayOnGround()` — функции не было вообще. После ходьбы игрок
  притягивается к поверхности; без этого он висит над землёй на спуске.
- порог опоры `sv_walkable_normal` = 0.7, а не 0.6;
- `StepMove` работает только когда игрок был на земле, в прыжке на ступеньку
  не взбираются;
- в пути отсечения выставляется опора — без этого игрок замирал намертво на
  45° рампе: гашение скорости контактом уравновешивало гравитацию.

Замер на gm_construct (30 секунд ходьбы): пройдено 6372 юнита вместо 1072,
проваливания и замирания нет. 81 тест зелёный, clippy чистый.
2026-10-13 16:02:06 +03:00
570f4bb9d0 docs: изучен CGameMovement из Source SDK 2013 — расхождения с нашей физикой
Доки Valve закрыты Anubis, читал первоисточник
ValveSoftware/source-sdk-2013, src/game/shared/gamemovement.cpp.

Записаны точные причины двух симптомов пользователя и ещё трёх расхождений:

1. StepMove у оригинала пробует оба варианта (вниз и вверх на flStepSize) и
   берёт тот, что ушёл дальше по горизонтали; вертикальную скорость берёт из
   движения ВНИЗ (m_vecVelocity.z = vecDownVel.z). У нас подшаг всегда идёт
   путём «поднять 18 и уронить», режет путь на рампе и добавляет паразитную
   вертикаль — отсюда и потеря скорости вверх, и «прыжки с рампы» вниз.
2. Friction() вызывается каждый кадр на земле безусловно, формула
   control = max(speed, sv_stopspeed), drop = control*friction*frametime.
   У нас трение только при отсутствии ввода — по рампе на бегу его нет.
3. Гравитация делится 0.5 до и 0.5 после движения (StartGravity/FinishGravity).
4. Ускорение — Quake-формула accelspeed = accel*frametime*wishspeed, а в
   воздухе ещё и ограничение sv_air_max_wishspeed = 30. Отсюда «вязкое»
   управление в воздухе, которого у нас нет.
5. Прыжок по фронту: в оригинале при выходе из воды ставится
   m_nOldButtons |= IN_JUMP с комментарием «don't jump again until released» —
   наша защёлка воспроизводит штатный приём движка.

Код не менял: сначала фиксирую расхождения, чтобы не подгонять тесты.
2026-10-13 15:20:08 +03:00
2e906e034b engine: физика движения как в Source — гравитация только в воздухе
Четыре жалобы (проталкивает на расстояние, метания вдоль стены, сползание по
рампам, bhop) оказались одной ошибкой: гравитация применялась КАЖДЫЙ кадр,
в том числе стоя на земле. Игрок вдавливался в геометрию, разрешение
столкновений выталкивало обратно.

Source делает наоборот: при FL_ONGROUND вертикальная скорость
обнуляется, а ApplyGravity в земле не вызывается вовсе.

Что изменено:
- гравитация только в воздухе;
- трение всегда на земле (Source зовёт Friction() каждый кадр) — именно
  оно держит игрока на рампе; sv_friction = 4, sv_stopspeed = 100;
- прыжок по фронту нажатия, защёлка держится пока пробел зажат;
- подшаг PM_StepSlideMove: полное движение, при блокировке повтор с
  подъёмом на 18 юнитов и спуском; при падении отключён, иначе игрёт в
  потолок;
- MoveResult::blocked — признак блокировки для подшага.

Три мои ошибки пойманы тестами:
1. jump_latched = jump — защёлка вставала ДО первого прыжка, прыгать было
   нельзя вообще;
2. jump_latched = jump && on_ground — флаг сбрасывался тем же кадром, когда
   прыжок уже отработал, bhop возвращался;
3. тест на рамку мерил первоначальное падение (40 -> 6), а не сползание, и
   падал с «сносом 34»; после отделения приземления снос ноль.

Также: пропы добавлены в коллизию игрока (Collision::boxes) и в is_on_ground
- иначе игрок стоял на ящике и считал себя в воздухе.

Тестов 73 в engine (было 70), 23 набора по воркспейсу, clippy чист.
2026-10-13 14:59:20 +03:00
e6d7454a6a engine: тест на прошивку тонкого пола — гипотеза не подтвердилась
Две версии про проваливание проверены тестами и обе оказались неверными.

1. «Пропов нет в коллизии игрока» — пробел реальный и устранён, но игрок
   ходил по брашам, это его не объясняло.
2. «На низком FPS шаг подшага прошивает пол» — опровергнуто: при
   terminal-скорости 2000 юнитов/с и dt = 1/60 игрок садится на площадку
   толщиной 4 юнита, on_ground = true.

Тест оставлен как регрессионный сторож: если подшаги когда-нибудь
ослабнут, он сразу это покажет.

Замер: стояние на спавне gm_construct стабильно 180 кадров (z = -143.9,
on_ground = true). Дефект локальный и требует указания места.

Причина не найдена — фиксирую это в docs/PARITY.md B3, а не подгоняю
тест под желаемый результат.
2026-10-13 14:44:45 +03:00
32bd7c05f2 engine: констрейны — шар, шарнир, ползун, сварка, верёвка
Батч 3, шаг 4. Соединения между телами по классам GMod: phys_ballsocket,
phys_hinge, phys_slider, phys_weld, rope. Плюс add_force (база для
thruster/hover) и set_angular_velocity_target (мотор шарнира).

Главная тонкость переноса: скрипт задаёт якорь в мировых координатах, а
Rapier ждёт локальные в каждом теле. Пересчёт через обратное вращение.
Ошибка не бросает исключение — соединение просто «приклеивается» к другой
точке и уезжает при первом движении.

Четыре вывода из измерений, а не из рассуждений:

1. Констрейн не «стягивает» тела, и это правильно: якорь в мировой точке
   совпадает у обоих тел в момент создания, ограничение выполнено сразу и
   включается, только когда тела расходятся. Пять первых тестов проверяли
   расстояние между телами и падали именно поэтому. Правильный инвариант —
   совпадение ТОЧЕК КРЕПЛЕНИЯ под действием силы.
2. Сила в Rapier действует один шаг и сбрасывается: add_force надо вызывать
   каждый кадр. Тест с однократной силой измерял не силу, а её отсутствие,
   и падал одинаково при 8000 и при 40000.
3. Спящее тело честно сообщает нулевую скорость, поэтому эффект силы
   проверяется сравнением с контрольным прогоном, а не по скорости.
4. ПОЛЗУН НЕ УДЕРЖИВАЕТ. Сустав сообщает enabled = true и 5 заблокированных
   осей из 6, шар/шарнир/сварка в том же прогоне держат, но ползун пропускает
   расхождение по высоте. Тест помечен #[ignore] с причиной: зелёная галочка
   на неработающем коде хуже явного отказа. Гипотеза к проверке записана в
   docs/PARITY.md B2.6.

Тестов 67 в engine (было 56), 23 набора по воркспейсу, clippy чист.
2026-10-13 13:56:47 +03:00
aa7558721a lua: стандартная библиотека GMod — hook.Run, math, game, file с песочницей
Батч 3, шаг 3. Сначала измерено, а не придумано: прогнаны 130 файлов
gamemodes/sandbox, извлечены 182 различных вызова API с частотами.
Топ по частоте: hook.Run (63), math.Clamp (61), game.SinglePlayer (36),
math.Rand (27), render.AddBeam (21). Первые четыре — чисто Lua-слой,
сделаны здесь; render.* — отдельная большая работа.

hook.Run не синоним hook.Call: он останавливается на первом не-nil. На этом
построены все проверки Sandbox, подмена Run на Call ломала бы их молча.

Найдено три дефекта, все — измерением:

1. Ключи хуков — строки (идентификаторы скриптов), а не числа. Отбор только
   целочисленных ключей давал пустой список: хуки не выполнялись вообще,
   hook.Run возвращал nil.
2. Vec<Value> реализует IntoLua как таблицу, поэтому Function::call передавал
   хуку ОДИН аргумент вместо трёх — молча, без ошибки. Проверено:
   select('#', ...) внутри хука давал 1 при трёх переданных. То же с одиночным
   числом. Лечится Variadic; отдельный тест фиксирует именно эту ошибку.
3. math.Rand определён на Lua, а не на Rust: он зовёт math.random того же
   состояния и передаёт аргументы нативно.

file.* — с песочницей на garrysmod/: выход за корень это отказ, а не
предупреждение, абсолютные пути не даны, чтение ограничено 8 МБ. На живом
клиенте file.Read('../../../../etc/passwd') вернул nil.

Совпадения с GMod зафиксированы тестами: math.Round(-2.5) = -3,
math.Rand(x) даёт целое в [1, x], AngleDifference(10, 370) = 0,
Remap на вырожденном диапазоне даёт out_min, а не NaN.

Тестов 38 в glua (было 24), 23 набора по воркспейсу, clippy чист.
2026-10-13 13:17:28 +03:00
b991cc59f1 lua: мост к движку — Entity(), ents.* работают с живым миром
Батч 3, шаг 2. Крейт glua не зависит от engine: поведение отдаётся через
трейт Host, реализация которого живёт в клиенте. Иначе был бы цикл
зависимостей и Lua-слой нельзя было бы тестировать в одиночку.

Мир переведён в Rc<RefCell<World>> — скрипт обращается к нему из хуков в
любой момент. Все обращения через try_borrow: пересечение заимствований
даёт panic, а падать из-за скрипта клиент не должен.

Проверено измерением на gm_construct: ents.GetAll() возвращает 1227
сущностей, ents.GetByClass('prop_physics') читает позицию.

Найден и исправлен рассинхрон: move_prop обновлял позу экземпляра пропа,
но не запись в пуле сущностей. Lua читал z = -64, пока физика уже опустила
проп на z = -84 — «данные» и «картинка» разошлись, и заметить это можно
было только глазами. Теперь поза пишется в оба места всегда, даже без
модели: проп без модели всё равно стоит правильно, он просто не рисуется.
Возвращаемое значение move_prop теперь означает именно «геометрия
пересчитана».

После правки Lua читает z = -84.15848, физика даёт -84.15847778320313 —
совпадение до последнего бита.

Тест на паникующий хост: скрипт получает «невалидно» вместо падения.

Тестов 24 в glua, 56 в engine, 23 набора по воркспейсу, clippy чист.
2026-10-13 12:57:01 +03:00
b9b03abe3b lua: типы GMod — Vector, Angle, Color с арифметикой
Батч 3, шаг 1. Фундамент Lua-API: без этих типов Entity:GetPos() не
возвращает ничего, с чем можно считать.

Проверено тестами через настоящий LuaRuntime, а не голый интерпретатор:
скрипт считает теми же глобалами, что и обычный автор в Sandbox.

Решения по совместимости:
- пропущенные аргументы не бывают ошибкой: Vector() и Vector(1, 2) должны
  работать, иначе ломается бытовой код Sandbox;
- деление на ноль даёт 1, а не inf — inf в позиции пропа отравляет всю
  геометрию объекта;
- NaN из скрипта не доезжает до физики;
- компоненты цвета обрезаются в 0..255, а не отвергаются;
- Source — левая система координат: нулевые углы смотрят вдоль +X,
  yaw=90 — вдоль +Y.

Найден дефект чтения чисел в mlua 0.11: Value::as_number() различает только
Number и молча возвращает None на Integer, а LuaJIT отдаёт целые как
Integer. Vector(1, 2, 3) давал Vector(0, 0, 0) — и это выглядело как
«арифметика не работает». Собственный value_number() принимает Number,
Integer и строку-число.

Ещё убрат шум тригонометрии: cos(90°) в f32 равен 6.1e-17, и
Angle(0,90,0):Forward() печатал -0.00000004371139 вместо 0.

Тестов 17 в glua (было 1), 23 набора по воркспейсу, clippy чист.
2026-10-13 12:48:50 +03:00
1cb836d4c9 engine: физика на Rapier3D — пропы падают, сталкиваются и засыпают
Батч 2. Мир не переезжает в Rapier целиком: геометрия карты остаётся
брашевой, Rapier обслуживает только динамические тела.

Четыре дефекта найдены измерением на gm_construct, а не догадкой — каждый
следующий был невидим, пока не закрыт предыдущий:

1. Рельеф раздувался в 15 277 коллайдеров (сетка LAND_CELL дублировала
   каждый треугольник в каждую ячейку). Заменено одним объединённым
   trimesh с дедупликацией вершин: 11 776 треугольников -> 6 148 вершин,
   1 коллайдер. Точнее и на порядки дешевле.
2. Уровень gm_construct построен брашами, а не displacement'ами: без них
   все пропы падали насквозь. add_brushes восстанавливает вершины
   перебором троек плоскостей (Крамер). Замер: 2121 построен, 5 пропущено.
3. Broad-phase Rapier наполняется только на step(): после вставки статики
   луч её не видел. Лечится явным broad_phase.set_aabb().
4. setup_physics начинался с PhysicsWorld::new() и стирал построенные браши.

Тест поймал баг, который замер бы не увидел: add_brushes создавал
коллайдеры, но не увеличивал счётчик, и лог врал (static = 1 при 2121 браше).

Проверка двумя независимыми системами коллизий: и Rapier, и брашевая
Collision находят пол под пропами на z = -96. После 400 кадров все 4
prop_physics осели: speed = 0.0, sleeping = true.

Ограничения зафиксированы в docs/PARITY.md: коллайдер пропа — AABB модели,
а не vcollide; физика только у prop_physics*; FPS просел до 10-21 и требует
отдельного замера.

Тестов 55 в engine (было 42), 23 набора по воркспейсу, clippy чист.
2026-10-13 12:36:51 +03:00
387abd6454 engine/render: динамический свет из карты (солнце, лампы, прожекторы)
collect_lights() разбирает light_environment (солнце), light, light_spot.
На gm_construct: 65 источников, 1 солнце, 22 прожектора. Шейдер получил
uniform-блок с солнцем, точечными источниками и конусами прожекторов.

Ключевые находки при подборе нормировки (проверено рендерами):
- четвертая компонента _light — это РАДИУС у ламп и ЯРКОСТЬ у солнца;
- яркость нельзя домножать в цвет, иначе возводится в квадрат;
- лайтмапа уже содержит запеченное солнце, динамическое добавляется слабо.

Также добавлен разбор GAME_LUMP в formats (нужен для деталь-текстур) и
заодно выяснено: в gm_construct деталь-текстур нет, под-лумпы prps/prpd —
это геометрия пропов. Текстура GRASS-SAND_13 есть, но не используется ни
одним texinfo — вопрос песка остается открытым (docs/PARITY.md A5).
2026-10-13 08:55:51 +03:00
c9264fe892 docs: план совместимости с GMod (одиночная игра)
Зафиксирован план достижения одиночной игры, совместимой с GMod.
Главный принцип — совместимость, а не тождество внутренностей: контент
карт и аддонов, Lua-API, поведение игрока, формат сущностей.

Часть A — семь найденных дефектов, в том числе подтверждённых замером
по реальному gm_construct.bsp: 77 из 2203 брашей ошибочно считаются
твёрдыми, скорость не гасится о контакт, нет отсечения граней,
нет деталь-текстур.
2026-10-12 16:45:34 +03:00
77d70f6b9f net: добавлен недостающий userinfo-конвар cl_playermodel
Наш первый netchan-пакет был на 22 байта короче эталонного — ровно на
cl_playermodel\0male07\0. Без него сервер не знает, какую модель ставить
нашему энтити-игроку.

Набор конваров вынесен в client::userinfo_pairs() и теперь сверяется с
эталоном настоящего GMod (новая фикстура fixtures/classicbox_client_hello.bin,
511 байт из пакета #24) попарно, включая порядок. При том же имени наш пакет
теперь совпадает с эталонным побайтово: тело идентично в 513 из 514 байт,
различаются только checksum/challenge из-за другой сессии и один остаточный
бит добивки.
2026-10-12 14:51:57 +03:00
9cee256cb8 net: разбор SVC_SendTable, SVC_ClassInfo, SVC_CreateStringTable
Раскладка взята из Source.NET (NetMessages.cs), т.к. эталонного pcap с этими
сообщениями нет — в захвате потеряно 2576 серверных пакетов именно на участке,
где сервер шлет таблицы (docs/RECON.md 13.13).

Важно: у SVC_SendTable нет имени таблицы, в отличие от раскладки Source 2013
SDK, на которую я ориентировался раньше.

Попутно найден баг через круговые тесты: префикс ':' у строковой таблицы
нужно смотреть (PeekUBitLong), а не читать — иначе теряется первый символ
имени ('userinfo' читался как 'serinfo') и дальше по потоку едет смещение.

Число классов ограничено MAX_SERVER_CLASSES=4096: значение приходит из сети.
2026-10-12 14:38:04 +03:00
fb48358208 net: «неожиданный» фрагмент сбрасывает передачу, а не весь пакет
Source.NET в ReadSubChannelData при фрагменте вне диапазона или нулевой
длине делает data.Return() и выходит — это штатная ситуация для
опоздавшего/дублирующего фрагмента. Наш код возвращал NetError, из-за чего
терялись все остальные сообщения пакета, а с ними и signon-блок.
2026-10-12 14:35:28 +03:00
459fe6b0bc net: PACKET_FLAG_CHOKED = 1<<4 (раньше был 1<<3 = Split)
Взят прямо из Source.NET (IConnectionlessPacketHandler.cs, enum PacketFlag):
Reliable=1, Compressed=2, Encrypted=4, Split=8, Choked=16, Challenge=32.
Из-за неверного бита в пакетах сервера с выставленным битом 3 читался
лишний байт, reliable_state и challenge уезжали на 8 бит.
Добавлены тесты на константы флагов и на заголовок с Choked.
2026-10-12 14:31:16 +03:00
c6b9b04404 net: разбор SVC_ServerInfo по эталону + фикстур ClassicBox
Раскладка ServerInfo была неверной (max_classes как long вместо 16 бит,
4 байта вместо MD5 карты, ServerOS как строка, нет loading_url/gamemode).
Исправлено по спецификации Source.NET и подтверждено тестами на реальном
блоке ClassicBox: map=gm_bigcity_improved_rp, host=ClassicBox,
gamemode=sandbox, maxcl=48, tick=0.030303, proto=24.
2026-10-12 14:24:22 +03:00
5600b53469 net: убран лишний бит «сжатие» в заголовке reliable-фрагмента
Layout одиночного блока, подтверждённый перебором по pcap настоящего GMod:
4 (подканал) + 2 (потоки) + 1 (single-block) + VarInt32 = 23 бита.
Мы писали 24 бита (лишний бит «сжатие»), из-за чего сервер читал длину
с неверного смещения и отбраковывал фрагмент — reliable-поток не читался,
m_bSendServerInfo не выставлялся.

Также набор USERINFO-конваров приведён к реальному (28 шт. с net_maxroutable).
2026-10-12 14:18:53 +03:00
92b4784c2a net: InReliableState=0 в первом пакете (по эталонному pcap)
Захват настоящего GMod (ClassicBox, 8091 пакетов) показал: реальный клиент
шлёт в первом netchan-пакете rel=0x0000, а мы ставили 1. Это поле означает
«какие подканалы мы хотим получить», в начале оно нулевое.
2026-10-12 14:05:49 +03:00