diff --git a/crates/benilla-protocol/src/bin/benilla-world/main.rs b/crates/benilla-protocol/src/bin/benilla-world/main.rs index bcd4fb38..5f9750aa 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/main.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/main.rs @@ -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, + /// 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 })); } diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs index 7b7c37ef..2ed4395b 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs @@ -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; diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/open_item.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/open_item.rs new file mode 100644 index 00000000..e425fe0f --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/open_item.rs @@ -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> = 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::>() + .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(()) + } +} diff --git a/crates/benilla-protocol/src/messages/items.rs b/crates/benilla-protocol/src/messages/items.rs index a6ab3fd6..d9cf00c8 100644 --- a/crates/benilla-protocol/src/messages/items.rs +++ b/crates/benilla-protocol/src/messages/items.rs @@ -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 + /// `` 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) 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 { + 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 diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 9e85c8c4..0e13e4c2 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -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, diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index 9f1d5de4..86a15c9a 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -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. diff --git a/crates/benilla-protocol/src/world/session.rs b/crates/benilla-protocol/src/world/session.rs index d8dcd291..ab96412c 100644 --- a/crates/benilla-protocol/src/world/session.rs +++ b/crates/benilla-protocol/src/world/session.rs @@ -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<()> { diff --git a/crates/benilla-protocol/src/world/writer/items.rs b/crates/benilla-protocol/src/world/writer/items.rs index 93a1d653..dc6a6df7 100644 --- a/crates/benilla-protocol/src/world/writer/items.rs +++ b/crates/benilla-protocol/src/world/writer/items.rs @@ -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`. diff --git a/crates/benilla-protocol/tests/items.rs b/crates/benilla-protocol/tests/items.rs index a45870f3..ba771cee 100644 --- a/crates/benilla-protocol/tests/items.rs +++ b/crates/benilla-protocol/tests/items.rs @@ -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( diff --git a/crates/benilla-ui/src/order.rs b/crates/benilla-ui/src/order.rs index 0c93786f..7a8c3ab3 100644 --- a/crates/benilla-ui/src/order.rs +++ b/crates/benilla-ui/src/order.rs @@ -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" 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 `` (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|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", ""]); + 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 = lines_of(&mut s).into_iter().map(|(t, _)| t).collect(); - assert_eq!(texts, vec!["Heavy Chest"]); + assert_eq!(texts, vec!["Small Barnacled Clam", ""]); assert!(s.take_errors().is_empty()); } diff --git a/crates/benilla-ui/src/script/tooltip_item/mod.rs b/crates/benilla-ui/src/script/tooltip_item/mod.rs index 12780bbb..acdf63d7 100644 --- a/crates/benilla-ui/src/script/tooltip_item/mod.rs +++ b/crates/benilla-ui/src/script/tooltip_item/mod.rs @@ -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 `` — 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])), diff --git a/crates/benilla-ui/src/script/tooltip_item/render.rs b/crates/benilla-ui/src/script/tooltip_item/render.rs index 8fe95d78..ce632d49 100644 --- a/crates/benilla-ui/src/script/tooltip_item/render.rs +++ b/crates/benilla-ui/src/script/tooltip_item/render.rs @@ -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 `` 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, } diff --git a/crates/benilla/assets/ui/PartyFrame.xml b/crates/benilla/assets/ui/PartyFrame.xml index 7d48bef5..92002492 100644 --- a/crates/benilla/assets/ui/PartyFrame.xml +++ b/crates/benilla/assets/ui/PartyFrame.xml @@ -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 @@ + 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). --> + @@ -761,8 +779,9 @@ - + @@ -784,8 +803,9 @@ + 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. --> @@ -965,9 +985,10 @@ - + diff --git a/crates/benilla/src/aura_visual.rs b/crates/benilla/src/aura_visual.rs index d817d457..07a18636 100644 --- a/crates/benilla/src/aura_visual.rs +++ b/crates/benilla/src/aura_visual.rs @@ -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)>, 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::(); } - (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 = freeze - .saved - .iter() - .map(|(n, s)| format!("{}@{s}", n.index())) + let clips: Vec = 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(" ")), ); } diff --git a/crates/benilla/src/aura_visual/tests.rs b/crates/benilla/src/aura_visual/tests.rs index 42ecf0f6..974594e8 100644 --- a/crates/benilla/src/aura_visual/tests.rs +++ b/crates/benilla/src/aura_visual/tests.rs @@ -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::() .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::().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::() + // `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::() .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::().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::().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); +} diff --git a/crates/benilla/src/creature_anim.rs b/crates/benilla/src/creature_anim.rs index 3e945cdd..da548c0b 100644 --- a/crates/benilla/src/creature_anim.rs +++ b/crates/benilla/src/creature_anim.rs @@ -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, + /// 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, diff --git a/crates/benilla/src/creature_anim/driver.rs b/crates/benilla/src/creature_anim/driver.rs index 1d25d9c5..f2d54f10 100644 --- a/crates/benilla/src/creature_anim/driver.rs +++ b/crates/benilla/src/creature_anim/driver.rs @@ -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() { diff --git a/crates/benilla/src/creature_anim/driver/tests.rs b/crates/benilla/src/creature_anim/driver/tests.rs index c643cd28..fa253abf 100644 --- a/crates/benilla/src/creature_anim/driver/tests.rs +++ b/crates/benilla/src/creature_anim/driver/tests.rs @@ -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::().unwrap().gait; + let mode = |app: &App| app.world().entity(unit).get::().unwrap().mode; + let overlay = |app: &App| { + app.world() + .entity(unit) + .get::() + .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::().unwrap().mode; + let overlay = |app: &App| { + app.world() + .entity(unit) + .get::() + .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" + ); +} diff --git a/crates/benilla/src/creature_anim/select.rs b/crates/benilla/src/creature_anim/select.rs index 2fbd9476..c9ef6289 100644 --- a/crates/benilla/src/creature_anim/select.rs +++ b/crates/benilla/src/creature_anim/select.rs @@ -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, - }, + /// (`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 }, } /// 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`. diff --git a/crates/benilla/src/items.rs b/crates/benilla/src/items.rs index c8fae50b..0ebadda0 100644 --- a/crates/benilla/src/items.rs +++ b/crates/benilla/src/items.rs @@ -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) { @@ -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(); diff --git a/crates/benilla/src/net.rs b/crates/benilla/src/net.rs index 2be24067..f9d1d046 100644 --- a/crates/benilla/src/net.rs +++ b/crates/benilla/src/net.rs @@ -600,6 +600,12 @@ pub(crate) enum ClientCommand { spell_index: u8, go_target: Option, }, + /// 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. diff --git a/crates/benilla/src/net/io.rs b/crates/benilla/src/net/io.rs index 760ec8b2..378cef55 100644 --- a/crates/benilla/src/net/io.rs +++ b/crates/benilla/src/net/io.rs @@ -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) } diff --git a/crates/benilla/src/player.rs b/crates/benilla/src/player.rs index 8323a240..5a5de241 100644 --- a/crates/benilla/src/player.rs +++ b/crates/benilla/src/player.rs @@ -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, diff --git a/crates/benilla/src/player/follow.rs b/crates/benilla/src/player/follow.rs new file mode 100644 index 00000000..4da641b2 --- /dev/null +++ b/crates/benilla/src/player/follow.rs @@ -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, + /// 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, +} + +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, +} + +/// 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