779460d87e
Upstream 0.3.0. The ruRU pack now takes upstream's locale path (Locale::from_language_country picks the column and the auth tag); our own part is the Data/ruRU mount, the name GetLocale answers and the UI's Russian name charsets. A language pack folder mounts without the base set that upstream now requires. moonwell-gfx shadows and per-pixel points move onto the per-vertex lit colour of upstream #2899; the retained static pass passes its own normal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
591 lines
23 KiB
Rust
591 lines
23 KiB
Rust
//! The WoW 1.12.1 (build 5875) wire protocol: [`auth`] for realmd (login protocol version 3),
|
|
//! [`world`] and [`events`] for the world server. SRP6 and the header crypto are `benilla-srp`'s.
|
|
|
|
pub mod auth;
|
|
pub mod events;
|
|
pub mod guid;
|
|
pub mod messages;
|
|
pub mod wire;
|
|
pub mod world;
|
|
pub use auth::{AuthReject, LogonReply};
|
|
pub use events::{
|
|
decode, CharAction, EntityKind, LoginRefusal, LoginStage, MoveSpeeds, NotifyBlock, Poll,
|
|
RealmListEmptied, RosterCause, SessionEnd, SessionEvent, SessionEventKind, TransportClock,
|
|
};
|
|
pub use messages::field;
|
|
pub use messages::{
|
|
AttackSwingError, CharCreateReq, CharEnumItem, Character, CorpseLook, CreateSpline, ItemInfo,
|
|
JumpInfo, MonsterMoveFacing, MoveMode, MoverState, ObjectFields, OwnerFallback, RelayVerb,
|
|
ServerPacket, SpeedKind, SplineMode, TransportPose, WowTime, CHARACTER_FLAG_GHOST,
|
|
CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME,
|
|
};
|
|
pub use world::{
|
|
observe_sends, CharListFailed, LinkTraffic, SendObserver, Unparsed, WorldAuthReject,
|
|
WorldConnectEvent, WorldReader, WorldSession, WorldWriter, WORLD_PORT,
|
|
};
|
|
|
|
use std::net::{SocketAddr, TcpStream};
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
use benilla_srp::{NormalizedString, PublicKey, SrpClientChallenge, SESSION_KEY_LENGTH};
|
|
|
|
/// The port a stock vmangos `realmd` listens on.
|
|
pub const AUTH_PORT: u16 = 3724;
|
|
/// The 1.12.1 client build we present to the server.
|
|
pub const CLIENT_BUILD: u16 = 5875;
|
|
/// Challenges [`logon`] draws for an unambiguous `B`; one in ~137 is not, so all 8 fail ~10⁻¹⁷.
|
|
const MAX_CHALLENGE_DIALS: u32 = 8;
|
|
|
|
/// Splits an optional `:port` off a host; one without a numeric port, a raw IPv6 address
|
|
/// included, comes back whole with `default`.
|
|
pub fn host_port(host: &str, default: u16) -> (&str, u16) {
|
|
match host.rsplit_once(':') {
|
|
Some((h, p)) if !h.contains(':') => match p.parse::<u16>() {
|
|
Ok(p) => (h, p),
|
|
Err(_) => (host, default),
|
|
},
|
|
_ => (host, default),
|
|
}
|
|
}
|
|
|
|
/// A dial that got no socket, split by step: the name did not resolve, or nothing answered (one
|
|
/// `io::Error` cannot tell them apart; macOS reports a failed lookup as `Uncategorized`).
|
|
#[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 {}
|
|
|
|
/// The port a realm's world address dials when it names none (`0x2382`, `0x53785a`).
|
|
const WORLD_DEFAULT_PORT: u16 = 9090;
|
|
|
|
/// Resolves, then dials the first IPv4 address ([`first_ipv4`]).
|
|
fn dial(host: &str, port: u16) -> Result<TcpStream> {
|
|
use std::net::ToSocketAddrs;
|
|
let address = format!("{host}:{port}");
|
|
let addrs: Vec<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}")))
|
|
}
|
|
};
|
|
let Some(target) = first_ipv4(&addrs) else {
|
|
return Err(anyhow::Error::new(DialFailure {
|
|
address,
|
|
unresolved: true,
|
|
}));
|
|
};
|
|
connect(target, &address)
|
|
}
|
|
|
|
/// Open a socket to `target`; a refusal is a [`DialFailure`] naming `address`.
|
|
fn connect(target: SocketAddr, address: &str) -> Result<TcpStream> {
|
|
TcpStream::connect(target).map_err(|e| {
|
|
anyhow::Error::new(DialFailure {
|
|
address: address.to_string(),
|
|
unresolved: false,
|
|
})
|
|
.context(format!("connecting: {e}"))
|
|
})
|
|
}
|
|
|
|
/// The address the world dial takes: IPv4 alone, the first the resolver gives
|
|
/// (`WowConnection::ConnectToHost` `0x5b6e40`: `inet_addr`, else `gethostbyname`'s first entry).
|
|
/// `localhost` resolves to `::1` first, which a server bound to IPv4 refuses only after about 2 s
|
|
/// on Windows.
|
|
fn first_ipv4(addrs: &[SocketAddr]) -> Option<SocketAddr> {
|
|
addrs.iter().copied().find(SocketAddr::is_ipv4)
|
|
}
|
|
|
|
/// Dial a realm's world server at its realm-list address ([`world_host_port`]).
|
|
pub(crate) fn dial_world(address: &str) -> Result<TcpStream> {
|
|
let (host, port) = world_host_port(address);
|
|
dial(host, port)
|
|
}
|
|
|
|
/// A realm's world address as `NetClient::Connect` (`0x537820`) splits it: the host up to the
|
|
/// first `:` (`SStrChr2` `0x64a3f0`, `0x53786c`) and the low 16 bits (`0x5b6e94`) of `_atoi` of
|
|
/// the rest (`0x53787a`), or the whole text and [`WORLD_DEFAULT_PORT`] with no `:`.
|
|
fn world_host_port(address: &str) -> (&str, u16) {
|
|
match address.split_once(':') {
|
|
Some((host, port)) => (host, atoi(port) as u16),
|
|
None => (address, WORLD_DEFAULT_PORT),
|
|
}
|
|
}
|
|
|
|
/// `_atoi` (`0x41449c` → `_atol` `0x414411`): C-locale spaces skipped, one `-` or `+`, then the
|
|
/// decimal digits in wrapping 32-bit arithmetic up to the first other byte; 0 with none.
|
|
fn atoi(text: &str) -> i32 {
|
|
let mut bytes = text
|
|
.bytes()
|
|
.skip_while(|b| matches!(b, b' ' | b'\t' | b'\n' | 0x0b | 0x0c | b'\r'))
|
|
.peekable();
|
|
let negative = match bytes.peek() {
|
|
Some(b'-') => {
|
|
bytes.next();
|
|
true
|
|
}
|
|
Some(b'+') => {
|
|
bytes.next();
|
|
false
|
|
}
|
|
_ => false,
|
|
};
|
|
let value = bytes.take_while(u8::is_ascii_digit).fold(0i32, |n, d| {
|
|
n.wrapping_mul(10).wrapping_add(i32::from(d - b'0'))
|
|
});
|
|
if negative {
|
|
value.wrapping_neg()
|
|
} else {
|
|
value
|
|
}
|
|
}
|
|
|
|
/// The hosts of a realmlist the logon resolves, in order: the text cut at each `;` (`SStrChr2` at
|
|
/// `0x43b8f6`) up to the first empty piece, which ends the list (`0x43b941`), each split by
|
|
/// [`host_port`] with the request's port 3724 (`0xe8c`, `0x5bc400`) when it names none. The
|
|
/// reference branches on a piece's first byte (`0x43b947`, `0x43b94f`, `'0'..'9'`): a digit-led
|
|
/// piece splits a `:port` (`0x43f3d0`: `SStrChr`, `SStrToInt`, then `inet_addr` unchecked, so a
|
|
/// failed parse stores 255.255.255.255), while a name goes to `gethostbyname` whole, `:port`
|
|
/// included (`0x43ba0e`), every address it returns taking 3724 (`0x43ba86`); a name's port here
|
|
/// is the deviation below.
|
|
fn logon_pieces(realmlist: &str) -> Vec<(&str, u16)> {
|
|
realmlist
|
|
.split(';')
|
|
.take_while(|piece| !piece.is_empty())
|
|
// Deviation: a named piece takes a `:port` here, where the reference hands a name to
|
|
// `gethostbyname` whole (`0x43ba0e`) and only a dotted piece takes a port (`0x43f3d0`),
|
|
// because a server on a non-default logon port behind a host name is everyday use.
|
|
.map(|piece| host_port(piece, AUTH_PORT))
|
|
.collect()
|
|
}
|
|
|
|
/// `ConnectToRandomAddress` (`0x5bc2b0`): entry `draw mod count` (`0x5bc392`) of the list, or none
|
|
/// of an empty one, which the reference reports as a failed dial (`0x5bc3cb`).
|
|
fn pick(addrs: &[SocketAddr], draw: u32) -> Option<SocketAddr> {
|
|
let count = u32::try_from(addrs.len()).ok().filter(|&n| n > 0)?;
|
|
Some(addrs[(draw % count) as usize])
|
|
}
|
|
|
|
/// The address a logon dials, and the realmlist's hosts for a failure to name. The list is every
|
|
/// IPv4 address of each piece in order, at the piece's port ([`logon_pieces`]): one for a dotted
|
|
/// address, every `AF_INET` address `gethostbyname` gives for a name (`0x43ba07`-`0x43baf6`), none
|
|
/// for a name that resolves to none (`0x43ba18`). One is drawn at random ([`pick`]), as
|
|
/// `ClientLink::Connect` (`0x5bc3e0`) hands the list to `ConnectToRandomAddress`.
|
|
fn logon_target(realmlist: &str) -> Result<(SocketAddr, String)> {
|
|
use std::net::ToSocketAddrs;
|
|
let pieces = logon_pieces(realmlist);
|
|
let address = pieces
|
|
.iter()
|
|
.map(|(host, port)| format!("{host}:{port}"))
|
|
.collect::<Vec<_>>()
|
|
.join(";");
|
|
let mut addrs = Vec::new();
|
|
let mut unresolved = Vec::new();
|
|
for &(host, port) in &pieces {
|
|
match (host, port).to_socket_addrs() {
|
|
Ok(found) => addrs.extend(found.filter(SocketAddr::is_ipv4)),
|
|
Err(e) => unresolved.push(format!("resolving {host}: {e}")),
|
|
}
|
|
}
|
|
match pick(&addrs, rand::random()) {
|
|
Some(target) => Ok((target, address)),
|
|
None => {
|
|
let failure = anyhow::Error::new(DialFailure {
|
|
address,
|
|
unresolved: true,
|
|
});
|
|
Err(if unresolved.is_empty() {
|
|
failure
|
|
} else {
|
|
failure.context(unresolved.join("; "))
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A realm from realmd's realm list, with every field the wire carries.
|
|
#[derive(Debug, Clone)]
|
|
pub struct RealmInfo {
|
|
pub name: String,
|
|
/// The world server's `host[:port]`, as the client connects to it ([`WorldSession::connect`]).
|
|
pub address: String,
|
|
/// The population, sentinels already rewritten as the reference parser does
|
|
/// ([`auth::MAGIC_POPULATIONS`]); the list shows a band against all realms' mean and deviation.
|
|
pub population: f32,
|
|
/// This account's character count there, shown as `"(3)"` (client realm record `+0x130`).
|
|
pub characters: u8,
|
|
/// The realm type (record `+0x04`), a join key, not an enum: `GetRealmInfo` takes `(pvp, rp)`
|
|
/// from the `Cfg_Configs.dbc` row whose `RealmType` equals it.
|
|
pub realm_type: u32,
|
|
/// The realm flags as the client holds them (record `+0x08`): the wire carries only `0x01`
|
|
/// invalid, `0x02` offline and `0x04`; `0x20`/`0x40`/`0x80` (Recommended, New, Full) are
|
|
/// synthesized from the magic populations ([`auth::MAGIC_POPULATIONS`]).
|
|
pub flags: u8,
|
|
/// The category (timezone) byte the list's tabs group on, matched by equality, not an index.
|
|
pub category: u8,
|
|
/// The wire's realm id.
|
|
pub id: u8,
|
|
}
|
|
|
|
/// A successful logon: the SRP6 session key, the realms, and the realmd socket, kept open because
|
|
/// the reference's `RealmList.lua` re-requests the list every `REALM_LIST_REFRESH_TIME` (5 s).
|
|
pub struct Logon {
|
|
pub session_key: [u8; SESSION_KEY_LENGTH],
|
|
pub realms: Vec<RealmInfo>,
|
|
/// The last reply was one the record fill refused ([`auth::RealmListRefused`]): `realms` is
|
|
/// empty and the link still up.
|
|
pub realms_refused: bool,
|
|
/// `None` once the link has failed a refresh; the held list is then final.
|
|
stream: Option<TcpStream>,
|
|
/// The PIN's digits, when the account asked for one: what the keys pressed showed.
|
|
pin: Option<Vec<u8>>,
|
|
/// The challenge asked for a PIN.
|
|
pin_asked: bool,
|
|
}
|
|
|
|
impl Logon {
|
|
/// The reference's `RequestRealmList`: re-reads the list, keeping the old one and dropping
|
|
/// the socket on a failure of the link (realmd closes idle sockets), or emptying it with the
|
|
/// socket up on a reply the record fill refused; `timeout` bounds a silent server.
|
|
pub fn refresh_realms(&mut self, timeout: std::time::Duration) -> bool {
|
|
let Some(stream) = self.stream.as_mut() else {
|
|
return false;
|
|
};
|
|
let refreshed = (|| -> Result<Vec<RealmInfo>> {
|
|
stream.set_read_timeout(Some(timeout))?;
|
|
auth::write_realm_list_request(stream).context("requesting realm list")?;
|
|
auth::read_realm_list(stream).context("reading realm list")
|
|
})();
|
|
match refreshed {
|
|
Ok(realms) => {
|
|
self.realms = realms;
|
|
self.realms_refused = false;
|
|
true
|
|
}
|
|
Err(e) if e.is::<auth::RealmListRefused>() => {
|
|
self.realms.clear();
|
|
self.realms_refused = true;
|
|
false
|
|
}
|
|
Err(_) => {
|
|
self.stream = None;
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether the realmd socket is still up, so [`Self::refresh_realms`] can do anything.
|
|
pub fn realmd_live(&self) -> bool {
|
|
self.stream.is_some()
|
|
}
|
|
|
|
/// `CLoginMgr::Cancel` (`0x5b3320`): the realmd socket closes; the session key and the last
|
|
/// list are kept.
|
|
pub fn close(&mut self) {
|
|
self.stream = None;
|
|
}
|
|
|
|
/// The digits of the PIN this logon proved, for a later logon of the same account to press
|
|
/// on its own grid ([`auth::pin::positions`]).
|
|
pub fn pin(&self) -> Option<&[u8]> {
|
|
self.pin.as_deref()
|
|
}
|
|
|
|
/// Forget the PIN, once a server has refused it: a logon of this account asks for it again.
|
|
pub fn forget_pin(&mut self) {
|
|
self.pin = None;
|
|
}
|
|
|
|
/// Whether a later logon of this account can go on its own: not once a PIN it asked for is
|
|
/// forgotten ([`Self::forget_pin`]), as only the player can enter one.
|
|
pub fn can_redial(&self) -> bool {
|
|
!self.pin_asked || self.pin.is_some()
|
|
}
|
|
}
|
|
|
|
/// The full SRP6 logon against a vanilla `realmd`, then the realm list. `realmlist` is one or more
|
|
/// `host[:port]` separated by `;`, the server dialled one of their addresses (`logon_target`).
|
|
/// `locale` is the client's locale tag the challenge carries ([`auth::write_logon_challenge`]).
|
|
/// An account that asks for a PIN fails it: [`logon_with_pin`] is the logon that can answer.
|
|
pub fn logon(realmlist: &str, username: &str, password: &str, locale: u32) -> Result<Logon> {
|
|
logon_with_pin(
|
|
realmlist,
|
|
username,
|
|
password,
|
|
locale,
|
|
&mut |_| {
|
|
Err(anyhow!(
|
|
"the account asks for a PIN, which this logon cannot enter"
|
|
))
|
|
},
|
|
&mut |_| {},
|
|
)
|
|
}
|
|
|
|
/// [`logon`], with `pin` asked for the keys pressed when the challenge asks for a PIN: it gets the
|
|
/// keypad's grid ([`auth::pin::grid`]) and returns the positions pressed on it, as `PINEntered`
|
|
/// hands them on (`0x46d1c0` → `0x5b3120`), or an error that ends the logon there, before the
|
|
/// proof.
|
|
///
|
|
/// `stage` is told each state the logon reaches as it reaches it, as the reference's logon object
|
|
/// reports them to its listener: [`LoginStage::Authenticating`] as the challenge goes out
|
|
/// (`0x5b2196`), [`LoginStage::CheckingVersions`] on a reply that asks for no PIN (`0x5b1cd7`;
|
|
/// one that asks for one is `pin`'s call) and [`LoginStage::ProofSent`] once the proof is out
|
|
/// (`0x5b1f93`). A redial for a fresh server key (below) is no new state, and a reply that
|
|
/// authenticates with no exchange reports none.
|
|
pub fn logon_with_pin(
|
|
realmlist: &str,
|
|
username: &str,
|
|
password: &str,
|
|
locale: u32,
|
|
pin: &mut dyn FnMut(&[u8; 10]) -> Result<Vec<u8>>,
|
|
stage: &mut dyn FnMut(LoginStage),
|
|
) -> Result<Logon> {
|
|
let username_n =
|
|
NormalizedString::new(username).map_err(|e| anyhow!("invalid username: {e}"))?;
|
|
let password_n =
|
|
NormalizedString::new(password).map_err(|e| anyhow!("invalid password: {e}"))?;
|
|
// One draw per login, as the reference dials once: the redials below go to the same address.
|
|
let (target, address) = logon_target(realmlist)?;
|
|
|
|
// The account name goes uppercased, as SRP6 hashes it. About one `B` in 137 serializes
|
|
// differently in the client and mangos (a right password gets 0x04), so redial for a fresh
|
|
// one; realmd counts nothing before the proof (`AuthSocket::_HandleLogonChallenge`).
|
|
let (mut stream, reply, server_public_key) = {
|
|
let mut dialed = None;
|
|
for dial in 0..MAX_CHALLENGE_DIALS {
|
|
let mut stream = connect(target, &address)?;
|
|
if dial == 0 {
|
|
stage(LoginStage::Authenticating);
|
|
}
|
|
auth::write_logon_challenge(
|
|
&mut stream,
|
|
&username.to_uppercase(),
|
|
CLIENT_BUILD,
|
|
locale,
|
|
)
|
|
.context("sending logon challenge")?;
|
|
let Some(reply) =
|
|
auth::read_challenge_reply(&mut stream).context("reading logon challenge reply")?
|
|
else {
|
|
// `SUCCESS_SURVEY` on the challenge authenticates with no SRP exchange; the
|
|
// reference's success arm copies no key (length 0 at `0x5b2e16`).
|
|
return finish_logon(stream, [0u8; SESSION_KEY_LENGTH], None, false);
|
|
};
|
|
let server_public_key = PublicKey::from_le_bytes(reply.server_public_key)
|
|
.map_err(|e| anyhow!("invalid server public key: {e}"))?;
|
|
let stable = server_public_key.is_width_stable();
|
|
dialed = Some((stream, reply, server_public_key));
|
|
if stable {
|
|
break;
|
|
}
|
|
}
|
|
// All ambiguous: go on with the last, since only the server can say it fails.
|
|
dialed.expect("MAX_CHALLENGE_DIALS is non-zero")
|
|
};
|
|
|
|
let challenge = SrpClientChallenge::new(
|
|
username_n,
|
|
password_n,
|
|
reply.generator,
|
|
reply.large_safe_prime,
|
|
server_public_key,
|
|
reply.salt,
|
|
);
|
|
|
|
// The keypad waits for the player (logon state 7, `0x5b1cd7`); the proof goes once the keys
|
|
// are in (`0x5b3120` reports state 9, whose arm sends it, `0x46bb2f`).
|
|
let (pin_proof, pin_digits) = match &reply.pin {
|
|
Some(challenge) => {
|
|
let grid = auth::pin::grid(challenge.grid_seed);
|
|
let positions = pin(&grid).context("entering the PIN")?;
|
|
(
|
|
Some(auth::PinProof::new(challenge, &positions)),
|
|
auth::pin::digits(&grid, &positions),
|
|
)
|
|
}
|
|
None => {
|
|
stage(LoginStage::CheckingVersions);
|
|
(None, None)
|
|
}
|
|
};
|
|
auth::write_logon_proof(
|
|
&mut stream,
|
|
challenge.client_public_key(),
|
|
challenge.client_proof(),
|
|
&reply.crc_salt,
|
|
pin_proof.as_ref(),
|
|
)
|
|
.context("sending logon proof")?;
|
|
stage(LoginStage::ProofSent);
|
|
|
|
let session_key =
|
|
match auth::read_proof_reply(&mut stream).context("reading logon proof reply")? {
|
|
Some(server_proof) => *challenge
|
|
.verify_server_proof(server_proof)
|
|
.map_err(|e| anyhow!("server proof mismatch (wrong password?): {e}"))?
|
|
.session_key(),
|
|
// `SUCCESS_SURVEY` on the proof authenticates with no `M2` to check; the key is the one
|
|
// this exchange derived.
|
|
None => challenge.into_unverified_session_key(),
|
|
};
|
|
finish_logon(stream, session_key, pin_digits, reply.pin.is_some())
|
|
}
|
|
|
|
/// An authenticated logon's tail: the realm list over the same socket.
|
|
fn finish_logon(
|
|
mut stream: TcpStream,
|
|
session_key: [u8; SESSION_KEY_LENGTH],
|
|
pin: Option<Vec<u8>>,
|
|
pin_asked: bool,
|
|
) -> Result<Logon> {
|
|
auth::write_realm_list_request(&mut stream).context("requesting realm list")?;
|
|
let (realms, realms_refused) = match auth::read_realm_list(&mut stream) {
|
|
Ok(realms) => (realms, false),
|
|
Err(e) if e.is::<auth::RealmListRefused>() => (Vec::new(), true),
|
|
Err(e) => return Err(e.context("reading realm list")),
|
|
};
|
|
|
|
Ok(Logon {
|
|
session_key,
|
|
realms,
|
|
realms_refused,
|
|
stream: Some(stream),
|
|
pin,
|
|
pin_asked,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod host_port_tests {
|
|
use super::{host_port, AUTH_PORT};
|
|
|
|
#[test]
|
|
fn explicit_port_splits() {
|
|
assert_eq!(
|
|
host_port("play.example.com:5000", AUTH_PORT),
|
|
("play.example.com", 5000)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bare_host_gets_default() {
|
|
assert_eq!(host_port("localhost", AUTH_PORT), ("localhost", AUTH_PORT));
|
|
}
|
|
|
|
#[test]
|
|
fn raw_ipv6_is_not_split() {
|
|
assert_eq!(host_port("::1", 3724), ("::1", 3724));
|
|
}
|
|
|
|
#[test]
|
|
fn non_numeric_suffix_stays_host() {
|
|
assert_eq!(
|
|
host_port("play.example.com:realmd", 3724),
|
|
("play.example.com:realmd", 3724)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod dial_tests {
|
|
use super::{first_ipv4, logon_pieces, pick, world_host_port, AUTH_PORT};
|
|
use std::net::SocketAddr;
|
|
|
|
fn addrs(list: &[&str]) -> Vec<SocketAddr> {
|
|
list.iter().map(|a| a.parse().unwrap()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn localhost_dials_its_ipv4_address() {
|
|
let got = first_ipv4(&addrs(&["[::1]:3724", "127.0.0.1:3724"]));
|
|
assert_eq!(got, Some("127.0.0.1:3724".parse().unwrap()));
|
|
}
|
|
|
|
#[test]
|
|
fn the_first_ipv4_address_is_dialled_and_ipv6_never() {
|
|
let got = first_ipv4(&addrs(&["[2001:db8::2]:1", "10.0.0.2:1", "10.0.0.1:1"]));
|
|
assert_eq!(got, Some("10.0.0.2:1".parse().unwrap()));
|
|
assert_eq!(first_ipv4(&addrs(&["[2001:db8::1]:1"])), None);
|
|
}
|
|
|
|
/// `NetClient::Connect` (`0x537820`): the port is 9090 (`0x2382`, `0x53785a`) unless the text
|
|
/// has a `:`, where the host is cut at the first one (`SStrChr2` `0x64a3f0`, `0x53786c`) and
|
|
/// the port is `_atoi` of the rest (`0x53787a`).
|
|
#[test]
|
|
fn a_world_address_without_a_port_dials_9090() {
|
|
assert_eq!(
|
|
world_host_port("realm.example.org"),
|
|
("realm.example.org", 9090)
|
|
);
|
|
assert_eq!(world_host_port("10.0.0.1"), ("10.0.0.1", 9090));
|
|
assert_eq!(world_host_port("10.0.0.1:8085"), ("10.0.0.1", 8085));
|
|
}
|
|
|
|
/// The cut is at the first `:`, and `_atol` (`0x414411`) skips C-locale spaces, takes one
|
|
/// sign and reads digits up to the first other character, 0 with none; the dial keeps the
|
|
/// low 16 bits (`0x5b6e94`).
|
|
#[test]
|
|
fn a_world_port_is_atoi_of_the_text_after_the_first_colon() {
|
|
assert_eq!(world_host_port("a:8085:9"), ("a", 8085));
|
|
assert_eq!(world_host_port("a: 8085x"), ("a", 8085));
|
|
assert_eq!(world_host_port("a:+8085"), ("a", 8085));
|
|
assert_eq!(world_host_port("a:"), ("a", 0));
|
|
assert_eq!(world_host_port("a:port"), ("a", 0));
|
|
assert_eq!(world_host_port("a:-1"), ("a", 0xffff));
|
|
assert_eq!(world_host_port("a:73621"), ("a", 8085));
|
|
assert_eq!(world_host_port(":8085"), ("", 8085));
|
|
}
|
|
|
|
/// The logon resolver splits the text at each `;` (`0x43b8f6`) and resolves the pieces in
|
|
/// order (`0x43b890`); the first empty piece ends the list (`0x43b941`).
|
|
#[test]
|
|
fn a_realmlist_is_split_at_each_semicolon_until_an_empty_piece() {
|
|
assert_eq!(
|
|
logon_pieces("logon.example.org"),
|
|
[("logon.example.org", AUTH_PORT)]
|
|
);
|
|
assert_eq!(
|
|
logon_pieces("10.0.0.1;10.0.0.2:3725;b.example.org"),
|
|
[
|
|
("10.0.0.1", AUTH_PORT),
|
|
("10.0.0.2", 3725),
|
|
("b.example.org", AUTH_PORT)
|
|
]
|
|
);
|
|
assert_eq!(logon_pieces("a;b;"), [("a", AUTH_PORT), ("b", AUTH_PORT)]);
|
|
assert_eq!(logon_pieces("a;;b"), [("a", AUTH_PORT)]);
|
|
assert!(logon_pieces(";a").is_empty());
|
|
}
|
|
|
|
/// `ConnectToRandomAddress` (`0x5bc2b0`) dials entry `draw mod count` (`0x5bc392`) of every
|
|
/// address the pieces resolved to.
|
|
#[test]
|
|
fn the_logon_dials_the_entry_its_draw_picks() {
|
|
let list = addrs(&["10.0.0.1:3724", "10.0.0.2:3724", "10.0.0.3:3724"]);
|
|
assert_eq!(pick(&list, 0), Some(list[0]));
|
|
assert_eq!(pick(&list, 5), Some(list[2]));
|
|
assert_eq!(pick(&list, u32::MAX), Some(list[0]));
|
|
assert_eq!(pick(&[], 1), None);
|
|
}
|
|
}
|