From 77d70f6b9f1d3d66a0ea95e0841e2f04e7957e5d Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Mon, 12 Oct 2026 14:51:57 +0300 Subject: [PATCH] =?UTF-8?q?net:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=BD=D0=B5=D0=B4=D0=BE=D1=81=D1=82=D0=B0=D1=8E?= =?UTF-8?q?=D1=89=D0=B8=D0=B9=20userinfo-=D0=BA=D0=BE=D0=BD=D0=B2=D0=B0?= =?UTF-8?q?=D1=80=20cl=5Fplayermodel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Наш первый netchan-пакет был на 22 байта короче эталонного — ровно на cl_playermodel\0male07\0. Без него сервер не знает, какую модель ставить нашему энтити-игроку. Набор конваров вынесен в client::userinfo_pairs() и теперь сверяется с эталоном настоящего GMod (новая фикстура fixtures/classicbox_client_hello.bin, 511 байт из пакета #24) попарно, включая порядок. При том же имени наш пакет теперь совпадает с эталонным побайтово: тело идентично в 513 из 514 байт, различаются только checksum/challenge из-за другой сессии и один остаточный бит добивки. --- crates/net/src/client.rs | 83 ++++++---- crates/net/src/netchan.rs | 17 ++ crates/net/tests/client_hello.rs | 145 ++++++++++++++++++ .../fixtures/classicbox_client_hello.bin | Bin 0 -> 511 bytes docs/RECON.md | 69 +++++++++ 5 files changed, 282 insertions(+), 32 deletions(-) create mode 100644 crates/net/tests/client_hello.rs create mode 100644 crates/net/tests/fixtures/classicbox_client_hello.bin diff --git a/crates/net/src/client.rs b/crates/net/src/client.rs index a24ecf0..bd26db4 100644 --- a/crates/net/src/client.rs +++ b/crates/net/src/client.rs @@ -8,6 +8,56 @@ use crate::netchan::{ use std::net::UdpSocket; use std::time::{Duration, Instant}; +/// Набор userinfo-конваров, который клиент отправляет в первом пакете. +/// +/// Порядок и значения — **ровно те, что шлёт настоящий GMod**: эталон взят +/// из первого netchan-пакета реального клиента и лежит в +/// `crates/net/tests/fixtures/classicbox_client_hello.bin`. Набор проверяется +/// тестом `client_hello::our_convars_match_reference_exactly`, поэтому +/// любое расхождение с настоящим клиентом будет поймано сразу. +/// +/// `name` подставляется отдельно: он зависит от того, под кем мы заходим. +/// Прочие значения соответствуют дефолтам GMod; позже их стоит брать из +/// конфига клиента, а не держать константами. +pub fn userinfo_pairs(name: &str) -> Vec<(String, String)> { + vec![ + ("tv_nochat", "0"), + ("cl_clanid", "0"), + ("name", name), + ("cl_interp_npcs", "0"), + ("cl_interp_ratio", "2"), + ("cl_predict", "1"), + ("cl_updaterate", "66"), + ("cl_thirdperson", "0"), + ("voice_loopback", "0"), + ("gm_snapgrid", "0"), + ("cl_lagcompensation", "1"), + ("closecaption", "0"), + ("cl_language", "english"), + ("english", "1"), + ("physgun_wheelspeed", "10000"), + ("physgun_rotation_sensitivity", "0.06"), + ("cl_defaultweapon", "weapon_physgun"), + ("cl_cmdrate", "66"), + ("net_maxroutable", "1260"), + ("gm_snapangles", "45"), + // Модель игрока: без неё сервер не сможет выбрать модель для нашего + // энтити. Раньше этот конвар мы не отправляли — пакет выходил на + // 22 байта короче эталонного. + ("cl_playermodel", "male07"), + ("rate", "1048576"), + ("cl_predictweapons", "1"), + ("cl_interpolate", "1"), + ("fov_desired", "100"), + ("cl_interp", "0.1"), + ("gmod_language", "ru"), + ("cl_autowepswitch", "1"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + /// Событие сетевой сессии. #[derive(Debug, Clone)] pub enum NetEvent { @@ -244,38 +294,7 @@ impl NetClient { let mut messages = if std::env::var_os("SOURCE_RS_NO_CONVARS").is_some() { Vec::new() } else { - vec![ClientMessage::SetConVar(vec![ - ("tv_nochat".to_string(), "0".to_string()), - ("cl_clanid".to_string(), "0".to_string()), - ("name".to_string(), self.name.clone()), - ("cl_interp_npcs".to_string(), "0".to_string()), - ("cl_interp_ratio".to_string(), "2".to_string()), - ("cl_predict".to_string(), "1".to_string()), - ("cl_updaterate".to_string(), "66".to_string()), - ("cl_thirdperson".to_string(), "0".to_string()), - ("voice_loopback".to_string(), "0".to_string()), - ("gm_snapgrid".to_string(), "0".to_string()), - ("cl_lagcompensation".to_string(), "1".to_string()), - ("closecaption".to_string(), "0".to_string()), - ("cl_language".to_string(), "english".to_string()), - ("english".to_string(), "1".to_string()), - ("physgun_wheelspeed".to_string(), "10000".to_string()), - ( - "physgun_rotation_sensitivity".to_string(), - "0.06".to_string(), - ), - ("cl_defaultweapon".to_string(), "weapon_physgun".to_string()), - ("cl_cmdrate".to_string(), "66".to_string()), - ("net_maxroutable".to_string(), "1260".to_string()), - ("gm_snapangles".to_string(), "45".to_string()), - ("rate".to_string(), "1048576".to_string()), - ("cl_predictweapons".to_string(), "1".to_string()), - ("cl_interpolate".to_string(), "1".to_string()), - ("fov_desired".to_string(), "100".to_string()), - ("cl_interp".to_string(), "0.1".to_string()), - ("gmod_language".to_string(), "ru".to_string()), - ("cl_autowepswitch".to_string(), "1".to_string()), - ])] + vec![ClientMessage::SetConVar(userinfo_pairs(&self.name))] }; messages.push(ClientMessage::SignOnState { state, diff --git a/crates/net/src/netchan.rs b/crates/net/src/netchan.rs index 2dce44e..4f65838 100644 --- a/crates/net/src/netchan.rs +++ b/crates/net/src/netchan.rs @@ -799,6 +799,15 @@ pub fn build_hello_packet( // + 1 (single-block) + VarInt32(511) = 23 бита. С добавленным битом // сервер читал длину с неверного смещения и отбраковывал фрагмент. w.write_one_bit(false); + // ВАЖНО: Source.NET этот бит читает (NetMessages.cs, ReadSubChannelData), + // и без него его сервер отвечает «Malformed fragment offset 0 len 0». + // То есть движок GMod и Source.NET тут расходятся. По умолчанию шлём + // как GMod (подтверждено эталоном); переключатель нужен, чтобы + // отделить это расхождение от остальных ошибок при проверке на + // Source.NET-сервере. + if std::env::var("SOURCE_RS_FRAG_COMPRESS_BIT").is_ok() { + w.write_one_bit(false); // «сжатие»: нет + } w.write_varint32(payload_bytes.len() as u32); for byte in &payload_bytes { w.write_byte(*byte); @@ -869,6 +878,14 @@ pub enum ClientMessage { } impl ClientMessage { + /// Записать сообщение в буфер. + /// + /// Видимость ограничена крейтом `net`; интеграционные тесты этого крейта + /// пользуются ей напрямую. + pub fn write_for_test(&self, w: &mut BitWriter) { + self.write(w); + } + fn write(&self, w: &mut BitWriter) { match self { ClientMessage::Nop => { diff --git a/crates/net/tests/client_hello.rs b/crates/net/tests/client_hello.rs new file mode 100644 index 0000000..d896fb6 --- /dev/null +++ b/crates/net/tests/client_hello.rs @@ -0,0 +1,145 @@ +//! Набор userinfo-конваров должен совпадать с настоящим клиентом GMod. +//! +//! Фикстур `fixtures/classicbox_client_hello.bin` — 511 байт, которые реальный +//! клиент GMod положил в первый netchan-пакет при подключении к ClassicBox +//! (пакет #24 в `real_client.pcap`). Это эталон: сервер формирует userinfo +//! игрока именно из этих пар, и по полноте набора видно, чего клиенту не +//! хватает. +//! +//! Тест ловит именно ту ошибку, которая была: мы не отправляли `cl_playermodel`, +//! и наш пакет был на 22 байта короче эталонного. + +use net::bitbuf::{BitReader, BitWriter}; +use net::client::userinfo_pairs; +use net::netchan::{ClientMessage, Message, parse_messages}; + +/// Полезная нагрузка первого netchan-пакета настоящего GMod. +const REFERENCE: &[u8] = include_bytes!("fixtures/classicbox_client_hello.bin"); + +/// Раскладывает `NET_SetConVar` из блока сообщений в список пар. +fn convars_of(block: &[u8]) -> Vec<(String, String)> { + let msgs = parse_messages(block, 0); + for m in &msgs { + if let Message::SetConVar(pairs) = m { + return pairs.clone(); + } + } + panic!("в блоке нет NET_SetConVar"); +} + +/// Собирает наш собственный hello так же, как это делает клиент при подключении. +fn our_hello_convars() -> Vec<(String, String)> { + let mut w = BitWriter::new(); + ClientMessage::SetConVar(userinfo_pairs("rpopre_apovle")).write_for_test(&mut w); + let bytes = w.bytes().to_vec(); + convars_of(&bytes) +} + +#[test] +fn reference_hello_parses() { + // Фикстура должна читаться: ровно SetConVar и SignonState. + let msgs = parse_messages(REFERENCE, 0); + let kinds: Vec<&str> = msgs + .iter() + .filter(|m| !matches!(m, Message::Nop)) + .map(|m| match m { + Message::SetConVar(_) => "SetConVar", + Message::SignOnState { .. } => "SignonState", + other => panic!("неожиданное сообщение {other:?}"), + }) + .collect(); + assert_eq!( + kinds, + vec!["SetConVar", "SignonState"], + "эталонный hello должен состоять из SetConVar и SignonState" + ); + + let pairs = convars_of(REFERENCE); + assert_eq!(pairs.len(), 28, "эталон содержит 28 пар"); + assert_eq!(pairs[0].0, "tv_nochat"); + assert_eq!( + pairs.last().unwrap().0, + "cl_autowepswitch", + "последняя пара эталонного набора" + ); +} + +#[test] +fn reference_hello_signon_state_is_connected() { + let msgs = parse_messages(REFERENCE, 0); + let state = msgs + .iter() + .find_map(|m| match m { + Message::SignOnState { state, spawn_count } => Some((*state, *spawn_count)), + _ => None, + }) + .expect("SignonState"); + assert_eq!(state.0, 2, "CONNECTED"); + assert_eq!(state.1, u32::MAX, "spawn_count = -1"); +} + +#[test] +fn our_convars_match_reference_exactly() { + let reference = convars_of(REFERENCE); + let ours = our_hello_convars(); + + assert_eq!( + ours.len(), + reference.len(), + "число userinfo-конваров: у нас {}, в эталоне {}\nнабор: {:?}", + ours.len(), + reference.len(), + ours + ); + + for (i, (ours, refpair)) in ours.iter().zip(reference.iter()).enumerate() { + // `name` зависит от того, под кем мы заходим, — сверяем отдельно. + if ours.0 == "name" { + assert_eq!(refpair.0, "name", "позиция {i}: в эталоне должен быть name"); + assert_eq!(ours.1, "rpopre_apovle"); + continue; + } + assert_eq!( + (ours.0.as_str(), ours.1.as_str()), + (refpair.0.as_str(), refpair.1.as_str()), + "конвар №{i} разошёлся с эталоном" + ); + } +} + +#[test] +fn no_duplicate_convars() { + // Сервер принимает дубликаты молча, но лишние userinfo-пары ломают + // отображение в браузере сервера — проверяем уникальность имён. + let ours = our_hello_convars(); + let mut names: Vec<&str> = ours.iter().map(|(k, _)| k.as_str()).collect(); + let count = names.len(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), count, "в наборе есть повторяющиеся имена"); +} + +#[test] +fn convar_values_fit_protocol_limits() { + // Имя и значение ограничены 260 байтами (см. NET_SetConVar в Source.NET), + // а их не больше 255 в одном сообщении. + let ours = our_hello_convars(); + assert!(ours.len() <= 255, "конваров больше 255"); + for (k, v) in &ours { + assert!(k.len() < 260, "имя конвара длиннее 260 байт: {k}"); + assert!(v.len() < 260, "значение длиннее 260 байт: {k}"); + assert!( + k.bytes().all(|b| b != 0), + "в имени конвара нуль-байт: {k:?}" + ); + } +} + +#[test] +fn reader_stays_inside_block() { + // Sanity: разбор эталонного блока не выходит за границы буфера. + let mut r = BitReader::new(REFERENCE); + assert!(r.bits_left() == REFERENCE.len() * 8); + let _ = r.read_ubit_long(6); + assert!(r.bits_left() < REFERENCE.len() * 8); +} diff --git a/crates/net/tests/fixtures/classicbox_client_hello.bin b/crates/net/tests/fixtures/classicbox_client_hello.bin new file mode 100644 index 0000000000000000000000000000000000000000..95c0cf3d290b3aa4b2b6f520f8d5370a98ab04a9 GIT binary patch literal 511 zcmX|8OHza|6l7z$5Y{BT#42yX-qJ0Emw1*O0M|o+@ho!yUGsUtGk60{V8(1-CEeBC zuXA^Q-cCfUVKPZCj3$O17CcEU*2t~!FzTgZevrrmT=ig_@F(!I*!i~y&CCimD&(W) zYGtyFmr4r7j0-5Y?^#4;(+jSh_X7lz4cB_fiD0bfxd@7irV*KUI%bG`oR=9vJBkhi z?5d=fnu&P5ayn9GR=jk?aTUe?OcT+3dv&HsHj0zbit{p!=LVcSm?%tBv?xq(3iFt> z5A7aSIaN#8j0y$7WgnCuk`Tg8HQZLS$hQjFhev5ThP-Us?6!^WLaaq3FT19|`pszq z#i7K&{LE~uC&bELzmATM1`k^j`^wjz0e7lhhAmf%?)9-xY