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:
Niko Marmeladkov 2026-10-12 14:38:04 +03:00
parent fb48358208
commit 9cee256cb8
6 changed files with 668 additions and 4 deletions

View file

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

View file

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

View file

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

View file

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

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

View file

@ -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).