comments: protocol, the quest, spell, vendor and world-state probes

404 comment lines become 168; the code is unchanged. Two claims were
wrong and are corrected: the world-state probe needs no temporary GM
grant, since the probe accounts carry level 6 and the command needs 5.
The root-ack citation now points at the current vmangos lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-23 20:00:05 +07:00
co-authored by Claude Opus 5.5
parent c32b0f92fc
commit 0d29490637
12 changed files with 160 additions and 396 deletions
@@ -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<dyn FnMut(&SessionEvent) -> Option<String>>;
/// 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<benilla_protocol::messages::ObjectFields>,
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));
@@ -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<benilla_protocol::messages::QuestTemplate> = 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<benilla_protocol::messages::ObjectFields>| {
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<benilla_protocol::messages::ObjectFields>| {
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;
@@ -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<benilla_protocol::messages::ObjectFields>| {
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) {
@@ -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<u32>,
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
@@ -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,
@@ -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<u64>| {
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!(
@@ -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)"
@@ -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);
@@ -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()
@@ -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<Vec<benilla_protocol::messages::VendorItem>> = 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<String> = None;
let mut coinage_delta: Option<String> = None;
let drain_until = Instant::now() + Duration::from_secs(5);
@@ -1,28 +1,10 @@
//! `--worldstate`: the world-state table's two wires, end to end against the live server.
//! `--worldstate`: `.debug send worldstate <id> <value>` 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 <id> <value>` 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=<account 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=<account 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!(
@@ -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<u32>,
/// 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<u64, Tracked>,
/// 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<ObjectFields>,
pub(crate) item_entries: HashMap<u64, u32>,
pub(crate) item_stacks: HashMap<u64, u32>,
pub(crate) item_names: HashMap<u32, String>,
/// Streamed NPCs that advertise UNIT_NPC_FLAG_VENDOR, by guid → position.
pub(crate) vendors: HashMap<u64, [f32; 3]>,
/// 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<String>)>,
pub(crate) cast_verdict: Option<(u32, bool, Option<u8>)>,
pub(crate) targeted_verdict: Option<(u32, bool, Option<u8>)>,
/// 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<u32>,
pub(crate) dest_verdict: Option<(u32, bool, Option<u8>)>,
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<AttackSwingError>,
pub(crate) self_moves: u32,
pub(crate) tally: BTreeMap<String, u32>,
pub(crate) total: u32,
pub(crate) death_arc: Option<DeathArc>,
/// 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 {