Files
benilla-octo/crates/benilla-protocol/src/auth.rs
T
SamandClaude Fable 5.1 0d5ff18fa2 the player picks the realm, and a harmful spell flinches its victim
- the player picks the realm: benilla connected to whichever realm the auth server listed first — not a policy but the absence of one — with the reference's Change Realm button drawn permanently disabled and the character screen's banner reading "Connecting…", a string 1.12 does not have, invented because the realm only ever arrived as a passenger on the character list (`CharacterSelect.lua` l.66 ends that ladder with `CharSelectRealmName:Hide()` — no name, no banner); `read_realm_list` had read the flags byte, the category byte and the realm id into `_` locals and stringified the population on arrival, and all four are load-bearing; the IO thread gains a third park — logon → which realm? → world handshake → which character — publishing `SessionEvent::RealmList` and blocking on `RealmRequest::{Enter, Refresh, Abandon}` exactly as at the other two, `Enter` carrying the realm's name and not its index since the list is re-requested every 5 s (`RealmList.lua`'s `REALM_LIST_REFRESH_TIME`) off a realmd socket `Logon` now keeps; Change Realm hands the logon forward (`HeldLogon`) so a different realm costs a world dial, not a re-authentication, and a deliberate ask arms `Realms::forced` for exactly one list, because the realm remembered from last time is precisely the realm you are standing on and asking to leave — without it the button re-entered the same realm and appeared to do nothing; the auto-answer ladder is a pure `auto_answer` — `WOW_REALM`, then the remembered `realmName` CVar (which `set_realm_name` had poked into its slot in place, never reaching `cvar_changes`, so the persisted CVar whose whole job is "the last realm connected to" was absent from disk forever; it writes through `set_from_engine` now), then an unattended run takes the first realm that is up, every arm requiring the realm to be up — and the screen is `RealmList.xml` rebuilt on the same 640×512 HelpFrame plate the AddOns list uses, eighteen rows at the authored 20 px pitch, four sortable columns; the Population word is a BAND, not the float: `realm_load_stats` (0x46e510) takes the mean and `sqrt(Σ(pop − mean)² / (n − 1)) · 0.6`, `realm_load_classify` (0x46ec60) reads the sentinels first (0x20 → Recommended, 0x40 → New, 0x80 → Full) then strict `pop < mean − stddev` → Low, `pop > mean + stddev` → High, else Medium — so the same realm reads High on a quiet list and Medium on a busy one, and a lone realm is banded against the `n ≤ 1` short circuit's literal 1.0, so a local vmangos realm at 0.06 reads Low, not Medium; then wow-re's cross-check of `RealmList.cpp [0x46e3bf, 0x46fa05)` confirmed 0x01 = invalid / 0x02 = offline and that the real client does auto-connect by `realmName` without raising the list (`0x5ab800` resolves the stored name against the fresh list, its success leg calling no `SetStatus`), and corrected four things the first cut shipped: the statistics are GLOBAL — `0x46e510` walks the flat all-categories array unconditionally (`0x46e5ee fadd [esi+0x12c]`), the per-category bucketing in the same loop a side effect and never a bound, so scoping them to the tab made the same realm read a different word per tab; Recommended/New/Full never travel as flag bits at all — the parser recognises magic populations by bit pattern and rewrites them, 600.0 → 0.0 + 0x20, 200.0 → 0.001 + 0x40, 400.0 → 8.0 + 0x80, and it has to happen in `read_realm_list` before anything averages, since one realm advertised Recommended arriving as 600.0 drags the global mean past every other row; pvp/rp is a `Cfg_Configs.dbc` join on the type dword at `[realm+0x04]`, and the shipped rows make types 3 and 5 PvP and 7 RP where the 1/6/8 match had all three Normal; and the default sort is `[characters, load, name, mode]`, all ascending, written once per process by `0x46e300` — a four-key fallthrough where "ascending" characters means higher count first, a column click being move-to-front (`0x46e9b0`) that keeps the column's direction unless it was already in front — with the reference's non-antisymmetric characters comparator (its tie-breaking `strcmp` result clobbered one instruction later at `0x46e846`) deliberately not reproduced; `RealmWizard`, the `REALM_IS_FULL` confirm and the category tab strip are deliberately not built, each for a stated reason (2056, 2062)
- a re-played effect REPLACES the same record at the same attach point: the director's report that the reference draws far fewer impact effects when many mobs beat on one warrior — and named the star burst on both hands on every crit — is not a limiter but a replace, recorded in wow-re for weeks: `CEffect::AddEffect` (0x61fdd0), the path every kit slot and the melee blood spurt take, opens with `0x6208e0(owner, rec, tag)`, which walks the owner's `+0xb4` list and destroys every node carrying the same `SpellVisualEffectName` record at the same attach tag (bounded `[0x6208e0, 0x62092c]`), skipping `tag == -1`; benilla replaced only persistent instances, on the narrower `(spell_id, class)` key of the spell-id reap `0x614150`, and pushed every one-shot unconditionally — a gap seen and scoped to one caller four times (0850, 0927, 1149, 1518) without being generalised — so five attackers landing swings on one body held five blood spurts at `(UnitBlood, 0xf)` where the reference holds one, linear in the attacker count on additively-blended emitters where the reference's is flat, and near-identical in a 1v1, which is why it survived; the sharpest case is Flurry (12966–12970), applied on every critical strike, whose visual 2759 hangs record 416 — `Spells\Strike_Impact_Chest.mdx`, the gold chest-impact flare reused at the hand tags 0x15/0x16 — on both hands via cast kit 3010 over a persistent `BloodLust_State_Hand` glow from state kit 3009, and every crit stacked another pair; `spell_fx::replace_same_slot` now runs per slot at every Begin for persistent and self-terminating instances alike, keyed on the RECORD id threaded through the fx lane (`FxSlot { tag, effect, path }`, the hardcoded map becoming name → (id, path)) and not the path, because two rows may name one .mdx and the reference lets those coexist, with both exclusions kept — the world plant and an already-decaying node — and four tests pinning the key's two halves and both; the other half of the report, that the burst is too large and too long, is a separate mechanism and is not touched — and the instrument that would have measured it was lying: `m2part` read a burst emitter's `peak_rate()` as its particle count, while the reference fires on the rising edge of `enabled != 0 && rate > 0` sampled on one clock, and `Strike_Impact_Chest.m2`'s gold flare steps its gate 1 → 0 on the very keyframe its rate steps 0 → 50, so the two are never true together and that emitter emits NOTHING; the tool printed "burst of ~50 particles, life 0.50s" and a diagnosis started 2.7× over on count and 1.7× over on duration off that one line; `EmitTiming::first_burst` walks the slot's keyed span at 60 Hz and returns the first instant the predicate holds or `None`, the dump prints the real count and time or `NEVER FIRES`, and a real-asset test pins that model's emitter #0 as never and #1 as exactly 30 (2057)
- a harmful spell's impact flinches its victim: benilla had one spell-side wound path, `play_kit`'s `[8,10]` branch — the kit player's own `0x60f3ad` test — carrying the KIT's anim id, so a spell whose impact kit names no wound anim, which is nearly every one (Fireball, Frostbolt, Shadow Bolt, Sunder Armor, a mob's Strike), twitched nothing, and a kit naming CombatCritical played 10 where the client plays 9 or 8; `0x60ea70` has six callers, not the three an earlier sweep found (`grep "call 0x60ea70"` over the full objdump was the one command it never ran): the missile hand-off `0x61dc50` lays the flinch before the impact kit for every living target (`0x61dc74`, HEALTH > 0; the already-dead arm `0x60ea30` plays Death/SwimDeath instead), the instant-hit ImpactKit loop `0x6e8bf0` lays it after the kit when `0x6ea280(SpellRec) == 2` (`0x6e8c89`), the reflect kit `0x6e8cb0` does the same on the caster, and every spell-side call pushes severity 0, so the id is always `0x60ea70`'s own `(severity, engaged)` pick — CombatWound(9) engaged, StandWound(8) otherwise — never the kit column; `0x6ea280` is the spell's hostility class and `== 2` ⇔ the spell targets enemies: Targets bit `0x100` → never, `0x80` → yes, else `EffectImplicitTargetA[i]` then `B[i]` for effects 0..3 indexed into the byte tables `0x6ea338`/`0x6ea378`, whose case-0 ids `{2, 6, 15, 16, 24, 28, 53, 54}` are exactly the modern enum's `*_ENEMY` set; so `WoundAnim { entity }` loses its id and the driver resolves every spell edge as `wound_anim(0, engaged)`, `play_impact` takes an `ImpactPlay` with `flinch` — the instant hits pass `SpellDisplay::is_harmful()`, a missile arrival passes true — `SpellDisplay` gains `effect_implicit_target_a/b` (columns 82–84 / 85–87) and `is_harmful()`, and the flinch is laid as the same secondary-blend overlay as a melee wound, over a cast, a swing or a run, never replacing them; pinned against the real 5875 `Spell.dbc` — Fireball, Charge and Sunder Armor harmful through A, Frost Nova through B alone, Renew, Healing Touch, Arcane Intellect and Battle Shout not — and the driver never picks CombatCritical for a spell edge; `WOW_MOVE_TRACE` gains `fct wound trigger/evict/expire` lines, since the whole flinch lifecycle was invisible in a trace; and the report this was chased under — the warrior's swings reading as cut short by incoming impacts in a many-mob brawl — stays open, with one reference gate found on the way that benilla does not model: `0x60ea70` aborts while any attached CEffect on the victim carries `+0x2c & 0x4` (`0x60eab6`), and which kit-spawned nodes carry that bit is the question dispatched to wow-re (2058)
- other players' roots, levitation and blinks now relay to onlookers: a report that a nearby player getting rooted, water-walking or Blinking slid or stood in the wrong place until their next movement packet was exactly right — a movement mode has three legs and benilla carried two, the mover's own acked handshake (`SMSG_FORCE_MOVE_ROOT → CMSG_FORCE_MOVE_ROOT_ACK`, 0866) and the server-driven `SMSG_SPLINE_MOVE_*` twelve (1780), while the third, what observers are told once the mover acks (vmangos `MovementPacketSender.h:30-60`, broadcast by `SendMovementFlagChangeToObservers` / `SendTeleportToObservers`), fell through `parse_server` to `ServerPacket::Other` and was dropped — so a rooted player kept sliding for the ~500 ms to their next heartbeat, a Levitate on someone else was never rendered, and a Blink left them standing at the old spot; six opcodes — `MSG_MOVE_ROOT`/`UNROOT` (236/237), `MSG_MOVE_HOVER` (247), `MSG_MOVE_FEATHER_FALL` (688), `MSG_MOVE_WATER_WALK` (689), `MSG_MOVE_TELEPORT` (197) — all the ordinary `[packed guid][MovementInfo]` shape and all sharing the client's one relay handler `0x603bb0` with the 22 already carried (wow-re's opcode-handler table has thirty rows on it; the remaining two are cheat opcodes vmangos never constructs), so admitting them to `is_movement_relay` is the whole fix for five: apply/unapply rides the flags word rather than the opcode, since vmangos runs `SetHoverReal` &co. before the broadcast and the word on the wire already carries the bit's new state, and `apply_move` already commits it wholesale into the union the ground resolve reads for hover's offset and the water-walk plane — the pose the packet carries beside the mode is half the value, a rooted player's stopping place being news too, and the root arrives already post-wipe with `MOVEFLAG_ROOT` forced back on, so no wipe of ours is invented; the teleport is a relay in shape and a discontinuity in meaning, so `RelayMove::reconciles()` excludes it from the pre-fire reconcile beside the heartbeat, for the opposite reason — the heartbeat because the dead-reckon has already converged, the teleport because there is nothing to converge to, and lerping toward a Blink's destination would drag the mover across the twenty yards the Blink exists to skip, sweeping the shared capsule through every wall between — while it still rides the unit's relay chain rather than snapping at arrival, because replay is paced against the sender's own stamps and applying it early would put the player at the destination while their walk to the launch point was still replaying; a `tp` tag joins `hb`/`tr` on the `rly` trace line; and parse and decode of all six are pinned against golden bodies asserted four bytes shorter than the counter-carrying `MSG_MOVE_TELEPORT_ACK` form, so a counter is provably not being read (2061)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:06:45 +07:00

493 lines
22 KiB
Rust

//! The realmd (login) wire protocol — 1.12.1 uses **login protocol version 3**. In-repo replacement
//! for `wow_login_messages` (decision 0021), scoped to the three exchanges [`crate::logon`] performs:
//! logon challenge, logon proof, and realm list. Login packets are *not* header-encrypted (unlike the
//! world protocol) and each is self-delimiting, so we read them in request/response order.
//!
//! Opcodes: `CMD_AUTH_LOGON_CHALLENGE = 0x00`, `CMD_AUTH_LOGON_PROOF = 0x01`, `CMD_REALM_LIST = 0x10`.
use std::io::{Read, Write};
use std::net::Ipv4Addr;
use anyhow::{bail, Result};
use sha1::{Digest, Sha1};
use crate::wire::{read_array, read_cstring, read_f32_le, read_u16_le, read_u32_le, read_u8};
use crate::RealmInfo;
const CMD_AUTH_LOGON_CHALLENGE: u8 = 0x00;
const CMD_AUTH_LOGON_PROOF: u8 = 0x01;
const CMD_REALM_LIST: u8 = 0x10;
const PROTOCOL_VERSION_THREE: u8 = 3;
const GAME_NAME_WOW: u32 = 0x0057_6f57; // "WoW\0" little-endian
const PLATFORM_X86: u32 = 0x0078_3836; // "x86\0"
// The OS/platform tags ride the wire *reversed*: the real client stores them as little-endian u32s,
// so `0x0057696e` writes the bytes `n i W \0`, and realmd reverses them back (`AuthSocket.cpp`:
// `std::reverse(m_os.begin(), m_os.end())`). vmangos accepts exactly "Win" and "OSX"
// (`WorldSocket::HandleAuthSession`) and fails the session on anything else.
const OS_WINDOWS: u32 = 0x0057_696e; // "Win\0"
const OS_MACOS: u32 = 0x004F_5358; // "OSX\0"
/// The OS tag for the host we are actually running on. We hardcoded Windows on every platform,
/// which is plainly false on a Mac — and servers act on this field: vmangos picks `WardenWin` vs
/// `WardenMac` from it, and realmd validates the build against it (`FindBuildInfo(build, os,
/// platform)`). There was never a 1.12 Linux client, so every non-Mac host keeps saying Windows —
/// the closest true thing we can say.
const fn client_os() -> u32 {
if cfg!(target_os = "macos") {
OS_MACOS
} else {
OS_WINDOWS
}
}
const LOCALE_EN_US: u32 = 0x656e_5553; // "enUS"
/// A non-success auth result byte from the server (challenge or proof stage), typed so the app can
/// map the code to the client's own `AUTH_*` glue string (decision 0539). Rides [`crate::logon`]'s
/// `anyhow` chain — `err.downcast_ref::<AuthReject>()` recovers the code through the contexts.
/// vmangos note (its `AuthCodes.h`, verified): unknown account AND wrong password both arrive as
/// 0x04 (`WOW_FAIL_UNKNOWN_ACCOUNT`) — the client locks out after an 0x05, so the server avoids it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthReject {
/// The grunt result byte (`WOW_FAIL_*`).
pub code: u8,
}
impl std::fmt::Display for AuthReject {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "server rejected logon: result {:#04x}", self.code)
}
}
impl std::error::Error for AuthReject {}
/// The SRP6 inputs from a successful `CMD_AUTH_LOGON_CHALLENGE_Server`.
pub struct ChallengeReply {
pub server_public_key: [u8; 32],
pub generator: u8,
pub large_safe_prime: [u8; 32],
pub salt: [u8; 32],
/// The 16-byte version challenge the client answers with `crc_hash` — see [`version_proof`].
/// Not an SRP6 input; it seeds the client's binary-integrity digest.
pub crc_salt: [u8; 16],
}
/// Send `CMD_AUTH_LOGON_CHALLENGE_Client`. `account_name` must already be uppercased (it is what the
/// SRP6 hashes use). `build` is the client build (5875).
pub fn write_logon_challenge(
w: &mut impl Write,
account_name: &str,
build: u16,
) -> std::io::Result<()> {
// Everything after the 2-byte size field, assembled first so we can prefix its length.
let mut body = Vec::with_capacity(34 + account_name.len());
body.extend_from_slice(&GAME_NAME_WOW.to_le_bytes());
body.push(1); // version major
body.push(12); // version minor
body.push(1); // version patch
body.extend_from_slice(&build.to_le_bytes());
body.extend_from_slice(&PLATFORM_X86.to_le_bytes());
body.extend_from_slice(&client_os().to_le_bytes());
body.extend_from_slice(&LOCALE_EN_US.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes()); // utc_timezone_offset
body.extend_from_slice(&Ipv4Addr::LOCALHOST.octets()); // client_ip_address (network order)
body.push(account_name.len() as u8);
body.extend_from_slice(account_name.as_bytes());
let mut packet = Vec::with_capacity(4 + body.len());
packet.push(CMD_AUTH_LOGON_CHALLENGE);
packet.push(PROTOCOL_VERSION_THREE);
packet.extend_from_slice(&(body.len() as u16).to_le_bytes());
packet.extend_from_slice(&body);
w.write_all(&packet)
}
/// Read the challenge reply, returning the SRP6 inputs (or erroring on a non-success result).
pub fn read_challenge_reply(r: &mut impl Read) -> Result<ChallengeReply> {
let opcode = read_u8(r)?;
if opcode != CMD_AUTH_LOGON_CHALLENGE {
bail!("expected CMD_AUTH_LOGON_CHALLENGE (0x00), got {opcode:#x}");
}
let _protocol_version = read_u8(r)?;
let result = read_u8(r)?;
if result != 0 {
return Err(AuthReject { code: result }.into());
}
let server_public_key = read_array::<32>(r)?;
let generator_len = read_u8(r)?;
let mut generator = vec![0u8; generator_len as usize];
r.read_exact(&mut generator)?;
let generator = match generator.first() {
Some(&g) => g,
None => bail!("server sent an empty generator"),
};
let prime_len = read_u8(r)? as usize;
let mut prime = vec![0u8; prime_len];
r.read_exact(&mut prime)?;
let large_safe_prime: [u8; 32] = prime
.as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("large safe prime was {prime_len} bytes, expected 32"))?;
let salt = read_array::<32>(r)?;
// Consume the rest of the packet so the stream stays aligned for the next message — login packets
// are NOT length-framed, so leftover bytes desync the very next read. crc_salt[16] feeds the
// integrity proof (not SRP6); security_flag (+ the PIN block if set) is unused but must still be
// read off.
let crc_salt = read_array::<16>(r)?;
let security_flag = read_u8(r)?;
if security_flag & 0x01 != 0 {
// PIN: pin_grid_seed (u32) + pin_salt[16]. (vmangos sends security_flag = 0; handled anyway.)
let _pin_grid_seed = read_u32_le(r)?;
let _pin_salt = read_array::<16>(r)?;
}
Ok(ChallengeReply {
server_public_key,
generator,
large_safe_prime,
salt,
crc_salt,
})
}
// --- the version (client-integrity) proof ---------------------------------------------------------
//
// The proof packet's `crc_hash` answers the challenge's `crc_salt`: the real client scans its own
// binaries under that salt into a 20-byte digest `H`, and sends `SHA1(A ‖ H)`. realmd recomputes the
// same expression from a stored `H` and rejects a mismatch as a *modified client*
// (vmangos `AuthSocket::VerifyVersion`, cmangos ditto — byte-identical implementations). We sent
// twenty zeros here for the project's whole life, which is invisible until a server turns the check
// on: `StrictVersionCheck = 1` then answers a correct password with `WOW_FAIL_VERSION_INVALID` (0x09,
// our login screen's "Wrong client version") — B241. Note vmangos's own shipped
// `realmd.conf.example` sets 1, so this is a *default* on a fresh repack, not exotic hardening.
/// The 16-byte `crc_salt` every mangos-family realmd sends — a **constant, not a nonce** (vmangos
/// `realmd/AuthSocket.cpp:65` and cmangos `realmd/AuthSocket.cpp:187`, two independent copies of the
/// same bytes). The whole scheme rests on that: because every server issues the same challenge, the
/// client's answer is a per-build constant a server can store without owning the client.
const MANGOS_VERSION_CHALLENGE: [u8; 16] = [
0xba, 0xa3, 0x1e, 0x99, 0xa0, 0x0b, 0x21, 0x57, 0xfc, 0x37, 0x3f, 0xb3, 0x69, 0xcd, 0xd2, 0xf1,
];
/// `H` for build 5875 under [`MANGOS_VERSION_CHALLENGE`], Windows client. Cross-checked between two
/// independent emulator lineages: vmangos ships it as DB data (`sql/migrations/20221117065844_logon.sql`,
/// `allowed_clients` row `1,12,1,'',5875,'Win','x86'`), cmangos as a hardcoded table
/// (`realmd/RealmList.cpp`, `ExpectedRealmdClientBuilds`) — byte-identical.
const INTEGRITY_HASH_5875_WINDOWS: [u8; 20] = [
0x95, 0xed, 0xb2, 0x7c, 0x78, 0x23, 0xb3, 0x63, 0xcb, 0xdd, 0xab, 0x56, 0xa3, 0x92, 0xe7, 0xcb,
0x73, 0xfc, 0xca, 0x20,
];
/// `H` for build 5875 under [`MANGOS_VERSION_CHALLENGE`], Mac client — same two sources, and the one
/// we must answer with on macOS, where [`client_os`] says `OSX` (vmangos keys its lookup on the os
/// field, cmangos picks `MacHash` off the same field).
const INTEGRITY_HASH_5875_MACOS: [u8; 20] = [
0x8d, 0x17, 0x3c, 0xc3, 0x81, 0x96, 0x1e, 0xeb, 0xab, 0xf3, 0x36, 0xf5, 0xe6, 0x67, 0x5b, 0x10,
0x1b, 0xb5, 0x13, 0xe5,
];
/// The 20-byte integrity digest to answer `crc_salt` with, or `None` when we have no answer for that
/// salt.
///
/// We hold `H` only for the one salt above, so a server that issues a *different* one gets zeros
/// rather than a stale constant. That is not defensive padding: replaying a constant computed for
/// another challenge is exactly the tell a randomised challenge would be looking for, and it cannot
/// pass anyway. (No server in the mangos family randomises it — a per-connection salt would make
/// their own stored hashes useless — so this branch is the honest answer to a hypothetical, and free.)
fn integrity_hash(crc_salt: &[u8; 16]) -> Option<[u8; 20]> {
if *crc_salt != MANGOS_VERSION_CHALLENGE {
return None;
}
Some(if client_os() == OS_MACOS {
INTEGRITY_HASH_5875_MACOS
} else {
INTEGRITY_HASH_5875_WINDOWS
})
}
/// The proof packet's `crc_hash`: `SHA1(A ‖ H)` over the wire bytes of `A` (realmd hashes `lp->A`
/// as received, so this is the same 32 bytes we put in the packet). Twenty zeros when we have no
/// `H` for this salt — the pre-B241 behaviour, which a non-strict server ignores.
pub fn version_proof(crc_salt: &[u8; 16], client_public_key: &[u8; 32]) -> [u8; 20] {
match integrity_hash(crc_salt) {
Some(h) => {
let mut sha = Sha1::new();
sha.update(client_public_key);
sha.update(h);
sha.finalize().into()
}
None => [0u8; 20],
}
}
/// Send `CMD_AUTH_LOGON_PROOF_Client`. `crc_salt` is the challenge's — the packet's `crc_hash` is
/// computed here ([`version_proof`]) rather than passed in, so no caller can forget it. No telemetry
/// keys, no security flag.
pub fn write_logon_proof(
w: &mut impl Write,
client_public_key: &[u8; 32],
client_proof: &[u8; 20],
crc_salt: &[u8; 16],
) -> std::io::Result<()> {
let mut packet = Vec::with_capacity(1 + 32 + 20 + 20 + 1 + 1);
packet.push(CMD_AUTH_LOGON_PROOF);
packet.extend_from_slice(client_public_key);
packet.extend_from_slice(client_proof);
packet.extend_from_slice(&version_proof(crc_salt, client_public_key));
packet.push(0); // number_of_telemetry_keys
packet.push(0); // security_flag = None
w.write_all(&packet)
}
/// Read the proof reply, returning the server's proof `M2` (or erroring on a non-success result).
pub fn read_proof_reply(r: &mut impl Read) -> Result<[u8; 20]> {
let opcode = read_u8(r)?;
if opcode != CMD_AUTH_LOGON_PROOF {
bail!("expected CMD_AUTH_LOGON_PROOF (0x01), got {opcode:#x}");
}
let result = read_u8(r)?;
if result != 0 {
// A wrong password fails HERE (the server can't verify M1): vmangos answers 0x04.
return Err(AuthReject { code: result }.into());
}
let server_proof = read_array::<20>(r)?;
let _hardware_survey_id = read_u32_le(r)?;
Ok(server_proof)
}
/// Send `CMD_REALM_LIST_Client` (opcode + a `u32` padding of 0).
pub fn write_realm_list_request(w: &mut impl Write) -> std::io::Result<()> {
let mut packet = [0u8; 5];
packet[0] = CMD_REALM_LIST;
w.write_all(&packet)
}
/// The three **magic populations** the reference's realm-list parser rewrites, and the flag bit
/// each one stands in for: `(population as sent, population rewritten to, flag OR'd)`.
///
/// Only bits `0x01`/`0x02`(/`0x04`) are real server flags on the wire. Recommended, New and Full
/// travel as *populations* — the parser recognises the exact float, replaces it, and ORs in the bit
/// the load band later reads. Compared by bit pattern because that is what the binary compares, and
/// because it says plainly that these are sentinels rather than a numeric range.
///
/// VERIFIED, wow-re `system/glue/scratch/realm-list-bindings.md` §5 (the parser is `0x5b2230`).
pub(crate) const MAGIC_POPULATIONS: [(u32, u32, u8); 3] = [
(0x4416_0000, 0x0000_0000, 0x20), // 600.0 → 0.0 — Recommended
(0x4348_0000, 0x3a83_126f, 0x40), // 200.0 → 0.001 — New
(0x43c8_0000, 0x4100_0000, 0x80), // 400.0 → 8.0 — Full
];
/// Read `CMD_REALM_LIST_Server` into the advertised realms.
///
/// **Every per-realm field is kept.** The flags byte, the category byte and the realm id were
/// read-and-dropped for as long as benilla connected to `realms.first()` without ever drawing a
/// list; the realm-list screen displays or groups on all three, and the population is the input to
/// the load band rather than a string to print.
///
/// **And the population is rewritten on the way in** — see [`MAGIC_POPULATIONS`]. This has to
/// happen *here*, in the parser, exactly as the reference does it: the realm-list screen computes a
/// mean over every realm's population, and a realm advertised as Recommended arrives carrying
/// `600.0`, which would drag that mean far enough to mislabel every other row on the list.
pub fn read_realm_list(r: &mut impl Read) -> Result<Vec<RealmInfo>> {
let opcode = read_u8(r)?;
if opcode != CMD_REALM_LIST {
bail!("expected CMD_REALM_LIST (0x10), got {opcode:#x}");
}
let _size = read_u16_le(r)?;
let _header_padding = read_u32_le(r)?;
let number_of_realms = read_u8(r)?;
let mut realms = Vec::with_capacity(number_of_realms as usize);
for _ in 0..number_of_realms {
let realm_type = read_u32_le(r)?;
let mut flags = read_u8(r)?;
let name = read_cstring(r)?;
let address = read_cstring(r)?;
let mut population = read_f32_le(r)?;
let characters = read_u8(r)?;
let category = read_u8(r)?;
let id = read_u8(r)?;
// The sentinel swap, before anyone can average this number.
if let Some(&(_, rewritten, bit)) = MAGIC_POPULATIONS
.iter()
.find(|(magic, _, _)| *magic == population.to_bits())
{
population = f32::from_bits(rewritten);
flags |= bit;
}
realms.push(RealmInfo {
name,
address,
population,
characters,
realm_type,
flags,
category,
id,
});
}
let _footer_padding = read_u16_le(r)?; // consume so the stream stays aligned (see challenge reply)
Ok(realms)
}
#[cfg(test)]
mod tests {
use super::*;
/// The OS/platform tags are reversed on the wire (the real client's little-endian u32s), and a
/// server that reads anything but "Win"/"OSX" fails the session — so pin the byte order, not
/// just the constant. Reversing the wire bytes must spell the tag.
#[test]
fn os_tags_reverse_to_the_names_servers_match_on() {
let spelled = |tag: u32| {
let mut b = tag.to_le_bytes();
b.reverse();
String::from_utf8(b.iter().copied().filter(|&c| c != 0).collect()).unwrap()
};
assert_eq!(spelled(OS_WINDOWS), "Win");
assert_eq!(spelled(OS_MACOS), "OSX");
assert_eq!(spelled(PLATFORM_X86), "x86");
}
/// We advertise the host we actually run on — Windows everywhere except macOS.
#[test]
fn client_os_follows_the_host() {
if cfg!(target_os = "macos") {
assert_eq!(client_os(), OS_MACOS);
} else {
assert_eq!(client_os(), OS_WINDOWS);
}
}
/// An arbitrary but fixed `A` for the version-proof vectors below.
fn test_public_key() -> [u8; 32] {
std::array::from_fn(|i| (i as u8).wrapping_mul(7).wrapping_add(9))
}
/// Known answers, computed *outside* this crate (Python `hashlib`) from realmd's own expression
/// `SHA1(A ‖ H)` and the emulator-sourced `H`. A regression here means either constant moved or
/// the hash grew an extra input — both of which read as "modified client" to a strict server and
/// are otherwise invisible until someone tries to log in.
#[test]
fn version_proof_matches_the_realmd_expression() {
let expected = if cfg!(target_os = "macos") {
"dccd0e67946fae221451513b1a59724090ff6096"
} else {
"9b95cd41edd719fddf237294b8aca17010e71703"
};
let got = version_proof(&MANGOS_VERSION_CHALLENGE, &test_public_key());
assert_eq!(
got.iter().map(|b| format!("{b:02x}")).collect::<String>(),
expected
);
}
/// A salt we hold no `H` for is answered with zeros, not with the constant for another salt.
#[test]
fn an_unknown_version_challenge_is_answered_with_zeros() {
let mut salt = MANGOS_VERSION_CHALLENGE;
salt[0] ^= 0xff;
assert_eq!(version_proof(&salt, &test_public_key()), [0u8; 20]);
}
/// The proof packet carries the answer at the offset realmd reads it from: `opcode · A[32] ·
/// M1[20] · crc_hash[20] · num_keys · security_flag`, 75 bytes total.
#[test]
fn the_proof_packet_carries_the_version_proof() {
let a = test_public_key();
let m1: [u8; 20] = std::array::from_fn(|i| (i as u8).wrapping_mul(13).wrapping_add(4));
let mut packet = Vec::new();
write_logon_proof(&mut packet, &a, &m1, &MANGOS_VERSION_CHALLENGE).unwrap();
assert_eq!(packet.len(), 1 + 32 + 20 + 20 + 1 + 1);
assert_eq!(packet[0], CMD_AUTH_LOGON_PROOF);
assert_eq!(&packet[1..33], &a);
assert_eq!(&packet[33..53], &m1);
assert_eq!(
&packet[53..73],
&version_proof(&MANGOS_VERSION_CHALLENGE, &a)
);
assert_eq!(&packet[73..], &[0, 0]);
}
/// Every per-realm field the wire carries survives the read.
///
/// The flags byte, the category byte and the realm id were read into `_`-prefixed locals and
/// dropped for as long as nothing drew a realm list; the population was stringified on the way
/// in. All four are load-bearing for the realm-list screen, so pin the byte layout *and* the
/// capture — a field silently going back to `_` is the failure this catches.
#[test]
fn the_realm_list_keeps_every_field_the_wire_carries() {
let mut body = Vec::new();
body.extend_from_slice(&8u32.to_le_bytes()); // realm_type: RPPVP
body.push(0x42); // flags: 0x02 offline | 0x40 sentinel
body.extend_from_slice(b"Onyxia\0");
body.extend_from_slice(b"127.0.0.1:8085\0");
body.extend_from_slice(&1.75f32.to_le_bytes()); // population
body.push(3); // characters
body.push(2); // category
body.push(9); // realm id
let mut packet = vec![CMD_REALM_LIST];
packet.extend_from_slice(&(body.len() as u16 + 3).to_le_bytes()); // size
packet.extend_from_slice(&0u32.to_le_bytes()); // header padding
packet.push(1); // number_of_realms
packet.extend_from_slice(&body);
packet.extend_from_slice(&0u16.to_le_bytes()); // footer padding
let realms = read_realm_list(&mut packet.as_slice()).unwrap();
assert_eq!(realms.len(), 1);
let r = &realms[0];
assert_eq!(r.name, "Onyxia");
assert_eq!(r.address, "127.0.0.1:8085");
assert_eq!(r.realm_type, 8);
assert_eq!(r.flags, 0x42);
assert_eq!(r.population, 1.75);
assert_eq!(r.characters, 3);
assert_eq!(r.category, 2);
assert_eq!(r.id, 9);
}
/// **Recommended / New / Full arrive as populations, not as flags**, and the parser swaps them
/// before anyone can average the number.
///
/// This is the one place it can be done. The realm list's load band is computed from a mean
/// over every realm's population, so a single realm advertised as Recommended (600.0) sitting
/// unswapped in a list of realms at ~1.0 drags the mean past every other row and relabels the
/// whole screen. The rewritten values are the reference's own, bit for bit.
#[test]
fn the_three_magic_populations_become_flags_and_are_rewritten() {
let one = |pop: f32| {
let mut body = Vec::new();
body.extend_from_slice(&0u32.to_le_bytes());
body.push(0x02); // a real wire flag, which must survive the OR
body.extend_from_slice(b"R\0");
body.extend_from_slice(b"h:1\0");
body.extend_from_slice(&pop.to_le_bytes());
body.extend_from_slice(&[0, 1, 0]);
let mut packet = vec![CMD_REALM_LIST];
packet.extend_from_slice(&(body.len() as u16 + 3).to_le_bytes());
packet.extend_from_slice(&0u32.to_le_bytes());
packet.push(1);
packet.extend_from_slice(&body);
packet.extend_from_slice(&0u16.to_le_bytes());
read_realm_list(&mut packet.as_slice()).unwrap().remove(0)
};
let recommended = one(600.0);
assert_eq!(recommended.flags, 0x02 | 0x20, "the wire flag survives");
assert_eq!(recommended.population, 0.0);
let new = one(200.0);
assert_eq!(new.flags, 0x02 | 0x40);
assert_eq!(new.population.to_bits(), 0x3a83_126f, "0.001f exactly");
let full = one(400.0);
assert_eq!(full.flags, 0x02 | 0x80);
assert_eq!(full.population, 8.0);
// An ordinary population is left alone, flags and all.
let plain = one(1.5);
assert_eq!(plain.flags, 0x02);
assert_eq!(plain.population, 1.5);
}
}