gmod-rs/docs/RECON.md
Niko Marmeladkov 12f94ae55a net: битовый буфер переведён на MSB-first, как в движке
Настоящая причина отсутствия server info: bf_write писал биты LSB-первым,
а Source читает/пишет MSB-первым (подтверждено дизассемблированием
bf_read::ReadUBitLongNoInline в engine_srv.so).

Из-за этого WriteOneBit(true) попадал в поле соседнего потока фрагментов,
а ID сообщений записывались перевёрнутыми — сервер не распознавал
net_SignonState и не выставлял m_bSendServerInfo.

- write_one_bit/write_ubit_long/read_one_bit/read_ubit_long: MSB-first;
- write_u32/write_short/read_long/read_short: побайтово little-endian
  (как ReadLong в движке);
- reliable_state кодируется через WriteUBitLong(16);
- тесты bit_order_matches_source и reliable_block_layout_is_canonical.
2026-10-12 11:52:28 +03:00

34 KiB
Raw Blame History

Разведка (снято 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, встроенные данные, внешние архивы <stem>_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/<map>/..., 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<<numbits)-1

Первым читается бит 7 байта. Значит и WriteOneBit, и WriteUBitLong должны писать MSB-first.

Почему это ломало всё. При LSB-first:

  • WriteOneBit(true) ставил бит в позицию 0 байта, а сервер читал позицию 7; из-за этого бит «поток 0 несёт данные» попадал в поле «поток 1» → сервер считал, что данные лежат в потоке File, а Normal пуст;
  • ID сообщений (net_SignonState, Net_SetConVar) записывались перевёрнутыми и сервер их не распознавал.

Итог: reliable-блок уходил, но не читался — ровно наблюдаемое поведение.

Важное различие, подтверждённое движком:

Операция Порядок
ReadUBitLong / WriteUBitLong / ReadOneBit / WriteOneBit MSB-first
ReadByte / ReadShort / ReadLong побайтово little-endian

(для 8 бит разницы нет, для 16/32 — есть). Именно поэтому reliable_state кодируется через WriteUBitLong(16): сервер шлёт InReliableState == 1 байтами 00 01, а не 01 00 — это и было видно в pcap.

Фикс: crates/net/src/bitbuf.rs переведён на MSB-first, reliable_state переведён на write_ubit_long(..., 16). Закреплено тестами bit_order_matches_source и reliable_block_layout_is_canonical.

Побочный эффект: исправлены все многобитные поля на проводе (ID сообщений, varint-длины, биты добивки), а не только подканал.