From 8d9f007415a27a7fc7e0a729880faf55182d25cf Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 Sep 2026 20:09:30 +0700 Subject: [PATCH] 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) --- .../examples/addon_chat_probe.rs | 105 ++----- .../examples/cooldown_probe.rs | 29 +- .../examples/faction_probe.rs | 11 +- .../examples/horde_chat_probe.rs | 33 +-- .../examples/login_refusal_probe.rs | 29 +- .../benilla-protocol/examples/make_account.rs | 5 +- .../benilla-protocol/examples/park_probe.rs | 16 +- .../benilla-protocol/examples/seed_classes.rs | 97 ++---- .../examples/srp_encoding_probe.rs | 70 ++--- .../examples/version_check_probe.rs | 70 ++--- crates/benilla-protocol/src/auth.rs | 163 ++++------- .../benilla-protocol/src/bin/benilla-auth.rs | 6 +- .../src/bin/benilla-world/main.rs | 275 ++++++------------ .../src/bin/benilla-world/probes/attack.rs | 21 +- .../src/bin/benilla-world/probes/aura.rs | 58 ++-- .../src/bin/benilla-world/probes/charge.rs | 44 +-- .../src/bin/benilla-world/probes/death.rs | 51 +--- .../benilla-world/probes/equip_pack_slot.rs | 13 +- .../bin/benilla-world/probes/giverstatus.rs | 15 +- .../src/bin/benilla-world/probes/groundfx.rs | 34 +-- .../src/bin/benilla-world/probes/loot.rs | 78 ++--- .../src/bin/benilla-world/probes/mod.rs | 36 +-- .../bin/benilla-world/probes/mount_tele.rs | 76 ++--- .../src/bin/benilla-world/probes/open_item.rs | 28 +- .../bin/benilla-world/probes/query_names.rs | 7 +- .../src/bin/benilla-world/probes/quest.rs | 68 ++--- 26 files changed, 470 insertions(+), 968 deletions(-) diff --git a/crates/benilla-protocol/examples/addon_chat_probe.rs b/crates/benilla-protocol/examples/addon_chat_probe.rs index 82dd8194..e1e9bfa6 100644 --- a/crates/benilla-protocol/examples/addon_chat_probe.rs +++ b/crates/benilla-protocol/examples/addon_chat_probe.rs @@ -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` convention). +/// The probe account's character: `probe4` plays `Probefour`. fn probe_char_name(user: &str) -> Option { 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::().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 { 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 } -/// 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 "` 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 "` 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); diff --git a/crates/benilla-protocol/examples/cooldown_probe.rs b/crates/benilla-protocol/examples/cooldown_probe.rs index 520666ae..6764e2fc 100644 --- a/crates/benilla-protocol/examples/cooldown_probe.rs +++ b/crates/benilla-protocol/examples/cooldown_probe.rs @@ -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`), `.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 { diff --git a/crates/benilla-protocol/examples/faction_probe.rs b/crates/benilla-protocol/examples/faction_probe.rs index e4ac015b..3c1f9632 100644 --- a/crates/benilla-protocol/examples/faction_probe.rs +++ b/crates/benilla-protocol/examples/faction_probe.rs @@ -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}; diff --git a/crates/benilla-protocol/examples/horde_chat_probe.rs b/crates/benilla-protocol/examples/horde_chat_probe.rs index ac36c945..f8beb134 100644 --- a/crates/benilla-protocol/examples/horde_chat_probe.rs +++ b/crates/benilla-protocol/examples/horde_chat_probe.rs @@ -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` 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` 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 { 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; diff --git a/crates/benilla-protocol/examples/login_refusal_probe.rs b/crates/benilla-protocol/examples/login_refusal_probe.rs index 250afd6d..9ca7a0af 100644 --- a/crates/benilla-protocol/examples/login_refusal_probe.rs +++ b/crates/benilla-protocol/examples/login_refusal_probe.rs @@ -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 { diff --git a/crates/benilla-protocol/examples/make_account.rs b/crates/benilla-protocol/examples/make_account.rs index 442c7052..998db832 100644 --- a/crates/benilla-protocol/examples/make_account.rs +++ b/crates/benilla-protocol/examples/make_account.rs @@ -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 -- \ diff --git a/crates/benilla-protocol/examples/park_probe.rs b/crates/benilla-protocol/examples/park_probe.rs index f6ca599b..3badbb46 100644 --- a/crates/benilla-protocol/examples/park_probe.rs +++ b/crates/benilla-protocol/examples/park_probe.rs @@ -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()) }) { diff --git a/crates/benilla-protocol/examples/seed_classes.rs b/crates/benilla-protocol/examples/seed_classes.rs index d04ec0d8..2ef3dd60 100644 --- a/crates/benilla-protocol/examples/seed_classes.rs +++ b/crates/benilla-protocol/examples/seed_classes.rs @@ -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 -- [flags] -//! --wipe delete EVERY existing character on the account first (see the warning below) +//! --wipe delete every character on the account first, irreversibly //! --level `.character level n` + `.learn all_myclass` (all class spells + talents) //! --tier `.character premade gear -` + `.character premade spec `; //! `` 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 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 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 ` 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 ` 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::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()); } diff --git a/crates/benilla-protocol/examples/srp_encoding_probe.rs b/crates/benilla-protocol/examples/srp_encoding_probe.rs index 23e8d1ed..4b591095 100644 --- a/crates/benilla-protocol/examples/srp_encoding_probe.rs +++ b/crates/benilla-protocol/examples/srp_encoding_probe.rs @@ -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)", diff --git a/crates/benilla-protocol/examples/version_check_probe.rs b/crates/benilla-protocol/examples/version_check_probe.rs index dd59749f..0300f38b 100644 --- a/crates/benilla-protocol/examples/version_check_probe.rs +++ b/crates/benilla-protocol/examples/version_check_probe.rs @@ -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 -- [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 -- +//! [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> { 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 [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)))?; diff --git a/crates/benilla-protocol/src/auth.rs b/crates/benilla-protocol/src/auth.rs index a95b7e37..29d5c108 100644 --- a/crates/benilla-protocol/src/auth.rs +++ b/crates/benilla-protocol/src/auth.rs @@ -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::()` 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 { 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 { .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 { }) } -// --- 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> { let opcode = read_u8(r)?; if opcode != CMD_REALM_LIST { @@ -324,7 +280,7 @@ pub fn read_realm_list(r: &mut impl Read) -> Result> { 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> { 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| { diff --git a/crates/benilla-protocol/src/bin/benilla-auth.rs b/crates/benilla-protocol/src/bin/benilla-auth.rs index 5313f6a8..b1c02f3a 100644 --- a/crates/benilla-protocol/src/bin/benilla-auth.rs +++ b/crates/benilla-protocol/src/bin/benilla-auth.rs @@ -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, diff --git a/crates/benilla-protocol/src/bin/benilla-world/main.rs b/crates/benilla-protocol/src/bin/benilla-world/main.rs index 9ee6deb7..4f33a2c1 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/main.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/main.rs @@ -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 `--` 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 `, 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 `). 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, - /// 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, - /// 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, - /// 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, - /// 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, - /// 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, - /// 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 `$w`/`$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 { 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> = 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], diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/attack.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/attack.rs index 858a1166..37fca7f2 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/attack.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/attack.rs @@ -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!( diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/aura.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/aura.rs index 761e14b0..aabf612d 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/aura.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/aura.rs @@ -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 ` -// 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 ` (`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 = 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); diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/charge.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/charge.rs index aad30f08..ba1c80f7 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/charge.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/charge.rs @@ -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, - /// 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, } @@ -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 diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/death.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/death.rs index 54a8608e..fe904c0a 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/death.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/death.rs @@ -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, 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() }; diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/equip_pack_slot.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/equip_pack_slot.rs index b2dd2884..fa35de9e 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/equip_pack_slot.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/equip_pack_slot.rs @@ -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 = 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 = 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 {}", diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/giverstatus.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/giverstatus.rs index ff8768ca..d445e4f4 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/giverstatus.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/giverstatus.rs @@ -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() diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/groundfx.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/groundfx.rs index 02e625da..fc1f744c 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/groundfx.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/groundfx.rs @@ -1,11 +1,7 @@ -//! `--groundfx `: 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 `: 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, /// 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, diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/loot.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/loot.rs index 2c3431f1..28c3f948 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/loot.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/loot.rs @@ -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 = 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)> = None; let mut loot_refusal: Option = 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 = Vec::new(); let mut pushes: Vec = Vec::new(); let mut items_created: Vec = 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; diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs index 82b199f2..a3200306 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs @@ -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; diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/mount_tele.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/mount_tele.rs index 14cbc5a6..dcad224d 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/mount_tele.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/mount_tele.rs @@ -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 { fn ensure_some(&self, msg: &str) -> Result; } diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/open_item.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/open_item.rs index e425fe0f..19283d31 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/open_item.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/open_item.rs @@ -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> = 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(()) diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/query_names.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/query_names.rs index 64277e86..121450ad 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/query_names.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/query_names.rs @@ -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 diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/quest.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/quest.rs index 7142bd87..fe579feb 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/quest.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/quest.rs @@ -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 Option>; -/// 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, 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| { @@ -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