diff --git a/crates/client/src/net_cmd.rs b/crates/client/src/net_cmd.rs index ed0b1d2..57fb557 100644 --- a/crates/client/src/net_cmd.rs +++ b/crates/client/src/net_cmd.rs @@ -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(()) } diff --git a/crates/net/src/bitbuf.rs b/crates/net/src/bitbuf.rs index ad07fff..8649aa0 100644 --- a/crates/net/src/bitbuf.rs +++ b/crates/net/src/bitbuf.rs @@ -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; diff --git a/crates/net/src/client.rs b/crates/net/src/client.rs index 8df4f7a..7a0013f 100644 --- a/crates/net/src/client.rs +++ b/crates/net/src/client.rs @@ -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), + /// Send table: описание раскладки полей энтити. + SendTable(Box), + /// Список серверных классов. + ClassInfo(Box), + /// Создана строковая таблица. + CreateStringTable(Box), 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 }, }); } diff --git a/crates/net/src/netchan.rs b/crates/net/src/netchan.rs index 4ab7ab9..2dce44e 100644 --- a/crates/net/src/netchan.rs +++ b/crates/net/src/netchan.rs @@ -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), + /// `SVC_SendTable` — описание send table для распаковки энтити. + /// + /// Раскладка по `Source.Common/Networking/NetMessages.cs:305`: бит + /// `needs_decoder`, `short length`, затем ровно `length` бит данных + /// таблицы. Имени у сообщения нет — идентичность таблицы определяется + /// порядком прихода. + SendTable(Box), + /// `SVC_ClassInfo` — список серверных классов. + ClassInfo(Box), + /// `SVC_CreateStringTable` — создание строковой таблицы. + CreateStringTable(Box), 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, +} + +/// Класс сервера из `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, +} + +/// `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, +} + +/// Копирует `bits` бит из позиции читателя в отдельный буфер. +/// +/// Значения читаются **от младшего бита**, и упаковка в выход делается так же, +/// поэтому порядок байтов совпадает с исходным потоком. Длина округляется +/// вниз до целых байт — «хвостовые» биты исходника не несут смысла. +fn take_bits(r: &mut BitReader<'_>, bits: usize) -> Vec { + 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 { 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, diff --git a/crates/net/tests/signon_tables.rs b/crates/net/tests/signon_tables.rs new file mode 100644 index 0000000..3b5e342 --- /dev/null +++ b/crates/net/tests/signon_tables.rs @@ -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 { + 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); +} diff --git a/docs/RECON.md b/docs/RECON.md index 5e31dd0..2184057 100644 --- a/docs/RECON.md +++ b/docs/RECON.md @@ -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).