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)

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) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-27 10:43:33 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0106b66b25
commit 243fd09154
7 changed files with 298 additions and 48 deletions
Generated
+3
View File
@@ -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",
+6
View File
@@ -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
+1 -1
View File
@@ -66,7 +66,7 @@ impl Plugin for NetPlugin {
crate::query_cache::register::<crate::names::NameCache>(app);
crate::query_cache::register::<crate::go_templates::GameObjectTemplates>(app);
crate::query_cache::register::<crate::items::Items>(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);
}
+9 -44
View File
@@ -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<String>,
}
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::<CharRequest>();
@@ -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<SessionEvent>,
writer_tx: &Sender<WorldWriter>,
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<Vec<benilla_protocol::Character>> {
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};
@@ -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<u8> = s.iter().step_by(2).copied().collect();
let odd: Vec<u8> = 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<u16> {
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<Mutex<Vec<u16>>> {
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>) -> 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::<WorldWriter>();
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?}"
);
}
+4 -2
View File
@@ -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=<name> 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
+1 -1
View File
@@ -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