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 0000000..95c0cf3 Binary files /dev/null and b/crates/net/tests/fixtures/classicbox_client_hello.bin differ diff --git a/docs/RECON.md b/docs/RECON.md index 2184057..5f045c3 100644 --- a/docs/RECON.md +++ b/docs/RECON.md @@ -821,3 +821,72 @@ return buffer.SeekRelative(Length); и отсутствие имени у `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-тикет, иначе сервер не отвечает.