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

442 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Разведка (снято 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-длины, биты добивки), а не только подканал.