diff --git a/crates/client/src/net_cmd.rs b/crates/client/src/net_cmd.rs index fb6d44d..c36b128 100644 --- a/crates/client/src/net_cmd.rs +++ b/crates/client/src/net_cmd.rs @@ -61,25 +61,43 @@ impl SteamAuth { self._client.run_callbacks(); } - /// Получает свежий тикет (для повторных connect). - pub fn new_ticket(&mut self) -> Option> { + /// Получает тикет, привязанный к SteamID игрового сервера. + /// + /// Современный Steamworks требует передавать идентичность сервера + /// (`GetAuthSessionTicket` с SteamNetworkingIdentity); «generic»-тикет + /// сервер отклоняет (`#GameUI_ServerRejectSteam`). + pub fn ticket_for_server(&mut self, game_server_steam_id: u64) -> Option> { let user = self._client.user(); + let identity = if game_server_steam_id != 0 { + steamworks::networking_types::NetworkingIdentity::new_steam_id( + steamworks::SteamId::from_raw(game_server_steam_id), + ) + } else { + steamworks::networking_types::NetworkingIdentity::new() + }; let (tx, rx) = std::sync::mpsc::channel(); let _cb = self._client .register_callback(move |res: steamworks::AuthSessionTicketResponse| { let _ = tx.send(res); }); - let (_ticket, data) = user.authentication_session_ticket_with_steam_id(user.steam_id()); + let (_ticket, data) = user.authentication_session_ticket(identity); let deadline = Instant::now() + Duration::from_secs(3); + let mut ready = false; while Instant::now() < deadline { self._client.run_callbacks(); - if rx.try_recv().is_ok() { + if let Ok(res) = rx.try_recv() { + ready = res.result.is_ok(); break; } std::thread::sleep(Duration::from_millis(20)); } - info!(ticket_len = data.len(), "обновлён Steam-тикет"); + info!( + ticket_len = data.len(), + ready, + game_server_steam_id, + "тикет для сервера получен" + ); Some(data) } } @@ -103,43 +121,14 @@ fn steam_auth() -> Option { } let steam_id = user.steam_id().raw(); - // Ждём AuthSessionTicketResponse: до него тикет ещё не готов. - let (tx, rx) = std::sync::mpsc::channel(); - let _cb = client.register_callback(move |res: steamworks::AuthSessionTicketResponse| { - let _ = tx.send(res); - }); - let (ticket, data) = user.authentication_session_ticket_with_steam_id(user.steam_id()); - let deadline = Instant::now() + Duration::from_secs(3); - let mut ready = false; - while Instant::now() < deadline { - client.run_callbacks(); - if let Ok(res) = rx.try_recv() { - match res.result { - Ok(()) => ready = true, - Err(err) => info!("Steam-тикет не готов: {err:?}"), - } - break; - } - std::thread::sleep(Duration::from_millis(20)); - } - if !ready { - info!("ответ AuthSessionTicketResponse не получен — пробуем всё равно"); - } - // Локальная проверка тикета (для диагностики). - let begin = user.begin_authentication_session(user.steam_id(), &data); - info!( - steam_id, - ticket_len = data.len(), - ready, - begin = ?begin, - "получен Steam-тикет" - ); - let _ = ticket; + // Тикет не запрашиваем здесь: он привязан к SteamID игрового сервера, + // который известен только после ответа challenge (см. `ticket_for_server`). + info!(steam_id, "Steam-клиент готов"); Some(SteamAuth { _client: client, info: net::AuthInfo { steam_id, - ticket: data, + ticket: Vec::new(), patch_version: String::new(), }, }) @@ -183,7 +172,7 @@ pub fn connect(args: ConnectArgs) -> anyhow::Result<()> { .with_context(|| format!("подключение к {}", args.addr))?; if let Some(steam) = &steam { let handle = steam.clone(); - client.set_ticket_provider(Box::new(move || handle.borrow_mut().new_ticket())); + client.set_ticket_provider(Box::new(move |gsid| handle.borrow_mut().ticket_for_server(gsid))); } let deadline = Instant::now() + Duration::from_secs(args.seconds); @@ -198,8 +187,16 @@ pub fn connect(args: ConnectArgs) -> anyhow::Result<()> { NetEvent::Challenge { challenge, auth_protocol, + game_server_steam_id, + secure, } => { - info!(challenge, auth_protocol, "сервер прислал challenge"); + info!( + challenge, + auth_protocol, + game_server_steam_id, + secure, + "сервер прислал challenge" + ); } NetEvent::Connected => { info!("подключение принято, netchan-сессия началась"); diff --git a/crates/net/src/client.rs b/crates/net/src/client.rs index 1197b87..4415718 100644 --- a/crates/net/src/client.rs +++ b/crates/net/src/client.rs @@ -15,6 +15,10 @@ pub enum NetEvent { Challenge { challenge: i32, auth_protocol: i32, + /// SteamID игрового сервера (из ответа challenge, auth_protocol=3). + game_server_steam_id: u64, + /// Сервер VAC-secured. + secure: bool, }, /// Сервер принял подключение (началась netchan-сессия). Connected, @@ -69,7 +73,7 @@ impl Default for AuthInfo { } /// Поставщик Steam-тикета (вызывается при повторных `connect`). -pub type TicketProvider = Box Option>>; +pub type TicketProvider = Box Option>>; /// Сетевой клиент GMod-протокола (v0: хендшейк + чтение сообщений). pub struct NetClient { @@ -92,6 +96,12 @@ pub struct NetClient { auth_protocol: i32, /// Провайдер свежего Steam-тикета для повторных connect. ticket_provider: Option, + /// SteamID игрового сервера из challenge (для тикета). + game_server_steam_id: u64, + /// Сервер secure (VAC). + game_server_secure: bool, + /// Кэш тикета: SteamID сервера → тикет (движок переиспользует тикет). + ticket_cache: Option<(u64, Vec)>, /// Первый 'A' пропущен (эксперимент). skip_done: bool, /// Отложенный connect (эксперимент). @@ -133,6 +143,9 @@ impl NetClient { connect_sent: false, auth_protocol: 3, ticket_provider: None, + game_server_steam_id: 0, + game_server_secure: false, + ticket_cache: None, skip_done: false, pending_connect: None, }; @@ -163,10 +176,16 @@ impl NetClient { } fn send_connect(&mut self, server_challenge: i32, auth_protocol: i32) -> Result<(), NetError> { - // Повторный connect: берём свежий тикет (старый мог быть использован). - if self.connect_sent { + // Тикет: кэшируем по SteamID сервера (как движок — + // GetAuthSessionTicket переиспользуется для того же сервера). + let cached = self + .ticket_cache + .as_ref() + .is_some_and(|(id, _)| *id == self.game_server_steam_id); + if !cached { if let Some(provider) = &mut self.ticket_provider { - if let Some(ticket) = provider() { + if let Some(ticket) = provider(self.game_server_steam_id) { + self.ticket_cache = Some((self.game_server_steam_id, ticket.clone())); self.auth.ticket = ticket; } } @@ -214,10 +233,25 @@ impl NetClient { .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(signon::CONNECTED); - let mut messages = vec![ClientMessage::SignOnState { + // Порядок как в движке/GMod: сначала Net.SetConVar (userinfo), затем + // Net.SignonState(Connected) — см. SignOnState.Connected в доках. + let mut messages = vec![ClientMessage::SetConVar(vec![ + ("name".to_string(), self.name.clone()), + ("rate".to_string(), "196608".to_string()), + ("cl_cmdrate".to_string(), "66".to_string()), + ("cl_updaterate".to_string(), "66".to_string()), + ("cl_interp".to_string(), "0.1".to_string()), + ("cl_interp_ratio".to_string(), "2".to_string()), + ("cl_lagcompensation".to_string(), "1".to_string()), + ("cl_pred_optimize".to_string(), "2".to_string()), + ("cl_smooth".to_string(), "1".to_string()), + ("cl_smoothtime".to_string(), "0.1".to_string()), + ("cl_language".to_string(), "english".to_string()), + ])]; + messages.push(ClientMessage::SignOnState { state, spawn_count: u32::MAX, - }]; + }); if std::env::var_os("SOURCE_RS_CLIENT_INFO").is_some() { messages.push(ClientMessage::ClientInfo { name: self.name.clone(), @@ -254,6 +288,7 @@ impl NetClient { let mut buf = vec![0u8; 65536]; let mut next_keepalive = Instant::now(); + let mut next_hello = Instant::now(); while Instant::now() < deadline { // Периодические пустые пакеты (как cmdrate у настоящего клиента). if let Ok(hz) = std::env::var("SOURCE_RS_KEEPALIVE_HZ") { @@ -275,6 +310,20 @@ impl NetClient { self.connect_sent = true; } } + // Эксперимент: повторять hello (SignonState) после 'B' — на случай, + // если сервер обрабатывает наш SignonState раньше, чем сам + // переходит в CONNECTED (ProcessSignonState иначе шлёт Reconnect). + if let Ok(ms) = std::env::var("SOURCE_RS_HELLO_EVERY_MS") { + if let Ok(ms) = ms.parse::() { + if ms > 0 + && self.state == ClientState::Connected + && Instant::now() >= next_hello + { + next_hello = Instant::now() + Duration::from_millis(ms); + self.send_hello()?; + } + } + } if self.state == ClientState::Challenge && Instant::now() >= self.next_resend && std::env::var_os("SOURCE_RS_NO_RESEND").is_none() @@ -390,6 +439,18 @@ impl NetClient { } self.server_challenge = challenge; self.auth_protocol = auth_protocol; + // GMod/Steam: u16 keysize(0), u64 SteamID игрового сервера, u8 secure. + let mut game_server_steam_id = 0u64; + let mut secure = false; + if auth_protocol == 3 && r.bits_left() >= 16 + 64 + 8 { + let _key_size = r.read_short(); + let low = r.read_long() as u64; + let high = r.read_long() as u64; + game_server_steam_id = low | (high << 32); + secure = r.read_byte() == 1; + } + self.game_server_steam_id = game_server_steam_id; + self.game_server_secure = secure; // Эксперимент: пропустить первый connect (сервер отвечает 'B' // только на свежий challenge). if std::env::var_os("SOURCE_RS_SKIP_FIRST_CONNECT").is_some() && !self.skip_done { @@ -417,6 +478,8 @@ impl NetClient { Ok(vec![NetEvent::Challenge { challenge, auth_protocol, + game_server_steam_id: self.game_server_steam_id, + secure: self.game_server_secure, }]) } b'B' => { @@ -433,6 +496,14 @@ impl NetClient { b'9' => { let _my_challenge = r.read_long(); let why = r.read_string(1024).unwrap_or_default(); + // Отказ (например, #GameUI_ServerRejectSteam): повторяем редко — + // Steam считает повторный BeginAuthSession дубликатом. + let delay_ms = std::env::var("SOURCE_RS_RETRY_DELAY_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(10_000); + self.next_resend = Instant::now() + Duration::from_millis(delay_ms); + self.ticket_cache = None; Err(NetError::Rejected(why)) } _ => Ok(Vec::new()), diff --git a/crates/net/src/netchan.rs b/crates/net/src/netchan.rs index 45b61b0..dcb974d 100644 --- a/crates/net/src/netchan.rs +++ b/crates/net/src/netchan.rs @@ -33,6 +33,16 @@ pub const MAX_FRAGMENTS_PER_PACKET_BITS: usize = 5; pub const MAX_FILE_SIZE_BITS: usize = 30; /// Биты номера подканала (в GMod — 4: 16 подканалов). pub const SUBCHANNEL_BITS: usize = 4; + +/// Ширина индекса подканала: GMod — 4 (16 подканалов), Source 2013 — 3. +/// Переопределяется переменной окружения для экспериментов. +pub fn subchannel_bits() -> usize { + std::env::var("SOURCE_RS_SUBCHANNEL_BITS") + .ok() + .and_then(|v| v.parse().ok()) + .filter(|v| (1..=6).contains(v)) + .unwrap_or(SUBCHANNEL_BITS) +} /// Число потоков фрагментов (Normal, File). pub const MAX_STREAMS: usize = 2; @@ -254,7 +264,7 @@ impl NetChan { let mut messages = Vec::new(); if header.is_reliable() { - let subchannel = r.read_ubit_long(SUBCHANNEL_BITS) as u16; + let subchannel = r.read_ubit_long(subchannel_bits()) as u16; for i in 0..MAX_STREAMS { if r.read_one_bit() { self.read_subchannel(&mut r, i)?; @@ -576,12 +586,16 @@ pub fn build_hello_packet( w.write_u32(ack); w.write_byte(PACKET_FLAG_CHALLENGE | PACKET_FLAG_RELIABLE); // flags (патчится ниже) w.write_short(0); // checksum (патчится ниже) - w.write_short(if messages.is_empty() { 0 } else { 1 }); // in_reliable_state: подканал 0 + let rel_state = std::env::var("SOURCE_RS_REL_STATE") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(if messages.is_empty() { 0 } else { 1 }); + w.write_short(rel_state); // in_reliable_state (состояние принятых подканалов) w.write_u32(challenge); if !messages.is_empty() && std::env::var_os("SOURCE_RS_UNRELIABLE_MSG").is_none() { // Подканал 0. - w.write_ubit_long(0, SUBCHANNEL_BITS); + w.write_ubit_long(0, subchannel_bits()); let streams = std::env::var("SOURCE_RS_STREAMS") .ok() .and_then(|v| v.parse::().ok()) diff --git a/docs/RECON.md b/docs/RECON.md index 5b72342..6aaa93d 100644 --- a/docs/RECON.md +++ b/docs/RECON.md @@ -210,3 +210,48 @@ рисует их с белой лайтмапой (полная яркость); ambient-куб листа в GMod (`LEAF_AMBIENT_INDEX/LIGHTING`, запись 28 байт) почти чёрный — использовать его нельзя. + +## 13. Хендшейк Steam-подключения (2026-10-12, эксперименты с живыми серверами) + +**Ключевая находка: тикет Steam нужно запрашивать с идентичностью игрового +сервера.** Современный Steamworks (`GetAuthSessionTicket` с +`SteamNetworkingIdentity`) требует SteamID сервера; «generic»-тикет (пустая +идентичность, как в старом движке `SteamUser()->GetAuthSessionTicket` без +identity) сервер отклоняет синхронно: `#GameUI_ServerRejectSteam`. +Работает: `NetworkingIdentity::new_steam_id(game_server_steam_id)`. + +- **Ответ challenge ('A', auth_protocol=3)** содержит: `magic u32`, `challenge + u32`, `my_challenge u32`, `auth_protocol u32`, `keysize u16` (=0), + `game_server_steam_id u64`, `secure u8`, затем строку версии. Пример с + ClassicBox: magic=0x5A4F4933, GSID=0x0130000000B33AE4 (универсум 1, + тип 3 = GameServer), secure=1. +- **connect (C2S)**: `0xffffffff`, `'k'`, `protocol u32` (24), + `auth_protocol u32` (3), `challenge u32`, `retry_challenge u32`, + `ChecksumXOR u32` (по записанным байтам), `name\0`, `password\0`, + `patch_version\0` (у GMod — версия сервера, напр. `2026.04.29`), затем + `size u16` (= ticket_len+8), `steam_id u64`, тикет. +- **Отказ**: `0xffffffff`, `'9'`, `my_challenge u32`, строка причины + (`#GameUI_ServerRejectSteam` — отказ `BeginAuthSession`). +- Сервер создаёт netchan-сессию **сразу на первый connect** (если тикет принят) + и отвечает `'B'`; повторные connect с тем же SteamID → `BeginAuthSession` + возвращает `k_EBeginAuthSessionResultDuplicateRequest` → снова отказ. Значит + ретраи надо делать с большим интервалом (в движке — тикет кэшируется на + сервер: `CSteam3Client::GetAuthSessionTicket` хранит m_hAuthTicket до смены + сервера/IP/secure). +- **Частые запросы тикетов подряд** (десятки за минуты) приводят к тому, что + Steam начинает выдавать тикеты, которые сервер отвергает — нужно ограничить + частоту и переиспользовать тикет. +- После 'B' сервер ждёт от клиента (состояние Connected): `Net.SetConVar` + (userinfo) + `Net.SignonState(CONNECTED=2)`, затем шлёт сигнон-блок + (Net.Tick, SVC_CreateStringTable×N, SVC_GMod_ServerToClient, SetConVar, + SignonState(NEW=3)). Порядок состояний: CONNECTED(2) → NEW(3) → PRESPAWN(4) + → SPAWN(5) → FULL(6); на NEW клиент шлёт CLC_ClientInfo + + CLC_GMod_ClientToServer + SignonState(NEW). + Наши пакеты сервер подтверждает (ack растёт), но сигнон-блок пока не приходит — + разбираемся (подозрение: разбор/порядок сообщений или ожидание доп. сообщений). +- Проверено на двух живых серверах: ClassicBox `194.147.90.27:24215` и + `37.230.228.47:27021` (ДаркРП). +- Замечание по окружению: на машине сломан системный резолвер + (`/etc/nsswitch.conf` → `resolve`, но `systemd-resolved` не запущен) — из-за + этого не резолвятся имена (srcds не стартует, master-сервер не опросить). + Прямые запросы к 1.1.1.1 работают.