# Разведка (снято 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.3. НАЙДЕНА ПРИЧИНА: ширина индекса подканала (3 бита, не 4) Гипотеза §13.2 оказалась **неверной**: дело не в Steam-авторизации. Настоящая причина — в заголовке reliable-фрагментов. **Как обнаружено.** Захвачен трафик живого подключения (`SOURCE_RS_LOG=net=trace`). Сервер подтверждает наши netchan-пакеты (поле ack растёт 0→1→2→…), но в его ответах поле `InReliableState` **всегда равно 0**. Значит сервер принимает заголовок, но **не читает наш reliable-поток**. Побитовый разбор нашего hello-пакета: ``` seq=1 ack=0 flags=0x21 csum=0x58df rel=0x0001 chal=0x63896b24 payload[191]: 10 bc 01 c5 82 ... ``` | SUBCHANNEL_BITS | прочитанный subchannel | биты потоков | итог | |---|---|---|---| | **4** (было) | **1** | `[0, 0, 0]` | **0 фрагментов → reliable-данные молча отброшены** | | **3** (стало) | **0** | `[1, 0, 0]` | корректный single-block фрагмент | При 4 битах сервер читал индекс 1 (несуществующий подканал) и ноль фрагментов → `net_SignonState` не обрабатывался → `m_bSendServerInfo` не выставлялся → `SendPendingServerInfo` пропускал клиента → **серверный блок не уходил никогда**. **Подтверждено дизассемблированием GMod-сервера** (`engine_srv.so`, `CNetChan::ProcessPacket`): ``` 189b6f: and $0x1,%esi ; flags & Reliable (0x01) — наш флаг верен 189b93: lea 0x3(%esi),%edx ; читаем РОВНО 3 бита индекса подканала ``` То есть `SUBCHANNEL_BITS = 3` (8 подканалов, как в Source 2013/HL2), а не 4. Прежнее значение 4 было ошибочным предположением; из-за него наши надёжные сообщения молча терялись. Побочно подтверждено: - reliable-флаг пакета = `0x01` (`and $0x1`) — совпадает с нашим; - флаг challenge = `0x20` — совпадает; - `IN_RELIABLE_STATE_BITS = 16` — совпадает с Source.NET. Ограничение `SendPendingServerInfo` (для публичного IP требуется `m_bFullyAuthenticated`) реально, но не блокировало: сервер готов слать данные сразу после разбора нашего reliable-потока. Новый прогон подключения упирается уже в `#GameUI_ServerRejectSteam` — это лимит Steam на частые подключения с одного аккаунта (`k_EBeginAuthSessionResultDuplicateRequest`), а не ошибка клиента. ### 13.4. НАСТОЯЩАЯ ПРИЧИНА: порядок битов в `bf_write` был LSB-first Разбор §13.3 вскрыл более глубокий баг: **наш битовый буфер писал биты LSB-первым, а движок — MSB-первым**. Подтверждено дизассемблированием GMod-сервера (`bf_read::ReadUBitLongNoInline`, `engine_srv.so`): ``` 206f8d: and $0x1f,%esi ; смещение бита внутри 32-битного слова 206f9d: shl %cl,%eax ; последнее слово сдвигаем влево 206fa1: shr %cl,%edx ; первое слово сдвигаем вправо 206fa7: or %edx,%eax ; значение собирается ОТ СТАРШЕГО бита 206fb4: and %edx,%eax ; маска (1<