net: разбор SVC_SendTable, SVC_ClassInfo, SVC_CreateStringTable
Раскладка взята из Source.NET (NetMessages.cs), т.к. эталонного pcap с этими
сообщениями нет — в захвате потеряно 2576 серверных пакетов именно на участке,
где сервер шлет таблицы (docs/RECON.md 13.13).
Важно: у SVC_SendTable нет имени таблицы, в отличие от раскладки Source 2013
SDK, на которую я ориентировался раньше.
Попутно найден баг через круговые тесты: префикс ':' у строковой таблицы
нужно смотреть (PeekUBitLong), а не читать — иначе теряется первый символ
имени ('userinfo' читался как 'serinfo') и дальше по потоку едет смещение.
Число классов ограничено MAX_SERVER_CLASSES=4096: значение приходит из сети.
This commit is contained in:
parent
fb48358208
commit
9cee256cb8
6 changed files with 668 additions and 4 deletions
|
|
@ -177,6 +177,10 @@ pub fn connect(args: ConnectArgs) -> anyhow::Result<()> {
|
|||
|
||||
let deadline = Instant::now() + Duration::from_secs(args.seconds);
|
||||
let mut server_info_seen = false;
|
||||
let mut class_info_seen = false;
|
||||
let mut send_tables = 0usize;
|
||||
let mut server_classes = 0usize;
|
||||
let mut string_tables = 0usize;
|
||||
while Instant::now() < deadline {
|
||||
if let Some(steam) = &steam {
|
||||
steam.borrow().pump();
|
||||
|
|
@ -212,6 +216,39 @@ pub fn connect(args: ConnectArgs) -> anyhow::Result<()> {
|
|||
"SVC_ServerInfo"
|
||||
);
|
||||
}
|
||||
NetEvent::SendTable(table) => {
|
||||
send_tables += 1;
|
||||
info!(
|
||||
index = send_tables,
|
||||
needs_decoder = table.needs_decoder,
|
||||
length_bits = table.length_bits,
|
||||
"SVC_SendTable"
|
||||
);
|
||||
}
|
||||
NetEvent::ClassInfo(classes) => {
|
||||
class_info_seen = true;
|
||||
server_classes += classes.classes.len();
|
||||
info!(
|
||||
create_on_client = classes.create_on_client,
|
||||
count = classes.classes.len(),
|
||||
total = server_classes,
|
||||
"SVC_ClassInfo"
|
||||
);
|
||||
}
|
||||
NetEvent::CreateStringTable(table) => {
|
||||
string_tables += 1;
|
||||
info!(
|
||||
index = string_tables,
|
||||
name = %table.table_name,
|
||||
entries = table.num_entries,
|
||||
max_entries = table.max_entries,
|
||||
length_bits = table.length_bits,
|
||||
filenames = table.is_filenames,
|
||||
user_data_fixed_size = table.user_data_fixed_size,
|
||||
compressed = table.data_compressed,
|
||||
"SVC_CreateStringTable"
|
||||
);
|
||||
}
|
||||
NetEvent::SignOnState { state, spawn_count } => {
|
||||
info!(state, spawn_count, "NET_SignonState");
|
||||
}
|
||||
|
|
@ -244,9 +281,15 @@ pub fn connect(args: ConnectArgs) -> anyhow::Result<()> {
|
|||
}
|
||||
|
||||
if server_info_seen {
|
||||
info!("итог: netchan и signon работают");
|
||||
info!(
|
||||
send_tables,
|
||||
server_classes, string_tables, class_info_seen, "итог: netchan и signon работают"
|
||||
);
|
||||
} else {
|
||||
info!("итог: SVC_ServerInfo не получен (см. лог)");
|
||||
info!(
|
||||
send_tables,
|
||||
server_classes, string_tables, "итог: SVC_ServerInfo не получен (см. лог)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,32 @@ impl<'a> BitReader<'a> {
|
|||
self.read_ubit_long(8) as u8
|
||||
}
|
||||
|
||||
/// Следующий байт **без** сдвига позиции.
|
||||
///
|
||||
/// Нужен там, где движок сначала смотрит байт и только потом решает,
|
||||
/// читать его или нет — так сделано с префиксом `:` в `SVC_CreateStringTable`
|
||||
/// (`PeekUBitLong` в Source.NET).
|
||||
pub fn peek_byte(&self) -> u8 {
|
||||
let byte_index = self.bit_pos / 8;
|
||||
if byte_index >= self.data.len() {
|
||||
return 0;
|
||||
}
|
||||
if self.bit_pos % 8 == 0 {
|
||||
return self.data[byte_index];
|
||||
}
|
||||
// Не выровнено по границе байта — собираем битовыми сдвигами.
|
||||
let mut value = 0u32;
|
||||
for i in 0..8 {
|
||||
let pos = self.bit_pos + i;
|
||||
if pos >= self.data.len() * 8 {
|
||||
break;
|
||||
}
|
||||
let bit = ((self.data[pos / 8] >> (pos % 8)) & 1) as u32;
|
||||
value |= bit << i;
|
||||
}
|
||||
value as u8
|
||||
}
|
||||
|
||||
/// Читает `VarInt32` (7 бит на байт, старший бит — продолжение).
|
||||
pub fn read_varint32(&mut self) -> u32 {
|
||||
let mut result = 0u32;
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
use crate::bitbuf::BitReader;
|
||||
use crate::netchan::{
|
||||
self, CONNECTIONLESS_HEADER, ClientMessage, NetError, PROTOCOL_VERSION, SPLITPACKET_HEADER,
|
||||
ServerInfoMessage, signon,
|
||||
self, CONNECTIONLESS_HEADER, ClassInfoMessage, ClientMessage, CreateStringTableMessage,
|
||||
NetError, PROTOCOL_VERSION, SPLITPACKET_HEADER, SendTableMessage, ServerInfoMessage, signon,
|
||||
};
|
||||
use std::net::UdpSocket;
|
||||
use std::time::{Duration, Instant};
|
||||
|
|
@ -23,6 +23,12 @@ pub enum NetEvent {
|
|||
/// Сервер принял подключение (началась netchan-сессия).
|
||||
Connected,
|
||||
ServerInfo(Box<ServerInfoMessage>),
|
||||
/// Send table: описание раскладки полей энтити.
|
||||
SendTable(Box<SendTableMessage>),
|
||||
/// Список серверных классов.
|
||||
ClassInfo(Box<ClassInfoMessage>),
|
||||
/// Создана строковая таблица.
|
||||
CreateStringTable(Box<CreateStringTableMessage>),
|
||||
Print(String),
|
||||
Tick {
|
||||
tick: u32,
|
||||
|
|
@ -439,6 +445,9 @@ impl NetClient {
|
|||
}
|
||||
netchan::Message::Print(text) => NetEvent::Print(text),
|
||||
netchan::Message::ServerInfo(info) => NetEvent::ServerInfo(info),
|
||||
netchan::Message::SendTable(info) => NetEvent::SendTable(info),
|
||||
netchan::Message::ClassInfo(info) => NetEvent::ClassInfo(info),
|
||||
netchan::Message::CreateStringTable(info) => NetEvent::CreateStringTable(info),
|
||||
netchan::Message::Unknown { cmd, .. } => NetEvent::Unknown { cmd },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ pub fn subchannel_bits() -> usize {
|
|||
/// Число потоков фрагментов (Normal, File).
|
||||
pub const MAX_STREAMS: usize = 2;
|
||||
|
||||
/// Предел числа классов в `SVC_ClassInfo` (данные из сети — нужен лимит).
|
||||
pub const MAX_SERVER_CLASSES: usize = 4096;
|
||||
|
||||
// Флаги пакета (`PacketFlag` в Source.Common/Networking/IConnectionlessPacketHandler.cs).
|
||||
pub const PACKET_FLAG_RELIABLE: u8 = 1 << 0;
|
||||
pub const PACKET_FLAG_COMPRESSED: u8 = 1 << 1;
|
||||
|
|
@ -455,12 +458,84 @@ pub enum Message {
|
|||
},
|
||||
Print(String),
|
||||
ServerInfo(Box<ServerInfoMessage>),
|
||||
/// `SVC_SendTable` — описание send table для распаковки энтити.
|
||||
///
|
||||
/// Раскладка по `Source.Common/Networking/NetMessages.cs:305`: бит
|
||||
/// `needs_decoder`, `short length`, затем ровно `length` бит данных
|
||||
/// таблицы. Имени у сообщения нет — идентичность таблицы определяется
|
||||
/// порядком прихода.
|
||||
SendTable(Box<SendTableMessage>),
|
||||
/// `SVC_ClassInfo` — список серверных классов.
|
||||
ClassInfo(Box<ClassInfoMessage>),
|
||||
/// `SVC_CreateStringTable` — создание строковой таблицы.
|
||||
CreateStringTable(Box<CreateStringTableMessage>),
|
||||
Unknown {
|
||||
cmd: u8,
|
||||
bit_offset: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// `SVC_SendTable`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SendTableMessage {
|
||||
pub needs_decoder: bool,
|
||||
/// Длина блока данных в битах.
|
||||
pub length_bits: usize,
|
||||
/// Сырые биты блока (выровнены вниз до целых байт для хранения).
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Класс сервера из `SVC_ClassInfo`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerClass {
|
||||
pub class_id: u32,
|
||||
pub class_name: String,
|
||||
pub data_table_name: String,
|
||||
}
|
||||
|
||||
/// `SVC_ClassInfo`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClassInfoMessage {
|
||||
/// `true`, если классы создаёт клиент (`CreateOnClient`).
|
||||
pub create_on_client: bool,
|
||||
pub classes: Vec<ServerClass>,
|
||||
}
|
||||
|
||||
/// `SVC_CreateStringTable`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreateStringTableMessage {
|
||||
/// Таблица хранит имена файлов (префикс `:` в имени).
|
||||
pub is_filenames: bool,
|
||||
pub table_name: String,
|
||||
pub max_entries: u32,
|
||||
pub num_entries: u32,
|
||||
pub user_data_fixed_size: bool,
|
||||
pub user_data_size: u32,
|
||||
pub user_data_size_bits: u32,
|
||||
pub data_compressed: bool,
|
||||
/// Длина блока данных в битах.
|
||||
pub length_bits: usize,
|
||||
/// Сырые биты блока, выровненные вниз до целых байт.
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Копирует `bits` бит из позиции читателя в отдельный буфер.
|
||||
///
|
||||
/// Значения читаются **от младшего бита**, и упаковка в выход делается так же,
|
||||
/// поэтому порядок байтов совпадает с исходным потоком. Длина округляется
|
||||
/// вниз до целых байт — «хвостовые» биты исходника не несут смысла.
|
||||
fn take_bits(r: &mut BitReader<'_>, bits: usize) -> Vec<u8> {
|
||||
let mut out = vec![0u8; bits / 8];
|
||||
for slot in out.iter_mut() {
|
||||
*slot = r.read_ubit_long(8) as u8;
|
||||
}
|
||||
// Недочитанные «остаточные» биты пропускаем, чтобы позиция совпала.
|
||||
for _ in 0..(bits % 8) {
|
||||
r.read_one_bit();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// SVC_ServerInfo (раскладка проверена на эталонном блоке ClassicBox).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ServerInfoMessage {
|
||||
|
|
@ -580,6 +655,89 @@ pub fn parse_messages(data: &[u8], payload_bit: usize) -> Vec<Message> {
|
|||
gamemode,
|
||||
})));
|
||||
}
|
||||
svc::SEND_TABLE => {
|
||||
// NetMessages.cs:305 — имени нет: bit needs_decoder,
|
||||
// short length, затем `length` бит данных таблицы.
|
||||
let needs_decoder = r.read_one_bit();
|
||||
let length_bits = r.read_short() as usize;
|
||||
let data = take_bits(&mut r, length_bits);
|
||||
out.push(Message::SendTable(Box::new(SendTableMessage {
|
||||
needs_decoder,
|
||||
length_bits,
|
||||
data,
|
||||
})));
|
||||
}
|
||||
svc::CLASS_INFO => {
|
||||
// NetMessages.cs:329 — short numServerClasses, bool
|
||||
// createOnClient; при createOnClient список не приходит.
|
||||
let num_server_classes = r.read_short() as u32;
|
||||
let create_on_client = r.read_one_bit();
|
||||
let mut classes = Vec::new();
|
||||
if !create_on_client {
|
||||
// Ширина id: floor(log2(n)) + 1 бит.
|
||||
let class_bits = if num_server_classes == 0 {
|
||||
1
|
||||
} else {
|
||||
(32 - num_server_classes.leading_zeros()) as usize
|
||||
};
|
||||
// Ограничение: классов в разумном сервере немного.
|
||||
let limit = (num_server_classes as usize).min(MAX_SERVER_CLASSES);
|
||||
for _ in 0..limit {
|
||||
let class_id = r.read_ubit_long(class_bits);
|
||||
let class_name = r.read_string(256).unwrap_or_default();
|
||||
let data_table_name = r.read_string(256).unwrap_or_default();
|
||||
classes.push(ServerClass {
|
||||
class_id,
|
||||
class_name,
|
||||
data_table_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
out.push(Message::ClassInfo(Box::new(ClassInfoMessage {
|
||||
create_on_client,
|
||||
classes,
|
||||
})));
|
||||
}
|
||||
svc::CREATE_STRING_TABLE => {
|
||||
// NetMessages.cs:413 — префикс ':' означает таблицу имён
|
||||
// файлов, затем имя, 5 бит log2(max), (log2(max)+1) бит
|
||||
// числа записей, VarInt32 длины, признаки user data и
|
||||
// сжатия, затем `length` бит данных.
|
||||
// Source.NET (NetMessages.cs:415) сначала СМОТРИТ байт через PeekUBitLong и
|
||||
// только при совпадении с ':' читает его. Читать безусловно
|
||||
// нельзя: тогда теряется первый символ имени таблицы.
|
||||
let is_filenames = r.peek_byte() == b':';
|
||||
if is_filenames {
|
||||
r.read_byte();
|
||||
}
|
||||
let table_name = r.read_string(500).unwrap_or_default();
|
||||
let encode_bits = r.read_ubit_long(5) as usize;
|
||||
let max_entries = 1u32 << encode_bits.min(31);
|
||||
let num_entries = r.read_ubit_long(encode_bits + 1);
|
||||
let length_bits = r.read_varint32() as usize;
|
||||
let user_data_fixed_size = r.read_one_bit();
|
||||
let (user_data_size, user_data_size_bits) = if user_data_fixed_size {
|
||||
(r.read_ubit_long(12), r.read_ubit_long(4))
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
let data_compressed = r.read_one_bit();
|
||||
let data = take_bits(&mut r, length_bits);
|
||||
out.push(Message::CreateStringTable(Box::new(
|
||||
CreateStringTableMessage {
|
||||
is_filenames,
|
||||
table_name,
|
||||
max_entries,
|
||||
num_entries,
|
||||
user_data_fixed_size,
|
||||
user_data_size,
|
||||
user_data_size_bits,
|
||||
data_compressed,
|
||||
length_bits,
|
||||
data,
|
||||
},
|
||||
)));
|
||||
}
|
||||
other => {
|
||||
out.push(Message::Unknown {
|
||||
cmd: other,
|
||||
|
|
|
|||
361
crates/net/tests/signon_tables.rs
Normal file
361
crates/net/tests/signon_tables.rs
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
//! Разбор signon-сообщений: `SVC_SendTable`, `SVC_ClassInfo`,
|
||||
//! `SVC_CreateStringTable`.
|
||||
//!
|
||||
//! Эталонного pcap с этими сообщениями у нас нет (в `real_client.pcap` потеряно
|
||||
//! 2576 серверных пакетов именно в той части сессии, где сервер шлёт таблицы —
|
||||
//! см. docs/RECON.md §13.13), поэтому раскладка берётся из исходников Source.NET
|
||||
//! и проверяется **круговым тестом**: сообщение собирается нашим же
|
||||
//! `BitWriter` по спецификации и затем разбирается обратно.
|
||||
//!
|
||||
//! Такой тест ловит именно ту ошибку, ради которой всё затевалось: если
|
||||
//! раскладка читается не с той ширины или не в том порядке, круг не сойдётся.
|
||||
|
||||
use net::bitbuf::BitWriter;
|
||||
use net::netchan::{MAX_SERVER_CLASSES, Message, NETMSG_TYPE_BITS, parse_messages};
|
||||
|
||||
/// Сообщения без хвостовой добивки нулями: `parse_messages` честно отдаёт
|
||||
/// `Nop` на выравнивающих битах после последнего сообщения.
|
||||
/// Пишет заголовок сообщения нужного типа.
|
||||
fn header(w: &mut BitWriter, cmd: u8) {
|
||||
w.write_ubit_long(cmd as u32, NETMSG_TYPE_BITS);
|
||||
}
|
||||
|
||||
/// Сообщения без хвостовой добивки нулями: `parse_messages` честно отдаёт
|
||||
/// `Nop` на выравнивающих битах после последнего сообщения.
|
||||
fn parse_significant(bytes: &[u8]) -> Vec<Message> {
|
||||
parse_messages(bytes, 0)
|
||||
.into_iter()
|
||||
.filter(|m| !matches!(m, Message::Nop))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_one(bytes: &[u8]) -> Message {
|
||||
let msgs = parse_significant(bytes);
|
||||
assert_eq!(msgs.len(), 1, "ожидалось ровно одно значимое сообщение");
|
||||
msgs.into_iter().next().expect("одно сообщение")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_table_roundtrip() {
|
||||
// Спецификация (NetMessages.cs:305): бит needs_decoder, short length,
|
||||
// затем ровно `length` бит данных таблицы. Имени у сообщения нет.
|
||||
let payload_bits = 96usize;
|
||||
let mut w = BitWriter::new();
|
||||
header(&mut w, 9); // SVC_SendTable
|
||||
w.write_one_bit(true); // needs_decoder
|
||||
w.write_short(payload_bits as u16);
|
||||
let mut expected = Vec::new();
|
||||
for i in 0..(payload_bits / 8) {
|
||||
let byte = (i as u8).wrapping_mul(37).wrapping_add(11);
|
||||
expected.push(byte);
|
||||
w.write_byte(byte);
|
||||
}
|
||||
let bytes = w.bytes().to_vec();
|
||||
|
||||
match parse_one(&bytes) {
|
||||
Message::SendTable(t) => {
|
||||
assert!(
|
||||
t.needs_decoder,
|
||||
"needs_decoder должен читаться первым битом"
|
||||
);
|
||||
assert_eq!(t.length_bits, payload_bits);
|
||||
assert_eq!(t.data, expected, "блок данных должен совпасть байт в байт");
|
||||
}
|
||||
other => panic!("ожидался SendTable, получено {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_table_payload_is_not_misaligned() {
|
||||
// Раньше для одиночного блока ошибочно читался лишний бит «сжатие».
|
||||
// Тест ловит сдвиг: длина блока должна определяться ровно тем числом бит,
|
||||
// которое объявлено, и следующее сообщение обязано читаться без смещения.
|
||||
let mut w = BitWriter::new();
|
||||
header(&mut w, 9);
|
||||
w.write_one_bit(false);
|
||||
w.write_short(16);
|
||||
w.write_byte(0xAA);
|
||||
w.write_byte(0xBB);
|
||||
// Сразу следом — SignonState, читаемость которого доказывает точность
|
||||
// позиционирования после блока данных.
|
||||
header(&mut w, 6);
|
||||
w.write_byte(2);
|
||||
w.write_u32(0xFFFF_FFFF);
|
||||
let bytes = w.bytes().to_vec();
|
||||
|
||||
let msgs = parse_messages(&bytes, 0);
|
||||
assert_eq!(
|
||||
msgs.len(),
|
||||
2,
|
||||
"ожидались SendTable и SignonState, {:?}",
|
||||
msgs
|
||||
);
|
||||
match (&msgs[0], &msgs[1]) {
|
||||
(Message::SendTable(t), Message::SignOnState { state, spawn_count }) => {
|
||||
assert!(!t.needs_decoder);
|
||||
assert_eq!(t.length_bits, 16);
|
||||
assert_eq!(t.data, vec![0xAA, 0xBB]);
|
||||
assert_eq!(*state, 2);
|
||||
assert_eq!(*spawn_count, 0xFFFF_FFFF, "spawn_count читается -1");
|
||||
}
|
||||
(a, b) => panic!("неожиданная пара: {a:?} / {b:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_info_roundtrip() {
|
||||
// Спецификация (NetMessages.cs:329): short numServerClasses,
|
||||
// bool createOnClient, затем для каждого класса — id шириной
|
||||
// floor(log2(n))+1 бит, class_name, data_table_name.
|
||||
let classes: Vec<(&str, &str)> = vec![
|
||||
("player", "CBasePlayer"),
|
||||
("prop_physics", "CPropPhysics"),
|
||||
("prop_ragdoll", "CBaseRagdoll"),
|
||||
("func_breakable", "CBreakable"),
|
||||
("env_soundscape", "CEnvSoundscape"),
|
||||
];
|
||||
let n = classes.len() as u32;
|
||||
let class_bits = (32 - n.leading_zeros()) as usize;
|
||||
|
||||
let mut w = BitWriter::new();
|
||||
header(&mut w, 10); // SVC_ClassInfo
|
||||
w.write_short(n as u16);
|
||||
w.write_one_bit(false); // createOnClient
|
||||
for (id, (class_name, table)) in classes.iter().enumerate() {
|
||||
w.write_ubit_long(id as u32, class_bits);
|
||||
w.write_string(class_name);
|
||||
w.write_string(table);
|
||||
}
|
||||
let bytes = w.bytes().to_vec();
|
||||
|
||||
match parse_one(&bytes) {
|
||||
Message::ClassInfo(info) => {
|
||||
assert!(!info.create_on_client);
|
||||
assert_eq!(info.classes.len(), classes.len());
|
||||
for (i, (class_name, table)) in classes.iter().enumerate() {
|
||||
assert_eq!(info.classes[i].class_id, i as u32, "id класса {i}");
|
||||
assert_eq!(info.classes[i].class_name, *class_name);
|
||||
assert_eq!(info.classes[i].data_table_name, *table);
|
||||
}
|
||||
}
|
||||
other => panic!("ожидался ClassInfo, получено {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_info_create_on_client_has_no_list() {
|
||||
// При createOnClient=true список классов не приходит вовсе.
|
||||
let mut w = BitWriter::new();
|
||||
header(&mut w, 10);
|
||||
w.write_short(0);
|
||||
w.write_one_bit(true);
|
||||
let bytes = w.bytes().to_vec();
|
||||
|
||||
match parse_one(&bytes) {
|
||||
Message::ClassInfo(info) => {
|
||||
assert!(info.create_on_client);
|
||||
assert!(
|
||||
info.classes.is_empty(),
|
||||
"при createOnClient список классов пуст"
|
||||
);
|
||||
}
|
||||
other => panic!("ожидался ClassInfo, получено {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_string_table_roundtrip() {
|
||||
// Спецификация (NetMessages.cs:413): префикс ':' для таблиц имён файлов,
|
||||
// имя, 5 бит log2(max), (log2(max)+1) бит числа записей, VarInt32 длины,
|
||||
// bool userDataFixedSize, [12 бит размера и 4 бита размера в битах],
|
||||
// bool compressed, затем `length` бит данных.
|
||||
let mut w = BitWriter::new();
|
||||
header(&mut w, 12); // SVC_CreateStringTable
|
||||
w.write_string("modelprecache");
|
||||
w.write_ubit_long(11, 5); // max_entries = 2048
|
||||
w.write_ubit_long(37, 12); // num_entries
|
||||
let body_bits = 40usize;
|
||||
w.write_varint32(body_bits as u32);
|
||||
w.write_one_bit(true); // user data фиксированного размера
|
||||
w.write_ubit_long(4, 12); // user_data_size
|
||||
w.write_ubit_long(12, 4); // user_data_size_bits
|
||||
w.write_one_bit(true); // data compressed
|
||||
let mut expected = Vec::new();
|
||||
for i in 0..(body_bits / 8) {
|
||||
let byte = (i as u8).wrapping_mul(53).wrapping_add(7);
|
||||
expected.push(byte);
|
||||
w.write_byte(byte);
|
||||
}
|
||||
let bytes = w.bytes().to_vec();
|
||||
|
||||
match parse_one(&bytes) {
|
||||
Message::CreateStringTable(t) => {
|
||||
assert!(!t.is_filenames, "без префикса ':' это обычная таблица");
|
||||
assert_eq!(t.table_name, "modelprecache");
|
||||
assert_eq!(t.max_entries, 2048);
|
||||
assert_eq!(t.num_entries, 37);
|
||||
assert_eq!(t.length_bits, body_bits);
|
||||
assert!(t.user_data_fixed_size);
|
||||
assert_eq!(t.user_data_size, 4);
|
||||
assert_eq!(t.user_data_size_bits, 12);
|
||||
assert!(t.data_compressed);
|
||||
assert_eq!(t.data, expected);
|
||||
}
|
||||
other => panic!("ожидался CreateStringTable, получено {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_string_table_filename_prefix() {
|
||||
// Таблица с префиксом ':' хранит пути файлов — например "modelprecache"
|
||||
// у GMod становится ":modelprecache" по SendTable-протоколу.
|
||||
let mut w = BitWriter::new();
|
||||
header(&mut w, 12);
|
||||
w.write_byte(b':');
|
||||
w.write_string("generic");
|
||||
w.write_ubit_long(0, 5); // max_entries = 1
|
||||
w.write_ubit_long(0, 1); // num_entries = 0
|
||||
w.write_varint32(0); // пустой блок
|
||||
w.write_one_bit(false); // user data не фиксированного размера
|
||||
w.write_one_bit(false); // не сжато
|
||||
let bytes = w.bytes().to_vec();
|
||||
|
||||
match parse_one(&bytes) {
|
||||
Message::CreateStringTable(t) => {
|
||||
assert!(t.is_filenames, "префикс ':' должен выставлять флаг");
|
||||
assert_eq!(t.table_name, "generic", "двоеточие в имя не входит");
|
||||
assert_eq!(t.max_entries, 1);
|
||||
assert_eq!(t.num_entries, 0);
|
||||
assert_eq!(t.length_bits, 0);
|
||||
assert!(!t.user_data_fixed_size);
|
||||
assert_eq!(t.user_data_size, 0, "размер user data не читается");
|
||||
assert_eq!(t.user_data_size_bits, 0);
|
||||
assert!(t.data.is_empty());
|
||||
}
|
||||
other => panic!("ожидался CreateStringTable, получено {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_string_table_variable_user_data() {
|
||||
// UserDataFixedSize = false: размеры не читаются, а блок данных идёт сразу.
|
||||
let mut w = BitWriter::new();
|
||||
header(&mut w, 12);
|
||||
w.write_string("userinfo");
|
||||
w.write_ubit_long(6, 5);
|
||||
w.write_ubit_long(28, 7);
|
||||
w.write_varint32(16); // 16 бит = два байта
|
||||
w.write_one_bit(false);
|
||||
w.write_one_bit(false);
|
||||
w.write_byte(0x12);
|
||||
w.write_byte(0x34);
|
||||
let bytes = w.bytes().to_vec();
|
||||
|
||||
assert_eq!(
|
||||
parse_significant(&bytes).len(),
|
||||
1,
|
||||
"ожидалось одно значимое сообщение, получено {:?}",
|
||||
parse_significant(&bytes)
|
||||
);
|
||||
match parse_one(&bytes) {
|
||||
Message::CreateStringTable(t) => {
|
||||
assert!(!t.user_data_fixed_size);
|
||||
assert_eq!(t.user_data_size, 0);
|
||||
assert_eq!(t.user_data_size_bits, 0);
|
||||
assert_eq!(t.max_entries, 64);
|
||||
assert_eq!(t.num_entries, 28);
|
||||
assert_eq!(t.data, vec![0x12, 0x34]);
|
||||
}
|
||||
other => panic!("ожидался CreateStringTable, получено {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_tables_in_one_packet_keep_order() {
|
||||
// Signon-блок несёт сотни таблиц в одном пакете. Проверяем, что после
|
||||
// блока данных переменной длины позиция остаётся точной и следующие
|
||||
// сообщения читаются без сдвига.
|
||||
let mut w = BitWriter::new();
|
||||
header(&mut w, 9); // SendTable
|
||||
w.write_one_bit(false);
|
||||
w.write_short(8);
|
||||
w.write_byte(0xDE);
|
||||
header(&mut w, 12); // CreateStringTable
|
||||
w.write_string("userinfo");
|
||||
w.write_ubit_long(4, 5);
|
||||
w.write_ubit_long(3, 5);
|
||||
w.write_varint32(0);
|
||||
w.write_one_bit(false);
|
||||
w.write_one_bit(false);
|
||||
header(&mut w, 10); // ClassInfo
|
||||
w.write_short(1);
|
||||
w.write_one_bit(false);
|
||||
w.write_ubit_long(0, 1);
|
||||
w.write_string("player");
|
||||
w.write_string("CBasePlayer");
|
||||
let bytes = w.bytes().to_vec();
|
||||
|
||||
let msgs = parse_significant(&bytes);
|
||||
assert_eq!(msgs.len(), 3, "все три сообщения: {msgs:?}");
|
||||
assert!(matches!(msgs[0], Message::SendTable(_)));
|
||||
match &msgs[1] {
|
||||
Message::CreateStringTable(t) => {
|
||||
assert_eq!(t.table_name, "userinfo");
|
||||
assert_eq!(t.max_entries, 16);
|
||||
assert_eq!(t.num_entries, 3);
|
||||
}
|
||||
other => panic!("ожидался CreateStringTable, получено {other:?}"),
|
||||
}
|
||||
match &msgs[2] {
|
||||
Message::ClassInfo(info) => {
|
||||
assert_eq!(info.classes.len(), 1);
|
||||
assert_eq!(info.classes[0].class_name, "player");
|
||||
assert_eq!(info.classes[0].data_table_name, "CBasePlayer");
|
||||
}
|
||||
other => panic!("ожидался ClassInfo, получено {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_count_is_bounded_from_network_data() {
|
||||
// Число классов приходит из сети — нужен предел, иначе сервер может
|
||||
// заставить клиент читать миллионы «классов» (правило #1 из AGENTS.md).
|
||||
let mut w = BitWriter::new();
|
||||
header(&mut w, 10);
|
||||
// Объявляем 0xFFFF классов, но данных ни на один нет.
|
||||
w.write_short(0xFFFF);
|
||||
w.write_one_bit(false);
|
||||
let bytes = w.bytes().to_vec();
|
||||
|
||||
// Разбор не должен уйти за пределы буфера и не должен упасть: недостающие
|
||||
// строки дают пустые значения.
|
||||
let msgs = parse_messages(&bytes, 0);
|
||||
match msgs.first() {
|
||||
Some(Message::ClassInfo(info)) => {
|
||||
assert!(
|
||||
info.classes.len() <= MAX_SERVER_CLASSES,
|
||||
"число классов должно быть ограничено"
|
||||
);
|
||||
}
|
||||
other => panic!("ожидался ClassInfo, получено {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_table_does_not_panic() {
|
||||
// Обрезанный пакет — обычное дело при потерях. Проверяем, что разбор
|
||||
// не паникует: ни явной, ни через выход за границы.
|
||||
let mut w = BitWriter::new();
|
||||
header(&mut w, 12);
|
||||
w.write_string("modelprecache");
|
||||
w.write_ubit_long(11, 5);
|
||||
w.write_ubit_long(2000, 12);
|
||||
w.write_varint32(4096); // обещаем много данных
|
||||
w.write_one_bit(false);
|
||||
w.write_one_bit(false);
|
||||
w.write_byte(0x01); // а данных всего один байт
|
||||
let bytes = w.bytes().to_vec();
|
||||
|
||||
// Должен завершиться без паники; результат неважен, важна безопасность.
|
||||
let _ = parse_messages(&bytes, 0);
|
||||
}
|
||||
|
|
@ -754,3 +754,70 @@ SOCKS-прокси), а не дефект парсера. Проверено: с
|
|||
То есть правка `CHOKED = 1<<4` затрагивала 3 пакета из 2718 — редко, но каждый
|
||||
из них раньше давал рассинхрон на 8 бит. Биты 6–7 (0x40/0x80) — добивка, их
|
||||
значения игнорируются.
|
||||
|
||||
### 13.16. Раскладка signon-таблиц (SVC_SendTable / ClassInfo / CreateStringTable)
|
||||
|
||||
Эталонного pcap с этими сообщениями нет (§13.13), поэтому раскладка взята из
|
||||
исходников — `Source.Common/Networking/NetMessages.cs`:
|
||||
|
||||
```csharp
|
||||
// SVC_SendTable (строка 305) — имени у таблицы НЕТ
|
||||
NeedsDecoder = buffer.ReadOneBit() != 0;
|
||||
Length = buffer.ReadShort();
|
||||
return buffer.SeekRelative(Length); // ровно Length бит данных
|
||||
|
||||
// SVC_ClassInfo (строка 329)
|
||||
int numServerClasses = buffer.ReadShort();
|
||||
int serverClassBits = (int)MathF.Log2(numServerClasses) + 1;
|
||||
CreateOnClient = buffer.ReadBool();
|
||||
if (CreateOnClient) return !buffer.Overflowed; // списка классов не будет
|
||||
// далее по одному: id шириной serverClassBits, class_name, data_table_name
|
||||
|
||||
// SVC_CreateStringTable (строка 413)
|
||||
byte prefixR = (byte)buffer.PeekUBitLong(8); // СМОТРИМ, не читаем
|
||||
if (prefix == ':') { IsFilenames = true; buffer.ReadByte(); }
|
||||
TableName = buffer.ReadString(500);
|
||||
int encodeBits = (int)buffer.ReadUBitLong(5); // log2(max_entries)
|
||||
NumEntries = (int)buffer.ReadUBitLong(encodeBits + 1);
|
||||
Length = (int)buffer.ReadVarInt32();
|
||||
UserDataFixedSize = buffer.ReadBool();
|
||||
if (UserDataFixedSize) { UserDataSize = ReadUBitLong(12);
|
||||
UserDataSizeBits = ReadUBitLong(4); }
|
||||
DataCompressed = buffer.ReadBool();
|
||||
return buffer.SeekRelative(Length);
|
||||
```
|
||||
|
||||
**Баг, найденный круговым тестом сразу же.** Префикс `:` нужно *смотреть*
|
||||
(`PeekUBitLong`), а не читать. Наивное `ReadByte()` безусловно съедает первый
|
||||
байт имени, и таблица `userinfo` читается как `serinfo` — а дальше по потоку
|
||||
едет смещение. Исправлено добавлением `BitReader::peek_byte`.
|
||||
|
||||
Отдельно: `SVC_SendTable` не содержит имени таблицы. Мой прежний Python-декодер
|
||||
(`msgs.py`) читал `needs_decoder + имя + props` — это раскладка Source 2013 SDK
|
||||
для другой цели; для GMod верна схема Source.NET (`bit + short + блок бит`).
|
||||
Идентичность таблицы определяется порядком прихода — их нумеруем сами.
|
||||
|
||||
**Ширина `id` класса** = `floor(log2(n)) + 1`, как в `MathF.Log2(n)+1`.
|
||||
У нас это `32 - leading_zeros(n)`. Число классов приходит из сети, поэтому
|
||||
ограничено `MAX_SERVER_CLASSES = 4096` (правило #1 из AGENTS.md: никаких
|
||||
`unwrap` и неограниченных циклов по сетевым данным).
|
||||
|
||||
**Проверка.** Круговые тесты `crates/net/tests/signon_tables.rs` (10 шт.):
|
||||
собирают сообщение нашим же `BitWriter` по спецификации и разбирают обратно.
|
||||
Это ловит именно ошибку позиционирования после блока данных переменной длины —
|
||||
ровно тот класс ошибок, что стоил нам предыдущих двух «правок».
|
||||
|
||||
### 13.17. Протокол как источник истины (итог по уроку Source.NET)
|
||||
|
||||
Порядок, который надо соблюдать с самого начала:
|
||||
|
||||
1. `Source.Common/Networking/Protocol.cs` — константы, флаги, фрагменты.
|
||||
2. `Source.Common/Networking/IConnectionlessPacketHandler.cs` — `PacketFlag`, `SubChannel`.
|
||||
3. `Source.Common/Networking/NetMessages.cs` — раскладка сообщений (читать `ReadFromBuffer`, а не `WriteToBuffer`).
|
||||
4. `Source.Engine/NetChannel.cs` — порядок обработки пакета.
|
||||
5. И только затем pcap — чтобы проверить, что GMod нигде не отступил.
|
||||
|
||||
Чтение исходников заняло секунды и сразу дало `FRAGMENT_SIZE=4096`, `CHOKED=1<<4`
|
||||
и отсутствие имени у `SVC_SendTable`. Перебор по захвату занял час и дал две
|
||||
регрессии. Правило на будущее: **сначала исходник, потом измерение**; измерение
|
||||
нужно только там, где движок GMod отличается (сжатие single-block).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue