a failed dial says which failure, and the sword reads the at-war bit

- "Unable to connect" now says which: an address that does not resolve reads the reference's "Invalid Login Server", a resolved address nothing answers on reads "Login Server Down", and each names the `host:port` actually dialed — resolution and connection are two operations now (a DNS failure surfaces as `Uncategorized` on macOS, so no error kind could tell them apart), and every address a name carries is tried, `localhost` → `::1` then `127.0.0.1` included
- a typed wrong password shows the login dialog instead of exiting the client, and the two auth-result enums — realmd's and the world server's — are split so a code is read against the family that sent it, with the world server's own refusal text shown for its codes
- the world cursor's fork is `CanInteract` and `CanAttack`, not a reaction-rank threshold: a Cenarion Circle NPC with no gossip showed the sword at neutral (and TAB acquired it, and right-click attacked it) because one predicate implemented twice read the standing where the binary reads the at-war bit in the player→unit direction; one byte-verified `CanAttack` is shared by the cursor, TAB, pet attack orders, `UnitCanAttack` and the action-button usable gate, the service ladder falls out to a plain pointer instead of the attack leg, and `/reaction` prints the at-war bit and the branch verdict

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-29 17:18:29 +07:00
co-authored by Claude Fable 5
parent 0d7b984304
commit dc779b3017
20 changed files with 863 additions and 127 deletions
+33 -1
View File
@@ -109,6 +109,33 @@ pub enum SessionEnd {
Lost,
}
/// **Who refused the login, and with which byte.**
///
/// A login crosses two servers and each has its own result enum. They overlap numerically and mean
/// unrelated things — 0x0C is `AUTH_LOGON_FAILED_SUSPENDED` to realmd and `AUTH_OK` to the world
/// server — so a bare `Option<u8>` could not say what it held, and the screen could not look up the
/// right authored string from it. It used to be exactly that bare byte: the realmd code rode it and
/// the world code was formatted into the reason string and lost, which is why every world-side
/// refusal reached the player as a generic "Unable to connect".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoginRefusal {
/// realmd's logon-proof `AuthLogonResult` (`crate::AuthReject`) — bad password, banned, …
Logon(u8),
/// The world server's `SMSG_AUTH_RESPONSE` code (`crate::WorldAuthReject`, the
/// `messages::AUTH_*` block) — session expired, server shutting down, …
World(u8),
}
impl LoginRefusal {
/// The raw byte, for logging. Deliberately not a `From`/`Into`: the whole point of the type is
/// that the byte alone is ambiguous, so getting it back should look like a narrowing.
pub fn byte(self) -> u8 {
match self {
LoginRefusal::Logon(b) | LoginRefusal::World(b) => b,
}
}
}
/// One decoded event from the world stream. Carries only primitives + the coarse [`EntityKind`]
/// classification — no wire types leak to the app, and no running state lives here.
#[derive(Debug, Clone)]
@@ -124,9 +151,14 @@ pub enum SessionEvent {
/// the server is simply unusable by this client (e.g. [`crate::WardenRequired`]). The app must
/// surface `reason` and stop, never fold it into the paced-resubmit path.
LoginFailed {
code: Option<u8>,
refusal: Option<LoginRefusal>,
reason: String,
terminal: bool,
/// Set when the failure was the **dial itself** — the socket never opened — carrying which
/// of the two reasons it was and the address it was aimed at (decision 1667's follow-up).
/// `None` for every other transport failure (a handshake that got a socket and then broke)
/// and for every server refusal, which have a `code` instead.
dial: Option<crate::DialFailure>,
},
/// The account's character roster (`SMSG_CHAR_ENUM`): the world socket is authenticated and
/// parked at character select. The IO thread emits this each connection cycle, then waits for
+72 -4
View File
@@ -14,7 +14,8 @@ pub mod wire;
pub mod world;
pub use auth::AuthReject;
pub use events::{
decode, CharAction, EntityKind, LoginStage, MoveSpeeds, Poll, SessionEnd, SessionEvent,
decode, CharAction, EntityKind, LoginRefusal, LoginStage, MoveSpeeds, Poll, SessionEnd,
SessionEvent,
};
pub use messages::{
CharCreateReq, CharEnumItem, Character, CreateSpline, ItemInfo, JumpInfo, MonsterMoveFacing,
@@ -22,7 +23,9 @@ pub use messages::{
CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM,
CHARACTER_FLAG_RENAME,
};
pub use world::{WardenRequired, WorldReader, WorldSession, WorldWriter, WORLD_PORT};
pub use world::{
WardenRequired, WorldAuthReject, WorldReader, WorldSession, WorldWriter, WORLD_PORT,
};
use std::net::TcpStream;
@@ -52,6 +55,72 @@ pub fn host_port(host: &str, default: u16) -> (&str, u16) {
}
}
/// The dial never got a socket — **and which of the two reasons it was**.
///
/// This distinction only started mattering when the realmlist became something a player types
/// (decision 1667). Before that the address was a constant and "Unable to connect" could only mean
/// one thing; afterwards it means either *you typed a name that does not exist* or *the address is
/// fine and nothing is answering on it*, and a player with no way to tell them apart edits a
/// correct address over and over. That is exactly what the first live use of the editor produced.
///
/// The split is drawn where it is honest — resolution and connection are two separate operations,
/// so we do them separately rather than reading tea leaves out of one `io::Error` (a DNS failure
/// surfaces as `ErrorKind::Uncategorized` on macOS, so the kind cannot carry this).
#[derive(Debug, Clone)]
pub struct DialFailure {
/// What we tried to reach, as the player would recognise it (`host:port`).
pub address: String,
/// `true` = the host name resolved to nothing; `false` = it resolved and the connection failed.
pub unresolved: bool,
}
impl std::fmt::Display for DialFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.unresolved {
write!(f, "cannot find a server called {}", self.address)
} else {
write!(f, "nothing answered at {}", self.address)
}
}
}
impl std::error::Error for DialFailure {}
/// Resolve `host:port` and open a socket to it, reporting the two failures apart ([`DialFailure`]).
///
/// `to_socket_addrs` is the resolution step on its own; an empty result is a name that resolved to
/// no addresses, which is as much a "cannot find it" as an outright lookup error. Connecting to the
/// resolved list (rather than to `(host, port)` again) also means the OS does not repeat the
/// lookup, and that every address the name carries is tried — the `localhost` → `::1` then
/// `127.0.0.1` case on a machine whose server binds only IPv4.
fn dial(host: &str, port: u16) -> Result<TcpStream> {
use std::net::ToSocketAddrs;
let address = format!("{host}:{port}");
let addrs: Vec<std::net::SocketAddr> = match (host, port).to_socket_addrs() {
Ok(addrs) => addrs.collect(),
Err(e) => {
return Err(anyhow::Error::new(DialFailure {
address,
unresolved: true,
})
.context(format!("resolving {host}: {e}")))
}
};
if addrs.is_empty() {
return Err(anyhow::Error::new(DialFailure {
address,
unresolved: true,
}));
}
TcpStream::connect(&addrs[..]).map_err(|e| {
anyhow::Error::new(DialFailure {
address,
unresolved: false,
})
.context(format!("connecting: {e}"))
})
}
/// A realm as advertised by the auth server's realm list.
#[derive(Debug, Clone)]
pub struct RealmInfo {
@@ -96,8 +165,7 @@ pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
let (mut stream, reply, server_public_key) = {
let mut dialed = None;
for _ in 0..MAX_CHALLENGE_DIALS {
let mut stream = TcpStream::connect((host, port))
.with_context(|| format!("connecting to {host}:{port}"))?;
let mut stream = dial(host, port)?;
auth::write_logon_challenge(&mut stream, &username.to_uppercase(), CLIENT_BUILD)
.context("sending logon challenge")?;
let reply =
+28 -1
View File
@@ -222,8 +222,35 @@ pub use vendor::{
};
pub use world_state::InitWorldStates;
/// `SMSG_AUTH_RESPONSE` AuthOk result.
/// The **world server's** `SMSG_AUTH_RESPONSE` result codes — a **different enum** from the
/// realmd logon-proof's `AuthLogonResult` (`crate::AuthReject`, 0x00..=0x12), which is the trap
/// this block exists to close: both are "the auth result byte", they overlap numerically, and they
/// mean unrelated things. 0x0C is `AUTH_OK` here and `AUTH_LOGON_FAILED_SUSPENDED` there.
///
/// VERIFIED two ways. The client's own dispatch over exactly this enum is decompiled in wow-re
/// `system/net/scratch/w2b-pack.c` — case 0x0c → `AUTH_OK`, 0x0d → `AUTH_FAILED`, 0x0e →
/// `AUTH_REJECT`, … 0x15 → `AUTH_UNKNOWN_ACCOUNT`, 0x16 → `AUTH_INCORRECT_PASSWORD`, 0x1b →
/// `AUTH_WAIT_QUEUE`, 0x22..=0x26 → the `REALM_LIST_*` strings — and the same numbering is
/// `AuthResponseCodes` in cmangos `src/game/Globals/SharedDefines.h:1721+`. Each constant's name
/// is the `GlueStrings` key the client shows for it, which is what makes the mapping in
/// `crate::login`'s `world_refusal_text` a transcription rather than a judgement call.
pub const AUTH_OK: u8 = 0x0C;
pub const AUTH_FAILED: u8 = 0x0D;
pub const AUTH_REJECT: u8 = 0x0E;
pub const AUTH_BAD_SERVER_PROOF: u8 = 0x0F;
pub const AUTH_UNAVAILABLE: u8 = 0x10;
pub const AUTH_SYSTEM_ERROR: u8 = 0x11;
pub const AUTH_BILLING_ERROR: u8 = 0x12;
pub const AUTH_BILLING_EXPIRED: u8 = 0x13;
pub const AUTH_VERSION_MISMATCH: u8 = 0x14;
pub const AUTH_UNKNOWN_ACCOUNT: u8 = 0x15;
pub const AUTH_INCORRECT_PASSWORD: u8 = 0x16;
pub const AUTH_SESSION_EXPIRED: u8 = 0x17;
pub const AUTH_SERVER_SHUTTING_DOWN: u8 = 0x18;
pub const AUTH_ALREADY_LOGGING_IN: u8 = 0x19;
pub const AUTH_LOGIN_SERVER_NOT_FOUND: u8 = 0x1A;
/// The realm is full and we are **queued**, not refused — the one code here that is not an ending.
pub const AUTH_WAIT_QUEUE: u8 = 0x1B;
/// `LogoutResult::Success` (`SMSG_LOGOUT_RESPONSE`).
pub const LOGOUT_SUCCESS: u32 = 0x0;
/// Chat `Language` wire ids (VERIFIED vmangos `SharedDefines.h:256-261`): the faction tongues.
@@ -872,8 +872,8 @@ impl ObjectFields {
}
/// The object's class from the type field (`OBJECT_FIELD_TYPE`), used for `Values`/`Movement`
/// updates that carry no separate `TypeId`. Unused by the create path (which has a `TypeId`).
#[allow(dead_code)]
/// updates that carry no separate `TypeId`. Also the reference's own "is this a Player" test
/// (`[obj->descriptorBlock[2] + 8] >> 4 & 1`), which is how `CanAttack`/`CanInteract` ask.
pub fn object_type(&self) -> Option<ObjectType> {
self.get_u32(FIELD_OBJECT_TYPE).map(|bits| {
if bits & 0x10 != 0 {
+1 -1
View File
@@ -28,7 +28,7 @@ mod session;
mod writer;
pub use reader::WorldReader;
pub use session::{WardenRequired, WorldSession};
pub use session::{WardenRequired, WorldAuthReject, WorldSession};
pub use writer::WorldWriter;
/// Default `mangosd` world-server port — the stock one, which our vmangos deploy maps straight
+30 -1
View File
@@ -28,6 +28,35 @@ const HANDSHAKE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_se
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WardenRequired;
/// The **world server** refused the session (`SMSG_AUTH_RESPONSE` with anything but
/// [`messages::AUTH_OK`]).
///
/// Typed, and carrying its raw code, for the same reason [`crate::AuthReject`] is: the screen owes
/// the player the client's own authored words for what happened, and it cannot look them up from a
/// formatted string. Before this the code was interpolated into a `bail!` and thrown away, so every
/// world-side refusal — expired session, server shutting down, already logging in — arrived at the
/// login screen as the same generic "Unable to connect".
///
/// **Its codes are `messages`' `AUTH_*` block, NOT [`crate::AuthReject`]'s.** See that block for
/// why the distinction is load-bearing: the two enums overlap numerically and mean unrelated
/// things.
#[derive(Debug, Clone, Copy)]
pub struct WorldAuthReject {
pub code: u8,
}
impl std::fmt::Display for WorldAuthReject {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"world server refused the session: result {:#04x}",
self.code
)
}
}
impl std::error::Error for WorldAuthReject {}
impl std::fmt::Display for WardenRequired {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
@@ -131,7 +160,7 @@ impl WorldSession {
match session.recv()? {
ServerPacket::AuthResponse { result } if result == messages::AUTH_OK => break,
ServerPacket::AuthResponse { result } => {
bail!("world auth rejected: result {result:#x}")
return Err(WorldAuthReject { code: result }.into())
}
ServerPacket::Other {
opcode: opcode::SMSG_WARDEN_DATA,