/target, /assist and /follow, and right click opens a clam

- /target and /assist find units by name like the original, and /follow walks you behind your target
- right click opens clams and lockboxes, and the tooltip promises it
- the freeze thaws clean, poses release instead of sticking, and you drop to neutral standing before it grips
- unit frame art layers over the bars like the original

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-02 18:02:44 +07:00
co-authored by Claude Fable 5
parent 2f40b6a9a0
commit 94de741cb1
36 changed files with 2086 additions and 204 deletions
@@ -22,7 +22,7 @@ use benilla_protocol::{decode, EntityKind, WorldSession, WORLD_PORT};
use clap::Parser;
use probes::{
Attack, Aura, Charge, Ctx, Death, EquipPackSlot, GiverStatus, GroundFx, Loot, Probe,
Attack, Aura, Charge, Ctx, Death, EquipPackSlot, GiverStatus, GroundFx, Loot, OpenItem, Probe,
QueryNames, Quest, QuestItem, QuestLog, Speed, Spells, Spirit, SwapPackSlots, UsePackSlot,
Vendor, WorldState,
};
@@ -81,6 +81,14 @@ struct Cli {
/// delta or a destroy on that item's guid (consumed), or an explicit 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).
#[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"`).
@@ -344,6 +352,9 @@ fn main() -> Result<()> {
if let Some(n) = cli.use_pack_slot {
probes.push(Box::new(UsePackSlot { n }));
}
if cli.open_item {
probes.push(Box::new(OpenItem));
}
if let Some(n) = cli.equip_pack_slot {
probes.push(Box::new(EquipPackSlot { n }));
}
@@ -67,6 +67,7 @@ mod equip_pack_slot;
mod giverstatus;
mod groundfx;
mod loot;
mod open_item;
mod query_names;
mod quest;
mod quest_item;
@@ -87,6 +88,7 @@ pub(crate) use equip_pack_slot::EquipPackSlot;
pub(crate) use giverstatus::GiverStatus;
pub(crate) use groundfx::GroundFx;
pub(crate) use loot::Loot;
pub(crate) use open_item::OpenItem;
pub(crate) use query_names::QueryNames;
pub(crate) use quest::Quest;
pub(crate) use quest_item::QuestItem;
@@ -0,0 +1,122 @@
//! `--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.
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
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.
const ITEM_ENTRY: u32 = 7973;
pub(crate) struct OpenItem;
impl Probe for OpenItem {
fn stage(&mut self, cx: &mut Ctx) -> Result<()> {
cx.session.send_chat(&format!(".additem {ITEM_ENTRY}"))?;
println!("sent GM: .additem {ITEM_ENTRY}");
Ok(())
}
fn verify(&mut self, cx: &mut Ctx) -> Result<()> {
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.
let sf = world
.self_fields
.as_ref()
.context("no self descriptor — can't walk the backpack")?;
let (slot0, item_guid) = (0..16)
.filter_map(|i| sf.player_pack_slot(i).filter(|g| *g != 0).map(|g| (i, g)))
.find(|(_, g)| world.item_entries.get(g).copied() == Some(ITEM_ENTRY))
.with_context(|| {
format!(
"--open-item: item {ITEM_ENTRY} isn't in the backpack — did `.additem` land? \
(needs a GM account; try a longer --seconds)"
)
})?;
let wire_slot = 23 + slot0;
println!(
"\nopenable item {ITEM_ENTRY}: guid {item_guid:#x} in pack slot {} → \
CMSG_OPEN_ITEM bag 255 slot {wire_slot}",
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.
session.open_item(255, wire_slot)?;
let drain_until = Instant::now() + Duration::from_secs(5);
let mut verdict: Option<Result<String, String>> = None;
while Instant::now() < drain_until && verdict.is_none() {
let Ok(msg) = session.recv() else { continue };
for ev in decode(msg) {
match ev {
SessionEvent::LootResponse {
guid,
loot_type,
gold,
items,
} if guid == item_guid => {
verdict = Some(Ok(format!(
"SMSG_LOOT_RESPONSE on the ITEM guid — type {loot_type}, {} copper, \
{} row(s): {}",
gold,
items.len(),
items
.iter()
.map(|i| format!("{}x{}", i.count, i.item_id))
.collect::<Vec<_>>()
.join(", ")
)));
}
SessionEvent::LootError { guid, error } if guid == item_guid => {
verdict = Some(Err(format!("server refused with loot error {error}")));
}
SessionEvent::InventoryFailure { reason, .. } => {
verdict = Some(Err(format!(
"server refused with EQUIP_ERR {reason} (locked? dead? flying?)"
)));
}
_ => {}
}
}
}
let v = verdict
.context("--open-item: no reaction to CMSG_OPEN_ITEM within 5s")?
.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.
session.loot_release(item_guid)?;
let until = Instant::now() + Duration::from_secs(2);
while Instant::now() < until {
let Ok(msg) = session.recv() else { continue };
for ev in decode(msg) {
if let SessionEvent::LootReleaseResponse { guid } = ev {
if guid == item_guid {
println!("✅ release: SMSG_LOOT_RELEASE_RESPONSE on {guid:#x}");
}
}
}
}
// Leave the probe character as found (the clam survives an un-emptied open).
session.send_chat(&format!(".additem {ITEM_ENTRY} -1"))?;
println!("cleanup: .additem {ITEM_ENTRY} -1");
Ok(())
}
}
@@ -191,8 +191,45 @@ impl ItemInfo {
pub fn placeable_on_action_bar(&self) -> bool {
self.use_spell.is_some() || self.inventory_type != 0
}
/// Does a right-click **open** this concrete instance rather than use it? `instance_flags` is
/// the item object's `ITEM_FIELD_FLAGS` (0 for a template-only view, which can never be
/// openable — you can't right-click a hyperlink).
///
/// The one predicate behind both halves of the affordance: the tooltip's green
/// `<Right Click to Open>` line and the `CMSG_OPEN_ITEM` fork. It is the reference's own
/// tooltip condition (wow-re `tooltip-content-law.md` §1-OPENABLE, byte-read at
/// `0x52e2f8`–`0x52e321`): the template LOOTABLE bit [`ITEM_FLAG_LOOTABLE`] behind its lock
/// sub-gate — a [`ItemInfo::lock_id`] item only opens once the instance carries
/// [`ITEM_DYNFLAG_UNLOCKED`] — **or** a wrapped gift ([`ITEM_FLAG_WRAPPER`] on the template
/// plus [`ITEM_DYNFLAG_WRAPPED`] on the instance). vmangos's `HandleOpenItemOpcode` gates on
/// exactly the same pair, so the two ends agree: what the tooltip promises is what the server
/// will honour, and a still-locked junkbox neither advertises the line nor sends the packet.
pub fn openable(&self, instance_flags: u32) -> bool {
let lootable = self.flags & ITEM_FLAG_LOOTABLE != 0
&& (self.lock_id == 0 || instance_flags & ITEM_DYNFLAG_UNLOCKED != 0);
let gift =
self.flags & ITEM_FLAG_WRAPPER != 0 && instance_flags & ITEM_DYNFLAG_WRAPPED != 0;
lootable || gift
}
}
/// `ITEM_FLAG_LOOTABLE` — the **template** bit that makes an item right-clickable into a loot
/// window (a clam, a lockbox, a Gnomish Mind Control Cap's box). vmangos
/// `ItemPrototype.h:66`, whose own comment names the client behaviour this drives: "It or lockid
/// set enable for client show 'Right click to open'".
pub const ITEM_FLAG_LOOTABLE: u32 = 0x0000_0004;
/// `ITEM_FLAG_WRAPPER` — the template bit for gift wrapping (vmangos `ItemPrototype.h:73`); paired
/// with [`ITEM_DYNFLAG_WRAPPED`] on the instance it makes the wrapped present openable.
pub const ITEM_FLAG_WRAPPER: u32 = 0x0000_0200;
/// `ITEM_DYNFLAG_UNLOCKED` — the **instance** (`ITEM_FIELD_FLAGS`) bit a lockbox gains once it has
/// been picked/keyed open; until then a `LockID` item refuses to open (vmangos
/// `HandleOpenItemOpcode`'s `EQUIP_ERR_ITEM_LOCKED`).
pub const ITEM_DYNFLAG_UNLOCKED: u32 = 0x0000_0004;
/// `ITEM_DYNFLAG_WRAPPED` — the instance bit on a gift-wrapped item; opening it unwraps to the
/// present (vmangos swaps the entry from `character_gifts` rather than sending loot).
pub const ITEM_DYNFLAG_WRAPPED: u32 = 0x0000_0008;
/// One `Damage` block ([`ItemInfo::damages`] — block 0 is also mirrored into
/// [`ItemInfo::dmg_min`]/[`ItemInfo::dmg_max`]/[`ItemInfo::dmg_type`]).
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -508,6 +545,18 @@ pub fn use_item(bag_index: u8, slot: u8, spell_slot: u8, go_target: Option<u64>)
body
}
/// Body of `CMSG_OPEN_ITEM` (VERIFIED vmangos `OpenItem::ReadFromWorldPacket`,
/// `Server/Packets/Spell.cpp` + `.h:36-45`; opcode 172 `Opcodes_1_12_1.h:175`): `bagIndex`, `slot`
/// — the same two bytes and the same bag addressing as [`use_item`], and nothing else (no spell
/// ordinal, no targets block: opening is not a cast).
///
/// The right-click fork for an [`ItemInfo::openable`] item. The server answers on the **item's own
/// guid**: `SendLoot(item, LOOT_CORPSE)` for a lootable, or — for a wrapped gift — an entry swap
/// and no window at all.
pub fn open_item(bag_index: u8, slot: u8) -> Vec<u8> {
vec![bag_index, slot]
}
/// Body of `CMSG_AUTOEQUIP_ITEM` (VERIFIED vmangos `AutoEquipItem::ReadFromWorldPacket`,
/// `Server/Packets/Item.cpp:17-21` + `.h:31-39`; opcode 266 `Opcodes_1_12_1.h:269`): source
/// `srcbag`/`srcslot` (both `uint8`), the same bag addressing as [`use_item`]. The real client
+4 -3
View File
@@ -105,9 +105,10 @@ pub use group::{
PartyMemberStatsInfo, RaidTargetUpdate, ReadyCheck, GROUP_MEMBER_ASSISTANT,
};
pub use items::{
auto_equip_item, auto_store_bag_item, destroy_item, item_query, set_ammo, split_item,
swap_inv_item, swap_item, use_item, ItemDamage, ItemInfo, ItemSpellEntry, ItemUseSpell,
BAG_PLAYER_INVENTORY, SLOT_BAG_FIRST, SLOT_PACK_FIRST,
auto_equip_item, auto_store_bag_item, destroy_item, item_query, open_item, set_ammo,
split_item, swap_inv_item, swap_item, use_item, ItemDamage, ItemInfo, ItemSpellEntry,
ItemUseSpell, BAG_PLAYER_INVENTORY, ITEM_DYNFLAG_UNLOCKED, ITEM_DYNFLAG_WRAPPED,
ITEM_FLAG_LOOTABLE, ITEM_FLAG_WRAPPER, SLOT_BAG_FIRST, SLOT_PACK_FIRST,
};
pub use loot::{
autostore_loot_item, loot, loot_error, loot_money, loot_release, loot_roll, loot_type,
@@ -218,6 +218,12 @@ pub const CMSG_GAMEOBJECT_QUERY: u16 = 0x005E; // 94
pub const CMSG_QUEST_QUERY: u16 = 0x005C; // 92
pub const CMSG_ITEM_QUERY_SINGLE: u16 = 0x0056; // 86
pub const CMSG_USE_ITEM: u16 = 0x00AB; // 171
/// VERIFIED vmangos `Opcodes_1_12_1.h:175`: 172. Body in [`super::items::open_item`] — the bag
/// position of an *openable* item (a clam, an unlocked lockbox, a wrapped gift). The right-click
/// fork `CMSG_USE_ITEM` never takes: the server answers `SMSG_LOOT_RESPONSE` on the **item's own
/// guid** (`HandleOpenItemOpcode` → `SendLoot(item, LOOT_CORPSE)`), so the loot window opens over
/// a thing in your bag rather than a corpse in the world.
pub const CMSG_OPEN_ITEM: u16 = 0x00AC; // 172
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 177. Body in [`super::gameobj_use`] — a full guid, the
/// GameObject to use (decision 0236). Not interchangeable with `CMSG_LOOT`: the server rejects a
/// GameObject guid on `CMSG_LOOT`, so a chest opens its loot through this opcode.
@@ -360,6 +360,15 @@ impl WorldSession {
)
}
/// 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<()> {
@@ -43,6 +43,18 @@ impl WorldWriter {
)
}
/// Open an item by bag position (`CMSG_OPEN_ITEM`, layout in [`messages::open_item`]) — crack
/// the clam, empty the picked lockbox, unwrap the gift. The right-click fork for an
/// [`crate::ItemInfo::openable`] item; the server answers with `SMSG_LOOT_RESPONSE` on the
/// item's **own** guid (so the loot window opens over a thing in the bag), or an equip error
/// on a refusal (still locked, dead, flying).
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`, layout in [`messages::auto_equip_item`]) — the
/// server picks the destination slot. Success arrives as inventory-slot values deltas (and the
/// visible-item change everyone renders); refusal as `SMSG_INVENTORY_CHANGE_FAILURE`.
+9
View File
@@ -42,6 +42,15 @@ fn item_query_wire() {
// CMSG_AUTOEQUIP_ITEM (vmangos AutoEquipItem::ReadFromWorldPacket): bagIndex, slot.
assert_eq!(messages::auto_equip_item(255, 25), hx("ff19"));
// CMSG_OPEN_ITEM (vmangos OpenItem::ReadFromWorldPacket, Server/Packets/Spell.cpp:19-23):
// bagIndex, slot — and NOTHING else (no spell ordinal, no targets block; opening is not a
// cast). The same two bytes as auto-equip under a different opcode: this is the fork a
// clam/lockbox/gift takes instead of USE_ITEM, and the only one the server answers with
// SendLoot on the item's own guid. Backpack slot 3 = bag 255 + player-array slot 25; an item
// inside an equipped bag addresses that bag's own player-array slot (19..22) + inner slot.
assert_eq!(messages::open_item(255, 25), hx("ff19"));
assert_eq!(messages::open_item(19, 0), hx("1300"));
// SMSG_INVENTORY_CHANGE_FAILURE (vmangos InventoryChangeFailure::AppendBodyTo): the level
// refusal (reason 1) carries a u32 required level before the guids; others don't.
let level_fail = messages::parse_server(