Настоящая причина отсутствия server info: bf_write писал биты LSB-первым, а Source читает/пишет MSB-первым (подтверждено дизассемблированием bf_read::ReadUBitLongNoInline в engine_srv.so). Из-за этого WriteOneBit(true) попадал в поле соседнего потока фрагментов, а ID сообщений записывались перевёрнутыми — сервер не распознавал net_SignonState и не выставлял m_bSendServerInfo. - write_one_bit/write_ubit_long/read_one_bit/read_ubit_long: MSB-first; - write_u32/write_short/read_long/read_short: побайтово little-endian (как ReadLong в движке); - reliable_state кодируется через WriteUBitLong(16); - тесты bit_order_matches_source и reliable_block_layout_is_canonical.
442 lines
34 KiB
Markdown
442 lines
34 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.3. НАЙДЕНА ПРИЧИНА: ширина индекса подканала (3 бита, не 4)
|
||
|
||
Гипотеза §13.2 оказалась **неверной**: дело не в Steam-авторизации. Настоящая
|
||
причина — в заголовке reliable-фрагментов.
|
||
|
||
**Как обнаружено.** Захвачен трафик живого подключения (`SOURCE_RS_LOG=net=trace`).
|
||
Сервер подтверждает наши netchan-пакеты (поле ack растёт 0→1→2→…), но в его
|
||
ответах поле `InReliableState` **всегда равно 0**. Значит сервер принимает
|
||
заголовок, но **не читает наш reliable-поток**.
|
||
|
||
Побитовый разбор нашего hello-пакета:
|
||
|
||
```
|
||
seq=1 ack=0 flags=0x21 csum=0x58df rel=0x0001 chal=0x63896b24
|
||
payload[191]: 10 bc 01 c5 82 ...
|
||
```
|
||
|
||
| SUBCHANNEL_BITS | прочитанный subchannel | биты потоков | итог |
|
||
|---|---|---|---|
|
||
| **4** (было) | **1** | `[0, 0, 0]` | **0 фрагментов → reliable-данные молча отброшены** |
|
||
| **3** (стало) | **0** | `[1, 0, 0]` | корректный single-block фрагмент |
|
||
|
||
При 4 битах сервер читал индекс 1 (несуществующий подканал) и ноль фрагментов
|
||
→ `net_SignonState` не обрабатывался → `m_bSendServerInfo` не выставлялся →
|
||
`SendPendingServerInfo` пропускал клиента → **серверный блок не уходил никогда**.
|
||
|
||
**Подтверждено дизассемблированием GMod-сервера** (`engine_srv.so`,
|
||
`CNetChan::ProcessPacket`):
|
||
|
||
```
|
||
189b6f: and $0x1,%esi ; flags & Reliable (0x01) — наш флаг верен
|
||
189b93: lea 0x3(%esi),%edx ; читаем РОВНО 3 бита индекса подканала
|
||
```
|
||
|
||
То есть `SUBCHANNEL_BITS = 3` (8 подканалов, как в Source 2013/HL2),
|
||
а не 4. Прежнее значение 4 было ошибочным предположением; из-за него
|
||
наши надёжные сообщения молча терялись.
|
||
|
||
Побочно подтверждено:
|
||
- reliable-флаг пакета = `0x01` (`and $0x1`) — совпадает с нашим;
|
||
- флаг challenge = `0x20` — совпадает;
|
||
- `IN_RELIABLE_STATE_BITS = 16` — совпадает с Source.NET.
|
||
|
||
Ограничение `SendPendingServerInfo` (для публичного IP требуется
|
||
`m_bFullyAuthenticated`) реально, но не блокировало: сервер готов слать данные
|
||
сразу после разбора нашего reliable-потока. Новый прогон подключения упирается
|
||
уже в `#GameUI_ServerRejectSteam` — это лимит Steam на частые подключения с
|
||
одного аккаунта (`k_EBeginAuthSessionResultDuplicateRequest`), а не ошибка
|
||
клиента.
|
||
|
||
### 13.4. НАСТОЯЩАЯ ПРИЧИНА: порядок битов в `bf_write` был LSB-first
|
||
|
||
Разбор §13.3 вскрыл более глубокий баг: **наш битовый буфер писал биты
|
||
LSB-первым, а движок — MSB-первым**.
|
||
|
||
Подтверждено дизассемблированием GMod-сервера
|
||
(`bf_read::ReadUBitLongNoInline`, `engine_srv.so`):
|
||
|
||
```
|
||
206f8d: and $0x1f,%esi ; смещение бита внутри 32-битного слова
|
||
206f9d: shl %cl,%eax ; последнее слово сдвигаем влево
|
||
206fa1: shr %cl,%edx ; первое слово сдвигаем вправо
|
||
206fa7: or %edx,%eax ; значение собирается ОТ СТАРШЕГО бита
|
||
206fb4: and %edx,%eax ; маска (1<<numbits)-1
|
||
```
|
||
|
||
Первым читается бит 7 байта. Значит и `WriteOneBit`, и `WriteUBitLong`
|
||
должны писать **MSB-first**.
|
||
|
||
**Почему это ломало всё.** При LSB-first:
|
||
- `WriteOneBit(true)` ставил бит в позицию 0 байта, а сервер читал позицию 7;
|
||
из-за этого бит «поток 0 несёт данные» попадал в поле «поток 1» →
|
||
сервер считал, что данные лежат в потоке File, а Normal пуст;
|
||
- ID сообщений (`net_SignonState`, `Net_SetConVar`) записывались
|
||
перевёрнутыми и сервер их не распознавал.
|
||
|
||
Итог: reliable-блок уходил, но не читался — ровно наблюдаемое поведение.
|
||
|
||
**Важное различие, подтверждённое движком:**
|
||
|
||
| Операция | Порядок |
|
||
|---|---|
|
||
| `ReadUBitLong` / `WriteUBitLong` / `ReadOneBit` / `WriteOneBit` | **MSB-first** |
|
||
| `ReadByte` / `ReadShort` / `ReadLong` | **побайтово little-endian** |
|
||
|
||
(для 8 бит разницы нет, для 16/32 — есть). Именно поэтому `reliable_state`
|
||
кодируется через `WriteUBitLong(16)`: сервер шлёт `InReliableState == 1`
|
||
байтами `00 01`, а не `01 00` — это и было видно в pcap.
|
||
|
||
Фикс: `crates/net/src/bitbuf.rs` переведён на MSB-first, `reliable_state`
|
||
переведён на `write_ubit_long(..., 16)`. Закреплено тестами
|
||
`bit_order_matches_source` и `reliable_block_layout_is_canonical`.
|
||
|
||
Побочный эффект: исправлены **все** многобитные поля на проводе (ID
|
||
сообщений, varint-длины, биты добивки), а не только подканал.
|