- `UnitHasRelicSlot` is real in 1.12 and benilla had no binding for it: `0x519e50` reads `ChrClasses.dbc` field 16 — set for Paladin, Shaman and Druid in `patch.MPQ`'s 17-column copy (the base `dbc.MPQ` copy has 16 columns and no such field, which is how the "always false in vanilla" belief survived twice in decisions and seven times in code) — and stock `PaperDollFrame.lua` calls it unconditionally, so the character sheet would have raised for EVERY class; a druid, paladin or shaman's sheet now hides the ammo slot, shows the relic plate where the bow was, tooltips "Relic", and reports ranged Attack/Damage/Power as N/A, all four branches transcribed from the reference - the loot window is the reference's own `LootFrame.xml`: `LootButton` is built as a real widget type rather than aliased (a Button plus one slot dword and one overridden OnClick — right-click loots exactly like left, no shift/ctrl/alt takes, a row whose Lua handler errored still loots, a fast double-click takes two slots), `LOOT_SLOT_CLEARED` fires per row again because the stock file hangs hide-in-place AND the emptied-page auto-advance off it, `EditBox:Insert(nil)` is a no-op as `lua_tostring` makes it, and an alias `GameTooltip.xml` had quietly become a reader of would have turned every item tooltip white, silently, had it just gone nil - the vendor cursor: `PickupMerchantItem` is two verbs behind one name, decided by what the cursor holds and never by the caller — a held bag item SELLS (index ignored, no merchant-open gate), otherwise the vendor row is grabbed as cursor mode 5, which is invisible to `CursorHasItem` (modes 1 and 9 only) so all thirteen stock gates stay closed while it is held; the drop is the buy, `CMSG_BUY_ITEM_IN_SLOT`, count 1, into a bag or an equipment slot, and every refusal is silent and clears the cursor; the four `Model_*` rotation handlers land in UIParent where the reference keeps them - the gap report reads `:Method(` sites now — it found `SetMerchantCompareItem`, which would have raised on the first vendor-row hover with every check green, and `LootButton:SetSlot`, and 28 "free" windows became 25; the shared coin kit moved out of the merchant window into `MoneyFrame.xml` so the eight windows that draw coins through it can reach it without loading a vendor Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
940 lines
43 KiB
Rust
940 lines
43 KiB
Rust
use std::net::{TcpStream, ToSocketAddrs};
|
||
|
||
use anyhow::{anyhow, bail, Context, Result};
|
||
use benilla_srp::vanilla_header::{HeaderCrypto, ProofSeed};
|
||
use benilla_srp::{NormalizedString, SESSION_KEY_LENGTH};
|
||
|
||
use crate::messages::{self, opcode, Character, MoveMode, ServerPacket};
|
||
|
||
use super::movement::{client_uptime_ms, movement_info, MOVEMENT_FLAG_FORWARD};
|
||
use super::reader::WorldReader;
|
||
use super::writer::WorldWriter;
|
||
use super::{recv_packet, send_packet};
|
||
|
||
/// Socket read timeout for the handshake phase only (connect → `player_login`), where each step
|
||
/// waits for one specific server reply and silence means the session is dead (decision 0065).
|
||
/// Generous — a local vmangos answers in milliseconds; this only bounds the pathological stall.
|
||
const HANDSHAKE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||
|
||
/// The read bound while **queued** — a different question from the handshake's.
|
||
///
|
||
/// [`HANDSHAKE_READ_TIMEOUT`] is 10 s because every handshake step should answer promptly. Sitting
|
||
/// in a login queue is the one step that should not: the server speaks when our place moves, which
|
||
/// on a full realm can be minutes apart. Keeping the handshake bound would abort every queue that
|
||
/// mattered. The real client bounds this not at all (it is event-driven and simply waits), but an
|
||
/// unbounded blocking read here would make a server that dies mid-queue indistinguishable from a
|
||
/// long wait, so the wait is generous rather than infinite.
|
||
const QUEUE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
|
||
|
||
/// The server requires Warden, which benilla does not implement — raised by
|
||
/// [`WorldSession::connect`] and recoverable through the `anyhow` chain with
|
||
/// `err.downcast_ref::<WardenRequired>()`, so the login screen can say so plainly.
|
||
///
|
||
/// VERIFIED (vmangos `src/game/Anticheat/WardenAnticheat/Warden.cpp`): every Warden request arms a
|
||
/// response clock (`BeginTimeoutClock`, `Warden.ClientResponseDelay` — default 30 s in
|
||
/// `World.cpp`), and `Warden::Update` kicks unconditionally when it expires — not subject to the
|
||
/// penalty config, and regardless of module/maiev mode. So a client that cannot answer cannot stay
|
||
/// in the world: refusing at the handshake is the honest outcome, not a policy choice.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct WardenRequired;
|
||
|
||
/// The **world server** refused the session (`SMSG_AUTH_RESPONSE` with anything but
|
||
/// [`messages::AUTH_OK`]).
|
||
///
|
||
/// Typed, and carrying its raw code, for the same reason [`crate::AuthReject`] is: the screen owes
|
||
/// the player the client's own authored words for what happened, and it cannot look them up from a
|
||
/// formatted string. Before this the code was interpolated into a `bail!` and thrown away, so every
|
||
/// world-side refusal — expired session, server shutting down, already logging in — arrived at the
|
||
/// login screen as the same generic "Unable to connect".
|
||
///
|
||
/// **Its codes are `messages`' `AUTH_*` block, NOT [`crate::AuthReject`]'s.** See that block for
|
||
/// why the distinction is load-bearing: the two enums overlap numerically and mean unrelated
|
||
/// things.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct WorldAuthReject {
|
||
pub code: u8,
|
||
}
|
||
|
||
impl std::fmt::Display for WorldAuthReject {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
write!(
|
||
f,
|
||
"world server refused the session: result {:#04x}",
|
||
self.code
|
||
)
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for WorldAuthReject {}
|
||
|
||
impl std::fmt::Display for WardenRequired {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
write!(
|
||
f,
|
||
"this server requires the Warden anticheat, which benilla does not implement"
|
||
)
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for WardenRequired {}
|
||
|
||
/// An authenticated world-server session with 1.12 header obfuscation active.
|
||
pub struct WorldSession {
|
||
stream: TcpStream,
|
||
crypto: HeaderCrypto,
|
||
/// The roster's guid → race, remembered from [`Self::char_enum`] so [`Self::player_login`]
|
||
/// can derive the character's faction tongue without the caller's help.
|
||
roster_races: std::collections::HashMap<u64, u8>,
|
||
/// The language chat sends speak — the logged-in character's faction tongue
|
||
/// ([`messages::faction_language`]), set automatically by [`Self::player_login`]. Load-bearing:
|
||
/// vmangos drops any chat whose language the character doesn't know (dot-commands included) —
|
||
/// hardcoded Common silently ate every Horde character's chat and commands once.
|
||
chat_language: u32,
|
||
}
|
||
|
||
impl WorldSession {
|
||
/// Connect to the world server and complete the auth handshake, leaving header obfuscation
|
||
/// enabled. `username` must be the same account used at logon; `session_key` is what
|
||
/// [`crate::logon`] returned.
|
||
pub fn connect(
|
||
addr: impl ToSocketAddrs,
|
||
username: &str,
|
||
session_key: [u8; SESSION_KEY_LENGTH],
|
||
) -> Result<Self> {
|
||
Self::connect_queued(addr, username, session_key, &mut |_| true)
|
||
}
|
||
|
||
/// [`Self::connect`], reporting our place in the **login queue** as it moves.
|
||
///
|
||
/// `on_queue` fires once per `AUTH_WAIT_QUEUE` the server sends — `None` when the packet
|
||
/// carried no readable position — and the call returns only once we are admitted (`AUTH_OK`),
|
||
/// refused, or the socket fails. The queue is a wait, not an outcome, so it is a callback
|
||
/// rather than a return value: the screen has to be able to show the position *while* the
|
||
/// handshake is still parked here.
|
||
///
|
||
/// **Returning `false` abandons the queue** and fails the connect. That is the only way out of
|
||
/// a wait that can last minutes: every other stage of the handshake is bounded tightly enough
|
||
/// that a cancel is honoured at its next boundary, and a queue is not. Abandoning is checked
|
||
/// once per packet rather than per frame — the reference tears the socket down on the next
|
||
/// tick, which is finer-grained, but it costs a player at most one server update.
|
||
pub fn connect_queued(
|
||
addr: impl ToSocketAddrs,
|
||
username: &str,
|
||
session_key: [u8; SESSION_KEY_LENGTH],
|
||
on_queue: &mut dyn FnMut(Option<u32>) -> bool,
|
||
) -> Result<Self> {
|
||
let mut queued = false;
|
||
let mut stream = TcpStream::connect(addr).context("connecting to world server")?;
|
||
// **Nagle off** (decision 0617). VERIFIED in the reference client: `0x5bca60` calls
|
||
// `setsockopt(s, 6 /* IPPROTO_TCP */, 1 /* TCP_NODELAY */, &1, 4)` unconditionally on its game
|
||
// socket (WSOCK32 ordinal 21, IAT slot `0x7ff6f8`; a sibling helper at `0x43dda0` toggles the
|
||
// same option from a bool). Rust leaves Nagle *on*, which is exactly wrong for this protocol:
|
||
// every packet we send is a sub-MSS write on a latency-critical stream, so the kernel holds
|
||
// each one until the server ACKs the last — coalescing our movement stream into delayed-ACK-
|
||
// sized clumps. The de-jitter chain on the observing client then sizes its buffer to that
|
||
// self-inflicted lateness (decision 0615). Fatal on failure: the option cannot fail on a
|
||
// healthy connected socket, so an error here means the next handshake write is doomed anyway —
|
||
// better one named failure than a session that silently plays at delayed-ACK cadence.
|
||
stream
|
||
.set_nodelay(true)
|
||
.context("disabling Nagle (TCP_NODELAY) on the world socket")?;
|
||
// Handshake reads wait for one specific reply each — silence is failure, so bound them
|
||
// (decision 0065). A server that stays connected but never answers must not wedge the net
|
||
// thread forever. Cleared in `into_split` for the streaming phase (a quiet world is legal).
|
||
stream
|
||
.set_read_timeout(Some(HANDSHAKE_READ_TIMEOUT))
|
||
.context("setting handshake read timeout")?;
|
||
|
||
// 1. SMSG_AUTH_CHALLENGE (unencrypted) carries the server seed.
|
||
let server_seed = match recv_packet(&mut stream, None)? {
|
||
ServerPacket::AuthChallenge { server_seed } => server_seed,
|
||
other => bail!("expected SMSG_AUTH_CHALLENGE, got {}", other.name()),
|
||
};
|
||
|
||
// 2. Bind the session key to (server_seed, our client_seed) → proof + header crypto.
|
||
let username_n =
|
||
NormalizedString::new(username).map_err(|e| anyhow!("invalid username: {e}"))?;
|
||
let seed = ProofSeed::new();
|
||
let client_seed = seed.seed();
|
||
let (client_proof, crypto) =
|
||
seed.into_client_header_crypto(&username_n, session_key, server_seed);
|
||
|
||
// 3. CMSG_AUTH_SESSION goes out unencrypted; obfuscation begins immediately after.
|
||
// The addon block is the tail of this packet and is NOT optional: cmangos-classic kicks a
|
||
// session whose addon size is zero, which is what benilla used to send (B277, decision
|
||
// 1497). `STOCK_SECURE_ADDONS` is what a stock 1.12.1 install reports.
|
||
let body = messages::auth_session(
|
||
u32::from(crate::CLIENT_BUILD),
|
||
&username.to_uppercase(),
|
||
client_seed,
|
||
&client_proof,
|
||
&messages::STOCK_SECURE_ADDONS,
|
||
);
|
||
send_packet(&mut stream, None, opcode::CMSG_AUTH_SESSION, &body)
|
||
.context("sending CMSG_AUTH_SESSION")?;
|
||
|
||
let mut session = WorldSession {
|
||
stream,
|
||
crypto,
|
||
roster_races: Default::default(),
|
||
chat_language: messages::LANGUAGE_COMMON,
|
||
};
|
||
|
||
// 4. Wait for SMSG_AUTH_RESPONSE. Usually the first encrypted packet, but not always first
|
||
// on the wire, so skip interleaved packets (as `char_enum` does) rather than demanding
|
||
// AUTH_RESPONSE lead. Each read is still bounded by the handshake timeout, so a server that
|
||
// never answers still fails per decision 0065.
|
||
//
|
||
// SMSG_WARDEN_DATA among them ends the connection here: it means the server runs Warden,
|
||
// whose response clock kicks us ~30 s later no matter what else we do ([`WardenRequired`]).
|
||
// Refusing at the handshake trades an unplayable 30-second kick/reconnect cycle for one
|
||
// honest message at the login screen.
|
||
loop {
|
||
match session.recv()? {
|
||
ServerPacket::AuthResponse { result, .. } if result == messages::AUTH_OK => break,
|
||
// **Queued, not refused** — the realm is full and we keep our place in line. Report
|
||
// the position and go back to reading; the server re-sends as we move up and ends
|
||
// the wait with an `AUTH_OK`. Treating this as an ending (which it was until now)
|
||
// meant benilla could not log into a busy server at all.
|
||
ServerPacket::AuthResponse {
|
||
result,
|
||
queue_position,
|
||
} if result == messages::AUTH_WAIT_QUEUE => {
|
||
if !queued {
|
||
queued = true;
|
||
session
|
||
.set_read_timeout(Some(QUEUE_READ_TIMEOUT))
|
||
.context("relaxing the read timeout for the login queue")?;
|
||
}
|
||
if !on_queue(queue_position) {
|
||
bail!("login queue abandoned");
|
||
}
|
||
}
|
||
ServerPacket::AuthResponse { result, .. } => {
|
||
return Err(WorldAuthReject { code: result }.into())
|
||
}
|
||
ServerPacket::Other {
|
||
opcode: opcode::SMSG_WARDEN_DATA,
|
||
} => return Err(WardenRequired.into()),
|
||
_ => continue,
|
||
}
|
||
}
|
||
// Admitted. The rest of the handshake is prompt again, so it gets the prompt bound back —
|
||
// leaving the queue's 300 s in place would make a stalled roster step look like a long wait.
|
||
if queued {
|
||
session
|
||
.set_read_timeout(Some(HANDSHAKE_READ_TIMEOUT))
|
||
.context("restoring the handshake read timeout after the queue")?;
|
||
}
|
||
|
||
Ok(session)
|
||
}
|
||
|
||
/// Set a read timeout on the underlying socket (e.g. so a debug read-loop can stop when the
|
||
/// world goes quiet). `None` clears it (blocking reads).
|
||
pub fn set_read_timeout(&self, timeout: Option<std::time::Duration>) -> Result<()> {
|
||
self.stream
|
||
.set_read_timeout(timeout)
|
||
.context("setting world socket read timeout")
|
||
}
|
||
|
||
/// Read + decrypt + parse one server packet.
|
||
pub fn recv(&mut self) -> Result<ServerPacket> {
|
||
recv_packet(&mut self.stream, Some(self.crypto.decrypter()))
|
||
}
|
||
|
||
/// Send a client packet (encrypted header + plaintext body).
|
||
fn send(&mut self, opcode: u16, body: &[u8]) -> Result<()> {
|
||
send_packet(
|
||
&mut self.stream,
|
||
Some(self.crypto.encrypter()),
|
||
opcode,
|
||
body,
|
||
)
|
||
}
|
||
|
||
/// Request the character list and return it (remembering each character's race, so
|
||
/// [`Self::player_login`] can pick the right chat tongue).
|
||
pub fn char_enum(&mut self) -> Result<Vec<Character>> {
|
||
self.send(opcode::CMSG_CHAR_ENUM, &[])?;
|
||
loop {
|
||
match self.recv()? {
|
||
ServerPacket::CharEnum { characters } => {
|
||
self.roster_races = characters.iter().map(|c| (c.guid, c.race)).collect();
|
||
return Ok(characters);
|
||
}
|
||
// Warden can land either side of SMSG_AUTH_RESPONSE depending on when the server
|
||
// arms it, so the roster step refuses it too — same reason as `connect`.
|
||
ServerPacket::Other {
|
||
opcode: opcode::SMSG_WARDEN_DATA,
|
||
} => return Err(WardenRequired.into()),
|
||
// The server interleaves account-data / tutorial / cache packets here; skip them.
|
||
_ => continue,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Create a character (the create screen's request, or the create-if-empty starter that unblocks
|
||
/// `PLAYER_LOGIN` on a fresh account). Returns the `SMSG_CHAR_CREATE` result byte (`WorldResult`)
|
||
/// for the caller to inspect ([`messages::CHAR_CREATE_SUCCESS`], a `CHAR_NAME_*` code, …).
|
||
pub fn create_character(&mut self, req: &messages::CharCreateReq) -> Result<u8> {
|
||
self.send(opcode::CMSG_CHAR_CREATE, &messages::char_create(req))?;
|
||
loop {
|
||
match self.recv()? {
|
||
ServerPacket::CharCreate { result } => return Ok(result),
|
||
_ => continue,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Delete a character (`CMSG_CHAR_DELETE`, full u64 guid — only valid at character select,
|
||
/// never while in-world). Returns the `SMSG_CHAR_DELETE` result byte
|
||
/// ([`messages::CHAR_DELETE_SUCCESS`] on success) for the caller to inspect.
|
||
pub fn delete_character(&mut self, guid: u64) -> Result<u8> {
|
||
self.send(opcode::CMSG_CHAR_DELETE, &messages::full_guid(guid))?;
|
||
loop {
|
||
match self.recv()? {
|
||
ServerPacket::CharDelete { result } => return Ok(result),
|
||
_ => continue,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Send `CMSG_PLAYER_LOGIN` to enter the world as the given character. Also adopts the
|
||
/// character's faction tongue for every subsequent chat send (session and split writer alike):
|
||
/// vmangos drops chat — dot-commands included — spoken in a language the character doesn't
|
||
/// know, so the tongue must follow the pick. Falls back to Common when the guid wasn't in the
|
||
/// enumerated roster (no [`Self::char_enum`] this connection).
|
||
pub fn player_login(&mut self, guid: u64) -> Result<()> {
|
||
self.chat_language = self
|
||
.roster_races
|
||
.get(&guid)
|
||
.map_or(messages::LANGUAGE_COMMON, |&race| {
|
||
messages::faction_language(race)
|
||
});
|
||
self.send(opcode::CMSG_PLAYER_LOGIN, &messages::full_guid(guid))
|
||
}
|
||
|
||
/// Declare `guid` as the unit this client controls. vmangos drops all `MSG_MOVE_*` until the
|
||
/// client is a *confirmed mover*, which this establishes (the real client sends it at login).
|
||
pub fn set_active_mover(&mut self, guid: u64) -> Result<()> {
|
||
self.send(opcode::CMSG_SET_ACTIVE_MOVER, &messages::full_guid(guid))
|
||
}
|
||
|
||
/// Acknowledge a triggered cinematic as finished (`CMSG_COMPLETE_CINEMATIC`, empty body) — the
|
||
/// unsplit twin of [`WorldWriter::complete_cinematic`], for the CLI/probe path.
|
||
pub fn complete_cinematic(&mut self) -> Result<()> {
|
||
self.send(opcode::CMSG_COMPLETE_CINEMATIC, &[])
|
||
}
|
||
|
||
/// Walk our player forward `info`-style: begin moving (`MSG_MOVE_START_FORWARD`).
|
||
pub fn start_forward(&mut self, pos: [f32; 3], orientation: f32) -> Result<()> {
|
||
self.send(
|
||
opcode::MSG_MOVE_START_FORWARD,
|
||
&messages::movement(&movement_info(pos, orientation, MOVEMENT_FLAG_FORWARD)),
|
||
)
|
||
}
|
||
|
||
/// Continue moving (`MSG_MOVE_HEARTBEAT`).
|
||
pub fn heartbeat(&mut self, pos: [f32; 3], orientation: f32) -> Result<()> {
|
||
self.send(
|
||
opcode::MSG_MOVE_HEARTBEAT,
|
||
&messages::movement(&movement_info(pos, orientation, MOVEMENT_FLAG_FORWARD)),
|
||
)
|
||
}
|
||
|
||
/// Stop (`MSG_MOVE_STOP`, no movement flags).
|
||
pub fn stop(&mut self, pos: [f32; 3], orientation: f32) -> Result<()> {
|
||
self.send(
|
||
opcode::MSG_MOVE_STOP,
|
||
&messages::movement(&movement_info(pos, orientation, 0)),
|
||
)
|
||
}
|
||
|
||
/// Answer a `SMSG_FORCE_*_SPEED_CHANGE` at rest — the unsplit twin of
|
||
/// [`WorldWriter::force_speed_change_ack`], for the `--speed` probe: echo the mover guid +
|
||
/// counter + exact speed with a stationary `MovementInfo`. A wrong shape (packed guid, missing
|
||
/// counter, off-by-0.01 speed) is rejected by the server's pending-change matcher — the probe's
|
||
/// pass is the server logging no mismatch and the next change arriving with counter+1.
|
||
pub fn force_speed_ack(
|
||
&mut self,
|
||
kind: messages::SpeedKind,
|
||
guid: u64,
|
||
counter: u32,
|
||
speed: f32,
|
||
pos: [f32; 3],
|
||
orientation: f32,
|
||
) -> Result<()> {
|
||
self.send(
|
||
kind.ack_opcode(),
|
||
&messages::force_speed_ack(guid, counter, &movement_info(pos, orientation, 0), speed),
|
||
)
|
||
}
|
||
|
||
/// Acknowledge a finished server spline (`CMSG_MOVE_SPLINE_DONE`) — the unsplit twin of
|
||
/// [`WorldWriter::move_spline_done`], for the `--charge` probe: after the server drives us with a
|
||
/// self `SMSG_MONSTER_MOVE`, echo its `spline_id` at the endpoint (at rest) so the server clears
|
||
/// its spline-pending state and relocates us. A malformed body throws in the server's parser and
|
||
/// drops the session — so a surviving stream is the live proof the wire is right.
|
||
pub fn move_spline_done(
|
||
&mut self,
|
||
pos: [f32; 3],
|
||
orientation: f32,
|
||
spline_id: u32,
|
||
) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_MOVE_SPLINE_DONE,
|
||
&messages::move_spline_done(&movement_info(pos, orientation, 0), spline_id),
|
||
)
|
||
}
|
||
|
||
/// Ask a player's name (`CMSG_NAME_QUERY`) — the unsplit twin of [`WorldWriter::name_query`],
|
||
/// for the CLI/probe path. The answer arrives in the stream as `SMSG_NAME_QUERY_RESPONSE`.
|
||
pub fn name_query(&mut self, guid: u64) -> Result<()> {
|
||
self.send(opcode::CMSG_NAME_QUERY, &messages::full_guid(guid))
|
||
}
|
||
|
||
/// Ask a creature template's name (`CMSG_CREATURE_QUERY`) — the unsplit twin of
|
||
/// [`WorldWriter::creature_query`].
|
||
pub fn creature_query(&mut self, entry: u32, guid: u64) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_CREATURE_QUERY,
|
||
&messages::creature_query(entry, guid),
|
||
)
|
||
}
|
||
|
||
/// Cast a spell (`CMSG_CAST_SPELL`) — the unsplit twin of [`WorldWriter::cast_spell`], for the
|
||
/// CLI/probe path. `target: None` = self/implicit; the verdict arrives as `SMSG_CAST_RESULT`.
|
||
pub fn cast_spell(&mut self, spell_id: u32, target: Option<u64>) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_CAST_SPELL,
|
||
&messages::cast_spell(spell_id, target),
|
||
)
|
||
}
|
||
|
||
/// Cast a spell at a **ground point** (`CMSG_CAST_SPELL` with `TARGET_FLAG_DEST_LOCATION` +
|
||
/// WoW world coords) — the unsplit twin of [`WorldWriter::cast_spell_at_dest`], for the
|
||
/// `--spells` probe's dest-cast round trip (decision 0792).
|
||
pub fn cast_spell_at_dest(&mut self, spell_id: u32, dest: [f32; 3]) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_CAST_SPELL,
|
||
&messages::cast_spell_at_dest(spell_id, dest),
|
||
)
|
||
}
|
||
|
||
/// Cast an OPEN_LOCK spell at a **GameObject** (`CMSG_CAST_SPELL`) — the unsplit twin of
|
||
/// [`WorldWriter::cast_spell_gameobject`], for a probe's live chest-loot verification (decision
|
||
/// 0239): cast e.g. 3365 "Opening" / 2575 "Mining" at a lockable GO and expect
|
||
/// `SMSG_LOOT_RESPONSE`.
|
||
pub fn cast_spell_gameobject(&mut self, spell_id: u32, go_guid: u64) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_CAST_SPELL,
|
||
&messages::cast_spell_gameobject(spell_id, go_guid),
|
||
)
|
||
}
|
||
|
||
/// Ask an item template (`CMSG_ITEM_QUERY_SINGLE`) — the unsplit twin of
|
||
/// [`WorldWriter::item_query`]. Answered by `SMSG_ITEM_QUERY_SINGLE_RESPONSE`.
|
||
pub fn item_query(&mut self, entry: u32, guid: u64) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_ITEM_QUERY_SINGLE,
|
||
&messages::item_query(entry, guid),
|
||
)
|
||
}
|
||
|
||
/// Use an item by bag position (`CMSG_USE_ITEM`) — the unsplit twin of
|
||
/// [`WorldWriter::use_item`], for the probe's live use-item verification.
|
||
pub fn use_item(&mut self, bag_index: u8, slot: u8, spell_slot: u8) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_USE_ITEM,
|
||
&messages::use_item(
|
||
bag_index,
|
||
slot,
|
||
spell_slot,
|
||
messages::UseItemTarget::default(),
|
||
),
|
||
)
|
||
}
|
||
|
||
/// Open an item by bag position (`CMSG_OPEN_ITEM`) — the unsplit twin of
|
||
/// [`WorldWriter::open_item`], for the probe's live open-item verification.
|
||
pub fn open_item(&mut self, bag_index: u8, slot: u8) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_OPEN_ITEM,
|
||
&messages::open_item(bag_index, slot),
|
||
)
|
||
}
|
||
|
||
/// Equip a bag item (`CMSG_AUTOEQUIP_ITEM`) — the unsplit twin of
|
||
/// [`WorldWriter::auto_equip_item`], for the probe's live equip verification.
|
||
pub fn auto_equip_item(&mut self, bag_index: u8, slot: u8) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_AUTOEQUIP_ITEM,
|
||
&messages::auto_equip_item(bag_index, slot),
|
||
)
|
||
}
|
||
|
||
/// Swap two player-array slots (`CMSG_SWAP_INV_ITEM`) — the unsplit twin of
|
||
/// [`WorldWriter::swap_inv_item`], for the probe's live backpack-move verification.
|
||
pub fn swap_inv_item(&mut self, src_slot: u8, dst_slot: u8) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_SWAP_INV_ITEM,
|
||
&messages::swap_inv_item(src_slot, dst_slot),
|
||
)
|
||
}
|
||
|
||
/// Ask a vendor's stock (`CMSG_LIST_INVENTORY`) — the unsplit twin of
|
||
/// [`WorldWriter::list_inventory`], for the probe's live vendor verification. Answered by
|
||
/// `SMSG_LIST_INVENTORY` (a `VendorInventory` event).
|
||
pub fn list_inventory(&mut self, vendor_guid: u64) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_LIST_INVENTORY,
|
||
&messages::list_inventory(vendor_guid),
|
||
)
|
||
}
|
||
|
||
/// Buy from a vendor (`CMSG_BUY_ITEM`) — the unsplit twin of [`WorldWriter::buy_item`]; `entry`
|
||
/// is the item template id, not the vendor row's `muid`.
|
||
pub fn buy_item(&mut self, vendor_guid: u64, entry: u32, count: u8) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_BUY_ITEM,
|
||
&messages::buy_item(vendor_guid, entry, count),
|
||
)
|
||
}
|
||
|
||
/// `CMSG_BUY_ITEM_IN_SLOT` — buy into a named container slot, the merchant cursor's drop.
|
||
pub fn buy_item_in_slot(
|
||
&mut self,
|
||
vendor_guid: u64,
|
||
entry: u32,
|
||
bag_guid: u64,
|
||
bag_slot: u8,
|
||
count: u8,
|
||
) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_BUY_ITEM_IN_SLOT,
|
||
&messages::buy_item_in_slot(vendor_guid, entry, bag_guid, bag_slot, count),
|
||
)
|
||
}
|
||
|
||
/// Sell an item to a vendor (`CMSG_SELL_ITEM`) — the unsplit twin of [`WorldWriter::sell_item`];
|
||
/// `count` 0 = the whole stack.
|
||
pub fn sell_item(&mut self, vendor_guid: u64, item_guid: u64, count: u8) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_SELL_ITEM,
|
||
&messages::sell_item(vendor_guid, item_guid, count),
|
||
)
|
||
}
|
||
|
||
/// Buy a sold item back (`CMSG_BUYBACK_ITEM`) — the unsplit twin of
|
||
/// [`WorldWriter::buyback_item`]; `slot` is the absolute buyback slot 69–80.
|
||
pub fn buyback_item(&mut self, vendor_guid: u64, slot: u32) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_BUYBACK_ITEM,
|
||
&messages::buyback_item(vendor_guid, slot),
|
||
)
|
||
}
|
||
|
||
/// Repair at a vendor (`CMSG_REPAIR_ITEM`) — the unsplit twin of [`WorldWriter::repair_item`];
|
||
/// `item_guid` 0 = repair everything.
|
||
pub fn repair_item(&mut self, vendor_guid: u64, item_guid: u64) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_REPAIR_ITEM,
|
||
&messages::repair_item(vendor_guid, item_guid),
|
||
)
|
||
}
|
||
|
||
/// Right-click a pure banker (`CMSG_BANKER_ACTIVATE`) — opens the bank window, answered by
|
||
/// `SMSG_SHOW_BANK` (a `ShowBank` event). Decision 0604.
|
||
pub fn banker_activate(&mut self, banker_guid: u64) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_BANKER_ACTIVATE,
|
||
&messages::banker_activate(banker_guid),
|
||
)
|
||
}
|
||
|
||
/// Buy the next bank bag slot (`CMSG_BUY_BANK_SLOT`) — the server buys slot
|
||
/// `purchased_count + 1` and debits the price itself; success is silent (the
|
||
/// `PLAYER_BYTES_2` bank-bag-count byte advances via `UPDATE_OBJECT`), failure answers
|
||
/// `SMSG_BUY_BANK_SLOT_RESULT` (a `BuyBankSlotResult` event). Decision 0604.
|
||
pub fn buy_bank_slot(&mut self, banker_guid: u64) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_BUY_BANK_SLOT,
|
||
&messages::buy_bank_slot(banker_guid),
|
||
)
|
||
}
|
||
|
||
/// Deposit a bag item into the bank (`CMSG_AUTOBANK_ITEM`) — moves the item at player-array
|
||
/// position `(bag, slot)` into the first free bank slot. Decision 0604.
|
||
pub fn autobank_item(&mut self, bag: u8, slot: u8) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_AUTOBANK_ITEM,
|
||
&messages::autobank_item(bag, slot),
|
||
)
|
||
}
|
||
|
||
/// Auto-move at the bank (`CMSG_AUTOSTORE_BANK_ITEM`) — withdraws `(bag, slot)` to inventory
|
||
/// when it names a bank position, else deposits it (vmangos `HandleAutoStoreBankItemOpcode`,
|
||
/// `ItemHandler.cpp:971`). Decision 0604.
|
||
pub fn autostore_bank_item(&mut self, bag: u8, slot: u8) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_AUTOSTORE_BANK_ITEM,
|
||
&messages::autostore_bank_item(bag, slot),
|
||
)
|
||
}
|
||
|
||
/// Ask an NPC's questgiver dialog status (`CMSG_QUESTGIVER_STATUS_QUERY`) — answered by
|
||
/// `SMSG_QUESTGIVER_STATUS`, the overhead `!`/`?` marker's value.
|
||
pub fn questgiver_status_query(&mut self, npc: u64) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_QUESTGIVER_STATUS_QUERY,
|
||
&messages::questgiver_status_query(npc),
|
||
)
|
||
}
|
||
|
||
/// Open a questgiver dialog (`CMSG_QUESTGIVER_HELLO`) — the unsplit twin for the probe's live
|
||
/// quest verification. The same `SendPreparedGossip` server path as gossip-hello.
|
||
pub fn questgiver_hello(&mut self, npc: u64) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_QUESTGIVER_HELLO,
|
||
&messages::questgiver_hello(npc),
|
||
)
|
||
}
|
||
|
||
/// Ask a quest's detail panel (`CMSG_QUESTGIVER_QUERY_QUEST`) — answered by
|
||
/// `SMSG_QUESTGIVER_QUEST_DETAILS`.
|
||
pub fn questgiver_query_quest(&mut self, npc: u64, quest: u32) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_QUESTGIVER_QUERY_QUEST,
|
||
&messages::questgiver_query_quest(npc, quest),
|
||
)
|
||
}
|
||
|
||
/// Accept a quest (`CMSG_QUESTGIVER_ACCEPT_QUEST`) — adds it to the log; the server closes the
|
||
/// gossip window.
|
||
pub fn questgiver_accept_quest(&mut self, npc: u64, quest: u32) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_QUESTGIVER_ACCEPT_QUEST,
|
||
&messages::questgiver_accept_quest(npc, quest),
|
||
)
|
||
}
|
||
|
||
/// Ask for a quest's turn-in progress panel (`CMSG_QUESTGIVER_COMPLETE_QUEST`) — answered by
|
||
/// `SMSG_QUESTGIVER_REQUEST_ITEMS` (or OFFER_REWARD when there are no required items).
|
||
pub fn questgiver_complete_quest(&mut self, npc: u64, quest: u32) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_QUESTGIVER_COMPLETE_QUEST,
|
||
&messages::questgiver_complete_quest(npc, quest),
|
||
)
|
||
}
|
||
|
||
/// Advance from progress to the reward panel (`CMSG_QUESTGIVER_REQUEST_REWARD`) — answered by
|
||
/// `SMSG_QUESTGIVER_OFFER_REWARD`.
|
||
pub fn questgiver_request_reward(&mut self, npc: u64, quest: u32) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_QUESTGIVER_REQUEST_REWARD,
|
||
&messages::questgiver_request_reward(npc, quest),
|
||
)
|
||
}
|
||
|
||
/// Choose a reward and finish the quest (`CMSG_QUESTGIVER_CHOOSE_REWARD`, `reward` = choice
|
||
/// index) — answered by `SMSG_QUESTGIVER_QUEST_COMPLETE` + the XP/money/item grants.
|
||
pub fn questgiver_choose_reward(&mut self, npc: u64, quest: u32, reward: u32) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_QUESTGIVER_CHOOSE_REWARD,
|
||
&messages::questgiver_choose_reward(npc, quest, reward),
|
||
)
|
||
}
|
||
|
||
/// Ask a quest's full template (`CMSG_QUEST_QUERY`) — the unsplit twin of
|
||
/// [`WorldWriter::quest_query`]; the quest-log detail pane's ask-once source, distinct from
|
||
/// [`Self::questgiver_query_quest`] (which needs an NPC guid, not just the quest id). Answered
|
||
/// by `SMSG_QUEST_QUERY_RESPONSE` (a `QuestTemplate` event).
|
||
pub fn quest_query(&mut self, quest_id: u32) -> Result<()> {
|
||
self.send(opcode::CMSG_QUEST_QUERY, &messages::quest_query(quest_id))
|
||
}
|
||
|
||
/// Ask the server for its wall clock (`CMSG_QUERY_TIME`) — the unsplit twin of
|
||
/// [`WorldWriter::query_time`]; answered by `SMSG_QUERY_TIME_RESPONSE`, one `u32` of
|
||
/// unix-epoch seconds. That is the epoch a timed quest's deadline is written in, so no
|
||
/// countdown can be read off the descriptor without it (decision 1150).
|
||
pub fn query_time(&mut self) -> Result<()> {
|
||
self.send(opcode::CMSG_QUERY_TIME, &messages::query_time())
|
||
}
|
||
|
||
/// Abandon a quest-log slot (`CMSG_QUESTLOG_REMOVE_QUEST`) — the unsplit twin of
|
||
/// [`WorldWriter::questlog_remove_quest`]; no ack SMSG, the server clears the
|
||
/// `PLAYER_QUEST_LOG` slot fields directly.
|
||
pub fn questlog_remove_quest(&mut self, slot: u8) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_QUESTLOG_REMOVE_QUEST,
|
||
&messages::questlog_remove_quest(slot),
|
||
)
|
||
}
|
||
|
||
/// Send a `/say` line — the unsplit twin of [`WorldWriter::send_chat`]; the probe CLI drives GM
|
||
/// dot-commands (`.go xyz …`) through it. Same body rules as the writer: spoken in the
|
||
/// character's own tongue (set by [`Self::player_login`]) — the server rejects `Universal`
|
||
/// from clients, and rejects a tongue the character doesn't know.
|
||
pub fn send_chat(&mut self, message: &str) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_MESSAGECHAT,
|
||
&messages::messagechat(messages::CHAT_TYPE_SAY, self.chat_language, message),
|
||
)
|
||
}
|
||
|
||
/// Send a chat line on an arbitrary lane — the generalised twin of [`Self::send_chat`]
|
||
/// (which is this with `CHAT_TYPE_SAY`). `target` is the whisper target on
|
||
/// `CHAT_TYPE_WHISPER` and the channel name on `CHAT_TYPE_CHANNEL`, `None` elsewhere. Spoken
|
||
/// in the character's own tongue, with the same server-side rules.
|
||
pub fn send_chat_kind(
|
||
&mut self,
|
||
chat_type: u32,
|
||
target: Option<&str>,
|
||
message: &str,
|
||
) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_MESSAGECHAT,
|
||
&messages::messagechat_kind(chat_type, self.chat_language, target, message),
|
||
)
|
||
}
|
||
|
||
/// Join a channel — the unsplit twin of [`WorldWriter::join_channel`]; `password` is empty for
|
||
/// a channel that has none.
|
||
pub fn join_channel(&mut self, name: &str, password: &str) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_JOIN_CHANNEL,
|
||
&messages::join_channel(name, password),
|
||
)
|
||
}
|
||
|
||
/// Leave a channel — the unsplit twin of [`WorldWriter::leave_channel`].
|
||
pub fn leave_channel(&mut self, name: &str) -> Result<()> {
|
||
self.send(opcode::CMSG_LEAVE_CHANNEL, &messages::leave_channel(name))
|
||
}
|
||
|
||
/// Invite a player to our group by name (`CMSG_GROUP_INVITE`); they answer with
|
||
/// `SMSG_GROUP_INVITE` and, if they want in, [`Self::group_accept`].
|
||
pub fn group_invite(&mut self, member_name: &str) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_GROUP_INVITE,
|
||
&messages::group_invite(member_name),
|
||
)
|
||
}
|
||
|
||
/// Accept the group invite we were just offered (`CMSG_GROUP_ACCEPT`, empty body).
|
||
pub fn group_accept(&mut self) -> Result<()> {
|
||
self.send(opcode::CMSG_GROUP_ACCEPT, &messages::group_accept())
|
||
}
|
||
|
||
/// Leave/disband our group (`CMSG_GROUP_DISBAND`, empty body) — probes tear the group down so
|
||
/// the next run starts ungrouped.
|
||
pub fn group_disband(&mut self) -> Result<()> {
|
||
self.send(opcode::CMSG_GROUP_DISBAND, &messages::group_disband())
|
||
}
|
||
|
||
/// Send an **addon message** — a `CMSG_MESSAGECHAT` on `chat_type`'s lane with
|
||
/// [`messages::LANGUAGE_ADDON`] in the language field, which is what makes it addon traffic
|
||
/// rather than speech (decision 1029). `target` is the channel name on
|
||
/// [`messages::CHAT_TYPE_CHANNEL`] and `None` on the group/guild lanes; `text` is the raw
|
||
/// payload (`prefix`, TAB, the addon's own encoding) — this verb does not compose it.
|
||
///
|
||
/// Probe-only, and deliberately: benilla runs FrameXML, not third-party addons, so it has
|
||
/// nothing to *say* over this lane. It exists so the receive gate can be proved against the
|
||
/// live server (`addon_chat_probe`) rather than argued from folk memory of the lane. The
|
||
/// server drops the whole message unless `AddonChannel` is on and `chat_type` is one of the
|
||
/// lanes `IsLanguageAllowedForChatType` permits (see [`messages::LANGUAGE_ADDON`]).
|
||
pub fn send_addon_message(
|
||
&mut self,
|
||
chat_type: u32,
|
||
target: Option<&str>,
|
||
text: &str,
|
||
) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_MESSAGECHAT,
|
||
&messages::messagechat_kind(chat_type, messages::LANGUAGE_ADDON, target, text),
|
||
)
|
||
}
|
||
|
||
/// Echo a same-map teleport ack — the unsplit twin of [`WorldWriter::teleport_ack`] (without it
|
||
/// the server freezes our movement after a `.go`).
|
||
pub fn teleport_ack(&mut self, guid: u64, counter: u32) -> Result<()> {
|
||
self.send(
|
||
opcode::MSG_MOVE_TELEPORT_ACK,
|
||
&messages::teleport_ack(guid, counter, client_uptime_ms()),
|
||
)
|
||
}
|
||
|
||
/// Echo a cross-map worldport ack — the unsplit twin of [`WorldWriter::worldport_ack`]
|
||
/// (`MSG_MOVE_WORLDPORT_ACK`, empty body). Without it the server never runs
|
||
/// `HandleMoveWorldportAckOpcode`, so nothing on the destination map is ever streamed: no self
|
||
/// create block, and none of the arrival's own side effects (the `--mount-tele` probe's whole
|
||
/// subject — the mount strip a map that forbids mounting performs right there).
|
||
pub fn worldport_ack(&mut self) -> Result<()> {
|
||
self.send(opcode::MSG_MOVE_WORLDPORT_ACK, &[])
|
||
}
|
||
|
||
/// **Acknowledge a granted mover mode** — root, water-walk, feather-fall or hover (the ack'd
|
||
/// family; decision 0866) — the unsplit twin of [`WorldWriter::move_mode_ack`], for the
|
||
/// `--death` probe: the server roots us at death, unroots and grants walk-on-water at release,
|
||
/// and applies nothing until we echo the counter (+ our current `pose`) back.
|
||
pub fn move_mode_ack(
|
||
&mut self,
|
||
guid: u64,
|
||
counter: u32,
|
||
mode: MoveMode,
|
||
apply: bool,
|
||
flags: u32,
|
||
pose: ([f32; 3], f32),
|
||
) -> Result<()> {
|
||
let info = movement_info(pose.0, pose.1, flags);
|
||
let trailing = mode.ack_carries_apply().then_some(apply);
|
||
self.send(
|
||
mode.ack_opcode(apply),
|
||
&messages::move_flag_ack(guid, counter, &info, trailing),
|
||
)
|
||
}
|
||
|
||
/// Release the spirit (`CMSG_REPOP_REQUEST`, empty body) — the unsplit twin of
|
||
/// [`WorldWriter::repop_request`], for the `--death` probe's live release verification.
|
||
pub fn repop_request(&mut self) -> Result<()> {
|
||
self.send(opcode::CMSG_REPOP_REQUEST, &[])
|
||
}
|
||
|
||
/// Ask where our corpse is (`MSG_CORPSE_QUERY`, empty request) — the unsplit twin of
|
||
/// [`WorldWriter::corpse_query`], for the `--death` probe. Answered by the same opcode (a
|
||
/// [`SessionEvent::CorpseQuery`](crate::SessionEvent::CorpseQuery)).
|
||
pub fn corpse_query(&mut self) -> Result<()> {
|
||
self.send(opcode::MSG_CORPSE_QUERY, &[])
|
||
}
|
||
|
||
/// Self-resurrect (`CMSG_SELF_RES`, empty body) — the unsplit twin of
|
||
/// [`WorldWriter::self_res`], for the `--self-res` probe. The server casts whatever
|
||
/// `PLAYER_SELF_RES_SPELL` holds and zeroes the field.
|
||
pub fn self_res(&mut self) -> Result<()> {
|
||
self.send(opcode::CMSG_SELF_RES, &[])
|
||
}
|
||
|
||
/// Ask the graveyard's Spirit Healer for the immediate res (`CMSG_SPIRIT_HEALER_ACTIVATE`,
|
||
/// full guid) — the unsplit twin of [`WorldWriter::spirit_healer_activate`], for the
|
||
/// `--spirit` probe's live durability-loss verification. The server resurrects at 50% health
|
||
/// and pushes the 25% durability loss as item values-deltas.
|
||
pub fn spirit_healer_activate(&mut self, npc: u64) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_SPIRIT_HEALER_ACTIVATE,
|
||
&messages::spirit_healer_activate(npc),
|
||
)
|
||
}
|
||
|
||
/// Start melee auto-attack (`CMSG_ATTACKSWING`, full guid) — the unsplit twin of
|
||
/// [`WorldWriter::attack_swing`]. Echoed as `SMSG_ATTACKSTART`; each completed swing arrives as
|
||
/// `SMSG_ATTACKERSTATEUPDATE`.
|
||
pub fn attack_swing(&mut self, guid: u64) -> Result<()> {
|
||
self.send(opcode::CMSG_ATTACKSWING, &messages::attack_swing(guid))
|
||
}
|
||
|
||
/// Set our target (`CMSG_SET_SELECTION`, full guid) — the unsplit twin of
|
||
/// [`WorldWriter::set_selection`], for probe fidelity (the client selects before it casts).
|
||
pub fn set_selection(&mut self, guid: u64) -> Result<()> {
|
||
self.send(opcode::CMSG_SET_SELECTION, &messages::full_guid(guid))
|
||
}
|
||
|
||
/// Open a loot window (`CMSG_LOOT`) — the unsplit twin of [`WorldWriter::loot`], for the
|
||
/// probe's live loot verification. Answered by `SMSG_LOOT_RESPONSE` (either shape).
|
||
pub fn loot(&mut self, guid: u64) -> Result<()> {
|
||
self.send(opcode::CMSG_LOOT, &messages::loot(guid))
|
||
}
|
||
|
||
/// Use a world GameObject (`CMSG_GAMEOBJ_USE`) — the unsplit twin of [`WorldWriter::gameobj_use`],
|
||
/// for a probe's live GO-interaction verification (decision 0236 phase 3's chest-loot capture).
|
||
/// The server answers on the type's own system (`SMSG_LOOT_RESPONSE` for a chest, gossip/quest for
|
||
/// a questgiver GO) or not at all.
|
||
pub fn gameobj_use(&mut self, guid: u64) -> Result<()> {
|
||
self.send(opcode::CMSG_GAMEOBJ_USE, &messages::gameobj_use(guid))
|
||
}
|
||
|
||
/// Ask for a GameObject template's head (`CMSG_GAMEOBJECT_QUERY`) — the unsplit twin of
|
||
/// [`WorldWriter::gameobject_query`], for the CLI/probe path. Answered by
|
||
/// `SMSG_GAMEOBJECT_QUERY_RESPONSE`.
|
||
pub fn gameobject_query(&mut self, entry: u32, guid: u64) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_GAMEOBJECT_QUERY,
|
||
&messages::gameobject_query(entry, guid),
|
||
)
|
||
}
|
||
|
||
/// Take one loot-window row (`CMSG_AUTOSTORE_LOOT_ITEM`) — the unsplit twin of
|
||
/// [`WorldWriter::autostore_loot_item`]; `loot_slot` is the wire's 0-based row index from the
|
||
/// `SMSG_LOOT_RESPONSE` this answers.
|
||
pub fn autostore_loot_item(&mut self, loot_slot: u8) -> Result<()> {
|
||
self.send(
|
||
opcode::CMSG_AUTOSTORE_LOOT_ITEM,
|
||
&messages::autostore_loot_item(loot_slot),
|
||
)
|
||
}
|
||
|
||
/// Take the loot's coin pile (`CMSG_LOOT_MONEY`, empty body) — the unsplit twin of
|
||
/// [`WorldWriter::loot_money`]. Answered by `SMSG_LOOT_MONEY_NOTIFY` + `SMSG_LOOT_CLEAR_MONEY`.
|
||
pub fn loot_money(&mut self) -> Result<()> {
|
||
self.send(opcode::CMSG_LOOT_MONEY, &messages::loot_money())
|
||
}
|
||
|
||
/// Close the loot window (`CMSG_LOOT_RELEASE`) — the unsplit twin of
|
||
/// [`WorldWriter::loot_release`]; the server ignores `guid` and releases its own stored loot
|
||
/// target. Answered by `SMSG_LOOT_RELEASE_RESPONSE`.
|
||
pub fn loot_release(&mut self, guid: u64) -> Result<()> {
|
||
self.send(opcode::CMSG_LOOT_RELEASE, &messages::loot_release(guid))
|
||
}
|
||
|
||
/// Split into independent read/write halves (separate cloned sockets + crypto halves) so a reader
|
||
/// thread can stream updates while another path sends our movement. Call after [`Self::player_login`].
|
||
pub fn into_split(self) -> Result<(WorldReader, WorldWriter)> {
|
||
// The handshake is over — clear its read timeout (decision 0065): the streaming phase reads
|
||
// block indefinitely (a quiet world is legal; connection liveness is the caller's loop).
|
||
self.stream
|
||
.set_read_timeout(None)
|
||
.context("clearing handshake read timeout")?;
|
||
let read_stream = self
|
||
.stream
|
||
.try_clone()
|
||
.context("cloning world socket for split")?;
|
||
let (encrypter, decrypter) = self.crypto.split();
|
||
Ok((
|
||
WorldReader {
|
||
stream: read_stream,
|
||
decrypter,
|
||
},
|
||
WorldWriter {
|
||
stream: self.stream,
|
||
encrypter,
|
||
chat_language: self.chat_language,
|
||
},
|
||
))
|
||
}
|
||
|
||
/// Cleanly leave the world back to character-select: send `CMSG_LOGOUT_REQUEST` and wait for
|
||
/// `SMSG_LOGOUT_COMPLETE` (instant for GM / rested). The server persists the character on logout,
|
||
/// so a follow-up [`Self::char_enum`] reflects the saved position. Requires a read timeout (so the
|
||
/// poll loop can give up); returns once logged out or `timeout` elapses.
|
||
pub fn logout(&mut self, timeout: std::time::Duration) -> Result<()> {
|
||
self.send(opcode::CMSG_LOGOUT_REQUEST, &[])?;
|
||
let deadline = std::time::Instant::now() + timeout;
|
||
let mut denied = false;
|
||
while std::time::Instant::now() < deadline {
|
||
match self.recv() {
|
||
Ok(ServerPacket::LogoutComplete) => return Ok(()),
|
||
Ok(ServerPacket::LogoutResponse { reason, .. }) => {
|
||
// A non-zero reason means the server refused (combat, falling, GM-frozen).
|
||
denied = reason != messages::LOGOUT_SUCCESS;
|
||
}
|
||
Ok(_) => {}
|
||
Err(_) => {} // read-timeout tick — keep polling until the deadline
|
||
}
|
||
}
|
||
if denied {
|
||
bail!("logout refused by server (in combat / not allowed)")
|
||
}
|
||
bail!("timed out waiting for SMSG_LOGOUT_COMPLETE")
|
||
}
|
||
}
|