gmod-rs/docs/RECON.md
Niko Marmeladkov 77d70f6b9f net: добавлен недостающий userinfo-конвар cl_playermodel
Наш первый netchan-пакет был на 22 байта короче эталонного — ровно на
cl_playermodel\0male07\0. Без него сервер не знает, какую модель ставить
нашему энтити-игроку.

Набор конваров вынесен в client::userinfo_pairs() и теперь сверяется с
эталоном настоящего GMod (новая фикстура fixtures/classicbox_client_hello.bin,
511 байт из пакета #24) попарно, включая порядок. При том же имени наш пакет
теперь совпадает с эталонным побайтово: тело идентично в 513 из 514 байт,
различаются только checksum/challenge из-за другой сессии и один остаточный
бит добивки.
2026-10-12 14:51:57 +03:00

892 lines
66 KiB
Markdown
Raw Permalink 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.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<<n)-1)`, т.е. младшие биты байта.
Вывод: §13.2 был верен — сетевой слой работает корректно (сервер читает
наши `net_SignonState`, выставляет `m_bSendServerInfo`), а блокирует
`m_bFullyAuthenticated` в `SendPendingServerInfo`. Возвращаемся к анализу
Steam-аутентификации.
Методика на будущее: любую правку сетевого формата проверять по изменению
`InReliableState`/поведению сервера, а не по «правдоподобию» декодирования.
### 13.6. Полная верификация протокола (что подтверждено, а где блокер)
Прогон с живым сервером и трассировкой (после отката §13.5) даёт
однозначную картину.
**Подтверждено как ВЕРНОЕ** (проверено дизассемблированием `engine_srv.so`
и/или живым трафиком):
| Что | Значение | Чем подтверждено |
|---|---|---|
| Индекс подканала | **4 бита** | `and $0xf`, позиция `+4` в `ProcessPacket` |
| Порядок битов в bf_write/bf_read | **LSB-first** | `rol %cl,%eax` в `bf_write::WriteByte` |
| Ширина ID сообщения `NETMSG_TYPE_BITS` | **6** | `lea 0x6(%eax)` + проверка `>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 (<IP 1.0.0.0:41451>)`, на проводе ответа нет).
- Через **LAN-адрес (192.168.31.36) всё работает**: приходит
`FFFFFFFF 42 <challenge> "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 считается занятой, а при исчерпании сессии приход<D0BE><D0B4>т
`#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-тикет, иначе сервер не отвечает.