From 243fd09154374f2f3058f7d58f5ba59eb62846be Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:43:33 +0700 Subject: [PATCH] Logging in to an account with no characters lands on the empty character list, as in 1.12.1, instead of creating a Human Warrior (#457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logging in to an account or realm with no characters lands on the empty character list. Until now the login cycle created a male Human Warrior on the server, named `WOW_CHAR` or "One", on every login that found an empty roster, a typed one included and again after Change Realm. - **The reference.** The client builds `CMSG_CHAR_CREATE` in one place, `0x5aac50`, reached only from the glue binding `CreateCharacter` (`0x4719e0` โ†’ `0x4711d0` โ†’ `0x46b290` โ†’ `0x5aac50`), which stock `CharacterCreate.lua` calls only from `CharacterCreate_Okay`. With an empty roster, stock `CharacterSelect.lua`'s `UpdateCharacterList` disables Enter World and Delete and shows Create New Character, which benilla's select screen already does. - **The change.** The create-if-empty step is gone from the per-realm cycle, and `NetConfig`, which existed only for it, goes with it. The create screen's own path is untouched. - **Scripted runs.** `WOW_CHAR` must now be on the account: a driverless run whose `WOW_CHAR` is missing fails at select, as it already did on a non-empty roster, and a player's run shows the list. `docs/CONTRIBUTING.md` says so, and names `WOW_PROBE_CHARCREATE` as the explicit way to make one. Checked: `an_empty_account_parks_at_select_without_creating_a_character` drives the real login cycle against a fake realmd (the server half of SRP6) and a fake world server answering an empty roster. It fails with the old step restored (the client sends `CMSG_CHAR_CREATE` on its own) and passes without it. Live against a local vmangos on a fresh account with no characters: before, the server gained a level-1 "One"; after, the client parks on the empty list and the account still holds no character. With a `WOW_CHAR` not on the account, a driverless run exits at select, and nothing is created. Gates and the smoke run green on macOS; no platform seam touched. Fixes #343 ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 3 + crates/benilla-app/Cargo.toml | 6 + crates/benilla-app/src/net.rs | 2 +- crates/benilla-app/src/net/io.rs | 53 +--- crates/benilla-app/src/net/io/cycle_tests.rs | 274 +++++++++++++++++++ docs/CONTRIBUTING.md | 6 +- docs/MAP.md | 2 +- 7 files changed, 298 insertions(+), 48 deletions(-) create mode 100644 crates/benilla-app/src/net/io/cycle_tests.rs diff --git a/Cargo.lock b/Cargo.lock index ab0e83e3..8e8e613f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -512,6 +512,7 @@ dependencies = [ "benilla-assets", "benilla-formats", "benilla-protocol", + "benilla-srp", "benilla-ui", "benilla-world", "bevy", @@ -528,6 +529,7 @@ dependencies = [ "kira", "libc", "mint", + "num-bigint", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-core-audio", @@ -538,6 +540,7 @@ dependencies = [ "regex", "rtrb", "serde", + "sha1", "smithay-clipboard", "symphonia", "toml", diff --git a/crates/benilla-app/Cargo.toml b/crates/benilla-app/Cargo.toml index c6b65cd4..80ed5285 100644 --- a/crates/benilla-app/Cargo.toml +++ b/crates/benilla-app/Cargo.toml @@ -96,3 +96,9 @@ cpal = "0.17" # Windows CPU clocks for `perf::clock`. [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] } + +# The login cycle's fake realmd runs the server half of SRP6 (`net::io::cycle_tests`). +[dev-dependencies] +benilla-srp = { path = "../benilla-srp" } +num-bigint = "0.4.6" +sha1.workspace = true diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index e4be9117..639cb436 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -66,7 +66,7 @@ impl Plugin for NetPlugin { crate::query_cache::register::(app); crate::query_cache::register::(app); crate::query_cache::register::(app); - let handles = io::spawn_net(io::NetConfig::from_env(), self.connect); + let handles = io::spawn_net(self.connect); if !self.connect { app.insert_resource(NetOffline); } diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index b34539c3..d9245ce8 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; -use anyhow::{anyhow, bail, Result}; +use anyhow::{anyhow, Result}; use benilla_assets::LockRecover; use benilla_protocol::{ host_port, messages, AuthReject, CharAction, LoginStage, Poll, SessionEnd, SessionEvent, @@ -145,21 +145,6 @@ impl PingClock { /// Cap on send warnings per connection, so a movement stream during an outage cannot flood the log. const SEND_WARN_CAP: u32 = 8; -/// The per-process connection parameters. Credentials and address ride each [`LoginRequest`]; -/// `$WOW_HOST` is read by `Realmlist::default()`. -pub(super) struct NetConfig { - /// `WOW_CHAR`: here only the name of the starter character on an empty account. - character: Option, -} - -impl NetConfig { - pub(super) fn from_env() -> Self { - NetConfig { - character: std::env::var("WOW_CHAR").ok(), - } - } -} - /// What one wake-up at the character park asked for, so every jump out is made outside `select!`. enum Parked { /// `CMSG_PLAYER_LOGIN` with this guid. @@ -204,7 +189,7 @@ pub(super) struct NetHandles { } /// Spawns the read thread with its park and cycle loop, and the one long-lived write thread. -pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles { +pub(super) fn spawn_net(connect: bool) -> NetHandles { let (events_tx, events_rx) = crossbeam_channel::unbounded(); let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let (pick_tx, pick_rx) = crossbeam_channel::unbounded::(); @@ -247,7 +232,6 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles { // writer, since the keepalive can still fire on the stale one until then. read_clock.lock_recover().clear(); match run( - &cfg, &events_tx, &writer_tx, &parks, @@ -310,7 +294,6 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles { /// dies, the character logs out or the app exits. A pre-roster failure emits /// [`SessionEvent::LoginFailed`] and re-parks; any retry is the app's. fn run( - cfg: &NetConfig, events_tx: &Sender, writer_tx: &Sender, parks: &Parks, @@ -501,31 +484,10 @@ fn run( return Ok(Cycle::Repark); } - // The roster, creating a starter character on an empty account so `PLAYER_LOGIN` has a - // target. Failures here are still pre-roster. - let mut characters = match (|| -> Result> { - let mut characters = session.char_enum()?; - if characters.is_empty() { - let name = cfg.character.as_deref().unwrap_or("One"); - let starter = messages::CharCreateReq { - name: name.to_string(), - race: messages::RACE_HUMAN, - class: messages::CLASS_WARRIOR, - gender: messages::GENDER_MALE, - skin: 0, - face: 0, - hair_style: 0, - hair_color: 0, - facial_hair: 0, - }; - match session.create_character(&starter)? { - messages::CHAR_CREATE_SUCCESS | messages::CHAR_CREATE_NAME_IN_USE => {} - other => bail!("character creation failed: result {other:#x}"), - } - characters = session.char_enum()?; - } - Ok(characters) - })() { + // The roster as the account holds it, an empty one included: the reference builds + // `CMSG_CHAR_CREATE` in one place (`0x5aac50`), reached only from the create screen. + // Failures here are still pre-roster. + let mut characters = match session.char_enum() { Ok(c) => c, Err(e) => { if canceled() { @@ -1530,6 +1492,9 @@ fn writer_loop( } } +#[cfg(test)] +mod cycle_tests; + #[cfg(test)] mod rtt_tests { use super::{PingClock, RTT_RING}; diff --git a/crates/benilla-app/src/net/io/cycle_tests.rs b/crates/benilla-app/src/net/io/cycle_tests.rs new file mode 100644 index 00000000..c30a155f --- /dev/null +++ b/crates/benilla-app/src/net/io/cycle_tests.rs @@ -0,0 +1,274 @@ +//! The login cycle, [`super::run`], against a fake realmd and a fake world server on loopback: the +//! real logon, realm park, world handshake and roster, driven through the parks as the app does. + +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::AtomicU64; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use benilla_protocol::messages::{self, opcode}; +use benilla_protocol::{SessionEvent, WorldWriter}; +use benilla_srp::vanilla_header::HeaderCrypto; +use benilla_srp::{ + password_verifier, NormalizedString, GENERATOR, LARGE_SAFE_PRIME_LITTLE_ENDIAN, + SESSION_KEY_LENGTH, +}; +use crossbeam_channel::{Receiver, Sender}; +use num_bigint::BigUint; +use sha1::{Digest, Sha1}; + +use super::{run, Cycle, LoginRequest, Parks, PingClock}; +use crate::net::{CharRequest, RealmRequest}; + +const USER: &str = "empty"; +const PASS: &str = "secret"; +const REALM: &str = "Fake"; +const SALT: [u8; 32] = [0x5a; 32]; +/// Every wait in the test: long enough for a loaded machine, short of a hung gate. +const WAIT: Duration = Duration::from_secs(20); + +fn sha1(parts: &[&[u8]]) -> [u8; 20] { + let mut h = Sha1::new(); + for p in parts { + h.update(p); + } + h.finalize().into() +} + +fn le32(v: &BigUint) -> [u8; 32] { + let raw = v.to_bytes_le(); + let mut out = [0u8; 32]; + out[..raw.len()].copy_from_slice(&raw); + out +} + +/// `SHA1_Interleave` over all 32 bytes of `S`, as vmangos's `SRP6::HashSessionKey` does. +fn interleave(s: &[u8; 32]) -> [u8; SESSION_KEY_LENGTH] { + let even: Vec = s.iter().step_by(2).copied().collect(); + let odd: Vec = s.iter().skip(1).step_by(2).copied().collect(); + let (g, h) = (sha1(&[&even]), sha1(&[&odd])); + let mut out = [0u8; SESSION_KEY_LENGTH]; + for (i, (gi, hi)) in g.iter().zip(&h).enumerate() { + out[i * 2] = *gi; + out[i * 2 + 1] = *hi; + } + out +} + +/// A realmd that logs [`USER`] in with the server half of SRP6 and lists one realm, [`REALM`] at +/// `world`. The session key goes to the fake world server, which needs it for the header cipher. +fn fake_realmd(world: String, keys: Sender<[u8; SESSION_KEY_LENGTH]>) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + thread::spawn(move || { + let (mut s, _) = listener.accept().unwrap(); + let n = BigUint::from_bytes_le(&LARGE_SAFE_PRIME_LITTLE_ENDIAN); + let g = BigUint::from(GENERATOR); + let user = NormalizedString::new(USER).unwrap(); + let pass = NormalizedString::new(PASS).unwrap(); + let v = BigUint::from_bytes_le(&password_verifier(&user, &pass, &SALT)); + + // CMD_AUTH_LOGON_CHALLENGE_Client: opcode, protocol, u16 size, then the body. + let mut head = [0u8; 4]; + s.read_exact(&mut head).unwrap(); + let mut body = vec![0u8; usize::from(u16::from_le_bytes([head[2], head[3]]))]; + s.read_exact(&mut body).unwrap(); + + // B = kยทv + g^b, with k = 3; `b` walks until `B`'s top byte is non-zero, since the client + // redials on a zero one. + let (b, b_pub) = (1u32..) + .map(|i| { + let b = BigUint::from_bytes_le(&[0x3c; 32]) + i; + let b_pub = le32(&((BigUint::from(3u8) * &v + g.modpow(&b, &n)) % &n)); + (b, b_pub) + }) + .find(|(_, b_pub)| b_pub[31] != 0) + .unwrap(); + let mut reply = vec![0x00, 0x00, 0x00]; + reply.extend_from_slice(&b_pub); + reply.extend_from_slice(&[1, GENERATOR, 32]); + reply.extend_from_slice(&LARGE_SAFE_PRIME_LITTLE_ENDIAN); + reply.extend_from_slice(&SALT); + reply.extend_from_slice(&[0u8; 16]); // crc_salt + reply.push(0); // security_flag + s.write_all(&reply).unwrap(); + + // CMD_AUTH_LOGON_PROOF_Client: opcode, A, M1, crc_hash, key count, security flag. + let mut proof = [0u8; 75]; + s.read_exact(&mut proof).unwrap(); + let (a_pub, m1) = (&proof[1..33], &proof[33..53]); + let u = BigUint::from_bytes_le(&sha1(&[a_pub, &b_pub])); + let a = BigUint::from_bytes_le(a_pub); + let big_s = (a * v.modpow(&u, &n) % &n).modpow(&b, &n); + let key = interleave(&le32(&big_s)); + let mut reply = vec![0x01, 0x00]; + reply.extend_from_slice(&sha1(&[a_pub, m1, &key])); + reply.extend_from_slice(&0u32.to_le_bytes()); // hardware_survey_id + s.write_all(&reply).unwrap(); + keys.send(key).unwrap(); + + // CMD_REALM_LIST_Client, answered with the one realm. + s.read_exact(&mut [0u8; 5]).unwrap(); + let mut realm = Vec::new(); + realm.extend_from_slice(&0u32.to_le_bytes()); // realm_type + realm.push(0); // flags + realm.extend_from_slice(format!("{REALM}\0{world}\0").as_bytes()); + realm.extend_from_slice(&0f32.to_le_bytes()); // population + realm.extend_from_slice(&[0, 1, 1]); // characters, category, id + let mut list = vec![0x10]; + list.extend_from_slice(&((4 + 1 + realm.len() + 2) as u16).to_le_bytes()); + list.extend_from_slice(&0u32.to_le_bytes()); + list.push(1); + list.extend_from_slice(&realm); + list.extend_from_slice(&0u16.to_le_bytes()); + s.write_all(&list).unwrap(); + + // Held until the cycle drops the logon, as realmd keeps it for the list refresh. + let _ = s.read(&mut [0u8; 1]); + }); + addr +} + +/// A server packet: `u16` BE size counting the opcode, `u16` LE opcode, the body. +fn send(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>, op: u16, body: &[u8]) { + let size = ((body.len() + 2) as u16).to_be_bytes(); + let op = op.to_le_bytes(); + let mut header = [size[0], size[1], op[0], op[1]]; + if let Some(c) = crypto { + c.encrypter().encrypt(&mut header); + } + s.write_all(&header).unwrap(); + s.write_all(body).unwrap(); +} + +/// A client packet's opcode, reading past its body: `u16` BE size counting the 4-byte opcode. +fn recv(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>) -> Option { + let mut header = [0u8; 6]; + s.read_exact(&mut header).ok()?; + if let Some(c) = crypto { + c.decrypter().decrypt(&mut header); + } + let size = usize::from(u16::from_be_bytes([header[0], header[1]])); + s.read_exact(&mut vec![0u8; size.saturating_sub(4)]).ok()?; + Some(u16::from_le_bytes([header[2], header[3]])) +} + +/// A world server whose account has no characters: it admits the session under realmd's key, +/// answers every `CMSG_CHAR_ENUM` with an empty roster and a `CMSG_CHAR_CREATE` with success, and +/// logs every opcode the client sends. +fn fake_world( + listener: TcpListener, + keys: Receiver<[u8; SESSION_KEY_LENGTH]>, +) -> Arc>> { + let sent = Arc::new(Mutex::new(Vec::new())); + let log = Arc::clone(&sent); + thread::spawn(move || { + let (mut s, _) = listener.accept().unwrap(); + send( + &mut s, + None, + opcode::SMSG_AUTH_CHALLENGE, + &0xDEAD_BEEFu32.to_le_bytes(), + ); + assert_eq!(recv(&mut s, None), Some(opcode::CMSG_AUTH_SESSION)); + let mut crypto = HeaderCrypto::from_session_key(keys.recv_timeout(WAIT).unwrap()); + send( + &mut s, + Some(&mut crypto), + opcode::SMSG_AUTH_RESPONSE, + &[messages::AUTH_OK], + ); + while let Some(op) = recv(&mut s, Some(&mut crypto)) { + log.lock().unwrap().push(op); + match op { + opcode::CMSG_CHAR_ENUM => { + send(&mut s, Some(&mut crypto), opcode::SMSG_CHAR_ENUM, &[0]); + } + opcode::CMSG_CHAR_CREATE => send( + &mut s, + Some(&mut crypto), + opcode::SMSG_CHAR_CREATE, + &[messages::CHAR_CREATE_SUCCESS], + ), + _ => {} + } + } + }); + sent +} + +/// The next event, skipping the stage announcements; a login failure fails the test by name. +fn next_event(events: &Receiver) -> SessionEvent { + loop { + match events.recv_timeout(WAIT).expect("the cycle went quiet") { + SessionEvent::LoginStage { .. } => continue, + SessionEvent::LoginFailed { reason, .. } => panic!("the login failed: {reason}"), + ev => return ev, + } + } +} + +/// The reference builds `CMSG_CHAR_CREATE` in one place (`0x5aac50`), reached only from the glue's +/// `CreateCharacter`, which the stock glue calls only from the create screen's Okay; so a login to +/// an account with no characters parks at select on the empty list and creates nothing. +#[test] +fn an_empty_account_parks_at_select_without_creating_a_character() { + let world_listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let world = world_listener.local_addr().unwrap().to_string(); + let (keys_tx, keys_rx) = crossbeam_channel::unbounded(); + let sent = fake_world(world_listener, keys_rx); + let realmd = fake_realmd(world, keys_tx); + + let (events_tx, events) = crossbeam_channel::unbounded(); + let (writer_tx, _writer_rx) = crossbeam_channel::unbounded::(); + let (login_tx, login_rx) = crossbeam_channel::unbounded(); + let (realm_tx, realm_rx) = crossbeam_channel::unbounded(); + let (pick_tx, pick_rx) = crossbeam_channel::unbounded(); + let cycle = thread::spawn(move || { + let parks = Parks { + login_rx, + realm_rx, + pick_rx, + }; + let ended = run( + &events_tx, + &writer_tx, + &parks, + &AtomicU64::new(0), + &Mutex::new(PingClock::default()), + &mut Default::default(), + ); + matches!(ended, Ok(Cycle::Repark)) + }); + + login_tx + .send(LoginRequest { + user: USER.into(), + pass: PASS.into(), + host: realmd, + generation: 0, + }) + .unwrap(); + match next_event(&events) { + SessionEvent::RealmList { realms } => assert_eq!(realms[0].name, REALM), + other => panic!("expected the realm list, got {other:?}"), + } + realm_tx.send(RealmRequest::Enter(REALM.into())).unwrap(); + match next_event(&events) { + SessionEvent::CharacterList { characters, .. } => assert!( + characters.is_empty(), + "the roster at select must be the account's, empty: {characters:?}" + ), + other => panic!("expected the roster at select, got {other:?}"), + } + // Select's Back ends the cycle, so the socket is done and its log complete. + pick_tx.send(CharRequest::Abandon).unwrap(); + assert!(cycle.join().unwrap(), "Back returns to the login park"); + let sent = sent.lock().unwrap().clone(); + assert!( + !sent.contains(&opcode::CMSG_CHAR_CREATE), + "the login sent CMSG_CHAR_CREATE on its own: {sent:#06x?}" + ); +} diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 128e5e6b..1b67ea2b 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -73,8 +73,10 @@ out of scope is closed with the reason. `localhost:3724`). A scripted run has no default account: `WOW_USER`, `WOW_PASS` and `WOW_CHAR` name a test account on your server whose login kicks nobody, all three, either in the environment or in a `.probe-identity` file at the repo root (one per line, never - committed), and `scripts/smoke.sh` (the live login gate) refuses without them. The probes - drive the body with GM commands, so give that account the top GM level. + committed), and `scripts/smoke.sh` (the live login gate) refuses without them. `WOW_CHAR` + must already be on the account, since the client creates a character only when asked: + `WOW_PROBE_CHARCREATE= WOW_PROBE_CHARCREATE_KEEP=1`, run without `WOW_CHAR`, makes it. + The probes drive the body with GM commands, so give that account the top GM level. - **Running it unattended.** The rules are `docs/METHOD.md`, "The local server"; these are the switches. - `WOW_UNATTENDED=1` reconnects instead of waiting at a dialog, and exits non-zero on a login diff --git a/docs/MAP.md b/docs/MAP.md index a06f1408..d71c0234 100644 --- a/docs/MAP.md +++ b/docs/MAP.md @@ -182,7 +182,7 @@ - `WOW_CAPTURE_STABLE` โ€” benilla-app/src/capture/mod.rs - `WOW_CAPTURE_UI` โ€” benilla-app/src/capture/scenarios.rs, benilla-app/src/ui_script/mod.rs, benilla-app/src/video.rs - `WOW_CAST_TRACE` โ€” benilla-app/src/net.rs -- `WOW_CHAR` โ€” benilla-app/src/capture/probe_mail.rs, benilla-app/src/char_select/mod.rs, benilla-app/src/cvars.rs, benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/net/io.rs, benilla-app/src/realm_select/smoke.rs, benilla-app/src/run_mode.rs +- `WOW_CHAR` โ€” benilla-app/src/capture/probe_mail.rs, benilla-app/src/char_select/mod.rs, benilla-app/src/cvars.rs, benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/realm_select/smoke.rs, benilla-app/src/run_mode.rs - `WOW_CHARCREATE_DIALS` โ€” benilla-app/src/char_create/mod.rs - `WOW_CHARCREATE_NAME` โ€” benilla-app/src/char_create/mod.rs - `WOW_CHARCREATE_PICK` โ€” benilla-app/src/capture/mod.rs, benilla-app/src/char_create/mod.rs