/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(())
}
}