Взят прямо из Source.NET (IConnectionlessPacketHandler.cs, enum PacketFlag): Reliable=1, Compressed=2, Encrypted=4, Split=8, Choked=16, Challenge=32. Из-за неверного бита в пакетах сервера с выставленным битом 3 читался лишний байт, reliable_state и challenge уезжали на 8 бит. Добавлены тесты на константы флагов и на заголовок с Choked.
53 KiB
Разведка (снято 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/servergarrysmod/garrysmod_{000,001,002}.vpk(~772 МБ) +fallbacks_*.vpk(~104 МБ)sourceengine/*(hl2_* vpk),platform/*garrysmod/mount.cfg,gameinfo.txt— порядок монтирования и mountssrcds_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 байта (
IDSVv4), вершина 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 на симлинк клиентской библиотеки
не помогла; файлы возвращены в исходное состояние.
Следующие шаги (в порядке полезности):
- починить
SteamAPI_Initу srcds — тогда видны серверные логи (Sending server info, код ответа валидации) и весь дальнейший сигнон; - как обходной путь — сервер с
sv_lan/insecure: условияIsLocalhost() || IsLoopback() || IsReservedAdr()вSendPendingServerInfoснимают требование авторизации, значит можно полностью отладить разбор signon-блока без Steam; - после получения 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-сервера):
-
Source.Engine/Client/ClientState.cs,SetSignonState: в состоянииConnectedклиент делаетNetChannel.Clear(),SetTimeout,SetMaxBufferSizeи отправляет два сообщения —NET_SetConVar(все FCVAR_USERINFO) иNET_SignonState(CONNECTED, -1). Наш поток этому соответствует. -
Source.Engine/NetChannel.cs,ProcessPacketHeader— точный порядок заголовка:sequ32,acku32,flagsu8,[checksum u16],reliable_state16 бит,[choked u8],[challenge u32], далее приReliable: индекс подканала + биты потоков + данные фрагментов. -
ReadSubChannelData— формат фрагмента:singleBlockбит; если да: бит сжатия, затем VarInt32 длины, затем байты. Нашbuild_hello_packetпишет ровно это. -
Source.Engine/Server/BaseClient.cs,SetSignOnState:NeedSendServerInfo = trueровно наConnected; проверкаmsg.SignOnState != m_SignonState→Reconnect(). -
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):
git clone --recursive— обязательны сабмодулиDependencies/{Steamworks.NET,LuaJIT}(иначе 59 ошибокHAuthTicket/CSteamID).- .NET 10 SDK (в системе был только 9) — ставится
dotnet-install.sh --channel 10.0. - Собирать конфигурацией
Release (Dedicated)— она задаётDefineConstants=SWDS;DEDICATED, а регистрацияIEngineAPIстоит за#if SWDS. ОбычныйReleaseпадает сNo service for type 'IEngineAPI'. - Подложить
libsteam_api64(иlibsteam_api64.so) в каталог сборки — взять из steamworks-sysredistributable_bin/linux64/libsteam_api.so. Без него падаетSteamAPI_SetBreakpadAppID. - Слинковать контент:
platformиsourceengineиз установки GMod (/mnt/games/SteamLibrary/.../GarrysMod), плюсgarrysmod— ради карт (Map_IsValid: No such map 'maps/de_dust2.bsp'без него). - Запускать с
+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.
Что дало чтение исходников за секунды (после часа перебора по захвату):
// 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).