/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:
@@ -22,7 +22,7 @@ use benilla_protocol::{decode, EntityKind, WorldSession, WORLD_PORT};
|
||||
use clap::Parser;
|
||||
|
||||
use probes::{
|
||||
Attack, Aura, Charge, Ctx, Death, EquipPackSlot, GiverStatus, GroundFx, Loot, Probe,
|
||||
Attack, Aura, Charge, Ctx, Death, EquipPackSlot, GiverStatus, GroundFx, Loot, OpenItem, Probe,
|
||||
QueryNames, Quest, QuestItem, QuestLog, Speed, Spells, Spirit, SwapPackSlots, UsePackSlot,
|
||||
Vendor, WorldState,
|
||||
};
|
||||
@@ -81,6 +81,14 @@ struct Cli {
|
||||
/// delta or a destroy on that item's guid (consumed), or an explicit cast-result refusal.
|
||||
#[arg(long)]
|
||||
use_pack_slot: Option<u8>,
|
||||
/// Live-verify `CMSG_OPEN_ITEM`: `.additem` a Small Barnacled Clam (entry 7973 — LOOTABLE,
|
||||
/// LockID 0, the director's own case), find it in the backpack, and run the fork a bag
|
||||
/// right-click makes for an *openable* item — `CMSG_OPEN_ITEM(bagIndex, slot)`, never
|
||||
/// `CMSG_USE_ITEM` (a clam has no on-use spell, so the use goes nowhere) — requiring
|
||||
/// `SMSG_LOOT_RESPONSE` on the **item's own guid**. Releases the window and subtracts the copy
|
||||
/// afterwards. Needs a GM account (the deploy's probes are gmlevel 6).
|
||||
#[arg(long)]
|
||||
open_item: bool,
|
||||
/// Send a `/say` line right after entering the world — the GM dot-command channel
|
||||
/// (`.additem 3732`, `.repairitems`, …) for setting up probe scenarios. Repeatable; the lines go
|
||||
/// out in order (e.g. `--say ".modify money 5000000" --say ".go creature 1"`).
|
||||
@@ -344,6 +352,9 @@ fn main() -> Result<()> {
|
||||
if let Some(n) = cli.use_pack_slot {
|
||||
probes.push(Box::new(UsePackSlot { n }));
|
||||
}
|
||||
if cli.open_item {
|
||||
probes.push(Box::new(OpenItem));
|
||||
}
|
||||
if let Some(n) = cli.equip_pack_slot {
|
||||
probes.push(Box::new(EquipPackSlot { n }));
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ mod equip_pack_slot;
|
||||
mod giverstatus;
|
||||
mod groundfx;
|
||||
mod loot;
|
||||
mod open_item;
|
||||
mod query_names;
|
||||
mod quest;
|
||||
mod quest_item;
|
||||
@@ -87,6 +88,7 @@ pub(crate) use equip_pack_slot::EquipPackSlot;
|
||||
pub(crate) use giverstatus::GiverStatus;
|
||||
pub(crate) use groundfx::GroundFx;
|
||||
pub(crate) use loot::Loot;
|
||||
pub(crate) use open_item::OpenItem;
|
||||
pub(crate) use query_names::QueryNames;
|
||||
pub(crate) use quest::Quest;
|
||||
pub(crate) use quest_item::QuestItem;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! `--open-item`: the openable-item wire — the fork a bag right-click makes when the clicked
|
||||
//! item's template carries the LOOTABLE bit (`ItemInfo::openable`). The client does NOT send
|
||||
//! `CMSG_USE_ITEM` for such an item (a clam has no on-use spell at all, so the use goes nowhere —
|
||||
//! the director's "there is no way to open clams"): it sends `CMSG_OPEN_ITEM(bagIndex, slot)`, and
|
||||
//! the server answers `SMSG_LOOT_RESPONSE` **on the item's own guid**, i.e. a loot window over a
|
||||
//! thing in your bag rather than a corpse in the world.
|
||||
//!
|
||||
//! This probe proves the whole round trip against the live server: add a clam, open it by bag
|
||||
//! position, require a loot response carrying rows on that guid, then release the window and
|
||||
//! subtract the copy so the character is left as found.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use benilla_protocol::{decode, SessionEvent};
|
||||
|
||||
use crate::probes::{Ctx, Probe};
|
||||
|
||||
/// The probe target: "Small Barnacled Clam" (entry 7973) — the director's own case. Template
|
||||
/// `Flags` carries LOOTABLE (`0x4`) with `LockID = 0`, so it is openable the moment it exists: no
|
||||
/// key, no lockpicking, no rigging. Its `item_loot_template` rows are what come back.
|
||||
const ITEM_ENTRY: u32 = 7973;
|
||||
|
||||
pub(crate) struct OpenItem;
|
||||
|
||||
impl Probe for OpenItem {
|
||||
fn stage(&mut self, cx: &mut Ctx) -> Result<()> {
|
||||
cx.session.send_chat(&format!(".additem {ITEM_ENTRY}"))?;
|
||||
println!("sent GM: .additem {ITEM_ENTRY}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify(&mut self, cx: &mut Ctx) -> Result<()> {
|
||||
let world = &mut *cx.world;
|
||||
let session = &mut *cx.session;
|
||||
|
||||
// 1) Find the clam in the backpack, and remember WHICH slot — the wire addresses the item
|
||||
// by position (bag 255 + the absolute player-array slot), never by guid.
|
||||
let sf = world
|
||||
.self_fields
|
||||
.as_ref()
|
||||
.context("no self descriptor — can't walk the backpack")?;
|
||||
let (slot0, item_guid) = (0..16)
|
||||
.filter_map(|i| sf.player_pack_slot(i).filter(|g| *g != 0).map(|g| (i, g)))
|
||||
.find(|(_, g)| world.item_entries.get(g).copied() == Some(ITEM_ENTRY))
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"--open-item: item {ITEM_ENTRY} isn't in the backpack — did `.additem` land? \
|
||||
(needs a GM account; try a longer --seconds)"
|
||||
)
|
||||
})?;
|
||||
let wire_slot = 23 + slot0;
|
||||
println!(
|
||||
"\nopenable item {ITEM_ENTRY}: guid {item_guid:#x} in pack slot {} → \
|
||||
CMSG_OPEN_ITEM bag 255 slot {wire_slot}",
|
||||
slot0 + 1
|
||||
);
|
||||
|
||||
// 2) The fork's own packet. A loot response on the ITEM's guid is the proof: nothing else
|
||||
// in the protocol opens a loot window over a bag position.
|
||||
session.open_item(255, wire_slot)?;
|
||||
let drain_until = Instant::now() + Duration::from_secs(5);
|
||||
let mut verdict: Option<Result<String, String>> = None;
|
||||
while Instant::now() < drain_until && verdict.is_none() {
|
||||
let Ok(msg) = session.recv() else { continue };
|
||||
for ev in decode(msg) {
|
||||
match ev {
|
||||
SessionEvent::LootResponse {
|
||||
guid,
|
||||
loot_type,
|
||||
gold,
|
||||
items,
|
||||
} if guid == item_guid => {
|
||||
verdict = Some(Ok(format!(
|
||||
"SMSG_LOOT_RESPONSE on the ITEM guid — type {loot_type}, {} copper, \
|
||||
{} row(s): {}",
|
||||
gold,
|
||||
items.len(),
|
||||
items
|
||||
.iter()
|
||||
.map(|i| format!("{}x{}", i.count, i.item_id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)));
|
||||
}
|
||||
SessionEvent::LootError { guid, error } if guid == item_guid => {
|
||||
verdict = Some(Err(format!("server refused with loot error {error}")));
|
||||
}
|
||||
SessionEvent::InventoryFailure { reason, .. } => {
|
||||
verdict = Some(Err(format!(
|
||||
"server refused with EQUIP_ERR {reason} (locked? dead? flying?)"
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let v = verdict
|
||||
.context("--open-item: no reaction to CMSG_OPEN_ITEM within 5s")?
|
||||
.map_err(|e| anyhow::anyhow!("--open-item: {e}"))?;
|
||||
println!("✅ open item: {v}");
|
||||
|
||||
// 3) Close the window again — an abandoned loot session would follow the character.
|
||||
session.loot_release(item_guid)?;
|
||||
let until = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < until {
|
||||
let Ok(msg) = session.recv() else { continue };
|
||||
for ev in decode(msg) {
|
||||
if let SessionEvent::LootReleaseResponse { guid } = ev {
|
||||
if guid == item_guid {
|
||||
println!("✅ release: SMSG_LOOT_RELEASE_RESPONSE on {guid:#x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Leave the probe character as found (the clam survives an un-emptied open).
|
||||
session.send_chat(&format!(".additem {ITEM_ENTRY} -1"))?;
|
||||
println!("cleanup: .additem {ITEM_ENTRY} -1");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -191,8 +191,45 @@ impl ItemInfo {
|
||||
pub fn placeable_on_action_bar(&self) -> bool {
|
||||
self.use_spell.is_some() || self.inventory_type != 0
|
||||
}
|
||||
|
||||
/// Does a right-click **open** this concrete instance rather than use it? `instance_flags` is
|
||||
/// the item object's `ITEM_FIELD_FLAGS` (0 for a template-only view, which can never be
|
||||
/// openable — you can't right-click a hyperlink).
|
||||
///
|
||||
/// The one predicate behind both halves of the affordance: the tooltip's green
|
||||
/// `<Right Click to Open>` line and the `CMSG_OPEN_ITEM` fork. It is the reference's own
|
||||
/// tooltip condition (wow-re `tooltip-content-law.md` §1-OPENABLE, byte-read at
|
||||
/// `0x52e2f8`–`0x52e321`): the template LOOTABLE bit [`ITEM_FLAG_LOOTABLE`] behind its lock
|
||||
/// sub-gate — a [`ItemInfo::lock_id`] item only opens once the instance carries
|
||||
/// [`ITEM_DYNFLAG_UNLOCKED`] — **or** a wrapped gift ([`ITEM_FLAG_WRAPPER`] on the template
|
||||
/// plus [`ITEM_DYNFLAG_WRAPPED`] on the instance). vmangos's `HandleOpenItemOpcode` gates on
|
||||
/// exactly the same pair, so the two ends agree: what the tooltip promises is what the server
|
||||
/// will honour, and a still-locked junkbox neither advertises the line nor sends the packet.
|
||||
pub fn openable(&self, instance_flags: u32) -> bool {
|
||||
let lootable = self.flags & ITEM_FLAG_LOOTABLE != 0
|
||||
&& (self.lock_id == 0 || instance_flags & ITEM_DYNFLAG_UNLOCKED != 0);
|
||||
let gift =
|
||||
self.flags & ITEM_FLAG_WRAPPER != 0 && instance_flags & ITEM_DYNFLAG_WRAPPED != 0;
|
||||
lootable || gift
|
||||
}
|
||||
}
|
||||
|
||||
/// `ITEM_FLAG_LOOTABLE` — the **template** bit that makes an item right-clickable into a loot
|
||||
/// window (a clam, a lockbox, a Gnomish Mind Control Cap's box). vmangos
|
||||
/// `ItemPrototype.h:66`, whose own comment names the client behaviour this drives: "It or lockid
|
||||
/// set enable for client show 'Right click to open'".
|
||||
pub const ITEM_FLAG_LOOTABLE: u32 = 0x0000_0004;
|
||||
/// `ITEM_FLAG_WRAPPER` — the template bit for gift wrapping (vmangos `ItemPrototype.h:73`); paired
|
||||
/// with [`ITEM_DYNFLAG_WRAPPED`] on the instance it makes the wrapped present openable.
|
||||
pub const ITEM_FLAG_WRAPPER: u32 = 0x0000_0200;
|
||||
/// `ITEM_DYNFLAG_UNLOCKED` — the **instance** (`ITEM_FIELD_FLAGS`) bit a lockbox gains once it has
|
||||
/// been picked/keyed open; until then a `LockID` item refuses to open (vmangos
|
||||
/// `HandleOpenItemOpcode`'s `EQUIP_ERR_ITEM_LOCKED`).
|
||||
pub const ITEM_DYNFLAG_UNLOCKED: u32 = 0x0000_0004;
|
||||
/// `ITEM_DYNFLAG_WRAPPED` — the instance bit on a gift-wrapped item; opening it unwraps to the
|
||||
/// present (vmangos swaps the entry from `character_gifts` rather than sending loot).
|
||||
pub const ITEM_DYNFLAG_WRAPPED: u32 = 0x0000_0008;
|
||||
|
||||
/// One `Damage` block ([`ItemInfo::damages`] — block 0 is also mirrored into
|
||||
/// [`ItemInfo::dmg_min`]/[`ItemInfo::dmg_max`]/[`ItemInfo::dmg_type`]).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -508,6 +545,18 @@ pub fn use_item(bag_index: u8, slot: u8, spell_slot: u8, go_target: Option<u64>)
|
||||
body
|
||||
}
|
||||
|
||||
/// Body of `CMSG_OPEN_ITEM` (VERIFIED vmangos `OpenItem::ReadFromWorldPacket`,
|
||||
/// `Server/Packets/Spell.cpp` + `.h:36-45`; opcode 172 `Opcodes_1_12_1.h:175`): `bagIndex`, `slot`
|
||||
/// — the same two bytes and the same bag addressing as [`use_item`], and nothing else (no spell
|
||||
/// ordinal, no targets block: opening is not a cast).
|
||||
///
|
||||
/// The right-click fork for an [`ItemInfo::openable`] item. The server answers on the **item's own
|
||||
/// guid**: `SendLoot(item, LOOT_CORPSE)` for a lootable, or — for a wrapped gift — an entry swap
|
||||
/// and no window at all.
|
||||
pub fn open_item(bag_index: u8, slot: u8) -> Vec<u8> {
|
||||
vec![bag_index, slot]
|
||||
}
|
||||
|
||||
/// Body of `CMSG_AUTOEQUIP_ITEM` (VERIFIED vmangos `AutoEquipItem::ReadFromWorldPacket`,
|
||||
/// `Server/Packets/Item.cpp:17-21` + `.h:31-39`; opcode 266 `Opcodes_1_12_1.h:269`): source
|
||||
/// `srcbag`/`srcslot` (both `uint8`), the same bag addressing as [`use_item`]. The real client
|
||||
|
||||
@@ -105,9 +105,10 @@ pub use group::{
|
||||
PartyMemberStatsInfo, RaidTargetUpdate, ReadyCheck, GROUP_MEMBER_ASSISTANT,
|
||||
};
|
||||
pub use items::{
|
||||
auto_equip_item, auto_store_bag_item, destroy_item, item_query, set_ammo, split_item,
|
||||
swap_inv_item, swap_item, use_item, ItemDamage, ItemInfo, ItemSpellEntry, ItemUseSpell,
|
||||
BAG_PLAYER_INVENTORY, SLOT_BAG_FIRST, SLOT_PACK_FIRST,
|
||||
auto_equip_item, auto_store_bag_item, destroy_item, item_query, open_item, set_ammo,
|
||||
split_item, swap_inv_item, swap_item, use_item, ItemDamage, ItemInfo, ItemSpellEntry,
|
||||
ItemUseSpell, BAG_PLAYER_INVENTORY, ITEM_DYNFLAG_UNLOCKED, ITEM_DYNFLAG_WRAPPED,
|
||||
ITEM_FLAG_LOOTABLE, ITEM_FLAG_WRAPPER, SLOT_BAG_FIRST, SLOT_PACK_FIRST,
|
||||
};
|
||||
pub use loot::{
|
||||
autostore_loot_item, loot, loot_error, loot_money, loot_release, loot_roll, loot_type,
|
||||
|
||||
@@ -218,6 +218,12 @@ pub const CMSG_GAMEOBJECT_QUERY: u16 = 0x005E; // 94
|
||||
pub const CMSG_QUEST_QUERY: u16 = 0x005C; // 92
|
||||
pub const CMSG_ITEM_QUERY_SINGLE: u16 = 0x0056; // 86
|
||||
pub const CMSG_USE_ITEM: u16 = 0x00AB; // 171
|
||||
/// VERIFIED vmangos `Opcodes_1_12_1.h:175`: 172. Body in [`super::items::open_item`] — the bag
|
||||
/// position of an *openable* item (a clam, an unlocked lockbox, a wrapped gift). The right-click
|
||||
/// fork `CMSG_USE_ITEM` never takes: the server answers `SMSG_LOOT_RESPONSE` on the **item's own
|
||||
/// guid** (`HandleOpenItemOpcode` → `SendLoot(item, LOOT_CORPSE)`), so the loot window opens over
|
||||
/// a thing in your bag rather than a corpse in the world.
|
||||
pub const CMSG_OPEN_ITEM: u16 = 0x00AC; // 172
|
||||
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 177. Body in [`super::gameobj_use`] — a full guid, the
|
||||
/// GameObject to use (decision 0236). Not interchangeable with `CMSG_LOOT`: the server rejects a
|
||||
/// GameObject guid on `CMSG_LOOT`, so a chest opens its loot through this opcode.
|
||||
|
||||
@@ -360,6 +360,15 @@ impl WorldSession {
|
||||
)
|
||||
}
|
||||
|
||||
/// Open an item by bag position (`CMSG_OPEN_ITEM`) — the unsplit twin of
|
||||
/// [`WorldWriter::open_item`], for the probe's live open-item verification.
|
||||
pub fn open_item(&mut self, bag_index: u8, slot: u8) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_OPEN_ITEM,
|
||||
&messages::open_item(bag_index, slot),
|
||||
)
|
||||
}
|
||||
|
||||
/// Equip a bag item (`CMSG_AUTOEQUIP_ITEM`) — the unsplit twin of
|
||||
/// [`WorldWriter::auto_equip_item`], for the probe's live equip verification.
|
||||
pub fn auto_equip_item(&mut self, bag_index: u8, slot: u8) -> Result<()> {
|
||||
|
||||
@@ -43,6 +43,18 @@ impl WorldWriter {
|
||||
)
|
||||
}
|
||||
|
||||
/// Open an item by bag position (`CMSG_OPEN_ITEM`, layout in [`messages::open_item`]) — crack
|
||||
/// the clam, empty the picked lockbox, unwrap the gift. The right-click fork for an
|
||||
/// [`crate::ItemInfo::openable`] item; the server answers with `SMSG_LOOT_RESPONSE` on the
|
||||
/// item's **own** guid (so the loot window opens over a thing in the bag), or an equip error
|
||||
/// on a refusal (still locked, dead, flying).
|
||||
pub fn open_item(&mut self, bag_index: u8, slot: u8) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_OPEN_ITEM,
|
||||
&messages::open_item(bag_index, slot),
|
||||
)
|
||||
}
|
||||
|
||||
/// Equip a bag item (`CMSG_AUTOEQUIP_ITEM`, layout in [`messages::auto_equip_item`]) — the
|
||||
/// server picks the destination slot. Success arrives as inventory-slot values deltas (and the
|
||||
/// visible-item change everyone renders); refusal as `SMSG_INVENTORY_CHANGE_FAILURE`.
|
||||
|
||||
@@ -42,6 +42,15 @@ fn item_query_wire() {
|
||||
// CMSG_AUTOEQUIP_ITEM (vmangos AutoEquipItem::ReadFromWorldPacket): bagIndex, slot.
|
||||
assert_eq!(messages::auto_equip_item(255, 25), hx("ff19"));
|
||||
|
||||
// CMSG_OPEN_ITEM (vmangos OpenItem::ReadFromWorldPacket, Server/Packets/Spell.cpp:19-23):
|
||||
// bagIndex, slot — and NOTHING else (no spell ordinal, no targets block; opening is not a
|
||||
// cast). The same two bytes as auto-equip under a different opcode: this is the fork a
|
||||
// clam/lockbox/gift takes instead of USE_ITEM, and the only one the server answers with
|
||||
// SendLoot on the item's own guid. Backpack slot 3 = bag 255 + player-array slot 25; an item
|
||||
// inside an equipped bag addresses that bag's own player-array slot (19..22) + inner slot.
|
||||
assert_eq!(messages::open_item(255, 25), hx("ff19"));
|
||||
assert_eq!(messages::open_item(19, 0), hx("1300"));
|
||||
|
||||
// SMSG_INVENTORY_CHANGE_FAILURE (vmangos InventoryChangeFailure::AppendBodyTo): the level
|
||||
// refusal (reason 1) carries a u32 required level before the guids; others don't.
|
||||
let level_fail = messages::parse_server(
|
||||
|
||||
@@ -306,6 +306,53 @@ impl ZKey {
|
||||
pub const fn raw(self) -> u64 {
|
||||
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
|
||||
/// 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*
|
||||
/// the total draw order (strata → **layer** → frame link-stamp → is-region → sub-level →
|
||||
/// texture<fontstring → decl; the layer outranks the frame — see the `ZKey` bit-layout note and
|
||||
/// decision 0884). Ties are impossible: distinct frames differ in link-stamp, a frame and its
|
||||
/// the total draw order (strata → frame level → **draw layer** → texture<fontstring → frame
|
||||
/// link-stamp → is-region → sub-level → decl; the layer outranks the frame — see the `ZKey`
|
||||
/// 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.
|
||||
///
|
||||
/// 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 —
|
||||
/// 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
|
||||
/// yields to the instance UNLOCKED bit — and ITEM_OPENABLE never emits from `SetBagItem` (a
|
||||
/// p6=1 source: the `0x52e2e8` bypass), so the unlocked chest shows its bare name.
|
||||
/// yields to the instance UNLOCKED bit — which is also what un-gates ITEM_OPENABLE: a bag hover
|
||||
/// 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]
|
||||
fn instance_tail_creator_and_readable() {
|
||||
let mut s = script();
|
||||
@@ -458,16 +459,26 @@ fn instance_tail_creator_and_readable() {
|
||||
flags,
|
||||
..Default::default()
|
||||
};
|
||||
s.set_item_template(
|
||||
7973,
|
||||
ItemTemplateView {
|
||||
name: "Small Barnacled Clam".into(),
|
||||
quality: 1,
|
||||
flags: 0x4,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let mut slots = HashMap::new();
|
||||
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(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(
|
||||
0,
|
||||
Some(ContainerState {
|
||||
name: Some("Backpack".into()),
|
||||
num_slots: 4,
|
||||
num_slots: 5,
|
||||
slots,
|
||||
}),
|
||||
);
|
||||
@@ -502,11 +513,18 @@ fn instance_tail_creator_and_readable() {
|
||||
vec!["Heavy Chest", "Locked", "|cff00ff00<Made by Geoffrey>|r"],
|
||||
"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
|
||||
// (SetBagItem is p6=1; only send-mail/auction/buyback sources reach the emitter in 5875).
|
||||
// The instance UNLOCKED bit retires the LOCKED line AND satisfies the openable lock sub-gate.
|
||||
s.run(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); TT:SetBagItem(0, 4)"#)
|
||||
.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();
|
||||
assert_eq!(texts, vec!["Heavy Chest"]);
|
||||
assert_eq!(texts, vec!["Small Barnacled Clam", "<Right Click to Open>"]);
|
||||
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(),
|
||||
has_text: false,
|
||||
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,
|
||||
},
|
||||
)
|
||||
@@ -375,8 +378,11 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
creator: s.creator.clone(),
|
||||
has_text: s.readable,
|
||||
flags: s.flags,
|
||||
// p6=1 in the ref — SetBagItem can never emit OPENABLE (§1-OPENABLE).
|
||||
openable_source: false,
|
||||
// The bag hover DOES emit `<Right Click to Open>` — director-observed
|
||||
// (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])),
|
||||
|
||||
@@ -29,11 +29,24 @@ pub(super) struct ItemInstance {
|
||||
/// Instance `ITEM_FIELD_FLAGS` — the openable lock sub-gate reads UNLOCKED `0x4`
|
||||
/// (`0x52e30c`) and the wrapped-gift arm WRAPPED `0x8` (`0x52e31d`).
|
||||
pub flags: u32,
|
||||
/// The ref's `p6=0` source class (wow-re §1-OPENABLE, byte-census 2026-07-20): ITEM_OPENABLE
|
||||
/// is reachable ONLY from object-present p6=0 sources (SetSendMailItem, SetAuctionSellItem,
|
||||
/// SetBuybackItem) — `SetBagItem`/`SetInventoryItem` pass p6=1 and a `[this+0x440]` gate
|
||||
/// (`0x52e2e8`) bypasses the openable tree entirely. Every current benilla source is p6=1
|
||||
/// or template-only, so this stays `false` until those tooltips carry real instances.
|
||||
/// May this source emit the ITEM_OPENABLE line at all?
|
||||
///
|
||||
/// wow-re §1-OPENABLE (byte-census 2026-07-20) reads a `[this+0x440]` gate at `0x52e2e8` as
|
||||
/// bypassing the whole openable tree for every `p6=1` binding, and concludes that
|
||||
/// `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,
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,13 @@
|
||||
frame.portrait = getglobal(name .. "Portrait")
|
||||
frame.healthBar = getglobal(name .. "HealthBar")
|
||||
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.leaderIcon = getglobal(tf .. "LeaderIcon")
|
||||
frame.masterIcon = getglobal(tf .. "MasterIcon")
|
||||
@@ -257,12 +264,18 @@
|
||||
<!-- 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).
|
||||
|
||||
LAYERING (the ref's law, in our z-model's idiom): the ref nests the frame art two anonymous
|
||||
child frames deep so it draws OVER the status bars — the bars show through the art's
|
||||
semi-transparent tube windows, inset, their ends clipped by the rails. Ours is the
|
||||
UnitFrames.xml idiom: a $parentTextureFrame child declared AFTER the bars. The name and the
|
||||
leader/master/disconnect icons ride in it too (the ref keeps them in that nested frame;
|
||||
left below the art, the ring would swallow the leader crown). -->
|
||||
LAYERING (the ref's law): the ref nests the frame art two anonymous child frames deep so it
|
||||
draws OVER the status bars — the bars show through the art's semi-transparent tube windows,
|
||||
inset, their ends clipped by the rails. Ours is the UnitFrames.xml idiom, which reaches the
|
||||
same place by the ref's OTHER route (its TargetFrame's): a $parentTextureFrame child, with
|
||||
OnLoad pushing the bars one frame level BELOW it. Both spend a frame LEVEL, which is the
|
||||
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,
|
||||
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
|
||||
|
||||
@@ -12,12 +12,20 @@
|
||||
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 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
|
||||
frame *insertion* order (crates/benilla-ui/src/order.rs), so we get the same result structurally
|
||||
and without SetFrameLevel: the parent holds trough + portrait (+ target's name plate) in
|
||||
BACKGROUND; the `$parentHealthBar`/`$parentPowerBar` StatusBars come next; then
|
||||
textureFrame-1)` to push the bars beneath it, and we do the same, in BenillaUnitFrame_OnLoad:
|
||||
the parent holds trough + portrait (+ target's name plate) in BACKGROUND; the
|
||||
`$parentHealthBar`/`$parentPowerBar` StatusBars sit at the PARENT's frame level; and
|
||||
`$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
|
||||
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
|
||||
@@ -129,6 +137,13 @@
|
||||
-- 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.
|
||||
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.levelText = getglobal(tf .. "LevelText")
|
||||
frame.deadText = getglobal(tf .. "DeadText")
|
||||
@@ -752,8 +767,11 @@
|
||||
</Anchors>
|
||||
<Color r="0" g="0" b="0" a="0.5"/>
|
||||
</Texture>
|
||||
<!-- Portrait 64×64 TOPLEFT (42,-12) (ref l.35-46, moved ARTWORK→BACKGROUND so the ring
|
||||
art draws over it). Texture set in OnLoad via SetPortraitToTexture (circle-masked). -->
|
||||
<!-- Portrait 64×64 TOPLEFT (42,-12) (ref l.35-46, which puts it in ARTWORK; ours sits
|
||||
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">
|
||||
<Size><AbsDimension x="64" y="64"/></Size>
|
||||
<Anchors>
|
||||
@@ -761,8 +779,9 @@
|
||||
</Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
<!-- The ring art + name/level text live in $parentTextureFrame below (declared AFTER the
|
||||
bars) so they draw OVER the bar edges — the recessed-bar look. See the LAYERING note. -->
|
||||
<!-- The ring art + name/level text live in $parentTextureFrame below, one frame level
|
||||
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>
|
||||
<Frames>
|
||||
<!-- 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"/>
|
||||
</StatusBar>
|
||||
<!-- 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
|
||||
insertion-order equivalent of the ref's SetFrameLevel(bar, textureFrame-1). -->
|
||||
parent; it draws over the bar edges because OnLoad pushes the bars one frame level
|
||||
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">
|
||||
<Size><AbsDimension x="232" y="100"/></Size>
|
||||
<Anchors>
|
||||
@@ -965,9 +985,10 @@
|
||||
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
|
||||
<BarColor r="0.0" g="0.0" b="1.0"/>
|
||||
</StatusBar>
|
||||
<!-- The ring art + text, over the bars (ref TargetFrameTextureFrame); declared after the
|
||||
StatusBars so it draws over their edges (our z-model's insertion-order equivalent of
|
||||
the ref's SetFrameLevel(bar, textureFrame-1)). -->
|
||||
<!-- The ring art + text, over the bars (ref TargetFrameTextureFrame); it draws over the
|
||||
bar edges because OnLoad pushes the bars one frame level 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">
|
||||
<Size><AbsDimension x="232" y="100"/></Size>
|
||||
<Anchors>
|
||||
|
||||
@@ -85,13 +85,14 @@
|
||||
//! [`crate::model_fade::ParentModel`] link up to the unit — the recursion itself, and the same walk
|
||||
//! `ModelAlphas` already does for alpha.
|
||||
//!
|
||||
//! **Proc 11 ships too** (decision 0889): the **freeze**. Its param is a playback *rate* written onto
|
||||
//! the unit's own animation clocks (`0x60db7e` → `SetBoneAnimSpeed 0x712910` on the mount's bone 0,
|
||||
//! the body's key-bone 4 — the upper-body split — and the body's bone 0), old rates saved in the node
|
||||
//! and written back when it expires (`0x6203e0`). Rate 0 is Ice Block: the pose holds exactly where
|
||||
//! it was, *including* a cast one-shot that had just been armed, which is why the caster never gets
|
||||
//! to raise their hand. [`apply_aura_anim_rate`] is that leg, and the reference's save/restore is
|
||||
//! [`AnimRateFreeze`].
|
||||
//! **Proc 11 ships too** (decisions 0889 + 0891): the **freeze**. Its param is a playback *rate*
|
||||
//! written onto the unit's own animation clocks (`0x60db7e` → `SetBoneAnimSpeed 0x712910` on the
|
||||
//! mount's bone 0, the body's key-bone 4 — the upper-body split — and the body's bone 0), old rates
|
||||
//! saved in the node and written back when it expires (`0x6203e0`). Rate 0 is Ice Block: the pose
|
||||
//! holds exactly where it was, *including* a cast one-shot that had just been armed, which is why the
|
||||
//! caster never gets to raise their hand. [`apply_aura_anim_rate`] is that leg — and it expresses
|
||||
//! 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
|
||||
//! "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
|
||||
/// 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).
|
||||
/// Marker: this rig's clocks are being held by a proc-11 aura, so the release knows to let them go.
|
||||
///
|
||||
/// Keyed by animation node because that is what a speed belongs to here: the reference saves per
|
||||
/// *bone* because a rate is a bone-clock property there, and ours is per armed clip.
|
||||
/// It carries **nothing**, and that is the fix for the first shipped version's bug. That one stored
|
||||
/// 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)]
|
||||
pub(crate) struct AnimRateFreeze {
|
||||
saved: Vec<(AnimationNodeIndex, f32)>,
|
||||
}
|
||||
pub(crate) struct AnimRateFreeze;
|
||||
|
||||
/// Hold every unit whose head proc-11 node says so at that node's rate — its own rig and its mount's
|
||||
/// (`[unit+0xd8]` and `[unit+0xdc]`, the two models `0x6201d0` writes) — and hand the speeds back
|
||||
/// when the aura goes.
|
||||
/// 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 let them go when the aura
|
||||
/// 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
|
||||
/// 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
|
||||
/// 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
|
||||
/// across every re-arm in between, and re-writing it each frame is how a per-clip speed says the
|
||||
/// same thing.
|
||||
/// across every re-arm in between, and re-pausing each frame is how a per-clip flag says the same
|
||||
/// 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
|
||||
/// 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
|
||||
/// holds the pose exactly where it stood).
|
||||
/// gets to raise their hand.
|
||||
///
|
||||
/// **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
|
||||
/// `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,
|
||||
Option<&crate::entities::mount::MountChild>,
|
||||
)>,
|
||||
mut rigs: Query<(&mut AnimationPlayer, Option<&mut AnimRateFreeze>)>,
|
||||
mut rigs: Query<(&mut AnimationPlayer, Has<AnimRateFreeze>)>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
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() {
|
||||
let Ok((mut player, freeze)) = rigs.get_mut(rig) else {
|
||||
let Ok((mut player, held)) = rigs.get_mut(rig) else {
|
||||
continue;
|
||||
};
|
||||
match (rate, freeze) {
|
||||
(Some(rate), Some(mut freeze)) => hold(&mut player, &mut freeze, rate),
|
||||
(Some(rate), None) => {
|
||||
let mut fresh = AnimRateFreeze::default();
|
||||
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);
|
||||
}
|
||||
match (frozen, held) {
|
||||
(true, _) => {
|
||||
for (_, anim) in player.playing_animations_mut() {
|
||||
anim.pause();
|
||||
}
|
||||
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>();
|
||||
}
|
||||
(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 clips it took over and the speeds it is holding — the difference between "the clocks 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) {
|
||||
/// the clips it took over and the speed each is *left* running at — the difference between "the clocks
|
||||
/// stopped" and "nothing was playing anyway", which no count of *absent* anim events can tell apart.
|
||||
///
|
||||
/// 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") {
|
||||
return;
|
||||
}
|
||||
let clips: Vec<String> = freeze
|
||||
.saved
|
||||
.iter()
|
||||
.map(|(n, s)| format!("{}@{s}", n.index()))
|
||||
let clips: Vec<String> = player
|
||||
.playing_animations()
|
||||
.map(|(n, a)| format!("{}@{}", n.index(), a.speed()))
|
||||
.collect();
|
||||
crate::dbg_trace::line(
|
||||
"aur",
|
||||
&format!("{what} rig={rig} rate={rate} clips=[{}]", clips.join(" ")),
|
||||
&format!("{what} rig={rig} clips=[{}]", clips.join(" ")),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
/// 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();
|
||||
for (node, speed) in speeds {
|
||||
player
|
||||
.play(AnimationNodeIndex::new(node as usize))
|
||||
.set_speed(speed);
|
||||
.play(AnimationNodeIndex::new(*node as usize))
|
||||
.set_speed(*speed);
|
||||
}
|
||||
player
|
||||
}
|
||||
|
||||
fn speed_of(app: &App, rig: Entity, node: u32) -> f32 {
|
||||
app.world()
|
||||
/// `(paused, speed)` of one armed clip — the two things the freeze is judged on: the clock is held,
|
||||
/// and the *value* other code copies is untouched.
|
||||
fn clip(app: &App, rig: Entity, node: u32) -> (bool, f32) {
|
||||
let a = app
|
||||
.world()
|
||||
.entity(rig)
|
||||
.get::<AnimationPlayer>()
|
||||
.expect("the rig kept its player")
|
||||
.animation(AnimationNodeIndex::new(node as usize))
|
||||
.expect("the clip is still armed")
|
||||
.speed()
|
||||
.expect("the clip is still armed");
|
||||
(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 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
|
||||
/// their hand. Dropping the aura hands each clip back the speed the driver had given it, not a
|
||||
/// blanket 1.0.
|
||||
/// their hand. Dropping the aura lets both go again, at the speeds the driver gave them.
|
||||
#[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 unit = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
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();
|
||||
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");
|
||||
// 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!(speed_of(&app, unit, 4), 0.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, 4).0, "the gait stops mid-stride");
|
||||
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
|
||||
// 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);
|
||||
app.update(); // idempotent
|
||||
|
||||
// The aura leaves the slots: the reference writes the saved rates back (`0x6203e0`).
|
||||
// The aura leaves the slots.
|
||||
app.world_mut()
|
||||
.entity_mut(unit)
|
||||
.insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[(
|
||||
@@ -836,41 +839,53 @@ fn ice_block_holds_the_clocks_and_the_drop_hands_them_back() {
|
||||
.unwrap()
|
||||
.rate
|
||||
.is_empty());
|
||||
assert_eq!(
|
||||
speed_of(&app, unit, 4),
|
||||
1.35,
|
||||
"the gait's own rate, restored"
|
||||
);
|
||||
assert_eq!(speed_of(&app, unit, 54), 1.0);
|
||||
assert!(!clip(&app, unit, 4).0, "running again");
|
||||
assert!(!clip(&app, unit, 54).0);
|
||||
assert_eq!(clip(&app, unit, 4).1, 1.35, "at its own rate");
|
||||
assert!(
|
||||
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
|
||||
/// back what the driver meant for it rather than the frozen 0.
|
||||
/// **The bug the first shipped version had, pinned so it cannot come back.** A one-shot moved to the
|
||||
/// 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]
|
||||
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 unit = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
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();
|
||||
app.update();
|
||||
|
||||
// The driver arms Stand at 1.0 a frame later (the root wiped the direction bits).
|
||||
app.world_mut()
|
||||
.entity_mut(unit)
|
||||
.get_mut::<AnimationPlayer>()
|
||||
// `transplant_up`'s own shape: read the frozen source clip, arm the torso twin from it.
|
||||
let speed = clip(&app, unit, 54).1;
|
||||
assert_eq!(speed, 1.0, "the source's speed is not the freeze");
|
||||
let seek = app
|
||||
.world()
|
||||
.entity(unit)
|
||||
.get::<AnimationPlayer>()
|
||||
.unwrap()
|
||||
.play(AnimationNodeIndex::new(0))
|
||||
.set_speed(1.0);
|
||||
.animation(AnimationNodeIndex::new(54))
|
||||
.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();
|
||||
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()
|
||||
.entity_mut(unit)
|
||||
@@ -878,7 +893,12 @@ fn a_clip_armed_under_the_freeze_is_saved_before_it_is_held() {
|
||||
95, 0,
|
||||
)])));
|
||||
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]`,
|
||||
@@ -888,20 +908,20 @@ fn the_freeze_reaches_the_mount_body() {
|
||||
let mut app = app_with_freeze();
|
||||
let mount = app
|
||||
.world_mut()
|
||||
.spawn(rig_playing([(5, 2.0), (91, 1.0)]))
|
||||
.spawn(rig_playing(&[(5, 2.0), (91, 1.0)]))
|
||||
.id();
|
||||
let unit = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
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),
|
||||
))
|
||||
.id();
|
||||
app.update();
|
||||
|
||||
assert_eq!(speed_of(&app, unit, 91), 0.0, "the rider");
|
||||
assert_eq!(speed_of(&app, mount, 5), 0.0, "and the mount under them");
|
||||
assert!(clip(&app, unit, 91).0, "the rider");
|
||||
assert!(clip(&app, mount, 5).0, "and the mount under them");
|
||||
|
||||
app.world_mut()
|
||||
.entity_mut(unit)
|
||||
@@ -909,7 +929,8 @@ fn the_freeze_reaches_the_mount_body() {
|
||||
95, 0,
|
||||
)])));
|
||||
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,
|
||||
@@ -921,10 +942,27 @@ fn an_aura_without_the_proc_never_touches_a_clock() {
|
||||
.world_mut()
|
||||
.spawn((
|
||||
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();
|
||||
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());
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
/// forces a fresh selection (first eval, or after a Special state exits).
|
||||
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
|
||||
/// (`[unit+0xd40]`, decision 0080): what the weapon placement renders (absent a
|
||||
/// [`VisualSheath`] ceremony pin) and what the setter/reconcile test against. Seeded from
|
||||
@@ -661,6 +669,7 @@ impl Default for AnimDriver {
|
||||
Self {
|
||||
mode: Mode::Gait,
|
||||
gait: None,
|
||||
gait_flags: 0,
|
||||
sheath_cur: None,
|
||||
sheath_byte: None,
|
||||
sheath_swap: None,
|
||||
|
||||
@@ -845,11 +845,7 @@ pub(super) fn drive_animations(
|
||||
if let Some((c, repeat)) = picked {
|
||||
play_clip(&mut tr, &mut player, c, repeat, 1.0);
|
||||
}
|
||||
drv.mode = Mode::Swing {
|
||||
id,
|
||||
flags: mv.flags,
|
||||
under: special,
|
||||
};
|
||||
drv.mode = Mode::Swing { id, under: special };
|
||||
drv.gait = None;
|
||||
base_played = true;
|
||||
played_oneshot = Some(id);
|
||||
@@ -1044,7 +1040,7 @@ pub(super) fn drive_animations(
|
||||
drv.gait = None; // recompute a fresh gait next frame
|
||||
}
|
||||
}
|
||||
Mode::Swing { id, flags, under } => {
|
||||
Mode::Swing { id, under } => {
|
||||
if special != under {
|
||||
// The state this one-shot replaced CHANGED — the client's next event play
|
||||
// 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);
|
||||
}
|
||||
} else if oneshot_finished(&player, anims, id, catalog) || mv.flags != flags {
|
||||
// Finished — or a movement-flag change: the client's locomotion 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
|
||||
} else if oneshot_finished(&player, anims, id, catalog)
|
||||
|| mv.flags != drv.gait_flags
|
||||
{
|
||||
// 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
|
||||
// post-shot runner over the ground on straight legs (director-observed vs
|
||||
// ref). An EDGE, not a level — the split-boneless masked fallback enters
|
||||
// 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
|
||||
// cache clears with it (decision 0406; a finished clip instead had its
|
||||
// cache consumed by the injection above, before this machine ran).
|
||||
drv.deferred = None;
|
||||
// …and it is a LOCOMOTION request, so a still-playing CAST/COMBAT clip
|
||||
// transplants up to the key-bone instead of being overwritten (decision
|
||||
// 0878). A *finished* clip has nothing to move: the client's descriptor
|
||||
// probe reports a completed slot as id −1 (`0x5fe1f0` reads the completion
|
||||
// latch, not the armed record), so the transplant predicates never see it.
|
||||
transplant_up(&mut drv, &mut player, &tr, anims, entity, id);
|
||||
// …and **if** the re-arm resolves to a LOCOMOTION id, a still-playing
|
||||
// CAST/COMBAT clip transplants up to the key-bone instead of being
|
||||
// overwritten (decision 0878; the client's gate is `0x5fee80` on the
|
||||
// *requested* id, `0x5fe912`). A *finished* clip has nothing to move: the
|
||||
// client's descriptor probe reports a completed slot as id −1 (`0x5fe1f0`
|
||||
// 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.gait = None; // recompute a fresh gait next frame
|
||||
@@ -1377,6 +1395,11 @@ pub(super) fn drive_animations(
|
||||
} else {
|
||||
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 —
|
||||
// the next shooting session opens with a fresh nock.
|
||||
if ranged_load.is_none() {
|
||||
|
||||
@@ -649,7 +649,6 @@ fn same_frame_collision_fast_paths_the_second_combat_clip() {
|
||||
drv(&app, spin_last).mode,
|
||||
super::super::select::Mode::Swing {
|
||||
id: 16,
|
||||
flags: 0,
|
||||
under: None,
|
||||
},
|
||||
"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,
|
||||
super::super::select::Mode::Swing {
|
||||
id: 57,
|
||||
flags: 0,
|
||||
under: None,
|
||||
},
|
||||
"the spin holds the body through the later swing"
|
||||
@@ -712,7 +710,6 @@ fn deferred_combat_clip_plays_once_the_body_frees() {
|
||||
drv.mode,
|
||||
super::super::select::Mode::Swing {
|
||||
id: 16,
|
||||
flags: 0,
|
||||
under: None,
|
||||
},
|
||||
"the parked swing armed"
|
||||
@@ -779,7 +776,6 @@ fn a_movement_flag_change_cuts_a_full_body_oneshot_immediately() {
|
||||
mode(&app),
|
||||
super::super::select::Mode::Swing {
|
||||
id: 16,
|
||||
flags: 0,
|
||||
under: None,
|
||||
},
|
||||
"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,
|
||||
super::super::select::Mode::Swing {
|
||||
id: 54,
|
||||
flags: move_flags::FALLING,
|
||||
under: Some(super::super::select::Special::Jump),
|
||||
},
|
||||
"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:?})"
|
||||
);
|
||||
}
|
||||
|
||||
/// **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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -405,18 +405,16 @@ pub(super) enum Mode {
|
||||
/// *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
|
||||
/// 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
|
||||
/// [`Mode::Land`], any movement-flag change from `flags` (captured at play time) re-picks
|
||||
/// immediately: the client's locomotion re-arm overwrites bone 0 blindly on the change (the
|
||||
/// same re-arm decision 0280 named), so a shot fired standing yields to the run the instant
|
||||
/// the player moves instead of sliding the clip out. The *masked* route never enters here —
|
||||
/// it plays as an overlay beside `Mode`, leaving the base machine (this enum) running
|
||||
/// underneath.
|
||||
Swing {
|
||||
id: u16,
|
||||
flags: u32,
|
||||
under: Option<Special>,
|
||||
},
|
||||
/// (`under == None`) it returns to Gait when it finishes; a new one restarts it — and any
|
||||
/// movement-flag change since the **base** was last armed
|
||||
/// ([`AnimDriver::gait_flags`](crate::creature_anim::AnimDriver)) re-picks immediately: the
|
||||
/// client's base re-arm overwrites bone 0 blindly on the change (the same re-arm decision 0280
|
||||
/// named), so a shot fired standing yields to the run the instant the player moves instead of
|
||||
/// sliding the clip out. It carried its own arm-time flags until 0894, which made the edge
|
||||
/// invisible whenever the one-shot and the movement change landed on the same frame. The
|
||||
/// *masked* route never enters here — it plays as an overlay beside `Mode`, leaving the base
|
||||
/// machine (this enum) running underneath.
|
||||
Swing { id: u16, under: Option<Special> },
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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`
|
||||
/// 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`.
|
||||
|
||||
@@ -249,6 +249,9 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
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;
|
||||
|
||||
fn commands() -> (NetCommands, crossbeam_channel::Receiver<ClientCommand>) {
|
||||
@@ -291,6 +294,35 @@ mod tests {
|
||||
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]
|
||||
fn negative_template_answer_is_cached() {
|
||||
let (cmds, rx) = commands();
|
||||
|
||||
@@ -600,6 +600,12 @@ pub(crate) enum ClientCommand {
|
||||
spell_index: u8,
|
||||
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
|
||||
/// *equippable* click, mirroring the real client's equip-vs-use decision. Refusals come back
|
||||
/// as `InventoryFailure` events onto the UI error line.
|
||||
|
||||
@@ -663,6 +663,7 @@ fn writer_loop(
|
||||
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 } => {
|
||||
w.auto_equip_item(bag_index, slot)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ use crate::ui_script::PointerOverUi;
|
||||
|
||||
mod arc;
|
||||
mod camera;
|
||||
mod follow;
|
||||
|
||||
mod gait;
|
||||
// The land-here affordance (free-fly's other half). `pub(crate)` for its `LandHere` message, which
|
||||
// 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`)
|
||||
// orders itself before it and lets that documented override stand.
|
||||
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::{
|
||||
apply_zoom_scroll, run_look_session, seat_camera, CameraProbe, FlyCam, LookButton,
|
||||
CAM_COLLISION_RADIUS, CAM_DIST_DEFAULT, CAM_PIVOT_FALLBACK,
|
||||
};
|
||||
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
|
||||
// what lets this module and the concern modules beside it keep naming them `super::X` unchanged.
|
||||
use state::{
|
||||
@@ -125,6 +130,7 @@ impl Plugin for PlayerPlugin {
|
||||
if let Some(cam) = probe_cam::from_env() {
|
||||
app.insert_resource(cam);
|
||||
}
|
||||
follow::plugin(app);
|
||||
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,
|
||||
// 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)
|
||||
.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
|
||||
// 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
|
||||
@@ -625,8 +642,13 @@ fn control(
|
||||
// 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
|
||||
// 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(
|
||||
keys_pressed(KeyCode::KeyW),
|
||||
keys_pressed(KeyCode::KeyW) || player.follow_forward,
|
||||
keys_pressed(KeyCode::KeyS),
|
||||
both_buttons,
|
||||
autorun,
|
||||
|
||||
@@ -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 ±π"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -379,6 +379,12 @@ pub(crate) struct Player {
|
||||
/// places the other forward sources do — the direction vector, the wire flags, the swim
|
||||
/// amounts, and the turn-rate's "am I translating" test.
|
||||
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.
|
||||
pub(crate) detached: bool,
|
||||
/// Feet position in **Bevy** coords (converted to raw WoW only when sending to the server).
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ use crate::interact::{InspectMode, WorldClick, WorldRightClick};
|
||||
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::schedule::WorldStage;
|
||||
|
||||
mod by_name;
|
||||
mod click;
|
||||
mod cursor_mode;
|
||||
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
|
||||
// (`scan`) answers it with the same core TAB uses.
|
||||
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
|
||||
// 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).
|
||||
@@ -171,6 +175,8 @@ impl Plugin for TargetPlugin {
|
||||
.init_resource::<CombatFlash>()
|
||||
.init_resource::<scan::TabHistory>()
|
||||
.add_message::<AttackNearestRequest>()
|
||||
.add_message::<TargetByNameRequest>()
|
||||
.add_message::<AssistRequest>()
|
||||
.add_systems(
|
||||
Startup,
|
||||
(
|
||||
@@ -211,6 +217,19 @@ impl Plugin for TargetPlugin {
|
||||
click::act_on_right_click,
|
||||
click::clear_target_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::tab_target,
|
||||
scan::acquire_and_attack,
|
||||
|
||||
@@ -71,6 +71,9 @@ pub(super) enum SlashIndex {
|
||||
LootFfa,
|
||||
LootRoundRobin,
|
||||
LootMaster,
|
||||
Target,
|
||||
Assist,
|
||||
Follow,
|
||||
}
|
||||
|
||||
impl SlashIndex {
|
||||
@@ -105,13 +108,16 @@ impl SlashIndex {
|
||||
Self::LootFfa => "LOOT_FFA",
|
||||
Self::LootRoundRobin => "LOOT_ROUNDROBIN",
|
||||
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
|
||||
/// nowhere and answers `HELP_TEXT_SIMPLE`, exactly as an unknown command does in the reference
|
||||
/// (better than a registered handler that silently does nothing).
|
||||
const ALL: [Self; 28] = [
|
||||
const ALL: [Self; 31] = [
|
||||
Self::Reply,
|
||||
Self::Join,
|
||||
Self::Leave,
|
||||
@@ -140,6 +146,9 @@ impl SlashIndex {
|
||||
Self::LootFfa,
|
||||
Self::LootRoundRobin,
|
||||
Self::LootMaster,
|
||||
Self::Target,
|
||||
Self::Assist,
|
||||
Self::Follow,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -122,15 +122,23 @@ pub(super) struct ChatProbes<'w, 's> {
|
||||
guids: Res<'w, crate::net::GuidIndex>,
|
||||
}
|
||||
|
||||
/// The two setters `DoEmote` drives besides the packet, bundled (the drain is at Bevy's 16-param
|
||||
/// ceiling): the **posture** (`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`). Both are queues into their subsystem's one setter,
|
||||
/// never applied here.
|
||||
/// Everything the drain **queues into another subsystem's one setter** rather than applying itself,
|
||||
/// bundled (the drain is at Bevy's 16-param ceiling).
|
||||
///
|
||||
/// - `stand`/`sheath` — the two setters `DoEmote` drives besides the packet: the **posture**
|
||||
/// (`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)]
|
||||
pub(super) struct EmoteOut<'w> {
|
||||
pub(super) struct ChatOut<'w> {
|
||||
stand: MessageWriter<'w, crate::player::StandStateRequest>,
|
||||
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.
|
||||
@@ -156,7 +164,7 @@ pub(super) fn drain_chat_input(
|
||||
mut go_targets: MessageWriter<crate::creature_anim::SpellGoTargets>,
|
||||
// The command table (decision 0881) — the reference's own aliases, resolved at boot.
|
||||
table: Res<super::commands::SlashCommands>,
|
||||
mut emote_out: EmoteOut,
|
||||
mut chat_out: ChatOut,
|
||||
probes: ChatProbes,
|
||||
) {
|
||||
let ChatProbes {
|
||||
@@ -495,6 +503,36 @@ pub(super) fn drain_chat_input(
|
||||
ParsedChat::Forfeit => {
|
||||
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
|
||||
// over the same binding the popup row calls, so it enters the same intent queue
|
||||
// (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
|
||||
// refusal, so an already-stowed body costs nothing.
|
||||
if let Ok((entity, _, _)) = self_player.single() {
|
||||
emote_out.sheath.write(crate::creature_anim::SheathRequest {
|
||||
chat_out.sheath.write(crate::creature_anim::SheathRequest {
|
||||
entity,
|
||||
state: 0,
|
||||
ceremony: false,
|
||||
@@ -577,7 +615,7 @@ pub(super) fn drain_chat_input(
|
||||
// the one setter in `crate::player`, which sends `CMSG_STANDSTATECHANGE`, holds the
|
||||
// local commit until the echo lands, and runs the sit-stow rider.
|
||||
if let Some(state) = posture {
|
||||
emote_out
|
||||
chat_out
|
||||
.stand
|
||||
.write(crate::player::StandStateRequest { state: state as u8 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// surface is exercisable serverless.
|
||||
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.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// The name half of the ref's `GetSlashCmdTarget` (ChatFrame.lua:650-658): a non-empty argument
|
||||
/// is the name (first word — 1.12 names carry no spaces); empty defers to the dispatch's
|
||||
/// player-target fallback.
|
||||
/// The name half of the ref's `GetSlashCmdTarget` (ChatFrame.lua:650-658) — the argument grammar
|
||||
/// **thirteen** shipped commands share, `/target` and `/assist` among them.
|
||||
///
|
||||
/// 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> {
|
||||
let name = args.split_whitespace().next().unwrap_or("");
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
(!args.is_empty()).then(|| args.to_string())
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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::Pvp => ParsedChat::Pvp,
|
||||
// The social verbs (decision 0668). The argument is passed WHOLE: `/who`'s is a filter
|
||||
|
||||
@@ -602,20 +602,60 @@ fn real_alias_table_resolves_the_shipped_commands() {
|
||||
"{line} resolves to an emote"
|
||||
);
|
||||
}
|
||||
// …and the four 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
|
||||
// `SlashCmdList["FOLLOW"]` follows your target (benilla has no follow yet, so it is honestly
|
||||
// unknown rather than wrongly an emote).
|
||||
for line in ["/follow", "/joke", "/puzzle", "/attackmytarget"] {
|
||||
// …and the three names the reference has NO command for, which the DBC-name match used to
|
||||
// accept as emotes.
|
||||
for line in ["/joke", "/puzzle", "/attackmytarget"] {
|
||||
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.
|
||||
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
|
||||
// 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
|
||||
// aliases** across the 28 registered `SlashCmdList` indices.
|
||||
assert_eq!(table.counts(), (55, 225), "(slash, emote) aliases");
|
||||
// are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **62 distinct
|
||||
// aliases** across the 31 registered `SlashCmdList` indices (0886 added TARGET's `/target`
|
||||
// `/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
|
||||
|
||||
@@ -209,6 +209,40 @@ pub(super) fn drain_container_uses(
|
||||
}
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -53,3 +53,94 @@ fn the_whole_shipped_manifest_loads_without_errors() {
|
||||
s.resolve();
|
||||
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());
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user