# Разведка (снято 2026-10-11) ## 1. Окружение разработки - OS: Linux x86_64 · CPU: 16 ядер · RAM: 31 ГБ (доступно ~17) · Swap: 8 ГБ - GPU: NVIDIA RTX 3060 (GA106) → Vulkan через wgpu - Rust: 1.97.1 (cargo 1.97.1) - Домашний каталог: `/home/niko` ## 2. Каталоги - `~/garrysmod-rs` — **корень проекта** (на момент разведки пустой; сессия переведена сюда) - `~/source-rs` — пустой, не используется (оставлен как есть) ## 3. Целевой сервер: ClassicBox - Адрес: `194.147.90.27:24215` - A2S-инфо (2026-10-11): имя `ClassicBox`, карта `gm_mobenix_v3_final`, игра `garrysmod`, тип `Sandbox`, **36/46 игроков**, Linux DS, VAC=1 (`\x01`), теги: `gm:sandbox gmc:other loc:ru ver:260917` - Вывод для плана: цель по нагрузке — 46–64 живых игрока и типичный сандбокс-набор (пропы, констрейны, PAC3/Starfall у игроков), открытая карта. ## 4. Локальные ассеты и полигон - **GMod Dedicated Server:** `~/gmod_server/server` - `garrysmod/garrysmod_{000,001,002}.vpk` (~772 МБ) + `fallbacks_*.vpk` (~104 МБ) - `sourceengine/*` (hl2_* vpk), `platform/*` - `garrysmod/mount.cfg`, `gameinfo.txt` — порядок монтирования и mounts - `srcds_linux` — локальный сервер для автотестов (sv_lan) - `garrysmod.ver`: `260508 prerelease` - **Steam-клиент GMod:** `~/.local/share/Steam/steamapps/common/GarrysMod` — заглушка ~32 КБ (полного клиентского контента нет). Для рендера используем DS-ассеты. - **Workshop:** `~/.local/share/Steam/steamapps/workshop/content/4000` — пусто. ## 5. Инструменты пользователя - `~/glua-steal` (исходники, C++) и `~/gluasteal` (рабочий каталог с `config.toml`) — дампер клиентского/шаренного Lua с серверов, исполнитель Lua, блокировщик файлов. Полезен как: - источник тест-корпуса Lua (дампить ClassicBox); - источник требований к защите путей при загрузке файлов с сервера. Лицензию glua-steal отдельно проверить перед заимствованием идей (код не копируем). ## 6. Референс-проекты | Проект | Что даёт | Ограничения | |---|---|---| | SourceDotNET/Source.NET | Структура Stage-слоёв Source; рабочий сетевой стек и подключение к реальным GMod-серверам; зачатки Lua | C#, GL46, только UnlitGeneric, физика Jitter-враппер, **нет анимаций**, Source 1 SDK License | | Leystryku/leysourceengineclient | Базовый сетевой протокол для внешних клиентов | — | | ValveSoftware/source-sdk-2013 | Спецификация: net_chan, dt_*, studio, шейдеры, VGUI, предсказание | Лицензия SDK (только для модов Source) — берём как спеку | | nillerusr/source-engine | Кроссплатформенный порт: Linux-специфика, шейдерные переводы | SDK-лицензия | | wiki.facepunch.com/gmod | Lua API и поведение движка | — | | gluasteal | Требования к безопасности загрузки файлов | проверить лицензию | ## 7. Ключевые факты о GMod, влияющие на план - Клиент исторически 32-битный (есть 64-битная бета) → у нас сразу x86_64. - Lua: LuaJIT (семантика 5.1). - PAC3/Starfall — аддоны внутри GMod Lua (Starfall несёт свой интерпретатор) → наша задача = движковый API + sandbox; их логику не переписываем. - Скачиваемые с сервера файлы: Lua + контент; LZMA-сжатие (уточнить capture'ами в M2). - Сервер может требовать Steam-тикет (ClassicBox VAC=1) → steamworks-rs с M2, fallback noSteam конваром. - В DS-установке есть базовые lua-файлы игры — референс порядка bootstrap Lua-рантайма. ## 8. Лицензии и правовая позиция - Цель проекта — личный клиент для игры на серверах; ассеты берутся из локальных установок пользователя и не распространяются. - Source SDK 2013 и Source.NET — Source 1 SDK License: код не копируем, используем как спецификацию форматов и поведения. - Наш код — собственный (clean-room по форматам/поведению). ## 9. Тестовый контур | Контур | Использование | |---|---| | Локальный `srcds_linux` (`~/gmod_server/server`) | Контролируемые сценарии, синтетика (2000 пропов, 500 констрейнов) | | Packet dump/replay | Воспроизведение прод-нагрузки без сервера | | ClassicBox (live) | Финальные проверки (36–46 игроков), аккуратно и по делу | | Fuzz-стенды | vpk/bsp/mdl/vtf/pcf/сеть — cargo-fuzz | ## 10. Заметки спринта 0 (обновляется по ходу) - VPK в установке: 9 `*_dir.vpk` (77 933 записи, 41 архив) — garrysmod (v2, 15 324 записи), fallbacks, hl2_{textures,misc,sound_misc,sound_vo_english}, content_hl2, content_cstrike, platform_misc. CS:S-контент присутствует (важно для gm_mobenix). - Формат VPK сверен с ValvePak (VRF): терминаторы, preload, встроенные данные, внешние архивы `_NNN.vpk`; парсер bounded и fuzz-устойчив. - `fs doctor`: 0 ошибок CRC на выборке; `fs cat` читает MDL (IDST) и текстовые файлы из VPK. - Клиент: SDL3 (собран из исходников, feature `build-from-source`) + wgpu 30. Запуск проверен: NVIDIA RTX 3060, Vulkan, ~58–63 FPS (vsync), чистое завершение по `--frames`. - wgpu 30: API проверено по примеру sdl3-rs (`raw-window-handle-with-wgpu`); заголовок окна обновляется через `sdl3::sys` (метод крейта требует `&mut Window`). - BSP v20: структуры `dnode_t`/`dleaf_t` на диске выровнены до 32 байт (хвостовой паддинг) — иначе поля читаются как мусор; `dnode_t.children` — `int[2]`, не `short`. - gm_construct: геометрия валидна (14 079 вершин, 9 460 граней, 4 988 узлов), pakfile — ZIP 3.8 МБ, 599 записей (CRC проверен на выборке). - VTF brickfloor001a.vtf: 7.1, DXT1 512×512, 10 мипов, миниатюра 16×16; раскладка данных совпадает с размером файла до байта; декод мипа 0 и миниатюры — ок. - По 200 случайных VTF и VMT из VPK разбираются без единой ошибки. - Полезные открытые референсы форматов (используем как спецификации, не код): `vformats` (KV/VMT/VTF/PHY/GMA/SND), `source-vtf` (VTF 7.x), ValvePak (VPK), glua-steal. - MDL: базовый studiohdr_t = 408 байт (все версии); у v48+ за заголовком лежит studiohdr2_t (256 байт, `studiohdr2index=408`), данные костей начинаются после него. Смещения полей совпадают у v44–v48 (проверено на 53 моделях: v44 — 47, v45 — 1, v48 — 5, `length == размер файла` у всех). - VVD: заголовок 64 байта (`IDSV` v4), вершина mstudiovertex_t = 48 байт. - VTX: структуры packed (`#pragma pack(1)`); **смещения вложенных структур отсчитываются от своей структуры**, а не от начала файла (только `bodyPartOffset` — от файла). Проверено: oildrum001 — 270 вершин / 756 индексов / 1 стрип; 51 пара VVD/VTX — checksum совпадает. - `fs inspect` для модели показывает кости/аттачменты/текстуры/keyvalues и меш-статистику. - M3 v0 (рендер мира): gm_construct собирается за ~0.5 с — 6838 браш-граней + 110 displacement, 46 646 вершин, 41 324 треугольника, 47 материалов, 65 МиБ текстур. - Запечённые VRAD-материалы карты лежат в **pakfile BSP** как VMT-патчи (`maps//...`, `include` → базовый материал, `replace $envmap` → кубмапа); pak монтируется как источник файлов VFS. VTFs в pak — кубмапы (`.hdr.vtf`). - Материалы без `$basetexture` (шейдер `lightmappedreflective` — стекло/вода) в v0 пропускаются как прозрачные, а не красятся заглушкой. - Скриншоты: на NVIDIA/Vulkan формат поверхности — **BGRA**; readback обязан менять местами R/B (иначе цвета инвертируются, что однажды уже сбило диагностику). - Спавн gm_construct — внутри «color room» (большой белой комнаты): для эффектных скриншотов камеру надо выносить наружу (`--pos/--angles`). - Скайбокс: небо «painted» — это реально почти равномерная светлая текстура (256×256 DXT5); проверять отрисовку скайбокса надо через смену clear color. - Скайбокс рисуется на дальней плоскости: `clip.z = clip.w * 0.99995` — ровно `z=w` отбрасывается клиппингом (симптом: скайбокс «не рисуется», видно цвет очистки). - M2 v0: нумерация net/SVC-сообщений в GMod сдвинута относительно Source 2013 (NET_Tick=3, NET_SignonState=6, SVC_Print=7, SVC_ServerInfo=8); сверено с Source.NET. - GMod требует в connectionless `connect` поле `ChecksumXOR` (зависит от первого байта и длины буфера) — без него сервер молчит. - ClassicBox (VAC=1) требует Steam-тикет (auth_protocol=3): steamworks-rs `Client::init_app(4000)` + `authentication_session_ticket_with_steam_id`; в connect пишется `u16(размер+8)`, `u64 steamid`, тикет. - Сервер сравнивает версию клиента: при старой версии — `#GameUI_ServerRejectNewVersion`; наша версия берётся из A2S-ответа сервера. - Локальный GMod DS (`srcds_linux`) в этом окружении виснет после «VAC secure mode disabled» (Steam-инициализация), поэтому живой полигон — ClassicBox. Запуск с `-insecure` открывает UDP-порт, но сервер не отвечает на A2S (зависает). ## 11. Netchan GMod (снято 2026-10-12, сверка с трафиком ClassicBox) - **Флаг Challenge = 0x20** (не 0x10, как в Source): при нём после `reliable_state` идёт `u32 challenge`; сервер шлёт challenge в каждом пакете. - **Флаг Reliable = 0x01**: при нём читается надёжная часть. Проверено: пакет с флагом 0x01, но без надёжной части → сервер отвечает «Buffer overflow in net message». - **Надёжная часть**: `subchannel u4` (в GMod 16 подканалов, не 8!) → для каждого из 2 потоков (Normal, File) 1 бит «есть данные» → для потока: `single block бит` (0 = один блок) → `compressed бит` → `varint32(bytes)` → данные. Затем ненадёжные сообщения. - `challenge` одноразовый: повторный `connect` со старым challenge → `#GameUI_ServerRejectBadChallenge`. - Сервер отвечает 'B' **только на повторный connect** (второй цикл `getchallenge` → 'A' → `connect`); первый connect как будто «регистрирует» клиента (асинхронная проверка Steam). - Пустой Steam-тикет → `#GameUI_ServerRejectSteam` (синхронная проверка формата). - Steam-клиент обязан жить всю сессию (иначе тикет инвалидируется); тикет готов только после события `AuthSessionTicketResponse`; локальный `begin_authentication_session` на своём тикете = Ok. - **Нерешённое**: после 'B' сервер шлёт challenge-пакеты раз в секунду и через ~1.5 с отключает: `NET_Disconnect` «Client dropped by server». Кик не зависит от наших пакетов после 'B' (проверено полным бездействием, ack'ами, keepalive 20 Гц, hello с SignOnState/ClientInfo в надёжном и ненадёжном потоках, разными флагами). В `engine_srv.so` рядом: «Invalid STEAM UserID Ticket» и «After this many seconds without a message from a client, the client is dropped (while connecting)» — похоже на серверную проверку аутентификации или нет-сообщений; формат нашего надёжного потока проверен побайтово против `NetChannel_engine.cs` и совпадает. ## 12. Форматы BSP GMod: уточнения (2026-10-12, сверка с gm_construct) - **`dleaf_t` в GMod = 32 байта** (без `CompressedLightCube`): `contents i32, cluster i16, area:9|flags:7 u16, mins i16[3], maxs i16[3], firstleafface u16, numleaffaces u16, firstleafbrush u16, numleafbrushes u16, leafWaterDataID i16, padding`. Area маскируется `& 0x1FF`. - **`dface_t` = 56 байт**: `planenum u16, side u8, onNode u8, firstedge i32, numedges i16, texinfo i16, dispinfo i16, surfaceFogVolumeID i16, styles u8[4], lightofs i32, area f32, LightmapTextureMinsInLuxels i32[2], LightmapTextureSizeInLuxels i32[2], origFace i32, numPrims u16, firstPrimID u16, smoothingGroups u32`. Отдельных `mins/maxs` нет. - **Лайтмапа грани**: блок `(size[0]+1) × (size[1]+1)` люкселей, индекс `v*width + u`. `lightofs` указывает на блок **после** `styles*4` байт усреднённого цвета (`PrecompLightmapOffsets` в vrad). Люксель: `ColorRGBExp32` → `(c/255) * 2^exponent`. - **Лайтмапа displacement'а**: не проекция мира! Плотная сетка по UV поверхности (`CCoreDispInfo::CalcDispSurfCoords(bLightMap=true)`): углы блока — `(0.5,0.5) … (flU+0.5, flV+0.5)`, где `flU = size[0]`, `flV = size[1]`; для вершины сетки `(a,b)` (индекс `a*side+b`, a вдоль p0→p1, b вдоль p0→p3): `luxel_u = 0.5 + (b/n)*flU`, `luxel_v = 0.5 + (a/n)*flV`. Проекция мира даёт «шахматку» теней на рельефе. - **3D-скайбокс**: движок рендерит по area bits листа `sky_camera` (`CSkyboxView::DrawInternal`) + vis от камеры скайбокса. Area листа скайбокса — отдельная (в gm_construct area=1, z≈10 000…15 200; в gm_flatgrass area=2, скайбокс **под** картой, z≈-15 000). **Displacement-грани не попадают в `leaf_faces`** (detail-геометрия) — выбирать грани надо по листу центра грани (`+normal*2` и `-normal*2`), иначе теряется основная плоскость скайбокса. Защита: если «область» накрыла >30 % граней карты — скайбокс не выделяем. - **Порядок отрисовки скайбокса**: 2D-скайбокс фоном (depth Always, без записи), затем 3D-меш (depth Always, без записи), затем мир (LessEqual, пишет depth) — стены карты всегда перекрывают скайбокс; иначе дальние стены (depth > 0.9999) оказываются позади сжатой глубины скайбокса. - **Вода**: `SURF_WARP` (0x8) + VMT шейдера `Water` (`$abovewater 1` — поверхность, `0` — изнанка; `$fogcolor "{24 64 72}"`). Две грани на один браш воды (поверхность/изнанка) — рисуем полупрозрачно с френелем и отбрасываем изнанку по нормали. - **`SURF_NOLIGHT`-грани** (вода, COLOR_ROOM и пр., `lightofs = -1`): движок рисует их с белой лайтмапой (полная яркость); ambient-куб листа в GMod (`LEAF_AMBIENT_INDEX/LIGHTING`, запись 28 байт) почти чёрный — использовать его нельзя. ## 13. Хендшейк Steam-подключения (2026-10-12, эксперименты с живыми серверами) **Ключевая находка: тикет Steam нужно запрашивать с идентичностью игрового сервера.** Современный Steamworks (`GetAuthSessionTicket` с `SteamNetworkingIdentity`) требует SteamID сервера; «generic»-тикет (пустая идентичность, как в старом движке `SteamUser()->GetAuthSessionTicket` без identity) сервер отклоняет синхронно: `#GameUI_ServerRejectSteam`. Работает: `NetworkingIdentity::new_steam_id(game_server_steam_id)`. - **Ответ challenge ('A', auth_protocol=3)** содержит: `magic u32`, `challenge u32`, `my_challenge u32`, `auth_protocol u32`, `keysize u16` (=0), `game_server_steam_id u64`, `secure u8`, затем строку версии. Пример с ClassicBox: magic=0x5A4F4933, GSID=0x0130000000B33AE4 (универсум 1, тип 3 = GameServer), secure=1. - **connect (C2S)**: `0xffffffff`, `'k'`, `protocol u32` (24), `auth_protocol u32` (3), `challenge u32`, `retry_challenge u32`, `ChecksumXOR u32` (по записанным байтам), `name\0`, `password\0`, `patch_version\0` (у GMod — версия сервера, напр. `2026.04.29`), затем `size u16` (= ticket_len+8), `steam_id u64`, тикет. - **Отказ**: `0xffffffff`, `'9'`, `my_challenge u32`, строка причины (`#GameUI_ServerRejectSteam` — отказ `BeginAuthSession`). - Сервер создаёт netchan-сессию **сразу на первый connect** (если тикет принят) и отвечает `'B'`; повторные connect с тем же SteamID → `BeginAuthSession` возвращает `k_EBeginAuthSessionResultDuplicateRequest` → снова отказ. Значит ретраи надо делать с большим интервалом (в движке — тикет кэшируется на сервер: `CSteam3Client::GetAuthSessionTicket` хранит m_hAuthTicket до смены сервера/IP/secure). - **Частые запросы тикетов подряд** (десятки за минуты) приводят к тому, что Steam начинает выдавать тикеты, которые сервер отвергает — нужно ограничить частоту и переиспользовать тикет. - После 'B' сервер ждёт от клиента (состояние Connected): `Net.SetConVar` (userinfo) + `Net.SignonState(CONNECTED=2)`, затем шлёт сигнон-блок (Net.Tick, SVC_CreateStringTable×N, SVC_GMod_ServerToClient, SetConVar, SignonState(NEW=3)). Порядок состояний: CONNECTED(2) → NEW(3) → PRESPAWN(4) → SPAWN(5) → FULL(6); на NEW клиент шлёт CLC_ClientInfo + CLC_GMod_ClientToServer + SignonState(NEW). Наши пакеты сервер подтверждает (ack растёт), но сигнон-блок пока не приходит — разбираемся (подозрение: разбор/порядок сообщений или ожидание доп. сообщений). - Проверено на двух живых серверах: ClassicBox `194.147.90.27:24215` и `37.230.228.47:27021` (ДаркРП). - Замечание по окружению: на машине сломан системный резолвер (`/etc/nsswitch.conf` → `resolve`, но `systemd-resolved` не запущен) — из-за этого не резолвятся имена (srcds не стартует, master-сервер не опросить). Прямые запросы к 1.1.1.1 работают. ### 13.1. Разбор сигнона по утёкшему исходнику движка (2026-10-12) Источник: `hl2_src-leak-2017` (движок Source ~2007 — база GMod) + символы `engine_srv.so` GMod. Ключевые функции и их логика: - `CBaseServer::ConnectClient` (baseserver.cpp): проверка challenge → Steam (`NotifyClientConnect` → `BeginAuthSession`) → `NET_CreateNetChannel(..., client)` (обработчик сообщений = клиент) → `netchan->SetChallengeNr(challenge)` (challenge netchan = challenge из connect) → `client->Connect(...)` (состояние CONNECTED) → отправка `'B'`. - `CBaseClient::ProcessSignonState` (baseclient.cpp): если `state > CONNECTED` и `spawn_count != GetSpawnCount()` → `Reconnect()`; если `state != m_nSignonState` → `Reconnect()`; иначе `SetSignOnState(state, spawn)`. - `CBaseClient::SetSignOnState`: при текущем состоянии `CONNECTED` → `m_bSendServerInfo = true`. - `CBaseServer::SendPendingServerInfo` (baseserver.cpp, вызывается каждый кадр из `RunFrame` после `UpdateUserSettings`): `if (cl->m_bSendServerInfo) cl->SendServerInfo()`. - `CBaseClient::SendServerInfo`: собирает Print + ServerInfo + Tick + реплицированные convar'ы + сигнон-байтлайны стрингтаблиц, шлёт одним `SendData`, ставит состояние NEW и шлёт `NET_SignonState(NEW)`. - `CNetChan::ProcessPacketHeader` (leak): `sequence u32, ack u32, flags u8, checksum u16, relState u16 (в GMod! в утечке u8 — GMod 16 подканалов), challenge u32`. Надёжная часть: `subchannel u4 (GMod)`, по 1 биту на поток, затем single/compressed + varint32 + данные. - Ответ нужен клиенту в состоянии CONNECTED: `Net_SetConVar` + `Net_SignonState(2)`. Наши пакеты доходят (сервер отражает `relState=1` — надёжные данные приняты), connect принимается, НО сигнон-блок не приходит. - GMod-специфика в бинарнике: convar `sv_signon_dos_disconnect` («Number of extra signon state confirmations required to disconnect a misbehaving client»), проверка `NET_SignonState receveied spawn count of -1 without SIGNONSTATE_CONNECTED!`. - Неизвестные connectionless-пакеты (`default:` в `ProcessConnectionlessPacket`) передаются в Steam (`SteamGameServer()->HandleIncomingPacket`) — оттуда, например, ответ `'c'` + нули на запросы Steam. **Что проверено и НЕ помогло** (сигнон-данные всё равно не приходят): разные `SignonState` (2/3, в т.ч. повторная отправка), convar'ы в hello, `CLC_ClientInfo`, 3/4 бита индекса подканалов, разные `relState`, длительное ожидание (150 с), повтор hello раз в 1.5–5 с. **Ограничение тестов**: Steam/сервер лимитируют частоту: после успешного подключения повторный connect с тем же SteamID ~2–3 минуты отвергается (`k_EBeginAuthSessionResultDuplicateRequest` → `#GameUI_ServerRejectSteam`), а десятки запросов тикетов подряд приводят к тому, что Steam выдаёт тикеты, которые сервер отвергает. Нужно: 1 подключение на пару минут, тикет кэшировать (сделано). ### 13.2. Дизассемблер `CBaseServer::SendPendingServerInfo` (GMod engine_srv.so) ``` cl = m_Clients[i] if (!cl->[0x14d]) continue ; m_bSendServerInfo chan = cl->[0x184]; if (!chan) continue ; m_NetChannel adr = chan->GetRemoteAddress() ; vcall 0xc4 if (cl->[0x5af] != 0) goto SendServerInfo ; ← ДОП. ФЛАГ КЛИЕНТА if (adr.IsLocalhost() || adr.IsLoopback() || adr.IsReservedAdr()) goto SendServerInfo if (CBaseServer::vfunc(0x4c) == 1) continue ; (IsHLTV?) if (CBaseServer::vfunc(0x6c) != 0) continue ; (IsReplay?) if (Steam3Server()->[0x90] != 1) continue ; ← Steam-состояние сервера SendServerInfo() ; cl->vfunc(0xd0) ``` То есть для **удалённого** клиента с `cl->[0x5af] == 0` сигнон-блок не отправляется, пока не выполнены проверки. Поле `0x5af` в `CBaseClient`, скорее всего, — флаг завершённой Steam-аутентификации (`m_bFullyAuthenticated`), который ставится в `CSteam3Server::OnValidateAuthTicketResponse` при успешной проверке тикета (наш connect при этом проходит синхронную проверку и не отвергается). **Вывод (гипотеза, согласуется со всеми наблюдениями)**: наш тикет проходит синхронную проверку формата (connect принимается, `'B'` приходит, netchan работает, наши надёжные сообщения доходят), но **асинхронная валидация тикета в Steam не завершается успехом** → `m_bFullyAuthenticated` остаётся false → сервер не шлёт ServerInfo, хотя `m_bSendServerInfo` уже выставлен нашим `SignonState(CONNECTED)`. Что проверять дальше: - точная семантика тикета: возможно, для VAC-серверов нужен тикет, запрошенный иначе (например, `GetAuthTicketForWebApi`/`ISteamUser`-вариант либо ожидание `AuthSessionTicketResponse` с проверкой, что тикет именно для этого сервера — callback отдаёт `AuthTicket`, можно сверить); - проверить, приходит ли `ValidateAuthTicketResponse` вообще (на своей стороне видно только по логам сервера — недоступны; косвенно: если бы пришёл failure, сервер бы кикнул («Invalid STEAM UserID Ticket»), а кика нет); - альтернатива для отладки: поднять локальный srcds на незалоченном порту (сейчас мешает сломанный резолвер в системе — `systemd-resolved` не запущен, см. §13). ### 13.5. ОПРОВЕРЖЕНИЕ §13.3/§13.4 — исходный код был верен Два «исправления» (3 бита подканала и MSB-first битов) оказались **ошибкой анализа** и откачены. Проверка велась по главному признаку: меняется ли `InReliableState` в пакетах сервера (это прямое свидетельство, прочитал ли сервер наш reliable-блок). | вариант | `InReliableState` сервера | вывод | |---|---|---| | было: 4 бита, LSB-first | `0x0001` | **сервер прочитал reliable-блок** | | 3 бита + MSB-first | `0x0000` | сервер блок не прочитал (регрессия) | **Почему 4 бита.** В `CNetChan::ProcessPacket` (engine_srv.so): ``` 189b93: lea 0x3(%esi),%edx ; сдвиг маски: 32-(numbits-1) 189ba6: lea 0x4(%esi),%eax ; новая битовая позиция = +4 бита 189bd0: and $0xf,%edx ; маска 0x0F = 4 бита ``` То есть индекс читается **4 битами** (`and $0xF`, позиция +4). Прежнее значение 4 было правильным. **Почему LSB-first.** `bf_write::WriteByte` при невыровненной позиции: ``` 206666: mov (%eax),%eax ; базовое слово 206668: and $0x1f,%esi ; смещение бита в слове 20667a: rol %cl,%eax ; циклический сдвиг байта на это смещение ``` Значение байта циклически сдвигается влево на `бит-позицию` и попадает в слово как есть → бит 0 байта ложится на младший бит позиции, то есть **LSB-first** внутри байта. Наш исходный `write_one_bit` (`1 << bit_index`) соответствует движку. То же верно для `ReadUBitLongNoInline`: при `esi == 0` значение = `word & ((1<5` в `ProcessMessages` | | Потоков фрагментов | **2** | `cmp $0x2,%esi` (цикл чтения потоков) | | Флаг reliable | `0x01` | `and $0x1,%esi` | | Флаг challenge | `0x20` | `or $0x21,%edx` в `SendDatagram` | | Ширина reliable_state | 16 бит | `IN_RELIABLE_STATE_BITS = 16` | | Тело `NET_SignonState` | **1 байт** state + **4 байта** spawn count | `NET_SignonState::ReadFromBuffer`: запись в `+0x10`, затем 32 бита в `+0x14` | | Формат cookie в connect | `u16 len` + `u64 SteamID` + тикет | `msg.ReadShort()` / `ReadBytes` в `baseserver.cpp` | | Нумерация сообщений | таблица GMod (SignonState=6) | согласуется с `SVC_Print=7`, `ServerInfo=8` | **Живое подтверждение:** сервер принимает и подтверждает наши пакеты (`ack` растёт синхронно с нашим `seq`), а `InReliableState` после нашего hello становится `1` — то есть **reliable-блок прочитан**, сообщения разобраны, `net_SignonState` обработан, `m_bSendServerInfo` выставлен. **Блокер — только Steam-авторизация.** `SendPendingServerInfo` (дизасм, §13.2) для публичного IP требует `m_bFullyAuthenticated` (поле клиента `0x5af`), которое ставит исключительно `CSteam3Server::OnValidateAuthTicketResponse`. Ни кика, ни сообщения об ошибке мы не получаем — сервер просто молчит, что соответствует ветке «`ClientFindFromSteamID` не нашёл клиента → тихий `return`». **Проверено и исключено:** - наш SteamID валиден (universe=1, type=1, instance=1, accountid=1533606282); - Steam-клиент залогинен (happy10635, тот же SteamID); - cookie принимается сервером (connect не отклоняется ⇒ `BeginAuthSession` вернул OK ⇒ формат тикета верный); - долгое ожидание (170 с) не помогает. **Почему srcds не даёт серверных логов:** `srcds_linux` зависает на `SteamAPI_Init` (не поднимается IPC с запущенным Steam-клиентом) — пробовал и локальный `steamclient.so` игры, и клиентский `linux32/steamclient.so`. Резерв: временная подмена `steamclient.so` на симлинк клиентской библиотеки не помогла; файлы возвращены в исходное состояние. **Следующие шаги (в порядке полезности):** 1. починить `SteamAPI_Init` у srcds — тогда видны серверные логи (`Sending server info`, код ответа валидации) и весь дальнейший сигнон; 2. как обходной путь — сервер с `sv_lan`/insecure: условия `IsLocalhost() || IsLoopback() || IsReservedAdr()` в `SendPendingServerInfo` снимают требование авторизации, значит можно полностью отладить разбор signon-блока без Steam; 3. после получения server info — string tables, send tables, snapshots, entities (следующие шаги M3/M7). ### 13.7. Source.NET: что удалось вытащить как референс Репозиторий `SourceDotNET/Source.NET` (клонирован в `/tmp/opencode/Source.NET`, нужны сабмодули `Dependencies/*`). Собирается целиком (.NET 10), но **dedicated-сервер не запускается**: `Source.Engine/EngineBuilder.BuildServer()` падает с `No service for type 'Source.Common.Engine.IEngineAPI'` — серверная часть проекта не дописана (движок интерфейса нужен клиенту с GUI). Поэтому Source.NET полезен как **спецификация поведения**, а не как стенд. Что оттуда взято (и подтверждено дизасмом GMod-сервера): 1. `Source.Engine/Client/ClientState.cs`, `SetSignonState`: в состоянии `Connected` клиент делает `NetChannel.Clear()`, `SetTimeout`, `SetMaxBufferSize` и отправляет **два** сообщения — `NET_SetConVar` (все FCVAR_USERINFO) и `NET_SignonState(CONNECTED, -1)`. Наш поток этому соответствует. 2. `Source.Engine/NetChannel.cs`, `ProcessPacketHeader` — точный порядок заголовка: `seq` u32, `ack` u32, `flags` u8, `[checksum u16]`, `reliable_state` 16 бит, `[choked u8]`, `[challenge u32]`, далее при `Reliable`: индекс подканала + биты потоков + данные фрагментов. 3. `ReadSubChannelData` — формат фрагмента: `singleBlock` бит; если да: бит сжатия, затем **VarInt32** длины, затем байты. Наш `build_hello_packet` пишет ровно это. 4. `Source.Engine/Server/BaseClient.cs`, `SetSignOnState`: `NeedSendServerInfo = true` ровно на `Connected`; проверка `msg.SignOnState != m_SignonState` → `Reconnect()`. 5. `BaseServer.SendPendingServerInfo` — в референсе проверки авторизации нет, она есть именно в GMod (дизасм §13.2), поэтому сервер GMod и молчит. Вывод: наш клиент по формату соответствует и Source.NET, и GMod-серверу; расхождений в байтовых форматах не осталось. ### 13.8. Локальный сервер-эталон: Source.NET Dedicated (поднят и работает) Цель — получить серверную сторону в логе, чтобы видеть, что именно сервер думает о нашем клиенте. Оригинальный srcds не подходит: в нативном Steam-клиенте нет GMod (app 4000), из-за чего `SteamGameServer_Init` не регистрирует игровой сервер и висит (`[API loaded no]`, IPC-вызовы IClientGameServerInternal::* таймаутятся). Установка GMod DS (app 4020) через steamcmd не помогает — дело в клиенте, а не в файлах сервера. **Source.NET Dedicated удалось запустить** (`/tmp/opencode/Source.NET`): 1. `git clone --recursive` — обязательны сабмодули `Dependencies/{Steamworks.NET,LuaJIT}` (иначе 59 ошибок `HAuthTicket`/`CSteamID`). 2. .NET 10 SDK (в системе был только 9) — ставится `dotnet-install.sh --channel 10.0`. 3. **Собирать конфигурацией `Release (Dedicated)`** — она задаёт `DefineConstants=SWDS;DEDICATED`, а регистрация `IEngineAPI` стоит за `#if SWDS`. Обычный `Release` падает с `No service for type 'IEngineAPI'`. 4. Подложить `libsteam_api64` (и `libsteam_api64.so`) в каталог сборки — взять из steamworks-sys `redistributable_bin/linux64/libsteam_api.so`. Без него падает `SteamAPI_SetBreakpadAppID`. 5. Слинковать контент: `platform` и `sourceengine` из установки GMod (`/mnt/games/SteamLibrary/.../GarrysMod`), плюс `garrysmod` — ради карт (`Map_IsValid: No such map 'maps/de_dust2.bsp'` без него). 6. Запускать с `+map gm_construct +sv_lan 1 +maxplayers 8` и отвечать `Y` на вопрос «Missing required content». Дальше сервер доходит до `SV_ActivateServer(finished)` и крутит кадры (`GameEngine.Frame()` в `dotnet-stack report`). **Что выяснилось о нашем клиенте** (это ценно): - Через **loopback (127.0.0.1) сервер не отвечает** на accept: Source.NET хранит адрес клиента как `1.0.0.0` и шлёт `S2C.Connection` не туда (в логе `Client "..." connected ()`, на проводе ответа нет). - Через **LAN-адрес (192.168.31.36) всё работает**: приходит `FFFFFFFF 42 "0000000000\0"`, клиент его принимает («подключение принято, netchan-сессия началась»), и сервер начинает слать netchan-пакеты (`01 00 00 00 00 00 00 00 20 ...`). - Значит наш разбор connect/accept корректен; расходился только адрес, что является багом сервера, а не клиента. **Оставшиеся препятствия Source.NET-сервера:** - `GetFreeClient` переиспользует слот только при совпадении адреса, а адрес хранится как `1.0.0.0` → при повторных коннектах слоты не возвращаются и сервер быстро уходит в `#GameUI_ServerRejectServerFull`. Нужен рестарт сервера между попытками. - Сервер падает на GMod-контенте: `Weapon (weapon_ar2) using undefined primary ammo type (AR2)` — несовместимость Lua/GMod API, к нашему клиенту отношения не имеет. - A2S (`TSource Engine Query`) не реализован. Вывод: для отладки клиента полезнее снимать трафик **настоящего** клиента — сервер тогда не нужен вовсе. ### 13.9. Эталонный захват настоящего GMod (ClassicBox) — 8091 пакетов Снят pcap полной сессии настоящего клиента (`/tmp/opencode/real_client.pcap`). Инструменты: `/tmp/opencode/pcap2.py` (разбор pcap, **заголовок 24 байта**, а не 16!) и `/tmp/opencode/ncdec.py` (декод reliable-блока). **Рукопожатие полностью совпало с нашим** (побайтно, порядок полей): | пакет | что | наш клиент | |---|---|---| | C→S `q` | getchallenge + наш challenge | так же | | C→S `k` | `18000000` протокол, `03000000` authProtocol=3, challengeNr, challenge, checksum, имя, пароль, `2026.04.29`, `f200`=242, SteamID, тикет | так же | | S→C `B` | challenge + `"0000000000\0"` | так же | **Найденное расхождение — `InReliableState`:** | | первый netchan-пакет | |---|---| | настоящий клиент | `seq=1 ack=0 flags=0x21 csum=0x7568 rel=0x0000` (531 б) | | наш клиент (было) | `seq=1 ack=0 flags=0x21 csum=0x58df rel=0x0001` (208 б) | `InReliableState` — это «какие подканалы мы **хотим получить** от сервера», а не «сколько мы отправили». Настоящий клиент в первом пакете шлёт **0**, и бит подканала выставляется только после приёма серверных данных. Мы же ставили 1 (костыль `if messages.is_empty() {0} else {1}`). Исправлено на 0. Ещё наблюдаемые отличия (наш выбор, не ошибка): - настоящий клиент **сжимает** reliable-блок (LZSS, бит compressed=1) и шлёт его несколькими фрагментами — 531 байт против наших 208. Сжатие необязательно (в `ReadSubChannelData` бит сжатия проверяется, но 0 допустим); - наш hello — один фрагмент, этого достаточно для `SetConVar`+`SignonState`. **Объём эталона:** 536 reliable-пакетов S→C и 878 C→C за сессию — полный signon (server info, send tables, class info, string tables), снапшоты и обычный игровой трафик. Это готовый фикстур для реализации разбора signon-блока без живого сервера. **Ограничение повторной проверки:** когда в игре настоящий клиент того же SteamID, наш вход отбивается `#GameUI_ServerRejectSteam` (`k_EBeginAuthSessionResultDuplicateRequest`). Для проверки правки нужен перерыв в сессии. ### 13.10. Заголовок reliable-фрагмента: лишний бит «сжатие» (исправлено) Перебором по эталону установлен точный layout одиночного блока: ``` 4 бита индекс подканала 2 бита биты потоков (Normal, File) 1 бит «не single-block» (0 = одиночный блок) VarInt32 длина данных данные[длина] ``` Проверка перебором по захвату: `конец бит=23, префикс=1, поле=VarInt32, длина=511`. То есть **4 + 2 + 1 + 16 = 23 бита**, и **бита «сжатие» перед длиной нет**. Мы же писали `singleBlock`, затем бит «сжатие», затем VarInt32 — то есть **23→24 бита**, и сервер читал длину с неверного смещения. Собственный декодер ошибку не показывал: он разбирал наш пакет по той же неверной схеме и «успешно» доставал наши же конвары — классическая ловушка самосогласованной ошибки. Расхождение вскрылось только сравнением с настоящим клиентом. **Дополнительно** подтянут точный набор USERINFO-конваров настоящего GMod (28 шт., включая `net_maxroutable=1260`, `rate=1048576`, `cl_defaultweapon=weapon_physgun`, `fov_desired=100`, `gmod_language=ru`, `cl_predict`, `cl_interpolate`). После правок наш первый netchan-пакет разбирается тем же декодером и даёт ту же структуру, что эталон (509 байт против 531 — разница ровно в длине строки `name`). **Текущее состояние.** Формат пакета теперь совпадает с настоящим клиентом, но сервер всё ещё не шлёт server info. Значит оставшийся барьер — не формат, а серверный `m_bFullyAuthenticated` (асинхронная Steam-валидация, §13.2), которую с клиента не видно: сессия на стороне Steam считается занятой, а при исчерпании сессии приход��т `#GameUI_ServerRejectSteam`. ### 13.11. Разбор signon-блока по фикстуре (реализовано) Инструменты в `/tmp/opencode` (в репозиторий не кладутся — это артефакты разбора): | файл | назначение | |---|---| | `pcap2.py` | разбор pcap (**заголовок 24 байта**, linktype 276 = Linux SLL2) | | `reliable.py` | реассемблер reliable-фрагментов по (подканал, поток) | | `msgs.py`, `signon.py` | декод сообщений по спецификации Source.NET | | `ncdec.py`, `scan.py`, `brute.py` | декод одного пакета / перебор раскладок | | `lzss.py` | распаковщик LZSS Valve (в эталоне не потребовался) | **Что собрано из захвата:** 615 reliable-блоков от сервера. Из них полностью разбираются первые (сигнон); крупные send tables и string tables идут multi-fragment (63 пакета, 72 КБ) и сжатыми (68 пакетов, 81 КБ) — их разбор ещё предстоит. **Исправленная раскладка `SVC_ServerInfo`** (была ошибкой: `max_classes` читался как long вместо 16 бит, вместо MD5 карты — 4 байта, `ServerOS` читался как строка, не было `loading_url`/`gamemode`). По спецификации: ``` short protocol | long serverCount | bit IsHLTV | bit IsDedicated | long legacyCRC | short maxClasses | 16 байт MD5 | byte slot | byte maxClients | float tickInterval | byte ServerOS | cstr gameDirectory | cstr mapName | cstr skyName | cstr hostName | cstr loadingURL | cstr gamemode ``` Проверено на эталонном блоке ClassicBox (305 байт, `crates/net/tests/fixtures/classicbox_signon_block.bin`): ``` proto=24 count=2 hltv=0 ded=1 maxcl=48 slot=2 tick=0.030303 os=108 dir='garrysmod' map='gm_bigcity_improved_rp' sky='painted' host='ClassicBox' gmod='sandbox' url='https://classic-box.ru/loadscreen/loadscreen' md5=008236620a39ac1c74f3dbc39933d3ad ``` Первый блок начинается с `SV_Print`: «ClassicBox / Gamemode: Sandbox / Map: gm_bigcity_improved_rp / Players…» — значения сходятся с ответом A2S. Пять тестов в `crates/net/tests/signon.rs` фиксируют эти значения, чтобы регрессия в раскладке сразу ломала сборку. **Статус сетевого слоя:** заголовок пакета, реассемблер фрагментов, connect, hello и `SV_ServerInfo` — по эталону подтверждены. Осталось разобрать multi-fragment переносы (send tables, string tables), сжатие и снапшоты. ### 13.12. Source.NET — источник истины, а не pcap (урок процесса) Замечание «у нас есть исходники Source.NET, зачем мы реверсем» — справедливое, и оно указывает на потерянное время. Правильный порядок: **сначала читать `Source.Common/Networking/Protocol.cs` и `Source.Engine/NetChannel.cs`**, и лишь потом сверять с эталонным pcap. Что дало чтение исходников за секунды (после часа перебора по захвату): ```csharp // Source.Common/Networking/Protocol.cs FRAGMENT_BITS = 12; FRAGMENT_SIZE = 1 << 12; // 4096, а НЕ 254 MAX_FILE_SIZE_BITS = 30; // startFragment = 30-12 = 18 бит NETMSG_TYPE_BITS = 6; MAX_PAYLOAD = 288000; MAX_ROUTABLE_PAYLOAD = 1260; // Source.Common/Networking/IConnectionlessPacketHandler.cs public enum PacketFlag { Reliable = 1<<0, Compressed = 1<<1, Encrypted = 1<<2, Split = 1<<3, Choked = 1<<4, Challenge = 1<<5 } ``` **Найденный баг.** У нас было `PACKET_FLAG_CHOKED = 1<<3` — это на самом деле `Split`. Значит в пакетах сервера с выставленным битом 3 мы читали лишний байт «choked», и `reliable_state` вместе с `challenge` уезжали на 8 бит — заголовок такого пакета разбирался неверно целиком. Исправлено на `Choked = 1<<4` + `Split = 1<<3`, закрыто тестами `packet_flags_match_specification` и `header_with_choked_flag_has_extra_byte`. **Про порядок бит.** `Source.Common/Bitbuffers/bf_read.cs:752` (`ReadUBitLong`) — `dw1 = word >> startBit; dw2 = word << (32 - startBit)` — это в точности тот алгоритм, что я дизассемблировал из `engine_srv.so`: значения собираются **LSB-first**. Подтверждено дважды. **Про сжатие в single-block.** Source.NET читает бит сжатия перед длиной, но эталон однозначно показывает 23-битный заголовок (4+2+1+VarInt32) без него. Похоже, GMod сигналит сжатие флагом пакета, а не фрагмента; наша правка опирается на измерение, источник тут расходится. **Что уже совпадало с исходником** (значит, брали правильно): `FRAGMENT_SIZE = 1<<12 = 4096`, `FRAGMENT_OFFSET_BITS = 18`, `MAX_FRAGMENTS_PER_PACKET_BITS = 5`, `NETMSG_TYPE_BITS = 6`, `IN_RELIABLE_STATE_BITS = 16`, `SubChannel.MAX_BITS = 4` (совпало с дизасмом), порядок `net.SetConVar` → `net.SignonState(CONNECTED, -1)`. ### 13.13. Полнота эталонного захвата: send tables в нём отсутствуют Проверка непрерывности `seq` в `real_client.pcap`: | сторона | netchan-пакетов | пропусков | потеряно | |---|---|---|---| | сервер | 2718 | 164 | **2576** | | клиент | 3204 | 82 | 201 | Основная потеря — в начале сессии (пакеты #63…#130, по 30+ подряд), то есть именно там, где сервер шлёт send tables, baselines и string tables. **Вывод:** отсутствие send tables в эталоне — дефект захвата (трафик шёл через SOCKS-прокси), а не дефект парсера. Проверено: собранные 582 блока разбираются до конца, первый блок (`#34`, 305 байт) содержит `SV_Print` (баннер ClassicBox) и `SV_ServerInfo` (proto=24, count=2, ded=1, maxcl=48, tick=0.030303, `dir=garrysmod`, `map=gm_bigcity_improved_rp`) — ровно тот блок, что лежит в фикстуре `tests/fixtures/classicbox_signon_block.bin`. Многофрагментных (multi-fragment) передач в читаемом виде в захвате нет: все собранные блоки меньше `FRAGMENT_SIZE`. Значит дальнейшую верификацию этой ветки проводить не на чем — но её раскладка теперь известна из исходника и совпадает с нашей реализацией. ### 13.14. Устойчивость разбора фрагментов `ReadSubChannelData` в Source.NET при «неожиданном» фрагменте (за пределами диапазона, нулевой длине, выходе за `bytes`) **не считает это ошибкой**: делает `data.Return()` и выходит, передача будет прислана заново. Наш код возвращал `NetError`, что сбрасывало разбор всех остальных сообщений пакета — достаточно одного опоздавшего фрагмента, чтобы потерять весь signon-блок. Исправлено на отбрасывание передачи (`stream.reset()`) с `trace`-логом. ### 13.15. Распределение флагов в эталоне | флаги | пакетов | состав | |---|---|---| | 0x20 / 0xa0 / 0x60 | 2187 | Challenge (+ биты добивки 0x40/0x80) | | 0x21 / 0x61 / 0xa1 | 527 | Reliable\|Challenge (+ добивка) | | 0xb0, 0xa7, 0xbf, 0xd3 | 4 | единичные Split/Choked/Compressed/Encrypted | То есть правка `CHOKED = 1<<4` затрагивала 3 пакета из 2718 — редко, но каждый из них раньше давал рассинхрон на 8 бит. Биты 6–7 (0x40/0x80) — добивка, их значения игнорируются. ### 13.16. Раскладка signon-таблиц (SVC_SendTable / ClassInfo / CreateStringTable) Эталонного pcap с этими сообщениями нет (§13.13), поэтому раскладка взята из исходников — `Source.Common/Networking/NetMessages.cs`: ```csharp // SVC_SendTable (строка 305) — имени у таблицы НЕТ NeedsDecoder = buffer.ReadOneBit() != 0; Length = buffer.ReadShort(); return buffer.SeekRelative(Length); // ровно Length бит данных // SVC_ClassInfo (строка 329) int numServerClasses = buffer.ReadShort(); int serverClassBits = (int)MathF.Log2(numServerClasses) + 1; CreateOnClient = buffer.ReadBool(); if (CreateOnClient) return !buffer.Overflowed; // списка классов не будет // далее по одному: id шириной serverClassBits, class_name, data_table_name // SVC_CreateStringTable (строка 413) byte prefixR = (byte)buffer.PeekUBitLong(8); // СМОТРИМ, не читаем if (prefix == ':') { IsFilenames = true; buffer.ReadByte(); } TableName = buffer.ReadString(500); int encodeBits = (int)buffer.ReadUBitLong(5); // log2(max_entries) NumEntries = (int)buffer.ReadUBitLong(encodeBits + 1); Length = (int)buffer.ReadVarInt32(); UserDataFixedSize = buffer.ReadBool(); if (UserDataFixedSize) { UserDataSize = ReadUBitLong(12); UserDataSizeBits = ReadUBitLong(4); } DataCompressed = buffer.ReadBool(); return buffer.SeekRelative(Length); ``` **Баг, найденный круговым тестом сразу же.** Префикс `:` нужно *смотреть* (`PeekUBitLong`), а не читать. Наивное `ReadByte()` безусловно съедает первый байт имени, и таблица `userinfo` читается как `serinfo` — а дальше по потоку едет смещение. Исправлено добавлением `BitReader::peek_byte`. Отдельно: `SVC_SendTable` не содержит имени таблицы. Мой прежний Python-декодер (`msgs.py`) читал `needs_decoder + имя + props` — это раскладка Source 2013 SDK для другой цели; для GMod верна схема Source.NET (`bit + short + блок бит`). Идентичность таблицы определяется порядком прихода — их нумеруем сами. **Ширина `id` класса** = `floor(log2(n)) + 1`, как в `MathF.Log2(n)+1`. У нас это `32 - leading_zeros(n)`. Число классов приходит из сети, поэтому ограничено `MAX_SERVER_CLASSES = 4096` (правило #1 из AGENTS.md: никаких `unwrap` и неограниченных циклов по сетевым данным). **Проверка.** Круговые тесты `crates/net/tests/signon_tables.rs` (10 шт.): собирают сообщение нашим же `BitWriter` по спецификации и разбирают обратно. Это ловит именно ошибку позиционирования после блока данных переменной длины — ровно тот класс ошибок, что стоил нам предыдущих двух «правок». ### 13.17. Протокол как источник истины (итог по уроку Source.NET) Порядок, который надо соблюдать с самого начала: 1. `Source.Common/Networking/Protocol.cs` — константы, флаги, фрагменты. 2. `Source.Common/Networking/IConnectionlessPacketHandler.cs` — `PacketFlag`, `SubChannel`. 3. `Source.Common/Networking/NetMessages.cs` — раскладка сообщений (читать `ReadFromBuffer`, а не `WriteToBuffer`). 4. `Source.Engine/NetChannel.cs` — порядок обработки пакета. 5. И только затем pcap — чтобы проверить, что GMod нигде не отступил. Чтение исходников заняло секунды и сразу дало `FRAGMENT_SIZE=4096`, `CHOKED=1<<4` и отсутствие имени у `SVC_SendTable`. Перебор по захвату занял час и дал две регрессии. Правило на будущее: **сначала исходник, потом измерение**; измерение нужно только там, где движок GMod отличается (сжатие single-block). ### 13.18. Первый пакет сверен с настоящим клиентом побайтово Теперь, когда набор конваров исправлен (§13.19), наш первый netchan-пакет можно сравнить с пакетом настоящего GMod напрямую — эталон лежит в pcap (#24, 531 байт). Сравнение выполняется скриптом по захвату: ``` эталон 531 байт, наш 531 байт заголовок различается на: [9, 10, 13, 14, 15, 16] ← checksum и challenge тело: эталон 514 байт, наш 514 байт различающихся байт в теле: 1 [513] эталон=7f наш=87 ``` То есть **тело совпадает в 513 из 514 байт**. Различаются: - байты 9–10 — контрольная сумма (считается от challenge, а сессия другая); - байты 13–16 — сам challenge; - последний байт тела — **один бит добивки**. Про добивку: в эталоне три клиентских пакета с полезной нагрузкой, у всех ровно 1 бит добивки, и значения разные (`1`, `0`, `0`). Значит это не правило, а остаточный бит битового писателя. Менять наше `1` на `0` по одному биту — ровно та ошибка, что уже дважды приводила к регрессии (3 бита подканала, MSB-first). Не трогаем: при разборе reliable-блока получатель читает ровно `length` байт и добивку не смотрит. ### 13.19. Не хватало userinfo-конвара `cl_playermodel` Наш пакет был на 22 байта короче эталонного. 22 байта — это ровно `cl_playermodel\0male07\0`. В эталонном наборе между `gm_snapangles` и `rate` есть `cl_playermodel = male07`, а у нас его не было вовсе. Без него сервер не знает, какую модель ставить нашему энтити-игроку. Набор вынесен в `net::client::userinfo_pairs()` и закрыт тестом `client_hello::our_convars_match_reference_exactly`, который сверяет наш список с эталонным **попарно, включая порядок**; фикстура — `fixtures/classicbox_client_hello.bin` (511 байт, вырезано из пакета #24). Теперь длина пакета совпадает с эталоном до байта при том же имени. ### 13.20. Source.NET отвергает наш фрагмент — это расхождение движков Наблюдение на Source.NET-сервере (свежий слот, `sv_lan 1`, подключение по LAN): | наш фрагмент | реакция сервера | |---|---| | GMod-формат (без бита сжатия, по умолчанию) | `Malformed fragment offset 0 len 0 buffer size 0` | | с битом сжатия (`SOURCE_RS_FRAG_COMPRESS_BIT=1`) | фрагмент принят, но прочитан как 962 байта вместо 481 | Проверено, что бинарь Source.NET новее исходника (сборка 12:58, исходник 12:18), то есть поведение соответствует коду `ReadSubChannelData`, где бит сжатия есть. При этом наш заголовок фрагмента **точно совпадает с эталоном GMod**: 4 бита подканала + 2 бита потоков + 1 бит single-block = 7 бит, и VarInt32 сразу следом. Отсюда важный вывод для проекта: **формат фрагмента нельзя проверять на Source.NET**. Он валидируется эталонным pcap настоящего GMod; Source.NET — другая реализация Source 1 и в этой детали отличается. Отсюда же и диагностический флаг `SOURCE_RS_FRAG_COMPRESS_BIT`: он нужен только чтобы отделить это расхождение от прочих ошибок, и по умолчанию выключен. Ограничения Source.NET-сервера для проверок (всё подтверждено на практике): - слоты не возвращаются при переподключении (`GetFreeClient` требует совпадения адреса, а адрес хранится как `1.0.0.0`) → `ServerFull`; перед каждой попыткой сервер надо перезапускать; - через loopback не отвечает (адрес клиента сохраняется как `1.0.0.0`); работает только LAN-адрес; - `getchallenge` клиент шлёт как `'q'`, не `'A'`; ответ — `'A'`; - нужен Steam-тикет, иначе сервер не отвечает.