net: добавлен недостающий userinfo-конвар cl_playermodel

Наш первый netchan-пакет был на 22 байта короче эталонного — ровно на
cl_playermodel\0male07\0. Без него сервер не знает, какую модель ставить
нашему энтити-игроку.

Набор конваров вынесен в client::userinfo_pairs() и теперь сверяется с
эталоном настоящего GMod (новая фикстура fixtures/classicbox_client_hello.bin,
511 байт из пакета #24) попарно, включая порядок. При том же имени наш пакет
теперь совпадает с эталонным побайтово: тело идентично в 513 из 514 байт,
различаются только checksum/challenge из-за другой сессии и один остаточный
бит добивки.
This commit is contained in:
Niko Marmeladkov 2026-10-12 14:51:57 +03:00
parent 7343f18ef2
commit 77d70f6b9f
5 changed files with 282 additions and 32 deletions

View file

@ -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,

View file

@ -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 => {

View file

@ -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);
}

Binary file not shown.

View file

@ -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-тикет, иначе сервер не отвечает.