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