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:
parent
7343f18ef2
commit
77d70f6b9f
5 changed files with 282 additions and 32 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
145
crates/net/tests/client_hello.rs
Normal file
145
crates/net/tests/client_hello.rs
Normal 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);
|
||||
}
|
||||
BIN
crates/net/tests/fixtures/classicbox_client_hello.bin
vendored
Normal file
BIN
crates/net/tests/fixtures/classicbox_client_hello.bin
vendored
Normal file
Binary file not shown.
|
|
@ -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-тикет, иначе сервер не отвечает.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue