diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/quest_item.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/quest_item.rs index df97d5f6..b41aa7eb 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/quest_item.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/quest_item.rs @@ -1,13 +1,6 @@ -//! `--quest-item`: the quest-STARTER item wire (decision 0664) — the fork a bag right-click makes -//! when the clicked item's template carries a non-zero `StartQuest`. The client does NOT send -//! `CMSG_USE_ITEM` for such an item (the server refuses that with `EQUIP_ERR_ITEM_NOT_FOUND`, the -//! red "The item was not found." line): it sends `CMSG_QUESTGIVER_QUERY_QUEST` addressed to the -//! **item's own guid**, and accepts against that same guid. -//! -//! This probe proves the whole item-as-questgiver round trip against the live server: add the item, -//! query its quest from the item guid, require `SMSG_QUESTGIVER_QUEST_DETAILS`, accept from the same -//! guid, and require the quest to land in `PLAYER_QUEST_LOG` with the starter kept-or-consumed as -//! the quest's own `ReqItemId`/`SrcItemId` dictate (vmangos `Player::AddQuest`). +//! `--quest-item`: an item with a non-zero `StartQuest` is its own questgiver. The client sends +//! `CMSG_QUESTGIVER_QUERY_QUEST` and the accept to the item's guid, never `CMSG_USE_ITEM`, which +//! the server refuses with `EQUIP_ERR_ITEM_NOT_FOUND`. use std::time::{Duration, Instant}; @@ -16,16 +9,11 @@ use benilla_protocol::{decode, SessionEvent, WorldSession}; use crate::probes::{Ctx, Probe, FIELD_PLAYER_QUEST_LOG_1_1}; -/// A per-event handler for this probe's drain pump: it inspects each decoded [`SessionEvent`] and -/// returns `Some(msg)` to stop the drain early (the match landed) or `None` to keep pumping — the -/// `--quest` probe's own shape. +/// A drain handler: `Some(msg)` stops the drain early, `None` keeps pumping. type QuestEventHandler = Box Option>; -/// The probe target: "Northshire Gift Voucher" (entry 14646), which starts quest 5805 "Welcome!" — -/// picked because it is takeable by the probe body as it stands (`MinLevel` 1, no prerequisite -/// quest, no race/class gate, item `RequiredLevel` 0 — VERIFIED live against -/// `mangos.item_template` ⋈ `quest_template`), so no rigging is needed. Non-equippable -/// (`inventory_type` 0) and spell-less, like all but five of the 215 quest-starters in 1.12. +/// "Northshire Gift Voucher", starter of quest 5805 "Welcome!": level 1, no prerequisite and no +/// race or class gate, so any character can take it. Not equippable and carries no spell. const ITEM_ENTRY: u32 = 14646; const QUEST_ID: u32 = 5805; @@ -33,8 +21,7 @@ pub(crate) struct QuestItem; impl Probe for QuestItem { fn stage(&mut self, cx: &mut Ctx) -> Result<()> { - // A clean slate so the accept is a real fresh accept across re-runs, then hand ourselves - // the starter item (`verify` subtracts it again at the end). + // Drop the quest so every run accepts it fresh; `verify` removes the item again. cx.session.send_chat(&format!(".quest remove {QUEST_ID}"))?; cx.session.send_chat(&format!(".additem {ITEM_ENTRY}"))?; println!("sent GM: .quest remove {QUEST_ID}; .additem {ITEM_ENTRY}"); @@ -46,9 +33,6 @@ impl Probe for QuestItem { let session = &mut *cx.session; let self_guid = world.self_guid; - // Pump packets up to `secs`, folding self-descriptor deltas into `self_fields` and handing - // each decoded event to `f`; stop early when `f` returns Some. (The `--quest` probe's own - // drain, which is a closure over its locals and so can't simply be shared.) let drain = |session: &mut WorldSession, sf: &mut Option, secs: u64, @@ -73,7 +57,7 @@ impl Probe for QuestItem { None }; - // 1) Find the starter item in the backpack — the guid IS the questgiver on this wire. + // 1) Find the starter in the backpack: its guid is the questgiver on this wire. let sf = world .self_fields .as_ref() @@ -89,7 +73,7 @@ impl Probe for QuestItem { })?; println!("\nquest-starter item {ITEM_ENTRY}: guid {item_guid:#x} (the giver on this wire)"); - // 2) The fork's own packet: QUERY_QUEST addressed to the ITEM guid → DETAILS. + // 2) QUERY_QUEST on the item guid answers with `SMSG_QUESTGIVER_QUEST_DETAILS`. println!("CMSG_QUESTGIVER_QUERY_QUEST({QUEST_ID}) on the item guid"); session.questgiver_query_quest(item_guid, QUEST_ID)?; let details = drain( @@ -110,12 +94,8 @@ impl Probe for QuestItem { )?; println!("✅ details: {details}"); - // 3) Accept against the same item guid: the quest must land in the log, and the starter - // must survive — vmangos `Player::AddQuest`'s "remove start item if not need" destroys a - // `TYPEID_ITEM` giver ONLY when the quest neither requires it (`ReqItemId`) nor names it - // `SrcItemId`, and this one is both (`quest_template` 5805: `SrcItemId = ReqItemId1 = - // 14646` — the voucher IS the turn-in). Asserting the retention pins the branch as firmly - // as a destroy would, and keeps the probe's item deterministic across re-runs. + // 3) Accept on the same guid. vmangos `Player::AddQuest` keeps an item giver the quest + // requires or names `SrcItemId`, and 5805 does both, so the starter must survive. println!("CMSG_QUESTGIVER_ACCEPT_QUEST({QUEST_ID}) on the item guid"); session.questgiver_accept_quest(item_guid, QUEST_ID)?; let mut destroyed = false; @@ -160,13 +140,9 @@ impl Probe for QuestItem { the quest's own required turn-in)" ); - // 4) The director's second click (decision 0669): the starter is still in the bag while - // the quest is in the log, so clicking it again re-sends the same QUERY. vmangos' - // `HandleQuestgiverQueryQuestOpcode` has NO status gate — it answers with the DETAILS - // again, which is why the panel legitimately re-opens — and the accept behind it is what - // fails: `CanTakeQuest` → `SatisfyQuestStatus` → `SendCanTakeQuestResponse` - // (`SMSG_QUESTGIVER_QUEST_INVALID`) with `INVALIDREASON_QUEST_ALREADY_ON` = 13 = 0x0d. - // That 13 is the code the client maps to `ERR_QUEST_ALREADY_ON` — pin BOTH halves here. + // 4) Click again while on the quest: `HandleQuestgiverQueryQuestOpcode` has no status gate, + // so DETAILS returns, and the accept is refused with `SMSG_QUESTGIVER_QUEST_INVALID` reason + // 13 (`INVALIDREASON_QUEST_ALREADY_ON`, shown as `ERR_QUEST_ALREADY_ON`). println!("\nsecond click while ON the quest — QUERY then ACCEPT again"); session.questgiver_query_quest(item_guid, QUEST_ID)?; let reopened = drain( @@ -207,8 +183,7 @@ impl Probe for QuestItem { ); println!("✅ refusal: QUEST_INVALID reason 13 → ERR_QUEST_ALREADY_ON (the ref's 0x5dbca0)"); - // Leave the probe character as found: drop the quest and the copy staging added (this - // starter survives the accept, so without the subtract every run would litter one). + // Leave the character as found; the starter survives the accept, so remove it too. session.send_chat(&format!(".quest remove {QUEST_ID}"))?; session.send_chat(&format!(".additem {ITEM_ENTRY} -1"))?; drain(session, &mut world.self_fields, 2, Box::new(|_| None)); diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/questlog.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/questlog.rs index cb3bad08..6f274848 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/questlog.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/questlog.rs @@ -1,6 +1,5 @@ -//! `--questlog`: the quest-LOG wire (decision 0109). Accept quest 7 at McBride, `CMSG_QUEST_QUERY` -//! it (the fat template parser's live golden), poll the `PLAYER_QUEST_LOG` slot, GM-complete it and -//! require the COMPLETE state byte, then `CMSG_QUESTLOG_REMOVE_QUEST` and require the id field to clear. +//! `--questlog`: accept quest 7 at McBride, query its template, GM-complete it and require the +//! slot's complete bit, then abandon it and require the slot's id field to clear. use std::time::{Duration, Instant}; @@ -17,10 +16,7 @@ pub(crate) struct QuestLog; impl Probe for QuestLog { 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 - // --giverstatus: 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 --giverstatus: `mcbride_staged` keeps these GM lines to one send per run. if !cx.world.mcbride_staged { cx.session .send_chat(&format!(".quest remove {QUESTLOG_ID}"))?; @@ -38,8 +34,7 @@ impl Probe for QuestLog { let session = &mut *cx.session; let self_guid = world.self_guid; - // --questlog: live-verify the quest-LOG wire (decision 0109) against quest 7 "Kobold Camp - // Cleanup" — McBride streamed in and we're in interaction range from the preamble teleport. + // The staging teleport lands on McBride, so he is streamed and in interaction range. let mcbride = world .tracked .iter() @@ -52,8 +47,7 @@ impl Probe for QuestLog { )?; println!("\nMarshal McBride: guid {mcbride:#x}"); - // 1) Query + accept quest 7 at McBride (he's both giver and ender — copies --quest's - // hello → query → accept flow, minus the separate turn-in NPC). + // 1) Hello, query and accept quest 7 at McBride, who also ends it. println!("giver: CMSG_QUESTGIVER_HELLO + CMSG_QUESTGIVER_QUERY_QUEST({QUESTLOG_ID})"); session.questgiver_hello(mcbride)?; session.questgiver_query_quest(mcbride, QUESTLOG_ID)?; @@ -75,8 +69,7 @@ impl Probe for QuestLog { println!("giver: CMSG_QUESTGIVER_ACCEPT_QUEST"); session.questgiver_accept_quest(mcbride, QUESTLOG_ID)?; - // Let the accept settle (its values delta can trail the GOSSIP_COMPLETE that closes the - // interaction) before asking for the template/slot below. + // The accept's values delta can trail the GOSSIP_COMPLETE that closes the interaction. let drain_until = Instant::now() + Duration::from_secs(3); while Instant::now() < drain_until { let Ok(msg) = session.recv() else { continue }; @@ -91,9 +84,7 @@ impl Probe for QuestLog { } } - // 2) The template: CMSG_QUEST_QUERY → SMSG_QUEST_QUERY_RESPONSE — the fat parser's live - // golden (hand-built fixtures already cover the wire traps; this proves the real byte - // stream). Require the title and at least one real (required_count > 0) objective. + // 2) CMSG_QUEST_QUERY: the template parser against the live server's bytes. println!("CMSG_QUEST_QUERY({QUESTLOG_ID})"); session.quest_query(QUESTLOG_ID)?; let mut template: Option = None; @@ -148,10 +139,8 @@ impl Probe for QuestLog { "✅ template: title matches '{QUESTLOG_TITLE}', ≥1 objective with required_count > 0." ); - // 3) Slot state: find which PLAYER_QUEST_LOG slot the accept landed in (same poll pattern - // --quest uses for its soft accept check, made a hard requirement here), then GM-complete - // it and require the slot's count-state field (id-field + 1) to gain the COMPLETE state - // byte (bit 0x01 at byte 3 — `count_state & 0xFF00_0000` gains `0x01 << 24`). + // 3) Find the accept's PLAYER_QUEST_LOG slot, GM-complete it, and require the slot's + // count-state field (id field + 1) to gain the complete bit, 0x01 in its top byte. let find_slot = |sf: &Option| { sf.as_ref().and_then(|sf| { (0..benilla_protocol::messages::PLAYER_QUEST_LOG_SLOTS) @@ -183,8 +172,7 @@ impl Probe for QuestLog { )?; println!("quest {QUESTLOG_ID} occupies PLAYER_QUEST_LOG slot {slot}"); - // The count-state word itself (id-field + 1) — read raw so we can print the exact - // before/after bytes, not just the decoded [`QuestLogSlot::state`]. + // Raw rather than decoded, so the exact before and after bytes print. let count_state_word = |sf: &Option| { sf.as_ref().and_then(|sf| { sf.raw_fields() @@ -233,8 +221,7 @@ impl Probe for QuestLog { complete ({before_word:#010x} → {after_word:#010x})." ); - // 4) Abandon: CMSG_QUESTLOG_REMOVE_QUEST(slot) — no ack SMSG exists on this wire (decision - // 0109); the descriptor's id field clearing to 0 IS the confirmation. + // 4) CMSG_QUESTLOG_REMOVE_QUEST has no reply; the slot's id field clearing is the ack. println!("CMSG_QUESTLOG_REMOVE_QUEST(slot {slot})"); session.questlog_remove_quest(slot)?; let mut cleared = false; diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/questtimer.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/questtimer.rs index 378c1d07..59594c93 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/questtimer.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/questtimer.rs @@ -1,24 +1,7 @@ -//! `--questtimer`: the timed-quest countdown's whole wire chain, live (decision 1150, B234). -//! -//! The countdown is not on the wire as a duration anywhere. The server writes a timed quest's -//! deadline into the quest-log slot's third field as an **absolute unix stamp** — `time(nullptr) + -//! limitTime`, vmangos `Player::AddQuest` — and the client is expected to subtract its own sample -//! of the server's clock, taken with `CMSG_QUERY_TIME`. Two independent readings, one subtraction, -//! and nothing in the client can tell you it got either one wrong: a mis-decoded field or a missing -//! clock both produce a number that *looks* like a countdown. -//! -//! So this probe reads both ends against the running server and checks the subtraction lands where -//! the quest template says it must: -//! -//! 1. `.quest remove` then `.quest add` [`TIMED_QUEST`] (idempotent across re-runs). -//! 2. Poll the descriptor for the slot the quest landed in and read its **raw** timer field. -//! 3. `CMSG_QUERY_TIME` for the server's wall clock. -//! 4. Assert the subtraction implies the quest was added *just now*, and print the countdown. -//! -//! Step 4 is what makes this more than a decode check. The probe added the quest itself seconds -//! ago, so the remaining time must sit just under the template's full limit — not merely somewhere -//! inside it. A wrong epoch misses that window by decades, a seconds/milliseconds slip by 1000×, -//! and a clock that never arrived by more than the run's own length. +//! `--questtimer`: the timed-quest countdown. The server writes the deadline into the quest-log +//! slot's third field as an absolute unix time, `time(nullptr) + limitTime` (vmangos +//! `Player::AddQuest`), and the countdown is that minus the server clock from `CMSG_QUERY_TIME`. +//! The probe adds the quest itself, so the remaining time must sit just under the full limit. use std::time::{Duration, Instant}; @@ -28,19 +11,13 @@ use benilla_protocol::SessionEvent; use crate::probes::{Ctx, Probe, FIELD_PLAYER_QUEST_LOG_1_1}; -/// "Iverron's Antidote" (Teldrassil) — `quest_template.LimitTime = 300`, min level 2, and no -/// prerequisite a GM `.quest add` cares about. Chosen because 5 minutes is long enough that the -/// remaining time is unambiguously mid-window while the probe runs, and short enough that a -/// seconds/milliseconds mix-up would fall outside it immediately. +/// "Iverron's Antidote" (Teldrassil): `LimitTime` 300, min level 2, and no prerequisite that a +/// GM `.quest add` checks. const TIMED_QUEST: u32 = 3522; -/// [`TIMED_QUEST`]'s `quest_template.LimitTime` (read from the running deploy's `mangos` DB). Not -/// on the wire in 1.12 — `SMSG_QUEST_QUERY_RESPONSE` has no such field, and vmangos reads -/// `GetLimitTime()` only server-side in `Player::AddQuest` — so it is a constant here rather than a -/// second packet's answer. +/// [`TIMED_QUEST`]'s `quest_template.LimitTime`. No 1.12 packet carries it, not even +/// `SMSG_QUEST_QUERY_RESPONSE`; vmangos reads it only in `Player::AddQuest`. const LIMIT_TIME_SECS: u32 = 300; -/// How much wall time the probe allows between its `.quest add` and the read below. Generous -/// against a slow login and a full stream window, and still ~100× tighter than any of the failure -/// modes it is bounding (a wrong epoch, a seconds/milliseconds slip, a clock that never arrived). +/// Seconds allowed between the `.quest add` and the read, generous for a slow login. const ADD_TO_READ_SLACK: f64 = 120.0; #[derive(Default)] @@ -69,8 +46,7 @@ impl Probe for QuestTimer { let Ctx { session, world } = cx; let self_guid = world.self_guid; - // 1) The slot. Poll the descriptor the same way `--questlog` does — the accept's field - // update can trail the chat ack by a beat. + // 1) The slot, polled: its field update can trail the GM command's chat ack. let find_slot = |sf: &Option| { sf.as_ref().and_then(|sf| { (0..benilla_protocol::messages::PLAYER_QUEST_LOG_SLOTS) @@ -106,8 +82,7 @@ impl Probe for QuestTimer { poll window (is `.quest add` refused for this character?)", )?; - // The timer field raw (id-field + 2), beside the decoded view, so a decode that reads the - // wrong field of the triple is visible as a mismatch rather than a plausible number. + // Raw timer field (id field + 2) beside the decoded one, so a wrong-field decode shows. let raw_timer = world .self_fields .as_ref() @@ -135,8 +110,7 @@ impl Probe for QuestTimer { ); } - // 2) The server's wall clock. Asked for here rather than leaning on a login-time send, so - // the probe is self-contained and the round trip itself is what's under test. + // 2) The server's wall clock, asked for here so the round trip itself is under test. session.query_time()?; let deadline = Instant::now() + Duration::from_secs(5); let asked_at = Instant::now(); @@ -160,18 +134,8 @@ impl Probe for QuestTimer { at.elapsed().as_secs_f64() ); - // 3) The subtraction, against a window the probe's own actions bound tightly on BOTH - // sides. `.quest add` ran seconds ago in `stage`, so the server wrote - // `now + LIMIT_TIME_SECS` seconds ago — the remaining time must therefore sit just under - // the full limit, not merely somewhere inside it. That tight upper bound is what makes - // this a real check: a wrong epoch is out by decades, a seconds/milliseconds slip by - // 1000×, and an absent or stale clock by more than the run's own length. - // - // (The limit is a constant because 1.12's `SMSG_QUEST_QUERY_RESPONSE` does not carry - // `LimitTime` — verified against the parsed body's field list and vmangos, which reads - // `GetLimitTime()` only in `Player::AddQuest`. Its provenance is the DB row cited on - // [`TIMED_QUEST`]; if content ever changes it, this probe fails loudly rather than - // quietly passing.) + // 3) A wrong epoch misses this window by decades, a seconds/milliseconds slip by a factor + // of 1000, an absent or stale clock by more than the run's length. let remaining = f64::from(raw_timer) - now; let elapsed_since_add = f64::from(LIMIT_TIME_SECS) - remaining; if !(0.0..=ADD_TO_READ_SLACK).contains(&elapsed_since_add) { diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/self_res.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/self_res.rs index e6b6415d..99a11d41 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/self_res.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/self_res.rs @@ -1,54 +1,36 @@ -//! `--self-res`: the self-resurrect wire (decision 1746) — `PLAYER_SELF_RES_SPELL` arriving at the -//! death, `CMSG_SELF_RES` spending it, and the field zeroing as we stand back up. -//! -//! The whole mechanism is one private descriptor field and one bodyless opcode, so the only way to -//! be sure of either is to watch a real death. The shared [`crate::world::DeathArc`] does the dying -//! — with `hold_release` set, because the button being tested lives on the DEATH dialog and that -//! dialog only exists *before* the release. -//! -//! **Reincarnation, not a soulstone**, because a soulstone needs a second character casting on us -//! while a shaman's passive needs only what a GM account can grant itself: vmangos's -//! `Player::SelectResurrectionSpellId` gates the Reincarnation arm on `HasSpell(20608)` + -//! `IsSpellReady(21169)` + `HasItemCount(17030, 1)` and asks **no class question**, so the staging -//! below arms it on whatever body the slot's probe account has. The two paths converge one line -//! later — both write their effect id into the same field — so the field, the send and the -//! resurrection are the same wire either way. +//! `--self-res`: `PLAYER_SELF_RES_SPELL` arrives at death, the bodyless `CMSG_SELF_RES` spends it, +//! and the field zeroes as we stand. The death holds its release: the button lives on the DEATH +//! dialog, which exists only before release. Reincarnation stands in for a soulstone: vmangos +//! `Player::SelectResurrectionSpellId` arms it with no class check, into the same field. use anyhow::{bail, Context, Result}; use benilla_protocol::SessionEvent; use crate::probes::{Ctx, Probe}; -/// Reincarnation's learnable passive — the `HasSpell` half of the server's gate. +/// Reincarnation's learnable passive, the `HasSpell` half of the server's gate. const REINCARNATION_PASSIVE: u32 = 20608; -/// Reincarnation's *effect* spell — what the server writes into `PLAYER_SELF_RES_SPELL`, and what -/// `Spell.dbc` names **"Reincarnation"** (the DEATH dialog's button text). +/// Reincarnation's effect: the `PLAYER_SELF_RES_SPELL` value, named on the DEATH dialog's button. const REINCARNATION_EFFECT: u32 = 21169; -/// Ankh — the reagent the same gate counts. +/// Ankh, the reagent the same gate counts. const ITEM_ANKH: u32 = 17030; #[derive(Default)] pub(crate) struct SelfRes { - /// The first non-zero `PLAYER_SELF_RES_SPELL` seen, and whether we were already at 0 health - /// when it landed — the ordering question the DEATH dialog's `OnShow` read depends on. + /// The first non-zero `PLAYER_SELF_RES_SPELL`, and whether health was already 0 when it came: + /// the DEATH dialog's `OnShow` reads the field. self_res_spell: Option, self_res_after_death: bool, sent: bool, - /// `PLAYER_SELF_RES_SPELL` read back as zero *after* the send — the server spending it. + /// `PLAYER_SELF_RES_SPELL` read back as zero after the send: the server spent it. field_cleared: bool, - /// Health back above 0 after the send, without ever having been a ghost. revived_without_releasing: bool, } impl Probe for SelfRes { fn stage(&mut self, cx: &mut Ctx) -> Result<()> { - // Arm the passive path. Reincarnation has a one-hour cooldown and a **successful run is - // what sets it**, so without this the probe passes once and then fails for an hour — which - // is exactly what it did, and the failure reads as "the server refused the gate" rather - // than "you already proved this". `.cooldown clear` (not `.cooldown`, which is a bare - // subcommand table and silently does nothing) removes all of the selected unit's - // cooldowns; with nothing selected `ChatHandler::GetSelectedUnit` falls back to self. - // The Ankh goes in every run because the resurrection spends it. + // A success starts Reincarnation's one-hour cooldown and spends the Ankh. Bare `.cooldown` + // does nothing; `.cooldown clear` with nothing selected clears our own. cx.session.send_chat(".cooldown clear")?; cx.session .send_chat(&format!(".learn {REINCARNATION_PASSIVE}"))?; @@ -61,8 +43,7 @@ impl Probe for SelfRes { } fn poll(&mut self, cx: &mut Ctx) -> Result<()> { - // Spend it once the death has landed AND the field has arrived — the same two facts the - // DEATH dialog's button2 waits on (`HasSoulstone()` non-nil while the popup is up). + // Dead, field set: the DEATH dialog offers button2 while `HasSoulstone()` is non-nil. let died = cx .world .death_arc @@ -83,8 +64,7 @@ impl Probe for SelfRes { if *guid != cx.world.self_guid { return Ok(()); } - // Read the DELTA for the arrival, not the merged store: the question is which packet - // carried the field, and a merged read cannot tell "arrived now" from "arrived earlier". + // The delta, not the merged store: only the delta says which packet carried the field. if let Some(spell) = fields.player_self_res_spell() { if self.self_res_spell.is_none() { self.self_res_spell = Some(spell); @@ -103,8 +83,8 @@ impl Probe for SelfRes { ); } } else if self.sent && !self.field_cleared { - // Zero and absent are the same `None` here, so the clear is only meaningful once the - // MERGED store also reads none — an unrelated delta must not be mistaken for it. + // A delta without the field also reads `None`, so the clear counts only once the + // merged store reads none too. if cx .world .self_fields diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/speed.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/speed.rs index 6e807a4d..8281cca1 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/speed.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/speed.rs @@ -1,6 +1,5 @@ -//! `--speed`: the force-speed-change wire. GM `.modify speed 1.5`, require + ack -//! `SMSG_FORCE_RUN_SPEED_CHANGE`, then `.modify speed 1` and require a SECOND change on a still-live -//! stream (a malformed ack drops the session, so surviving both round trips is the proof). +//! `--speed`: ack two `SMSG_FORCE_RUN_SPEED_CHANGE`s from `.modify speed`; a malformed ack drops +//! the session, so surviving both round trips is the proof. use anyhow::{ensure, Result}; use benilla_protocol::{SessionEvent, SpeedKind}; @@ -21,10 +20,8 @@ impl Probe for Speed { fn on_event(&mut self, ev: &SessionEvent, cx: &mut Ctx) -> Result<()> { if let SessionEvent::ForceSpeedChange { guid, .. } = ev { - // Fires after World has acked + recorded the change. World acks only when our pose is - // known (`tracked.get(guid)` Some, else it skips the arm entirely and never records) — - // mirror that guard (self + tracked-present) here so the second `.modify` is suppressed - // in exactly the same pose-missing case the old in-arm `continue` suppressed it. + // Runs after World's ack, which World sends only with our pose tracked; the same + // guard holds the second `.modify` back when there was no ack. if *guid == cx.world.self_guid && cx.world.tracked.contains_key(guid) && !self.second_sent @@ -38,9 +35,7 @@ impl Probe for Speed { } fn verify(&mut self, cx: &mut Ctx) -> Result<()> { - // --speed verdict: two changes, both Run, the flat speeds the GM rates imply (1.5×7.0 then - // 1.0×7.0), and an incremented counter — proof both acks were parsed (a malformed body drops - // the session before the second round trip could complete). + // Base run speed is 7.0 yd/s, so the rates 1.5 and 1 give 10.5 then 7.0. let speed_changes_seen = &cx.world.speed_changes_seen; ensure!( speed_changes_seen.len() >= 2, diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/spells.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/spells.rs index 128cb538..f302208b 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/spells.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/spells.rs @@ -1,6 +1,5 @@ -//! `--spells`: the spell/action wire. Require `SMSG_INITIAL_SPELLS` + `SMSG_ACTION_BUTTONS` at -//! login, cast one spell (self, then packed-guid targeted), require a `SMSG_CAST_RESULT` verdict, -//! and read our inventory back out of the descriptor as the round-trip evidence. +//! `--spells`: require `SMSG_INITIAL_SPELLS` and `SMSG_ACTION_BUTTONS` at login, then a +//! `SMSG_CAST_RESULT` for a self cast, a ground cast and a packed-guid targeted cast. use std::time::{Duration, Instant}; @@ -9,9 +8,8 @@ use benilla_protocol::{decode, guid, EntityKind, SessionEvent}; use crate::probes::{Ctx, Probe}; -/// The dest-cast phase's spell: 4054 "Rough Dynamite" — dest-targeted (`Targets = 0x40`), zero -/// mana, no reagents/totems, no equipped-item requirement, no aura state (live `spell_template` -/// sweep, decision 0792) — castable by ANY class the probe character happens to be, once GM-learnt. +/// "Rough Dynamite": dest-targeted (`Targets = 0x40`), no mana, reagent, equipment or aura state, +/// so any class can cast it once GM-learnt. const DEST_SPELL: u32 = 4054; #[derive(Default)] @@ -20,21 +18,15 @@ pub(crate) struct Spells { targeted_cast_sent: Option<(u32, u64)>, dest_cast_sent: Option<[f32; 3]>, dest_learn_sent: bool, - /// Set by `SMSG_LEARNED_SPELL` naming [`DEST_SPELL`] — the gate the dest cast waits behind. - /// The `.learn` chat command executes DEFERRED on the server (past the session's opcode - /// batch), so a cast sent in the same batch beats it and is silently dropped as unknown - /// ("casts spell 4054 which he shouldn't have" — observed live, 2026-07-30). + /// Set by `SMSG_LEARNED_SPELL` for [`DEST_SPELL`]. The server runs `.learn` after the + /// session's opcode batch, so a cast in the same batch is dropped as unknown. dest_spell_known: bool, } impl Probe for Spells { fn poll(&mut self, cx: &mut Ctx) -> Result<()> { - // Cast once both the book and the bar are known. The pick must be an *active* spell — - // vmangos silently drops a cast of a passive (HandleCastSpellOpcode returns before any - // CAST_RESULT), and the book mixes proficiencies/passives in. A spell the player slotted - // on the action bar is active by construction (6603 auto-attack excluded: it's the - // attack toggle, not a cast); Battle Shout 6673 is the fallback. Either verdict (ok or a - // failure reason like no-rage/bad-target) proves the round trip. + // `HandleCastSpellOpcode` drops a passive with no CAST_RESULT, so pick a bar spell, + // skipping auto-attack 6603 (a toggle, not a cast), else Battle Shout 6673. if self.cast_sent.is_none() { if let (Some(book), Some(bar)) = (&cx.world.spell_book, &cx.world.bar_spells) { let spell = bar @@ -51,16 +43,10 @@ impl Probe for Spells { } } } - // Phase 3 (decision 0792): the GROUND cast — mask 0x40 + three f32 WoW coords, the body - // the targeting cursor's world-click commit sends. GM-learn the classless DEST_SPELL, - // wait for the server's own `SMSG_LEARNED_SPELL` ack (the chat command executes deferred - // — a cast in the same batch is dropped as unknown), then cast at our own feet - // (`self_pose` — distance 0, always in range, always LOS). Routed to `dest_verdict` by - // spell id, so it can't collide with phase 2's positional slot. Gated like phase 2 on - // the phase-1 verdict (the book/bar round trip is proven). + // Phase 3, the ground cast: mask 0x40 and three f32 coords, the targeting cursor's world + // click body, cast at our own feet so it is always in range and line of sight. if self.dest_cast_sent.is_none() && cx.world.cast_verdict.is_some() { - // A prior run's deferred .learn may already have stuck — the login book then - // carries the spell and no fresh SMSG_LEARNED_SPELL will ever fire. + // A prior run's learn is already in the login book; no SMSG_LEARNED_SPELL will come. if !self.dest_spell_known && cx .world @@ -86,11 +72,8 @@ impl Probe for Spells { self.dest_cast_sent = Some(pos); } } - // Phase 2: once the self-cast AND the ground cast are answered, cast the bar spell AT - // the first streamed creature — the mask-2 + PACKED-guid target block. Ordered AFTER the - // dest phase deliberately: `.learn` executes deferred and resolves the SELECTION, so a - // creature selected here before the command ran turned the learn into "Player not - // found!" (observed live, 2026-07-30 — the `.cheat god` re-target trap, docs/METHOD.md). + // Phase 2, mask 2 with a packed guid. It runs after the ground cast: the deferred `.learn` + // acts on the selection and fails with "Player not found!" on a creature. if self.targeted_cast_sent.is_none() && cx.world.dest_verdict.is_some() { if let Some(spell) = self.cast_sent { if let Some((&guid, _)) = cx @@ -110,9 +93,7 @@ impl Probe for Spells { } fn on_event(&mut self, ev: &SessionEvent, _cx: &mut Ctx) -> Result<()> { - // The dest phase's learn ack: the book already carrying the spell (a prior probe run's - // leftover) counts the same — `SMSG_INITIAL_SPELLS` is handled at the gate below instead, - // since the book arrives before the learn is ever sent. + // The dest phase's learn ack; `poll` checks a login book that already has the spell. if let SessionEvent::SpellLearned { spell_id } = ev { if *spell_id == DEST_SPELL { self.dest_spell_known = true; @@ -125,7 +106,7 @@ impl Probe for Spells { let world = &mut *cx.world; let session = &mut *cx.session; - // --spells inventory readout: what the server says One is actually holding. + // Inventory readout: what the server says the character holds. if let Some(sf) = &world.self_fields { let slot_entry = |guid: Option| { guid.filter(|g| *g != 0) @@ -188,8 +169,7 @@ impl Probe for Spells { }; println!(" {label:<10} {text}"); }; - // Equipment names resolve through the PUBLIC visible-item entries (item objects - // have no decode path yet — a named gap; the private INV guids prove presence). + // Names come from the public visible-item entries; private INV guids show presence. for slot in [15u8, 16, 17] { if let Some(e) = sf.player_visible_item_entry(slot) { if !world.item_names.contains_key(&e) { @@ -213,7 +193,6 @@ impl Probe for Spells { println!("(no self descriptor captured — inventory readout skipped)"); } - // --spells verdict: book + bar must have arrived and parsed; the cast must have been answered. let book = world .spell_book .clone() @@ -243,9 +222,8 @@ impl Probe for Spells { (Some(e), _) => bail!("no SMSG_ITEM_QUERY_SINGLE_RESPONSE for entry {e}"), (None, _) => println!("(no item-kind button on the bar to item-query)"), } - // --spells dest-cast verdict (decision 0792): the wire shape must not merely parse — the - // cast must be ACCEPTED. A missing verdict means the body desynced the server's reader - // (the CMSG was dropped or the session died); a refusal names the CheckCast reason. + // The ground cast must be accepted: no verdict means the body desynced the server's + // reader, and a refusal carries the `CheckCast` reason. match (self.dest_cast_sent, &world.dest_verdict) { (Some(pos), Some((_, true, _))) => { println!( diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/spirit.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/spirit.rs index beb48c6b..da5c887d 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/spirit.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/spirit.rs @@ -1,7 +1,5 @@ -//! `--spirit`: the spirit-healer res + the 25% durability loss's wire (decision 0318). Repair to a -//! full baseline (the [`crate::world::DeathArc`] dies + releases), teleport onto the graveyard's -//! Spirit Healer, `CMSG_SPIRIT_HEALER_ACTIVATE`, and require BOTH the res (ghost flag clears) and -//! the post-activate `ITEM_FIELD_DURABILITY` deltas the loss must push. +//! `--spirit`: `CMSG_SPIRIT_HEALER_ACTIVATE` must clear the ghost flag and push the 25% +//! durability loss as `ITEM_FIELD_DURABILITY` deltas. use std::collections::HashMap; use std::time::{Duration, Instant}; @@ -11,9 +9,7 @@ use benilla_protocol::{guid, EntityKind, ObjectFields, SessionEvent}; use crate::probes::{Ctx, Probe}; -/// The merged per-item descriptor stores (create seed + every values-delta — the same shape the -/// app's `Items` store holds), the durability baseline snapshotted at activate time, and the raw -/// post-activate durability deltas the verdict requires. +/// Per-item merged descriptors, the durability baseline at activate time, and the deltas after. #[derive(Default)] pub(crate) struct Spirit { healer: Option<(u64, [f32; 3])>, @@ -27,18 +23,16 @@ pub(crate) struct Spirit { impl Probe for Spirit { fn stage(&mut self, cx: &mut Ctx) -> Result<()> { - // A full-durability baseline (`.repairitems`, vmangos `Chat.cpp:1297` SEC_GAMEMASTER): - // every post-activate delta must then read as a DROP, idempotent across probe re-runs - // (each spirit res costs a real 25%). + // Full durability, so each post-activate delta reads as a drop; `.repairitems` needs + // SEC_GAMEMASTER (vmangos `Chat.cpp:1297`). cx.session.send_chat(".repairitems")?; println!("sent GM: .repairitems (full-durability baseline)"); Ok(()) } fn poll(&mut self, cx: &mut Ctx) -> Result<()> { - // --spirit: once we're a released ghost and the graveyard's Spirit Healer has streamed, - // teleport onto it — the repop spot can land outside the activate's interaction gate - // (vmangos `GetNPCIfCanInteractWith`, INTERACTION_DISTANCE 5 yd). + // Teleport onto the healer: the repop spot can be outside the activate's 5 yd + // interaction gate (vmangos `GetNPCIfCanInteractWith`). if !self.healer_tp_sent && cx .world @@ -53,12 +47,9 @@ impl Probe for Spirit { self.healer_tp_sent = true; } } - // --spirit: standing on the healer, snapshot the durability baseline (the merged item - // stores: create seed + the `.repairitems` deltas) and ask for the res — the XP_LOSS - // popup's accept. The 2s settle after the teleport ack lets the server PROCESS the ack - // first: the movement-ack and world-packet queues are separate, and an activate racing - // its own teleport ack is range-checked from the PRE-teleport spot (>5 yd → silently - // refused; live-observed on the first probe run). + // On the healer, snapshot the baseline and send the XP_LOSS popup's accept. The server + // queues movement acks apart from world packets, so an activate sent too soon after the + // teleport is range-checked from the old spot and silently refused; hence the 2 s wait. if !self.activate_sent { if let (Some((hg, hp)), Some(landed)) = (self.healer, self.healer_tp_landed) { let (pos, _) = cx.world.self_pose(); @@ -92,10 +83,8 @@ impl Probe for Spirit { fn on_event(&mut self, ev: &SessionEvent, cx: &mut Ctx) -> Result<()> { match ev { - // --spirit: the graveyard's Spirit Healer advertises UNIT_NPC_FLAG_SPIRITHEALER (0x20 — - // vmangos `UnitDefines.h:662`, the flag `GetNPCIfCanInteractWith` gates the activate on). - // It only streams to ghosts, so it can't appear before release. (The capture condition is - // the arm guard so the whole match reads as one dispatch — clippy's collapsible_match.) + // `UNIT_NPC_FLAG_SPIRITHEALER` 0x20 (`UnitDefines.h:662`), which the activate's gate + // checks; the healer streams only to ghosts. SessionEvent::ObjectCreate { guid, kind, @@ -112,11 +101,7 @@ impl Probe for Spirit { self.healer = Some((*guid, *position)); } SessionEvent::ItemCreate { guid, fields, .. } => { - // --spirit: seed/overlay the item's merged store (the app's `Items` - // discipline) — the durability baseline reads off these. The print - // is the created-semantics live proof: a broken item's create OMITS - // its zero `DURABILITY` word, and the pair must still read `0/max` - // (the director's "100% on broken gear" bug). + // A broken item's create omits its zero `DURABILITY`; it must still read `0/max`. if let Some((d, m)) = fields .item_durability() .zip(fields.item_max_durability()) @@ -136,9 +121,7 @@ impl Probe for Spirit { } } } - // --spirit: an item values-delta — the durability wire under test. Log - // every `ITEM_FIELD_DURABILITY` it carries (post-activate ones are the - // verdict's evidence) and merge into the item's store. + // Item values deltas: the post-activate durability ones are the verdict's evidence. SessionEvent::ObjectValues { guid, fields } if guid::is_item(*guid) => { if let Some(d) = fields.item_durability() { if self.activate_sent { @@ -162,10 +145,7 @@ impl Probe for Spirit { } } } - // The old Teleport arm's `else if cli.spirit … healer_tp_landed` branch. Its condition is - // equivalent to the else-if: `healer_tp_sent` can only become true after `graveyard_pos` - // is Some (the healer-TP poll requires it), so this and World's graveyard capture (the - // other branch) are temporally disjoint. (Folded into the arm guard — collapsible_match.) + // `healer_tp_sent` follows World's graveyard capture, so this is the healer teleport. SessionEvent::Teleport { guid, .. } if *guid == cx.world.self_guid && self.healer_tp_sent @@ -184,8 +164,7 @@ impl Probe for Spirit { let healer_tp_sent = self.healer_tp_sent; let session = &mut *cx.session; - // Cleanup before judging: the res left the character alive at 75% — a GM repair keeps - // re-runs (and the shared GM character's gear) clean. Fire-and-forget; the stream is done. + // Repair before judging, so the 25% loss does not carry into the next run. session.send_chat(".repairitems")?; if !self.activate_sent { @@ -195,7 +174,7 @@ impl Probe for Spirit { ); } if !revived_seen { - // Don't leave the shared GM character a ghost (live-observed: the first run did). + // Do not leave the shared GM character a ghost. session.send_chat(".revive")?; bail!( "--spirit: CMSG_SPIRIT_HEALER_ACTIVATE never cleared the ghost flag — the res didn't land (a cleanup .revive was sent)" diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/swap_pack_slots.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/swap_pack_slots.rs index 12f84f7f..15346cbf 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/swap_pack_slots.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/swap_pack_slots.rs @@ -1,6 +1,5 @@ -//! `--swap-pack-slots`: `CMSG_SWAP_INV_ITEM` (the backpack pick/place/swap wire). Swap two 1-based -//! backpack slots, assert the exchange, then swap back and assert the original layout (leaves the -//! character as found). +//! `--swap-pack-slots`: `CMSG_SWAP_INV_ITEM` swaps two 1-based backpack slots, then swaps them +//! back so the character is left as found. use std::time::{Duration, Instant}; @@ -20,8 +19,7 @@ impl Probe for SwapPackSlots { let session = &mut *cx.session; let (a, b) = (self.a, self.b); - // --swap-pack-slots: swap two backpack slots, assert the exchange landed, then swap back and - // assert the original layout is restored (leaves the character as found). + // Backpack slots are player-array slots from 23 (`INVENTORY_SLOT_ITEM_START`). let sf = world .self_fields .as_mut() @@ -38,8 +36,6 @@ impl Probe for SwapPackSlots { CMSG_SWAP_INV_ITEM src {wire_a} dst {wire_b}" ); - // A closure would borrow `session` + `sf` at once; inline the drain so the borrows don't - // overlap. Await until both slots read their expected guids (or 5s). session.swap_inv_item(wire_a, wire_b)?; let mut settled = false; let drain_until = Instant::now() + Duration::from_secs(5); @@ -64,7 +60,6 @@ impl Probe for SwapPackSlots { } println!("✅ swap: slots {a}↔{b} exchanged in the descriptor (guids confirmed)."); - // Swap back and require the original layout to return. session.swap_inv_item(wire_a, wire_b)?; let mut restored = false; let drain_until = Instant::now() + Duration::from_secs(5); diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/use_pack_slot.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/use_pack_slot.rs index deccaa37..6a8410fe 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/use_pack_slot.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/use_pack_slot.rs @@ -1,5 +1,5 @@ -//! `--use-pack-slot`: `CMSG_USE_ITEM` on a 1-based backpack slot; require a server reaction — a -//! stack-count delta, a destroy on the item's guid, or an explicit cast-result refusal. +//! `--use-pack-slot`: `CMSG_USE_ITEM` on a 1-based backpack slot must draw a stack-count delta, +//! a destroy of the item, or a cast-result refusal. use std::time::{Duration, Instant}; @@ -18,7 +18,7 @@ impl Probe for UsePackSlot { let session = &mut *cx.session; let n = self.n; - // --use-pack-slot: poke a real item and require a server reaction on its guid. + // Bag 255 (`INVENTORY_SLOT_BAG_0`) is the player's own array; the backpack is slots 23-38. let sf = world .self_fields .as_ref() diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/vendor.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/vendor.rs index d8a76eb9..25c2b334 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/vendor.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/vendor.rs @@ -1,6 +1,5 @@ -//! `--vendor`: the vendor wire (decision 0081 phase 4). Auto-find the nearest streamed vendor, -//! `CMSG_LIST_INVENTORY` it, buy the cheapest row, and require a reaction — plus confirm the money -//! accessor (`PLAYER_FIELD_COINAGE`) reads and that a coinage delta lands on that field. +//! `--vendor`: list the nearest vendor, buy its cheapest row and require a reaction; a coinage +//! delta confirms the `PLAYER_FIELD_COINAGE` accessor. use std::time::{Duration, Instant}; @@ -9,8 +8,8 @@ use benilla_protocol::{decode, SessionEvent}; use crate::probes::{Ctx, Probe}; -/// vmangos `INTERACTION_DISTANCE` (`Objects/ObjectDefines.h:24`) — the range every NPC service -/// opcode is gated on, silently. +/// vmangos `INTERACTION_DISTANCE` (`Objects/ObjectDefines.h:24`): every NPC service opcode past +/// it is silently ignored. const INTERACTION_DISTANCE: f32 = 5.0; pub(crate) struct Vendor; @@ -21,8 +20,6 @@ impl Probe for Vendor { let session = &mut *cx.session; let self_guid = world.self_guid; - // --vendor: list the nearest streamed vendor and buy its cheapest row; also confirm the money - // accessor (`PLAYER_FIELD_COINAGE`) reads and that any coinage delta lands on that field. let sf = world .self_fields .as_mut() @@ -36,7 +33,7 @@ impl Probe for Vendor { ); let self_pos = world.tracked.get(&self_guid).map(|t| t.position); - // The nearest advertised vendor (2D distance from us; z ignored — Northshire is ~flat here). + // Nearest by 2D distance; Northshire is nearly flat. let vendor = self_pos.and_then(|p| { world .vendors @@ -48,12 +45,8 @@ impl Probe for Vendor { "no UNIT_NPC_FLAG_VENDOR creature streamed in range (fresh Northshire spawn has none — \ re-run with a longer --seconds after moving toward a vendor, or grant GM to teleport)", )?; - // The server refuses SILENTLY past interaction range, so an out-of-range send looks exactly - // like a broken wire: no packet, a 5s drain, "nothing arrived". Name the real cause here - // instead of leaving it to be re-diagnosed. VERIFIED vmangos `INTERACTION_DISTANCE = 5.0f` - // (`Objects/ObjectDefines.h:24`), checked by `Player::CanInteractWithNPC`'s last gate - // (`Objects/Player.cpp:2556`) — a 3D check with both bounding radii allowed for, so this 2D - // screen is approximate and deliberately generous. + // The server's check (`Player::CanInteractWithNPC`, `Objects/Player.cpp:2556`) is 3D and + // allows for both bounding radii, so this 2D screen is only approximate. if dist > INTERACTION_DISTANCE { bail!( "nearest vendor {vendor_guid:#x} is {dist:.1} yd away; vmangos refuses \ @@ -70,7 +63,6 @@ impl Probe for Vendor { ); session.list_inventory(vendor_guid)?; - // Drain for the vendor's stock list. let mut items: Option> = None; let drain_until = Instant::now() + Duration::from_secs(5); while Instant::now() < drain_until && items.is_none() { @@ -104,8 +96,7 @@ impl Probe for Vendor { bail!("the vendor listed 0 rows — can't exercise a buy"); } - // Buy the cheapest priced, in-stock row (price > 0; current_count 0 = sold out — a limited - // row we already drained; 0xFFFF_FFFF = unlimited, always buyable), one stack. + // `current_count` 0 is sold out and 0xFFFF_FFFF unlimited. let cheapest = rows .iter() .filter(|r| r.price > 0 && r.current_count != 0) @@ -117,10 +108,7 @@ impl Probe for Vendor { ); session.buy_item(vendor_guid, cheapest.entry, 1)?; - // Drain for a reaction: the item arriving, the stock updating, a coinage delta on exactly the - // COINAGE field, or a decoded refusal — any one proves the round trip. Keep draining the full - // window (not stopping at the first) so a coinage delta — the strongest evidence, it confirms - // the accessor *index* — is captured even when the item-create lands first in the same batch. + // Keep draining past the first reaction: the coinage delta can trail the item create. let mut round_trip: Option = None; let mut coinage_delta: Option = None; let drain_until = Instant::now() + Duration::from_secs(5); diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/worldstate.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/worldstate.rs index 2117512c..fe2dcce6 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/worldstate.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/worldstate.rs @@ -1,28 +1,10 @@ -//! `--worldstate`: the world-state table's two wires, end to end against the live server. +//! `--worldstate`: `.debug send worldstate ` must come back as that exact +//! `SMSG_UPDATE_WORLD_STATE` pair, and a zone change must re-send `SMSG_INIT_WORLD_STATES` +//! (`Player::UpdateZone`, `Player.cpp:6660`), which cannot be requested. //! -//! Both are worth a live run for different reasons. `SMSG_UPDATE_WORLD_STATE` is *deterministic* — -//! `.debug send worldstate ` makes the server send one pair we chose, so the probe can -//! require that exact pair back and a wrong reader shows up as a mismatch rather than a plausible -//! number. `SMSG_INIT_WORLD_STATES` is the one with real parse risk (two leading dwords, a `u16` -//! count, a run of pairs, a `(0,0)` terminator that *is* counted) and it can't be requested — so -//! the probe forces one by teleporting across a zone boundary, which is what makes vmangos re-send -//! it (`Player::UpdateZone` → `SendInitWorldStates`, `Player.cpp:6660`). -//! -//! **The update leg needs a temporary grant.** `.debug send worldstate` sits in -//! `debugSendCommandTable` at **SEC_DEVELOPER = 5** (vmangos `Chat.cpp:323`) — one above the -//! gmlevel 6 the probe accounts carry (decisions 0450/0651). Without it the server answers -//! *"This command is not available to you."* in chat and sends no packet, and the probe fails on -//! the update leg while the init leg still passes. -//! -//! The effective level lives in **`realmd.account_access`**, not `realmd.account.gmlevel` — -//! mangosd reads the former (rows per `RealmID`, plus a `-1` catch-all) and ignores the latter for -//! command gating. Grant, run, revert, with the account offline: -//! -//! ```text -//! UPDATE realmd.account_access SET gmlevel=5 WHERE id=; -- both rows -//! cargo run -p benilla-protocol --bin benilla-world -- probeN pprobeN --worldstate --seconds 16 -//! UPDATE realmd.account_access SET gmlevel=3 WHERE id=; -- put it straight back -//! ``` +//! The command needs SEC_DEVELOPER, 5 (vmangos `Chat.cpp:323`); below it the server only refuses +//! in chat. The level is read from `realmd.account_access` (a row per `RealmID` and a `-1` row), +//! not `realmd.account.gmlevel`: grant both rows with the account offline, then revert. use std::time::{Duration, Instant}; @@ -31,22 +13,18 @@ use benilla_protocol::SessionEvent; use crate::probes::{Ctx, Probe}; -/// Northshire (zone 9, Elwynn Forest) — the same spot the quest probes stage on. +/// Northshire, zone 9 (Elwynn Forest). const ELWYNN_TP: &str = ".go xyz -8902.59 -162.606 82.0223"; -/// Stormwind's Trade District (zone 1519) — a different zone on the same map, so the hop from -/// [`ELWYNN_TP`] is a genuine `UpdateZone` and forces a fresh `SMSG_INIT_WORLD_STATES`. +/// Stormwind's Trade District, zone 1519 on the same map, so the hop is a real zone change. const STORMWIND_TP: &str = ".go xyz -8913.0 554.0 93.8"; -/// How long to sit in Elwynn before hopping, so the first zone's init has landed and the second is -/// unambiguously a *second* packet. +/// Time in Elwynn before the hop, so the second init is clearly a second packet. const HOP_AFTER: Duration = Duration::from_secs(4); -/// A synthetic `(id, value)` no real content uses, so a match proves our reader and not a -/// coincidence with a live Silithus/AQ counter. +/// A synthetic `(id, value)` no real content uses. const PROBE_ID: u32 = 0xBEEF; const PROBE_VALUE: u32 = 1_234_567; -/// One received `SMSG_INIT_WORLD_STATES`: the packet's `(map, zone)` scope and its pair run -/// verbatim — terminator included, which [`WorldState::verify`] asserts on. +/// One `SMSG_INIT_WORLD_STATES`: its `(map, zone)` scope and its pairs, terminator included. struct Init { map: u32, zone: u32, @@ -75,7 +53,6 @@ impl Probe for WorldState { return Ok(()); } self.hopped = true; - // The zone hop (forces an init) and the one deterministic pair, back to back. cx.session.send_chat(STORMWIND_TP)?; cx.session .send_chat(&format!(".debug send worldstate {PROBE_ID} {PROBE_VALUE}"))?; @@ -122,8 +99,7 @@ impl Probe for WorldState { if self.inits.is_empty() { bail!("no SMSG_INIT_WORLD_STATES arrived — the zone hop should have forced one"); } - // The terminator is counted by the server and read by us: a well-formed init ends on (0,0). - // Assert it rather than assume, since dropping it would be the natural "helpful" bug. + // The server counts the (0,0) terminator, so a parsed init must end on it. for init in &self.inits { if init.states.last() != Some(&(0, 0)) { bail!( diff --git a/crates/benilla-protocol/src/bin/benilla-world/world.rs b/crates/benilla-protocol/src/bin/benilla-world/world.rs index 89b75817..8ff6e66f 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/world.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/world.rs @@ -1,7 +1,4 @@ -//! The shared world state every probe reads: the entity tracker, the merged self descriptor, the -//! item/vendor stores, the opcode tally — plus the session-keeping acks (teleport, force-speed, the -//! granted movement-mode family) that keep *any* session alive regardless of probe mix, and the shared -//! [`DeathArc`] scenario machinery (`--death`/`--spirit` both read it, so neither owns it). +//! The state every probe shares, the acks that keep any session alive, and the [`DeathArc`]. use std::collections::{BTreeMap, HashMap}; use std::time::Instant; @@ -12,9 +9,8 @@ use benilla_protocol::{ SpeedKind, WorldSession, }; -/// A Kobold Vermin spawn in Northshire (mangos `creature` guid 79992) — the `--attack` teleport -/// target: hostile, level 1, melee-reach the moment we land on it. Lives here (not in a probe) -/// because attack staging, loot staging, and the [`DeathArc`] staging all teleport to it. +/// A Kobold Vermin spawn in Northshire (`creature` guid 79992): hostile, level 1, in melee reach +/// on landing. Attack, loot and death staging all teleport here. pub(crate) const ATTACK_TP: &str = ".go xyz -8780.71 -164.568 81.94"; /// One entity decoded from the stream, as `benilla-world` reports it (raw WoW coords). @@ -24,10 +20,7 @@ pub(crate) struct Tracked { pub(crate) orientation: f32, } -/// The shared die→release→ghost→corpse scenario machinery (decision 0308 slice 1). Created when -/// `--death` or `--spirit` is set; both probes read it, so neither owns it. The staging is in -/// [`World::stage`]; the die/release pumps in [`World::poll`]; the ghost/graveyard/reclaim evidence -/// in [`World::on_event`]. +/// The shared die, release, ghost and corpse sequence the death probes read. #[derive(Default)] pub(crate) struct DeathArc { pub(crate) die_sent: bool, @@ -40,13 +33,10 @@ pub(crate) struct DeathArc { pub(crate) ghost_seen: bool, pub(crate) graveyard_pos: Option<[f32; 3]>, pub(crate) reclaim_delay_ms: Option, - /// Set when the death probe sends `.revive` OR the spirit probe sends the healer activate — the - /// ghost-clear check keys off this instead of either probe's own flag, so the two are decoupled. + /// Set by `.revive` (death) or the healer activate (spirit); the ghost-clear check needs it. pub(crate) revive_initiated: bool, pub(crate) revived_seen: bool, - /// **Stop at dead-unreleased** — `--self-res` sets it, and nothing else does (decision 1746). - /// The soulstone button lives on the DEATH dialog, which only exists *before* the release; an - /// arc that repops on its own would walk straight past the state under test. + /// Stop dead and unreleased for `--self-res`: the DEATH dialog exists only until release. pub(crate) hold_release: bool, } @@ -56,18 +46,16 @@ pub(crate) struct World { pub(crate) self_name: String, pub(crate) self_level: u8, pub(crate) self_map: u32, - /// The `SMSG_CHAR_ENUM` spawn pose, the [`Self::self_pose`] fallback before our own create streams. + /// The `SMSG_CHAR_ENUM` spawn pose, used until our own create streams. pub(crate) spawn_pos: [f32; 3], pub(crate) tracked: HashMap, - /// Our own descriptor fields (equipment/pack slots are PRIVATE-flagged — sent only for our own - /// player) merged across the create + every values delta. + /// Our merged descriptor; its private equipment and pack slots are sent only to us. pub(crate) self_fields: Option, pub(crate) item_entries: HashMap, pub(crate) item_stacks: HashMap, pub(crate) item_names: HashMap, - /// Streamed NPCs that advertise UNIT_NPC_FLAG_VENDOR, by guid → position. pub(crate) vendors: HashMap, - /// The last same-map teleport landing point (the `--attack`/`--charge`/`--loot`/`--death` spot). + /// The last same-map teleport landing point, whichever probe's staging sent it. pub(crate) attack_pos: Option<[f32; 3]>, /// Every self force-speed change seen (kind, counter, flat speed). pub(crate) speed_changes_seen: Vec<(SpeedKind, u32, f32)>, @@ -79,21 +67,17 @@ pub(crate) struct World { pub(crate) item_answer: Option<(u32, Option)>, pub(crate) cast_verdict: Option<(u32, bool, Option)>, pub(crate) targeted_verdict: Option<(u32, bool, Option)>, - /// The `--spells` dest-cast phase's spell (decision 0792): a `SMSG_CAST_RESULT` naming it - /// routes to [`Self::dest_verdict`] instead of the positional pair above — the dest phase - /// runs concurrently with the targeted phase, so position alone can't route it. + /// The `--spells` ground-cast spell, whose `SMSG_CAST_RESULT` goes to [`Self::dest_verdict`]. pub(crate) dest_spell: Option, pub(crate) dest_verdict: Option<(u32, bool, Option)>, pub(crate) swings_seen: u32, - /// The refusals the server answered a swing with, by arm — `--attack`'s phase 2 verdict. The - /// wire has no reason byte, so the arm IS the reason. + /// Swing refusals: the wire has no reason byte, the opcode is the reason. pub(crate) swing_refusals: Vec, pub(crate) self_moves: u32, pub(crate) tally: BTreeMap, pub(crate) total: u32, pub(crate) death_arc: Option, - /// Shared staging flags: the ATTACK_TP (attack/loot/DeathArc all want it) and the McBride - /// teleport (questlog/giverstatus both want it) each go out once, set by whichever stages first. + /// Shared teleports go out once: whichever probe stages first sets the flag. pub(crate) attack_tp_staged: bool, pub(crate) mcbride_staged: bool, } @@ -139,10 +123,7 @@ impl World { } } - /// Our tracked pose, for the movement acks the server expects while dead/ghost (root, - /// water-walk): the probe holds still throughout the arc, so any recent `tracked` position is - /// truthful — fall back to the SMSG_CHAR_ENUM spawn pose for the sliver of time before our own - /// ObjectCreate has streamed. + /// Our pose for movement acks: the probe holds still, so the tracked position is current. pub(crate) fn self_pose(&self) -> ([f32; 3], f32) { match self.tracked.get(&self.self_guid) { Some(t) => (t.position, t.orientation), @@ -150,26 +131,19 @@ impl World { } } - /// Count a received packet: one per `recv`, before decode (the opcode tally + total). + /// Count a received packet, once per `recv` and before decode. pub(crate) fn tally_packet(&mut self, msg: &ServerPacket) { self.total += 1; *self.tally.entry(msg.name()).or_default() += 1; } - /// One-time pre-stream staging for the [`DeathArc`]. Called BEFORE the probes' stage loop, so - /// for `--spirit` the order is preserved: the arc's `.revive` + teleport, then spirit's - /// `.repairitems`. + /// The [`DeathArc`]'s staging. It runs before the probes' own, so the arc's `.revive` and + /// teleport precede `--spirit`'s `.repairitems`. pub(crate) fn stage(&mut self, session: &mut WorldSession) -> Result<()> { if self.death_arc.is_some() { - // Stage AWAY from any graveyard first (the kobold spot the other probes use): dying AT a - // graveyard makes the release teleport a 0-yd no-op and the >20-yd graveyard assertion - // reads it as a missing port (live-observed: a cleanup `.revive` leaves the character at - // the graveyard, and the next `.die` there re-pops in place). The `.die` itself goes out - // in-loop, once this staging teleport has landed. The `.revive` first is defensive: a - // prior aborted run can leave the character a ghost (live-observed), and the arc under - // test must start from alive (it's a no-op on a living character). + // Die away from any graveyard: a death at one re-pops in place, and the graveyard + // check reads that as a missing port. `.revive` first: a prior run may leave a ghost. session.send_chat(".revive")?; - // The ATTACK_TP is the shared teleport — mark it staged so attack/loot don't re-send it. session.send_chat(ATTACK_TP)?; self.attack_tp_staged = true; println!("sent GM: .revive (defensive); teleport (death staging): {ATTACK_TP}"); @@ -180,16 +154,13 @@ impl World { /// Every pump iteration, before recv: the [`DeathArc`] die/release steps. pub(crate) fn poll(&mut self, session: &mut WorldSession) -> Result<()> { if let Some(arc) = &mut self.death_arc { - // --death/--spirit: kill ourselves once the staging teleport has landed (`.die` with - // nothing selected targets the caster). + // `.die` with nothing selected kills the caster; sent once the staging teleport lands. if !arc.die_sent && self.attack_pos.is_some() { session.send_chat(".die")?; println!("sent GM: .die (self-kill)"); arc.die_sent = true; } - // --death: once we've hit 0 health AND the server has force-rooted us (the wire doesn't - // guarantee which arrives first — see the MoveRoot/ObjectValues arms below), release the - // spirit: the RELEASE SPIRIT button's `CMSG_REPOP_REQUEST` (decision 0308 §1). + // Release spirit (`CMSG_REPOP_REQUEST`) once dead and rooted, in whichever order. if !arc.hold_release && !arc.repop_sent && arc.died_at.is_some() && arc.rooted_seen { session.repop_request()?; println!("sent CMSG_REPOP_REQUEST (release spirit)"); @@ -199,8 +170,7 @@ impl World { Ok(()) } - /// Every decoded event, before the probes' `on_event`: the ungated narration/evidence + the - /// unconditional session-keeping acks + the [`DeathArc`] evidence. + /// Each decoded event, before the probes' `on_event`: logging, session acks, death evidence. pub(crate) fn on_event(&mut self, ev: &SessionEvent, session: &mut WorldSession) -> Result<()> { match ev { SessionEvent::ObjectCreate { @@ -214,8 +184,7 @@ impl World { if *guid == self.self_guid { self.self_fields = Some(fields.clone()); } - // A vendor advertises UNIT_NPC_FLAG_VENDOR (0x4 — NOT 0x80, which is - // INNKEEPER in 1.12; VERIFIED vmangos `UnitDefines.h:659`) in NPC_FLAGS. + // `UNIT_NPC_FLAG_VENDOR` is 0x4; in 1.12 0x80 is innkeeper (`UnitDefines.h:659`). if *kind == EntityKind::Unit && fields.unit_npc_flags() & 0x4 != 0 { self.vendors.insert(*guid, *position); } @@ -237,10 +206,8 @@ impl World { .insert(*guid, fields.item_stack_count().unwrap_or(1)); } SessionEvent::ObjectValues { guid, fields } if *guid == self.self_guid => { - // --death: the server force-flushes UNIT_FIELD_HEALTH the instant we - // die — a values delta carrying an EXPLICIT 0 (not merely absent) is - // the death instant itself. Must be read off `fields` (this delta) - // before the merge below moves it. + // The server flushes an explicit UNIT_FIELD_HEALTH 0 at the instant of death; read + // it off this delta, before the merge below. let pose = self.self_pose(); if let Some(arc) = &mut self.death_arc { if arc.died_at.is_none() && fields.unit_health() == Some(0) { @@ -255,10 +222,8 @@ impl World { } if let Some(sf) = &mut self.self_fields { sf.merge(fields.clone()); - // PLAYER_FLAGS_GHOST (bit 0x10, field 190) sets at release and - // clears at revive — read off the MERGED store, not this delta - // alone: a delta that doesn't touch PLAYER_FLAGS reads as an - // absent field on `fields`, not "still whatever it last was". + // PLAYER_FLAGS_GHOST (0x10, field 190) sets at release and clears at revive. + // Read the merged store: a delta without PLAYER_FLAGS reads as absent. if let Some(arc) = &mut self.death_arc { if !arc.ghost_seen && sf.player_is_ghost() { arc.ghost_seen = true; @@ -326,7 +291,6 @@ impl World { b.slot, b.action, b.kind ); } - // Item-query the first item-kind button (T2 wire groundwork), once. if self.item_asked.is_none() { if let Some(b) = buttons.iter().find(|b| b.kind == 0x80) { session.item_query(b.action, 0)?; @@ -372,9 +336,8 @@ impl World { counter, speed, } if *guid == self.self_guid => { - // Ack with our live (streamed) pose — a zeroed fallback would ask the - // server to relocate us to (0,0,0), so no known pose means no ack (the - // post-stream ensure then fails loudly instead). + // The ack carries our pose and a zeroed one would move us to (0,0,0), so with no + // streamed pose there is no ack. let Some(t) = self.tracked.get(guid) else { println!( "force {kind:?} speed change before our own create streamed — cannot ack" @@ -394,12 +357,8 @@ impl World { ); self.speed_changes_seen.push((*kind, *counter, *speed)); } - // A cross-map port. Unacked, `HandleMoveWorldportAckOpcode` never runs, so the - // destination map streams NOTHING — no self create, and none of the arrival's own - // side effects (`--mount-tele`'s subject: the mount strip a map that forbids mounting - // performs right there). The tracked set is deliberately NOT purged here: the ack for - // whatever the arrival sends needs a pose, and ours is the landing point the packet - // just gave us. + // Until acked, `HandleMoveWorldportAckOpcode` never runs and the new map streams + // nothing. Keep the tracked set: acks after arrival need our pose, the landing point. SessionEvent::Worldport { map_id, position, @@ -428,11 +387,9 @@ impl World { position, .. } if *guid == self.self_guid => { - // Ack the same-map port (the server freezes our movement otherwise). + // Ack the same-map port; the server freezes our movement until we do. session.teleport_ack(*guid, *counter)?; self.attack_pos = Some(*position); - // Move our tracked self to the landing point so range-based picks (the - // --vendor nearest-vendor search) measure from where we actually are. if let Some(t) = self.tracked.get_mut(guid) { t.position = *position; } @@ -440,11 +397,7 @@ impl World { "teleported to ({:.1}, {:.1}, {:.1}) — ack sent", position[0], position[1], position[2] ); - // --death: the graveyard port after release rides this same event — - // record it without duplicating the ack above. The spirit probe's - // healer_tp_landed capture (the old else-if branch) now lives in its own - // on_event: `healer_tp_sent` can only become true after `graveyard_pos` is - // Some (the healer-TP poll requires it), so the two are temporally disjoint. + // The graveyard port after release is this same event, already acked above. if let Some(arc) = &mut self.death_arc { if arc.repop_sent && arc.graveyard_pos.is_none() { arc.graveyard_pos = Some(*position); @@ -526,11 +479,8 @@ impl World { } } } - // **A granted mover mode** (root / water-walk / feather-fall / hover — decision 0866) - // must be acked with the echoed counter + our current pose, or the server never applies - // the change and observers never see it. Unconditional session-keeping (like - // teleport/force-speed): a mode granted outside the death arc would otherwise go - // un-acked, and the real client always acks. + // Root, water walk, feather fall and hover need an ack echoing the counter with our + // pose, or the server never applies them; the 1.12 client always acks. SessionEvent::MoveMode { guid, counter, @@ -538,12 +488,9 @@ impl World { apply, } if *guid == self.self_guid => { let pose = self.self_pose(); - // The ack's MovementInfo must carry the applied mode's own bit: for root the server - // KICKS one without it (vmangos `HandleMoveRootAck:715-723`; live-verified — the - // flags-0 ack drew "movement info does not have rooted movement flag" in - // Movement.log and the root never confirmed, so release sent no unroot), and for the - // other three the word IS the mover's new flags. This probe holds no mover state of - // its own, so the applied bit alone is the honest word. + // The ack's MovementInfo must carry the mode's own bit: vmangos kicks a player + // whose root ack lacks it (`HandleMoveRootAck`, MovementHandler.cpp:722-729). For + // the other modes the word becomes the mover's flags; the probe keeps no others. let flags = if *apply { mode.flag() } else { 0 }; session.move_mode_ack(*guid, *counter, *mode, *apply, flags, pose)?; if let Some(arc) = &mut self.death_arc {