/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 clap::Parser;
use probes::{ 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, QueryNames, Quest, QuestItem, QuestLog, Speed, Spells, Spirit, SwapPackSlots, UsePackSlot,
Vendor, WorldState, Vendor, WorldState,
}; };
@@ -81,6 +81,14 @@ struct Cli {
/// delta or a destroy on that item's guid (consumed), or an explicit cast-result refusal. /// delta or a destroy on that item's guid (consumed), or an explicit cast-result refusal.
#[arg(long)] #[arg(long)]
use_pack_slot: Option<u8>, 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 /// 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 /// (`.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"`). /// 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 { if let Some(n) = cli.use_pack_slot {
probes.push(Box::new(UsePackSlot { n })); probes.push(Box::new(UsePackSlot { n }));
} }
if cli.open_item {
probes.push(Box::new(OpenItem));
}
if let Some(n) = cli.equip_pack_slot { if let Some(n) = cli.equip_pack_slot {
probes.push(Box::new(EquipPackSlot { n })); probes.push(Box::new(EquipPackSlot { n }));
} }
@@ -67,6 +67,7 @@ mod equip_pack_slot;
mod giverstatus; mod giverstatus;
mod groundfx; mod groundfx;
mod loot; mod loot;
mod open_item;
mod query_names; mod query_names;
mod quest; mod quest;
mod quest_item; mod quest_item;
@@ -87,6 +88,7 @@ pub(crate) use equip_pack_slot::EquipPackSlot;
pub(crate) use giverstatus::GiverStatus; pub(crate) use giverstatus::GiverStatus;
pub(crate) use groundfx::GroundFx; pub(crate) use groundfx::GroundFx;
pub(crate) use loot::Loot; pub(crate) use loot::Loot;
pub(crate) use open_item::OpenItem;
pub(crate) use query_names::QueryNames; pub(crate) use query_names::QueryNames;
pub(crate) use quest::Quest; pub(crate) use quest::Quest;
pub(crate) use quest_item::QuestItem; 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 { pub fn placeable_on_action_bar(&self) -> bool {
self.use_spell.is_some() || self.inventory_type != 0 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 /// One `Damage` block ([`ItemInfo::damages`] — block 0 is also mirrored into
/// [`ItemInfo::dmg_min`]/[`ItemInfo::dmg_max`]/[`ItemInfo::dmg_type`]). /// [`ItemInfo::dmg_min`]/[`ItemInfo::dmg_max`]/[`ItemInfo::dmg_type`]).
#[derive(Debug, Clone, Copy, PartialEq)] #[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
} }
/// 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`, /// 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 /// `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 /// `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, PartyMemberStatsInfo, RaidTargetUpdate, ReadyCheck, GROUP_MEMBER_ASSISTANT,
}; };
pub use items::{ pub use items::{
auto_equip_item, auto_store_bag_item, destroy_item, item_query, set_ammo, split_item, auto_equip_item, auto_store_bag_item, destroy_item, item_query, open_item, set_ammo,
swap_inv_item, swap_item, use_item, ItemDamage, ItemInfo, ItemSpellEntry, ItemUseSpell, split_item, swap_inv_item, swap_item, use_item, ItemDamage, ItemInfo, ItemSpellEntry,
BAG_PLAYER_INVENTORY, SLOT_BAG_FIRST, SLOT_PACK_FIRST, 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::{ pub use loot::{
autostore_loot_item, loot, loot_error, loot_money, loot_release, loot_roll, loot_type, 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_QUEST_QUERY: u16 = 0x005C; // 92
pub const CMSG_ITEM_QUERY_SINGLE: u16 = 0x0056; // 86 pub const CMSG_ITEM_QUERY_SINGLE: u16 = 0x0056; // 86
pub const CMSG_USE_ITEM: u16 = 0x00AB; // 171 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 /// 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 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. /// 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 /// Equip a bag item (`CMSG_AUTOEQUIP_ITEM`) — the unsplit twin of
/// [`WorldWriter::auto_equip_item`], for the probe's live equip verification. /// [`WorldWriter::auto_equip_item`], for the probe's live equip verification.
pub fn auto_equip_item(&mut self, bag_index: u8, slot: u8) -> Result<()> { 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 /// 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 /// 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`. /// 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. // CMSG_AUTOEQUIP_ITEM (vmangos AutoEquipItem::ReadFromWorldPacket): bagIndex, slot.
assert_eq!(messages::auto_equip_item(255, 25), hx("ff19")); 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 // SMSG_INVENTORY_CHANGE_FAILURE (vmangos InventoryChangeFailure::AppendBodyTo): the level
// refusal (reason 1) carries a u32 required level before the guids; others don't. // refusal (reason 1) carries a u32 required level before the guids; others don't.
let level_fail = messages::parse_server( let level_fail = messages::parse_server(
+50 -3
View File
@@ -306,6 +306,53 @@ impl ZKey {
pub const fn raw(self) -> u64 { pub const fn raw(self) -> u64 {
self.0 self.0
} }
/// Unpack this key back into its fields.
#[inline]
pub const fn parts(self) -> ZParts {
unpack(self.0)
}
}
/// The fields of a packed [`ZKey`], in significance order — the readable face of an opaque `u64`.
///
/// A draw-order key is a single number by design (one sort produces the whole render list), which
/// makes every question *about* an order — "why is this over that?", "which pairs would a re-rank
/// invert?" — unanswerable at a glance. [`unpack`] is that answer, and it is why
/// [`crate::script::ExtractedQuad::z`] can stay a bare `u64`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ZParts {
/// The frame strata bucket id (0..=9, [`Strata::index`]).
pub strata: u8,
/// The frame level within the strata bucket.
pub level: u16,
/// The draw layer (0..=4, [`DrawLayer::index`]) — bucket-wide, above the frame (decision 0884).
pub layer: u8,
/// `true` for a font string: all textures of a `(strata, level, layer)` precede all its text.
pub is_fontstring: bool,
/// The owning frame's link-stamp — its position in the client's intrusive bucket list.
pub insertion: u32,
/// `false` for a frame's own slot, `true` for one of its regions.
pub is_region: bool,
/// The Era `textureSubLevel` knob; inert for 1.12 content (always 0 there).
pub sub_level: i8,
/// Declaration order within the owning frame's layer.
pub decl: u16,
}
/// Unpack a raw [`ZKey`] (e.g. [`crate::script::ExtractedQuad::z`]) into its fields.
#[inline]
pub const fn unpack(raw: u64) -> ZParts {
ZParts {
strata: ((raw >> STRATUM_SHIFT) & 0xf) as u8,
level: ((raw >> LEVEL_SHIFT) & 0xffff) as u16,
layer: ((raw >> LAYER_SHIFT) & 0x7) as u8,
is_fontstring: (raw >> FONTSTRING_SHIFT) & 1 == 1,
insertion: ((raw >> INSERTION_SHIFT) & ((1 << INSERTION_BITS) - 1)) as u32,
is_region: (raw >> IS_REGION_SHIFT) & 1 == 1,
sub_level: (((raw >> SUBLEVEL_SHIFT) & 0xff) as i16 - 128) as i8,
decl: ((raw >> DECL_SHIFT) & ((1 << DECL_BITS) - 1)) as u16,
}
} }
// ───────────────────────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -329,9 +376,9 @@ pub enum ZTarget {
/// (`propagation.md`) — a child of a hidden frame already carries `effective_visible == false` and is /// (`propagation.md`) — a child of a hidden frame already carries `effective_visible == false` and is
/// skipped here. Each visible frame emits its own [`ZTarget::Frame`] entry followed by one /// skipped here. Each visible frame emits its own [`ZTarget::Frame`] entry followed by one
/// [`ZTarget::Region`] per owned region; the returned vec is sorted ascending by [`ZKey`], which *is* /// [`ZTarget::Region`] per owned region; the returned vec is sorted ascending by [`ZKey`], which *is*
/// the total draw order (strata → **layer** → frame link-stamp → is-region → sub-level → /// the total draw order (strata → frame level → **draw layer** → texture<fontstring → frame
/// texture<fontstring → decl; the layer outranks the frame — see the `ZKey` bit-layout note and /// link-stamp → is-region → sub-level → decl; the layer outranks the frame — see the `ZKey`
/// decision 0884). Ties are impossible: distinct frames differ in link-stamp, a frame and its /// bit-layout note and decision 0884). Ties are impossible: distinct frames differ in link-stamp, a frame and its
/// regions differ in the is-region bit, and a frame's regions differ in the remaining fields. /// regions differ in the is-region bit, and a frame's regions differ in the remaining fields.
/// ///
/// Ordering only: this emits an entry for *every* region of a visible frame. Region-level /// Ordering only: this emits an entry for *every* region of a visible frame. Region-level
@@ -425,8 +425,9 @@ fn required_level_one_is_hidden() {
/// (white) when the instance carries letter text, the green-escaped "<Made by %s>" otherwise — /// (white) when the instance carries letter text, the green-escaped "<Made by %s>" otherwise —
/// then READABLE off the instance text id (its template's PageText is 0 — the /// then READABLE off the instance text id (its template's PageText is 0 — the
/// director-reported gap). An unresolved creator (name query in flight) emits no line. LOCKED /// director-reported gap). An unresolved creator (name query in flight) emits no line. LOCKED
/// yields to the instance UNLOCKED bit — and ITEM_OPENABLE never emits from `SetBagItem` (a /// yields to the instance UNLOCKED bit — which is also what un-gates ITEM_OPENABLE: a bag hover
/// p6=1 source: the `0x52e2e8` bypass), so the unlocked chest shows its bare name. /// on an openable instance DOES show the green `<Right Click to Open>` (director-observed on a
/// clam; see `ItemInstance::openable_source` for why this overrides §1-OPENABLE's p6 reading).
#[test] #[test]
fn instance_tail_creator_and_readable() { fn instance_tail_creator_and_readable() {
let mut s = script(); let mut s = script();
@@ -458,16 +459,26 @@ fn instance_tail_creator_and_readable() {
flags, flags,
..Default::default() ..Default::default()
}; };
s.set_item_template(
7973,
ItemTemplateView {
name: "Small Barnacled Clam".into(),
quality: 1,
flags: 0x4,
..Default::default()
},
);
let mut slots = HashMap::new(); let mut slots = HashMap::new();
slots.insert(1, slot(889, true, Some("One"), 0)); // the mail letter slots.insert(1, slot(889, true, Some("One"), 0)); // the mail letter
slots.insert(2, slot(889, true, None, 0)); // creator name still in flight slots.insert(2, slot(889, true, None, 0)); // creator name still in flight
slots.insert(3, slot(2589, false, Some("Geoffrey"), 0)); // crafted, still locked slots.insert(3, slot(2589, false, Some("Geoffrey"), 0)); // crafted, still locked
slots.insert(4, slot(2589, false, None, 0x4)); // unlocked → LOCKED gone, and NO open line slots.insert(4, slot(2589, false, None, 0x4)); // unlocked → LOCKED gone, open line on
slots.insert(5, slot(7973, false, None, 0)); // the clam: lockless, openable outright
s.set_container( s.set_container(
0, 0,
Some(ContainerState { Some(ContainerState {
name: Some("Backpack".into()), name: Some("Backpack".into()),
num_slots: 4, num_slots: 5,
slots, slots,
}), }),
); );
@@ -502,11 +513,18 @@ fn instance_tail_creator_and_readable() {
vec!["Heavy Chest", "Locked", "|cff00ff00<Made by Geoffrey>|r"], vec!["Heavy Chest", "Locked", "|cff00ff00<Made by Geoffrey>|r"],
"CREATED_BY carries the string's own green escape; locked chest hides OPENABLE" "CREATED_BY carries the string's own green escape; locked chest hides OPENABLE"
); );
// The instance UNLOCKED bit retires the LOCKED line — and OPENABLE still never emits // The instance UNLOCKED bit retires the LOCKED line AND satisfies the openable lock sub-gate.
// (SetBagItem is p6=1; only send-mail/auction/buyback sources reach the emitter in 5875).
s.run(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); TT:SetBagItem(0, 4)"#) s.run(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); TT:SetBagItem(0, 4)"#)
.unwrap(); .unwrap();
let lines = lines_of(&mut s);
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(texts, vec!["Heavy Chest", "<Right Click to Open>"]);
assert_eq!(lines[1].1, [0.0, 1.0, 0.0, 1.0], "OPENABLE is green");
// The director's case: a lockless LOOTABLE template (a clam) is openable outright — name +
// the green line, nothing between them.
s.run(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); TT:SetBagItem(0, 5)"#)
.unwrap();
let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect(); let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect();
assert_eq!(texts, vec!["Heavy Chest"]); assert_eq!(texts, vec!["Small Barnacled Clam", "<Right Click to Open>"]);
assert!(s.take_errors().is_empty()); assert!(s.take_errors().is_empty());
} }
@@ -309,7 +309,10 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
creator: s.creator.clone(), creator: s.creator.clone(),
has_text: false, has_text: false,
flags: s.flags, flags: s.flags,
// p6=1 in the ref — SetInventoryItem can never emit OPENABLE. // Unobservable either way: nothing openable is equippable, so the
// doll hover has no case to show. Left `false` (wow-re
// §1-OPENABLE's p6 reading) rather than flipped on the bag's
// evidence — see `ItemInstance::openable_source`.
openable_source: false, openable_source: false,
}, },
) )
@@ -375,8 +378,11 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
creator: s.creator.clone(), creator: s.creator.clone(),
has_text: s.readable, has_text: s.readable,
flags: s.flags, flags: s.flags,
// p6=1 in the ref — SetBagItem can never emit OPENABLE (§1-OPENABLE). // The bag hover DOES emit `<Right Click to Open>` — director-observed
openable_source: false, // (a clam in a bag), against wow-re §1-OPENABLE's p6 reading. See
// `ItemInstance::openable_source`: that gate is under re-derivation,
// and the observation is the ground truth we build on.
openable_source: true,
}, },
), ),
None => return Ok(MultiValue::from_vec(vec![Value::Nil])), None => return Ok(MultiValue::from_vec(vec![Value::Nil])),
@@ -29,11 +29,24 @@ pub(super) struct ItemInstance {
/// Instance `ITEM_FIELD_FLAGS` — the openable lock sub-gate reads UNLOCKED `0x4` /// Instance `ITEM_FIELD_FLAGS` — the openable lock sub-gate reads UNLOCKED `0x4`
/// (`0x52e30c`) and the wrapped-gift arm WRAPPED `0x8` (`0x52e31d`). /// (`0x52e30c`) and the wrapped-gift arm WRAPPED `0x8` (`0x52e31d`).
pub flags: u32, pub flags: u32,
/// The ref's `p6=0` source class (wow-re §1-OPENABLE, byte-census 2026-07-20): ITEM_OPENABLE /// May this source emit the ITEM_OPENABLE line at all?
/// is reachable ONLY from object-present p6=0 sources (SetSendMailItem, SetAuctionSellItem, ///
/// SetBuybackItem) — `SetBagItem`/`SetInventoryItem` pass p6=1 and a `[this+0x440]` gate /// wow-re §1-OPENABLE (byte-census 2026-07-20) reads a `[this+0x440]` gate at `0x52e2e8` as
/// (`0x52e2e8`) bypasses the openable tree entirely. Every current benilla source is p6=1 /// bypassing the whole openable tree for every `p6=1` binding, and concludes that
/// or template-only, so this stays `false` until those tooltips carry real instances. /// `SetBagItem` — the binding your own bags hover through — can NEVER emit it. **That
/// conclusion is contradicted by ground truth:** the director's screenshot shows a Small
/// Barnacled Clam hovered IN A BAG with the green `<Right Click to Open>` line, and vmangos's
/// own `ITEM_FLAG_LOOTABLE` comment names that exact client behaviour ("It or lockid set
/// enable for client show 'Right click to open'"). A line that could only ever appear on the
/// send-mail / auction-sell / buyback tooltips would also be behaviourally absurd. The note is
/// additionally self-inconsistent there (§1-CREATOR puts the GIFTCREATOR read at `0x52e32a`,
/// inside the region §1-OPENABLE calls the openable emit + jump), so the transcription — not
/// the observation — is the suspect. A wow-re §5 re-derivation of `[0x52e2e0, 0x52e376)` is in
/// flight; until it lands, the bag hover follows the director's eyes.
///
/// The flag is kept rather than deleted because the *template-vs-instance* distinction it also
/// carries is settled (gate 1, `0x52e2e0`: no object ⇒ no line — you can't right-click a
/// hyperlink), and because the p6 split may still be real for the sources we cannot observe.
pub openable_source: bool, pub openable_source: bool,
} }
+19 -6
View File
@@ -67,6 +67,13 @@
frame.portrait = getglobal(name .. "Portrait") frame.portrait = getglobal(name .. "Portrait")
frame.healthBar = getglobal(name .. "HealthBar") frame.healthBar = getglobal(name .. "HealthBar")
frame.manaBar = getglobal(name .. "ManaBar") frame.manaBar = getglobal(name .. "ManaBar")
-- The recessed-bar look, the reference's own mechanism (ref-TargetFrame.lua l.32-34):
-- push the bars one frame level BELOW the art's TextureFrame. Frame level is the only
-- key term above the draw layer (decision 0884), so this is the only thing that can hold
-- the BACKGROUND party art over the ARTWORK bar fills. See the LAYERING note below.
local textureFrameLevel = getglobal(tf):GetFrameLevel()
frame.healthBar:SetFrameLevel(textureFrameLevel - 1)
frame.manaBar:SetFrameLevel(textureFrameLevel - 1)
frame.nameText = getglobal(tf .. "Name") frame.nameText = getglobal(tf .. "Name")
frame.leaderIcon = getglobal(tf .. "LeaderIcon") frame.leaderIcon = getglobal(tf .. "LeaderIcon")
frame.masterIcon = getglobal(tf .. "MasterIcon") frame.masterIcon = getglobal(tf .. "MasterIcon")
@@ -257,12 +264,18 @@
<!-- The shared member-frame template — ref PartyFrameTemplates.xml, 128x53 over the 128x64 <!-- The shared member-frame template — ref PartyFrameTemplates.xml, 128x53 over the 128x64
UI-PartyFrame art at (0,-2) (the art hangs 13 px below the frame rect, as authored). UI-PartyFrame art at (0,-2) (the art hangs 13 px below the frame rect, as authored).
LAYERING (the ref's law, in our z-model's idiom): the ref nests the frame art two anonymous LAYERING (the ref's law): the ref nests the frame art two anonymous child frames deep so it
child frames deep so it draws OVER the status bars — the bars show through the art's draws OVER the status bars — the bars show through the art's semi-transparent tube windows,
semi-transparent tube windows, inset, their ends clipped by the rails. Ours is the inset, their ends clipped by the rails. Ours is the UnitFrames.xml idiom, which reaches the
UnitFrames.xml idiom: a $parentTextureFrame child declared AFTER the bars. The name and the same place by the ref's OTHER route (its TargetFrame's): a $parentTextureFrame child, with
leader/master/disconnect icons ride in it too (the ref keeps them in that nested frame; OnLoad pushing the bars one frame level BELOW it. Both spend a frame LEVEL, which is the
left below the art, the ring would swallow the leader crown). --> point — level is the one key term above the draw layer (decision 0884). Until then this file
instead leaned on declaring the TextureFrame after the bars, and the old draw key's
insertion-order tie-break; 0884 pinned the layer as bucket-wide and above the frame, which
put the bars' ARTWORK fills straight over the BACKGROUND art. Do not go back to declaration
order. The name and the leader/master/disconnect icons ride in the TextureFrame too (the ref
keeps them in that nested frame; left below the art, the ring would swallow the leader
crown). -->
<!-- frameStrata LOW is the ref's own (`PartyFrameTemplates.xml` l.194) and it is load-bearing, <!-- frameStrata LOW is the ref's own (`PartyFrameTemplates.xml` l.194) and it is load-bearing,
not decoration. The panel windows (loot, character, merchant, quest log, …) are MEDIUM, and not decoration. The panel windows (loot, character, merchant, quest log, …) are MEDIUM, and
the party frames share the top-left corner with the left-slot panel. Left in the default the party frames share the top-left corner with the left-slot panel. Left in the default
+35 -14
View File
@@ -12,12 +12,20 @@
LAYERING (matches the ref's structure). The draw order must be trough < bars < ring art < text: LAYERING (matches the ref's structure). The draw order must be trough < bars < ring art < text:
the frame's metal well-edges overlap the bar edges so the bars read RECESSED, not pasted on top. the frame's metal well-edges overlap the bar edges so the bars read RECESSED, not pasted on top.
The ref gets this with a child `TargetFrameTextureFrame` (ring art) and `SetFrameLevel(bar, The ref gets this with a child `TargetFrameTextureFrame` (ring art) and `SetFrameLevel(bar,
textureFrame-1)` to push the bars beneath it. Our z-model tie-breaks a `(strata, level)` bucket by textureFrame-1)` to push the bars beneath it, and we do the same, in BenillaUnitFrame_OnLoad:
frame *insertion* order (crates/benilla-ui/src/order.rs), so we get the same result structurally the parent holds trough + portrait (+ target's name plate) in BACKGROUND; the
and without SetFrameLevel: the parent holds trough + portrait (+ target's name plate) in `$parentHealthBar`/`$parentPowerBar` StatusBars sit at the PARENT's frame level; and
BACKGROUND; the `$parentHealthBar`/`$parentPowerBar` StatusBars come next; then
`$parentTextureFrame` (232×100, filling the parent) holds the ring art (BACKGROUND) + name/level/dead text `$parentTextureFrame` (232×100, filling the parent) holds the ring art (BACKGROUND) + name/level/dead text
(OVERLAY), declared LAST so it draws over the bar edges and the portrait. The ring's art is a THIN (OVERLAY) one level ABOVE them, so it draws over the bar edges and the portrait.
That SetFrameLevel is load-bearing, and this file used to do without it. Until decision 0884
the bars were held under the ring art by DECLARING `$parentTextureFrame` after them, leaning on
the old draw key's insertion-order tie-break between siblings at one `(strata, level)`. 0884
pinned the real law at the binary: the draw LAYER is bucket-wide and outranks the frame, so
siblings interleave by layer and the bars' ARTWORK fills painted straight over the TextureFrame's
BACKGROUND ring art (the frames read as pasted-on slabs — director report, 2026-08-02). Frame
level is the one term above the layer, which is exactly why the reference spends a level here.
Do not "simplify" this back into declaration order. The ring's art is a THIN
band with transparent corners (not an opaque surround), so the square is hidden by the portrait band with transparent corners (not an opaque surround), so the square is hidden by the portrait
itself — set via SetPortraitToTexture, masked to its inscribed circle (the client's portraits are itself — set via SetPortraitToTexture, masked to its inscribed circle (the client's portraits are
always round: a model render or a circular-cropped icon), so no corner reaches the ring's gap. The always round: a model render or a circular-cropped icon), so no corner reaches the ring's gap. The
@@ -129,6 +137,13 @@
-- The name/level/dead text live inside $parentTextureFrame (so they draw over the ring art -- The name/level/dead text live inside $parentTextureFrame (so they draw over the ring art
-- that draws over the bars), so their global names carry the TextureFrame prefix. -- that draws over the bars), so their global names carry the TextureFrame prefix.
local tf = name .. "TextureFrame" local tf = name .. "TextureFrame"
-- The recessed-bar look, the reference's own mechanism (ref-TargetFrame.lua l.32-34):
-- push the bars one frame level BELOW the ring art's TextureFrame. Frame level is the
-- only key term above the draw layer (decision 0884), so this is the only thing that can
-- hold a BACKGROUND ring texture over an ARTWORK bar fill. See the LAYERING note up top.
local textureFrameLevel = getglobal(tf):GetFrameLevel()
frame.healthBar:SetFrameLevel(textureFrameLevel - 1)
frame.powerBar:SetFrameLevel(textureFrameLevel - 1)
frame.nameText = getglobal(tf .. "NameText") frame.nameText = getglobal(tf .. "NameText")
frame.levelText = getglobal(tf .. "LevelText") frame.levelText = getglobal(tf .. "LevelText")
frame.deadText = getglobal(tf .. "DeadText") frame.deadText = getglobal(tf .. "DeadText")
@@ -752,8 +767,11 @@
</Anchors> </Anchors>
<Color r="0" g="0" b="0" a="0.5"/> <Color r="0" g="0" b="0" a="0.5"/>
</Texture> </Texture>
<!-- Portrait 64×64 TOPLEFT (42,-12) (ref l.35-46, moved ARTWORK→BACKGROUND so the ring <!-- Portrait 64×64 TOPLEFT (42,-12) (ref l.35-46, which puts it in ARTWORK; ours sits
art draws over it). Texture set in OnLoad via SetPortraitToTexture (circle-masked). --> in BACKGROUND, a leftover of the pre-0884 layering. The ring art now clears it by
frame LEVEL, and nothing else in this layer overlaps the portrait's square, so the
two are indistinguishable here — left alone rather than churned inside a bug fix).
Texture set in OnLoad via SetPortraitToTexture (circle-masked). -->
<Texture name="$parentPortrait"> <Texture name="$parentPortrait">
<Size><AbsDimension x="64" y="64"/></Size> <Size><AbsDimension x="64" y="64"/></Size>
<Anchors> <Anchors>
@@ -761,8 +779,9 @@
</Anchors> </Anchors>
</Texture> </Texture>
</Layer> </Layer>
<!-- The ring art + name/level text live in $parentTextureFrame below (declared AFTER the <!-- The ring art + name/level text live in $parentTextureFrame below, one frame level
bars) so they draw OVER the bar edges — the recessed-bar look. See the LAYERING note. --> ABOVE the bars, so they draw OVER the bar edges — the recessed-bar look. OnLoad puts
them there (SetFrameLevel); see the LAYERING note at the top of this file. -->
</Layers> </Layers>
<Frames> <Frames>
<!-- Health bar 119×12 TOPLEFT (106,-41), UI-StatusBar, green (ref l.288-315). --> <!-- Health bar 119×12 TOPLEFT (106,-41), UI-StatusBar, green (ref l.288-315). -->
@@ -784,8 +803,9 @@
<BarColor r="0.0" g="0.0" b="1.0"/> <BarColor r="0.0" g="0.0" b="1.0"/>
</StatusBar> </StatusBar>
<!-- The ring art + text, over the bars (ref TargetFrameTextureFrame). 232×100 fills the <!-- The ring art + text, over the bars (ref TargetFrameTextureFrame). 232×100 fills the
parent; declared after the StatusBars so it draws over their edges — our z-model's parent; it draws over the bar edges because OnLoad pushes the bars one frame level
insertion-order equivalent of the ref's SetFrameLevel(bar, textureFrame-1). --> BELOW it (the ref's SetFrameLevel(bar, textureFrame-1)) — NOT because of where it
is declared. See the LAYERING note at the top of this file. -->
<Frame name="$parentTextureFrame"> <Frame name="$parentTextureFrame">
<Size><AbsDimension x="232" y="100"/></Size> <Size><AbsDimension x="232" y="100"/></Size>
<Anchors> <Anchors>
@@ -965,9 +985,10 @@
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/> <BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0.0" g="0.0" b="1.0"/> <BarColor r="0.0" g="0.0" b="1.0"/>
</StatusBar> </StatusBar>
<!-- The ring art + text, over the bars (ref TargetFrameTextureFrame); declared after the <!-- The ring art + text, over the bars (ref TargetFrameTextureFrame); it draws over the
StatusBars so it draws over their edges (our z-model's insertion-order equivalent of bar edges because OnLoad pushes the bars one frame level BELOW it (the ref's
the ref's SetFrameLevel(bar, textureFrame-1)). --> SetFrameLevel(bar, textureFrame-1)) — NOT because of where it is declared. See the
LAYERING note at the top of this file. -->
<Frame name="$parentTextureFrame"> <Frame name="$parentTextureFrame">
<Size><AbsDimension x="232" y="100"/></Size> <Size><AbsDimension x="232" y="100"/></Size>
<Anchors> <Anchors>
+72 -60
View File
@@ -85,13 +85,14 @@
//! [`crate::model_fade::ParentModel`] link up to the unit — the recursion itself, and the same walk //! [`crate::model_fade::ParentModel`] link up to the unit — the recursion itself, and the same walk
//! `ModelAlphas` already does for alpha. //! `ModelAlphas` already does for alpha.
//! //!
//! **Proc 11 ships too** (decision 0889): the **freeze**. Its param is a playback *rate* written onto //! **Proc 11 ships too** (decisions 0889 + 0891): the **freeze**. Its param is a playback *rate*
//! the unit's own animation clocks (`0x60db7e` → `SetBoneAnimSpeed 0x712910` on the mount's bone 0, //! written onto the unit's own animation clocks (`0x60db7e` → `SetBoneAnimSpeed 0x712910` on the
//! the body's key-bone 4 — the upper-body split — and the body's bone 0), old rates saved in the node //! mount's bone 0, the body's key-bone 4 — the upper-body split — and the body's bone 0), old rates
//! and written back when it expires (`0x6203e0`). Rate 0 is Ice Block: the pose holds exactly where //! saved in the node and written back when it expires (`0x6203e0`). Rate 0 is Ice Block: the pose
//! it was, *including* a cast one-shot that had just been armed, which is why the caster never gets //! holds exactly where it was, *including* a cast one-shot that had just been armed, which is why the
//! to raise their hand. [`apply_aura_anim_rate`] is that leg, and the reference's save/restore is //! caster never gets to raise their hand. [`apply_aura_anim_rate`] is that leg — and it expresses
//! [`AnimRateFreeze`]. //! rate 0 as a **pause**, not as a speed, because the reference's rate belongs to a bone while a speed
//! here belongs to a clip that other code copies from ([`AnimRateFreeze`] carries the story).
//! //!
//! Types 2, 7 and 13 also appear on state kits (Berserk/Bloodlust's 2, the Sap/Feign-Death 7, the //! Types 2, 7 and 13 also appear on state kits (Berserk/Bloodlust's 2, the Sap/Feign-Death 7, the
//! "Glowy (Red)" 13) and have **no verified mechanism** — they fall through [`node_for`]'s single `_` //! "Glowy (Red)" 13) and have **no verified mechanism** — they fall through [`node_for`]'s single `_`
@@ -319,33 +320,50 @@ fn tint_probe() -> Option<[u8; 3]> {
}) })
} }
/// On a rig whose clocks a proc-11 aura is holding: the playback speeds we took over, so the release /// Marker: this rig's clocks are being held by a proc-11 aura, so the release knows to let them go.
/// can hand them back — the reference's own `+0x60`/`+0x64`/`+0x68` (`0x6201d0` saves the three bone
/// rates before overwriting them; `0x6203e0` writes them back when the node expires with the aura).
/// ///
/// Keyed by animation node because that is what a speed belongs to here: the reference saves per /// It carries **nothing**, and that is the fix for the first shipped version's bug. That one stored
/// *bone* because a rate is a bone-clock property there, and ours is per armed clip. /// the speed of every clip it took over and wrote the values back on release — the reference's own
/// `+0x60`/`+0x64`/`+0x68` save/restore, transliterated one level too low. It cannot work here,
/// because in the reference a rate belongs to a *bone* (one value, saved once, and clips armed later
/// never carry one) while ours would have to belong to a *clip*, and clips get armed under the freeze
/// carrying values copied from other, already-frozen clips. `transplant_up` is exactly that: moving a
/// one-shot to the torso overlay copies the source clip's speed, which under the freeze was `0`, so
/// the overlay was saved at `0`, restored to `0`, and never advanced again — a cast pose welded to the
/// upper body for the rest of the session, surviving every later cast (it never *finished*, so the
/// overlay never released). Pausing owns no value and so cannot poison one.
#[derive(Component, Default, Debug)] #[derive(Component, Default, Debug)]
pub(crate) struct AnimRateFreeze { pub(crate) struct AnimRateFreeze;
saved: Vec<(AnimationNodeIndex, f32)>,
}
/// Hold every unit whose head proc-11 node says so at that node's rate — its own rig and its mount's /// Hold the clocks of every unit whose head proc-11 node says so — its own rig and its mount's
/// (`[unit+0xd8]` and `[unit+0xdc]`, the two models `0x6201d0` writes) — and hand the speeds back /// (`[unit+0xd8]` and `[unit+0xdc]`, the two models `0x6201d0` writes) — and let them go when the aura
/// when the aura goes. /// does.
///
/// **A rate of 0 is expressed as a PAUSE, not as `set_speed(0.0)`.** They are the same picture on
/// screen (Bevy evaluates a paused animation at its frozen seek, exactly as `0x712910`'s `bias` rebase
/// holds the reference's current frame) but not the same object: a pause is a bit the clock reads,
/// where a speed is a *value* other code copies. `transplant_up` copies a clip's speed when it moves a
/// one-shot to the torso overlay, and that is how the first version welded a cast pose to the upper
/// body permanently — see [`AnimRateFreeze`] and decision 0891. Nothing here reads or writes a speed, so nothing can
/// inherit the freeze and outlive it.
/// ///
/// **Reasserted every frame, and that is the faithful shape, not a belt-and-braces clamp.** The /// **Reasserted every frame, and that is the faithful shape, not a belt-and-braces clamp.** The
/// reference's rate lives on the bone (`+0xb0`, the factor `timebase.md` §2's clock multiplies its /// reference's rate lives on the bone (`+0xb0`, the factor `timebase.md` §2's clock multiplies its
/// window by) and **arming an animation does not touch it**: op4 `0x7121a0`'s success leg writes the /// window by) and **arming an animation does not touch it**: op4 `0x7121a0`'s success leg writes the
/// track index and the cursors and leaves `+0xb0` alone (only its disarm leg and `SetBoneAnimSpeed /// track index and the cursors and leaves `+0xb0` alone (only its disarm leg and `SetBoneAnimSpeed
/// 0x712910` write it). So "rate 0 until the aura is reaped" is literally the reference's state /// 0x712910` write it). So "rate 0 until the aura is reaped" is literally the reference's state
/// across every re-arm in between, and re-writing it each frame is how a per-clip speed says the /// across every re-arm in between, and re-pausing each frame is how a per-clip flag says the same
/// same thing. /// thing — including for clips armed *under* the freeze, which the reference freezes by construction.
/// ///
/// It runs **after the driver**, so this frame's arms are already in — which is the whole Ice Block /// It runs **after the driver**, so this frame's arms are already in — which is the whole Ice Block
/// observable: the cast one-shot is armed and then frozen at the frame it was on, so the caster never /// observable: the cast one-shot is armed and then frozen at the frame it was on, so the caster never
/// gets to raise their hand (`0x712910` re-bases `bias` to preserve the current frame, so rate 0 /// gets to raise their hand.
/// holds the pose exactly where it stood). ///
/// **A non-zero rate does nothing** (and only kit 1744's `8947848.0` is one — a packed grey that
/// landed in the rate column, on Stoned / Petrification / Thadius Spawn). Our clock is f32 seconds
/// where the reference's is integer milliseconds with a modulo, so a rate that large is noise there
/// and different noise here; there is no honest transliteration, and inventing one would be worse than
/// saying so. Recorded, not special-cased.
/// ///
/// What it deliberately does **not** touch: attached effect models (the ice block's own /// What it deliberately does **not** touch: attached effect models (the ice block's own
/// `icebarrier_state.mdx` keeps shimmering — the reference writes the unit's two models only) and /// `icebarrier_state.mdx` keeps shimmering — the reference writes the unit's two models only) and
@@ -356,65 +374,59 @@ pub(crate) fn apply_aura_anim_rate(
&AuraNodes, &AuraNodes,
Option<&crate::entities::mount::MountChild>, Option<&crate::entities::mount::MountChild>,
)>, )>,
mut rigs: Query<(&mut AnimationPlayer, Option<&mut AnimRateFreeze>)>, mut rigs: Query<(&mut AnimationPlayer, Has<AnimRateFreeze>)>,
mut commands: Commands, mut commands: Commands,
) { ) {
for (entity, nodes, mount) in &units { for (entity, nodes, mount) in &units {
let rate = nodes.head_anim_rate(); let frozen = nodes.head_anim_rate() == Some(0.0);
for rig in [Some(entity), mount.map(|m| m.0)].into_iter().flatten() { for rig in [Some(entity), mount.map(|m| m.0)].into_iter().flatten() {
let Ok((mut player, freeze)) = rigs.get_mut(rig) else { let Ok((mut player, held)) = rigs.get_mut(rig) else {
continue; continue;
}; };
match (rate, freeze) { match (frozen, held) {
(Some(rate), Some(mut freeze)) => hold(&mut player, &mut freeze, rate), (true, _) => {
(Some(rate), None) => { for (_, anim) in player.playing_animations_mut() {
let mut fresh = AnimRateFreeze::default(); anim.pause();
hold(&mut player, &mut fresh, rate);
trace_clock_edge("freeze", rig, rate, &fresh);
commands.entity(rig).try_insert(fresh);
}
(None, Some(freeze)) => {
for (node, speed) in &freeze.saved {
if let Some(anim) = player.animation_mut(*node) {
anim.set_speed(*speed);
}
} }
trace_clock_edge("thaw", rig, 1.0, &freeze); if !held {
trace_clock_edge("freeze", rig, &player);
commands.entity(rig).try_insert(AnimRateFreeze);
}
}
(false, true) => {
// Resume everything: the set we paused is "whatever was playing", and it grew
// every frame the freeze held. Nothing else on a unit rig pauses an animation
// (the portrait booth does, on its own rigs), so there is no third party's pause
// to trample.
for (_, anim) in player.playing_animations_mut() {
anim.resume();
}
trace_clock_edge("thaw", rig, &player);
commands.entity(rig).try_remove::<AnimRateFreeze>(); commands.entity(rig).try_remove::<AnimRateFreeze>();
} }
(None, None) => {} (false, false) => {}
} }
} }
} }
} }
/// Take every playing animation's clock to `rate`, remembering the speed each one was running at the
/// first time we touch it — a clip armed *during* the freeze is saved on its own first frame, so the
/// release hands back what the driver meant rather than a blanket 1.0.
fn hold(player: &mut AnimationPlayer, freeze: &mut AnimRateFreeze, rate: f32) {
for (node, anim) in player.playing_animations_mut() {
if !freeze.saved.iter().any(|(n, _)| n == node) {
freeze.saved.push((*node, anim.speed()));
}
anim.set_speed(rate);
}
}
/// The freeze's own instrument (`WOW_MOVE_TRACE`, tag `aur`): one line per rig at each edge, naming /// The freeze's own instrument (`WOW_MOVE_TRACE`, tag `aur`): one line per rig at each edge, naming
/// the clips it took over and the speeds it is holding — the difference between "the clocks stopped" /// the clips it took over and the speed each is *left* running at — the difference between "the clocks
/// and "nothing was playing anyway", which no count of *absent* anim events can tell apart. /// stopped" and "nothing was playing anyway", which no count of *absent* anim events can tell apart.
fn trace_clock_edge(what: &str, rig: Entity, rate: f32, freeze: &AnimRateFreeze) { ///
/// It prints the speeds precisely because the first version's bug was invisible without them: a `@0`
/// on the thaw line is a clip that will never advance again.
fn trace_clock_edge(what: &str, rig: Entity, player: &AnimationPlayer) {
if !crate::dbg_trace::enabled_for("aur") { if !crate::dbg_trace::enabled_for("aur") {
return; return;
} }
let clips: Vec<String> = freeze let clips: Vec<String> = player
.saved .playing_animations()
.iter() .map(|(n, a)| format!("{}@{}", n.index(), a.speed()))
.map(|(n, s)| format!("{}@{s}", n.index()))
.collect(); .collect();
crate::dbg_trace::line( crate::dbg_trace::line(
"aur", "aur",
&format!("{what} rig={rig} rate={rate} clips=[{}]", clips.join(" ")), &format!("{what} rig={rig} clips=[{}]", clips.join(" ")),
); );
} }
+82 -44
View File
@@ -773,39 +773,41 @@ fn app_with_freeze() -> App {
/// A rig mid-animation: two clips playing at the speeds the driver picked for them (a gait scaled to /// A rig mid-animation: two clips playing at the speeds the driver picked for them (a gait scaled to
/// the mover's speed, and a cast one-shot at 1.0 — the pair Ice Block catches). /// the mover's speed, and a cast one-shot at 1.0 — the pair Ice Block catches).
fn rig_playing(speeds: [(u32, f32); 2]) -> AnimationPlayer { fn rig_playing(speeds: &[(u32, f32)]) -> AnimationPlayer {
let mut player = AnimationPlayer::default(); let mut player = AnimationPlayer::default();
for (node, speed) in speeds { for (node, speed) in speeds {
player player
.play(AnimationNodeIndex::new(node as usize)) .play(AnimationNodeIndex::new(*node as usize))
.set_speed(speed); .set_speed(*speed);
} }
player player
} }
fn speed_of(app: &App, rig: Entity, node: u32) -> f32 { /// `(paused, speed)` of one armed clip — the two things the freeze is judged on: the clock is held,
app.world() /// and the *value* other code copies is untouched.
fn clip(app: &App, rig: Entity, node: u32) -> (bool, f32) {
let a = app
.world()
.entity(rig) .entity(rig)
.get::<AnimationPlayer>() .get::<AnimationPlayer>()
.expect("the rig kept its player") .expect("the rig kept its player")
.animation(AnimationNodeIndex::new(node as usize)) .animation(AnimationNodeIndex::new(node as usize))
.expect("the clip is still armed") .expect("the clip is still armed");
.speed() (a.is_paused(), a.speed())
} }
/// **The director's retest, at the mechanism.** Ice Block's state kit carries proc 11 at rate 0, so /// **The director's retest, at the mechanism.** Ice Block's state kit carries proc 11 at rate 0, so
/// the unit's clocks stop dead: the run cycle it was mid-stride in and the cast one-shot that had /// the unit's clocks stop dead: the run cycle it was mid-stride in and the cast one-shot that had
/// just been armed both hold the frame they were on — which is why the caster never gets to raise /// just been armed both hold the frame they were on — which is why the caster never gets to raise
/// their hand. Dropping the aura hands each clip back the speed the driver had given it, not a /// their hand. Dropping the aura lets both go again, at the speeds the driver gave them.
/// blanket 1.0.
#[test] #[test]
fn ice_block_holds_the_clocks_and_the_drop_hands_them_back() { fn ice_block_holds_the_clocks_and_the_drop_lets_them_go() {
let mut app = app_with_freeze(); let mut app = app_with_freeze();
let unit = app let unit = app
.world_mut() .world_mut()
.spawn(( .spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])), crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])),
rig_playing([(4, 1.35), (54, 1.0)]), rig_playing(&[(4, 1.35), (54, 1.0)]),
)) ))
.id(); .id();
app.update(); app.update();
@@ -814,15 +816,16 @@ fn ice_block_holds_the_clocks_and_the_drop_hands_them_back() {
assert_eq!(n.head_anim_rate(), Some(0.0), "kit 3709's proc 11 is 0"); assert_eq!(n.head_anim_rate(), Some(0.0), "kit 3709's proc 11 is 0");
// 9074175 = 0x8A75FF — the ice-blue the same kit tints the body with, beside the freeze. // 9074175 = 0x8A75FF — the ice-blue the same kit tints the body with, beside the freeze.
assert_eq!(n.head_tint(), Some([0x8A, 0x75, 0xFF]), "kit 3709's proc 1"); assert_eq!(n.head_tint(), Some([0x8A, 0x75, 0xFF]), "kit 3709's proc 1");
assert_eq!(speed_of(&app, unit, 4), 0.0, "the gait stops mid-stride"); assert!(clip(&app, unit, 4).0, "the gait stops mid-stride");
assert_eq!(speed_of(&app, unit, 54), 0.0, "so does the cast one-shot"); assert!(clip(&app, unit, 54).0, "so does the cast one-shot");
// The SPEEDS are untouched, which is the whole point: nothing that copies a clip's rate
// (`transplant_up` does, to move a one-shot onto the torso overlay) can inherit the freeze.
assert_eq!(clip(&app, unit, 4).1, 1.35);
assert_eq!(clip(&app, unit, 54).1, 1.0);
// A second frame changes nothing — the hold is idempotent, and it must not re-save the zero it app.update(); // idempotent
// wrote itself (that is what would make the release hand back 0.0 forever).
app.update();
assert_eq!(speed_of(&app, unit, 4), 0.0);
// The aura leaves the slots: the reference writes the saved rates back (`0x6203e0`). // The aura leaves the slots.
app.world_mut() app.world_mut()
.entity_mut(unit) .entity_mut(unit)
.insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[( .insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[(
@@ -836,41 +839,53 @@ fn ice_block_holds_the_clocks_and_the_drop_hands_them_back() {
.unwrap() .unwrap()
.rate .rate
.is_empty()); .is_empty());
assert_eq!( assert!(!clip(&app, unit, 4).0, "running again");
speed_of(&app, unit, 4), assert!(!clip(&app, unit, 54).0);
1.35, assert_eq!(clip(&app, unit, 4).1, 1.35, "at its own rate");
"the gait's own rate, restored"
);
assert_eq!(speed_of(&app, unit, 54), 1.0);
assert!( assert!(
app.world().entity(unit).get::<AnimRateFreeze>().is_none(), app.world().entity(unit).get::<AnimRateFreeze>().is_none(),
"the release drops the save with it" "the release drops the marker with it"
); );
} }
/// A clip armed *during* the freeze is saved on the frame we first touch it, so the release hands /// **The bug the first shipped version had, pinned so it cannot come back.** A one-shot moved to the
/// back what the driver meant for it rather than the frozen 0. /// torso overlay under the freeze (`transplant_up` copies the source clip's speed and seek) used to
/// be saved at the frozen `0`, restored to `0`, and left welded to the upper body for the rest of the
/// session — surviving every later cast, because a clip that never advances never *finishes* and so
/// never releases the overlay. Pausing carries no value to copy: the transplant inherits the driver's
/// real speed, and the thaw sets it running.
#[test] #[test]
fn a_clip_armed_under_the_freeze_is_saved_before_it_is_held() { fn a_clip_armed_under_the_freeze_comes_back_at_its_own_speed() {
let mut app = app_with_freeze(); let mut app = app_with_freeze();
let unit = app let unit = app
.world_mut() .world_mut()
.spawn(( .spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])), crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])),
rig_playing([(4, 1.35), (54, 1.0)]), rig_playing(&[(4, 1.35), (54, 1.0)]),
)) ))
.id(); .id();
app.update(); app.update();
// The driver arms Stand at 1.0 a frame later (the root wiped the direction bits). // `transplant_up`'s own shape: read the frozen source clip, arm the torso twin from it.
app.world_mut() let speed = clip(&app, unit, 54).1;
.entity_mut(unit) assert_eq!(speed, 1.0, "the source's speed is not the freeze");
.get_mut::<AnimationPlayer>() let seek = app
.world()
.entity(unit)
.get::<AnimationPlayer>()
.unwrap() .unwrap()
.play(AnimationNodeIndex::new(0)) .animation(AnimationNodeIndex::new(54))
.set_speed(1.0); .unwrap()
.seek_time();
{
let mut ent = app.world_mut().entity_mut(unit);
let mut player = ent.get_mut::<AnimationPlayer>().unwrap();
let upper = player.play(AnimationNodeIndex::new(156));
upper.seek_to(seek);
upper.set_speed(speed);
}
app.update(); app.update();
assert_eq!(speed_of(&app, unit, 0), 0.0, "held the frame it arrived on"); assert!(clip(&app, unit, 156).0, "held on arrival");
app.world_mut() app.world_mut()
.entity_mut(unit) .entity_mut(unit)
@@ -878,7 +893,12 @@ fn a_clip_armed_under_the_freeze_is_saved_before_it_is_held() {
95, 0, 95, 0,
)]))); )])));
app.update(); app.update();
assert_eq!(speed_of(&app, unit, 0), 1.0); assert!(!clip(&app, unit, 156).0);
assert_eq!(
clip(&app, unit, 156).1,
1.0,
"the overlay runs out and releases instead of welding a cast pose on"
);
} }
/// The mount comes with the rider: `0x6201d0` writes `[unit+0xdc]` before it writes `[unit+0xd8]`, /// The mount comes with the rider: `0x6201d0` writes `[unit+0xdc]` before it writes `[unit+0xd8]`,
@@ -888,20 +908,20 @@ fn the_freeze_reaches_the_mount_body() {
let mut app = app_with_freeze(); let mut app = app_with_freeze();
let mount = app let mount = app
.world_mut() .world_mut()
.spawn(rig_playing([(5, 2.0), (91, 1.0)])) .spawn(rig_playing(&[(5, 2.0), (91, 1.0)]))
.id(); .id();
let unit = app let unit = app
.world_mut() .world_mut()
.spawn(( .spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])), crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])),
rig_playing([(91, 1.0), (54, 1.0)]), rig_playing(&[(91, 1.0), (54, 1.0)]),
crate::entities::mount::MountChild(mount), crate::entities::mount::MountChild(mount),
)) ))
.id(); .id();
app.update(); app.update();
assert_eq!(speed_of(&app, unit, 91), 0.0, "the rider"); assert!(clip(&app, unit, 91).0, "the rider");
assert_eq!(speed_of(&app, mount, 5), 0.0, "and the mount under them"); assert!(clip(&app, mount, 5).0, "and the mount under them");
app.world_mut() app.world_mut()
.entity_mut(unit) .entity_mut(unit)
@@ -909,7 +929,8 @@ fn the_freeze_reaches_the_mount_body() {
95, 0, 95, 0,
)]))); )])));
app.update(); app.update();
assert_eq!(speed_of(&app, mount, 5), 2.0); assert!(!clip(&app, mount, 5).0);
assert_eq!(clip(&app, mount, 5).1, 2.0);
} }
/// An aura with no proc 11 — Stealth — leaves the clocks entirely alone. The freeze is the kit's, /// An aura with no proc 11 — Stealth — leaves the clocks entirely alone. The freeze is the kit's,
@@ -921,10 +942,27 @@ fn an_aura_without_the_proc_never_touches_a_clock() {
.world_mut() .world_mut()
.spawn(( .spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, STEALTH), (95, 0x0E)])), crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, STEALTH), (95, 0x0E)])),
rig_playing([(4, 1.35), (54, 1.0)]), rig_playing(&[(4, 1.35), (54, 1.0)]),
)) ))
.id(); .id();
app.update(); app.update();
assert_eq!(speed_of(&app, unit, 4), 1.35); assert!(!clip(&app, unit, 4).0);
assert!(app.world().entity(unit).get::<AnimRateFreeze>().is_none()); assert!(app.world().entity(unit).get::<AnimRateFreeze>().is_none());
} }
/// Kit 1744's `8947848.0` is a rate our f32-seconds clock cannot express (the reference's is integer
/// ms with a modulo, where it is noise rather than a freeze). It installs a node — the data is read,
/// not dropped — and the apply leaves the clocks alone rather than inventing a look for it.
#[test]
fn a_non_zero_rate_installs_a_node_and_holds_nothing() {
let mut n = AuraNodes::new(1.0);
install(&mut n, 17624, &[AuraNode::AnimRate(8_947_848.0)], 0.0);
assert_eq!(n.head_anim_rate(), Some(8_947_848.0));
let mut app = app_with_freeze();
let unit = app.world_mut().spawn(rig_playing(&[(4, 1.35)])).id();
app.world_mut().entity_mut(unit).insert(n);
app.update();
assert!(!clip(&app, unit, 4).0);
assert_eq!(clip(&app, unit, 4).1, 1.35);
}
+9
View File
@@ -521,6 +521,14 @@ pub(crate) struct AnimDriver {
/// The gait id currently targeted while in [`Mode::Gait`] (so we cross-fade only on a change). `None` /// The gait id currently targeted while in [`Mode::Gait`] (so we cross-fade only on a change). `None`
/// forces a fresh selection (first eval, or after a Special state exits). /// forces a fresh selection (first eval, or after a Special state exits).
gait: Option<u16>, gait: Option<u16>,
/// The movement flags the **base slot** was last armed for — the reference has no per-one-shot
/// latch, so "the movement state changed" means *since the base last took a request*, never
/// "since this one-shot started" (decision 0894). The distinction is invisible until a one-shot
/// and a movement change land on the SAME frame: Ice Block's root wipes the direction bits in
/// the very frame the cast one-shot arrives, so an arm-time comparison sees no edge at all and
/// the cast keeps bone 0 — where the reference's next base request overwrites it and leaves the
/// character neutral.
gait_flags: u32,
/// The unit's **client-side sheath state** — the mirror of the client's committed CUR cache /// The unit's **client-side sheath state** — the mirror of the client's committed CUR cache
/// (`[unit+0xd40]`, decision 0080): what the weapon placement renders (absent a /// (`[unit+0xd40]`, decision 0080): what the weapon placement renders (absent a
/// [`VisualSheath`] ceremony pin) and what the setter/reconcile test against. Seeded from /// [`VisualSheath`] ceremony pin) and what the setter/reconcile test against. Seeded from
@@ -661,6 +669,7 @@ impl Default for AnimDriver {
Self { Self {
mode: Mode::Gait, mode: Mode::Gait,
gait: None, gait: None,
gait_flags: 0,
sheath_cur: None, sheath_cur: None,
sheath_byte: None, sheath_byte: None,
sheath_swap: None, sheath_swap: None,
+40 -17
View File
@@ -845,11 +845,7 @@ pub(super) fn drive_animations(
if let Some((c, repeat)) = picked { if let Some((c, repeat)) = picked {
play_clip(&mut tr, &mut player, c, repeat, 1.0); play_clip(&mut tr, &mut player, c, repeat, 1.0);
} }
drv.mode = Mode::Swing { drv.mode = Mode::Swing { id, under: special };
id,
flags: mv.flags,
under: special,
};
drv.gait = None; drv.gait = None;
base_played = true; base_played = true;
played_oneshot = Some(id); played_oneshot = Some(id);
@@ -1044,7 +1040,7 @@ pub(super) fn drive_animations(
drv.gait = None; // recompute a fresh gait next frame drv.gait = None; // recompute a fresh gait next frame
} }
} }
Mode::Swing { id, flags, under } => { Mode::Swing { id, under } => {
if special != under { if special != under {
// The state this one-shot replaced CHANGED — the client's next event play // The state this one-shot replaced CHANGED — the client's next event play
// supersedes it: a fresh jump/pose entry (`None → Some`), the FALLINGFAR // supersedes it: a fresh jump/pose entry (`None → Some`), the FALLINGFAR
@@ -1125,25 +1121,47 @@ pub(super) fn drive_animations(
); );
drv.mode = Mode::Looping(sp); drv.mode = Mode::Looping(sp);
} }
} else if oneshot_finished(&player, anims, id, catalog) || mv.flags != flags { } else if oneshot_finished(&player, anims, id, catalog)
// Finished — or a movement-flag change: the client's locomotion re-arm lands || mv.flags != drv.gait_flags
// on the change and blindly overwrites bone 0, one-shot or not (the same {
// re-arm decision 0280 named for the un-finishable looping kit clip, and // Finished — or a movement-flag change: the client's base re-arm lands on the
// change and blindly overwrites bone 0, one-shot or not (the same re-arm
// decision 0280 named for the un-finishable looping kit clip, and
// Mode::Land's flag-change re-pick). Holding the clip out instead slides the // Mode::Land's flag-change re-pick). Holding the clip out instead slides the
// post-shot runner over the ground on straight legs (director-observed vs // post-shot runner over the ground on straight legs (director-observed vs
// ref). An EDGE, not a level — the split-boneless masked fallback enters // ref). An EDGE, not a level — the split-boneless masked fallback enters
// here already moving, and steady flags must let that clip play out. // here already moving, and steady flags must let that clip play out.
if mv.flags != flags { //
// Against `drv.gait_flags` — what the **base** was last armed for — not this
// one-shot's own arm-time `flags` (decision 0894). The reference keeps no
// per-one-shot latch: a movement-state change requests the base and bone 0
// takes it, whenever the one-shot happened to start. Ice Block is the case that
// separates them — its root wipes the direction bits in the SAME frame the cast
// one-shot arrives, so the arm-time compare sees no edge ever and the cast held
// bone 0 for the whole block; against the base's flags the edge is still there,
// Stand overwrites the cast, and the character is neutral when the freeze lands.
if mv.flags != drv.gait_flags {
// The locomotion re-arm is a normal PlayAnimation — the deferred-combat // The locomotion re-arm is a normal PlayAnimation — the deferred-combat
// cache clears with it (decision 0406; a finished clip instead had its // cache clears with it (decision 0406; a finished clip instead had its
// cache consumed by the injection above, before this machine ran). // cache consumed by the injection above, before this machine ran).
drv.deferred = None; drv.deferred = None;
// …and it is a LOCOMOTION request, so a still-playing CAST/COMBAT clip // …and **if** the re-arm resolves to a LOCOMOTION id, a still-playing
// transplants up to the key-bone instead of being overwritten (decision // CAST/COMBAT clip transplants up to the key-bone instead of being
// 0878). A *finished* clip has nothing to move: the client's descriptor // overwritten (decision 0878; the client's gate is `0x5fee80` on the
// probe reports a completed slot as id −1 (`0x5fe1f0` reads the completion // *requested* id, `0x5fe912`). A *finished* clip has nothing to move: the
// latch, not the armed record), so the transplant predicates never see it. // client's descriptor probe reports a completed slot as id −1 (`0x5fe1f0`
transplant_up(&mut drv, &mut player, &tr, anims, entity, id); // reads the completion latch, not the armed record), so the transplant
// predicates never see it.
//
// The gate was missing here, and Ice Block is what it costs (decision
// 0894): a stun's root wipes the direction bits, so the flag change re-arms
// to **Stand(0)** — not locomotion — and the reference *overwrites* the
// cast on bone 0, leaving the character fully neutral for the freeze to
// catch. Transplanting unconditionally moved it to the torso instead and
// froze an arm out.
if select::gait_is_locomotion(&mv, walk) {
transplant_up(&mut drv, &mut player, &tr, anims, entity, id);
}
} }
drv.mode = Mode::Gait; drv.mode = Mode::Gait;
drv.gait = None; // recompute a fresh gait next frame drv.gait = None; // recompute a fresh gait next frame
@@ -1377,6 +1395,11 @@ pub(super) fn drive_animations(
} else { } else {
drv.gait = Some(target); // no clip (bind pose) — record the target so we don't churn drv.gait = Some(target); // no clip (bind pose) — record the target so we don't churn
} }
// The base is now arm-consistent with this movement state — every branch above
// leaves `drv.gait == Some(target)`. A one-shot that displaces it later reads
// this, not its own arm-time flags, to know whether the movement state has
// moved on since (decision 0894).
drv.gait_flags = mv.flags;
// Leaving the ranged idle (moving, sheath change, cancel) drops the hold — // Leaving the ranged idle (moving, sheath change, cancel) drops the hold —
// the next shooting session opens with a fresh nock. // the next shooting session opens with a fresh nock.
if ranged_load.is_none() { if ranged_load.is_none() {
@@ -649,7 +649,6 @@ fn same_frame_collision_fast_paths_the_second_combat_clip() {
drv(&app, spin_last).mode, drv(&app, spin_last).mode,
super::super::select::Mode::Swing { super::super::select::Mode::Swing {
id: 16, id: 16,
flags: 0,
under: None, under: None,
}, },
"the first arrival holds the body" "the first arrival holds the body"
@@ -662,7 +661,6 @@ fn same_frame_collision_fast_paths_the_second_combat_clip() {
drv(&app, swing_last).mode, drv(&app, swing_last).mode,
super::super::select::Mode::Swing { super::super::select::Mode::Swing {
id: 57, id: 57,
flags: 0,
under: None, under: None,
}, },
"the spin holds the body through the later swing" "the spin holds the body through the later swing"
@@ -712,7 +710,6 @@ fn deferred_combat_clip_plays_once_the_body_frees() {
drv.mode, drv.mode,
super::super::select::Mode::Swing { super::super::select::Mode::Swing {
id: 16, id: 16,
flags: 0,
under: None, under: None,
}, },
"the parked swing armed" "the parked swing armed"
@@ -779,7 +776,6 @@ fn a_movement_flag_change_cuts_a_full_body_oneshot_immediately() {
mode(&app), mode(&app),
super::super::select::Mode::Swing { super::super::select::Mode::Swing {
id: 16, id: 16,
flags: 0,
under: None, under: None,
}, },
"standing swing holds the base track" "standing swing holds the base track"
@@ -1331,7 +1327,6 @@ fn a_jump_in_place_cast_replaces_the_hang_and_survives_the_arc() {
drv(&app, unit).mode, drv(&app, unit).mode,
super::super::select::Mode::Swing { super::super::select::Mode::Swing {
id: 54, id: 54,
flags: move_flags::FALLING,
under: Some(super::super::select::Special::Jump), under: Some(super::super::select::Special::Jump),
}, },
"the cast replaces the hang full-body — never dropped, never masked" "the cast replaces the hang full-body — never dropped, never masked"
@@ -2114,3 +2109,100 @@ fn a_melee_to_ranged_toggle_stows_both_hands_before_it_reaches_for_the_bow() {
"the ceremony must end with the pin dropped, leaving the committed state (saw {seen:?})" "the ceremony must end with the pin dropped, leaving the committed state (saw {seen:?})"
); );
} }
/// **Ice Block, at the mechanism (decision 0894).** The stun's root wipes the direction bits in the
/// SAME frame the cast one-shot arrives, so the one-shot's own arm-time flags already read ROOT and
/// an arm-time comparison sees no movement edge — ever. The base's flags still hold the run it was
/// armed for, so the edge is there, the re-arm resolves to **Stand(0)** (not locomotion → no
/// transplant), and Stand overwrites the cast on bone 0: the character is fully neutral, with
/// nothing on the torso, by the time the freeze catches it. Before this the cast held bone 0 for the
/// whole block, or rode up to the torso and froze an arm out.
#[test]
fn a_root_landing_with_the_cast_returns_the_body_to_neutral() {
let mut app = app();
let unit = jumper(&mut app);
app.world_mut().entity_mut(unit).insert(MovementState {
flags: move_flags::FORWARD,
speed: 7.0,
..Default::default()
});
app.update();
let gait = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().gait;
let mode = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().mode;
let overlay = |app: &App| {
app.world()
.entity(unit)
.get::<AnimDriver>()
.unwrap()
.overlay
.map(|o| o.id)
};
assert_eq!(gait(&app), Some(5), "running");
// One frame: the root lands (direction bits wiped) AND the cast one-shot arrives.
app.world_mut().entity_mut(unit).insert(MovementState {
flags: move_flags::ROOT,
..Default::default()
});
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id: 54,
seq: 1,
});
app.update();
// The cast takes bone 0 and the base re-arm displaces it inside the same frame — the reference's
// one-slot last-writer-wins, which is exactly what the base's flags (still FORWARD) unlock.
assert_eq!(
mode(&app),
super::super::select::Mode::Gait,
"the base request wins bone 0 back"
);
assert!(
overlay(&app).is_none(),
"no torso transplant: Stand(0) is not a locomotion request"
);
app.update();
assert_eq!(gait(&app), Some(0), "fully neutral standing");
assert!(overlay(&app).is_none(), "and nothing left on the torso");
}
/// The control that keeps 0878 honest: the transplant still fires when the re-arm really *is*
/// locomotion. A cast fired standing, then the player runs — the legs take Run and the cast keeps
/// going on the torso, which is the whole of R-B.
#[test]
fn a_run_starting_under_a_cast_still_transplants_it_up() {
let mut app = app();
let unit = jumper(&mut app);
app.update(); // settle: Stand
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id: 54,
seq: 1,
});
app.update();
let mode = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().mode;
let overlay = |app: &App| {
app.world()
.entity(unit)
.get::<AnimDriver>()
.unwrap()
.overlay
.map(|o| o.id)
};
assert!(matches!(
mode(&app),
super::super::select::Mode::Swing { id: 54, .. }
));
app.world_mut().entity_mut(unit).insert(MovementState {
flags: move_flags::FORWARD,
speed: 7.0,
..Default::default()
});
app.update();
assert_eq!(
overlay(&app),
Some(54),
"a locomotion re-arm moves the cast to the torso instead of cutting it"
);
}
+29 -12
View File
@@ -405,18 +405,16 @@ pub(super) enum Mode {
/// *edge* exits — the FALLINGFAR latch's Fall (played ONCE, at the latch edge /// *edge* exits — the FALLINGFAR latch's Fall (played ONCE, at the latch edge
/// `0x61a820` — 0864's per-tick re-assert was §5-refuted, decision 0868; a clip armed /// `0x61a820` — 0864's per-tick re-assert was §5-refuted, decision 0868; a clip armed
/// after the latch holds like any other) and the `0x602c60` land pick at touchdown. Grounded /// after the latch holds like any other) and the `0x602c60` land pick at touchdown. Grounded
/// (`under == None`) it returns to Gait when it finishes; a new one restarts it — and, like /// (`under == None`) it returns to Gait when it finishes; a new one restarts it — and any
/// [`Mode::Land`], any movement-flag change from `flags` (captured at play time) re-picks /// movement-flag change since the **base** was last armed
/// immediately: the client's locomotion re-arm overwrites bone 0 blindly on the change (the /// ([`AnimDriver::gait_flags`](crate::creature_anim::AnimDriver)) re-picks immediately: the
/// same re-arm decision 0280 named), so a shot fired standing yields to the run the instant /// client's base re-arm overwrites bone 0 blindly on the change (the same re-arm decision 0280
/// the player moves instead of sliding the clip out. The *masked* route never enters here — /// named), so a shot fired standing yields to the run the instant the player moves instead of
/// it plays as an overlay beside `Mode`, leaving the base machine (this enum) running /// sliding the clip out. It carried its own arm-time flags until 0894, which made the edge
/// underneath. /// invisible whenever the one-shot and the movement change landed on the same frame. The
Swing { /// *masked* route never enters here — it plays as an overlay beside `Mode`, leaving the base
id: u16, /// machine (this enum) running underneath.
flags: u32, Swing { id: u16, under: Option<Special> },
under: Option<Special>,
},
} }
/// The gait an idle/moving unit should play in [`Mode::Gait`], most-specific id first with fallbacks so a /// The gait an idle/moving unit should play in [`Mode::Gait`], most-specific id first with fallbacks so a
@@ -975,6 +973,25 @@ pub(super) fn is_locomotion(id: u16) -> bool {
RATE_SCALED.contains(&id) RATE_SCALED.contains(&id)
} }
/// Whether the gait this movement state resolves to is a **locomotion** id — i.e. whether a
/// movement-flag re-arm is the transplant's `0x5fee80` request or an ordinary bone-0 overwrite.
///
/// The re-arm at a flag change has no id in hand yet (the gait is recomputed the following frame),
/// so this asks [`gait_candidates`] what it *will* pick. `ready`/`ranged_load` are passed `None`
/// deliberately and it changes no answer: both are **standing** idles that locomotion outranks in
/// the cascade, so a moving unit picks its locomotion id whether or not they are set, and a still
/// one picks something that is not locomotion either way.
///
/// This is the gate Ice Block turns on. A stun's root wipes the direction bits, the flag change
/// re-arms, and the pick is **Stand(0)** — *not* locomotion — so the cast one-shot on bone 0 is
/// **overwritten** rather than transplanted onto the torso: the character goes fully neutral before
/// the freeze catches it (decision 0894, the director's reading of the reference client).
pub(super) fn gait_is_locomotion(state: &MovementState, walk_speed: f32) -> bool {
gait_candidates(state, walk_speed, None, None)
.first()
.is_some_and(|id| is_locomotion(*id))
}
/// The playback rate for a clip given the unit's live speed (wow-5875-re `0x5fe2f0`): `speed / moveSpeed` /// The playback rate for a clip given the unit's live speed (wow-5875-re `0x5fe2f0`): `speed / moveSpeed`
/// for a rate-scaled locomotion clip, else 1×. (The client also divides by the model's scale magnitude, /// for a rate-scaled locomotion clip, else 1×. (The client also divides by the model's scale magnitude,
/// 1.0 for an unscaled character.) Guarded on `moveSpeed > 0`. /// 1.0 for an unscaled character.) Guarded on `moveSpeed > 0`.
+32
View File
@@ -249,6 +249,9 @@ mod tests {
use super::*; use super::*;
use super::test_template as info; use super::test_template as info;
use benilla_protocol::messages::{
ITEM_DYNFLAG_UNLOCKED, ITEM_DYNFLAG_WRAPPED, ITEM_FLAG_LOOTABLE, ITEM_FLAG_WRAPPER,
};
use crossbeam_channel::TryRecvError; use crossbeam_channel::TryRecvError;
fn commands() -> (NetCommands, crossbeam_channel::Receiver<ClientCommand>) { fn commands() -> (NetCommands, crossbeam_channel::Receiver<ClientCommand>) {
@@ -291,6 +294,35 @@ mod tests {
assert!(items.take_fresh().is_empty(), "a drain empties the queue"); assert!(items.take_fresh().is_empty(), "a drain empties the queue");
} }
/// The one predicate both halves of the right-click-to-open affordance ask
/// (`ItemInfo::openable`): the tooltip's green line and the `CMSG_OPEN_ITEM` fork. Its whole
/// subtlety is the two sub-gates — a `LockID` template stays shut until the INSTANCE carries
/// UNLOCKED, and a wrapper template needs the instance's WRAPPED bit — so a template-only
/// view (instance flags 0) can never be openable.
#[test]
fn openable_is_the_lootable_bit_behind_its_lock_and_gift_sub_gates() {
let plain = |flags: u32, lock_id: u32| {
let mut t = info("Small Barnacled Clam");
t.flags = flags;
t.lock_id = lock_id;
t
};
// The clam: LOOTABLE, no lock — openable outright, instance flags irrelevant.
assert!(plain(ITEM_FLAG_LOOTABLE, 0).openable(0));
// An ordinary item is never openable, however its instance is flagged.
assert!(!plain(0, 0).openable(ITEM_DYNFLAG_UNLOCKED | ITEM_DYNFLAG_WRAPPED));
// A junkbox: LOOTABLE but locked — shut until the instance says UNLOCKED.
assert!(!plain(ITEM_FLAG_LOOTABLE, 7).openable(0));
assert!(plain(ITEM_FLAG_LOOTABLE, 7).openable(ITEM_DYNFLAG_UNLOCKED));
// Gift wrap: the WRAPPER template opens only while the instance is still WRAPPED.
assert!(!plain(ITEM_FLAG_WRAPPER, 0).openable(0));
assert!(plain(ITEM_FLAG_WRAPPER, 0).openable(ITEM_DYNFLAG_WRAPPED));
// The wrapped arm ignores the lock gate entirely — it is the sibling `||`, not a nested
// case: a wrapped gift of a locked box opens as the gift.
assert!(plain(ITEM_FLAG_WRAPPER | ITEM_FLAG_LOOTABLE, 7).openable(ITEM_DYNFLAG_WRAPPED));
}
#[test] #[test]
fn negative_template_answer_is_cached() { fn negative_template_answer_is_cached() {
let (cmds, rx) = commands(); let (cmds, rx) = commands();
+6
View File
@@ -600,6 +600,12 @@ pub(crate) enum ClientCommand {
spell_index: u8, spell_index: u8,
go_target: Option<u64>, go_target: Option<u64>,
}, },
/// Open a bag item (`CMSG_OPEN_ITEM`, same bag addressing) — the drain's fork for an
/// *openable* click (`ItemInfo::openable`): a clam, an unlocked lockbox, a wrapped gift. The
/// server answers `SMSG_LOOT_RESPONSE` on the **item's own guid**, so the ordinary loot feed
/// opens a window over a thing in the bag; a wrapped gift instead swaps entry in place and
/// sends no window. Refusals arrive as `InventoryFailure` (still locked, dead, flying).
OpenItem { bag_index: u8, slot: u8 },
/// Equip a bag item (`CMSG_AUTOEQUIP_ITEM`, same bag addressing) — the drain's fork for an /// Equip a bag item (`CMSG_AUTOEQUIP_ITEM`, same bag addressing) — the drain's fork for an
/// *equippable* click, mirroring the real client's equip-vs-use decision. Refusals come back /// *equippable* click, mirroring the real client's equip-vs-use decision. Refusals come back
/// as `InventoryFailure` events onto the UI error line. /// as `InventoryFailure` events onto the UI error line.
+1
View File
@@ -663,6 +663,7 @@ fn writer_loop(
spell_index, spell_index,
go_target, go_target,
} => w.use_item(bag_index, slot, spell_index, go_target), } => w.use_item(bag_index, slot, spell_index, go_target),
ClientCommand::OpenItem { bag_index, slot } => w.open_item(bag_index, slot),
ClientCommand::AutoEquipItem { bag_index, slot } => { ClientCommand::AutoEquipItem { bag_index, slot } => {
w.auto_equip_item(bag_index, slot) w.auto_equip_item(bag_index, slot)
} }
+23 -1
View File
@@ -31,6 +31,8 @@ use crate::ui_script::PointerOverUi;
mod arc; mod arc;
mod camera; mod camera;
mod follow;
mod gait; mod gait;
// The land-here affordance (free-fly's other half). `pub(crate)` for its `LandHere` message, which // The land-here affordance (free-fly's other half). `pub(crate)` for its `LandHere` message, which
// the debug panel's button writes. // the debug panel's button writes.
@@ -54,11 +56,14 @@ mod wire_in;
// render-alpha field, so the unit-lane material-alpha compose (`entities::apply_unit_mat_alpha`) // render-alpha field, so the unit-lane material-alpha compose (`entities::apply_unit_mat_alpha`)
// orders itself before it and lets that documented override stand. // orders itself before it and lets that documented override stand.
pub(crate) use camera::apply_self_model_fade; pub(crate) use camera::apply_self_model_fade;
// `/follow` (decision 0890): chat asks with the message, `crate::target` resolves the subject into
// the state, and this module owns the motion.
use camera::{ use camera::{
apply_zoom_scroll, run_look_session, seat_camera, CameraProbe, FlyCam, LookButton, apply_zoom_scroll, run_look_session, seat_camera, CameraProbe, FlyCam, LookButton,
CAM_COLLISION_RADIUS, CAM_DIST_DEFAULT, CAM_PIVOT_FALLBACK, CAM_COLLISION_RADIUS, CAM_DIST_DEFAULT, CAM_PIVOT_FALLBACK,
}; };
pub(crate) use camera::{head_height, CameraControl, CameraPivot, WorldCamera, CAM_NEAR}; pub(crate) use camera::{head_height, CameraControl, CameraPivot, WorldCamera, CAM_NEAR};
pub(crate) use follow::{FollowRequest, FollowState};
// The shared avatar state + movement constants live in [`state`]; the private re-imports below are // The shared avatar state + movement constants live in [`state`]; the private re-imports below are
// what lets this module and the concern modules beside it keep naming them `super::X` unchanged. // what lets this module and the concern modules beside it keep naming them `super::X` unchanged.
use state::{ use state::{
@@ -125,6 +130,7 @@ impl Plugin for PlayerPlugin {
if let Some(cam) = probe_cam::from_env() { if let Some(cam) = probe_cam::from_env() {
app.insert_resource(cam); app.insert_resource(cam);
} }
follow::plugin(app);
app.add_systems(Startup, setup::setup_player.after(AssetSet::Open)) app.add_systems(Startup, setup::setup_player.after(AssetSet::Open))
// The world camera renders only when the world can be seen (decision 0540): in world, // The world camera renders only when the world can be seen (decision 0540): in world,
// or under the opaque loading screen (whose covered render is what compiles the // or under the opaque loading screen (whose covered render is what compiles the
@@ -204,6 +210,17 @@ impl Plugin for PlayerPlugin {
.in_set(WorldStage::Input) .in_set(WorldStage::Input)
.before(control), .before(control),
) )
// `/follow` (decision 0890): steer the facing and decide this tick's synthesized forward
// input immediately BEFORE the controller, which folds the flag into its forward axis.
// The player's own turn input therefore runs after us and wins, which is exactly what
// makes the turn-away cancel reachable.
.add_systems(
Update,
follow::steer_follow
.in_set(WorldStage::Input)
.before(control)
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
// The self-avatar zoom-in fade rides the same `MeshTag`/material channel as the interior // The self-avatar zoom-in fade rides the same `MeshTag`/material channel as the interior
// classifier + the appear/despawn fades, so it must run *after* both to win the frame while // classifier + the appear/despawn fades, so it must run *after* both to win the frame while
// fading (and yield to them otherwise). It also writes `Visibility` (the first-person // fading (and yield to them otherwise). It also writes `Visibility` (the first-person
@@ -625,8 +642,13 @@ fn control(
// and the bit survives — and the client emits MSG_MOVE_STOP with S still held. The other order // and the bit survives — and the client emits MSG_MOVE_STOP with S still held. The other order
// (autorun, then S) destroys the bit at key-down and walks you backward. Same two keys, two // (autorun, then S) destroys the bit at key-down and walks you backward. Same two keys, two
// outcomes; that asymmetry is the whole shape of the feature. // outcomes; that asymmetry is the whole shape of the feature.
// `/follow` enters as the FORWARD term, not a fifth source (decision 0890): the reference's
// follow pushes the very same move-forward bit `0x100000` the W key does, through the same
// setter, so it nets against a held S and diagonals with a strafe exactly like a held W. It
// rides the HELD state and not the key-DOWN edge, so it never trips the autorun cancel set
// above — which is right: synthesized input is not a keypress.
let fwd_axis = state::forward_axis( let fwd_axis = state::forward_axis(
keys_pressed(KeyCode::KeyW), keys_pressed(KeyCode::KeyW) || player.follow_forward,
keys_pressed(KeyCode::KeyS), keys_pressed(KeyCode::KeyS),
both_buttons, both_buttons,
autorun, autorun,
+342
View File
@@ -0,0 +1,342 @@
//! `/follow` — the auto-follow movement mode (decision 0890).
//!
//! The finding that shapes this module: **follow synthesizes keyboard input.** It owns no
//! translation of its own. Every state change in the reference funnels through the movement
//! singleton's setter (`0x60e790` sets the move-forward bit `0x100000`, `0x60e7f0` clears it) — the
//! same setter the MoveForward keybinding drives, one of ~40 stubs in the keybinding command table
//! at `0x513de0`-`0x514273`. So follow is "hold W for me, and steer", and the right implementation
//! reuses the controller wholesale rather than growing a second mover (wow-re
//! `object-layer/scratch/targeting-by-name.md` PART D, §5-cross-checked).
//!
//! It also sends **nothing on the wire** — corroborated against vmangos, which has no follow opcode
//! in 1.12.1. The server only ever sees the ordinary movement stream our synthesized input produces.
//!
//! ## The law
//!
//! - **Facing is steered, never snapped** (`0x6103d0`/`0x6108xx`): turn toward the followee at
//! [`TURN_RATE`] = π rad/s (180°/s), clamped to `min(remaining, rate × elapsed)`, and stop turning
//! inside a [`TURN_DEADZONE`] of 0.001 rad. That rate limit is why follow *reads* like a character
//! running rather than a camera snapping.
//! - **Beeline, re-aimed every tick** (`0x610e40`): the guid and both positions are re-resolved every
//! tick and nothing is cached. No path, no spline — `0x670630` is a *getter* over the click-to-move
//! order, which follow reads and never writes.
//! - **It never reads the followee's speed** (VERIFIED negative, whole-graph scan). You move at
//! whatever a held forward key yields for *you*, which is why follow falls behind a faster runner.
//! - **A hysteresis band, not a stop distance** — see [`should_move`]. Arrive is inclusive, resume is
//! one-directional. The gap between the two is why follow visibly starts and stops rather than
//! juddering on a single threshold.
//!
//! ## The one INFERRED rule
//!
//! The reference cancels follow when the player turns ~180° away (band 160°-220°), but wow-re could
//! not trace *what that angle is measured against* — it is explicitly flagged unresolved. Measuring
//! it against the live bearing alone cannot be right: starting a follow while facing away from the
//! followee would then cancel on the first tick, before the steer had a chance to turn us.
//!
//! So this module latches [`FollowState::aligned`] the first time the facing comes inside the band
//! and only cancels *after* that. Observable behaviour: starting a follow facing any direction works
//! and turns you around; deliberately turning around once you are following drops it. That matches
//! what the band is plainly *for*, and is marked INFERRED until the reference angle is pinned.
use std::f32::consts::PI;
use bevy::prelude::*;
use crate::net::GuidIndex;
use super::state::{MoveSpeed, Player};
/// Follow's facing-turn rate, rad/s — `0xc4d93c`, seeded `0x40490fdb` = π at `0x6111f9`. Its own
/// constant, distinct from the keyboard turn rate.
const TURN_RATE: f32 = PI;
/// Inside this many radians of the bearing, follow stops turning (`0x6108bb` → `0x60e920`).
const TURN_DEADZONE: f32 = 0.001;
/// The speed the distance thresholds are normalised by — `.rdata 0x80c4d0` = 7.0, which is also
/// vanilla's base run speed, so at normal speed the band is exactly 3.0 / 4.5 yd.
const SPEED_NORM: f32 = 7.0;
/// The base stop distance — `.rdata 0x80c4c0` = 3.0, a compile-time constant (its three writers are
/// CRT dynamic initializers behind `_initterm`), **not** a cvar.
const STOP_DISTANCE: f32 = 3.0;
/// Resume is 1.5× the stop distance, and its speed scale is floored at 1.0 — so the band never
/// closes below 3.0 / 4.5 yd however slowly you are moving.
const RESUME_FACTOR: f32 = 1.5;
/// How far off the bearing the facing must sit for a deliberate turn to drop the follow. The
/// reference's band is 160°-220°; wrapped into `[0, π]` that is "at least 160° off".
const CANCEL_ANGLE: f32 = 160.0 * PI / 180.0;
/// Who we are following, and the hysteresis latch — the reference's followed-guid pair `0xc4d980`
/// (armed `0x6111c9`, cleared `0x60fc1e`) plus the state its band implies.
#[derive(Resource, Default)]
pub(crate) struct FollowState {
/// The followee's guid, or `None` when not following.
pub(crate) guid: Option<u64>,
/// Whether the synthesized forward input is currently held. The band's latch: which threshold
/// applies depends on which side we are already on.
moving: bool,
/// Whether the facing has come inside [`CANCEL_ANGLE`] at least once since the follow began —
/// the guard on the INFERRED turn-away cancel (see the module header).
aligned: bool,
/// `WOW_FOLLOW_TRACE` bookkeeping: when the last trace line went out, and where we were.
/// `None` until a tick has seeded it.
traced_at: f64,
traced_pos: Option<Vec3>,
}
impl FollowState {
/// Begin following `guid`, from a clean band.
pub(crate) fn start(&mut self, guid: u64) {
self.guid = Some(guid);
self.moving = false;
self.aligned = false;
self.traced_at = 0.0;
self.traced_pos = None;
}
/// Stop following. Returns whether we actually were.
pub(crate) fn stop(&mut self) -> bool {
self.moving = false;
self.aligned = false;
self.guid.take().is_some()
}
}
/// `/follow [name]` — start following. `None` is the bare form (`FollowUnit("target")`), whose
/// subject is the current selection; a name resolves **players only** and additionally through the
/// reference's filter mode 2 (`CanAssist` + alive), which is [`crate::target`]'s to apply.
#[derive(bevy::ecs::message::Message, Clone, Debug)]
pub(crate) struct FollowRequest {
pub(crate) name: Option<String>,
}
/// The distance at which follow **arrives** and lets go of the key (`0x610ad2`-`0x610b1b`):
/// `(speed / 7.0) × 3.0`, tested inclusively (`test ah,0x41; jp` — the `<=` edge).
fn arrive_distance(speed: f32) -> f32 {
(speed / SPEED_NORM) * STOP_DISTANCE
}
/// The distance at which a stopped follow **resumes** (`0x610bc4`-`0x610c2b`):
/// `3.0 × 1.5 × max(speed / 7.0, 1.0)` = 4.5 yd at normal run speed. Only consulted while stopped.
fn resume_distance(speed: f32) -> f32 {
STOP_DISTANCE * RESUME_FACTOR * (speed / SPEED_NORM).max(1.0)
}
/// Should the synthesized forward key be held this tick? The band, as a pure function of which side
/// we are already on — the whole reason follow starts and stops instead of juddering.
fn should_move(was_moving: bool, distance: f32, speed: f32) -> bool {
if was_moving {
// Arrive is inclusive, so we keep going only while strictly beyond it.
distance > arrive_distance(speed)
} else {
distance >= resume_distance(speed)
}
}
/// Wrap an angle into `(-π, π]`.
fn wrap_pi(a: f32) -> f32 {
let t = std::f32::consts::TAU;
let x = (a + PI).rem_euclid(t);
x - PI
}
/// The `face_yaw` that points at a horizontal delta in **Bevy** space.
///
/// Derived from the controller's own forward vector rather than from a coordinate convention:
/// `control` builds `move_fwd = Quat::from_rotation_y(face_yaw) * NEG_Z`, which expands to
/// `(-sin y, 0, -cos y)`. Solving `(-sin y, -cos y) ∝ (dx, dz)` gives `y = atan2(-dx, -dz)`. Tied to
/// the expression it must agree with, so it cannot drift out of sign with it.
fn bearing_to(delta: Vec3) -> f32 {
(-delta.x).atan2(-delta.z)
}
/// Turn `face` toward `bearing` by at most this tick's budget, or leave it alone inside the
/// deadzone. The reference's `min(remaining, rate × elapsed)` clamp.
fn steer(face: f32, bearing: f32, dt: f32) -> f32 {
let remaining = wrap_pi(bearing - face);
if remaining.abs() <= TURN_DEADZONE {
return face;
}
let budget = TURN_RATE * dt;
face + remaining.clamp(-budget, budget)
}
/// Drive the follow: re-resolve the followee, steer the facing, and decide whether the synthesized
/// forward input is held this tick. Runs immediately **before** `control`, which reads
/// [`Player::follow_forward`] as one more term of its forward axis — the reference's shape exactly,
/// where follow pushes the same move-forward bit the W key does.
///
/// The player's own turn input runs *after* this in the same frame and therefore wins; that is what
/// makes the turn-away cancel reachable at all.
pub(super) fn steer_follow(
time: Res<Time>,
mut follow: ResMut<FollowState>,
mut player: ResMut<Player>,
speed: Res<MoveSpeed>,
index: Res<GuidIndex>,
transforms: Query<&Transform>,
) {
player.follow_forward = false;
let Some(guid) = follow.guid else { return };
// Re-resolved every tick, nothing cached — a followee that streams out ends the follow
// (`0x610e40` → `0x6106e7`).
let Some(target) = index
.0
.get(&guid)
.and_then(|e| transforms.get(*e).ok())
.map(|t| t.translation)
else {
info!("follow: the followee is gone — follow ends");
follow.stop();
return;
};
let delta = target - player.pos;
let flat = Vec3::new(delta.x, 0.0, delta.z);
let distance = flat.length();
if distance < f32::EPSILON {
return;
}
let bearing = bearing_to(flat);
// The INFERRED turn-away cancel (module header): only armed once the facing has come inside the
// band, so beginning a follow while facing away turns us around instead of cancelling.
let off_bearing = wrap_pi(bearing - player.face_yaw).abs();
if off_bearing < CANCEL_ANGLE {
follow.aligned = true;
} else if follow.aligned {
info!("follow: turned away from the followee — follow ends");
follow.stop();
return;
}
player.face_yaw = steer(player.face_yaw, bearing, time.delta_secs());
let moving = should_move(follow.moving, distance, speed.value);
if moving != follow.moving {
info!(
"follow: {} at {distance:.1} yd (arrive {:.1}, resume {:.1}, speed {:.1})",
if moving { "running" } else { "arrived" },
arrive_distance(speed.value),
resume_distance(speed.value),
speed.value,
);
}
// `WOW_FOLLOW_TRACE=1` — the field instrument for "follow won't catch up / overshoots": one
// line a second carrying the closing distance and the ground we actually covered, so the
// travel rate is a measured number rather than an end-to-end guess (decision 0404: timing and
// feel are measured, never eyeballed). Gated because this one IS per-frame.
if follow_trace_on() {
let now = time.elapsed_secs_f64();
if now - follow.traced_at >= 1.0 {
// Quiet until a tick has seeded a reference point: an instrument whose opening line is
// garbage is worse than one that says nothing for a second.
if let Some(from) = follow.traced_pos {
let elapsed = now - follow.traced_at;
let moved = player.pos.distance(from);
info!(
"follow-trace: {distance:.1} yd to go, covered {moved:.1} yd in {elapsed:.2} s \
({:.1} yd/s), moving={moving}",
moved / elapsed as f32,
);
}
follow.traced_at = now;
follow.traced_pos = Some(player.pos);
}
}
follow.moving = moving;
player.follow_forward = moving;
}
/// `WOW_FOLLOW_TRACE=1` — see [`steer_follow`]. One `OnceLock` read per tick when unset.
fn follow_trace_on() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_FOLLOW_TRACE").is_some())
}
/// Register the follow mode. The steer runs in the input stage just before `control`, so the flag
/// and the facing it writes are what the controller reads this same frame.
pub(super) fn plugin(app: &mut App) {
app.init_resource::<FollowState>()
.add_message::<FollowRequest>();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_band_is_three_and_four_point_five_yards_at_normal_speed() {
assert_eq!(arrive_distance(7.0), 3.0);
assert_eq!(resume_distance(7.0), 4.5);
}
#[test]
fn resume_scales_with_speed_but_never_below_the_base_band() {
// Above base speed both thresholds stretch…
assert_eq!(arrive_distance(14.0), 6.0);
assert_eq!(resume_distance(14.0), 9.0);
// …below it, arrive shrinks but resume is FLOORED at the 1.0 scale (the `max` in `0x610bfd`),
// so the band can never invert.
assert!(arrive_distance(3.5) < 3.0);
assert_eq!(resume_distance(3.5), 4.5);
}
#[test]
fn the_hysteresis_band_latches_on_both_edges() {
// Moving: keep going until strictly inside arrive (which is inclusive, so 3.0 stops).
assert!(should_move(true, 3.001, 7.0));
assert!(!should_move(true, 3.0, 7.0), "arrive is inclusive");
// Stopped: nothing happens until resume, so the gap between 3.0 and 4.5 is dead in BOTH
// directions — that gap is the whole point of the band.
assert!(!should_move(false, 4.0, 7.0));
assert!(should_move(false, 4.5, 7.0));
}
#[test]
fn bearing_agrees_with_the_controllers_own_forward_vector() {
// The invariant that keeps this from drifting out of sign with `control`.
for (dx, dz) in [
(0.0, -1.0),
(1.0, 0.0),
(0.0, 1.0),
(-1.0, 0.0),
(3.0, -4.0),
] {
let delta = Vec3::new(dx, 0.0, dz);
let yaw = bearing_to(delta);
let fwd = Quat::from_rotation_y(yaw) * Vec3::NEG_Z;
let want = delta.normalize();
assert!(
(fwd.x - want.x).abs() < 1e-5 && (fwd.z - want.z).abs() < 1e-5,
"yaw {yaw} should point at ({dx}, {dz}), got ({}, {})",
fwd.x,
fwd.z
);
}
}
#[test]
fn the_turn_is_rate_limited_and_has_a_deadzone() {
// A quarter turn at 180°/s takes 0.5 s, so one 0.1 s tick covers 18°, not the whole 90°.
let after = steer(0.0, PI / 2.0, 0.1);
assert!(
(after - TURN_RATE * 0.1).abs() < 1e-6,
"clamped to the budget"
);
// Within the budget it lands exactly on the bearing rather than overshooting.
assert!((steer(0.0, 0.05, 1.0) - 0.05).abs() < 1e-6);
// Inside the deadzone it does not move at all.
assert_eq!(steer(1.0, 1.0 + TURN_DEADZONE / 2.0, 1.0), 1.0);
}
#[test]
fn steering_takes_the_short_way_round() {
// Bearing just past -π from a facing just under π: the turn must be a small positive step,
// not a near-full sweep back the other way.
let after = steer(PI - 0.05, -PI + 0.05, 1.0);
assert!(
wrap_pi(after - (PI - 0.05)) > 0.0,
"should wrap forward across ±π"
);
}
}
+6
View File
@@ -379,6 +379,12 @@ pub(crate) struct Player {
/// places the other forward sources do — the direction vector, the wire flags, the swim /// places the other forward sources do — the direction vector, the wire flags, the swim
/// amounts, and the turn-rate's "am I translating" test. /// amounts, and the turn-rate's "am I translating" test.
pub(super) autorun: bool, pub(super) autorun: bool,
/// **`/follow` is holding the forward key this frame** (decision 0890). Not a mode of its own:
/// the reference's follow owns no translation and simply pushes the same move-forward bit the W
/// key does (`0x60e790`), so this folds into [`forward_axis`] beside `autorun` and the
/// both-button run rather than driving the mover itself. Rewritten every frame by
/// [`super::follow::steer_follow`], which runs just before the controller reads it.
pub(super) follow_forward: bool,
/// Free-fly (`F`): the camera moves on its own and the avatar/server position is frozen. /// Free-fly (`F`): the camera moves on its own and the avatar/server position is frozen.
pub(crate) detached: bool, pub(crate) detached: bool,
/// Feet position in **Bevy** coords (converted to raw WoW only when sending to the server). /// Feet position in **Bevy** coords (converted to raw WoW only when sending to the server).
+533
View File
@@ -0,0 +1,533 @@
//! Selection **by name** — the shared resolver behind `/target` and `/assist` (decision 0886).
//!
//! The reference has ONE resolver, `0x493aa0`, parameterised per caller; `/target`, `/assist` and
//! `/follow` are the same search with different arguments (wow-re
//! `object-layer/scratch/targeting-by-name.md`, §5-cross-checked):
//!
//! | binding | typemask | filter mode | exact-only |
//! |---|---|---|---|
//! | `TargetByName 0x489d60` | 8 = UNIT (creatures **and** players) | 0 | Lua arg #2, never supplied |
//! | `AssistByName 0x489c40` | 0x10 = **PLAYER only** | 0 | hardcoded 0 |
//! | `FollowByName 0x489ec0` | 0x10 = PLAYER only | 2 (`CanAssist` + alive) | Lua arg #2 |
//!
//! **Filter mode 0 is the whole point of the shape**: it underflows `0x493e40`'s jump table
//! (`dec eax; cmp eax,3; ja`) into the default arm `mov eax,1; ret 4` — *accept unconditionally*. So
//! by-name selection has **no** range limit, **no** facing cone, **no** dead/reaction/attackability
//! test, **no** scene-attach visibility gate, and **no** self-exclusion. It is a far wider net than
//! TAB ([`super::scan`]), which shares none of those defaults. `/target <your own name>` self-targets
//! in the real client, and so does it here.
//!
//! ## The comparison — two tiers, both case-insensitive
//!
//! 1. **Whole-string** (`0x64a4c0` → CRT `0x414310`, which folds both operands in its own bytes).
//! 2. **Longest common PREFIX** (`0x493cb6`-`0x493cf6`), anchored at index 0 — a prefix, never a
//! substring. Live whenever the second Lua argument is absent, which is every shipped call site,
//! so `/target Rag` really does select "Ragnaros". The running best length starts at **1**, so one
//! folded character of overlap is enough to enter the ranking — and a query sharing no first
//! letter with anything matches nothing.
//!
//! Within one prefix-length class the tie-break is the **strictly nearest** 3D centre-to-centre
//! dist² (the reference's `fcomp`/`jp` edge is a strict `<`, so ties keep the incumbent).
//!
//! ## The one deliberate deviation — and why
//!
//! In the reference a tier-1 whole-string hit **returns 0 from the walk callback, terminating the
//! enumeration** (`0x493ca2`, stopped at `0x4683b7`). Two consequences fall out, both
//! enumeration-order dependent: among several identically-named creatures the client takes
//! *whichever the ClntObjMgr table walk reaches first* — never the nearest — and a near "Bobby"
//! recorded before a far "Bob" beats it outright for the query `Bob`.
//!
//! **We do not reproduce the early-out**, and since 0886 was written the order it depends on has
//! been derived, so the choice is now made against a known reference rather than an unknown one.
//! Table #1 is an intrusive doubly-linked list (link field at `obj+0x38`, written by the ctor at
//! `0x4650c2`) whose insert is a **tail append** (`0x4646c3`-`0x4646d4`), so the walk runs
//! **oldest-linked first** — and "linked", not "spawned": a unit that streams out and back in is
//! re-appended to the tail. The reference's tier-1 pick is therefore *the same-named candidate that
//! has been continuously known longest*, which no client-side heuristic reproduces without modelling
//! the list itself.
//!
//! [`Rank::beats`] is instead a **total order** — exact beats any prefix, then longer prefix, then
//! strictly nearest — which is deterministic and picks the nearest of several same-named kobolds.
//! wow-re's note calls the reference behaviour here "the single behaviour most likely to look like a
//! bug in a reimplementation". Reproducing it is now *possible* (stamp each net entity with a
//! monotonic link sequence, re-stamped on stream-in, and order the walk by it); it is not done
//! because nearest is the better answer, not because the order is unknown. This is the one place to
//! change if that judgement is ever reversed.
//!
//! ## Not carried
//!
//! The reference pre-scans the **party** (`0x493b20`) and **raid** (`0x493b9d`) rosters before the
//! object walk, matching group members against the guid-keyed name cache at a sentinel distance of
//! 2.5e9 — so a groupmate with no streamed object is still selectable. benilla cannot hold a
//! guid-only selection at all yet ([`Selection::target`] is an `Entity`; the same gap already
//! no-ops `TargetUnit("partyN")` on an out-of-range member, see [`super::click`]), so the pre-scans
//! wait on that slice rather than being half-built here.
use std::cmp::Ordering;
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use benilla_protocol::guid;
use crate::names::NameCache;
use crate::net::{Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
use super::{scan, Selection};
/// Which guid families a search accepts — the reference's `edx` typemask argument.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NameSearch {
/// Typemask 8: creatures **and** players (a player object carries the UNIT bit). `/target`.
AnyUnit,
/// Typemask 0x10: players only. `/assist` — so `/assist <creature name>` cannot resolve, while
/// assisting a *targeted* creature can (the no-argument form never goes through the resolver).
PlayerOnly,
}
impl NameSearch {
fn accepts(self, guid_val: u64) -> bool {
match self {
Self::AnyUnit => guid::is_player(guid_val) || guid::is_creature_or_pet(guid_val),
Self::PlayerOnly => guid::is_player(guid_val),
}
}
}
/// `/target <name>` — resolve and select (`TargetByName`).
#[derive(bevy::ecs::message::Message, Clone, Debug)]
pub(crate) struct TargetByNameRequest {
pub(crate) name: String,
}
/// `/assist [name]` — select the basis unit's own target (`AssistByName` / `AssistUnit("target")`).
/// `None` is the bare form, whose basis is the current selection.
#[derive(bevy::ecs::message::Message, Clone, Debug)]
pub(crate) struct AssistRequest {
pub(crate) name: Option<String>,
}
/// How well one candidate answered the query. Ordered by [`Rank::beats`].
#[derive(Clone, Copy, Debug, PartialEq)]
struct Rank {
/// A tier-1 whole-string (case-insensitive) match.
exact: bool,
/// Folded common-prefix length, capped by both strings — the reference's
/// `matchLen = searchCursor - ctx.name`.
prefix: usize,
/// 3D centre-to-centre squared distance from the active player.
dist2: f32,
}
impl Rank {
/// Strictly better than the incumbent? Exact beats any prefix; then the longer prefix; then the
/// strictly nearer. Ties keep the incumbent, and a NaN distance never displaces — both edges the
/// reference's `fcomp` + `test ah,5` + `jp` reject makes.
fn beats(self, other: Rank) -> bool {
match (self.exact, other.exact) {
(true, false) => true,
(false, true) => false,
_ => match self.prefix.cmp(&other.prefix) {
Ordering::Greater => true,
Ordering::Less => false,
Ordering::Equal => self.dist2 < other.dist2,
},
}
}
}
/// The folded common-prefix length of `query` and `name` — the reference's lockstep walk
/// (`0x493cc0`-`0x493cee`) through the ASCII case fold `0x41089b`, stopping at whichever string ends
/// first or at the first folded mismatch.
fn common_prefix_len(query: &str, name: &str) -> usize {
query
.bytes()
.zip(name.bytes())
.take_while(|(q, n)| q.eq_ignore_ascii_case(n))
.count()
}
/// Score one candidate against the query, or `None` when it does not match at all. The reference's
/// running best length seeds at **1**, so a zero-length overlap is not a match.
fn rank(query: &str, name: &str, dist2: f32) -> Option<Rank> {
if query.eq_ignore_ascii_case(name) {
return Some(Rank {
exact: true,
prefix: query.len(),
dist2,
});
}
let prefix = common_prefix_len(query, name);
(prefix >= 1).then_some(Rank {
exact: false,
prefix,
dist2,
})
}
/// Everything a by-name search reads. Shared by the `/target` and `/assist` drains.
#[derive(SystemParam)]
#[allow(clippy::type_complexity)] // one bundled system param — the app's convention for big query sets
pub(crate) struct ByNameScan<'w, 's> {
/// Every known unit INCLUDING our own avatar — the reference has no self-exclusion on this path.
units: Query<
'w,
's,
(
Entity,
&'static Guid,
&'static Transform,
Option<&'static ObjectStore>,
),
>,
self_q: Query<
'w,
's,
(
Entity,
&'static Guid,
&'static Transform,
Option<&'static ObjectStore>,
),
With<SelfPlayer>,
>,
/// Read-only: a by-name sweep must never fire a name query per candidate. A unit whose name has
/// not been cached yet is simply not matchable — the same limit the reference has, whose own
/// name getter reads an already-resolved string.
names: Res<'w, NameCache>,
/// Only [`Filter::AssistableAlive`] reads these — the reaction ladder behind `CanAssist`.
factions: Option<Res<'w, super::Factions>>,
reputations: Res<'w, crate::net::Reputations>,
}
/// The per-candidate filter the reference hands `0x493e40` as its mode argument.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Filter {
/// **Mode 0** — and the shape is the point: `0-1` underflows the `cmp eax,3; ja` bound, so the
/// jump table is skipped entirely for `mov eax,1; ret 4`. Accept unconditionally. `/target`,
/// `/assist`.
AcceptAll,
/// **Mode 2** (`0x493eca`) — `CanAssist 0x6066f0` **and** `UNIT_FIELD_HEALTH > 0`. `/follow`
/// resolves only to living, assistable candidates, so `/follow <an enemy player>` finds nothing.
AssistableAlive,
}
impl ByNameScan<'_, '_> {
/// The active player's position — the distance origin (the reference returns `(0,0)` outright
/// when the active player object does not resolve, `0x493ae0`).
fn origin(&self) -> Option<Vec3> {
self.self_q
.single()
.ok()
.map(|(_, _, tf, _)| tf.translation)
}
/// The reference's mode-2 arm (`0x493eca`): `CanAssist` — the reaction ladder at **≥ 4**
/// (friendly; wow-re `w2d1` `CanAssist_Unit 0x6066f0`), the mirror of [`scan::can_attack`]'s
/// `≤ 3` — **and** alive.
fn assistable_alive(&self, store: Option<&ObjectStore>) -> bool {
if store.is_some_and(|s| s.0.unit_is_dead()) {
return false;
}
let self_store = self.self_q.single().ok().and_then(|(_, _, _, s)| s);
super::ring_reaction(
self.factions.as_deref(),
&self.reputations,
store,
self_store,
) >= 4
}
/// Resolve a name to a unit, or `None`. See the module header for the full law.
///
/// Every call logs its verdict under the `by-name:` prefix — one line per typed command, so a
/// "`/target` won't take that mob" report is diagnosable from the log rather than re-guessed
/// (the `WOW_TAB_TRACE` instrument's shape, ungated because the cost is per-command, not
/// per-frame). The line names how many candidates were eligible, which won, and on what.
fn resolve(&self, query: &str, search: NameSearch, filter: Filter) -> Option<(Entity, u64)> {
let query = query.trim();
if query.is_empty() {
return None;
}
let Some(origin) = self.origin() else {
info!("by-name: \"{query}\" — no active player object; nothing resolves");
return None;
};
let mut considered = 0usize;
let mut nameless = 0usize;
let mut best: Option<(Entity, u64, Rank, String)> = None;
for (entity, guid, tf, store) in &self.units {
if !search.accepts(guid.0) {
continue;
}
if filter == Filter::AssistableAlive && !self.assistable_alive(store) {
continue;
}
let Some(name) = self.names.peek(guid.0) else {
nameless += 1;
continue;
};
considered += 1;
let dist2 = tf.translation.distance_squared(origin);
let Some(r) = rank(query, name, dist2) else {
continue;
};
if best.as_ref().is_none_or(|(_, _, b, _)| r.beats(*b)) {
best = Some((entity, guid.0, r, name.to_string()));
}
}
match &best {
Some((_, guid, r, name)) => info!(
"by-name: \"{query}\" ({search:?}) -> \"{name}\" guid {guid:#x} \
({}, prefix {}, {:.1} yd) over {considered} named candidates ({nameless} unnamed)",
if r.exact { "exact" } else { "prefix" },
r.prefix,
r.dist2.sqrt(),
),
None => info!(
"by-name: \"{query}\" ({search:?}) -> NO MATCH over {considered} named candidates \
({nameless} unnamed); target left untouched"
),
}
best.map(|(e, g, _, _)| (e, g))
}
}
/// Drain `/target <name>`: resolve, then commit through the shared SetSelection path so a by-name
/// switch obeys the same stop → select → re-swing law a click does ([`scan::commit`]).
///
/// A miss leaves the current target **untouched** — verified: neither failure edge in `0x489db4`
/// calls `SetSelection`, so `/target nosuchname` never clears what you had. The reference does emit
/// a game message there (ids `0x127` name-not-found / `0xb8` empty name), but the id→string table is
/// runtime-populated BSS and wow-re could not statically recover the text, so we say nothing rather
/// than invent a line. That silence is the known deviation on this path.
pub(super) fn target_by_name_requests(
mut requests: MessageReader<TargetByNameRequest>,
scan_params: ByNameScan,
mut commit: SelectCommit,
) {
for request in requests.read() {
let Some((entity, guid)) =
scan_params.resolve(&request.name, NameSearch::AnyUnit, Filter::AcceptAll)
else {
continue;
};
commit.commit(entity, guid);
}
}
/// Drain `/assist [name]`: find the **basis** unit, read its `UNIT_FIELD_TARGET` (+0x28 off the unit
/// block — wow-re proved the offset arithmetic against HEALTH and DYNAMIC_FLAGS on the same line),
/// and select whatever it is pointing at.
///
/// The bare form is `AssistUnit("target")` in the reference — assist whoever you have selected,
/// creature or player. The named form resolves **players only**. A basis with no target is a
/// completely silent no-op (verified: the shared tail bails before any send).
///
/// **The `assistAttack` leg is correctly absent.** With that CVar (`0xb4d8f8`) non-zero the
/// reference's tail also opens the swing on the newly-selected target (`0x5ecb70` →
/// `CMSG_ATTACKSWING`) — but its **registered default is `"0"`** (VERIFIED at the registration
/// bytes: `0x48fc50`, default-value slot `[ebp+0xc]` = `0x82e570` = `"0"`, int-parsed into
/// `[cvar+0x28]` by `0x63e127`). So stock `/assist` selects and does **not** swing, which is exactly
/// what this does. The leg becomes reachable only if benilla ever grows the CVar itself.
pub(super) fn assist_requests(
mut requests: MessageReader<AssistRequest>,
scan_params: ByNameScan,
index: Res<GuidIndex>,
mut commit: SelectCommit,
) {
for request in requests.read() {
// The basis: a named PLAYER, or — bare — whatever is currently selected.
let basis = match &request.name {
Some(name) => scan_params
.resolve(name, NameSearch::PlayerOnly, Filter::AcceptAll)
.map(|(e, _)| e),
None => commit.selection.target,
};
let Some(basis) = basis else {
info!("by-name: /assist — no basis unit; nothing to assist");
continue;
};
// Its own target. A zero/absent guid is the silent no-op — the reference's shared tail
// bails here before any send.
let Some(guid) = commit
.stores
.get(basis)
.ok()
.and_then(|s| s.0.unit_target())
.filter(|g| *g != 0)
else {
info!("by-name: /assist — the basis unit is targeting nothing; silent no-op");
continue;
};
// "Select if it resolves" (`0x489a40`): only a streamed unit can be selected — the
// reference's group-roster fallback for an unstreamed guid needs the guid-only selection
// benilla does not have yet (see the module header).
let Some(entity) = index.0.get(&guid).copied() else {
info!(
"by-name: /assist — the basis is targeting guid {guid:#x}, which is not streamed"
);
continue;
};
info!("by-name: /assist -> the basis unit's target, guid {guid:#x}");
commit.commit(entity, guid);
}
}
/// Drain `/follow [name]`: resolve the subject and hand it to [`crate::player`], which owns the
/// motion (decision 0890). Nothing goes on the wire — follow is client-side movement only.
///
/// The two forms differ in more than the argument. A **name** goes through the resolver with the
/// reference's filter mode 2 — players only, and only ones that are **alive and assistable** — so
/// `/follow <an enemy>` finds nothing. The **bare** form is `FollowUnit("target")`, a unit-token
/// resolve that never touches the by-name path at all, so it follows whatever you have selected,
/// creature or player, with no such filter.
pub(super) fn follow_requests(
mut requests: MessageReader<crate::player::FollowRequest>,
scan_params: ByNameScan,
selection: Res<Selection>,
mut follow: ResMut<crate::player::FollowState>,
) {
for request in requests.read() {
let resolved = match &request.name {
Some(name) => scan_params
.resolve(name, NameSearch::PlayerOnly, Filter::AssistableAlive)
.map(|(_, guid)| guid),
None => selection.guid,
};
match resolved {
Some(guid) => {
info!("follow: now following guid {guid:#x}");
follow.start(guid);
}
None => {
// The reference's `if GetSlashCmdTarget(msg)` guard and the resolver's own miss both
// land here: nothing to follow, and whatever we were following is left alone.
info!("follow: nothing to follow");
}
}
}
}
/// The shared commit tail — everything [`scan::commit`] needs, bundled, so both drains route a
/// resolved unit through the one SetSelection path (dedup + the stop → select → re-swing law).
#[derive(SystemParam)]
#[allow(clippy::type_complexity)] // one bundled system param — the app's convention for big query sets
pub(crate) struct SelectCommit<'w, 's> {
selection: ResMut<'w, Selection>,
net: Res<'w, NetCommands>,
me: Query<
'w,
's,
(
&'static Guid,
Option<&'static ObjectStore>,
Has<crate::creature_anim::Engaged>,
),
With<SelfPlayer>,
>,
stores: Query<'w, 's, &'static ObjectStore>,
factions: Option<Res<'w, super::Factions>>,
reputations: Res<'w, crate::net::Reputations>,
}
impl SelectCommit<'_, '_> {
fn commit(&mut self, entity: Entity, guid: u64) {
let me = self.me.single().ok();
// The new-target classification `scan::commit` expects from its callers (it does not
// re-derive one) — the same `can_attack` the cursor and the TAB scan pass.
let attackable = scan::can_attack(
self.stores.get(entity).ok(),
self.factions.as_deref(),
&self.reputations,
me.and_then(|(_, store, _)| store),
);
scan::commit(
&mut self.selection,
&self.net,
entity,
guid,
me.is_some_and(|(_, _, engaged)| engaged),
me.map(|(g, _, _)| g.0),
attackable,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn exact(dist2: f32) -> Rank {
Rank {
exact: true,
prefix: 3,
dist2,
}
}
fn prefix(prefix: usize, dist2: f32) -> Rank {
Rank {
exact: false,
prefix,
dist2,
}
}
#[test]
fn prefix_length_folds_case_and_stops_at_the_shorter_string() {
assert_eq!(common_prefix_len("rag", "Ragnaros"), 3);
assert_eq!(common_prefix_len("RAG", "ragnaros"), 3);
assert_eq!(
common_prefix_len("Ragnarosx", "Ragnaros"),
8,
"capped by the candidate"
);
assert_eq!(
common_prefix_len("zzz", "Ragnaros"),
0,
"no shared first letter"
);
}
#[test]
fn a_match_needs_one_folded_character() {
// The reference seeds its best length at 1, so zero overlap is not a match at all.
assert!(rank("zzz", "Ragnaros", 1.0).is_none());
assert_eq!(rank("rag", "Ragnaros", 1.0).map(|r| r.prefix), Some(3));
}
#[test]
fn whole_string_match_is_case_insensitive_and_ranks_exact() {
let r = rank("kobold vermin", "Kobold Vermin", 9.0).expect("matches");
assert!(r.exact, "tier 1 is case-insensitive whole-string");
// A multi-word creature name is the case that made the whole-argument trim load-bearing.
}
#[test]
fn exact_beats_any_prefix_and_nearest_breaks_the_tie() {
// Our deliberate deviation: the reference's enumeration-order early-out would let a nearer
// "Bobby" beat a farther "Bob"; we rank exact first, unconditionally.
assert!(exact(100.0).beats(prefix(3, 1.0)));
assert!(!prefix(3, 1.0).beats(exact(100.0)));
// Among exact matches — several identically-named kobolds — the nearest wins.
assert!(exact(4.0).beats(exact(9.0)));
assert!(!exact(9.0).beats(exact(4.0)));
}
#[test]
fn longer_prefix_beats_nearer_shorter_one() {
// The reference's `jg -> accept` edge: a longer prefix wins outright, distance irrelevant.
assert!(prefix(5, 900.0).beats(prefix(3, 1.0)));
assert!(!prefix(3, 1.0).beats(prefix(5, 900.0)));
// Equal prefix length falls through to strictly-nearest.
assert!(prefix(3, 4.0).beats(prefix(3, 9.0)));
}
#[test]
fn ties_keep_the_incumbent_and_nan_never_displaces() {
// The reference's `jp` reject edge makes the accept a strict `<`.
assert!(!prefix(3, 4.0).beats(prefix(3, 4.0)));
assert!(!exact(4.0).beats(exact(4.0)));
assert!(!prefix(3, f32::NAN).beats(prefix(3, 4.0)));
}
}
+19
View File
@@ -44,6 +44,7 @@ use crate::interact::{InspectMode, WorldClick, WorldRightClick};
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer}; use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
use crate::schedule::WorldStage; use crate::schedule::WorldStage;
mod by_name;
mod click; mod click;
mod cursor_mode; mod cursor_mode;
mod flash; mod flash;
@@ -63,6 +64,9 @@ pub(crate) use flash::CombatFlash;
// The action layer's "attack pressed with no target" request — the nearest-enemy auto-acquire // The action layer's "attack pressed with no target" request — the nearest-enemy auto-acquire
// (`scan`) answers it with the same core TAB uses. // (`scan`) answers it with the same core TAB uses.
pub(crate) use scan::{can_attack, AttackNearestRequest}; pub(crate) use scan::{can_attack, AttackNearestRequest};
// The chat layer's by-name selection asks (`/target`, `/assist` — decision 0886), answered by the
// shared resolver the reference parameterises per caller.
pub(crate) use by_name::{AssistRequest, TargetByNameRequest};
// The byte-verified reaction decode + its faction catalog, reused by the unit-frame feed to tint the // The byte-verified reaction decode + its faction catalog, reused by the unit-frame feed to tint the
// target's name plate (`TargetFrame_CheckFaction`) the same way the selection ring colours itself. // target's name plate (`TargetFrame_CheckFaction`) the same way the selection ring colours itself.
// `duel_rung` is the diagnostic face of the same walk, for `/reaction` (decision 0637). // `duel_rung` is the diagnostic face of the same walk, for `/reaction` (decision 0637).
@@ -171,6 +175,8 @@ impl Plugin for TargetPlugin {
.init_resource::<CombatFlash>() .init_resource::<CombatFlash>()
.init_resource::<scan::TabHistory>() .init_resource::<scan::TabHistory>()
.add_message::<AttackNearestRequest>() .add_message::<AttackNearestRequest>()
.add_message::<TargetByNameRequest>()
.add_message::<AssistRequest>()
.add_systems( .add_systems(
Startup, Startup,
( (
@@ -211,6 +217,19 @@ impl Plugin for TargetPlugin {
click::act_on_right_click, click::act_on_right_click,
click::clear_target_requests, click::clear_target_requests,
click::target_unit_requests, click::target_unit_requests,
// The chat layer's by-name asks (decision 0886), beside the UI's token asks:
// both are non-mouse selection writers committing through `scan::commit`.
// The chat layer's by-name asks, grouped as one chained element (the outer
// chain is at Bevy's 20-tuple limit): `/target` and `/assist` commit through
// `scan::commit` like every other non-mouse selection writer, and `/follow`
// resolves its subject for `crate::player` to move (decision 0890) without
// touching `Selection` at all.
(
by_name::target_by_name_requests,
by_name::assist_requests,
by_name::follow_requests,
)
.chain(),
scan::auto_acquire_attacker, scan::auto_acquire_attacker,
scan::tab_target, scan::tab_target,
scan::acquire_and_attack, scan::acquire_and_attack,
+10 -1
View File
@@ -71,6 +71,9 @@ pub(super) enum SlashIndex {
LootFfa, LootFfa,
LootRoundRobin, LootRoundRobin,
LootMaster, LootMaster,
Target,
Assist,
Follow,
} }
impl SlashIndex { impl SlashIndex {
@@ -105,13 +108,16 @@ impl SlashIndex {
Self::LootFfa => "LOOT_FFA", Self::LootFfa => "LOOT_FFA",
Self::LootRoundRobin => "LOOT_ROUNDROBIN", Self::LootRoundRobin => "LOOT_ROUNDROBIN",
Self::LootMaster => "LOOT_MASTER", Self::LootMaster => "LOOT_MASTER",
Self::Target => "TARGET",
Self::Assist => "ASSIST",
Self::Follow => "FOLLOW",
} }
} }
/// Every registered index — the registry proper. A reference command NOT in this list resolves /// Every registered index — the registry proper. A reference command NOT in this list resolves
/// nowhere and answers `HELP_TEXT_SIMPLE`, exactly as an unknown command does in the reference /// nowhere and answers `HELP_TEXT_SIMPLE`, exactly as an unknown command does in the reference
/// (better than a registered handler that silently does nothing). /// (better than a registered handler that silently does nothing).
const ALL: [Self; 28] = [ const ALL: [Self; 31] = [
Self::Reply, Self::Reply,
Self::Join, Self::Join,
Self::Leave, Self::Leave,
@@ -140,6 +146,9 @@ impl SlashIndex {
Self::LootFfa, Self::LootFfa,
Self::LootRoundRobin, Self::LootRoundRobin,
Self::LootMaster, Self::LootMaster,
Self::Target,
Self::Assist,
Self::Follow,
]; ];
} }
+47 -9
View File
@@ -122,15 +122,23 @@ pub(super) struct ChatProbes<'w, 's> {
guids: Res<'w, crate::net::GuidIndex>, guids: Res<'w, crate::net::GuidIndex>,
} }
/// The two setters `DoEmote` drives besides the packet, bundled (the drain is at Bevy's 16-param /// Everything the drain **queues into another subsystem's one setter** rather than applying itself,
/// ceiling): the **posture** (`EmoteSpecProc == 1` → `SetStandState`) and the **stow** /// bundled (the drain is at Bevy's 16-param ceiling).
/// (`SetSheatheState(0, SNAP)`, unconditional on every emote that passes the gates — wow-re ///
/// `sheath-policy.md` §1, site `0x5ef630`). Both are queues into their subsystem's one setter, /// - `stand`/`sheath` — the two setters `DoEmote` drives besides the packet: the **posture**
/// never applied here. /// (`EmoteSpecProc == 1` → `SetStandState`) and the **stow** (`SetSheatheState(0, SNAP)`,
/// unconditional on every emote that passes the gates — wow-re `sheath-policy.md` §1, site
/// `0x5ef630`).
/// - `target`/`assist` — the by-name selection asks (decision 0886), answered by
/// [`crate::target`]'s shared resolver so they commit through the same SetSelection path a click
/// does. Chat never writes [`Selection`] itself.
#[derive(bevy::ecs::system::SystemParam)] #[derive(bevy::ecs::system::SystemParam)]
pub(super) struct EmoteOut<'w> { pub(super) struct ChatOut<'w> {
stand: MessageWriter<'w, crate::player::StandStateRequest>, stand: MessageWriter<'w, crate::player::StandStateRequest>,
sheath: MessageWriter<'w, crate::creature_anim::SheathRequest>, sheath: MessageWriter<'w, crate::creature_anim::SheathRequest>,
target: MessageWriter<'w, crate::target::TargetByNameRequest>,
assist: MessageWriter<'w, crate::target::AssistRequest>,
follow: MessageWriter<'w, crate::player::FollowRequest>,
} }
// One parameter per concern — the chat drain fans out to every command's consumer. // One parameter per concern — the chat drain fans out to every command's consumer.
@@ -156,7 +164,7 @@ pub(super) fn drain_chat_input(
mut go_targets: MessageWriter<crate::creature_anim::SpellGoTargets>, mut go_targets: MessageWriter<crate::creature_anim::SpellGoTargets>,
// The command table (decision 0881) — the reference's own aliases, resolved at boot. // The command table (decision 0881) — the reference's own aliases, resolved at boot.
table: Res<super::commands::SlashCommands>, table: Res<super::commands::SlashCommands>,
mut emote_out: EmoteOut, mut chat_out: ChatOut,
probes: ChatProbes, probes: ChatProbes,
) { ) {
let ChatProbes { let ChatProbes {
@@ -495,6 +503,36 @@ pub(super) fn drain_chat_input(
ParsedChat::Forfeit => { ParsedChat::Forfeit => {
script.queue_duel_request(benilla_ui::script::DuelRequest::Cancel); script.queue_duel_request(benilla_ui::script::DuelRequest::Cancel);
} }
// The by-name selection pair (decision 0886). Both hand the name to `crate::target`'s
// shared resolver — the reference's own `0x493aa0`, parameterised per caller — so the
// commit goes through the one SetSelection path a click, TAB and `TargetUnit` share.
//
// A bare `/target` reproduces `GetSlashCmdTarget`'s fallback (your current target's
// name, iff it is a player) and is therefore a no-op re-select; a bare `/target` with a
// creature selected resolves to nothing, exactly as the reference's `if
// GetSlashCmdTarget(msg)` guard does.
ParsedChat::Target { name } => {
if let Some(name) =
name.or_else(|| target_player_name(&selection, &mut names, &commands))
{
chat_out
.target
.write(crate::target::TargetByNameRequest { name });
}
}
// A bare `/assist` is the ref's `AssistUnit("target")` — assist whoever is selected,
// creature or player — so it passes `None` straight through rather than taking the
// player-name fallback. The ref reaches the same unit by resolving its NAME first
// (`AssistByName`), which can pick a different same-named player; going by the live
// selection is the same intent without that ambiguity (recorded in 0886).
ParsedChat::Assist { name } => {
chat_out.assist.write(crate::target::AssistRequest { name });
}
// `/follow` (decision 0890) — the subject is resolved by `crate::target` and the motion
// is `crate::player`'s; chat only carries the ask. Bare follows the current selection.
ParsedChat::Follow { name } => {
chat_out.follow.write(crate::player::FollowRequest { name });
}
// The ref's handler is `SlashCmdList["PVP"] = function() TogglePVP() end` — one line // The ref's handler is `SlashCmdList["PVP"] = function() TogglePVP() end` — one line
// over the same binding the popup row calls, so it enters the same intent queue // over the same binding the popup row calls, so it enters the same intent queue
// (decision 0646 §3; the `/duel` reasoning above, verbatim). // (decision 0646 §3; the `/duel` reasoning above, verbatim).
@@ -565,7 +603,7 @@ pub(super) fn drain_chat_input(
// sheathes in the reference too. The anim layer's one setter owns the idempotency // sheathes in the reference too. The anim layer's one setter owns the idempotency
// refusal, so an already-stowed body costs nothing. // refusal, so an already-stowed body costs nothing.
if let Ok((entity, _, _)) = self_player.single() { if let Ok((entity, _, _)) = self_player.single() {
emote_out.sheath.write(crate::creature_anim::SheathRequest { chat_out.sheath.write(crate::creature_anim::SheathRequest {
entity, entity,
state: 0, state: 0,
ceremony: false, ceremony: false,
@@ -577,7 +615,7 @@ pub(super) fn drain_chat_input(
// the one setter in `crate::player`, which sends `CMSG_STANDSTATECHANGE`, holds the // the one setter in `crate::player`, which sends `CMSG_STANDSTATECHANGE`, holds the
// local commit until the echo lands, and runs the sit-stow rider. // local commit until the echo lands, and runs the sit-stow rider.
if let Some(state) = posture { if let Some(state) = posture {
emote_out chat_out
.stand .stand
.write(crate::player::StandStateRequest { state: state as u8 }); .write(crate::player::StandStateRequest { state: state as u8 });
} }
+42 -5
View File
@@ -159,16 +159,42 @@ pub(in crate::ui_chat) enum ParsedChat {
/// kick, leave, loot settings, raid marks — apply to the local mirror, so the whole menu /// kick, leave, loot settings, raid marks — apply to the local mirror, so the whole menu
/// surface is exercisable serverless. /// surface is exercisable serverless.
PartyTest { arg: String }, PartyTest { arg: String },
/// `/target [name]` — select by name (`TargetByName`, decision 0886). Resolves creatures AND
/// players, case-insensitively, by whole name or longest common prefix, with no range, cone,
/// liveness or hostility gate at all. `None` = bare, which the ref's `GetSlashCmdTarget` turns
/// into your current target's name when that target is a player (a no-op re-select), and into
/// nothing otherwise.
Target { name: Option<String> },
/// `/assist [name]` — select whatever the basis unit is targeting. A named basis resolves
/// **players only** (typemask 0x10); bare assists your current target, creature or player.
Assist { name: Option<String> },
/// `/follow [name]` — auto-follow (decision 0890). A named subject resolves players only, and
/// only living assistable ones (the ref's filter mode 2); bare follows the current selection,
/// creature or player. Client-side movement — nothing goes on the wire.
Follow { name: Option<String> },
/// A slash line matching neither a chat command nor an `EmotesText` name — dropped. /// A slash line matching neither a chat command nor an `EmotesText` name — dropped.
Unknown, Unknown,
} }
/// The name half of the ref's `GetSlashCmdTarget` (ChatFrame.lua:650-658): a non-empty argument /// The name half of the ref's `GetSlashCmdTarget` (ChatFrame.lua:650-658) — the argument grammar
/// is the name (first word — 1.12 names carry no spaces); empty defers to the dispatch's /// **thirteen** shipped commands share, `/target` and `/assist` among them.
/// player-target fallback. ///
/// Its first line is a trim, not a tokenizer: `gsub(msg, "(%s*)(.*[^%s]+)(%s*)", "%2", 1)` keeps
/// **everything between the outer whitespace**, internal spaces included. This used to take the
/// first word instead, on the reasoning that 1.12 player names carry no spaces — true, but
/// `/target` resolves *creature* names too, and "Kobold Vermin" is the common case. `args` reaches
/// us already trimmed ([`parse_line`]), which is exactly what that gsub computes.
///
/// Empty defers to the caller's fallback (a bare party command acts on the selected PLAYER —
/// `target_player_name` in [`super`], the ref's `UnitIsPlayer("target")` branch).
///
/// **Not carried: the unit-token expansion.** The ref's tail expands `player`, `target`,
/// `^party[1-4]` and `^raid[0-9]` through `UnitName` before returning, so `/assist party1` means
/// "that member's name". benilla feeds only the `player` and `target` tokens to the VM's unit
/// store, so the roster half has nothing to resolve against yet; the tokens would pass through as
/// literal names. Named in decision 0886 as a gap rather than half-built here.
fn slash_target_name(args: &str) -> Option<String> { fn slash_target_name(args: &str) -> Option<String> {
let name = args.split_whitespace().next().unwrap_or(""); (!args.is_empty()).then(|| args.to_string())
(!name.is_empty()).then(|| name.to_string())
} }
/// Parse a trimmed slash line into an ACTION (`/join`, `/afk`, `/random`, `/wave`, the dev /// Parse a trimmed slash line into an ACTION (`/join`, `/afk`, `/random`, `/wave`, the dev
@@ -271,6 +297,17 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
S::Duel => ParsedChat::Duel { S::Duel => ParsedChat::Duel {
name: slash_target_name(args), name: slash_target_name(args),
}, },
// The by-name selection pair (decision 0886). Both take the whole trimmed argument —
// `/target Kobold Vermin` is one name, not a name plus junk.
S::Target => ParsedChat::Target {
name: slash_target_name(args),
},
S::Assist => ParsedChat::Assist {
name: slash_target_name(args),
},
S::Follow => ParsedChat::Follow {
name: slash_target_name(args),
},
S::DuelCancel => ParsedChat::Forfeit, S::DuelCancel => ParsedChat::Forfeit,
S::Pvp => ParsedChat::Pvp, S::Pvp => ParsedChat::Pvp,
// The social verbs (decision 0668). The argument is passed WHOLE: `/who`'s is a filter // The social verbs (decision 0668). The argument is passed WHOLE: `/who`'s is a filter
+48 -8
View File
@@ -602,20 +602,60 @@ fn real_alias_table_resolves_the_shipped_commands() {
"{line} resolves to an emote" "{line} resolves to an emote"
); );
} }
// …and the four names the reference has NO command for, which the DBC-name match used to // …and the three names the reference has NO command for, which the DBC-name match used to
// accept. `/follow` is the sharp one: it fired a text emote where the real client's // accept as emotes.
// `SlashCmdList["FOLLOW"]` follows your target (benilla has no follow yet, so it is honestly for line in ["/joke", "/puzzle", "/attackmytarget"] {
// unknown rather than wrongly an emote).
for line in ["/follow", "/joke", "/puzzle", "/attackmytarget"] {
assert_eq!(parse_line(line), ParsedChat::Unknown, "{line}"); assert_eq!(parse_line(line), ParsedChat::Unknown, "{line}");
} }
// `/follow` was the sharp one in that class: the DBC-name match fired a text emote where the
// real client's `SlashCmdList["FOLLOW"]` follows your target. 0881 made it honestly unknown;
// 0890 makes it the real command, over all three shipped aliases (SLASH_FOLLOW1-6 → `/f`,
// `/follow`, `/fol`).
for line in ["/follow", "/f", "/fol"] {
assert_eq!(
parse_line(line),
ParsedChat::Follow { name: None },
"{line}"
);
}
assert_eq!(
parse_line("/follow Probeone"),
ParsedChat::Follow {
name: Some("Probeone".into())
}
);
// A command whose handler benilla does not register answers like any unknown command. // A command whose handler benilla does not register answers like any unknown command.
assert_eq!(parse_line("/ginvite"), ParsedChat::Unknown); assert_eq!(parse_line("/ginvite"), ParsedChat::Unknown);
// The by-name selection pair (decision 0886) — every shipped alias, and the whole-argument
// grammar that makes a multi-word creature name ONE name. `/tar` and `/a` are the short forms
// the shipped strings carry (SLASH_TARGET2/4, SLASH_ASSIST1/3).
assert_eq!(
parse_line("/target Kobold Vermin"),
ParsedChat::Target {
name: Some("Kobold Vermin".into())
},
"the argument is trimmed WHOLE — `GetSlashCmdTarget`'s gsub, not a first-word split"
);
assert_eq!(
parse_line("/tar Hogger "),
ParsedChat::Target {
name: Some("Hogger".into())
}
);
assert_eq!(parse_line("/target"), ParsedChat::Target { name: None });
assert_eq!(
parse_line("/a Bob"),
ParsedChat::Assist {
name: Some("Bob".into())
}
);
assert_eq!(parse_line("/assist"), ParsedChat::Assist { name: None });
// The whole shipped surface, so a table that half-loaded fails loudly: **225 distinct emote // The whole shipped surface, so a table that half-loaded fails loudly: **225 distinct emote
// commands** over the 169 `EmotesText` names (the strings repeat — `EMOTE87_CMD1` and `_CMD2` // commands** over the 169 `EmotesText` names (the strings repeat — `EMOTE87_CMD1` and `_CMD2`
// are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **55 distinct // are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **62 distinct
// aliases** across the 28 registered `SlashCmdList` indices. // aliases** across the 31 registered `SlashCmdList` indices (0886 added TARGET's `/target`
assert_eq!(table.counts(), (55, 225), "(slash, emote) aliases"); // `/tar` and ASSIST's `/assist` `/a` to 0881's 55; 0890 added FOLLOW's `/f` `/follow` `/fol`).
assert_eq!(table.counts(), (62, 225), "(slash, emote) aliases");
} }
// ── The send-side posture-eligibility gate (`emote_send_eligible`) — the director-verified rows // ── The send-side posture-eligibility gate (`emote_send_eligible`) — the director-verified rows
+34
View File
@@ -209,6 +209,40 @@ pub(super) fn drain_container_uses(
} }
continue; continue;
} }
// The openable fork: a clam, an unlocked lockbox, a wrapped gift. `CMSG_OPEN_ITEM`, not
// `CMSG_USE_ITEM` — the server's `HandleOpenItemOpcode` is the only handler that answers
// with `SendLoot(item guid, LOOT_CORPSE)`, i.e. a loot window over a thing in your bag;
// sending USE_ITEM instead casts the item's (absent) on-use spell and nothing happens,
// which is exactly the "no way to open clams" symptom. The predicate is
// `ItemInfo::openable` — literally the same call the tooltip's `<Right Click to Open>`
// line makes, so the promise and the packet cannot drift apart.
//
// It sits ABOVE the readable fork because the reference's own tooltip law makes the two
// mutually exclusive with **openable winning** (wow-re `tooltip-content-law.md`
// §1-OPENABLE, the `jmp` past the READABLE test): a wrapped gift that also carries letter
// text opens, it does not read. Below the merchant/bank intercepts, which outrank every
// ordinary click.
let openable = self_q
.iter()
.next()
.and_then(|store| slot_guid(&store.0, bag, slot0.unwrap_or(0), &items))
.and_then(|guid| {
let obj = items.object(guid)?;
let inst_flags = obj.item_flags().unwrap_or(0);
let entry = obj.object_entry()?;
items
.template(entry, guid, &commands)?
.openable(inst_flags)
.then_some(guid)
});
if let Some(guid) = openable {
debug!("ui_items: open item {guid:#x} (lua bag {bag} → wire {bag_index}/{wire_slot})");
let _ = commands.0.send(ClientCommand::OpenItem {
bag_index,
slot: wire_slot,
});
continue;
}
// The readable fork: an item INSTANCE carrying `ITEM_FIELD_ITEM_TEXT_ID` (a mail-made // The readable fork: an item INSTANCE carrying `ITEM_FIELD_ITEM_TEXT_ID` (a mail-made
// permanent letter) opens the reader instead of a use — client-side, no permission // permanent letter) opens the reader instead of a use — client-side, no permission
// packet (vmangos' `CMSG_READ_ITEM` handler gates on the *template*'s PageText, which is // packet (vmangos' `CMSG_READ_ITEM` handler gates on the *template*'s PageText, which is
@@ -53,3 +53,94 @@ fn the_whole_shipped_manifest_loads_without_errors() {
s.resolve(); s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
} }
/// **The `$parentTextureFrame` idiom's contract, over the whole shipped UI**: a frame whose art is
/// meant to cap a unit frame must sit at a strictly HIGHER frame level than that frame's status
/// bars — because frame level is the only key term above the draw layer, and the layer would
/// otherwise lift an ARTWORK bar fill straight over BACKGROUND art.
///
/// Decision 0884 pinned the layer as bucket-wide and above the frame. Two files had been getting
/// this right for the wrong reason — `UnitFrames.xml` and `PartyFrame.xml` both *declared* the
/// TextureFrame after the bars and leaned on the retired key's insertion-order tie-break — so both
/// inverted the instant the key was corrected, and both reached the director's screen. The
/// reference spends a real frame level on this in both of its own spellings (`TargetFrame.lua`
/// l.32-34's explicit `SetFrameLevel(textureFrame-1)`, `PlayerFrame.xml` l.50-52's two anonymous
/// nesting frames); benilla now uses the first, everywhere.
///
/// Name-driven on purpose, so a *new* copy of the idiom is covered the day it is written rather
/// than when someone remembers to test it. A geometric sweep was run once over this same UI —
/// every substantially-overlapping cross-frame quad pair at one `(strata, level)`, i.e. every pair
/// the draw layer alone orders — and found no third instance; the surviving 45 pairs were all
/// benign (action-bar and bag-slot chrome adjacency, text over art, and a bar fill over its own
/// frame's trough, which is the intended order). It is not kept as a gate: frozen, that list would
/// churn on every action-bar edit, and a noisy gate is a gate nobody reads.
#[test]
fn every_texture_frame_outranks_its_status_bars() {
use benilla_ui::order::unpack;
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
// Every unit-frame family painting at once: the frames hide themselves without a unit, and a
// hidden frame emits no quads to read a level from.
for unit in ["player", "target", "party1", "party2", "party3", "party4"] {
s.set_unit(
unit,
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Someone".into()),
health: 60,
max_health: 100,
level: 60,
power_type: 0,
power: 60,
max_power: 100,
..benilla_ui::script::UnitState::default()
}),
);
}
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
s.resolve();
// owner frame name → its (strata, level), read off the packed draw key the renderer sorts by.
let mut level_of: std::collections::BTreeMap<String, (u8, u16)> =
std::collections::BTreeMap::new();
for q in s.extract() {
if let Some(name) = s.quad_owner_name(q.target) {
let p = unpack(q.z);
level_of.insert(name, (p.strata, p.level));
}
}
let mut checked = 0;
for (texture_frame, &(tf_strata, tf_level)) in &level_of {
let Some(base) = texture_frame.strip_suffix("TextureFrame") else {
continue;
};
for suffix in ["HealthBar", "PowerBar", "ManaBar"] {
let bar = format!("{base}{suffix}");
let Some(&(bar_strata, bar_level)) = level_of.get(&bar) else {
continue;
};
assert_eq!(
bar_strata, tf_strata,
"{bar} and {texture_frame} must share a strata for the level to decide"
);
assert!(
tf_level > bar_level,
"{texture_frame} (level {tf_level}) must outrank {bar} (level {bar_level}): \
tied, the draw layer lifts the bar's ARTWORK fill over the frame's BACKGROUND art"
);
checked += 1;
}
}
// Player + target (health/power) and four party members (health/mana) — never let this pass by
// matching nothing, which is exactly how a renamed frame would silently retire the check.
assert!(
checked >= 12,
"only {checked} texture-frame/bar pairs checked — the name sweep found nothing"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -1055,3 +1055,145 @@ fn target_frame_border_follows_the_classification_law() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
} }
/// The recessed-bar look, asserted in the draw list: the ring art's metal well-edges must paint
/// **over** the health/power fills, and the name/level text over both.
///
/// This is the shape decision 0884 broke. Our transcription used to get the ring over the bars by
/// *declaring* `$parentTextureFrame` after the StatusBars, leaning on the old draw key's
/// insertion-order tie-break between siblings at one `(strata, level)`. 0884 pinned the real law —
/// the draw **layer** is bucket-wide and outranks the frame — so the bars' ARTWORK fills started
/// painting over the TextureFrame's BACKGROUND ring art and the frames read as pasted-on slabs.
/// The fix is the reference's own mechanism (`TargetFrame.lua` l.32-34): push the bars one frame
/// level *below* the texture frame, where no layer can lift them back over it.
#[test]
fn the_ring_art_paints_over_the_bars() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Onemage".into()),
health: 60,
max_health: 100,
level: 60,
power_type: 0,
power: 60,
max_power: 100,
..UnitState::default()
}),
);
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
s.resolve();
let quads = s.extract();
// The ring art: `UI-TargetingFrame` exactly — not the `-LevelBackground` / `-Elite` siblings.
let ring = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.ends_with("UI-TargetingFrame"))
})
.expect("the player frame's ring art");
// The bar fills (both bars share the texture; take the highest z of the two).
let fill = quads
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.ends_with("UI-StatusBar"))
})
.map(|q| q.z)
.max()
.expect("a bar fill");
let name = quads
.iter()
.find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "Onemage"))
.expect("the name text");
assert!(
ring.z > fill,
"the ring art must paint OVER the bar fills (ring z={:#x}, fill z={fill:#x})",
ring.z
);
assert!(
name.z > fill,
"the name text must paint OVER the bar fills (name z={:#x}, fill z={fill:#x})",
name.z
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The same recessed-bar law for the PARTY member frames, which carry an independent copy of the
/// idiom (`PartyFrame.xml`'s `$parentTextureFrame` over `$parentHealthBar`/`$parentManaBar`).
///
/// Split from the player/target test on purpose: the two files reach the same look through separate
/// OnLoads, so one green assertion says nothing about the other. This is the copy 0884 broke in
/// silence — nobody was in a party when the director reported the player frame.
#[test]
fn the_party_art_paints_over_the_bars() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The loot test's prefix (`loot_tests.rs`): PartyFrame's inline <Script> reads
// StaticPopupDialogs, which UiPanels.xml defines, and its per-member dropdown OnLoad walks the
// whole popup kit.
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
// BENILLA_POWER_COLORS (the shared power-colour table) lives in UnitFrames.xml; the party
// frames' Update reads it. The player/target frames it also defines stay hidden here — no unit.
load_xml(&s, "UnitFrames.xml");
load_xml(&s, "PartyFrame.xml");
s.set_unit(
"party1",
Some(UnitState {
exists: true,
name: Some("Onepriest".into()),
health: 60,
max_health: 100,
level: 60,
power_type: 0,
power: 60,
max_power: 100,
..UnitState::default()
}),
);
s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
s.resolve();
let quads = s.extract();
// Scoped to member frame 1 by owner name, so a stray UI-StatusBar from any other frame in the
// manifest can never stand in for the bar under test.
let mine = |q: &benilla_ui::script::ExtractedQuad| {
s.quad_owner_name(q.target)
.is_some_and(|n| n.starts_with("PartyMemberFrame1"))
};
let art = quads
.iter()
.find(|q| {
mine(q)
&& matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.ends_with("UI-PartyFrame"))
})
.expect("the party member frame art");
let fill = quads
.iter()
.filter(|q| {
mine(q)
&& matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.ends_with("UI-StatusBar"))
})
.map(|q| q.z)
.max()
.expect("a bar fill");
assert!(
art.z > fill,
"the party art must paint OVER the bar fills (art z={:#x}, fill z={fill:#x})",
art.z
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}