Наш первый netchan-пакет был на 22 байта короче эталонного — ровно на cl_playermodel\0male07\0. Без него сервер не знает, какую модель ставить нашему энтити-игроку. Набор конваров вынесен в client::userinfo_pairs() и теперь сверяется с эталоном настоящего GMod (новая фикстура fixtures/classicbox_client_hello.bin, 511 байт из пакета #24) попарно, включая порядок. При том же имени наш пакет теперь совпадает с эталонным побайтово: тело идентично в 513 из 514 байт, различаются только checksum/challenge из-за другой сессии и один остаточный бит добивки.
892 lines
66 KiB
Markdown
892 lines
66 KiB
Markdown
# Разведка (снято 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-тикет, иначе сервер не отвечает.
|