comments: protocol, the auth code, the live probes and their examples

1,071 comment lines become 573; the code is unchanged. The world
probe's field docs are its --help text, so the help is shorter now and
matches what each flag runs: --quest takes quest 783 from Willem to
McBride, --quest-item needs the starter item to survive, --giverstatus
queries once, --spells makes three casts. Also corrected:
- the aura-duration packet has a SessionEvent;
- the SRP xor hash is SHA1(N) XOR SHA1(g), not a hash of it;
- the cooldown probe picks Tri, else the first character.

benilla sending a stored integrity hash instead of hashing the install
is now marked as a deviation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-23 20:09:30 +07:00
co-authored by Claude Opus 5.5
parent fc95eec18a
commit 8d9f007415
26 changed files with 470 additions and 968 deletions
@@ -1,36 +1,16 @@
//! Live probe (decision 1029, B215): **what an addon broadcast actually looks like on the wire.**
//!
//! 1.12.1 has no addon opcode — `SendAddonMessage` rides the ordinary chat lanes and the sentinel
//! `language = LANG_ADDON` (`0xFFFFFFFF`) is the *only* thing separating "a hunter addon telling
//! the party its version" from "someone talking". benilla rendered whatever arrived, so a partied
//! real-client player's Quiver ping printed as `[Party] [Soreen]: Quiver VERSION:3.1.4`. The gate
//! that fixes it keys on that one field, so that one field had to be proved end-to-end against a
//! live vmangos rather than argued from folk memory of the lane.
//!
//! What it asserts, on the reported lane (`CHAT_MSG_PARTY`) and on the other lane addon traffic
//! commonly uses (`CHAT_MSG_CHANNEL`):
//!
//! - an addon send comes back out of the server with `language == LANG_ADDON` **intact** (the
//! relay does not normalise it away — `BuildChatPacket` is handed `Language(packet.lang)`
//! verbatim, `Handlers/ChatHandler.cpp:488`);
//! - its `chat_type` is the **ordinary lane's** type byte, not some addon-specific value: there is
//! nothing in the type field to filter on;
//! - the `prefix` TAB `payload` text survives byte-for-byte (addon chat skips `SanitizeChatMessage`
//! entirely, `ChatHandler.cpp:49`, so the tab is not stripped);
//! - the control — a plain line on the same lane — comes back with the character's real tongue, so
//! a language-keyed gate cannot swallow speech.
//! Live probe: what an addon broadcast looks like on the wire. 1.12.1 has no addon opcode;
//! `SendAddonMessage` rides the ordinary chat lanes on their ordinary type byte, and only
//! `language == LANG_ADDON` (`0xFFFFFFFF`) marks it. vmangos relays the language verbatim
//! (`ChatHandler.cpp:488`) and skips `SanitizeChatMessage` for addon chat
//! (`ChatHandler.cpp:49`), so the prefix TAB survives.
//!
//! Run: `cargo run -p benilla-protocol --example addon_chat_probe -- probeN pprobeN [probeM
//! pprobeM] [host]`. With one account it runs the CHANNEL case alone (the server echoes a channel
//! line back to its own sender, so one session is a complete round trip). With two it also runs
//! the PARTY case, which needs a real group: the first account invites the second, the second
//! accepts, and the addon line goes out over `/p`. **The second identity is a probe account no
//! checkout declares** — a login kicks whoever holds the account (decision 0530).
//! pprobeM] [host]`. One account runs the CHANNEL case; a second adds the PARTY case, and must be
//! an account nobody else is logged in on, since a login kicks the current holder.
//!
//! Server prerequisites, both checked by the probe's own output rather than assumed:
//! `AddonChannel = 1` (off ⇒ the server silently drops every addon send) and
//! `AllowTwoSide.Interaction.Channel = 0` (on ⇒ `Channel::Say` rewrites the language to
//! `LANG_UNIVERSAL` and the CHANNEL case would report a false negative).
//! Needs `AddonChannel = 1` (off drops every addon send) and
//! `AllowTwoSide.Interaction.Channel = 0` (on, `Channel::Say` rewrites the language to
//! `LANG_UNIVERSAL`).
use std::time::{Duration, Instant};
@@ -41,17 +21,14 @@ use benilla_protocol::messages::{
};
use benilla_protocol::{ServerPacket, WorldSession, WORLD_PORT};
/// The channel the CHANNEL case talks over. A custom name (not General/Trade) so the probe never
/// touches a real channel, and so its own membership is the only membership.
/// A private channel, so the probe's own membership is the only one.
const PROBE_CHANNEL: &str = "benillaaddonprobe";
/// The addon payload under test — the reported one, verbatim: prefix `Quiver`, TAB, the version
/// string a real Quiver 3.1.4 broadcasts.
/// What a real Quiver 3.1.4 broadcasts: this prefix, a TAB, then the body.
const ADDON_PREFIX: &str = "Quiver";
const ADDON_BODY: &str = "VERSION:3.1.4";
/// The slot's probe character: `probe4` → `Probefour` (decision 0530's
/// `probeN`/`pprobeN`/`Probe<N-spelled>` convention).
/// The probe account's character: `probe4` plays `Probefour`.
fn probe_char_name(user: &str) -> Option<String> {
let n: usize = user.strip_prefix("probe")?.parse().ok()?;
let spelled = [
@@ -78,11 +55,8 @@ fn login(host: &str, user: &str, pass: &str) -> Result<(WorldSession, String)> {
session.player_login(character.guid)?;
session.set_active_mover(character.guid)?;
session.set_read_timeout(Some(Duration::from_millis(200)))?;
// Wait for `SMSG_LOGIN_VERIFY_WORLD` before sending anything else. Every `STATUS_LOGGEDIN`
// opcode is dropped **silently** until `_player->IsInWorld()` (vmangos
// `WorldSession::ProcessPackets`, `Server/WorldSession.cpp:568-577`: no reply, no log, just
// `break`) — a channel join issued into the login flood vanishes, and the only symptom is a
// `NOT_MEMBER` on the next say, which reads like a permissions problem rather than a race.
// Until the player is in world the server silently drops every `STATUS_LOGGEDIN` opcode
// (`WorldSession.cpp:568-577`); a lost join only shows later, as `NOT_MEMBER` on the say.
if !wait_for(&mut session, 30, |p| {
matches!(p, ServerPacket::LoginVerifyWorld { .. })
}) {
@@ -90,17 +64,14 @@ fn login(host: &str, user: &str, pass: &str) -> Result<(WorldSession, String)> {
"{name} never got SMSG_LOGIN_VERIFY_WORLD — not in world, every send would be dropped"
);
}
// Start ungrouped, always. `CMSG_GROUP_DISBAND` is a no-op when we have no group (vmangos
// `HandleGroupDisbandOpcode` returns immediately), so this is free — and without it a run that
// died before its teardown leaves the character in a group forever, after which every later
// invite is answered `SMSG_PARTY_COMMAND_RESULT` instead of forming a party. That is a probe
// that fails for a reason having nothing to do with what it tests.
// Start ungrouped: a group left by a crashed run turns every invite into
// `SMSG_PARTY_COMMAND_RESULT`. Disbanding with no group is a no-op.
session.group_disband()?;
println!("probe: logged in {name} ({user}) — in world, ungrouped");
Ok((session, name))
}
/// A read timeout on a quiet socket — the normal case, and the one recv error that is not news.
/// A read timeout on a quiet socket, the one expected recv error.
fn is_timeout(e: &anyhow::Error) -> bool {
e.chain().any(|c| {
c.downcast_ref::<std::io::Error>().is_some_and(|io| {
@@ -112,10 +83,7 @@ fn is_timeout(e: &anyhow::Error) -> bool {
})
}
/// Drain until `done` says the thing we were waiting for arrived, or `secs` elapse. Every step of
/// the setup waits on the **server's own acknowledgement** rather than a sleep: the login flood
/// runs for seconds and a channel join issued into it is answered `NOT_MEMBER` when the say lands
/// first — a sleep long enough to hide that is a sleep that will be too short on a loaded server.
/// Drain until a packet satisfies `done`, or `secs` elapse.
fn wait_for(
session: &mut WorldSession,
secs: u64,
@@ -150,18 +118,13 @@ struct Seen {
text: String,
}
/// Drain `session` for up to `secs`, keeping every `SMSG_MESSAGECHAT` whose text is one of ours.
/// Read timeouts are the normal case (a quiet socket), never an error.
///
/// `WOW_ADDON_PROBE_TRACE=1` names every packet that goes past — the affordance that turns "the
/// line never arrived" into "the *join* was refused", which is the only interesting way this probe
/// fails.
/// Drain for `secs`, keeping each `SMSG_MESSAGECHAT` whose text is in `wanted`.
/// `WOW_ADDON_PROBE_TRACE=1` prints every packet that goes past.
fn collect(session: &mut WorldSession, secs: u64, wanted: &[&str]) -> Vec<Seen> {
let trace = std::env::var("WOW_ADDON_PROBE_TRACE").is_ok();
let mut seen = Vec::new();
let deadline = Instant::now() + Duration::from_secs(secs);
while Instant::now() < deadline {
// A recv error here is a read timeout on a quiet socket — the normal case, not news.
let Ok(packet) = session.recv() else { continue };
if trace {
println!("probe: .. {}", packet.name());
@@ -196,8 +159,8 @@ fn collect(session: &mut WorldSession, secs: u64, wanted: &[&str]) -> Vec<Seen>
seen
}
/// Assert one lane's pair: the addon line arrives with `LANG_ADDON` on the lane's own type byte and
/// its tab intact, and the control line arrives on the same type byte with a real tongue.
/// Judge one lane: the addon line carries `LANG_ADDON`, the lane's own type byte and its TAB;
/// the control line carries the same type byte and a real language.
fn judge(lane: &str, expect_type: u8, addon_text: &str, control_text: &str, seen: &[Seen]) -> bool {
let mut ok = true;
let mut check = |label: &str, cond: bool| {
@@ -258,7 +221,7 @@ fn main() -> Result<()> {
[a, b, ..] => (a.clone(), b.clone()),
_ => bail!("{usage}"),
};
// The optional second identity, then the optional host — told apart by the `probe` prefix.
// An optional second account, then an optional host, told apart by the `probe` prefix.
let second = match args.get(2) {
Some(u) if u.starts_with("probe") => Some((u.clone(), args.get(3).context(usage)?.clone())),
_ => None,
@@ -270,15 +233,12 @@ fn main() -> Result<()> {
let addon_text = format!("{ADDON_PREFIX}\t{ADDON_BODY}");
let mut all_ok = true;
// The "be the other client in a running benilla's party" mode: instead of driving both sides,
// wait to be invited, accept, and broadcast. This is how the app-side gate is closed against
// the *symptom* — a benilla started with `WOW_PROBE_CHAT="/invite <this character>"` pulls the
// probe into its party and then has to not render what the probe says.
// Await-invite mode: wait to be invited, accept and broadcast, so a benilla started with
// `WOW_PROBE_CHAT="/invite <this character>"` gets a party member whose addon talks.
let await_invite = std::env::var("WOW_ADDON_PROBE_AWAIT_INVITE").is_ok();
// ── 1 · the CHANNEL lane, single session ──────────────────────────────────────────────────
// `Channel::SendToAll` (vmangos `Chat/Channel.cpp:777-785`) writes to every member INCLUDING
// the sender, so one account is a complete send→relay→receive round trip.
// `Channel::SendToAll` includes the sender (`Channel.cpp:777-785`): one session round-trips.
let (mut a, name_a) = login(&host, &user_a, &pass_a)?;
if await_invite {
println!("probe: — PARTY lane — {name_a} waits to be invited (60s)");
@@ -295,8 +255,7 @@ fn main() -> Result<()> {
let party_control = "party control line";
a.send_addon_message(CHAT_TYPE_PARTY, None, &addon_text)?;
a.send_chat_kind(CHAT_TYPE_PARTY, None, party_control)?;
// Party chat echoes to its own sender (`Group::BroadcastPacket` takes no ignore guid), so
// even as the talker the probe still sees the relayed wire and can judge it.
// Party chat echoes to its sender (`Group::BroadcastPacket` takes no ignore guid).
let seen = collect(&mut a, 8, &[&addon_text, party_control]);
all_ok &= judge("PARTY", CHAT_MSG_PARTY, &addon_text, party_control, &seen);
a.group_disband()?;
@@ -329,7 +288,7 @@ fn main() -> Result<()> {
);
a.leave_channel(PROBE_CHANNEL)?;
// ── 2 · the PARTY lane, the reported case — needs a real group, so a second identity ──────
// ── 2 · the PARTY lane, two sessions ──────────────────────────────────────────────────────
match second {
None => println!(
"probe: — PARTY lane — SKIPPED (pass a second probe account to run the reported case)"
@@ -344,8 +303,8 @@ fn main() -> Result<()> {
bail!("PARTY lane: {name_b} never saw SMSG_GROUP_INVITE — is {name_a} already grouped?");
}
b.group_accept()?;
// The group is only real once the server pushes the roster to BOTH sides; `/p` from A
// before A's own `SMSG_GROUP_LIST` lands is dropped by `if (!group) return`.
// Wait for the roster on both sides: a `/p` sent before the sender's own
// `SMSG_GROUP_LIST` is dropped by `if (!group) return`.
if !wait_for(&mut b, 20, |p| matches!(p, ServerPacket::GroupList { .. })) {
bail!("PARTY lane: {name_b} accepted but never got SMSG_GROUP_LIST");
}
@@ -357,8 +316,6 @@ fn main() -> Result<()> {
let party_control = "party control line";
a.send_addon_message(CHAT_TYPE_PARTY, None, &addon_text)?;
a.send_chat_kind(CHAT_TYPE_PARTY, None, party_control)?;
// B is the *other* client — exactly the reported situation (a partied real client's
// addon talking at us).
let seen = collect(&mut b, 5, &[&addon_text, party_control]);
all_ok &= judge("PARTY", CHAT_MSG_PARTY, &addon_text, party_control, &seen);
@@ -1,19 +1,13 @@
//! Diagnostic probe: measure the server's ACTUAL cooldown window for Charge (spell 100,
//! `recoveryTime 0 / category 44 / categoryRecoveryTime 15000`) against the client-rendered
//! 15 s sweep — the "I can charge before the indicator is up" report.
//! Diagnostic probe: the server's cooldown window for Charge (spell 100: `recoveryTime 0`,
//! category 44, `categoryRecoveryTime 15000`) against the client's 15 s sweep. It charges a
//! Northshire Kobold Vermin (entry 6), then recasts every 200 ms. vmangos checks the cooldown
//! first in `CheckCast` (`Spell.cpp:5369`), so the first result other than
//! `SPELL_FAILED_NOT_READY` (60), or a second `SMSG_SPELL_GO`, marks the server's cooldown end
//! relative to the first GO.
//!
//! Flow: log in the slot's probe warrior (`Probe<N-spelled>`), `.learn 100`, teleport into the Northshire kobold
//! cluster, Battle Stance, pick a Kobold Vermin (entry 6) from the create stream, Charge it,
//! then spam re-casts every 200 ms. vmangos checks the cooldown at the TOP of `CheckCast`
//! (`Spell.cpp:5369`), so the flip from `SPELL_FAILED_NOT_READY` (60) to any other code (or a
//! second `SMSG_SPELL_GO`) timestamps the server's cooldown end relative to the first GO.
//!
//! Run: `cargo run -p benilla-protocol --example cooldown_probe -- probeN pprobeN [host]` — the
//! probe account (the `probe` skill; never a player's account — a probe login there kicks their
//! live session). Two caveats: this is a COMBAT probe, so it runs supervised (docs/METHOD.md: no
//! unattended combat probes), and `.learn` is
//! SEC_DEVELOPER (5); probe accounts are gmlevel 6 (SEC_ADMINISTRATOR), so it lands. The vmangos
//! console for the run and restore it after.
//! Run: `cargo run -p benilla-protocol --example cooldown_probe -- probeN pprobeN [host]` on a
//! probe account with a warrior (a login kicks whoever is on the account); `.learn` needs
//! gmlevel 5 (`SEC_DEVELOPER`).
use std::time::{Duration, Instant};
@@ -107,7 +101,7 @@ fn main() -> Result<()> {
_ => {}
};
// World-enter settle, then arm the character: learn Charge, stance, teleport to the kobolds.
// Let the world-enter flood settle first.
drain(&mut session, &mut kobolds, 3.0, &mut quiet)?;
session.set_selection(self_guid)?;
session.send_chat(".learn 100")?;
@@ -137,8 +131,7 @@ fn main() -> Result<()> {
println!("charging kobold {guid:#x}…");
session.set_selection(guid)?;
session.cast_spell(CHARGE, Some(guid))?;
// The anchor is stamped INSIDE the watch, at GO decode — the drain window runs its full
// length regardless, and a window-end stamp would smear the anchor by up to 1.5 s.
// Stamp the anchor at GO decode: the drain runs its full 1.5 s regardless.
let mut verdict: Option<(bool, Instant)> = None;
let mut watch = |_t: f64, ev: SessionEvent| match ev {
SessionEvent::SpellGo {
@@ -1,11 +1,8 @@
//! Diagnostic probe: stream object creates and print each unit's decoded
//! `UNIT_FIELD_FACTIONTEMPLATE` (+ level/display id as sanity anchors), to check the descriptor
//! decode against the server DB's `creature_template.faction`.
//! Diagnostic probe: prints each streamed unit's `UNIT_FIELD_FACTIONTEMPLATE`, with level and
//! display id, to check the descriptor decode against `creature_template.faction`.
//!
//! Run: `cargo run -p benilla-protocol --example faction_probe -- probeN pprobeN [host]`
//! — a probe account (the `probe` skill). The account is a REQUIRED arg with no default: a
//! default is somebody's account, and a probe login there kicks their live session (decision
//! 0530).
//! Run: `cargo run -p benilla-protocol --example faction_probe -- probeN pprobeN [host]`. The
//! account has no default, because a login kicks whoever is on the account.
use std::time::{Duration, Instant};
@@ -1,19 +1,16 @@
//! Live probe: verify the faction-language mechanism end-to-end against the local vmangos — a
//! HORDE character's say must echo back (the server accepted the tongue), a dot-command must
//! answer (it survived the pre-parse `KnowsLanguage` gate), and the split-writer path must carry
//! the tongue too. Run: `cargo run -p benilla-protocol --example horde_chat_probe -- probeN
//! pprobeN [host]` — a probe account (the `probe` skill; it once hardcoded a shared identity,
//! decision 0530). Creates the orc
//! `Orc<N-spelled>` on this account on first run (names are realm-unique, so the orc keys to
//! the slot too). Exists because a hardcoded-Common send silently ate every Horde character's
//! chat and commands (decision 0392) — this is the one-shot regression check for that whole path.
//! Live probe: a Horde character's chat reaches vmangos in its own language. A say must echo
//! back, a dot-command must answer (it passes the pre-parse `KnowsLanguage` gate), and the split
//! writer must carry the language too. The server drops a say in a language the character does
//! not know, answering only `SMSG_NOTIFICATION`.
//!
//! Run: `cargo run -p benilla-protocol --example horde_chat_probe -- probeN pprobeN [host]`.
//! Creates the orc `Orc<N-spelled>` on the account on first run.
use anyhow::{bail, Context, Result};
use benilla_protocol::messages::{CharCreateReq, CHAR_CREATE_NAME_IN_USE, CHAR_CREATE_SUCCESS};
use benilla_protocol::{ServerPacket, WorldSession, WORLD_PORT};
/// The slot's Horde character name: `probe4` → `Orcfour` (letters only — digits are not legal
/// in character names, and realm-wide name uniqueness means each slot needs its own orc).
/// The account's orc: `probe4` plays `Orcfour`. Names are letters only and realm-unique.
fn orc_name(user: &str) -> Option<String> {
let n: usize = user.strip_prefix("probe")?.parse().ok()?;
let spelled = [
@@ -44,7 +41,7 @@ fn main() -> Result<()> {
let mut characters = session.char_enum()?;
if !characters.iter().any(|c| c.name == orc) {
// Orc (race 2) warrior male — the Horde case under test.
// Race 2 orc, class 1 warrior, gender 0 male.
let req = CharCreateReq {
name: orc.clone(),
race: 2,
@@ -70,11 +67,9 @@ fn main() -> Result<()> {
session.player_login(orc.guid)?;
session.set_active_mover(orc.guid)?;
// 1. A plain say must be ACCEPTED: the server echoes our own say back (own sends are never
// locally echoed in vanilla). Pre-fix, a Horde say sent Common and the server dropped it
// with only an SMSG_NOTIFICATION.
// 1. An accepted say comes back from the server; the 1.12 client never echoes it locally.
session.send_chat("orcish probe line")?;
// 2. A dot-command must survive the language gate: `.gps` answers with system messages.
// 2. A dot-command passes the language gate: `.gps` answers with system messages.
session.send_chat(".gps")?;
let mut say_echoed = false;
@@ -92,8 +87,7 @@ fn main() -> Result<()> {
say_echoed = true;
println!("probe: SAY ECHOED (language {})", m.language);
}
// CHAT_MSG_SYSTEM = 0x0a on the inbound u8 field. Only the `.gps` answer counts
// (the login MOTD is also SYSTEM — it must not satisfy the command check).
// `CHAT_MSG_SYSTEM` is 0x0a; the text check skips the login MOTD, also SYSTEM.
if m.chat_type == 0x0a && m.text.contains("Map:") {
system_reply = true;
println!("probe: .gps REPLY: {:?}", m.text);
@@ -108,8 +102,7 @@ fn main() -> Result<()> {
}
}
// 3. The same through the SPLIT writer — the path the benilla app actually sends on
// (`into_split` must carry the tongue across).
// 3. The same through the split writer the app sends on; `into_split` keeps the language.
let (mut reader, mut writer) = session.into_split()?;
writer.send_chat("orcish writer line")?;
let mut writer_echoed = false;
@@ -1,30 +1,17 @@
//! Live probe: what a **refused character login** actually looks like on the wire, and what it
//! leaves behind.
//! Live probe: what a refused character login looks like on the wire, and what it leaves. It
//! checks that `SMSG_CHARACTER_LOGIN_FAILED` (0x41) arrives with result `1`
//! (`HandlePlayerLoginOpcode`), which the 1.12 client reads as `CHAR_LOGIN_NO_WORLD` ("World
//! server is down"); that the session stays `STATUS_AUTHED` and still serves `CMSG_CHAR_ENUM`;
//! and that a valid pick then works on the same socket.
//!
//! benilla used to send `CMSG_PLAYER_LOGIN` and declare itself in the world in the same breath, so
//! `SMSG_CHARACTER_LOGIN_FAILED` (0x41) reached nothing — a refusal left the client on a loading
//! screen that could never clear. The fix rests on three facts about the server, and this pins all
//! three against the local vmangos:
//!
//! 1. the refusal **arrives**, with its result byte — vmangos sends a bare `1`
//! (`WorldSession::HandlePlayerLoginOpcode`'s `loginFailedPacket->result = 1`), which the
//! reference's table reads as `CHAR_LOGIN_NO_WORLD`, "World server is down";
//! 2. the **session survives** it — still `STATUS_AUTHED`, still able to serve `CMSG_CHAR_ENUM`;
//! 3. a **second, valid pick still works** on the same socket, so a refusal costs the player a
//! click and not a reconnect.
//!
//! The refusal is provoked by the one guard that needs no server state: `!packet.guid.IsPlayer()`.
//! A creature-typed guid is refused immediately and touches nothing else.
//!
//! Needs the local vmangos up; account `two`/`ptwo` (the account-X/password-pX convention).
//! A creature guid trips the stateless `!packet.guid.IsPlayer()` guard. Needs the local vmangos
//! and account `two`/`ptwo`.
use std::time::Duration;
use benilla_protocol::{logon, messages::ServerPacket, WorldSession, WORLD_PORT};
/// A guid the server cannot read as a player: vmangos's `ObjectGuid::IsPlayer()` tests the high
/// type nibble, and `0xF130…` is `HIGHGUID_UNIT`. Nothing on the server is looked up by it — the
/// guard rejects before any lookup — so the probe cannot disturb a real character.
/// A `HIGHGUID_UNIT` guid (`0xF130…`): `ObjectGuid::IsPlayer()` rejects it before any lookup.
const NOT_A_PLAYER: u64 = 0xF130_0000_0000_0001;
fn connect(user: &str, pass: &str) -> anyhow::Result<WorldSession> {
@@ -1,6 +1,5 @@
//! Dev helper: print SQL to create a vmangos account, computing the SRP6 verifier/salt with
//! `benilla-srp`. vmangos stores `v`/`s` as big-endian hex, while we compute
//! little-endian, so we reverse. Usage:
//! Dev helper: prints the SQL that creates a vmangos account, with the SRP6 verifier and salt
//! from `benilla-srp`. vmangos stores `v` and `s` as big-endian hex; we compute little-endian.
//!
//! ```text
//! cargo run -p benilla-protocol --example make_account -- <user> <pass> \
+6 -10
View File
@@ -1,12 +1,8 @@
//! Live probe (decision 0193): the character-select park behaviors benilla's glue layer rests on.
//! Live probe: the character-select park behaviours the glue screens rely on. A logout ends in
//! `SMSG_LOGOUT_COMPLETE` and a reconnect re-serves the roster; a socket parked at character
//! select for 130 s still logs in, since vmangos does not kick a quiet authenticated socket.
//!
//! 1) the logout round-trip — login, logout, confirm `SMSG_LOGOUT_COMPLETE`, reconnect, confirm
//! the roster re-serves (the app's drop-and-reconnect relist);
//! 2) idle tolerance — park an authenticated socket at character select for 130 s, then log in
//! (verified 2026-07-07: vmangos does NOT kick a quiet parked socket, so the glue screen needs
//! no keep-alive ping).
//!
//! Needs the local vmangos up; account `two`/`ptwo` (the account-X/password-pX convention).
//! Needs the local vmangos and account `two`/`ptwo`.
use std::time::Duration;
@@ -42,7 +38,7 @@ fn main() -> anyhow::Result<()> {
println!("PASS: SMSG_LOGOUT_COMPLETE received");
drop(s);
// Fresh cycle re-serves the roster (the app's drop-and-reconnect relist).
// A fresh connection re-serves the roster, as the app's relist does.
let mut s = connect(user, pass)?;
let n = s.char_enum()?.len();
println!("PASS: post-logout reconnect roster has {n} chars");
@@ -55,7 +51,7 @@ fn main() -> anyhow::Result<()> {
std::thread::sleep(Duration::from_secs(130));
match s.player_login(chars[0].guid).and_then(|()| {
s.set_active_mover(chars[0].guid)?;
// Prove the world actually streams: wait for any post-login packet.
// Any post-login packet shows the world streams.
s.set_read_timeout(Some(Duration::from_secs(5)))?;
s.recv().map(|p| p.name())
}) {
@@ -1,71 +1,44 @@
//! Dev helper: put an account into the standard **full class set** — one character of every vanilla
//! class, optionally levelled, geared, specced and parked in its own capital. Headless: a bare
//! `WorldSession`, no Bevy app and no window, so one process dresses a whole nine-body account.
//! Dev helper: gives an account one character of every class, optionally levelled, geared,
//! specced and parked in its capital, over a headless `WorldSession`.
//!
//! ```text
//! cargo run -p benilla-protocol --example seed_classes -- <user> <pass> <Prefix> [flags]
//! --wipe delete EVERY existing character on the account first (see the warning below)
//! --wipe delete every character on the account first, irreversibly
//! --level <n> `.character level n` + `.learn all_myclass` (all class spells + talents)
//! --tier <t> `.character premade gear <role>-<t>` + `.character premade spec <spec>`;
//! `<t>` is `phase6-bis` (Naxx BiS), `preraid-bis`, or `r14` (rank-14 PvP
//! kit, custom templates 901–909 — decision 0825), role is per class below
//! kit, custom templates 901–909), role is per class below
//! --home `.tele` each body to its own race's capital
//! --spread <a|b> which race spread to use (default `a`)
//! --reload-templates make the server re-read the premade tables before dressing (see below)
//! --reload-templates make the server re-read the premade tables before dressing
//! --host <h> default `localhost`
//!
//! # the director's two accounts, rebuilt as geared 60s:
//! … -- one pone One --wipe --level 60 --tier phase6-bis --home --spread a
//! … -- two ptwo Two --wipe --level 60 --tier phase6-bis --home --spread b
//! ```
//!
//! Without `--wipe` it is idempotent **by class** — classes the account already has are skipped — so
//! it tops an account up to the full nine. Creating characters needs no GM level; the state flags do,
//! and that is where this silently half-applies if the account is short: **`.character premade
//! gear|spec` needs gmlevel 4 and `--level`'s two commands need 5**, and that level lives in
//! `realmd.account_access`, *not* `account.gmlevel` (see docs/METHOD.md, "The local vmangos server").
//! Every server reply is echoed as `server says — …`, so a refusal is visible rather than silent.
//! Without `--wipe` it skips classes the account already has. Creating needs no GM level;
//! `.character premade gear|spec` needs 4 and `--level` needs 5, read from
//! `realmd.account_access`, not `account.gmlevel`. Both factions on one account need
//! `AllowTwoSide.Accounts = 1`, or a Horde create fails `CHAR_CREATE_PVP_TEAMS_VIOLATION` (0x33).
//!
//! **`--wipe` deletes characters irreversibly, hand-made ones included.** It is deliberately not the
//! default, and it names each body and its level as it goes. Both factions on one account needs
//! `AllowTwoSide.Accounts = 1` in mangosd.conf — otherwise the Horde creates (shaman is Horde-only)
//! come back `CHAR_CREATE_PVP_TEAMS_VIOLATION` (0x33).
//! Dressing is not idempotent: `.character premade gear` adds a fresh copy of the set on every
//! run, so re-dress only behind `--wipe`. `.learn all_myclass` also grants a Horde mage the
//! ungated Alliance city teleports, so a Horde mage ends on 74 spells and an Alliance one on 68.
//!
//! Spell counts do **not** match across two accounts' same-class bodies, and that is not a
//! half-applied `.learn all_myclass`: an Alliance mage ends on 68 spells and a Horde one on 74,
//! because the command grants every spell the class *can* learn and the Alliance city
//! teleports/portals carry no gate that stops a Horde mage — so the Horde body collects all twelve
//! while the Alliance body gets only its own six. Each still has its own faction's full set.
//!
//! **Creating is idempotent; dressing is NOT.** `.character premade gear` *adds* a fresh copy of the
//! set every time it runs — it does not diff against what is worn. Dress an already-dressed body and
//! the second set lands in its bags, and any slot whose first-pass item is still equipped keeps the
//! duplicate in the backpack instead: observed as a warrior left with an empty finger2 and ten spare
//! epics, because both rings wanted finger1. So re-dress only behind `--wipe`; to fix a body that was
//! dressed twice, wipe and rebuild it rather than unpicking the bags.
//!
//! **`--reload-templates`:** the world server caches the premade tables at startup and has no
//! reload command, so a template edited in the DB is invisible to `.character premade gear` until
//! the next restart. `.character premade savegear <name>` re-reads them all as a side effect
//! (`CharacterCommands.cpp` → `LoadPlayerPremadeTemplates`; decision 0818's in-band trick), so this
//! flag sends `.character premade savegear zz-seed-reload` as the first command of the first body
//! dressed. The junk template it saves (the naked just-created body) is inert — no role a bot would
//! pick, self-naming, safe to leave or delete from `player_premade_item_template` later.
//! The server caches the premade tables at startup with no reload command, but
//! `.character premade savegear <name>` re-reads them (`LoadPlayerPremadeTemplates`), so
//! `--reload-templates` saves an inert `zz-seed-reload` template before the first dressing.
use std::time::{Duration, Instant};
use anyhow::{bail, Context, Result};
use benilla_protocol::{logon, messages, ServerPacket, WorldSession, WORLD_PORT};
/// `CHAT_MSG_SYSTEM` on the inbound `SMSG_MESSAGECHAT` type byte — every dot-command's
/// success/refusal text arrives as one of these.
/// The inbound `SMSG_MESSAGECHAT` type byte every dot-command reply arrives on.
const CHAT_MSG_SYSTEM: u8 = 0x0a;
/// Per class: the premade templates that make a level-60 body of that class make sense. `role` joins
/// the `--tier` (`dps` + `phase6-bis` → `dps-phase6-bis`, a `player_premade_item_template.name`);
/// `spec` is a verbatim `player_premade_spell_template.name` (specs carry no tier suffix — there is
/// one per role at 60). Every name here was read off this deploy's world DB, and the roles spread
/// plate/mail/cloth and tank/heal/dps across the nine rather than making nine damage dealers.
/// Per class: the gear role, joined to `--tier` into a `player_premade_item_template.name`
/// (`dps-phase6-bis`), and a verbatim `player_premade_spell_template.name`, which has no tier.
const CLASSES: [(u8, &str, &str, &str); 9] = [
// class, name, gear role, spec template
(1, "warrior", "dps", "fury-dw-pve"),
@@ -79,10 +52,8 @@ const CLASSES: [(u8, &str, &str, &str); 9] = [
(11, "druid", "tank", "feral-bear-pve"),
];
/// `(race, gender)` per class, indexed to match [`CLASSES`]. Two spreads exist so two accounts can
/// hold **eighteen distinct** race/class/gender bodies instead of the same nine twice; each spread on
/// its own still covers all eight races, and every pair is a legal 1.12 combination (paladin is
/// Human/Dwarf only, shaman Orc/Tauren/Troll, druid Night Elf/Tauren).
/// `(race, gender)` per class, indexed like [`CLASSES`]; each spread covers all eight races in
/// legal 1.12 pairs (paladin Human/Dwarf, shaman Orc/Tauren/Troll, druid Night Elf/Tauren).
const SPREAD_A: [(u8, u8); 9] = [
(1, 0), // human warrior
(3, 0), // dwarf paladin
@@ -95,7 +66,7 @@ const SPREAD_A: [(u8, u8); 9] = [
(6, 0), // tauren druid
];
/// The second spread — no race/class pair shared with [`SPREAD_A`].
/// The second spread, sharing no race/class pair with [`SPREAD_A`].
const SPREAD_B: [(u8, u8); 9] = [
(3, 0), // dwarf warrior
(1, 0), // human paladin
@@ -108,8 +79,7 @@ const SPREAD_B: [(u8, u8); 9] = [
(4, 1), // night elf druid
];
/// Race id → (display name, the `game_tele` name of its capital). Gnomes ride with the dwarves:
/// Gnomeregan is a dungeon, and `game_tele` has no gnome capital row.
/// Race id → (display name, `game_tele` capital); gnomes have no capital row and use Ironforge.
fn race_info(id: u8) -> Option<(&'static str, &'static str)> {
Some(match id {
1 => ("Human", "Stormwind"),
@@ -191,11 +161,7 @@ fn connect(o: &Opts) -> Result<WorldSession> {
WorldSession::connect(&addr, &o.user, l.session_key)
}
/// Read inbound packets for `window`, echoing every system-chat line — the server's own
/// success/refusal text for a dot-command, and the only thing separating a command that worked from
/// one that was silently refused. A read-timeout tick is the expected quiet case rather than an
/// error (the convention [`WorldSession::logout`]'s own poll loop uses), so draining runs to the
/// deadline either way.
/// Read for `window`, printing every system-chat line, the server's reply to a dot-command.
fn drain(s: &mut WorldSession, window: Duration) {
let deadline = Instant::now() + window;
while Instant::now() < deadline {
@@ -212,8 +178,7 @@ fn main() -> Result<()> {
let mut s = connect(&o)?;
// ── 1 · wipe ──
// Blocking reads for the whole character-select phase: char_enum/create/delete each loop on
// `recv` until their own reply lands, so a read timeout here surfaces as a hard error.
// Blocking reads: enum, create and delete loop on `recv`, where a read timeout is an error.
s.set_read_timeout(None)?;
let have = s.char_enum()?;
println!("account {}: {} existing character(s)", o.user, have.len());
@@ -266,10 +231,8 @@ fn main() -> Result<()> {
}
// ── 3 · dress each body ──
// The re-enum is load-bearing, not a formality: `player_login` takes each character's chat
// tongue from the roster it last saw, and vmangos DROPS chat — dot-commands included — spoken in
// a language the character doesn't know. Skip it and every Horde body's commands vanish
// silently (decision 0392).
// Re-enum first: `player_login` takes the chat language from the last roster, and vmangos
// drops chat, dot-commands included, in a language the character does not know.
let roster = s.char_enum()?;
let mut dressed = 0usize;
if o.level.is_some() || o.tier.is_some() || o.home {
@@ -281,14 +244,12 @@ fn main() -> Result<()> {
let (_, capital) = race_info(o.spread[i].0).context("spread holds a known race")?;
let mut steps = Vec::new();
if o.reload && dressed == 0 {
// Must run before the first `.character premade gear` — the server's template
// cache predates any DB edit this seeding is meant to pick up (see module doc).
// Before the first `.character premade gear`, which reads the cached templates.
steps.push(".character premade savegear zz-seed-reload".into());
}
if let Some(l) = o.level {
// Level before gear (a premade template only levels *up*), spells after the level
// they belong to, and the premade spec after `all_myclass` so a real talent tree
// wins over its every-talent state — the order the rig established (decision 0651).
// Order matters: level before gear (a premade only levels up), spells after the
// level, and the premade spec after `all_myclass` so its talent tree wins.
steps.push(format!(".character level {l}"));
steps.push(".learn all_myclass".into());
}
@@ -1,22 +1,16 @@
//! Probe: **how does realmd feed the SRP6 big numbers to SHA-1** — fixed width, or minimal length?
//!
//! The 1.12.1 client hashes `A`, `B`, `K`, the salt, the `H(N)⊕H(g)` constant and `M1` at their
//! *declared* widths, zero-padded in the high bytes (`wow-5875-re` `srp6_client_session`, byte-exact
//! from `WoW.exe` `0x5d3650`). vmangos feeds the same values as `BigNumber`s
//! (`SHA1::Generator::UpdateData(BigNumber const&)` → `AsByteArray()` with `minSize = 0`), which
//! **drops high-order zero bytes**. The two agree only while no value happens to have one — and
//! disagree, silently, when one does. cmangos is identical (`Sha1Hash::UpdateBigNumbers`).
//!
//! This probe forces each case against a live realmd so the disagreement is observed, not argued:
//! Probe: whether realmd feeds the SRP6 big numbers to SHA-1 at fixed width or minimal length.
//! The 1.12 client hashes `A`, `B`, `K`, the salt, `H(N)⊕H(g)` and `M1` at their declared
//! widths, zero-padded in the high bytes (`0x5d3650`). vmangos hashes them as `BigNumber`s
//! (`AsByteArray()` with `minSize = 0`), dropping high-order zero bytes, so the two disagree
//! whenever a value has one; cmangos does the same (`Sha1Hash::UpdateBigNumbers`).
//!
//! ```text
//! cargo run --release -p benilla-protocol --example srp_encoding_probe -- --user one --pass pone
//! ```
//!
//! `--stress N` instead runs N ordinary handshakes at the client-faithful (fixed-width) encoding and
//! reports the observed failure rate. realmd locks an IP out for 60 s after `WrongPass.MaxAttempts`
//! (default 10) failures inside that window and then answers `0x08 WOW_FAIL_DB_BUSY`; the probe
//! drains the window rather than counting the lockout as a proof failure.
//! `--stress N` runs N ordinary fixed-width handshakes and reports the failure rate, waiting out
//! realmd's lockout: 60 s after `WrongPass.MaxAttempts` (default 10) failures, during which it
//! answers `0x08 WOW_FAIL_DB_BUSY`.
use std::net::TcpStream;
use std::time::Duration;
@@ -35,9 +29,9 @@ const THROTTLE_MAX: u32 = 10;
/// How a big number is serialized into the SHA-1 stream.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Enc {
/// The client's law: the value's full declared width, zero-padded in the high bytes.
/// The 1.12 client: the full declared width, zero-padded in the high bytes.
Fixed,
/// vmangos' law: `BigNumber::AsByteArray()` — high-order zero bytes dropped.
/// vmangos: `BigNumber::AsByteArray()`, high-order zero bytes dropped.
Minimal,
}
@@ -73,9 +67,8 @@ fn to_fixed_le_32(v: &BigUint) -> [u8; 32] {
out
}
/// WoW's `SHA1_Interleave` **as vmangos computes it** — all 32 bytes of `S`, no trimming
/// (`SRP6::HashSessionKey`, `S.AsByteArray(32)`). benilla matches this today; the real client instead
/// strips low-order zero bytes of `S` first, which is its own (separately recorded) divergence.
/// `SHA1_Interleave` as vmangos computes it, over all 32 bytes of `S` (`SRP6::HashSessionKey`);
/// the 1.12 client strips the low-order zero bytes of `S` first.
fn interleave(s: &[u8; 32]) -> [u8; 40] {
let mut even = [0u8; 16];
let mut odd = [0u8; 16];
@@ -102,7 +95,7 @@ struct Attempt {
}
impl Attempt {
/// Which hashed values carry a high-order zero byte — i.e. where the two encodings differ.
/// The hashed values with a high-order zero byte, where the two encodings differ.
fn short_values(&self) -> Vec<&'static str> {
let mut v = Vec::new();
if self.a_pub[31] == 0 {
@@ -130,7 +123,7 @@ impl Attempt {
}
}
/// What realmd said, and — when it accepted — whose `M2` encoding its reply matches.
/// What realmd said and, on acceptance, which encoding its `M2` matches.
enum Outcome {
Accepted { m2_fixed: bool, m2_minimal: bool },
Refused(u8),
@@ -161,24 +154,22 @@ impl Outcome {
/// What the ephemeral search is aiming for.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Want {
/// Whatever the first draw gives — an ordinary handshake.
/// Whatever the first draw gives: an ordinary handshake.
Any,
/// Nothing short — the case both encodings agree on.
/// Nothing short: the case both encodings agree on.
Clean,
/// `A` with a zero high byte.
ShortA,
/// `K` with a zero high byte.
ShortK,
/// `M1` with a zero high byte — realmd accepts, but hashes a 19-byte `M1` into its `M2`.
/// `M1` with a zero high byte: realmd accepts, but hashes a 19-byte `M1` into its `M2`.
ShortM1,
/// `B` with a zero high byte. Not searchable: `B` is the server's, so the caller redials until
/// one turns up, and the ephemeral is then drawn clean so `B` is the only short value.
/// `B` with a zero high byte: the server's draw, so the caller redials; the rest draw clean.
ShortB,
}
impl Want {
/// Does a draw of this shape satisfy the search? Every case wants exactly its own value short
/// and the others long, so each verdict isolates one divergence.
/// Each case wants exactly its own value short, so each verdict isolates one divergence.
fn accepts(self, a_short: bool, k_short: bool, m1_short: bool) -> bool {
match self {
Want::Any => true,
@@ -219,7 +210,7 @@ fn compute(
let a = BigUint::from_bytes_le(&priv_key) + BigUint::from(n.bits());
let a_pub = to_fixed_le_32(&g.modpow(&a, &n));
// Reject on the A-shape we are not after before paying for the second modpow.
// Reject a wrong `A` shape before paying for the second modpow.
let a_short = a_pub[31] == 0;
if want != Want::Any && a_short != (want == Want::ShortA) {
continue;
@@ -250,7 +241,7 @@ fn compute(
None
}
/// `H( SHA1(N) XOR SHA1(g) )`, little endian — a constant for WoW's fixed `N`/`g`.
/// `SHA1(N) XOR SHA1(g)`, little endian: a constant for the fixed `N` and `g`.
fn xor_hash(generator: u8, large_safe_prime: &[u8; 32]) -> [u8; 20] {
let hn = sha1(&[large_safe_prime]);
let hg = sha1(&[&[generator]]);
@@ -261,7 +252,6 @@ fn xor_hash(generator: u8, large_safe_prime: &[u8; 32]) -> [u8; 20] {
out
}
/// One full realmd exchange.
fn handshake(
host: &str,
user: &str,
@@ -276,8 +266,7 @@ fn handshake(
auth::write_logon_challenge(&mut s, user, BUILD).context("sending logon challenge")?;
let reply = auth::read_challenge_reply(&mut s).context("reading logon challenge reply")?;
// `B` is not ours to choose: drop the connection — sending no proof, so realmd records nothing —
// and let the caller redial until the server hands one of the shape we want.
// A wrong-shape `B` drops the connection before any proof, so realmd records no failure.
if want != Want::Any && (reply.server_public_key[31] == 0) != (want == Want::ShortB) {
return Err(anyhow!("this connection's B is not the wanted shape"));
}
@@ -300,7 +289,7 @@ fn handshake(
Ok((attempt, outcome))
}
/// Redial until the search finds the wanted shape (`B` is the server's draw, so `ShortB` needs many).
/// Redial until the wanted shape turns up; `ShortB` needs many, `B` being the server's draw.
fn until(
host: &str,
user: &str,
@@ -369,8 +358,8 @@ fn main() -> Result<()> {
}
}
// The end-to-end gate: run the shipped `logon` — the code the client actually uses — and count
// what comes back. Anything but a clean sweep means the ephemeral guarantee is not holding.
// `--logon N` runs the shipped `logon` N times; every handshake must succeed, or its
// ephemeral guarantee is not holding.
if logons > 0 {
println!("logon: {logons} full handshakes through benilla_protocol::logon\n");
let (mut ok, mut failed) = (0u32, 0u32);
@@ -380,7 +369,7 @@ fn main() -> Result<()> {
Err(e) => {
failed += 1;
println!(" #{i}: {e:#}");
// Do not walk into realmd's lockout while reporting a real regression.
// Stay clear of realmd's lockout.
std::thread::sleep(Duration::from_secs(7));
}
}
@@ -402,8 +391,7 @@ fn main() -> Result<()> {
std::thread::sleep(Duration::from_secs(61)); // drain realmd's lockout window
window_failures = 0;
}
// `Want::Any` with one try: the first ephemeral is used whatever its shape, and whatever
// `B` the server sends is kept — an ordinary handshake.
// `Want::Any` with one try: an ordinary handshake, whatever the shapes of `A` and `B`.
match handshake(&host, &user, &pass, Enc::Fixed, Want::Any, 1) {
Ok((a, v)) => {
let short = a.short_values();
@@ -443,8 +431,8 @@ fn main() -> Result<()> {
}
println!("realmd at {host}:{AUTH_PORT}, account {user}");
// The `H(N)⊕H(g)` constant is hashed as a BigNumber too. It is fixed for WoW's N/g — if its high
// byte were zero, *every* fixed-width login would fail, not one in sixty.
// `H(N)⊕H(g)` is hashed as a BigNumber too; it is constant, so a zero high byte would fail
// every fixed-width login.
let xh = xor_hash(7, &benilla_srp::LARGE_SAFE_PRIME_LITTLE_ENDIAN);
println!(
"H(N)^H(g) high byte = {:#04x} (non-zero, so the constant is unambiguous)",
@@ -1,28 +1,16 @@
//! `version_check_probe` — the B241 instrument (decision 1263): does this realmd enforce
//! `StrictVersionCheck`, and does our logon proof satisfy it?
//! `version_check_probe`: whether a realmd enforces `StrictVersionCheck`, and whether our logon
//! proof satisfies it. It runs the handshake twice, differing only in the proof's `crc_hash`:
//! twenty zeros, which a strict realmd refuses with `WOW_FAIL_VERSION_INVALID` (0x09, shown as
//! `AUTH_VERSION_MISMATCH`, "Wrong client version"), and the computed `SHA1(A ‖ H)`
//! ([`auth::version_proof`]). A non-strict server accepts both.
//!
//! Runs the same handshake **twice against the same server**, differing only in the proof packet's
//! `crc_hash`:
//! With an install directory it also derives that install's own digest the way the 1.12 client
//! does, HMAC-SHA1 keyed by the challenge over five binaries, and says whether it is the stock
//! one. Deviation: benilla always sends the published constant instead, since an install without
//! those binaries could never answer for itself.
//!
//! - **zeros** — what benilla sent for its whole life before 1263. A strict realmd answers this with
//! `WOW_FAIL_VERSION_INVALID` (0x09) and logs *"tried to login with modified client!"*; 0x09 is the
//! code our login screen renders as `AUTH_VERSION_MISMATCH` / "Wrong client version", which is
//! exactly how B241 was reported.
//! - **computed** — `SHA1(A ‖ H)` with the integrity digest for our build and OS
//! ([`auth::version_proof`]).
//!
//! A non-strict server accepts both arms (it never looks), so the probe reports *what the server
//! enforces* as much as what we send. That is the point: it is the A/B that closes B241 and the one
//! to run against any server a strict-mode rejection is reported from.
//!
//! Give it an **install directory** as a fourth argument and it also derives that install's own
//! integrity digest the way the real client does — HMAC-SHA1 keyed by the challenge over five
//! binaries (decision 1265, mechanism in wow-re `system/net/scratch/logon-integrity-hash.md`) — and
//! says whether it is the stock one. Diagnostic only: benilla always sends the published constant,
//! so a patched `WoW.exe` shows up here without changing a single login.
//!
//! Usage: `cargo run -p benilla-protocol --example version_check_probe -- <host[:port]> <user> <pass> [install-dir]`
//! (the local strict-mode realmd of decision 1263 is `127.0.0.1:3725`; the ordinary one is 3724.)
//! Usage: `cargo run -p benilla-protocol --example version_check_probe -- <host[:port]> <user>
//! <pass> [install-dir]`. The local strict-mode realmd is `127.0.0.1:3725`, the ordinary one 3724.
use std::net::TcpStream;
use std::path::{Path, PathBuf};
@@ -36,10 +24,9 @@ use sha1::{Digest, Sha1};
/// Which `crc_hash` the arm puts in the proof packet.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Arm {
/// Twenty zeros — pre-1263 benilla. Forced by handing the writer a `crc_salt` we hold no digest
/// for, which is precisely the "no answer" path.
/// Twenty zeros, forced by handing the writer a `crc_salt` we hold no digest for.
Zeros,
/// `SHA1(A ‖ H)` for the salt the server actually sent.
/// `SHA1(A ‖ H)` for the salt the server sent.
Computed,
}
@@ -52,14 +39,13 @@ impl Arm {
}
}
/// One full handshake, sending `arm`'s `crc_hash`. `Ok(None)` = accepted, `Ok(Some(code))` = the
/// server's `WOW_FAIL_*` byte.
/// One full handshake with `arm`'s `crc_hash`: `None` when accepted, else the `WOW_FAIL_*` byte.
fn attempt(host: &str, port: u16, user: &str, pass: &str, arm: Arm) -> Result<Option<u8>> {
let user_n = NormalizedString::new(user).map_err(|e| anyhow!("invalid username: {e}"))?;
let pass_n = NormalizedString::new(pass).map_err(|e| anyhow!("invalid password: {e}"))?;
// Redial past an ambiguously-serialized `B` exactly as `logon` does, so a 0x04 here means a bad
// password and never an encoding coin-flip (see `benilla-srp`, "Encoding-unambiguous handshakes").
// Redial past a width-unstable `B`, as `logon` does, so a 0x04 here is a bad password and
// never the SHA-1 encoding disagreement.
for _ in 0..8 {
let mut stream = TcpStream::connect((host, port))
.with_context(|| format!("connecting to {host}:{port}"))?;
@@ -104,9 +90,8 @@ fn attempt(host: &str, port: u16, user: &str, pass: &str, arm: Arm) -> Result<Op
Err(anyhow!("eight dials, every `B` ambiguous — try again"))
}
/// The five files the real client hashes, **in this order** — `ChecksumExecutables` at `0x5b1170`,
/// walking the `char*` table at `0x85def0` (wow-re `system/net/scratch/logon-integrity-hash.md`).
/// Order is load-bearing: exactly 1 of the 120 permutations reproduces the published digest.
/// The five files the 1.12 client hashes, in this order (`ChecksumExecutables` at `0x5b1170`, the
/// table at `0x85def0`); no other of the 120 orders gives the published digest.
const SCANNED: [&str; 5] = [
"WoW.exe",
"fmod.dll",
@@ -115,9 +100,8 @@ const SCANNED: [&str; 5] = [
"unicows.dll",
];
/// HMAC-SHA1 with the 16-byte `crc_salt` as the key — the framing the client uses (`rep stos` of
/// 64×`0x36` / 64×`0x5c`, salt XORed into both, `0x5b117e`–`0x5b11b6`). No long-key shortening,
/// because the key is always the 16-byte salt; the client has no such step either.
/// HMAC-SHA1 keyed by the 16-byte `crc_salt`, framed as the client does (`0x5b117e`–`0x5b11b6`);
/// the key is always 16 bytes, so there is no long-key step, as in the client.
fn hmac_sha1(key: &[u8; 16], msg: &[u8]) -> [u8; 20] {
let mut ipad = [0x36u8; 64];
let mut opad = [0x5cu8; 64];
@@ -134,11 +118,8 @@ fn hmac_sha1(key: &[u8; 16], msg: &[u8]) -> [u8; 20] {
outer.finalize().into()
}
/// Derive `H` from a 1.12.1 install the way the real client does, and say what it means. Purely a
/// diagnostic: benilla sends the published constant for its build and OS (decision 1265), never this.
/// A file that cannot be read is **skipped**, contributing no bytes — the client's own behaviour
/// (four early-outs at `0x5b11eb`/`0x5b1212`/`0x5b121f`/`0x5b1246`), and the reason a Data-only
/// install could never answer this challenge for itself.
/// Derive `H` from an install the way the 1.12 client does. An unreadable file contributes no
/// bytes, as in the client (`0x5b11eb`, `0x5b1212`, `0x5b121f`, `0x5b1246`).
fn report_install(install_dir: &Path, crc_salt: &[u8; 16]) {
println!("\n install {}", install_dir.display());
let mut msg = Vec::new();
@@ -158,8 +139,7 @@ fn report_install(install_dir: &Path, crc_salt: &[u8; 16]) {
let h = hmac_sha1(crc_salt, &msg);
let hex: String = h.iter().map(|b| format!("{b:02X}")).collect();
println!(" H = {hex}");
// What we actually send is `SHA1(A ‖ H)`, so compare the digests through that: equal proofs for
// one `A` means equal `H`.
// Compare through `SHA1(A ‖ H)`, what we send: equal proofs for one `A` mean equal `H`.
let a = [0u8; 32];
let matches = {
let mut sha = Sha1::new();
@@ -196,8 +176,8 @@ fn main() -> Result<()> {
};
let (host, port) = host_port(&host_arg, AUTH_PORT);
// Report the challenge itself first: our digest is only valid for the one salt every mangos-family
// realmd sends, so a server issuing anything else explains a `zeros` result on the computed arm.
// Our digest holds only for the one salt every mangos-family realmd sends; under any other,
// the computed arm sends zeros too.
let mut stream =
TcpStream::connect((host, port)).with_context(|| format!("connecting to {host}:{port}"))?;
stream.set_read_timeout(Some(Duration::from_secs(10)))?;
+49 -114
View File
@@ -1,9 +1,6 @@
//! 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`.
//! The realmd (login) wire protocol, version 3 in 1.12.1: logon challenge, logon proof and realm
//! list, the three exchanges [`crate::logon`] performs. Login packets are not header-encrypted and
//! each is self-delimiting, so they are read in request/response order.
use std::io::{Read, Write};
use std::net::Ipv4Addr;
@@ -21,18 +18,15 @@ 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.
// Tags are little-endian u32s, so they reach the
// wire reversed (`0x0057696e` is `n i W \0`) and
// realmd reverses them back. vmangos fails a session
// on anything but "Win" or "OSX".
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.
/// The host's OS tag: `OSX` on macOS, else `Win`, as there was no 1.12 Linux client. Servers act
/// on it: vmangos picks `WardenWin` or `WardenMac`, and realmd checks the build (`FindBuildInfo`).
const fn client_os() -> u32 {
if cfg!(target_os = "macos") {
OS_MACOS
@@ -42,11 +36,9 @@ const fn client_os() -> u32 {
}
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.
/// A non-success auth result byte, typed so the app can map it to its `AUTH_*` glue string; it
/// survives [`crate::logon`]'s `anyhow` contexts via `downcast_ref`. vmangos answers an unknown
/// account and a wrong password alike with 0x04 (`WOW_FAIL_UNKNOWN_ACCOUNT`, `AuthCodes.h`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthReject {
/// The grunt result byte (`WOW_FAIL_*`).
@@ -67,13 +59,12 @@ pub struct ChallengeReply {
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.
/// The version challenge answered by `crc_hash` ([`version_proof`]); not an SRP6 input.
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).
/// Send `CMD_AUTH_LOGON_CHALLENGE_Client`. `account_name` must be uppercased, as the SRP6 hashes
/// use it; `build` is 5875.
pub fn write_logon_challenge(
w: &mut impl Write,
account_name: &str,
@@ -102,7 +93,7 @@ pub fn write_logon_challenge(
w.write_all(&packet)
}
/// Read the challenge reply, returning the SRP6 inputs (or erroring on a non-success result).
/// Read the challenge reply; a non-success result is an [`AuthReject`].
pub fn read_challenge_reply(r: &mut impl Read) -> Result<ChallengeReply> {
let opcode = read_u8(r)?;
if opcode != CMD_AUTH_LOGON_CHALLENGE {
@@ -129,14 +120,12 @@ pub fn read_challenge_reply(r: &mut impl Read) -> Result<ChallengeReply> {
.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.
// Read to the end: login packets are not length-framed, so leftover bytes desync the next
// read. `security_flag` and the PIN block are unused but must be consumed.
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.)
// PIN: pin_grid_seed (u32) + pin_salt[16]; vmangos always sends security_flag 0.
let _pin_grid_seed = read_u32_le(r)?;
let _pin_salt = read_array::<16>(r)?;
}
@@ -149,50 +138,34 @@ pub fn read_challenge_reply(r: &mut impl Read) -> Result<ChallengeReply> {
})
}
// --- the version (client-integrity) proof ---------------------------------------------------------
// --- 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 proof's `crc_hash` answers the challenge's `crc_salt`: the 1.12 client hashes its own
// binaries under that salt into a 20-byte digest `H` and sends `SHA1(A ‖ H)`. realmd recomputes it
// from a stored `H` (`AuthSocket::VerifyVersion`) and, under `StrictVersionCheck = 1` (the shipped
// `realmd.conf.example` value), refuses a mismatch with `WOW_FAIL_VERSION_INVALID` (0x09).
/// 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.
/// The `crc_salt` every mangos-family realmd sends: a constant, not a nonce (vmangos
/// `AuthSocket.cpp:65`, cmangos `AuthSocket.cpp:187`), so the answer is a per-build constant.
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.
/// `H` for the build 5875 Windows client under [`MANGOS_VERSION_CHALLENGE`]: vmangos's
/// `allowed_clients` row (`20221117065844_logon.sql`), identical to cmangos's `RealmList.cpp`.
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).
/// The same for the Mac client, sent on macOS because servers pick `H` by the OS tag.
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.)
/// The integrity digest `H` for `crc_salt`, known only for [`MANGOS_VERSION_CHALLENGE`].
/// Deviation: a stored constant, not a hash of the install, which may lack the five binaries.
fn integrity_hash(crc_salt: &[u8; 16]) -> Option<[u8; 20]> {
if *crc_salt != MANGOS_VERSION_CHALLENGE {
return None;
@@ -204,9 +177,8 @@ fn integrity_hash(crc_salt: &[u8; 16]) -> Option<[u8; 20]> {
})
}
/// 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.
/// The proof's `crc_hash`: `SHA1(A ‖ H)` over `A`'s wire bytes (realmd hashes `lp->A` as
/// received), or twenty zeros for an unknown salt, which only a strict server refuses.
pub fn version_proof(crc_salt: &[u8; 16], client_public_key: &[u8; 32]) -> [u8; 20] {
match integrity_hash(crc_salt) {
Some(h) => {
@@ -219,9 +191,7 @@ pub fn version_proof(crc_salt: &[u8; 16], client_public_key: &[u8; 32]) -> [u8;
}
}
/// 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.
/// Send `CMD_AUTH_LOGON_PROOF_Client`, computing `crc_hash` from the challenge's `crc_salt`.
pub fn write_logon_proof(
w: &mut impl Write,
client_public_key: &[u8; 32],
@@ -238,7 +208,7 @@ pub fn write_logon_proof(
w.write_all(&packet)
}
/// Read the proof reply, returning the server's proof `M2` (or erroring on a non-success result).
/// Read the proof reply: the server's `M2`, or an [`AuthReject`].
pub fn read_proof_reply(r: &mut impl Read) -> Result<[u8; 20]> {
let opcode = read_u8(r)?;
if opcode != CMD_AUTH_LOGON_PROOF {
@@ -246,7 +216,7 @@ pub fn read_proof_reply(r: &mut impl Read) -> Result<[u8; 20]> {
}
let result = read_u8(r)?;
if result != 0 {
// A wrong password fails HERE (the server can't verify M1): vmangos answers 0x04.
// A wrong password fails here, where `M1` does not verify: vmangos answers 0x04.
return Err(AuthReject { code: result }.into());
}
let server_proof = read_array::<20>(r)?;
@@ -261,32 +231,18 @@ pub fn write_realm_list_request(w: &mut impl Write) -> std::io::Result<()> {
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`).
/// The magic populations the 1.12 realm-list parser (`0x5b2230`) rewrites, as `(population sent,
/// population rewritten, flag OR'd)`: Recommended, New and Full travel as these exact float bit
/// patterns, not as wire flags.
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
(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.
/// Read `CMD_REALM_LIST_Server` into the advertised realms, rewriting [`MAGIC_POPULATIONS`] here
/// as the reference parser does: the realm-list screen averages every realm's population, and an
/// unswapped Recommended `600.0` would skew that mean for every row.
pub fn read_realm_list(r: &mut impl Read) -> Result<Vec<RealmInfo>> {
let opcode = read_u8(r)?;
if opcode != CMD_REALM_LIST {
@@ -324,7 +280,7 @@ pub fn read_realm_list(r: &mut impl Read) -> Result<Vec<RealmInfo>> {
id,
});
}
let _footer_padding = read_u16_le(r)?; // consume so the stream stays aligned (see challenge reply)
let _footer_padding = read_u16_le(r)?; // consume so the stream stays aligned
Ok(realms)
}
@@ -332,9 +288,6 @@ pub fn read_realm_list(r: &mut impl Read) -> Result<Vec<RealmInfo>> {
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| {
@@ -347,7 +300,6 @@ mod tests {
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") {
@@ -362,10 +314,7 @@ mod tests {
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.
/// Expected digests computed outside this crate (Python `hashlib`) as `SHA1(A ‖ H)`.
#[test]
fn version_proof_matches_the_realmd_expression() {
let expected = if cfg!(target_os = "macos") {
@@ -380,7 +329,6 @@ mod tests {
);
}
/// 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;
@@ -388,8 +336,7 @@ mod tests {
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.
/// `opcode · A[32] · M1[20] · crc_hash[20] · num_keys · security_flag`, 75 bytes.
#[test]
fn the_proof_packet_carries_the_version_proof() {
let a = test_public_key();
@@ -408,12 +355,6 @@ mod tests {
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();
@@ -446,13 +387,7 @@ mod tests {
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.
/// The rewritten values are the reference parser's own, bit for bit.
#[test]
fn the_three_magic_populations_become_flags_and_are_rewritten() {
let one = |pop: f32| {
@@ -1,4 +1,4 @@
//! `benilla-auth` — Phase 3 CLI: SRP6 logon against a vanilla realmd, print the realm list.
//! `benilla-auth`: SRP6 logon against a vanilla realmd, printing the realm list.
//!
//! Example: `cargo run --bin benilla-auth -- one pone localhost`
@@ -32,9 +32,7 @@ fn main() -> Result<()> {
println!("no realms advertised");
}
for (i, realm) in logon.realms.iter().enumerate() {
// Every field the wire carries: the three that used to be dropped (flags, category,
// realm id) are what the realm-list screen greys rows out and groups tabs by, and the
// population is the raw float the load band is computed FROM, not the word it shows.
// Every field the wire carries; the population is the raw float the load band comes from.
println!(
"[Realm {}] {} @ {} — type {} flags {:#04x} pop {} chars {} category {} id {}",
i + 1,
@@ -1,11 +1,10 @@
//! `benilla-world` — Phase 4 CLI: log in to realmd, connect to the world server (`mangosd`), enter the
//! world as a character, and stream/tally the object updates the server pushes.
//! `benilla-world`: logs in to realmd, enters the world server as a character, and tallies the
//! updates it pushes; the `--<probe>` flags live-verify one wire each.
//!
//! Example: `cargo run -p benilla-protocol --bin benilla-world -- one pone localhost`
//!
//! If the account has no character yet, one is created (`--create <name>`, Human Warrior) so
//! `CMSG_PLAYER_LOGIN` has something to log in. The realmd host is a positional arg; the world server
//! address defaults to whatever the realm list advertises (override with `--world`).
//! An account with no character gets a Human Warrior (`--create <name>`). The world address
//! defaults to the realm list's (`--world` overrides).
mod probes;
mod world;
@@ -48,228 +47,135 @@ struct Cli {
/// How many seconds to stream packets after entering the world.
#[arg(long, default_value_t = 10)]
seconds: u64,
/// After streaming, walk this many yards forward, then log out and re-enum to confirm the server
/// persisted the new position. Omit to just observe (read-only).
/// After streaming, walk this many yards forward, log out, and check the server saved the move.
#[arg(long)]
walk: Option<f32>,
/// Live-verify the name-query pair: ask our own name (`CMSG_NAME_QUERY`) and the first streamed
/// creature's template name (`CMSG_CREATURE_QUERY`, entry from its guid), and require both answers
/// to arrive and parse.
/// Live-verify `CMSG_NAME_QUERY` (our own name) and `CMSG_CREATURE_QUERY` (the first creature).
#[arg(long)]
query_names: bool,
/// Live-verify the spell/action wire: require `SMSG_INITIAL_SPELLS` + `SMSG_ACTION_BUTTONS` to
/// arrive and parse at login, then send one `CMSG_CAST_SPELL` (Battle Shout 6673 if known, else
/// the first known spell) and require an `SMSG_CAST_RESULT` verdict (ok *or* a failure reason —
/// either proves the round trip).
/// Live-verify `SMSG_INITIAL_SPELLS` and `SMSG_ACTION_BUTTONS` at login, then require an
/// `SMSG_CAST_RESULT` for a self cast, a ground cast and a targeted cast.
#[arg(long)]
spells: bool,
/// Capture the dest-anchored effect wire for a ground cast of this spell id (the B132
/// follow-up instrument): GM-learn it + GM-fill mana, cast at own feet (mask 0x40), and dump
/// every DynamicObject create raw (labeled `DYNAMICOBJECT_*` fields), the SPELL_GO, and the
/// removal edge with its lifetime. Pair with `--seconds 25`+ so a channel + its object's whole
/// life fits the window. Needs a GM account (the deploy's probes are gmlevel 6).
/// Capture a ground cast of this spell id at our feet (target mask 0x40): every DynamicObject
/// create, the SPELL_GO and the removal. Needs GM; pair with `--seconds 25`.
#[arg(long)]
groundfx: Option<u32>,
/// Live-verify the melee-swing wire (decision 0073): GM-teleport (`.go xyz`) onto a Northshire
/// Kobold Vermin spawn, `CMSG_ATTACKSWING` the nearest streamed creature, and require ≥1
/// `SMSG_ATTACKERSTATEUPDATE` to arrive and decode (attacker/victim/hitInfo/damage). Needs a GM
/// account (the deploy's probes are gmlevel 6).
/// Live-verify melee: teleport to the Northshire kobolds, `CMSG_ATTACKSWING` the nearest
/// creature and require an `SMSG_ATTACKERSTATEUPDATE`. Needs GM.
#[arg(long)]
attack: bool,
/// Live-verify `CMSG_USE_ITEM`: use the item in this 1-based backpack slot (the wire's bag
/// 255 with player-array slot 23…) and require the server to react — a stack-count values
/// delta or a destroy on that item's guid (consumed), or an explicit cast-result refusal.
/// Live-verify `CMSG_USE_ITEM` on this 1-based backpack slot (wire bag 255, slot 22 + n):
/// require a stack delta, a destroy, or a cast-result refusal.
#[arg(long)]
use_pack_slot: Option<u8>,
/// Live-verify `CMSG_OPEN_ITEM`: `.additem` a Small Barnacled Clam (entry 7973 — LOOTABLE,
/// LockID 0, the director's own case), find it in the backpack, and run the fork a bag
/// right-click makes for an *openable* item — `CMSG_OPEN_ITEM(bagIndex, slot)`, never
/// `CMSG_USE_ITEM` (a clam has no on-use spell, so the use goes nowhere) — requiring
/// `SMSG_LOOT_RESPONSE` on the **item's own guid**. Releases the window and subtracts the copy
/// afterwards. Needs a GM account (the deploy's probes are gmlevel 6).
/// Live-verify `CMSG_OPEN_ITEM` on an added Small Barnacled Clam (7973, lootable, no lock),
/// requiring `SMSG_LOOT_RESPONSE` on the item's own guid. A bag right-click on an openable
/// item sends this, never `CMSG_USE_ITEM`. Needs GM.
#[arg(long)]
open_item: bool,
/// Send a `/say` line right after entering the world — the GM dot-command channel
/// (`.additem 3732`, `.repairitems`, …) for setting up probe scenarios. Repeatable; the lines go
/// out in order (e.g. `--say ".modify money 5000000" --say ".go creature 1"`).
/// Say this line after entering the world, e.g. a GM dot-command; repeatable, sent in order.
#[arg(long)]
say: Vec<String>,
/// Live-verify `CMSG_AUTOEQUIP_ITEM`: equip the item in this 1-based backpack slot and
/// require the server to react — a self values delta landing that item's guid in an
/// equipment INV slot, or a decoded `SMSG_INVENTORY_CHANGE_FAILURE` refusal.
/// Live-verify `CMSG_AUTOEQUIP_ITEM` on this 1-based backpack slot: require the item in an
/// equipment slot or an `SMSG_INVENTORY_CHANGE_FAILURE`.
#[arg(long)]
equip_pack_slot: Option<u8>,
/// Live-verify `CMSG_SWAP_INV_ITEM` (the backpack pick/place/swap wire): swap the two 1-based
/// backpack slots `A:B` (the wire's bag 255, player-array slots 23+A-1 / 23+B-1), await the
/// self values delta, and assert the two slots' item guids exchanged — then swap back and assert
/// the original layout is restored (leaves the character exactly as found). Slot A must be
/// occupied; B may be empty (an empty destination is a move on this wire).
/// Live-verify `CMSG_SWAP_INV_ITEM` on 1-based backpack slots `A:B`, then swap back. A must be
/// occupied; an empty B makes it a move.
#[arg(long, value_parser = parse_slot_pair)]
swap_pack_slots: Option<(u8, u8)>,
/// Live-verify the vendor wire (decision 0081 phase 4): auto-find the nearest streamed
/// vendor NPC (`UNIT_NPC_FLAG_VENDOR`), `CMSG_LIST_INVENTORY` it and require an
/// `SMSG_LIST_INVENTORY` to arrive and parse (N rows), then `CMSG_BUY_ITEM` the cheapest row and
/// require a reaction — the purchased item arriving (`ItemCreate`/values), the vendor stock
/// updating (`SMSG_BUY_ITEM`), *or* a decoded `SMSG_BUY_FAILED` (all prove the round trip). Also
/// reads our `PLAYER_FIELD_COINAGE` (the money accessor) at login and confirms any coinage delta
/// lands on that field. No GM needed — it uses whatever vendor streams in range.
/// Live-verify the vendor wire on the nearest vendor: `CMSG_LIST_INVENTORY`, then buy the
/// cheapest row and require the item, a stock update or `SMSG_BUY_FAILED`. No GM needed.
#[arg(long)]
vendor: bool,
/// Live-verify the solo-loot wire (decision 0084 §1): select a target (nearest streamed
/// creature within range of the `--attack` teleport spot, or `--loot-guid` if given),
/// GM-kill it (`.damage 10000`), wait for `UNIT_DYNFLAG_LOOTABLE`, then `CMSG_LOOT` it,
/// require `SMSG_LOOT_RESPONSE` to arrive and parse, `CMSG_AUTOSTORE_LOOT_ITEM` every row,
/// `CMSG_LOOT_MONEY` if it carried gold, and `CMSG_LOOT_RELEASE` — requiring
/// `SMSG_LOOT_RELEASE_RESPONSE` to close it. Prints every loot-related packet decoded. Needs a
/// GM account (the deploy's probes are gmlevel 6).
/// Live-verify solo loot: GM-kill the nearest creature (or `--loot-guid`), loot every row and
/// the money, and require `SMSG_LOOT_RELEASE_RESPONSE`. Needs GM.
#[arg(long)]
loot: bool,
/// Skip the nearest-creature search for `--loot` and loot this guid directly (decimal or
/// `0x`-prefixed hex).
/// Loot this guid for `--loot` (decimal or `0x` hex) instead of the nearest creature.
#[arg(long, value_parser = parse_guid)]
loot_guid: Option<u64>,
/// Live-verify the questgiver wire (decision 0088): GM-teleport onto Marshal McBride (Northshire,
/// entry 197), `CMSG_GOSSIP_HELLO` him, `CMSG_QUESTGIVER_QUERY_QUEST` the target quest and require
/// `SMSG_QUESTGIVER_QUEST_DETAILS` to parse, `CMSG_QUESTGIVER_ACCEPT_QUEST` it and confirm the
/// quest id lands in the player descriptor's `PLAYER_QUEST_LOG` fields, GM-complete it
/// (`.quest complete`), then `CMSG_QUESTGIVER_COMPLETE_QUEST` → `_REQUEST_REWARD` → `_CHOOSE_REWARD`
/// requiring `SMSG_QUESTGIVER_QUEST_COMPLETE` (XP/money) + a `PLAYER_FIELD_COINAGE` delta. Uses
/// quest 7 "Kobold Camp Cleanup" (McBride gives + takes it; XP 170, money 25c). Needs a GM
/// account (the deploy's probes are gmlevel 6).
/// Live-verify the questgiver wire with quest 783, from Deputy Willem (823) to Marshal McBride
/// (197): details, accept, GM-complete, reward, requiring `SMSG_QUESTGIVER_QUEST_COMPLETE` and
/// an XP delta. Needs GM.
#[arg(long)]
quest: bool,
/// Live-verify the quest-LOG wire (decision 0109 — the questgiver wire's deferred second
/// slice): GM-teleport onto Marshal McBride (entry 197 — gives *and* takes quest 7 "Kobold
/// Camp Cleanup", VERIFIED live against `mangos.creature_questrelation` /
/// `creature_involvedrelation`, both rows pointing at 197; objective is 10× creature entry 6,
/// `quest_template.ReqCreatureOrGOId1/Count1`), accept quest 7, `CMSG_QUEST_QUERY` it and
/// require `SMSG_QUEST_QUERY_RESPONSE` to parse into the title plus a real
/// (`required_count > 0`) objective — the fat template parser's live golden, distinct from the
/// giver-panel `SMSG_QUESTGIVER_QUEST_DETAILS` [`Self::quest`] already exercises. Then poll the
/// player descriptor for the `PLAYER_QUEST_LOG` slot the accept landed in, GM-complete it
/// (`.quest complete 7`) and require the slot's count-state field to gain the `COMPLETE` state
/// byte, then `CMSG_QUESTLOG_REMOVE_QUEST` that slot and require its id field to clear to `0`
/// (no ack SMSG on this wire — the field clear *is* the confirmation). Needs a GM account (the
/// deploy's probes are gmlevel 6).
/// Live-verify the quest log with quest 7 (10 kills of entry 6, from Marshal McBride):
/// `CMSG_QUEST_QUERY` must parse a real objective, `.quest complete 7` must set the slot's
/// `COMPLETE` byte, and `CMSG_QUESTLOG_REMOVE_QUEST` must clear the slot, which has no ack.
/// Needs GM.
#[arg(long)]
questlog: bool,
/// Live-verify the timed-quest COUNTDOWN chain (decision 1150, B234): GM-add a quest with a
/// `LimitTime`, read its `PLAYER_QUEST_LOG` slot's raw timer field (an absolute unix stamp,
/// not a duration), ask `CMSG_QUERY_TIME` for the server's own clock, and require the
/// subtraction to land inside the template's own `limit_time` window. The one leg no offline
/// test can cover: two independent packets whose epochs must agree. Needs a GM account (the
/// deploy's probes are gmlevel 6).
/// Live-verify a timed quest: its `PLAYER_QUEST_LOG` timer is an absolute unix stamp, and
/// minus `CMSG_QUERY_TIME`'s server clock it must fall inside the template's `limit_time`.
/// Needs GM.
#[arg(long)]
questtimer: bool,
/// Live-verify the quest-STARTER item wire (decision 0664): `.additem` the Northshire Gift
/// Voucher (entry 14646, starts quest 5805 "Welcome!"), find it in the backpack, and run the
/// fork a bag right-click makes for an item whose template carries a non-zero `StartQuest` —
/// `CMSG_QUESTGIVER_QUERY_QUEST` addressed to the **item's own guid** (never `CMSG_USE_ITEM`,
/// which the server refuses with `EQUIP_ERR_ITEM_NOT_FOUND`, the red "The item was not found."
/// line) — requiring `SMSG_QUESTGIVER_QUEST_DETAILS`, then `CMSG_QUESTGIVER_ACCEPT_QUEST` on
/// the same guid, requiring BOTH the quest id landing in `PLAYER_QUEST_LOG` and the starter
/// item being destroyed. Cleans up after itself (`.quest remove`). Needs a GM account (the
/// deploy's probes are gmlevel 6).
/// Live-verify a quest-starter item, the Northshire Gift Voucher (14646, quest 5805): a bag
/// right-click sends `CMSG_QUESTGIVER_QUERY_QUEST` to the item's guid, never `CMSG_USE_ITEM`
/// (refused with `EQUIP_ERR_ITEM_NOT_FOUND`); accepting must log the quest and keep the item,
/// which 5805 requires. Needs GM.
#[arg(long)]
quest_item: bool,
/// Live-verify the force-speed-change wire: GM `.modify speed 1.5` (self-targeted), require
/// `SMSG_FORCE_RUN_SPEED_CHANGE` to arrive and parse (flat speed 10.5 = 1.5 × the 7.0 base),
/// ack it (`CMSG_FORCE_RUN_SPEED_CHANGE_ACK` echoing counter + exact speed with our live pose),
/// then `.modify speed 1` and require a SECOND change (counter incremented, speed 7.0) on a
/// still-live stream. A malformed ack body throws in the server's parser and drops the session
/// (the `--charge` precedent), so survival through both round trips is the wire proof. Needs a
/// GM account (the deploy's probes are gmlevel 6).
/// Live-verify `SMSG_FORCE_RUN_SPEED_CHANGE` via `.modify speed 1.5` (10.5 = 1.5 × 7.0) and
/// back to 1, acking each. A malformed ack drops the session, so surviving both is the proof.
/// Needs GM.
#[arg(long)]
speed: bool,
/// Live-verify what the server sends when a teleport DISMOUNTS you (B213, decision 1478):
/// `.aura 458` (Brown Horse — a real `SPELL_AURA_MOUNTED` holder, unlike `.modify mount`),
/// require the mounted `SMSG_FORCE_RUN_SPEED_CHANGE`, then `.go xyz` into Ragefire Chasm
/// (map 389 — a dungeon, so `MapEntry::IsMountAllowed()` is false) and require, in this order:
/// `SMSG_NEW_WORLD`, our own create block on the new map **still carrying the mount's** run
/// speed (vmangos sends it from `Map::Add` → `SendInitSelf`), and then the strip's
/// `SMSG_FORCE_RUN_SPEED_CHANGE` back at 7.0 — printing the gap between the two. Both are
/// written by one `HandleMoveWorldportAckOpcode` call, which is why a client draining its
/// socket once a frame sees them in a single drain. Leaves the character unmounted and back on
/// map 0. Needs a GM account (the deploy's probes are gmlevel 6); pair with `--seconds 30`+.
/// Live-verify a dismounting teleport: `.aura 458` (Brown Horse, a real mount aura, unlike
/// `.modify mount`), then `.go xyz` into Ragefire Chasm (map 389, no mounts). Requires
/// `SMSG_NEW_WORLD`, our create block still at mounted speed, then
/// `SMSG_FORCE_RUN_SPEED_CHANGE` back to 7.0, both written by one
/// `HandleMoveWorldportAckOpcode`. Needs GM; pair with `--seconds 30`.
#[arg(long)]
mount_tele: bool,
/// Live-verify the aura wire (decision 0255 phase 1): GM-apply Mark of the Wild (1126, a
/// cancelable buff) and Shadow Word: Pain (589, a periodic-damage DoT, unambiguously negative)
/// to ourselves with explicit durations via `.aura`, then require — from the *live* descriptor
/// — that both land in `UNIT_FIELD_AURA` (proving the field index), in the correct half (buffs
/// 0–31, debuffs 32–47), with the `AURAFLAGS` cancelable nibble bit set only on the buff, the
/// `AURALEVELS` byte equal to our own level, and a stack of 1 (the `count - 1` wire bias). Also
/// requires an `SMSG_UPDATE_AURA_DURATION` for each aura's own slot carrying the duration we
/// asked for, and asserts it arrived **before** the descriptor delta that names the slot (the
/// ordering the aura model depends on). Leaves the character as found (`.unaura` both). Needs a
/// GM account (the deploy's probes are gmlevel 6).
/// Live-verify auras: `.aura` Mark of the Wild (1126) and Shadow Word: Pain (589), requiring
/// each in `UNIT_FIELD_AURA` (buffs 0–31, debuffs 32–47) with the cancelable flag on the buff
/// only, our level, a stack of 1 (wire `count - 1`), and its `SMSG_UPDATE_AURA_DURATION`
/// before the descriptor delta. Needs GM.
#[arg(long)]
aura: bool,
/// Live-verify the questgiver STATUS wire (the overhead `!`/`?` markers' data):
/// teleport onto Marshal McBride, `CMSG_QUESTGIVER_STATUS_QUERY` his guid, and require an
/// `SMSG_QUESTGIVER_STATUS` answer for him — printing the dialog status BEFORE and AFTER a
/// `.quest remove 7` (unaccepted quest 7 should read AVAILABLE=5). Needs a GM account.
/// Live-verify `SMSG_QUESTGIVER_STATUS` for Marshal McBride after `.quest remove 7`, so an
/// unaccepted quest 7 reads AVAILABLE (5). Needs GM.
#[arg(long)]
giverstatus: bool,
/// Live-verify the world-state table's two wires (`SMSG_INIT_WORLD_STATES` /
/// `SMSG_UPDATE_WORLD_STATE` — what the NPC-text `$<n>w`/`$<n>e` tokens read): teleport to
/// Elwynn, hop to Stormwind to force a zone-change init, and `.debug send worldstate` one
/// synthetic pair to require back. Prints every state received. Needs a **SEC_DEVELOPER**
/// account: `.debug send worldstate` is gmlevel 5, under the 6 the probe accounts
/// carry (decision 0450), so the update leg needs a temporary grant.
/// Live-verify `SMSG_INIT_WORLD_STATES` (a zone change into Stormwind) and
/// `SMSG_UPDATE_WORLD_STATE` (`.debug send worldstate`, which needs gmlevel 5).
#[arg(long)]
worldstate: bool,
/// Live-verify the Charge wire (warrior Charge rank 1, spell 100): GM `.learn 100`, teleport to
/// open ground near the Northshire kobold camp ([`CHARGE_TP`]), pick a creature at charge range
/// (8–25 yd), `CMSG_SET_SELECTION` + `CMSG_CAST_SPELL` 100 at it — then require an
/// `SMSG_MONSTER_MOVE` addressed to **our own guid**, proving Charge drives the caster through
/// the same server spline machinery as any creature (not a teleport / not a knockback). Prints
/// the self spline in full (facing kind, duration, waypoints, flying bit) so the ride + the
/// `CMSG_MOVE_SPLINE_DONE` ack it obliges can be built from the real numbers. Needs a GM warrior
/// (any probe account, gmlevel 6).
/// Live-verify Charge (spell 100): charge a creature 8–25 yd away and require an
/// `SMSG_MONSTER_MOVE` for our own guid, the same server spline as any creature's; prints it
/// in full. Needs a GM warrior.
#[arg(long)]
charge: bool,
/// Live-verify the death-arc slice-1 wire (decision 0308): GM-kill self (`.die`), require the
/// death signals (health force-flushed to 0, the death root), release
/// (`CMSG_REPOP_REQUEST`), require the full ghost transition — unroot, the water-walk grant,
/// `SMSG_CORPSE_RECLAIM_DELAY`, `PLAYER_FLAGS_GHOST`, the corpse object streaming in, the
/// graveyard teleport — query the corpse (`MSG_CORPSE_QUERY`), then GM-revive (`.revive`) to
/// leave the character alive. Recommend `--seconds 45`: the `.die`→repop→revive round trip
/// itself completes in ~10s, but the reclaim delay is only ever observed as a packet value
/// (never awaited), and the slack keeps a slow local server from truncating the ghost phase.
/// Needs a GM account (the deploy's probes are gmlevel 6).
/// Live-verify death: `.die`, release, then the ghost transition (unroot, water walk,
/// `SMSG_CORPSE_RECLAIM_DELAY`, `PLAYER_FLAGS_GHOST`, the corpse, the graveyard teleport),
/// `MSG_CORPSE_QUERY` and `.revive`. Pair with `--seconds 45`. Needs GM.
#[arg(long)]
death: bool,
/// Live-verify the self-resurrect wire (decision 1746): arm a shaman's Reincarnation with GM
/// commands (`.cooldown`, `.learn 20608`, `.additem 17030` — vmangos's gate asks no class
/// question), die and **stay dead-unreleased** (the DEATH dialog's state, where the soulstone
/// button lives), require `PLAYER_SELF_RES_SPELL` to arrive carrying the effect spell 21169,
/// send `CMSG_SELF_RES`, and require both that the field clears and that we stand back up
/// without ever having been a ghost. Recommend `--seconds 30`. Needs a GM account; leaves the
/// character alive. Mutually exclusive with `--death`/`--spirit`, which release the spirit.
/// Live-verify `CMSG_SELF_RES`: arm Reincarnation (`.learn 20608`, `.additem 17030`; vmangos
/// checks no class), die unreleased, require `PLAYER_SELF_RES_SPELL` 21169, then stand up
/// without becoming a ghost. Pair with `--seconds 30`. Needs GM; excludes `--death` and
/// `--spirit`, which release.
#[arg(long)]
self_res: bool,
/// Live-verify the spirit-healer res + the 25% durability loss's wire (director-reported:
/// "durability still 100% after spirit-healer rez"): GM-repair to a full baseline, die and
/// release (the --death staging), teleport onto the graveyard's Spirit Healer, send
/// `CMSG_SPIRIT_HEALER_ACTIVATE`, and require BOTH the res (the ghost flag clears) and the
/// post-activate `ITEM_FIELD_DURABILITY` values-deltas the loss must push — the exact packets
/// the app's tooltip line feeds from. Recommend `--seconds 45`. Needs a GM account; leaves the
/// character alive and re-repaired.
/// Live-verify the spirit-healer res: die, release, `CMSG_SPIRIT_HEALER_ACTIVATE`, requiring
/// the ghost flag to clear and the 25% durability loss as `ITEM_FIELD_DURABILITY` deltas.
/// Pair with `--seconds 45`. Needs GM.
#[arg(long)]
spirit: bool,
}
/// Parse a `--loot-guid` value: decimal, or `0x`-prefixed hex (as `benilla-world`'s own `guid
/// {:#x}` printouts read out).
/// Parse a `--loot-guid` value: decimal, or `0x`-prefixed hex.
fn parse_guid(s: &str) -> Result<u64, String> {
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
u64::from_str_radix(hex, 16).map_err(|e| e.to_string())
@@ -294,12 +200,11 @@ fn parse_slot_pair(s: &str) -> Result<(u8, u8), String> {
fn main() -> Result<()> {
let cli = Cli::parse();
// 1. realmd logon → session key + realm list (Phase 3).
// 1. realmd logon: session key and realm list.
let logon = benilla_protocol::logon(&cli.host, &cli.username, &cli.password)?;
println!("authenticated as '{}'", cli.username);
// 2. Resolve the world address: explicit override, else the first advertised realm, else fall
// back to the auth host on the default world port.
// 2. The world address: the override, else the first realm's, else the auth host's.
let world_addr = match (&cli.world, logon.realms.first()) {
(Some(addr), _) => addr.clone(),
(None, Some(realm)) => {
@@ -345,29 +250,26 @@ fn main() -> Result<()> {
character.name, character.guid, character.level, character.map,
);
// 5. Enter the world, then claim movement control (vmangos ignores MSG_MOVE_* until we're the
// confirmed mover — the real client sends CMSG_SET_ACTIVE_MOVER on login).
// 5. Enter the world and claim the mover: vmangos ignores `MSG_MOVE_*` until then, and the
// 1.12 client sends `CMSG_SET_ACTIVE_MOVER` on login.
session.player_login(character.guid)?;
session.set_active_mover(character.guid)?;
// 6. Stream packets, tallying opcodes and folding the decoded `SessionEvent`s into a local entity
// map so we can read nearby entities' positions out of the stream.
// 6. Stream packets, tallying opcodes and tracking entity positions.
session.set_read_timeout(Some(Duration::from_secs(2)))?;
let deadline = Instant::now() + Duration::from_secs(cli.seconds);
// The shared world state every probe reads (identity, entity tracker, item/vendor stores, the
// session-keeping acks); the DeathArc scenario machinery is staged when --death/--spirit is set.
// The world state every probe reads; the death arc is staged for the three death probes.
let mut world = World::new(character);
if cli.death || cli.spirit || cli.self_res {
world.death_arc = Some(DeathArc {
// --self-res tests the dead-UNRELEASED state, so the arc must not repop on its own.
// --self-res needs the dead, unreleased state, so the arc must not repop.
hold_release: cli.self_res,
..DeathArc::default()
});
}
// The probe registry: one entry per flag, in today's stream-loop/verify block order (so poll,
// on_event, verify, and output order are preserved). Adding a probe is one push here.
// One probe per flag; registry order is the poll, on_event, verify and output order.
let mut probes: Vec<Box<dyn Probe>> = Vec::new();
if cli.attack {
probes.push(Box::new(Attack::default()));
@@ -441,8 +343,8 @@ fn main() -> Result<()> {
probes.push(Box::new(WorldState::default()));
}
// Pre-stream staging: the DeathArc first (its `.revive` + teleport lead --spirit's `.repairitems`),
// then every probe's stage in registry order, then the `--say` lines (after all staging, as today).
// Staging order: the death arc (its `.revive` and teleport precede --spirit's `.repairitems`),
// each probe in registry order, then the `--say` lines.
world.stage(&mut session)?;
for probe in probes.iter_mut() {
let mut cx = Ctx {
@@ -494,10 +396,7 @@ fn main() -> Result<()> {
// Report the entities we decoded, with raw WoW coordinates.
let self_guid = world.self_guid;
println!("\n--- tracked {} entit(ies) ---", world.tracked.len());
// Every `EntityKind`, or the dump silently drops a whole class of streamed object. `Corpse`
// was missing from here since 1706 gave TYPEID_CORPSE its own variant: a hardcoded list is not
// a `match`, so nothing warned, and a streamed body simply never appeared in the census (found
// by 1723, alongside the same omission in the --death corpse capture).
// Every `EntityKind`: this list is not a `match`, so a new variant must be added by hand.
for kind in [
EntityKind::Player,
EntityKind::Unit,
@@ -553,9 +452,8 @@ fn main() -> Result<()> {
probe.verify(&mut cx)?;
}
// 7. Optional: prove we can drive our own movement server-side. Walk forward, log out (which
// saves the character), then re-enum and compare the persisted position. Terminal — it ends
// the session, so it runs last.
// 7. Optional walk, then log out (which saves the character) and compare the saved position.
// It ends the session, so it runs last.
if let Some(yards) = cli.walk {
let self_entity = world.tracked.get(&self_guid);
let start = self_entity.map(|t| t.position).unwrap_or(world.spawn_pos);
@@ -609,9 +507,8 @@ fn main() -> Result<()> {
Ok(())
}
/// Walk the active player `yards` forward along `orientation`, sending a realistic
/// start→heartbeat→stop sequence at run speed, and return the destination. WoW orientation 0 faces
/// +X; forward is `(cos o, sin o, 0)`. Coordinates are raw WoW yards.
/// Walk `yards` forward as start, heartbeats and stop at run speed, returning the destination.
/// Orientation 0 faces +X, so forward is `(cos o, sin o, 0)`, in raw WoW yards.
fn walk_forward(
session: &mut WorldSession,
start: [f32; 3],
@@ -1,5 +1,5 @@
//! `--attack`: melee-swing wire (decision 0073). GM-teleport onto a Northshire Kobold Vermin, swing
//! the nearest creature, require ≥1 `SMSG_ATTACKERSTATEUPDATE`.
//! `--attack`: the melee-swing wire. Teleports onto a Northshire Kobold Vermin, swings at the
//! nearest creature and requires an `SMSG_ATTACKERSTATEUPDATE`.
use anyhow::{bail, Context, Result};
use benilla_protocol::{guid, EntityKind};
@@ -14,9 +14,7 @@ pub(crate) struct Attack {
impl Probe for Attack {
fn stage(&mut self, cx: &mut Ctx) -> Result<()> {
// --loot reuses this teleport spot to guarantee a killable creature in range when no
// explicit --loot-guid was given; avoid sending it twice if both flags are set (the shared
// `attack_tp_staged` flag — attack/loot/DeathArc all key off it).
// --loot and the death arc share this teleport; `attack_tp_staged` sends it once.
if !cx.world.attack_tp_staged {
cx.session.send_chat(ATTACK_TP)?;
cx.world.attack_tp_staged = true;
@@ -26,9 +24,7 @@ impl Probe for Attack {
}
fn poll(&mut self, cx: &mut Ctx) -> Result<()> {
// Swing at the nearest creature once we've landed (the port repopulates `tracked` around the
// kobold spawn; anything within 20 yd of the landing point is a candidate — the nearest is
// the kobold we landed on, in melee reach).
// Once landed, swing at the nearest creature within 20 yd: the kobold we stand on.
if self.attack_target.is_none() {
if let Some(pos) = cx.world.attack_pos {
let nearest = cx
@@ -54,8 +50,6 @@ impl Probe for Attack {
}
fn verify(&mut self, cx: &mut Ctx) -> Result<()> {
// --attack verdict: the port must have landed, the swing gone out, and at least one completed
// swing decoded — the full decision-0073 trigger chain against the live server.
if cx.world.attack_pos.is_none() {
bail!("--attack: the GM teleport never arrived (is the account gmlevel ≥ 2?)");
}
@@ -67,11 +61,8 @@ impl Probe for Attack {
bail!("--attack: swung at {target:#x} but no SMSG_ATTACKERSTATEUPDATE decoded");
}
println!("✅ attack: {swings_seen} SMSG_ATTACKERSTATEUPDATE swing(s) decoded (target {target:#x}).");
// The refusals are REPORTED, never required: this probe lands on top of its kobold, so the
// server has no reason to send one. They are surfaced because before decision 2037 they
// were dropped silently, and a run that does provoke one should say so rather than swallow
// it again. Eliciting one on purpose needs a target out of melee reach whose hostility we
// can be sure of, which a live server does not hand us (2037's remainder 4).
// Refusals are reported, never required: the probe stands on its kobold, so the server has
// no cause to send one.
let refusals = &cx.world.swing_refusals;
if refusals.is_empty() {
println!(
@@ -1,6 +1,6 @@
//! `--aura`: the aura wire (decision 0255 phase 1). GM-apply a buff + a DoT with explicit durations,
//! require both in `UNIT_FIELD_AURA` (correct half, cancelable nibble, level byte, stack bias), and
//! require an `SMSG_UPDATE_AURA_DURATION` per aura ordered BEFORE the descriptor delta that names it.
//! `--aura`: the aura wire. Applies a buff and a DoT with set durations, requires both in
//! `UNIT_FIELD_AURA` (half, cancelable bit, level byte, stack bias), and requires each one's
//! `SMSG_UPDATE_AURA_DURATION` to arrive before the descriptor delta that names it.
use std::time::{Duration, Instant};
@@ -9,17 +9,15 @@ use benilla_protocol::{decode, ObjectFields, ServerPacket, SessionEvent};
use crate::probes::{Ctx, Probe};
// --aura: two spells with opposite sign, applied to ourselves by the GM `.aura <spell> <seconds>`
// command (`ChatHandler::HandleAuraCommand` → `HandleAuraHelper`, `UnitCommands.cpp:997-1051`;
// with no selection it targets the caster — `ChatHandler::GetSelectedUnit`, `Chat.cpp:2621-2622`).
/// Mark of the Wild — positive, and lacks `SPELL_ATTR_NO_AURA_CANCEL`, so the wire's cancelable bit
/// must be set. Lands in the positive half (slots 0–31).
// `.aura <spell> <seconds>` (`UnitCommands.cpp:997-1051`) targets the caster when nothing is
// selected (`Chat.cpp:2621-2622`).
/// Mark of the Wild: positive, without `SPELL_ATTR_NO_AURA_CANCEL`, so cancelable (slots 0–31).
const AURA_BUFF_SPELL: u32 = 1126;
/// Shadow Word: Pain — `SPELL_AURA_PERIODIC_DAMAGE`, unambiguously negative however it is applied.
/// Lands in the negative half (slots 32–47) and must NOT be cancelable.
/// Shadow Word: Pain, `SPELL_AURA_PERIODIC_DAMAGE`, negative however applied: slots 32–47, not
/// cancelable.
const AURA_DEBUFF_SPELL: u32 = 589;
const AURA_BUFF_SECONDS: u32 = 300;
/// Short: this one ticks damage on us, and we want it gone even if the cleanup `.unaura` is missed.
/// Short, since it damages us and must expire even if the cleanup `.unaura` is missed.
const AURA_DEBUFF_SECONDS: u32 = 15;
pub(crate) struct Aura;
@@ -28,13 +26,9 @@ impl Probe for Aura {
fn verify(&mut self, cx: &mut Ctx) -> Result<()> {
let self_guid = cx.world.self_guid;
let self_level = cx.world.self_level;
// --aura: live-verify the aura descriptor block + SMSG_UPDATE_AURA_DURATION (decision 0255).
// Nothing here trusts a field index: the probe *searches* the decoded slots for the spell ids it
// asked for, so a wrong `FIELD_UNIT_AURA` fails as "never appeared" rather than passing on
// garbage.
// Start from the LOGIN snapshot, not an empty store: a values delta only carries *changed*
// fields, so a delta-only view would silently hide auras the server restored at login from
// `character_aura` (they persist across logout) — and then misreport which slots are free.
// The probe searches the decoded slots for its spell ids, so a wrong field index fails as
// "never appeared". Start from the login snapshot: deltas carry only changed fields, and
// auras restored from `character_aura` at login would otherwise be invisible.
let mut fields = cx.world.self_fields.clone().unwrap_or_default();
let session = &mut *cx.session;
let dump = |label: &str, f: &ObjectFields| {
@@ -58,8 +52,8 @@ impl Probe for Aura {
};
dump("at login (restored from character_aura)", &fields);
// Clean slate, so a re-run measures a fresh apply and not a leftover refresh. `.unaura`
// zeroes the slot, which reaches us as an explicit `0` in the next delta.
// A clean slate, so a re-run measures a fresh apply. `.unaura` zeroes the slot, which
// arrives as an explicit `0` in the next delta.
session.send_chat(&format!(".unaura {AURA_BUFF_SPELL}"))?;
session.send_chat(&format!(".unaura {AURA_DEBUFF_SPELL}"))?;
let settle = Instant::now() + Duration::from_secs(3);
@@ -74,10 +68,9 @@ impl Probe for Aura {
}
}
dump("after .unaura of both probe spells", &fields);
// Whatever survives is not ours to touch (a warrior's Battle Stance, 2457, permanently owns
// slot 0). Nothing may report a duration for these while we watch: the server sends
// `SMSG_UPDATE_AURA_DURATION` only on apply/refresh, and never at all for a permanent aura —
// which is precisely the reference's "until cancelled" (an occupied slot with no timer).
// Survivors are not ours (Battle Stance, 2457, owns slot 0) and must report no duration:
// the server sends `SMSG_UPDATE_AURA_DURATION` only on apply or refresh, never for a
// permanent aura, which the 1.12 client shows as "until cancelled".
let untouched: Vec<u8> = fields.unit_auras().map(|a| a.slot).collect();
println!("\nGM: .aura {AURA_BUFF_SPELL} {AURA_BUFF_SECONDS} (Mark of the Wild)");
@@ -85,10 +78,8 @@ impl Probe for Aura {
session.send_chat(&format!(".aura {AURA_BUFF_SPELL} {AURA_BUFF_SECONDS}"))?;
session.send_chat(&format!(".aura {AURA_DEBUFF_SPELL} {AURA_DEBUFF_SECONDS}"))?;
// Record the arrival ORDER of the first duration packet against the values delta that first
// names the buff's spell in its slot — both indexed by PACKET arrival, the axis the ordering
// claim is actually about. `SMSG_UPDATE_AURA_DURATION` has no `SessionEvent` yet (nothing in
// the app consumes it until phase 2), so the probe reads the `ServerPacket` directly.
// Index the first duration packet and the first delta naming the buff by packet arrival;
// `SMSG_UPDATE_AURA_DURATION` is read off the `ServerPacket`, before decode.
let mut durations: Vec<(u8, u32)> = Vec::new();
let (mut seq, mut first_duration_at, mut buff_field_at) = (0usize, None, None);
let drain_until = Instant::now() + Duration::from_secs(8);
@@ -135,7 +126,6 @@ impl Probe for Aura {
.copied()
.context("--aura: spell 589 never appeared in UNIT_FIELD_AURA")?;
// The halves, the nibble, the level byte, the stack bias — each a distinct packing claim.
ensure!(
buff.is_helpful() && buff.slot < 32,
"buff landed in the debuff half (slot {})",
@@ -170,8 +160,7 @@ impl Probe for Aura {
debuff.stacks
);
// Durations: keyed by slot, carrying what we asked for. `.aura` sets an exact duration, and
// the packet is sent immediately, so allow only for a tick of decay.
// `.aura` sets an exact duration and the packet goes out at once: allow 2 s of decay.
for (label, aura, asked) in [
("buff", buff, AURA_BUFF_SECONDS),
("debuff", debuff, AURA_DEBUFF_SECONDS),
@@ -207,8 +196,7 @@ impl Probe for Aura {
);
println!("✅ duration packet precedes the descriptor delta (event {d} before {v})");
// Durations are apply/refresh EDGES, not a stream — the client counts down locally from
// here (the cast bar's model). An untouched slot must never have reported one.
// Durations are apply/refresh edges, not a stream; the client counts down locally.
if let Some(&slot) = untouched
.iter()
.find(|s| durations.iter().any(|(d, _)| d == *s))
@@ -224,8 +212,8 @@ impl Probe for Aura {
untouched.len()
);
// Leave the character as found. The drain matters: `character_aura` persists across logout,
// so exiting before the server processes these would save the probe's buffs onto the char.
// Clean up, then drain: `character_aura` persists across logout, so an unprocessed
// `.unaura` would leave the probe's auras on the character.
session.send_chat(&format!(".unaura {AURA_BUFF_SPELL}"))?;
session.send_chat(&format!(".unaura {AURA_DEBUFF_SPELL}"))?;
let settle = Instant::now() + Duration::from_secs(3);
@@ -1,6 +1,6 @@
//! `--charge`: warrior Charge (spell 100). GM `.learn 100`, teleport near the kobold camp, cast
//! Charge at a creature 8–25 yd out, and require an `SMSG_MONSTER_MOVE` for our OWN guid (Charge is
//! a self spline) — then ack the spline (`CMSG_MOVE_SPLINE_DONE`) and prove the stream survives.
//! `--charge`: warrior Charge (spell 100) at a creature 8–25 yd out must bring an
//! `SMSG_MONSTER_MOVE` for our own guid (a self spline), and the session must survive our
//! `CMSG_MOVE_SPLINE_DONE` ack.
use std::time::{Duration, Instant};
@@ -9,22 +9,17 @@ use benilla_protocol::{guid, EntityKind, SessionEvent};
use crate::probes::{Ctx, Probe};
/// The `--charge` probe spot: open ground ~18 yd west of the kobold camp ([`crate::world::ATTACK_TP`]),
/// so the camp's kobolds stream in at charge range (8–25 yd) while we land *outside* the immediate
/// aggro cluster — Charge refuses to fire while the caster is in combat, so we must cast before a
/// kobold closes and engages us.
/// Open ground about 18 yd west of the kobold camp: kobolds in charge range but outside aggro,
/// since Charge refuses to fire while the caster is in combat.
const CHARGE_TP: &str = ".go xyz -8798.71 -164.568 81.94";
#[derive(Default)]
pub(crate) struct Charge {
charge_target: Option<u64>,
/// The pending self-spline ack: (endpoint, splineId, when-to-send). Captured from the self
/// `SMSG_MONSTER_MOVE`, sent after the ride's duration elapses — the round-trip that proves our
/// `CMSG_MOVE_SPLINE_DONE` wire is right (a malformed body drops the session).
/// The pending self-spline ack: (endpoint, splineId, when to send).
charge_ack: Option<([f32; 3], u32, Instant)>,
charge_acked: bool,
/// `World::total` snapshotted when the ack went out; `verify` reads `total - total_at_ack` as the
/// "packets after the ack" count (the old per-packet `msgs_after_ack` tally, exactly).
/// `World::total` when the ack went out, so `verify` can count the packets after it.
total_at_ack: Option<u32>,
}
@@ -37,10 +32,8 @@ impl Probe for Charge {
}
fn poll(&mut self, cx: &mut Ctx) -> Result<()> {
// Fire Charge at a creature in range once we've landed. Charge needs the target 8–25 yd out,
// so pick the *farthest* streamed creature inside that band (maximises the odds of a valid
// range, and keeps clear of a kobold already on top of us). One shot — the first packet the
// server sends back for our own guid is the whole proof.
// Once landed, charge the farthest creature inside Charge's 8–25 yd band, clear of any
// kobold already on us. One shot.
if self.charge_target.is_none() {
if let Some(pos) = cx.world.attack_pos {
let pick = cx
@@ -58,9 +51,8 @@ impl Probe for Charge {
.filter(|(_, _, d)| (8.0..=25.0).contains(d))
.max_by(|a, b| a.2.total_cmp(&b.2));
if let Some((guid, tpos, dist)) = pick {
// Face the target first — Charge refuses a target that isn't in front (reason 124,
// SPELL_FAILED_UNIT_NOT_INFRONT). Report our facing with a Stop at the landing spot
// (WoW orientation = atan2(Δy, Δx)), then select + cast.
// Face it first: Charge refuses a target not in front (124,
// `SPELL_FAILED_UNIT_NOT_INFRONT`). Orientation is `atan2(Δy, Δx)`.
let orientation = (tpos[1] - pos[1]).atan2(tpos[0] - pos[0]);
cx.session.stop(pos, orientation)?;
cx.session.set_selection(guid)?;
@@ -72,9 +64,8 @@ impl Probe for Charge {
}
}
}
// Ack the self-spline once its ride would have finished (`CMSG_MOVE_SPLINE_DONE` at the
// endpoint). The server holds a player mover as spline-pending until this arrives; a surviving
// stream afterward is the live proof the ack wire parses.
// Ack at the endpoint once the ride is over: the server holds a player mover
// spline-pending until `CMSG_MOVE_SPLINE_DONE` arrives.
if let Some((endpoint, spline_id, at)) = self.charge_ack {
if !self.charge_acked && Instant::now() >= at {
cx.session.move_spline_done(endpoint, 0.0, spline_id)?;
@@ -99,9 +90,7 @@ impl Probe for Charge {
..
} = ev
{
// Queue the SPLINE_DONE ack for once the ride would have finished:
// the endpoint is the last waypoint; send after `duration_ms` (+ a
// margin) so we don't ack a spline the server still thinks is running.
// Ack at the last waypoint after `duration_ms` plus a margin, never mid-spline.
if *guid == cx.world.self_guid && self.charge_ack.is_none() && !*stop {
if let Some(&endpoint) = path.last() {
let at = Instant::now() + Duration::from_millis(u64::from(*duration_ms) + 200);
@@ -113,8 +102,6 @@ impl Probe for Charge {
}
fn verify(&mut self, cx: &mut Ctx) -> Result<()> {
// --charge verdict: the port must have landed, a target been found in range and Charge cast, and
// — the whole point — the server must have driven us with an SMSG_MONSTER_MOVE for our OWN guid.
if cx.world.attack_pos.is_none() {
bail!("--charge: the GM teleport never arrived (is the account gmlevel ≥ 2?)");
}
@@ -126,8 +113,7 @@ impl Probe for Charge {
bail!("--charge: cast Charge at {target:#x} but the server sent NO SMSG_MONSTER_MOVE for our guid (check the CAST_RESULT reason above — combat / range / stance)");
}
println!("✅ charge: {self_moves} self SMSG_MONSTER_MOVE — Charge drives the caster via a server spline (target {target:#x}).");
// The ack half: we sent CMSG_MOVE_SPLINE_DONE at the endpoint; a malformed body throws in the
// server's parser and drops us, so a stream that keeps flowing afterward is the live proof.
// A malformed ack body drops the session, so traffic after the ack proves it parsed.
if self.charge_acked {
let msgs_after_ack = cx.world.total
- self
@@ -1,6 +1,6 @@
//! `--death`: the death-arc slice-1 wire (decision 0308). The shared [`crate::world::DeathArc`] runs
//! the die → release → ghost transition; this probe adds the corpse query, the self-revive cleanup,
//! and the post-revive not-found re-query, then asserts the whole arc in wire order.
//! `--death`: the death wire. [`crate::world::DeathArc`] runs die, release and the ghost
//! transition; this probe adds the corpse query, a GM revive and a post-revive re-query that must
//! find nothing, then checks the whole arc in wire order.
use anyhow::{bail, Context, Result};
use benilla_protocol::{EntityKind, SessionEvent};
@@ -12,22 +12,18 @@ pub(crate) struct Death {
corpse_query_sent: bool,
corpse_answer: Option<(bool, i32, [f32; 3], u32)>,
revive_sent: bool,
/// The post-revive corpse re-query — the app's marker-drop mechanism (the server's own
/// "corpse gone" push is LOOTER-gated, vmangos Map.cpp:3617-3629, so a PvE res never sends
/// one; the client must re-ask and hear not-found). Director-reported: the map tombstone
/// survived a spirit-healer res before this was mirrored.
/// The post-revive corpse re-query: the server's "corpse gone" push goes only to looters
/// (`Map.cpp:3617-3629`), so after a PvE res the client must re-ask and hear not-found.
post_revive_query_sent: bool,
corpse_gone: Option<bool>,
corpse_create: Option<(u64, [f32; 3])>,
/// The corpse descriptor's interaction bits at create: `(CORPSE_FIELD_FLAGS, owner, bones,
/// lootable, insignia)` — decision 1723's inputs, read off the wire rather than assumed.
/// The corpse descriptor at create: `(CORPSE_FIELD_FLAGS, owner, bones, lootable, insignia)`.
corpse_flags: Option<(u32, u64, bool, bool, bool)>,
}
impl Probe for Death {
fn poll(&mut self, cx: &mut Ctx) -> Result<()> {
// Once we're a released ghost AND the graveyard teleport has landed, ask where our corpse
// is — the corpse-run marker's own source (decision 0308 §5).
// A released ghost at the graveyard asks where its corpse is (the corpse-run marker).
if !self.corpse_query_sent {
if let Some(arc) = &cx.world.death_arc {
if arc.ghost_seen && arc.graveyard_pos.is_some() {
@@ -37,9 +33,7 @@ impl Probe for Death {
}
}
}
// Once the corpse-query answer is in, GM-revive so the round trip leaves the character
// alive (cleanup, not part of the wire under test — a real player would corpse-run +
// CMSG_RECLAIM_CORPSE instead).
// Then GM-revive as cleanup; a real player corpse-runs and sends `CMSG_RECLAIM_CORPSE`.
if !self.revive_sent && self.corpse_answer.is_some() {
cx.session.send_chat(".revive")?;
println!("sent GM: .revive (self-revive)");
@@ -48,8 +42,7 @@ impl Probe for Death {
arc.revive_initiated = true;
}
}
// Once revived, re-ask where the corpse is: the answer MUST be not-found (the corpse
// unbinds at SpawnCorpseBones) — the round trip the app's unghost-edge marker drop rides.
// Once revived, the re-query must find nothing: the corpse unbinds at `SpawnCorpseBones`.
if !self.post_revive_query_sent {
if let Some(arc) = &cx.world.death_arc {
if arc.revived_seen {
@@ -71,23 +64,13 @@ impl Probe for Death {
fields,
..
} => {
// --death: the corpse streams in once we've released (decision 0308 §4).
//
// **It is `EntityKind::Corpse`, not `Other`** — and this line said `Other` from
// 0308 until 1723 found it. 1706 gave TYPEID_CORPSE its own `EntityKind` variant;
// the compiler enumerated every `match` on the enum and corrected them, but this
// is an `==`, so it stayed silent and the probe's corpse capture quietly stopped
// firing: the arc would have bailed "the corpse object never streamed" on a wire
// that was perfectly correct. An instrument that fails closed after a refactor is
// the expensive kind (docs/METHOD.md §5 — the instruments are part of the codebase).
// The corpse streams in after release as `EntityKind::Corpse`; an `==` here, unlike
// a `match`, gets no compiler help if the variant changes.
let repop_sent = cx.world.death_arc.as_ref().is_some_and(|a| a.repop_sent);
if repop_sent && self.corpse_create.is_none() && *kind == EntityKind::Corpse {
self.corpse_create = Some((*guid, *position));
// The descriptor bits the client's whole corpse interaction hangs off
// (decision 1723): BONES picks the model, DYNAMIC_FLAGS bit 0 is the only
// thing that opens the `CMSG_LOOT` route, FLAGS bit 5 is the PvP insignia the
// skin leg reads. Printed rather than asserted — what vmangos actually sets on
// a plain PvE death is a fact worth having in the log, not a pass/fail.
// BONES picks the model, DYNAMIC_FLAGS bit 0 alone opens `CMSG_LOOT`, FLAGS
// bit 5 is the PvP insignia. Printed, not asserted.
self.corpse_flags = Some((
fields.corpse_flags(),
fields.corpse_owner().unwrap_or(0),
@@ -132,9 +115,7 @@ impl Probe for Death {
.as_ref()
.expect("death_arc present when --death is set");
// --death verdict (decision 0308 slice 1): every signal in the release→ghost→corpse arc, in
// wire order — `bail!` on the first one that never arrived, so a partial run points straight
// at the phase that broke.
// Every signal of the arc in wire order, bailing on the first one missing.
let death_pos = arc.death_pos.context(
"--death: `.die` never dropped our health to 0 — is the account gmlevel ≥ 2?",
)?;
@@ -153,9 +134,7 @@ impl Probe for Death {
if !arc.ghost_seen {
bail!("--death: PLAYER_FLAGS_GHOST (bit 0x10, field 190) never set");
}
// Full 3D distance: the corpse/graveyard/query checks below all compare against where we
// died, and elevation is as telling as the ground plane (a graveyard directly below/above
// death, or a corpse object mis-z'd, are both real wire bugs — not noise to filter out).
// 3D distance from where we died: a wrong z is as much a wire bug as a wrong x or y.
let dist3 = |a: [f32; 3], b: [f32; 3]| {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
};
@@ -1,5 +1,5 @@
//! `--equip-pack-slot`: `CMSG_AUTOEQUIP_ITEM` on a 1-based backpack slot; require a server reaction
//! (the guid landing in an equipment INV slot, or a decoded refusal), then swap it home.
//! `--equip-pack-slot`: `CMSG_AUTOEQUIP_ITEM` on a 1-based backpack slot must land the item in an
//! equipment slot or draw a refusal; the item is then swapped home.
use std::time::{Duration, Instant};
@@ -18,7 +18,6 @@ impl Probe for EquipPackSlot {
let session = &mut *cx.session;
let n = self.n;
// --equip-pack-slot: auto-equip a real bag item and require a server reaction.
let sf = world
.self_fields
.as_mut()
@@ -36,7 +35,7 @@ impl Probe for EquipPackSlot {
session.auto_equip_item(255, 23 + n0)?;
let drain_until = Instant::now() + Duration::from_secs(5);
let mut verdict: Option<String> = None;
// The equipment INV slot the item landed in (Some once equipped) — so we can restore it.
// The equipment slot the item landed in, for the restore.
let mut equipped_slot: Option<u8> = None;
while Instant::now() < drain_until && verdict.is_none() {
let Ok(msg) = session.recv() else { continue };
@@ -67,10 +66,8 @@ impl Probe for EquipPackSlot {
let v = verdict.context("no reaction to CMSG_AUTOEQUIP_ITEM within 5s")?;
println!("✅ auto-equip: {v}.");
// Restore: if we actually equipped it, SWAP_INV_ITEM the equipment slot back to the source
// backpack slot — moves the item home (and whatever it displaced back into place), leaving
// the character exactly as found. (The wire's both-slots-on-the-player addressing spans
// equipment 0-18 ↔ backpack 23-38.)
// Swap the equipment slot back to the backpack slot, which also returns anything it
// displaced. Player slots: equipment 0-18, backpack 23-38.
if let Some(slot) = equipped_slot {
println!(
"restoring: CMSG_SWAP_INV_ITEM equipment slot {slot} → backpack slot {}",
@@ -1,5 +1,5 @@
//! `--giverstatus`: the questgiver STATUS wire (the overhead `!`/`?` markers' data). Teleport onto
//! Marshal McBride, `CMSG_QUESTGIVER_STATUS_QUERY` him, require an `SMSG_QUESTGIVER_STATUS` answer.
//! `--giverstatus`: the questgiver status wire behind the overhead `!`/`?` markers.
//! `CMSG_QUESTGIVER_STATUS_QUERY` on Marshal McBride must draw an `SMSG_QUESTGIVER_STATUS`.
use std::time::{Duration, Instant};
@@ -12,10 +12,7 @@ pub(crate) struct GiverStatus;
impl Probe for GiverStatus {
fn stage(&mut self, cx: &mut Ctx) -> Result<()> {
// Same cleanup-then-teleport pattern as --quest (idempotent across re-runs); McBride is
// both giver and ender for quest 7, so one teleport (already onto him) suffices. Shared with
// --questlog: the `mcbride_staged` flag makes the two GM lines go out exactly once for a
// co-run, matching today's single `cli.questlog || cli.giverstatus` staging block.
// Shared with --questlog: `mcbride_staged` sends the cleanup and teleport once.
if !cx.world.mcbride_staged {
cx.session
.send_chat(&format!(".quest remove {QUESTLOG_ID}"))?;
@@ -32,10 +29,8 @@ impl Probe for GiverStatus {
let world = &mut *cx.world;
let session = &mut *cx.session;
// --giverstatus: live-verify the questgiver STATUS wire — the overhead `!`/`?` markers'
// data plane (CMSG_QUESTGIVER_STATUS_QUERY → SMSG_QUESTGIVER_STATUS, vmangos
// QuestHandler.cpp:36-77). Quest 7 was `.quest remove`d in the preamble, so McBride should
// answer AVAILABLE(5) for a fresh log.
// Quest 7 was removed in staging, so McBride answers AVAILABLE, 5
// (`QuestHandler.cpp:36-77`).
let mcbride = world
.tracked
.iter()
@@ -1,11 +1,7 @@
//! `--groundfx <spell_id>`: capture the **dest-anchored effect wire** a ground cast produces —
//! the instrument for the B132 follow-up ("the cast lands but nothing shows"). GM-learn the
//! spell, GM-fill mana (any class can then channel it), cast it at our own feet through the same
//! `cast_spell_at_dest` body the client's world-click commit sends, and dump raw everything the
//! server anchors at the point: every `DynamicObject` create (all `DYNAMICOBJECT_*` fields,
//! labeled per vmangos `UpdateFields_1_12_1.h`), the `SPELL_GO`, and the removal edge with its
//! measured lifetime. Run with `--seconds 25`+ so a channeled spell's whole life fits the window
//! (Blizzard 10 channels 8 s and its object outlives the channel).
//! `--groundfx <spell_id>`: the effect wire of a ground cast. Learns the spell, fills mana, casts
//! it at our feet with the body a world click sends (`cast_spell_at_dest`), and dumps every
//! `DynamicObject` create, the `SPELL_GO` and the removal with its lifetime. Blizzard (spell 10)
//! channels 8 s and its object outlives the channel, so run with `--seconds 25`.
use std::time::Instant;
@@ -40,18 +36,16 @@ fn dyn_field_is_f32(index: u16) -> bool {
matches!(index, 4 | 10..=14)
}
/// `UNIT_FIELD_POWER1` (mana) for 1.12.1 — `OBJECT_END(6) + 0x11` (vmangos
/// `UpdateFields_1_12_1.h:50`). The mana-ack gate below watches this on the self guid.
/// `UNIT_FIELD_POWER1` (mana): `OBJECT_END` (6) + 0x11 (`UpdateFields_1_12_1.h:50`).
const FIELD_UNIT_POWER1: u16 = 6 + 0x11;
pub(crate) struct GroundFx {
spell: u32,
staged: bool,
known: bool,
/// Set when the self unit's `UNIT_FIELD_POWER1` echoes the GM fill — BOTH chat commands
/// (`.learn`, `.modify mana`) execute deferred, and each needs its own server-side evidence
/// before the cast: a cast in the learn's batch is dropped as unknown (decision 0792's trap),
/// and one in the mana fill's batch refuses with no-power (observed live, reason 77).
/// Set once our `UNIT_FIELD_POWER1` shows the fill. `.learn` and `.modify mana` run deferred:
/// a cast before the learn lands is dropped as unknown, one before the fill fails with no
/// power (reason 77).
mana_seen: bool,
cast_sent: Option<Instant>,
/// DynamicObject creates seen: (guid, seen-at, spell id from field 9).
@@ -77,10 +71,8 @@ impl GroundFx {
impl Probe for GroundFx {
fn poll(&mut self, cx: &mut Ctx) -> Result<()> {
// Stage once the book is in: GM-learn the spell (skipped if already known — a prior run's
// leftover) and fill a mana pool any class can spend (the probe char may be a warrior).
// Both chat commands execute DEFERRED server-side, so the cast waits for the
// SMSG_LEARNED_SPELL ack (the same trap the --spells dest phase hit, decision 0792).
// Once the spell book is in, learn the spell unless known and fill mana, which any class
// can spend; the cast then waits for `SMSG_LEARNED_SPELL`.
if !self.staged {
let Some(book) = &cx.world.spell_book else {
return Ok(());
@@ -113,8 +105,7 @@ impl Probe for GroundFx {
SessionEvent::SpellLearned { spell_id } if *spell_id == self.spell => {
self.known = true;
}
// The mana-ack gate: the self unit's POWER1 showing a real pool — either the login
// create (a mage arrives full) or the `.modify mana` echo (the warrior's fill).
// The mana gate: our POWER1 shows a real pool, at login or after `.modify mana`.
SessionEvent::ObjectCreate { guid, fields, .. }
| SessionEvent::ObjectValues { guid, fields }
if *guid == cx.world.self_guid && !self.mana_seen =>
@@ -127,8 +118,7 @@ impl Probe for GroundFx {
println!("groundfx: self mana visible — the cast gate is open");
}
}
// vmangos sends UPDATEFLAG_HAS_POSITION on every dynobj create, so the position is
// always live here. Field 9 (SPELLID) is dumped with the rest raw.
// Every vmangos dynobj create has `UPDATEFLAG_HAS_POSITION`, so the position is real.
SessionEvent::ObjectCreate {
guid,
kind: EntityKind::DynamicObject,
@@ -1,6 +1,6 @@
//! `--loot`: the solo-loot wire (decision 0084 §1). Select a target, GM-kill it, wait for the
//! lootable dynamic flag, teleport onto the corpse, `CMSG_LOOT`, AUTOSTORE every row, LOOT_MONEY if
//! it carried gold, then LOOT_RELEASE — printing every loot-related packet decoded.
//! `--loot`: the solo-loot wire. GM-kills a target, waits for it to turn lootable, teleports onto
//! the corpse, then `CMSG_LOOT`, stores every row, takes the money and releases, printing every
//! loot packet.
use std::time::{Duration, Instant};
@@ -16,9 +16,7 @@ pub(crate) struct Loot {
impl Probe for Loot {
fn stage(&mut self, cx: &mut Ctx) -> Result<()> {
// --loot reuses the --attack teleport spot to guarantee a killable creature in range when no
// explicit --loot-guid was given; avoid sending it twice if both flags are set (the shared
// `attack_tp_staged` flag — attack/loot/DeathArc all key off it).
// Without --loot-guid, use the --attack teleport for a creature in range; sent once.
if self.loot_guid.is_none() && !cx.world.attack_tp_staged {
cx.session.send_chat(ATTACK_TP)?;
cx.world.attack_tp_staged = true;
@@ -32,20 +30,8 @@ impl Probe for Loot {
let session = &mut *cx.session;
let self_guid = world.self_guid;
// --loot: kill + loot a creature end-to-end (decision 0084 §1). Select the target (explicit
// --loot-guid, or the nearest streamed creature to the --attack teleport spot), GM-kill it
// (a GM `.damage` acts on the selected unit regardless of range), wait for the corpse's
// lootable dynamic flag, GM-teleport directly onto the (now-stationary) corpse — `--attack`'s
// ~20yd melee-swing search is far looser than the actual loot-range check
// (`Player::GetMaxLootDistance`, VERIFIED vmangos `Player.cpp:15472-15476`, a tight
// combat-reach distance), and Northshire's mobs wander enough that "nearest at landing" is
// routinely just outside it by the time the kill lands — then CMSG_LOOT it, decode the
// response, AUTOSTORE every row, LOOT_MONEY if it carried gold, then LOOT_RELEASE. Prints
// every loot-related packet decoded.
// Candidate targets: the explicit --loot-guid, or the nearest few streamed creatures. A
// list, not one — the nearest creature can be a corpse a previous run already looted
// (dead until respawn, so `.damage` does nothing and it never goes lootable); trying the
// next-nearest instead makes back-to-back probe runs reliable.
// `.damage` hits the selected unit at any range. The nearest three creatures are tried,
// since the nearest may be a corpse an earlier run already looted.
let candidates: Vec<u64> = if let Some(g) = self.loot_guid {
vec![g]
} else {
@@ -69,8 +55,7 @@ impl Probe for Loot {
bail!("--loot: no creature streamed in (try --loot-guid)");
}
// Kill candidates until one shows the corpse's lootable dynamic flag (UNIT_DYNFLAG_LOOTABLE,
// bit 0x1 — VERIFIED vmangos `SharedDefines.h:1153`).
// Kill until one turns lootable: `UNIT_DYNFLAG_LOOTABLE` 0x1 (`SharedDefines.h:1153`).
let mut target = 0u64;
let mut lootable = false;
for &candidate in &candidates {
@@ -108,12 +93,9 @@ impl Probe for Loot {
}
println!("✅ {target:#x} is lootable (UNIT_DYNFLAG_LOOTABLE set)");
// Reposition directly onto the (now-stationary) corpse before looting — see the block
// comment above for why "nearest at landing" isn't reliably in loot range. The tracked
// position itself can lag where the creature actually died (a mid-spline kill reports the
// spline's last waypoint, not the death spot), and `GetMaxLootDistance` is combat-reach
// tight — so on a TOO_FAR refusal, drain (refreshing tracked positions) and retry with the
// updated spot instead of failing the run on instrument flakiness.
// Teleport onto the corpse: loot range is combat reach (`Player.cpp:15472-15476`), and a
// mid-spline kill leaves the tracked position at the spline's last waypoint, so a TOO_FAR
// refusal retries from the refreshed spot.
let mut response: Option<(u8, u32, Vec<benilla_protocol::messages::LootItem>)> = None;
let mut loot_refusal: Option<u8> = None;
for attempt in 1..=3 {
@@ -149,8 +131,7 @@ impl Probe for Loot {
loot_refusal = None;
session.loot(target)?;
// Drain for SMSG_LOOT_RESPONSE (either shape), keeping tracked positions fresh so a
// retry teleports to the corpse's *corrected* spot.
// Drain for either `SMSG_LOOT_RESPONSE` shape, refreshing positions for a retry.
let drain_until = Instant::now() + Duration::from_secs(5);
while Instant::now() < drain_until && response.is_none() && loot_refusal.is_none() {
let Ok(msg) = session.recv() else { continue };
@@ -179,10 +160,8 @@ impl Probe for Loot {
}
response = Some((loot_type, gold, items));
}
// The master-loot candidate list rides the window OPEN, ahead of the
// response it belongs to, and it is the one part of the master-loot arc
// that cannot be staged from a single probe account (decision 1675). It
// is printed unconditionally — it carries no loot guid to match on.
// The master-loot list arrives ahead of its response and carries no loot
// guid, so it is printed unconditionally.
SessionEvent::LootMasterList { candidates } => {
println!(
"SMSG_LOOT_MASTER_LIST: {} eligible looter(s)",
@@ -225,8 +204,7 @@ impl Probe for Loot {
let (loot_type, gold, items) =
response.context("--loot: no SMSG_LOOT_RESPONSE arrived within 5s")?;
// AUTOSTORE every row, watching for the LOOT_REMOVED + ITEM_PUSH_RESULT + bag ItemCreate
// each one drives.
// Store every row; each drives LOOT_REMOVED, ITEM_PUSH_RESULT and a bag ItemCreate.
let mut removed_slots: Vec<u8> = Vec::new();
let mut pushes: Vec<benilla_protocol::messages::ItemPushResult> = Vec::new();
let mut items_created: Vec<u64> = Vec::new();
@@ -286,25 +264,9 @@ impl Probe for Loot {
items_created.len()
);
// LOOT_MONEY, only if the response actually carried gold.
//
// Live-verified departure from the pin: this vmangos build never sends
// SMSG_LOOT_MONEY_NOTIFY for a solo (ungrouped) looter — `LootHandler.cpp`'s
// `HandleLootMoneyOpcode` comments out `player->SendLootMoneyNotify(pLoot->gold)` for the
// non-group branch ("in wotlk and after this should be sent for solo looting too") and
// applies the money silently via `Player::LootMoney` (a `PLAYER_FIELD_COINAGE` delta)
// instead. Only `SMSG_LOOT_CLEAR_MONEY` fires. So the pass criterion here is CLEAR_MONEY +
// a coinage delta on our own guid, with the notify event accepted too (group loot, or a
// future server that un-comments it).
//
// `NotifyMoneyRemoved` (CLEAR_MONEY) sends immediately; `LootMoney`'s
// `PLAYER_FIELD_COINAGE` write reaches us on the ordinary dirty-field broadcast tick, not
// synchronously with CLEAR_MONEY. NOTE the loot flow guarantees *unrelated* self values
// updates in the same window (the server toggles `UNIT_FLAG_LOOTING` on us at
// `SendLoot`/release) — so "a self values update arrived" is NOT "the coinage delta
// arrived"; only an actual change of `player_money()` counts (the original drain exited on
// the flags-only update, read an unchanged purse, and mis-reported the money as lost —
// the DB was always correct).
// vmangos sends no `SMSG_LOOT_MONEY_NOTIFY` to a solo looter (commented out in
// `HandleLootMoneyOpcode`): `SMSG_LOOT_CLEAR_MONEY` comes at once and the money as a later
// `PLAYER_FIELD_COINAGE` delta. The notify still counts, for group loot.
if gold > 0 {
let money_before = world.self_fields.as_ref().and_then(|sf| sf.player_money());
println!(
@@ -332,9 +294,8 @@ impl Probe for Loot {
SessionEvent::ObjectValues { guid: g, fields } if g == self_guid => {
if let Some(sf) = &mut world.self_fields {
sf.merge(fields);
// Only a *changed* purse is the coinage delta — the loot flow also
// pushes flags-only self updates (UNIT_FLAG_LOOTING), and treating
// those as "the money arrived" mis-reads an unchanged purse as lost.
// Only a changed purse counts: the loot flow also pushes
// flags-only self updates (`UNIT_FLAG_LOOTING`).
if sf.player_money() != money_before {
money_after = sf.player_money();
}
@@ -371,7 +332,6 @@ impl Probe for Loot {
println!("(no gold on this corpse — skipping CMSG_LOOT_MONEY)");
}
// RELEASE.
println!("sending CMSG_LOOT_RELEASE");
session.loot_release(target)?;
let mut released = false;
@@ -1,20 +1,17 @@
//! The probe registry: one file per `--flag` scenario, each a self-contained [`Probe`] with a
//! uniform lifecycle. Registration order (in `main`) = today's stream-loop/verify block order, so
//! output and execution order are preserved.
//! The probes: one file per `--flag` scenario, each a [`Probe`] run in registration order.
use anyhow::Result;
use benilla_protocol::{SessionEvent, WorldSession};
use crate::world::World;
/// What a [`Probe`]'s lifecycle methods borrow: the live session (to send) + the shared [`World`].
/// What a [`Probe`]'s lifecycle methods borrow: the live session and the shared [`World`].
pub(crate) struct Ctx<'a> {
pub session: &'a mut WorldSession,
pub world: &'a mut World,
}
/// One scripted wire-verification scenario. Registered from a `--flag`; the pump drives every
/// registered probe through this lifecycle in registration order.
/// One scripted wire-verification scenario, driven through this lifecycle in registration order.
pub(crate) trait Probe {
/// One-time pre-stream staging (GM teleports, cleanup commands).
fn stage(&mut self, cx: &mut Ctx) -> Result<()> {
@@ -26,37 +23,30 @@ pub(crate) trait Probe {
let _ = cx;
Ok(())
}
/// Every decoded event, AFTER [`World::on_event`] has processed it.
/// Every decoded event, after [`World::on_event`] has processed it.
fn on_event(&mut self, ev: &SessionEvent, cx: &mut Ctx) -> Result<()> {
let _ = (ev, cx);
Ok(())
}
/// Post-stream: assertions + follow-up round trips (each block's bespoke drains move verbatim).
/// Post-stream: assertions and follow-up round trips.
fn verify(&mut self, cx: &mut Ctx) -> Result<()> {
let _ = cx;
Ok(())
}
}
// Quest consts shared by more than one probe (put here rather than `pub(super)` in quest.rs
// because giverstatus/questlog re-use them and neither is quest.rs's child).
// Quest constants shared by several probes.
/// Marshal McBride's teleport spot — the `--quest` turn-in NPC, and the `--questlog`/`--giverstatus`
/// stage target (McBride is quest 7's giver *and* ender).
/// Onto Marshal McBride, the `--quest` turn-in NPC and quest 7's giver and ender.
pub(crate) const QUEST_TURNIN_TP: &str = ".go xyz -8902.59 -162.606 82.0223"; // onto Marshal McBride
pub(crate) const QUEST_TURNIN_ENTRY: u32 = 197; // Marshal McBride — takes 783
/// `PLAYER_QUEST_LOG_1_1` UpdateField index for 1.12.1 (UNIT_END 188 + 0xA), 3 fields per slot ×
/// 20 slots (vmangos `UpdateFields_1_12_1.h:128`).
pub(crate) const QUEST_TURNIN_ENTRY: u32 = 197; // Marshal McBride, who takes 783
/// `PLAYER_QUEST_LOG_1_1`: `UNIT_END` (188) + 0xA, 3 fields per slot for 20 slots
/// (`UpdateFields_1_12_1.h:128`).
pub(crate) const FIELD_PLAYER_QUEST_LOG_1_1: u16 = 198;
/// The `--questlog` probe target: quest 7 "Kobold Camp Cleanup" — Marshal McBride (entry 197, the
/// same NPC [`QUEST_TURNIN_TP`]/[`QUEST_TURNIN_ENTRY`] already land on) is *both* giver and ender
/// (VERIFIED live against the running vmangos: `mangos.creature_questrelation` and
/// `creature_involvedrelation` each carry exactly one row for quest 7, both `197`). Its one
/// objective — kill 10 of creature entry 6 (Kobold) — is a real `required_count > 0` slot, unlike
/// 783's report-only "A Threat Within" the `--quest` probe turns in; a GM `.quest complete`
/// substitutes for the grind, same as `--quest`. Shared with `--giverstatus`, which `.quest remove`s
/// it in staging so McBride reads AVAILABLE for a fresh log.
/// Quest 7 "Kobold Camp Cleanup": Marshal McBride (197) gives and takes it
/// (`creature_questrelation`, `creature_involvedrelation`), and its objective, 10 kills of entry
/// 6, is a real counted slot, unlike 783's report-only "A Threat Within".
pub(crate) const QUESTLOG_ID: u32 = 7;
mod attack;
@@ -1,19 +1,11 @@
//! `--mount-tele`: **what the server actually sends when a teleport dismounts you** — B213's wire.
//! `--mount-tele`: what the server sends when a teleport dismounts you. vmangos strips the mount
//! in `HandleMoveWorldportAckOpcode` right after the destination's own create block
//! (`Map::Add` → `SendInitSelf`, then `RemoveSpellsCausingAura(SPELL_AURA_MOUNTED)` unless
//! `IsMountAllowed`), so the two arrive back to back in one tick.
//!
//! The report: mount up, teleport somewhere that forbids mounting, and the auto-dismount leaves the
//! mount's run speed on your feet. vmangos does that strip inside the worldport ack handler, three
//! statements after the destination map's own create block goes out
//! (`MovementHandler.cpp` `HandleMoveWorldportAckOpcode`: `Map::Add` → `SendInitSelf`, then
//! `if (!mEntry->IsMountAllowed()) RemoveSpellsCausingAura(SPELL_AURA_MOUNTED)`), so the two land
//! back to back in one tick. Reading the source says they *should*; this probe measures that they
//! *do*, on the running deploy, and prints the gap.
//!
//! The scenario: `.aura` a real mount spell on open ground (a genuine `SPELL_AURA_MOUNTED` holder —
//! `.modify mount` sets a display id with no aura and would never be stripped), require the mounted
//! `SMSG_FORCE_RUN_SPEED_CHANGE`, then `.go xyz` into a dungeon map and require, in order: the
//! `SMSG_NEW_WORLD`, a self create block still carrying the **mounted** run speed, and the
//! dismount's `SMSG_FORCE_RUN_SPEED_CHANGE` back at base. Needs a GM account (the deploy's probes
//! are gmlevel 6).
//! Mounts with `.aura` on open ground, then `.go xyz` into a dungeon, and requires in order
//! `SMSG_NEW_WORLD`, a self create block still at the mounted run speed, and the dismount's
//! `SMSG_FORCE_RUN_SPEED_CHANGE` back to base, printing the gap. Needs GM.
use std::time::Instant;
@@ -23,24 +15,21 @@ use benilla_protocol::{SessionEvent, SpeedKind};
use crate::probes::{Ctx, Probe};
use crate::world::ATTACK_TP;
/// Brown Horse (the 60% apprentice mount): `SPELL_AURA_MOUNTED` + `SPELL_AURA_MOD_INCREASE_MOUNTED_SPEED`,
/// so `.aura` on it produces the same holder a real mount cast would — the one
/// `RemoveSpellsCausingAura(SPELL_AURA_MOUNTED)` goes looking for.
/// Brown Horse (60%): `SPELL_AURA_MOUNTED` + `SPELL_AURA_MOD_INCREASE_MOUNTED_SPEED`, so `.aura`
/// makes the holder a real mount cast would; `.modify mount` sets only a display id.
const MOUNT_SPELL: u32 = 458;
/// Ragefire Chasm. A dungeon, and not one of `MapEntry::IsMountAllowed`'s four exceptions
/// (Zul'Gurub, Zul'Farrak, AQ Ruins, Caverns of Time), so arriving there strips the mount.
const DUNGEON_MAP: u32 = 389;
/// Inside Ragefire Chasm's entrance. The landing spot does not matter — the packets we want are
/// sent by the arrival itself — but a real one keeps the character somewhere sane if a run is
/// interrupted before the exit teleport in `verify`.
/// Inside Ragefire Chasm's entrance, a sane place to be stranded if a run is interrupted.
const DUNGEON_TP: &str = ".go xyz 3.0 -14.0 -18.0 389";
/// 1.12.1's base run speed (yd/s) — the value the strip must come back to.
/// The 1.12 base run speed (yd/s), which the strip restores.
const BASE_RUN: f32 = 7.0;
/// The map the scenario has to start on — open ground where a mount is allowed.
/// The starting map: open ground where a mount is allowed.
const OUTDOOR_MAP: u32 = 0;
#[derive(Default)]
@@ -60,11 +49,9 @@ pub(crate) struct MountTele {
impl Probe for MountTele {
fn stage(&mut self, cx: &mut Ctx) -> Result<()> {
// Get onto open ground and clean of any leftover holder FIRST — an interrupted earlier run
// can leave the character mounted inside the dungeon, and from there the scenario's own
// teleport is a same-map port that sends no `SMSG_NEW_WORLD` at all. The map id is
// explicit for the same reason `verify`'s exit is (`.go xyz`'s map argument defaults to
// the map you are standing on). Mounting waits for the arrival, in `poll`.
// Unmount and go outdoors first: from inside the dungeon the scenario's teleport would be
// a same-map port, which sends no `SMSG_NEW_WORLD`. `.go xyz` defaults to the current map,
// so the map id is explicit.
cx.session.send_chat(&format!(".unaura {MOUNT_SPELL}"))?;
cx.session
.send_chat(&format!("{ATTACK_TP} {OUTDOOR_MAP}"))?;
@@ -74,9 +61,8 @@ impl Probe for MountTele {
fn on_event(&mut self, ev: &SessionEvent, cx: &mut Ctx) -> Result<()> {
match ev {
// The mount landed: its speed change is our cue to teleport. Gate on the *tracked*
// self like `--speed` does — World only acks once our pose is known, and an unacked
// change is one vmangos has not applied yet.
// The mounted speed change cues the teleport, gated on a tracked self: `World` acks
// only once our pose is known, and vmangos applies no change before its ack.
SessionEvent::ForceSpeedChange {
guid, kind, speed, ..
} if *guid == cx.world.self_guid && *kind == SpeedKind::Run => {
@@ -84,8 +70,7 @@ impl Probe for MountTele {
return Ok(());
}
if self.ported {
// Post-arrival: the first Run change after the destination's create block is
// the dismount we came for.
// After arrival, the first Run change after the create block is the dismount.
if self.create_after.is_some() && self.strip_after.is_none() {
self.strip_after = Some((*speed, Instant::now()));
}
@@ -94,15 +79,13 @@ impl Probe for MountTele {
println!("mounted: run {speed} yd/s — teleporting into map {DUNGEON_MAP}");
}
}
// `SMSG_LOGIN_VERIFY_WORLD` decodes to this too, so login itself announces map 0 —
// gate on having actually sent the teleport, or the whole scenario latches onto the
// login and every later check reads the wrong packets.
// `SMSG_LOGIN_VERIFY_WORLD` also decodes to this: gate on the teleport being sent.
SessionEvent::Worldport { map_id, .. } if self.ported && self.arrived.is_none() => {
self.arrived = Some((*map_id, Instant::now()));
println!("SMSG_NEW_WORLD: map {map_id}");
}
// Our own create on the destination map — sent by `Map::Add` → `SendInitSelf`, before
// the strip. Its LIVING block is the speed set the client seeds a fresh entity with.
// Our create on the new map, from `SendInitSelf` before the strip; its LIVING block
// seeds a fresh entity's speeds.
SessionEvent::ObjectCreate { guid, speeds, .. }
if *guid == cx.world.self_guid && self.ported && self.arrived.is_some() =>
{
@@ -117,8 +100,7 @@ impl Probe for MountTele {
}
fn poll(&mut self, cx: &mut Ctx) -> Result<()> {
// Mount only once the staging teleport has actually landed us outdoors — proving the run's
// starting state from a packet rather than assuming it (`docs/METHOD.md` step 6).
// Mount only once the staging teleport has landed us outdoors.
if !self.mount_requested && cx.world.self_map == OUTDOOR_MAP {
self.mount_requested = true;
cx.session.send_chat(&format!(".aura {MOUNT_SPELL}"))?;
@@ -135,17 +117,13 @@ impl Probe for MountTele {
}
fn verify(&mut self, cx: &mut Ctx) -> Result<()> {
// Leave the character as found before asserting: a failed run must not strand it in a
// dungeon on a mount. The exit names map 0 explicitly — `.go xyz`'s map argument is
// OPTIONAL and defaults to the map you are standing on, so the bare [`ATTACK_TP`] would
// port us to Northshire's coordinates *inside Ragefire Chasm*.
// Restore before asserting. The exit names map 0: `.go xyz` defaults to the current map,
// so a bare `ATTACK_TP` would land on Northshire's coordinates inside Ragefire Chasm.
cx.session.send_chat(&format!(".unaura {MOUNT_SPELL}"))?;
cx.session
.send_chat(&format!("{ATTACK_TP} {OUTDOOR_MAP}"))?;
// …and stay on the wire long enough to ACK that exit port. A far teleport the client never
// acks does not happen: dropping the session here would leave the probe character standing
// in a dungeon for the next run to find. Bounded, and only advisory — `stage` recovers from
// a stranded character anyway, so a quiet stream here is not a failure.
// Stay to ack the exit port: a far teleport the client never acks does not happen.
// Advisory only, since `stage` recovers a stranded character.
let until = Instant::now() + std::time::Duration::from_secs(5);
while Instant::now() < until && cx.world.self_map != OUTDOOR_MAP {
match cx.session.recv() {
@@ -204,7 +182,7 @@ impl Probe for MountTele {
}
}
/// `Option::ok_or_else` with the probe's message shape, so each `verify` line reads as one claim.
/// `Option::ok_or_else` with a message, so each `verify` line reads as one claim.
trait EnsureSome<T> {
fn ensure_some(&self, msg: &str) -> Result<T>;
}
@@ -1,13 +1,7 @@
//! `--open-item`: the openable-item wire — the fork a bag right-click makes when the clicked
//! item's template carries the LOOTABLE bit (`ItemInfo::openable`). The client does NOT send
//! `CMSG_USE_ITEM` for such an item (a clam has no on-use spell at all, so the use goes nowhere —
//! the director's "there is no way to open clams"): it sends `CMSG_OPEN_ITEM(bagIndex, slot)`, and
//! the server answers `SMSG_LOOT_RESPONSE` **on the item's own guid**, i.e. a loot window over a
//! thing in your bag rather than a corpse in the world.
//!
//! This probe proves the whole round trip against the live server: add a clam, open it by bag
//! position, require a loot response carrying rows on that guid, then release the window and
//! subtract the copy so the character is left as found.
//! `--open-item`: the openable-item wire. A bag right-click on an item whose template carries
//! LOOTABLE (`ItemInfo::openable`) sends `CMSG_OPEN_ITEM(bagIndex, slot)`, never `CMSG_USE_ITEM`,
//! and the server answers `SMSG_LOOT_RESPONSE` on the item's own guid. Adds a clam, opens it,
//! releases the window and removes the clam.
use std::time::{Duration, Instant};
@@ -16,9 +10,7 @@ use benilla_protocol::{decode, SessionEvent};
use crate::probes::{Ctx, Probe};
/// The probe target: "Small Barnacled Clam" (entry 7973) — the director's own case. Template
/// `Flags` carries LOOTABLE (`0x4`) with `LockID = 0`, so it is openable the moment it exists: no
/// key, no lockpicking, no rigging. Its `item_loot_template` rows are what come back.
/// Small Barnacled Clam: `Flags` has LOOTABLE (0x4) and `LockID` is 0, so it opens as is.
const ITEM_ENTRY: u32 = 7973;
pub(crate) struct OpenItem;
@@ -34,8 +26,7 @@ impl Probe for OpenItem {
let world = &mut *cx.world;
let session = &mut *cx.session;
// 1) Find the clam in the backpack, and remember WHICH slot — the wire addresses the item
// by position (bag 255 + the absolute player-array slot), never by guid.
// 1) Find the clam: the wire addresses it by position (bag 255, player slot), not guid.
let sf = world
.self_fields
.as_ref()
@@ -56,8 +47,7 @@ impl Probe for OpenItem {
slot0 + 1
);
// 2) The fork's own packet. A loot response on the ITEM's guid is the proof: nothing else
// in the protocol opens a loot window over a bag position.
// 2) Open it: nothing else opens a loot window on a bag item's own guid.
session.open_item(255, wire_slot)?;
let drain_until = Instant::now() + Duration::from_secs(5);
let mut verdict: Option<Result<String, String>> = None;
@@ -100,7 +90,7 @@ impl Probe for OpenItem {
.map_err(|e| anyhow::anyhow!("--open-item: {e}"))?;
println!("✅ open item: {v}");
// 3) Close the window again — an abandoned loot session would follow the character.
// 3) Close the window, or the loot session stays with the character.
session.loot_release(item_guid)?;
let until = Instant::now() + Duration::from_secs(2);
while Instant::now() < until {
@@ -114,7 +104,7 @@ impl Probe for OpenItem {
}
}
// Leave the probe character as found (the clam survives an un-emptied open).
// Remove the clam, which survives an open that is not emptied.
session.send_chat(&format!(".additem {ITEM_ENTRY} -1"))?;
println!("cleanup: .additem {ITEM_ENTRY} -1");
Ok(())
@@ -1,5 +1,5 @@
//! `--query-names`: the name-query pair. Ask our own name (`CMSG_NAME_QUERY`) at staging and the
//! first streamed creature's template name (`CMSG_CREATURE_QUERY`), require both to arrive and parse.
//! `--query-names`: the name-query pair. `CMSG_NAME_QUERY` for our own name and
//! `CMSG_CREATURE_QUERY` for the first streamed creature must both be answered and parse.
use anyhow::{bail, Context, Result};
use benilla_protocol::{guid, EntityKind};
@@ -40,8 +40,7 @@ impl Probe for QueryNames {
}
fn verify(&mut self, cx: &mut Ctx) -> Result<()> {
// --query-names verdict: both answers must have arrived and parsed (the creature one only if a
// creature streamed in at all).
// The creature answer is required only if a creature streamed in.
let own = cx
.world
.player_name_answer
@@ -1,6 +1,5 @@
//! `--quest`: accept + turn in a real quest end-to-end (decision 0088). GM-teleport onto Deputy
//! Willem, run the full questgiver wire (details → accept → complete → request/offer reward → choose
//! reward → quest-complete), verified against `PLAYER_QUEST_LOG` + `PLAYER_XP`.
//! `--quest`: accepts and turns in a real quest over the whole questgiver wire (details, accept,
//! complete, reward, quest-complete), checked against `PLAYER_QUEST_LOG` and `PLAYER_XP`.
use std::time::{Duration, Instant};
@@ -9,30 +8,21 @@ use benilla_protocol::{decode, guid, EntityKind, SessionEvent, WorldSession};
use crate::probes::{Ctx, Probe, FIELD_PLAYER_QUEST_LOG_1_1, QUEST_TURNIN_ENTRY, QUEST_TURNIN_TP};
/// A per-event handler passed to the `--quest` probe's `drain_quest` pump: it inspects each decoded
/// [`SessionEvent`] and returns `Some(msg)` to stop the drain early (the match landed) or `None` to
/// keep pumping.
/// A `drain_quest` handler: `Some(msg)` stops the drain, `None` keeps pumping.
type QuestEventHandler = Box<dyn FnMut(&SessionEvent) -> Option<String>>;
/// The `--quest` probe target: Northshire's opening quest 783 "A Threat Within" — no prerequisite,
/// no kill/collect objective (a "report to McBride" quest, so it's completable the instant it's
/// accepted, no GM objective-grind needed). Given by Deputy Willem (entry 823, at
/// -8933.5 -136.5 83.4) and turned in at Marshal McBride (entry 197, at -8902.6 -162.6 82.0) — both
/// a few yards apart in the Abbey, so one teleport streams both. Rewards 40 XP (no money), so the
/// grant is verified on `PLAYER_XP`.
/// Quest 783 "A Threat Within": no prerequisite and no objective, from Deputy Willem (823) to
/// Marshal McBride (197), a few yards apart in the Abbey. It rewards 40 XP and no money.
const QUEST_GIVER_TP: &str = ".go xyz -8933.54 -136.523 83.4466"; // onto Deputy Willem
const QUEST_GIVER_ENTRY: u32 = 823; // Deputy Willem — gives 783
const QUEST_GIVER_ENTRY: u32 = 823; // Deputy Willem, who gives 783
const QUEST_ID: u32 = 783;
pub(crate) struct Quest;
impl Probe for Quest {
fn stage(&mut self, cx: &mut Ctx) -> Result<()> {
// Clean slate so the accept is a real fresh accept (idempotent across probe re-runs), then
// teleport onto the giver (Deputy Willem) so he streams AND is in interaction range (the
// accept's CanInteractWithQuestGiver distance gate — ~5yd — is stricter than QUERY_QUEST).
// McBride streams too (~30yd away, within object-stream range); the turn-in teleports onto
// him later.
// Remove the quest for a fresh accept, then stand on Willem: the accept's
// `CanInteractWithQuestGiver` gate (about 5 yd) is stricter than QUERY_QUEST's.
cx.session.send_chat(&format!(".quest remove {QUEST_ID}"))?;
cx.session.send_chat(QUEST_GIVER_TP)?;
println!("sent GM: .quest remove {QUEST_ID}; teleport onto Willem {QUEST_GIVER_TP}");
@@ -44,10 +34,7 @@ impl Probe for Quest {
let session = &mut *cx.session;
let self_guid = world.self_guid;
// --quest: accept + turn in a real quest end-to-end (decision 0088). McBride streamed in after
// the QUEST_TP; run the full questgiver wire against him.
// A short blocking drain helper: pump packets up to `secs`, folding self-descriptor deltas
// into `self_fields`, and hand each decoded event to `f`; stop early when `f` returns Some.
// Pump for up to `secs`, folding self deltas into `self_fields`, until `f` returns `Some`.
let drain_quest = |session: &mut WorldSession,
sf: &mut Option<benilla_protocol::messages::ObjectFields>,
secs: u64,
@@ -72,9 +59,7 @@ impl Probe for Quest {
None
};
// Find the giver (Deputy Willem) + the turn-in NPC (Marshal McBride) among the streamed
// creatures (each guid carries its template entry in bits 24–47). Both stand in the Abbey a
// few yards from the teleport spot.
// Find Willem and McBride by entry, which a creature guid carries in bits 24–47.
let find_npc = |entry: u32| {
world
.tracked
@@ -113,9 +98,8 @@ impl Probe for Quest {
.context("--quest: no SMSG_QUESTGIVER_QUEST_DETAILS for quest 783 within 5s")?;
println!("✅ details: {details}");
// 2) Accept at the giver, then confirm the quest id landed in a PLAYER_QUEST_LOG slot of our
// descriptor (id field 198 + 3·i, over 20 slots). Poll ~6s — the values delta can trail the
// GOSSIP_COMPLETE that closes the interaction.
// 2) Accept, then find the quest id in a `PLAYER_QUEST_LOG` slot (field 198 + 3·i, 20
// slots), polling 6 s since the delta can trail the closing GOSSIP_COMPLETE.
println!("giver: CMSG_QUESTGIVER_ACCEPT_QUEST");
session.questgiver_accept_quest(willem, QUEST_ID)?;
let quest_in_log = |sf: &Option<benilla_protocol::messages::ObjectFields>| {
@@ -138,19 +122,16 @@ impl Probe for Quest {
if in_log {
println!("✅ accept: quest {QUEST_ID} is in the player descriptor's PLAYER_QUEST_LOG (field-confirmed)");
} else {
// The quest-log UpdateFields are the deferred log slice; a solo accept's low-priority
// values delta may not stream inside the poll window. Not fatal — the turn-in below can
// only produce QUEST_COMPLETE if the accept took, so it's the authoritative check.
// Not fatal: the delta may miss the window, and the turn-in only completes if the
// accept took.
println!(
"ℹ️ accept: quest {QUEST_ID} not observed in a PLAYER_QUEST_LOG field within the poll \
window (the log UpdateFields are a deferred slice); the turn-in below is authoritative"
);
}
// 3) Mark the quest COMPLETE server-side. "A Threat Within" is a talk-to-McBride quest — its
// status stays INCOMPLETE until the ender interaction, and CHOOSE_REWARD's CanRewardQuest
// gate requires COMPLETE — so GM-complete it (fair per the brief; grinding is impractical
// and this one has no grindable objective anyway). Print the server's reply and let it apply.
// 3) GM-complete it: a talk-to quest stays INCOMPLETE until the ender interaction, and
// CHOOSE_REWARD's `CanRewardQuest` requires COMPLETE.
session.send_chat(&format!(".quest complete {QUEST_ID}"))?;
println!("GM: .quest complete {QUEST_ID} (letting it apply…)");
drain_quest(
@@ -165,13 +146,12 @@ impl Probe for Quest {
}),
);
// Teleport onto McBride so the turn-in is in interaction range (his guid is already known
// from the giver-spot stream; only our position needs to change). Drain until the port acks.
// Teleport onto McBride for interaction range; his guid is already known.
session.send_chat(QUEST_TURNIN_TP)?;
println!("moving to McBride: {QUEST_TURNIN_TP}");
drain_quest(session, &mut world.self_fields, 3, Box::new(|_| None));
// Turn in at McBride: COMPLETE_QUEST → REQUEST_ITEMS(complete) or straight to OFFER_REWARD.
// Turn in at McBride: COMPLETE_QUEST answers REQUEST_ITEMS, or OFFER_REWARD directly.
println!("turn-in: CMSG_QUESTGIVER_HELLO + CMSG_QUESTGIVER_COMPLETE_QUEST({QUEST_ID})");
session.questgiver_hello(mcbride)?;
session.questgiver_complete_quest(mcbride, QUEST_ID)?;
@@ -196,9 +176,8 @@ impl Probe for Quest {
.context("--quest: no REQUEST_ITEMS/OFFER_REWARD after COMPLETE_QUEST within 5s")?;
println!("✅ progress: {progress}");
// 4) Reach the reward panel. When the quest has required items the progress panel came back
// above; its Continue → REQUEST_REWARD → OFFER_REWARD. When it has none (783), COMPLETE_QUEST
// *already* answered with OFFER_REWARD, so the reward panel is reached — skip REQUEST_REWARD.
// 4) With required items, the progress panel's Continue sends REQUEST_REWARD for
// OFFER_REWARD; without them (783), COMPLETE_QUEST already answered OFFER_REWARD.
if !progress.contains("OFFER_REWARD") {
println!("turn-in: CMSG_QUESTGIVER_REQUEST_REWARD");
session.questgiver_request_reward(mcbride, QUEST_ID)?;
@@ -225,8 +204,7 @@ impl Probe for Quest {
);
}
// 5) Finish: CHOOSE_REWARD (index 0 — no choice rewards) → QUEST_COMPLETE + the XP grant via
// UPDATE_OBJECT. Quest 783 rewards 40 XP (no money), so the grant is verified on PLAYER_XP.
// 5) CHOOSE_REWARD (index 0, no choices) brings QUEST_COMPLETE and the XP delta.
let xp_before = world.self_fields.as_ref().and_then(|sf| sf.player_xp());
println!("turn-in: CMSG_QUESTGIVER_CHOOSE_REWARD (choice 0)");
session.questgiver_choose_reward(mcbride, QUEST_ID, 0)?;
@@ -242,8 +220,8 @@ impl Probe for Quest {
c.money,
c.items.len()
)),
// Diagnostics: the server re-offers the reward if it rejected the choice, or offers
// the next quest in the chain (783 → 7) after a successful reward.
// Diagnostics: a rejected choice re-offers the reward, and a success offers the
// chain's next quest (783, then 7).
SessionEvent::QuestOffer(o) => {
eprintln!(" (diag) OFFER_REWARD again for quest {}", o.quest_id);
None