net: PACKET_FLAG_CHOKED = 1<<4 (раньше был 1<<3 = Split)

Взят прямо из Source.NET (IConnectionlessPacketHandler.cs, enum PacketFlag):
Reliable=1, Compressed=2, Encrypted=4, Split=8, Choked=16, Challenge=32.
Из-за неверного бита в пакетах сервера с выставленным битом 3 читался
лишний байт, reliable_state и challenge уезжали на 8 бит.
Добавлены тесты на константы флагов и на заголовок с Choked.
This commit is contained in:
Niko Marmeladkov 2026-10-12 14:31:16 +03:00
parent c6b9b04404
commit 459fe6b0bc
2 changed files with 89 additions and 3 deletions

View file

@ -46,12 +46,12 @@ pub fn subchannel_bits() -> usize {
/// Число потоков фрагментов (Normal, File).
pub const MAX_STREAMS: usize = 2;
// Флаги пакета.
// Флаги пакета (`PacketFlag` в Source.Common/Networking/IConnectionlessPacketHandler.cs).
pub const PACKET_FLAG_RELIABLE: u8 = 1 << 0;
pub const PACKET_FLAG_COMPRESSED: u8 = 1 << 1;
pub const PACKET_FLAG_ENCRYPTED: u8 = 1 << 2;
pub const PACKET_FLAG_CHOKED: u8 = 1 << 3;
/// GMod-дополнение: в пакете присутствует challenge (0x20, не 0x10).
pub const PACKET_FLAG_SPLIT: u8 = 1 << 3;
pub const PACKET_FLAG_CHOKED: u8 = 1 << 4;
pub const PACKET_FLAG_CHALLENGE: u8 = 1 << 5;
/// Net-сообщения (нумерация GMod).
@ -765,6 +765,46 @@ mod tests {
let _ = parse_messages(&packet, header.payload_bit);
}
/// Флаги `PacketFlag` — по спецификации Source.NET:
/// Reliable=1, Compressed=2, Encrypted=4, Split=8, Choked=16, Challenge=32.
///
/// Раньше `CHOKED` был объявлен как `1<<3`, то есть фактически `Split`.
/// Из-за этого в пакетах сервера с выставленным битом 3 читался лишний
/// байт «choked», и `reliable_state` с `challenge` уезжали на 8 бит.
#[test]
fn packet_flags_match_specification() {
assert_eq!(PACKET_FLAG_RELIABLE, 0x01);
assert_eq!(PACKET_FLAG_COMPRESSED, 0x02);
assert_eq!(PACKET_FLAG_ENCRYPTED, 0x04);
assert_eq!(PACKET_FLAG_SPLIT, 0x08);
assert_eq!(PACKET_FLAG_CHOKED, 0x10);
assert_eq!(PACKET_FLAG_CHALLENGE, 0x20);
}
/// Заголовок с флагом Choked: между reliable_state и challenge
/// вставляется байт «сколько пакетов пропущено».
#[test]
fn header_with_choked_flag_has_extra_byte() {
let mut packet = Vec::new();
packet.extend_from_slice(&7u32.to_le_bytes()); // sequence
packet.extend_from_slice(&3u32.to_le_bytes()); // ack
packet.push(PACKET_FLAG_CHOKED);
packet.extend_from_slice(&0u16.to_le_bytes()); // checksum, патчится ниже
packet.extend_from_slice(&0x1234u16.to_le_bytes()); // reliable state
packet.push(4); // choked
// Далее — payload; challenge не выставлен.
let csum = fold_crc32(&packet[11..]);
packet[9..11].copy_from_slice(&csum.to_le_bytes());
let header = parse_packet(&packet, true).expect("заголовок");
assert_eq!(header.sequence, 7);
assert_eq!(header.ack, 3);
assert_eq!(header.flags & PACKET_FLAG_CHOKED, PACKET_FLAG_CHOKED);
assert_eq!(header.reliable_state, 0x1234, "reliable_state не сдвинут");
assert_eq!(header.choked, Some(4));
assert!(header.checksum_ok);
}
#[test]
fn checksum_detects_corruption() {
let packet = build_hello_packet(5, 0, 42, &[]);

View file

@ -663,3 +663,49 @@ Map: gm_bigcity_improved_rp / Players…» — значения сходятся
**Статус сетевого слоя:** заголовок пакета, реассемблер фрагментов, connect,
hello и `SV_ServerInfo` — по эталону подтверждены. Осталось разобрать
multi-fragment переносы (send tables, string tables), сжатие и снапшоты.
### 13.12. Source.NET — источник истины, а не pcap (урок процесса)
Замечание «у нас есть исходники Source.NET, зачем мы реверсем» — справедливое,
и оно указывает на потерянное время. Правильный порядок: **сначала читать
`Source.Common/Networking/Protocol.cs` и `Source.Engine/NetChannel.cs`**,
и лишь потом сверять с эталонным pcap.
Что дало чтение исходников за секунды (после часа перебора по захвату):
```csharp
// Source.Common/Networking/Protocol.cs
FRAGMENT_BITS = 12; FRAGMENT_SIZE = 1 << 12; // 4096, а НЕ 254
MAX_FILE_SIZE_BITS = 30; // startFragment = 30-12 = 18 бит
NETMSG_TYPE_BITS = 6; MAX_PAYLOAD = 288000;
MAX_ROUTABLE_PAYLOAD = 1260;
// Source.Common/Networking/IConnectionlessPacketHandler.cs
public enum PacketFlag {
Reliable = 1<<0, Compressed = 1<<1, Encrypted = 1<<2,
Split = 1<<3, Choked = 1<<4, Challenge = 1<<5
}
```
**Найденный баг.** У нас было `PACKET_FLAG_CHOKED = 1<<3` — это на самом деле
`Split`. Значит в пакетах сервера с выставленным битом 3 мы читали лишний байт
«choked», и `reliable_state` вместе с `challenge` уезжали на 8 бит — заголовок
такого пакета разбирался неверно целиком. Исправлено на `Choked = 1<<4` +
`Split = 1<<3`, закрыто тестами `packet_flags_match_specification` и
`header_with_choked_flag_has_extra_byte`.
**Про порядок бит.** `Source.Common/Bitbuffers/bf_read.cs:752`
(`ReadUBitLong`) — `dw1 = word >> startBit; dw2 = word << (32 - startBit)` —
это в точности тот алгоритм, что я дизассемблировал из `engine_srv.so`:
значения собираются **LSB-first**. Подтверждено дважды.
**Про сжатие в single-block.** Source.NET читает бит сжатия перед длиной,
но эталон однозначно показывает 23-битный заголовок (4+2+1+VarInt32) без него.
Похоже, GMod сигналит сжатие флагом пакета, а не фрагмента; наша правка
опирается на измерение, источник тут расходится.
**Что уже совпадало с исходником** (значит, брали правильно):
`FRAGMENT_SIZE = 1<<12 = 4096`, `FRAGMENT_OFFSET_BITS = 18`,
`MAX_FRAGMENTS_PER_PACKET_BITS = 5`, `NETMSG_TYPE_BITS = 6`,
`IN_RELIABLE_STATE_BITS = 16`, `SubChannel.MAX_BITS = 4` (совпало с дизасмом),
порядок `net.SetConVar` → `net.SignonState(CONNECTED, -1)`.