click your mount again to dismount, and the poof puffs

- clicking your mount item while mounted dismounts you, and the mount icon no longer wears a stack "1"
- the horse appears and you're simply in the seat pose with the mount's cloud puff, no spellcast finishing over the saddle — and dismounting cuts clean to standing, no phantom jump-landing
- a mounted rider counter-turns head only, item-targeted spells show the targeting cursor, and the wrong thing in a quiver says "Only Arrows can be placed in that."
- liquid lookups ride a spatial index, and the camera's waterline comes from the client's own corner probe

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-03 17:54:27 +07:00
co-authored by Claude Fable 5
parent 2d83c8392c
commit 906e997aa6
52 changed files with 3275 additions and 1028 deletions
@@ -599,6 +599,12 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> {
// needs the spin axis as ground truth, which a bare key COUNT never shows. Small
// tracks print every key; a long track (a swirl/rotor loop) prints first/last plus
// the axis of the first key-to-key increment — the spin axis itself.
//
// A long track also prints `swing` — the largest angle any key makes with the first,
// i.e. the track's actual AMPLITUDE. `step` is only the first increment, so a summary
// without this cannot answer "how far does this bone swing", which is the question a
// "does the rider inherit the mount's gallop pitch" investigation puts to it. Reading
// `step` as the amplitude under-reports a 20° swing as 1.5°.
if !bk.rotation.is_empty() {
let aa = |q: &[f32; 4]| {
let w = q[3].clamp(-1.0, 1.0);
@@ -630,8 +636,19 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> {
q1[3] * inv0[2] + q1[0] * inv0[1] - q1[1] * inv0[0] + q1[2] * inv0[3],
q1[3] * inv0[3] - q1[0] * inv0[0] - q1[1] * inv0[1] - q1[2] * inv0[2],
];
// The amplitude: the largest angle any key makes with the first. `2·acos|⟨qi,q0⟩|`
// is the geodesic angle between two unit quats, sign-folded so a double-cover
// flip doesn't read as a 360° swing.
let swing = bk
.rotation
.iter()
.map(|(_, q)| {
let dot: f32 = (0..4).map(|k| q[k] * q0[k]).sum();
2.0 * dot.abs().clamp(0.0, 1.0).acos().to_degrees()
})
.fold(0.0f32, f32::max);
println!(
" seq{si} (anim {}) R: {} keys first {t0:.3}s {} step {} last {tn:.3}s {}",
" seq{si} (anim {}) R: {} keys first {t0:.3}s {} step {} swing {swing:.2}° last {tn:.3}s {}",
s.anim_id,
bk.rotation.len(),
aa(q0),
+119
View File
@@ -0,0 +1,119 @@
//! ItemBagFamily.dbc — the container-family names, i.e. what a specialised bag *accepts*:
//! "Arrows", "Bullets", "Soul Shards", "Herbs", "Enchanting Supplies", "Engineering Supplies",
//! "Keys".
//!
//! One consumer, and it is the reason this table exists here at all: the inventory error line's
//! **reason 16** substitution (`ERR_WRONG_BAG_TYPE_SUBCLASS` = *"Only %s can be placed in
//! that."*). wow-re `system/ui/scratch/inventory-change-failure-display.md` §6 carves the helper
//! `0x5ede00(player, bagSlot)`: it resolves the named bag as an item, maps it through `0x5da050`
//! into the `[0xc0dc38]` row table (bound `[0xc0dc3c]`), reads the localized name at
//! `[row + 4*[0xc0e080] + 4]` — i.e. field 1 at the active locale — and calls
//! `DisplayError(0x118, thatName)` itself. **Despite the errorId's `_SUBCLASS` name this is not
//! ItemSubClass.dbc**: the row is keyed by the bag's `BagFamily`, which is what makes the line
//! read "Only Arrows can be placed in that." for a quiver rather than naming the bag's own type.
//!
//! Record layout (8 rows in the shipped 5875 file, verified by loading it): `ID@0`,
//! `Name_Lang@1..8`, `NameFlags@9`. **Ids are not contiguous** — 0,1,2,3,6,7,8,9, with 4 and 5
//! absent — so this is an id-keyed lookup, never an index. Row 0's name is the literal string
//! `"NONE"`; see [`ItemBagFamilyCatalog::name`].
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, str_at, u32_at};
use crate::Chain;
const ITEM_BAG_FAMILY: &str = "DBFilesClient\\ItemBagFamily.dbc";
/// ItemBagFamily.dbc keyed by the `BagFamily` id an item template carries.
pub struct ItemBagFamilyCatalog {
names: HashMap<u32, String>,
}
impl ItemBagFamilyCatalog {
/// What family `id` is *called* — the `%s` of "Only %s can be placed in that."
///
/// **Family 0 yields `None`**, not the row's literal `"NONE"`. 0 means "an ordinary item /
/// ordinary bag", so a resolved 0 would render "Only NONE can be placed in that." — the
/// caller wants the generic line there. Unreachable on this wire in practice (the server
/// only sends reason 16 for a *specialised* container mismatch), so this is a guard on a
/// nonsense string rather than a modelled branch.
pub fn name(&self, id: u32) -> Option<&str> {
(id != 0)
.then(|| self.names.get(&id).map(String::as_str))
.flatten()
}
/// Row count, for the load log.
pub fn len(&self) -> usize {
self.names.len()
}
pub fn is_empty(&self) -> bool {
self.names.is_empty()
}
}
fn item_bag_family_schema() -> Schema {
let mut s = Schema::new("ItemBagFamily");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
for i in 0..8 {
s.add_field(SchemaField::new(format!("Name{i}"), FieldType::String));
}
s.add_field(SchemaField::new("NameFlags", FieldType::UInt32));
s
}
/// Load ItemBagFamily.dbc from the patch chain.
pub fn load_item_bag_families(chain: &mut Chain) -> Result<ItemBagFamilyCatalog> {
let bytes = chain
.read_file(ITEM_BAG_FAMILY)
.with_context(|| format!("reading {ITEM_BAG_FAMILY}"))?;
let rs = parse(&bytes, item_bag_family_schema(), "ItemBagFamily")?;
let mut names = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let (Some(id), Some(name)) = (u32_at(r, 0), str_at(&rs, r, 1).filter(|n| !n.is_empty()))
else {
continue;
};
names.insert(id, name);
}
Ok(ItemBagFamilyCatalog { names })
}
#[cfg(test)]
mod tests {
use super::load_item_bag_families;
/// The shipped 5875 table, read as data rather than assumed: the ids our
/// `ItemInfo::bag_family` doc already names must each carry the family's *plural* spelling,
/// which is what makes "Only Arrows can be placed in that." read correctly. Also pins the
/// non-contiguity (4 and 5 absent) that makes this an id lookup, and family 0's guard.
/// Skips without client data.
#[test]
fn the_shipped_families_are_named_as_the_error_line_needs() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_item_bag_families(&mut chain).expect("ItemBagFamily.dbc");
assert_eq!(cat.name(1), Some("Arrows"), "quiver");
assert_eq!(cat.name(2), Some("Bullets"), "ammo pouch");
assert_eq!(cat.name(3), Some("Soul Shards"), "soul bag");
assert_eq!(cat.name(6), Some("Herbs"));
assert_eq!(cat.name(7), Some("Enchanting Supplies"));
assert_eq!(cat.name(8), Some("Engineering Supplies"));
assert_eq!(cat.name(9), Some("Keys"), "the keyring family");
// 4 and 5 genuinely do not ship — the gap is why this is keyed, not indexed.
assert_eq!(cat.name(4), None);
assert_eq!(cat.name(5), None);
// Row 0 exists and is literally "NONE"; the accessor refuses it (see `name`).
assert_eq!(cat.name(0), None, "family 0 must not render as \"NONE\"");
assert_eq!(cat.len(), 8, "the whole shipped table");
}
}
+2
View File
@@ -27,6 +27,8 @@ pub use creatures::{load_creature_catalog, CreatureCatalog, CreatureModel, NpcAp
pub use unit_blood::{load_blood_catalog, BloodCatalog};
mod itemsets;
pub use itemsets::{load_item_sets, ItemSetCatalog, ItemSetInfo};
mod itembagfamily;
pub use itembagfamily::{load_item_bag_families, ItemBagFamilyCatalog};
mod itemsubclass;
pub use itemsubclass::{load_item_sub_classes, ItemSubClassCatalog, ItemSubClassInfo};
mod factions;
+13 -3
View File
@@ -292,9 +292,10 @@ pub struct SpellVisualCatalog {
/// The `"HARDCODED *"`-named rows, name → path — the client's engine-spawned effect set,
/// resolved BY NAME once at boot exactly like this (`0x61f5b0` matches a 14-string baked
/// table against the name column: loot art, footsteps, breath, level-up…; wow-re
/// `loot-corpse-effect.md` + `levelup-ding.md`). Two consumers today: "HARDCODED Loot Art"
/// (id 14 → `Particles\LootFX.mdl`, the corpse sparkle) and "HARDCODED Unit Level Up"
/// (id 21 → `Spells\LevelUp\LevelUp.mdl`, the ding).
/// `loot-corpse-effect.md` + `levelup-ding.md`). Three consumers today: "HARDCODED Loot Art"
/// (id 14 → `Particles\LootFX.mdl`, the corpse sparkle), "HARDCODED Unit Level Up"
/// (id 21 → `Spells\LevelUp\LevelUp.mdl`, the ding) and "HARDCODED Mount Poof"
/// (id 1185 → `Spells\DruidMorph_Impact_Base.mdx`, the mount-up cloud — decision 0927).
hardcoded: HashMap<String, String>,
}
@@ -321,6 +322,15 @@ impl SpellVisualCatalog {
}
}
/// Seed one `"HARDCODED …"` name → model path, for fixtures exercising the engine-spawned
/// effects (the loot sparkle, the level-up ding, the mount poof). The live path is the boot
/// name-resolve inside [`load_spell_visual_catalog`], which is what `0x61f5b0` does.
#[must_use]
pub fn with_hardcoded(mut self, name: &str, path: &str) -> Self {
self.hardcoded.insert(name.to_string(), path.to_string());
self
}
/// A `SpellVisual.dbc` row's stage kits by its id (`Spell.dbc` column 115, `spells::SpellDisplay::visual`).
pub fn stages(&self, visual_id: u32) -> Option<&VisualStages> {
self.visuals.get(&visual_id)
@@ -843,3 +843,96 @@ fn the_tooltip_gates_read_effect_and_mask() {
// §3-EQUIPITEM's naming rule moved to `ItemSubClassCatalog::requirement_name`, where the
// vocabulary it reads lives — see `itemsubclass::tests` for its coverage against the real DBCs.
}
/// The item-target family and its gate columns (decision 0923), against the real 5875 file. The
/// reference's `TargetingWantsItem 0x6e6330` is `flag_word & 0x4010`, and on shipped data those
/// two bits are **never** mixed with a unit bit — the whole family is `Targets` exactly `0x10`
/// (the enchant/poison/stone/scope arm, this slice) or exactly `0x4000` (the OPEN_LOCK arm, whose
/// lock machinery is a separate one). That disjointness is what lets the resolver fork on the
/// bare word instead of running the reference's bind walk to exhaustion.
///
/// The gate columns are the three `0x495d60` reads, pinned on rows whose answer is checkable by
/// eye: an armor enchant names its `InventoryType` slot (bracer → 9, chest → 5 | robe 20) while a
/// weapon enchant/poison names a class+subclass instead and leaves the type mask 0.
#[test]
fn real_item_target_family_and_its_gate_columns() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon");
// The unit-shaped bits of the flag_word (`cast_target`'s `UNIT_BITS`).
const UNIT_BITS: u32 = 0x0002 | 0x0004 | 0x0008 | 0x0080 | 0x0100 | 0x0200 | 0x0400 | 0x8000;
let mut item_only = 0usize;
let mut locked_only = 0usize;
for (id, d) in cat.iter() {
if d.targets & 0x4010 == 0 {
continue;
}
assert_eq!(
d.targets & UNIT_BITS,
0,
"spell {id} mixes an item bit with a unit bit — the resolver's fork would be wrong"
);
match d.targets {
0x0010 => item_only += 1,
0x4000 => locked_only += 1,
other => panic!("spell {id}: unexpected item-family word {other:#x}"),
}
}
assert_eq!(
item_only, 363,
"Targets == 0x10 — the enchant/poison family"
);
assert_eq!(locked_only, 103, "Targets == 0x4000 — the OPEN_LOCK family");
// Bracer enchant: armor (4), any subclass, InventoryType WRIST(9) only.
let bracer = cat.get(7418).unwrap();
assert_eq!(bracer.targets, 0x10);
assert_eq!(bracer.equipped_item_class, 4);
assert_eq!(bracer.equipped_item_inventory_type_mask, 1 << 9);
// Chest enchant: CHEST(5) or ROBE(20) — the mask that makes a cloth robe legal.
let chest = cat.get(7443).unwrap();
assert_eq!(
chest.equipped_item_inventory_type_mask,
(1 << 5) | (1 << 20)
);
// A weapon-side row gates on class+subclass and leaves the type mask alone.
let poison = cat.get(8679).unwrap();
assert_eq!(
(
poison.equipped_item_class,
poison.equipped_item_subclass_mask,
poison.equipped_item_inventory_type_mask
),
(2, 0x2a5f3, 0)
);
// The reference's gate walks all THREE effect slots looking for an enchant effect
// (`0x495d60`'s loop, `495de4`–`496050`); [`SpellDisplay`] carries only slot 0. That is
// byte-equivalent on shipped data and this is why: across the whole item-target family, not
// one row puts its enchant effect anywhere but slot 0. Read raw, since the catalog itself
// only keeps `effect_1`.
let raw = chain.read_file(SPELL).expect("Spell.dbc");
let set = parse(&raw, spell_schema(), "Spell.dbc").expect("parse Spell.dbc");
let is_enchant = |e: u32| {
e == crate::SPELL_EFFECT_ENCHANT_ITEM || e == crate::SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY
};
for r in set.records() {
if u32_at(r, COL_TARGETS).unwrap_or(0) != 0x10 {
continue;
}
let effects: Vec<u32> = (0..3)
.map(|i| u32_at(r, COL_EFFECT_1 + i).unwrap_or(0))
.collect();
assert!(
is_enchant(effects[0]) || !(is_enchant(effects[1]) || is_enchant(effects[2])),
"spell {} hides its enchant effect outside slot 0 ({effects:?}) — the one-slot read \
in `ui_action::targeting::item_target_refusal` would miss it",
u32_at(r, 0).unwrap_or(0)
);
}
}
@@ -136,6 +136,12 @@ pub struct SpellDisplay {
/// `EquippedItemSubClassMask` (column 59) — `1 << subclass` mask refining
/// [`Self::equipped_item_class`] (`0x4000c` = bows|guns|crossbows).
pub equipped_item_subclass_mask: u32,
/// `EquippedItemInventoryTypeMask` (column 60, `SpellRec+0xf0`) — a `1 << InventoryType` mask,
/// the third and last leg of the reference's item-target gate (`0x495d60` @ `495e4d`; the two
/// before it are [`Self::equipped_item_class`]/[`Self::equipped_item_subclass_mask`] at
/// `495e10`). `0` = no requirement. This is the leg that decides a Crude Scope goes on a gun
/// and not a chestpiece; a mismatch is the client's own local "Invalid target" (`0x0a`).
pub equipped_item_inventory_type_mask: u32,
/// `RequiresSpellFocus` ([`COL_REQUIRES_SPELL_FOCUS`]) — the `SpellFocusObject.dbc` object
/// that must be nearby (1 Anvil, 3 Forge, 4 Cooking Fire — [`crate::spell_focus`]); 0 = none.
/// The crafting book's "Requires: …" line (0437).
@@ -262,6 +268,7 @@ impl Default for SpellDisplay {
reagents: [(0, 0); 8],
equipped_item_class: -1,
equipped_item_subclass_mask: 0,
equipped_item_inventory_type_mask: 0,
requires_spell_focus: 0,
shapeshift_form: None,
stance_bar_order: 0,
+6
View File
@@ -212,6 +212,10 @@ const COL_REAGENT_1: usize = 42;
const COL_REAGENT_COUNT_1: usize = 50;
const COL_EQUIPPED_ITEM_CLASS: usize = 58;
const COL_EQUIPPED_ITEM_SUBCLASS_MASK: usize = 59;
/// `EquippedItemInventoryTypeMask` — chain-locked between `COL_EQUIPPED_ITEM_SUBCLASS_MASK`
/// (`SpellRec+0xec`) and `COL_EFFECT_1` (`+0xf4`), i.e. exactly the `+0xf0` the reference's
/// item-target gate reads at `0x495d60` @ `495e4d`. Decision 0923.
const COL_EQUIPPED_ITEM_INVENTORY_TYPE_MASK: usize = 60;
/// `RequiresSpellFocus` (`SpellRec+0x3c`, `0x3c/4 == 15` — the run the pinned neighbors chain-lock:
/// `Stances` 11-12, `Targets` 13, `CasterAuraState` 16). A `SpellFocusObject.dbc` id that must be
/// nearby to cast (1 Anvil, 3 Forge, 4 Cooking Fire — [`crate::spell_focus`]); 0 = none. Verified
@@ -636,6 +640,8 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
equipped_item_class: u32_at(r, COL_EQUIPPED_ITEM_CLASS).unwrap_or(0) as i32,
equipped_item_subclass_mask: u32_at(r, COL_EQUIPPED_ITEM_SUBCLASS_MASK)
.unwrap_or(0),
equipped_item_inventory_type_mask: u32_at(r, COL_EQUIPPED_ITEM_INVENTORY_TYPE_MASK)
.unwrap_or(0),
requires_spell_focus: u32_at(r, COL_REQUIRES_SPELL_FOCUS).unwrap_or(0),
// The stance-bar keys (wow-re shapeshift-bar-api.md): the first MOD_SHAPESHIFT
// effect's MiscValue is the form id; order/active-icon read raw.
+3
View File
@@ -476,6 +476,9 @@ pub enum SessionEvent {
reason: u8,
required_level: Option<u32>,
item_guid: u64,
/// The destination bag's ABSOLUTE player slot — the `%s` source of reason 16's
/// "Only Arrows can be placed in that."; 255 = the player's own array (no bag to name).
bag_slot: u8,
},
/// A unit began melee auto-attack (`SMSG_ATTACKSTART` — including the echo of our own
/// `CMSG_ATTACKSWING`).
@@ -119,11 +119,13 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
reason,
required_level,
item_guid,
bag_slot,
} if reason != 0 => {
vec![SessionEvent::InventoryFailure {
reason,
required_level,
item_guid,
bag_slot,
}]
}
ServerPacket::AttackStart { attacker, victim } => {
+65 -9
View File
@@ -184,6 +184,33 @@ impl ItemInfo {
self.spells.iter().find(|s| s.trigger == 0).map(|s| s.index)
}
/// Is this item **consumable** in the sense the action bar's Count fontstring means?
/// `IsConsumableAction 0x4e5250`, byte-read (`4e52b9`–`4e52ea`), after it has resolved the
/// slot's item template:
///
/// ```text
/// [rec+0x2c] InventoryType == 0x18 (AMMO) or 0x19 (THROWN) -> true (4e52b9-4e52c4)
/// OR ∃ i∈[0,5): SpellId[i] [rec+0x11c+4i] != 0 (4e52d0)
/// ∧ SpellTrigger[i] [rec+0x130+4i] == 0 (ON_USE) (4e52d7)
/// ∧ SpellCharges[i] [rec+0x144+4i] < 0 (destroy-on-use) (4e52dc)
/// otherwise -> false
/// ```
///
/// **Negative** charges — not merely finite ones — are the test: the sign is vmangos's own
/// "the ITEM is consumed once the charges run out" convention, so a potion (`-1`) counts and a
/// wand-like item with positive charges does not. Nothing here looks at `Class`: a mount
/// (`Class` 15 Miscellaneous, `InventoryType` 0, on-use spell with `SpellCharges` 0) is **not**
/// consumable, which is why the reference shows no stack number under a mount on the bar.
///
/// [`Self::spells`] already drops the `SpellId == 0` blocks, so iterating it is the `4e52d5`
/// skip.
pub fn is_consumable(&self) -> bool {
const INVTYPE_AMMO: u32 = 0x18;
const INVTYPE_THROWN: u32 = 0x19;
matches!(self.inventory_type, INVTYPE_AMMO | INVTYPE_THROWN)
|| self.spells.iter().any(|s| s.trigger == 0 && s.charges < 0)
}
/// Can this item be placed on an action-bar slot? `PlaceAction`'s only item filter, byte-read
/// (wow-re `action-item-slot.md` §5, `4e6571`–`4e6598`): **an on-use spell OR equippable**.
/// No quality, bind, class/subclass, container or level test exists anywhere on that path — a
@@ -550,6 +577,10 @@ const TARGET_FLAG_UNIT: u16 = 0x0002;
/// `TARGET_FLAG_DEST_LOCATION` — a ground point, the same bit and the same three `f32` WoW coords
/// a ground-targeted `CMSG_CAST_SPELL` writes ([`super::spells::cast_spell_at_dest`]).
const TARGET_FLAG_DEST_LOCATION: u16 = 0x0040;
/// `TARGET_FLAG_ITEM` — a bound *item* target, the same bit and the same packed guid an
/// item-targeted `CMSG_CAST_SPELL` writes ([`super::spells::cast_spell_on_item`]). One block
/// builder, two opcodes (decision 0923).
const TARGET_FLAG_ITEM: u16 = 0x0010;
/// The `SpellCastTargets` block a `CMSG_USE_ITEM` carries — built by the *same* code as a
/// `CMSG_CAST_SPELL`'s. `SendCast 0x6e54f0` picks the opcode from its item-vs-caster discriminator
@@ -576,6 +607,12 @@ pub enum UseItemTarget {
/// **thrown** item: dynamite, grenades, bombs, the Goblin Mortar (46 of the 1.12 on-use item
/// spells, decision 0914). Same block a ground-targeted spell writes; only the opcode differs.
Dest([f32; 3]),
/// `TARGET_FLAG_ITEM` + the packed guid — the targeting cursor's **item** commit: a poison, a
/// sharpening stone, a weapon oil, an enchanting scroll applied to the item you clicked in a
/// bag or on the paper doll (decision 0923). The reference reaches it through the very same
/// `BindTarget 0x6e5b40` a unit goes through (`0x495d60` @ `496056`), so the block is the same
/// block; only which bit is set differs.
Item(u64),
}
/// Body of `CMSG_USE_ITEM` (VERIFIED vmangos `UseItem::ReadFromWorldPacket` + opcode 171
@@ -601,6 +638,10 @@ pub fn use_item(bag_index: u8, slot: u8, spell_slot: u8, target: UseItemTarget)
body.extend_from_slice(&(TARGET_FLAG_GAMEOBJECT | TARGET_FLAG_LOCKED).to_le_bytes());
guid
}
UseItemTarget::Item(guid) => {
body.extend_from_slice(&TARGET_FLAG_ITEM.to_le_bytes());
guid
}
UseItemTarget::Dest(dest) => {
body.extend_from_slice(&TARGET_FLAG_DEST_LOCATION.to_le_bytes());
for c in dest {
@@ -692,12 +733,24 @@ pub fn set_ammo(entry: u32) -> Vec<u8> {
/// Read `SMSG_INVENTORY_CHANGE_FAILURE` (VERIFIED vmangos `InventoryChangeFailure::AppendBodyTo`):
/// `u8 reason` (`InventoryResult`; 0 = OK, no tail), then — only when failed — a `u32` required
/// level *iff* `reason == 1` (`CANT_EQUIP_LEVEL_I`), the two full item guids, and the bag subslot.
/// Returns `(reason, required_level, item_guid)`.
pub(super) fn read_inventory_change_failure(r: &mut &[u8]) -> io::Result<(u8, Option<u32>, u64)> {
/// level *iff* `reason == 1` (`CANT_EQUIP_LEVEL_I`), the two full item guids, and the bag slot.
///
/// The trailing `u8` is **the destination bag's absolute player slot**, not a subslot: vmangos
/// declares it *"slot of target bag that has storing condition (can be InventorySlots or
/// BankBagSlots)"* and fills it with `bagSlot = bag` at each `CanStoreItem` refusal
/// (`Player.cpp:8899`ff), where `bag` is `INVENTORY_SLOT_BAG_0` (255, the player's own array) or
/// an equipped bag's slot. The reference reads it the same way — its reason-16 helper
/// `0x5ede00` bails on `slot == 0xFF` and otherwise indexes the player's slot array by it (wow-re
/// `inventory-change-failure-display.md` §6). It is the `%s` source of *"Only Arrows can be
/// placed in that."*; see `benilla::ui_items::feed`.
///
/// Returns `(reason, required_level, item_guid, bag_slot)`.
pub(super) fn read_inventory_change_failure(
r: &mut &[u8],
) -> io::Result<(u8, Option<u32>, u64, u8)> {
let reason = read_u8(r)?;
if reason == 0 {
return Ok((0, None, 0));
return Ok((0, None, 0, 0));
}
let required_level = if reason == 1 {
Some(read_u32_le(r)?)
@@ -706,8 +759,8 @@ pub(super) fn read_inventory_change_failure(r: &mut &[u8]) -> io::Result<(u8, Op
};
let item_guid = read_u64_le(r)?;
let _item2 = read_u64_le(r)?;
let _bag_subslot = read_u8(r)?;
Ok((reason, required_level, item_guid))
let bag_slot = read_u8(r)?;
Ok((reason, required_level, item_guid, bag_slot))
}
/// Read `SMSG_ITEM_ENCHANT_TIME_UPDATE` (VERIFIED vmangos
@@ -787,7 +840,10 @@ mod tests {
// reason 0 (EQUIP_ERR_OK) ships no tail.
let buf = [0u8];
let mut r = &buf[..];
assert_eq!(read_inventory_change_failure(&mut r).unwrap(), (0, None, 0));
assert_eq!(
read_inventory_change_failure(&mut r).unwrap(),
(0, None, 0, 0)
);
}
#[test]
@@ -802,7 +858,7 @@ mod tests {
let mut r = &buf[..];
assert_eq!(
read_inventory_change_failure(&mut r).unwrap(),
(1, Some(40), 0x1122_3344_5566_7788)
(1, Some(40), 0x1122_3344_5566_7788, 7)
);
}
@@ -836,7 +892,7 @@ mod tests {
let mut r = &buf[..];
assert_eq!(
read_inventory_change_failure(&mut r).unwrap(),
(3, None, 0xDEAD_BEEF_0000_0001)
(3, None, 0xDEAD_BEEF_0000_0001, 0)
);
}
}
@@ -357,6 +357,8 @@ pub enum ServerPacket {
reason: u8,
required_level: Option<u32>,
item_guid: u64,
/// The destination bag's ABSOLUTE player slot (255 = the player's own array).
bag_slot: u8,
},
/// `SMSG_INITIAL_SPELLS` — the player's spell book + active cooldowns, once at login
/// (layout in [`super::spellbook::read_initial_spells`]).
@@ -360,11 +360,13 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
}
}
opcode::SMSG_INVENTORY_CHANGE_FAILURE => {
let (reason, required_level, item_guid) = items::read_inventory_change_failure(&mut r)?;
let (reason, required_level, item_guid, bag_slot) =
items::read_inventory_change_failure(&mut r)?;
ServerPacket::InventoryChangeFailure {
reason,
required_level,
item_guid,
bag_slot,
}
}
opcode::SMSG_INITIAL_SPELLS => {
+22
View File
@@ -64,6 +64,25 @@ fn item_query_wire() {
hx("13030140000000803f0000004000004040")
);
// The ITEM form (decision 0923): the targeting cursor's item commit — a poison / sharpening
// stone / weapon oil applied to the weapon you clicked. TARGET_FLAG_ITEM (0x0010) + the packed
// guid, the same block cast_spell_on_item writes; the reference reaches both through the one
// BindTarget 0x6e5b40 (0x495d60 @ 496056). Packed guid 0xF1500000_0000ABCD = mask 0xc3 (bytes
// 0, 1, 6, 7 nonzero) then those four bytes.
assert_eq!(
messages::use_item(255, 24, 0, messages::UseItemTarget::Item(1)),
hx("ff180010000101")
);
assert_eq!(
messages::use_item(
255,
24,
0,
messages::UseItemTarget::Item(0xF150_0000_0000_ABCD)
),
hx("ff18001000c3cdab50f1")
);
// CMSG_AUTOEQUIP_ITEM (vmangos AutoEquipItem::ReadFromWorldPacket): bagIndex, slot.
assert_eq!(messages::auto_equip_item(255, 25), hx("ff19"));
@@ -88,9 +107,12 @@ fn item_query_wire() {
reason,
required_level,
item_guid,
bag_slot,
} => {
assert_eq!((reason, required_level), (1, Some(10)));
assert_eq!(item_guid, 0x4000_0000_0000_0042);
// The trailing byte is the destination BAG's absolute player slot, not a subslot.
assert_eq!(bag_slot, 0);
}
other => panic!("level refusal, got {}", other.name()),
}
+21 -14
View File
@@ -469,22 +469,29 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"PickupContainerItem",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// The repair-mode intercept lives IN the pickup path, exactly the real client
// (`0x4f9c7b`, wow-re repair-machinery.md): while the repair cursor is armed, the
// click means "repair this item" — queued for the app to send — and nothing is
// picked up. The mode STICKS across clicks (only HideRepairCursor/merchant-close
// clears it).
if model.repair_mode {
model.container_repairs.push((bag, slot));
// Two intercepts live IN the pickup path, in the reference's own order (decision
// 0923 re-read `PickupContainerItem 0x4f9b30` whole; the targeting rung is at
// `4f9c54` and the repair test at `4f9c7b`, so targeting wins — a poisoning click at
// a repair vendor binds the poison, it does not repair):
//
// 4f9c54 call 0x6e48a0 ; IsTargeting
// 4f9c5d call 0x6e6330 ; TargetingWantsItem (word & 0x4010)
// 4f9c6d call 0x495d60 ; bind THIS item — then return, nothing picked up
//
// The held-payload check precedes BOTH (`4f9c38`: a non-empty cursor jumps to the
// place/swap arm long before either), so a click while carrying an item is a place —
// transcribed by the `cursor.is_none()` gate. The word is one-shot: the app clears it
// on completion, cancel, or close.
if model.item_pick_armed && model.cursor.is_none() {
model.item_picks.push((bag, slot));
return Ok(false);
}
// The item-targeted-cast intercept, same seam (byte-VERIFIED — wow-re `tradeskill`
// TU-F: the enchant pick reroutes the generic bag click inside `PickupContainerItem
// 0x4f9b30` while the spell-targeting cursor is up): the click means "cast the armed
// spell AT this item" — queued for the app to complete — and nothing is picked up.
// One-shot: the app clears the arm on completion/close.
if model.item_pick_armed {
model.item_picks.push((bag, slot));
// Repair mode (`0x4f9c7b`, wow-re repair-machinery.md): while the repair cursor is
// armed, the click means "repair this item" — queued for the app to send — and
// nothing is picked up. The mode STICKS across clicks (only HideRepairCursor /
// merchant-close clears it).
if model.repair_mode {
model.container_repairs.push((bag, slot));
return Ok(false);
}
Ok(pickup_container_item(&mut model, bag, slot))
-14
View File
@@ -185,20 +185,6 @@ impl super::UiScript {
pub fn take_craft_close(&mut self) -> bool {
std::mem::take(&mut self.model_mut().craft_close)
}
/// Arm (or disarm) the item-targeted-cast pick: while armed, a bag click queues its
/// `(bag, slot)` into the pick list instead of running the cursor gesture — the engine half
/// of the CraftFrame enchant flow (byte-VERIFIED seam: the `PickupContainerItem 0x4f9b30`
/// reroute, wow-re `tradeskill` TU-F). The app owns WHEN it's armed (an item-targeted
/// `DoCraft`) and cleared (completion, close).
pub fn set_item_pick_armed(&mut self, armed: bool) {
self.model_mut().item_pick_armed = armed;
}
/// Drain the `(bag, slot)` clicks the armed pick consumed since the last call.
pub fn take_item_picks(&mut self) -> Vec<(i64, u32)> {
std::mem::take(&mut self.model_mut().item_picks)
}
}
/// The recipe at a 1-based index, or `None` when out of range / no window is open.
+20
View File
@@ -345,6 +345,26 @@ impl super::UiScript {
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Lua globals: GetCursorInfo / CursorHasItem / CursorHasSpell / ClearCursor / SplitContainerItem /
// DeleteCursorItem — all top-level (the real client's `GetCursorInfo` &c. are not namespaced).
impl super::UiScript {
/// Arm (or disarm) **the targeting cursor's item half** — the engine mirror of
/// `TargetingWantsItem 0x6e6330`. While armed, a bag click ([`super::container`]) or a
/// paper-doll click ([`doll::pickup_inventory_item`]) queues its `(bag, slot)` into the pick
/// list instead of running the cursor gesture, exactly as the reference's two pickup
/// functions reroute (`0x4f9b30` @ `4f9c54`, `0x4c7300` @ `4c76df`). The app owns the word:
/// it arms on a resolved item-targeted cast and clears on the bind, a cancel, or ESC
/// (decision 0923 — before it, this was the CraftFrame's private enchant pick).
pub fn set_item_pick_armed(&mut self, armed: bool) {
self.model_mut().item_pick_armed = armed;
}
/// Drain the `(bag, slot)` clicks the armed item half consumed since the last call. A doll
/// click reports as [`EQUIPMENT_BAG`] + its 1-based inventory slot — the ONE bag space, so
/// the app resolves both seams with one lookup.
pub fn take_item_picks(&mut self) -> Vec<(i64, u32)> {
std::mem::take(&mut self.model_mut().item_picks)
}
}
// The paper-doll globals (`PickupInventoryItem` &c.) install from [`doll`]; the action-bar
// globals (`PickupAction`/`PlaceAction`) install from [`bar`].
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -38,6 +38,17 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
if !(1..=23).contains(&id) {
return false;
}
// The targeting cursor's item half, on the doll (decision 0923). The reference's doll pickup
// `0x4c7300` carries the byte-IDENTICAL rung to the bag one — `4c76df: call 0x6e48a0`
// (IsTargeting), `4c76e8: call 0x6e6330` (TargetingWantsItem), `4c76fb: call 0x495d60` (bind
// this item), then return with nothing picked up — and it is what makes poisoning or
// sharpening the weapon you are *wearing* possible at all. Reported in the ONE bag space
// ([`EQUIPMENT_BAG`] + the 1-based slot), so the app's drain resolves both seams identically.
// A held payload wins, exactly as in the bag path (the ref's own `4c73af` pair precedes it).
if model.item_pick_armed && model.cursor.is_none() {
model.item_picks.push((EQUIPMENT_BAG, id));
return false;
}
match model.cursor.take() {
None => {
let picked = model
@@ -628,4 +639,44 @@ mod tests {
}]
);
}
/// The targeting cursor's item half reroutes BOTH pickup seams (decision 0923), and the
/// held-payload check precedes it in both — the reference's own order (`4f9c38` before
/// `4f9c54`; `4c73af` before `4c76df`). Without the doll seam a rogue could not poison the
/// weapon they are wearing; without the payload gate, dragging an item across a bag while a
/// poison is armed would silently fire the cast at whatever you dropped it on.
#[test]
fn the_armed_item_half_reroutes_bag_and_doll_clicks() {
let mut s = UiScript::new().unwrap();
s.set_inventory_slots(doll_slots());
s.set_container(0, Some(one_fitting_bag_item()));
// Unarmed, both seams do their ordinary gesture and queue nothing.
assert!(s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
assert!(s.cursor_item().is_some());
assert!(s.take_item_picks().is_empty());
// Armed but HOLDING: the payload wins, exactly as the reference orders it.
s.set_item_pick_armed(true);
s.run("C_Container.PickupContainerItem(0, 1)").unwrap();
assert!(
s.take_item_picks().is_empty(),
"a click while carrying an item is a place, not a bind"
);
// Armed with an empty cursor: both seams bind instead of picking up, and neither
// disturbs the cursor.
s.run("ClearCursor()").unwrap();
assert!(s.cursor_item().is_none());
assert!(!s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
assert!(!s
.eval::<bool>("return C_Container.PickupContainerItem(0, 1)")
.unwrap());
assert_eq!(
s.take_item_picks(),
vec![(EQUIPMENT_BAG, 1), (0, 1)],
"the doll reports in the ONE bag space; the bag reports its own id"
);
assert!(s.cursor_item().is_none(), "a bind never stages a payload");
}
}
+6 -3
View File
@@ -304,10 +304,13 @@ pub(crate) struct Model {
pub(crate) container_moves: Vec<container::ContainerMove>,
/// Repair-mode clicks the pickup intercept queued (`(bag, slot)`; [`container`]).
pub(crate) container_repairs: Vec<(i64, u32)>,
/// The armed item-targeted cast (the CraftFrame enchant pick, 0437 phase 3): while set, a
/// bag click queues into [`Self::item_picks`] instead of the cursor gesture.
/// The targeting cursor's **item** half is up (`TargetingWantsItem 0x6e6330`, decision 0923;
/// first built for the CraftFrame enchant pick, 0437 phase 3): while set, a bag OR paper-doll
/// click queues into [`Self::item_picks`] instead of running the cursor gesture.
pub(crate) item_pick_armed: bool,
/// `(bag, slot)` clicks consumed by the armed item pick, drained by the app.
/// `(bag, slot)` clicks consumed by the armed item pick, drained by the app. A doll click
/// reports as [`super::EQUIPMENT_BAG`] + its 1-based inventory slot, so both seams speak the
/// one bag space the drain already resolves.
pub(crate) item_picks: Vec<(i64, u32)>,
/// `(bag, slot, count)` triples queued by `DeleteCursorItem` (`count == 0` = the whole
/// stack) — the popup-confirmed destroy (decision 0216 §3), drained by the app into
+18 -10
View File
@@ -29,10 +29,10 @@
the event set registers on the BAR frame and fans to all 12 buttons, instead of the
ref's per-button Register/Unregister churn on HasAction (12 fixed buttons make the
skip-empties optimization noise; the handlers themselves are the ref's).
- Count (decision 0216 §7, slice 4): the ref gates it on IsConsumableAction; we simplify to
"an ITEM-kind action with a positive bag count" (GetActionCount always answers 0 for a
non-item kind, so the gate is really just `count > 0`) — not every consumable is
detectable as such without the full consumable-flag column, which nothing else needs yet.
- Count: ActionButton_UpdateCount transcribed, gated on IsConsumableAction exactly as the
ref does (0216 §7's "count > 0" simplification is retired — it put a stack number under
a mount, which has no stack; the engine-side law is ItemInfo::is_consumable, byte-cited
from 0x4e5250).
- Empty-slot grid (ref ActionButton.lua:241-259's ShowGrid/HideGrid, a per-button COUNTER):
simplified to one bar-level BOOLEAN — the cursor is a single global payload, so no two
"shows" can ever nest, and a counter would just be a boolean with extra steps. Unlike the
@@ -382,6 +382,19 @@
end
end
-- ref ActionButton.lua:285-292 ActionButton_UpdateCount, transcribed. The gate is
-- IsConsumableAction, NOT "count > 0": a consumable you have run out of shows a literal
-- "0", and an item that is not consumable (a mount, a trinket, a bag) shows nothing at
-- all, however many copies you hold.
function BenillaActionButton_UpdateCount(button)
local action = BenillaActionButton_Action(button)
if IsConsumableAction(action) then
button.count:SetText(GetActionCount(action))
else
button.count:SetText("")
end
end
function BenillaActionButton_Update(button)
local action = BenillaActionButton_Action(button)
if HasAction(action) then
@@ -389,12 +402,7 @@
button:SetNormalTexture("Interface\\Buttons\\UI-Quickslot2")
button:Show()
button.rangeTimer = -1
local count = GetActionCount(action)
if count > 0 then
button.count:SetText(count)
else
button.count:SetText("")
end
BenillaActionButton_UpdateCount(button)
BenillaActionButton_UpdateState(button)
BenillaActionButton_UpdateUsable(button)
BenillaActionButton_UpdateCooldown(button)
+16 -1
View File
@@ -503,7 +503,8 @@ use blood::{blood_spurts, load_blood_tables};
use env_damage::{hard_landing_dust, load_env_damage_table};
pub(crate) use env_damage::{EnvDamageTable, HardLanding, HARD_LANDING_DESCENT};
use spell_visual::{
arm_aura_state_fx, arm_level_up_fx, arm_loot_fx, load_spell_visuals, route_cast_visuals,
arm_aura_state_fx, arm_level_up_fx, arm_loot_fx, arm_mount_poof_fx, load_spell_visuals,
route_cast_visuals,
};
// The aura-slot watcher is the shared trigger for BOTH halves of a state kit, so the CharProc half's
// own tests (`crate::aura_visual`) drive it directly rather than re-deriving the slot diff.
@@ -606,6 +607,16 @@ pub(crate) struct AnimDriver {
/// working the divisor out by hand. Recorded rather than re-derived, so the card can never
/// disagree with what is actually playing.
gait_rate: f32,
/// `UNIT_FIELD_MOUNTDISPLAYID` as of the last pass — the driver's **mount-transition edge**
/// detector, the local twin of the client's own UpdateField change-watcher (`0x604329`:
/// TYPEID Unit, descriptor offset `0x1fc`, width 4, thunk `0x604570` → `0x5ffa50`). Any
/// change is an edge — 0→N (mount), N→N′ (swap) and N→0 (dismount) alike — because the
/// reference's handler is one watcher whose two legs BOTH arm bone 0 of the body: the build
/// `0x607b44` op4(bone 0, **91 `Mount`**, cross-fade, PRIMARY), the teardown `0x607ce0` op4
/// seq **0 `Stand`**. The arm is a plain last-writer-wins play, so it *displaces a full-body
/// one-shot the transition catches in flight* — which our gait-slot mount pin alone never did
/// (decision 0927).
mount_display: u32,
/// The Special wanted LAST frame — the driver's Special **edge** detector (decision 0864).
/// An edge is a play in the client (the jump/pose entry, the FALLINGFAR latch's Fall, the
/// land pick), so it clears the deferred-combat cache like any normal arm (`0x5fe48e`);
@@ -710,6 +721,7 @@ impl Default for AnimDriver {
ranged_held: false,
loop_window: None,
gait_rate: 1.0,
mount_display: 0,
last_special: None,
frozen: None,
}
@@ -816,6 +828,9 @@ impl Plugin for CreatureAnimPlugin {
arm_loot_fx,
// Its level-edge sibling: the ding (decision 0305) — same shape, same home.
arm_level_up_fx,
// …and its mount-edge sibling: the poof (decision 0927). Same hardcoded-effect
// spawn shape, one field over.
arm_mount_poof_fx,
// The aura-slot watcher: state kits persist for the aura's life (the bread).
arm_aura_state_fx,
blood_spurts,
+128 -489
View File
@@ -1,10 +1,18 @@
//! The Bevy driver: [`drive_animations`], which executes the state machine [`super::select`]
//! picks, reading and writing [`super::AnimDriver`]'s private state. Kept in its own file as it
//! carries the bulk of the per-frame Bevy system logic, separate from the module face ([`super`])
//! and the pure selector logic ([`super::select`]). Split into a small family of children for its
//! three extractable concerns: the playback primitives ([`play`]), the wound-flinch secondary-blend
//! slot logic ([`wound`]), and the per-hand weapon-grip overlay ([`grip`]) — this file remains the
//! stable face, re-exporting [`grip::drive_hand_grip`] unchanged.
//! and the pure selector logic ([`super::select`]). Split into a small family of children — the
//! playback primitives ([`play`]), the wound-flinch secondary-blend slot logic ([`wound`]), the
//! per-hand weapon-grip overlay ([`grip`]), and the base track's **mode machine** ([`mode`]) —
//! with this file the stable face, re-exporting [`grip::drive_hand_grip`] unchanged.
//!
//! What is left here is one function, [`drive_animations`], and it is a *sequence of phases* over
//! one unit, not a set of concerns: the prologue that computes the frame's facts, the death
//! override, the mount transition edge, the sheath state, the one-shot gather-and-arm, the mode
//! machine (now [`mode`]), the rate write, the fades, the trace, the wound, the sheath reconcile.
//! The phases share ~20 live bindings and a handful of frame-local flags, so they do not come
//! apart on a module boundary the way the four children did — decision 0933 records which cut was
//! taken, which was not, and why.
use std::time::Duration;
@@ -21,9 +29,8 @@ use crate::net::{
use crate::sound::EmoteSounds;
use super::select::{
self, current_special, defense_anim, gait_candidates, is_bare_stand, is_swing_id,
playback_rate, ready_anim, route_oneshot, state_emote_gait, swing_anim_main, swing_anim_off,
unify, Mode, OneShotRoute, DEATH, DEFAULT_WALK_SPEED, STAND,
self, current_special, defense_anim, is_swing_id, route_oneshot, swing_anim_main,
swing_anim_off, unify, Mode, OneShotRoute, DEATH, DEFAULT_WALK_SPEED, STAND,
};
use super::sheath::{advance_sheath_ceremony, start_sheath_ceremony};
use super::{
@@ -33,15 +40,14 @@ use super::{
};
mod grip;
mod mode;
mod play;
#[cfg(test)]
mod tests;
mod wound;
pub(super) use grip::drive_hand_grip;
use play::{
enter_special, leave_special, oneshot_finished, play, play_clip, roll_loop, roll_oneshot,
};
use play::{oneshot_finished, play_clip, roll_loop, roll_oneshot};
use wound::{wound_evict, wound_trigger, wound_upkeep, WoundEdge};
/// The weight of a masked upper-body one-shot overlay ([`AnimDriver::overlay`]) over the base clip on
@@ -484,7 +490,13 @@ pub(super) fn drive_animations(
// picked plays on the MOUNT child entity instead (its own driver pass, fed the host's own
// movement view through `MountBody.host` — the `mount_hosts` fetch at the top of this
// loop). A mount child itself has no `ObjectStore`, so it can never read as mounted here.
let mounted = store.is_some_and(|s| s.0.unit_mount_display_id() != 0);
let mount_display = store.map_or(0, |s| s.0.unit_mount_display_id());
let mounted = mount_display != 0;
// The transition EDGE, consumed after the death override below ([`AnimDriver::mount_display`]
// carries the byte-cited why). Latched here, with the other per-frame edge bookkeeping, so
// a pass that returns early still consumes it: a unit that mounts and dies in the same
// breath must not have the arm fire late, over the death play that owns bone 0.
let mount_edge = std::mem::replace(&mut drv.mount_display, mount_display) != mount_display;
// First time we drive this unit (nothing chosen yet) — used to settle a corpse to its end pose.
let first = drv.gait.is_none() && drv.mode == Mode::Gait;
let mut mv = unify(movement, remote, spline, creature_swimming);
@@ -590,6 +602,77 @@ pub(super) fn drive_animations(
continue;
}
// **The mount transition's own bone-0 arm** (wow-re `mount-cast-anim-disposition.md` §1 +
// `mount-composition.md` Q4, §5 2026-08-03). Both legs of the `UNIT_FIELD_MOUNTDISPLAYID`
// watcher arm the BODY's bone 0 through op4 `0x7121a0` on the PRIMARY slot: the build
// `0x607b44` plays **91 `Mount`** with a cross-fade, the teardown `0x607ce0` plays seq
// **0 `Stand`** with the cross-fade **off** (decision 0931 — the two legs are the same
// seven-argument call differing in that one literal, and the difference is visible).
// Because that is an ordinary last-writer-wins play, it **displaces a full-body one-shot
// the transition catches mid-clip** — and our gait-slot mount pin (below) never could:
// `Mode::Swing` holds bone 0 until its clip ends, so the pin waited.
//
// That is exactly B203. The mount summon's cast-stage kit (1604/1607 → **54
// `SpellCastOmni`**) is armed at SPELL_GO, while the caster is still *unmounted*, so it
// takes the full-body route; the mount field then lands a beat later and, without this
// arm, the caster sat on the horse finishing a standing spellcast. The reference plays
// the same clip and then takes bone 0 straight back — which is why the director sees "no
// cast animation", only the poof.
//
// Deliberately NOT touched: the masked overlay. `0x607b44` names bone 0, the key bone is
// a different per-bone block, and op4's replace-notice fires only for the bone's own
// primary — so an upper-body clip rides the transition untouched. (While mounted every
// later one-shot takes the masked route anyway, and the reference's own re-force
// `0x5fe803`–`0x5fe816` re-pins 91 on every subsequent play; the gait pin renders that.)
if mount_edge {
if matches!(drv.mode, Mode::Swing { .. }) {
drv.mode = Mode::Gait;
}
if mounted {
// A FULL-BODY wound in flight is evicted by a **blended** primary re-arm — the
// same shared-slot eviction the death play does above (decision 0114): op4's
// cross-fade path snapshots the outgoing pose *into* the secondary slot
// (`rep movsd` at `0x7125d9`). Only the BUILD leg blends (`0x607b35 push 0x1`);
// the teardown's `crossFadeFlag = 0` jumps past that memcpy entirely
// (`0x71253e`–`0x712543`), so a dismount leaves the secondary alone — decision
// 0931. A masked wound rides the key bone and decays out on its own, either way.
if let Some(wd) = drv.wound.take_if(|wd| !wd.masked) {
player.stop(wd.node);
}
} else {
// **The teardown CUTS** (decision 0931 — the director's "the dismount looks like
// it's landing after a jump"). `0x607ce0`'s arm is
// `op4(bone −1, anim 0 Stand, variation −1, bias 0, rate 1.0, crossFade 0,
// PRIMARY)` at `0x607d1a`–`0x607d2b` — byte-for-byte the build leg's call with
// **one** literal different: `push 0x0` where `0x607b35` pushes `0x1`. So the
// saddle pose does not blend away, it is *gone* on the arm's first frame.
//
// Blending it away is what the crouch-and-recover was: Mount(91) splays and bends
// the legs, and easing that into Stand over the clip's blend time reads as a body
// absorbing an impact. There is no landing clip anywhere near this — the shape of
// the cross-fade WAS the landing.
//
// The gait re-pick below is the arm's own successor, `RecomputeBaseAnim(−1)` at
// `0x607d30`–`0x607d34` (`0x5fd9e0`: run the base chain `0x5fd8b0`, play the
// result through `0x5fe2f0` unless it comes back `0xD0`). It blends normally —
// which is right, because it now blends from **Stand**, not from the saddle.
play::cut_loop(
&mut tr,
&mut player,
anims,
STAND,
catalog,
&mut rng,
&mut drv.loop_window,
);
}
// Force a fresh gait pick even when the selector's answer is unchanged: the id may
// still read `Stand` on both sides of a dismount, but the *node* holding bone 0 is
// the displaced one-shot's, and only a re-arm takes it back.
drv.gait = None;
drv.deferred = None; // a normal arm clears the fast-path cache (`0x5fe48e`)
}
// The loot kneel's trigger (see [`select::LOOT`]; byte-verified, wow-re
// `loot-anim-leg.md` — the 2026-07-18 §5 fold-back, decision 0515): the predicate
// (`0x6126b0`) SPLITS on IsActivePlayer — the **self** unit kneels off the client-local
@@ -968,484 +1051,40 @@ pub(super) fn drive_animations(
}
}
}
match drv.mode {
Mode::Entering(sp) => {
// The swim re-latch does NOT cut the hop's kick: JumpStart PLAYS OUT over the
// re-latch and the swim gait resumes only at its end (decision 0517 —
// director-corrected against the ref; the §5's static cut-at-relatch law could
// not reproduce the screen and is flagged wow-re-side for a live capture).
// Only the swim re-latch holds — a ground landing, a water exit, or a new
// Special still cuts (0503's snapshot-freeze).
let swim_relatch_hold = sp == select::Special::Jump
&& special.is_none()
&& mv.flags & move_flags::SWIMMING != 0
&& !oneshot_finished(&player, anims, sp.enter(), catalog);
if swim_relatch_hold {
// Hold: the kick keeps playing; the gait recompute waits at its end.
} else if special != Some(sp) {
// What we're entering is no longer wanted before the enter even finished — preempt
// to the new Special, to the gait (a pose cut by movement), or to this one's exit.
let drv = &mut *drv; // two disjoint field borrows below (window + frozen)
drv.mode = leave_special(
sp,
special,
moving,
relaxed,
mv.flags,
&mut tr,
&mut player,
anims,
catalog,
&mut rng,
&mut drv.loop_window,
&mut drv.frozen,
);
} else if oneshot_finished(&player, anims, sp.enter(), catalog) {
play(
&mut tr,
&mut player,
anims,
sp.loop_id(),
true,
relaxed,
1.0,
catalog,
&mut rng,
&mut drv.loop_window,
);
drv.mode = Mode::Looping(sp);
}
}
Mode::Looping(sp) => {
if special != Some(sp) {
let drv = &mut *drv; // two disjoint field borrows below (window + frozen)
drv.mode = leave_special(
sp,
special,
moving,
relaxed,
mv.flags,
&mut tr,
&mut player,
anims,
catalog,
&mut rng,
&mut drv.loop_window,
&mut drv.frozen,
);
}
}
Mode::Land { id, flags } => {
// The jump landing (39/187) as a freely-overwritten pick (decisions 0083/0087 (d)).
if let Some(sp) = special {
// A new jump or a pose interrupts (a second jump, or sitting right after landing).
drv.mode = enter_special(
sp,
relaxed,
&mut tr,
&mut player,
anims,
catalog,
&mut rng,
&mut drv.loop_window,
);
} else if mv.flags != flags {
// Any movement-flag change re-picks from live state *immediately* — land-then-press
// runs, land-then-release stands, a direction flip drops the stale-direction land.
drv.mode = Mode::Gait;
drv.gait = None;
} else if oneshot_finished(&player, anims, id, catalog) {
// Input held steady through the whole landing: fall through to a fresh gait pick.
drv.mode = Mode::Gait;
drv.gait = None;
}
}
Mode::Exiting(sp, exit) => {
// Pose stand-ups only now (Jump lands via `Mode::Land`, not this bracket).
if let Some(next) = special {
// A new Special interrupts the exit — re-sitting during the stand-up, say. Enter
// it straight away instead of waiting the stand-up out.
drv.mode = enter_special(
next,
relaxed,
&mut tr,
&mut player,
anims,
catalog,
&mut rng,
&mut drv.loop_window,
);
} else if sp.interruptible_by_move() && moving {
// Started moving mid stand-up: drop the rest, let the gait cross-fade take over.
drv.mode = Mode::Gait;
drv.gait = None;
} else if oneshot_finished(&player, anims, exit, catalog) {
drv.mode = Mode::Gait;
drv.gait = None; // recompute a fresh gait next frame
}
}
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
// latch's Fall (`Jump → Fall`), the `0x602c60` land pick at touchdown
// (`Some → None`) — each a plain PlayAnimation over bone 0, never a
// "restore". Leaving an airborne/pose `under` routes through
// [`leave_special`] exactly like the un-replaced machine: the latch
// handoff, the landing pick, and the pose exits all apply unchanged.
//
// …but FIRST the **transplant** (decision 0878): when what the base is about
// to play is a LOCOMOTION clip — a jump entry (37), a land pick (39/187) — and
// this one-shot is a live CAST/COMBAT clip, the client moves it up onto the
// key-bone rather than letting the request overwrite it. Fall(40) and the pose
// enters/exits are NOT locomotion ids, so a FALLINGFAR latch or a sit-down
// still replaces the clip on bone 0, exactly as the bytes order it.
let incoming_locomotion = match (under, special) {
(_, Some(next)) => select::is_locomotion(next.enter()),
(Some(select::Special::Jump | select::Special::Fall), None) => {
select::jump_land_pick(mv.flags).is_some_and(select::is_locomotion)
}
_ => false,
};
if incoming_locomotion {
transplant_up(&mut drv, &mut player, &tr, anims, entity, id);
}
let drv = &mut *drv; // two disjoint field borrows below (window + frozen)
drv.mode = if let Some(sp) = under {
leave_special(
sp,
special,
moving,
relaxed,
mv.flags,
&mut tr,
&mut player,
anims,
catalog,
&mut rng,
&mut drv.loop_window,
&mut drv.frozen,
)
} else if let Some(sp) = special {
enter_special(
sp,
relaxed,
&mut tr,
&mut player,
anims,
catalog,
&mut rng,
&mut drv.loop_window,
)
} else {
Mode::Gait // unreachable: special != under with under None ⇒ special Some
};
} else if matches!(under, Some(select::Special::Jump | select::Special::Fall)) {
// The airborne-freeze (`0x5fd8e8` keep-current): mid-arc nothing re-picks
// bone 0 — a finished clip clamps and holds its last frame for the rest of
// the arc (the §6 clamp path), and a mid-air flag change is a keep-current
// no-op. The only exits are the edges above: the FALLINGFAR latch's Fall
// and the land pick at touchdown. This holds over Fall too: 0864's per-tick
// Fall(40) re-assert was §5-REFUTED (decision 0868 — Fall plays ONCE, at the
// latch edge `0x61a820@0x61a9eb`; `0x5ff030` is a wire-apply path, not a
// tick), so a clip that takes bone 0 after the latch holds until landing.
} else if let Some(sp) = under {
// A pose held under the one-shot: on finish, back to the pose LOOP directly
// (decision 0083 (c) — the enter never replays after an interruption).
if oneshot_finished(&player, anims, id, catalog) {
play(
&mut tr,
&mut player,
anims,
sp.loop_id(),
true,
relaxed,
1.0,
catalog,
&mut rng,
&mut drv.loop_window,
);
drv.mode = Mode::Looping(sp);
}
} 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.
//
// 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 **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
}
}
Mode::Gait => {
if let Some(sp) = special {
// Enter a Special state.
drv.mode = enter_special(
sp,
relaxed,
&mut tr,
&mut player,
anims,
catalog,
&mut rng,
&mut drv.loop_window,
);
drv.gait = None;
} else if airborne_frozen && drv.gait.is_some_and(|g| g != DEATH) {
// The airborne-freeze on the STEP-OFF arc — the exact §5-verified gate
// (`0x5fd8e8`: `FALLING && (FALLINGFAR || vz ≠ 0)`, decisions 0864/0868;
// the selector chain's leg right after death): mid-air the selector never
// re-picks, so the takeoff-frozen gait keeps rolling mid-cycle AND the live
// pins further down the chain (the stationary cast hold, the loot kneel, the
// Ready/ranged idles, the state-emote idle) cannot swap the clip until
// touchdown. Rate stays synced — the client's per-frame rate write is outside
// the selector, so it is [`play::sync_base_rate`]'s below and this arm does
// nothing at all. (DEATH is excluded: the dead-override owns that gait, and a
// mid-air revive must re-select, not hold the corpse pose.)
} else {
// A bracket-less step-off fall landing needs NO case of its own: the arc never
// latched FALLINGFAR, so the `0x602c60` land dispatcher is a verified no-op
// (decision 0179) — and a no-op means the gait must keep rolling mid-cycle,
// not be re-picked (a re-pick replays the run cycle from its head: the
// landing-frame leg pop, decision 0187). Falling through keeps the clip when
// the flags still agree and cross-fades normally when they changed mid-air.
// Normal gait: select, cross-fade on change, keep the rate synced each frame.
// The engaged standing idle: the weapon-class Ready pick (decision 0073).
let ready =
(engaged && !moving).then(|| ready_anim(wielded.and_then(|w| w.main)));
// The ranged standing idle (0099 phase 5): sheath ranged + either armed
// bit → the ranged weapon's Load/Hold clip. `auto_repeat` is the local
// `0x200` (the resolver `0x5fd460`'s own gate); `ranged_hold` is the
// any-caster `0x400` weapon-visual hold — what puts a REMOTE shooter (an
// NPC archer, another hunter) in the drawn idle between shots (HoldBow
// sustains on `0x200|0x400`, rifle/thrown on `0x400`; the per-weapon
// asymmetry is folded into this one gate — no benilla-visible difference).
// After one full pass of the Load clip the idle swaps to the drawn HOLD
// twin — nock once, then HOLD through every shot: a mid-volley fire clip
// returns straight to the hold, never a full re-pull (director-refuted;
// decision 0409). The pull replays only when the idle is LEFT and
// re-entered (volley start, movement, sheath change — the reset at this
// arm's tail). Named deviation: a remote's post-movement re-entry replays
// the pull like the local player's, where the byte corollary (the
// `0x200`-gated resolver) would leave a remote in plain Stand — unobserved
// on the ref, and the derivation rests on the LOAD-ANIM-RECOMPUTE-MODE
// open item; revisit if the ref's remote hunters visibly differ.
let ranged_load =
((auto_repeat || ranged_hold) && !moving && drv.sheath_cur == Some(2))
.then(|| select::ranged_load_anim(wielded.and_then(|w| w.ranged)))
.map(|id| {
if drv.ranged_held {
select::ranged_hold_anim(id)
} else {
id
}
});
let cands = gait_candidates(&mv, walk, ready, ranged_load);
// The stationary cast/channel hold pins its pose **full-body in the gait slot**
// (decision 0107 — the client's `[CGUnit+0xb4]` stationary-cast gate),
// outranking the Ready idle and the state-emote idle below. "Stationary" is
// the client's `[9e8] & 0x20000f` test ([`move_flags::CAST_PIN_MOVE`]:
// translation + swim, NEVER the turn bits) — a turning caster keeps the pin,
// feet sliding; only a translating/swimming one falls through to the masked
// hold overlay (the hold block after the mode machine). Testing the turn bits
// here was the frostbolt right-drag jitter (decision 0491).
let hold_cands;
let cands: &[u16] = match cast_hold {
Some(h) if mv.flags & move_flags::CAST_PIN_MOVE == 0 => {
hold_cands = [h.anim_id, STAND];
&hold_cands
}
_ => cands,
};
// The looping state-emote idle (`UNIT_NPC_EMOTESTATE`: `/dance`, NPC
// cooking/sweeping flavor loops): fills exactly the bare-Stand slot
// (`is_bare_stand`) — everything that already outranks Stand (movement, turn,
// swim, the Ready idle, a chair-loop stand-state) has already routed `cands`
// elsewhere, and Special is handled entirely above this arm. Cleared field
// (`unit_emote_state() == 0`, or the catalog has no `AnimID` for it) falls
// straight through to `cands` unchanged — back to Stand.
let state_emote_cands;
let cands: &[u16] = if is_bare_stand(cands) {
let emote_anim = store.and_then(|s| {
emote_sounds
.as_deref()
.and_then(|e| e.anim(s.0.unit_emote_state()))
});
match emote_anim {
Some(id) => {
state_emote_cands = state_emote_gait(id as u16);
&state_emote_cands
}
None => cands,
}
} else {
cands
};
// The loot kneel (see [`select::LOOT`]): while the loot trigger is up
// (self: the latch; remote: the flag — the `looting` predicate above) on a
// stationary, unmounted unit, the gait slot holds Loot 50 — over the cast
// pin, the Ready/ranged idles, the chair loops and the state-emote idle
// alike (the `0x5fd8b0` chain order, §5-verified: locomotion → LOOT →
// standState → combat/channel). The trigger dropping cross-fades back to
// whatever the slot picks next.
let loot_cands;
let cands: &[u16] = if looting {
loot_cands = [select::LOOT, STAND];
&loot_cands
} else {
cands
};
// The mounted pin outranks the whole gait slot (decision 0442 confirms 0441's
// B1): the rider holds Mount(91) — moving, turning, engaged — while the mount
// child's own driver plays the locomotion this selector would have picked. The
// real client has no mount leg in this chain at all — it arms 91 once at
// attach and re-forces it on every PlayAnimation instead of selecting it here;
// our gait-slot pin renders the identical result without modeling the
// re-force. 91 is not rate-scaled, and the resolver's Stand fallback covers a
// body that doesn't author it.
let mount_cands;
let cands: &[u16] = if mounted {
mount_cands = [select::MOUNT, STAND];
&mount_cands
} else {
cands
};
let target = cands[0];
// Each RF-0057 candidate, in priority order, resolved through the model's own
// baked fallback (decision 0082) before moving to the next candidate — a model
// missing the exact id still plays its baked substitute rather than stepping
// down the selector's own list early. The state-emote idle's id (above) resolves
// through the same call, like every other candidate.
let clip = cands
.iter()
.find_map(|&id| find_resolved(anims, id, catalog));
if drv.gait == Some(target) {
if let Some(c) = clip {
// The gait is already armed and stays armed — its rate is
// [`play::sync_base_rate`]'s per-frame write below, which finds
// whichever *rolled variation* (decision 0123) is actually the live
// one rather than sweeping every node of the id.
//
// One completed pass of the ranged Load clip arms the HOLD twin
// (the `ranged_load` map above) — next frame the idle cross-fades
// to the drawn hold instead of replaying the nock.
if select::is_ranged_load(target)
&& anims
.clips
.iter()
.filter(|v| v.anim_id == c.anim_id)
.any(|v| {
player
.animation(v.node)
.is_some_and(|a| a.completions() >= 1)
})
{
drv.ranged_held = true;
}
}
} else if let Some(c) = clip {
// A looping base arm rolls its variation when relaxed (decision 0123 —
// the client's base-arm `variationIdx = −1`; a combat/cast arm keeps the
// deterministic head) AND its replay budget (decision 0516 §7d — the
// watchdog window). A re-armed Stand landing on its rare look-around
// variations IS the idle fidget.
let (c, budget) = roll_loop(anims, c, relaxed, &mut rng);
if traced && crate::dbg_trace::enabled() {
// Every fresh gait play, including a same-clip replay (which the
// settled-state diff below cannot see) — the exact restart-from-head
// event a "frames snap" report is hunting.
crate::dbg_trace::line(
"anim",
&format!(
"{subject}PLAY gait {} (was {:?}) rate {:.2}",
c.anim_id,
drv.gait,
playback_rate(c, mv.speed, model_scale)
),
);
}
// The ranged Load plays ONCE and freezes at full draw: as a Forever
// gait it WRAPPED to its head at completion — the frames + cross-fade
// against the restarted reach-to-quiver were the director's "jumps
// back to the start the moment it gets fully pulled", in every build
// that replayed a pull (trace-caught, decision 0412). The promotion
// then cross-fades the hold from the matching drawn pose.
// Loot 50 is likewise authored clamp — one 0.5 s kneel-down that must
// FREEZE in the rummage pose; as Forever it would wrap back to standing
// and re-kneel every half second.
let repeat = if select::is_ranged_load(target) || target == select::LOOT {
// A deliberate freeze — no window either: the client's ranged
// hand-off is the HOLD promotion, not a watchdog re-pull.
drv.loop_window = None;
bevy::animation::RepeatAnimation::Never
} else {
drv.loop_window = Some((c.node, budget));
bevy::animation::RepeatAnimation::Forever
};
drv.gait_rate = playback_rate(c, mv.speed, model_scale);
play_clip(&mut tr, &mut player, c, repeat, drv.gait_rate);
drv.gait = Some(target);
// A fresh pick of the drawn HOLD re-nocks (INTERIM, decision 0409): the
// hold resuming after a fire clip (or after the Load promotion) shows
// the arrow + drawn string again without a full re-pull — the `$BWP`
// re-latch only lives in the Load clip our mid-volley cycle no longer
// replays. The ref's exact mid-volley re-attach source is the Q-I
// dispatch; the arrow itself still gates on a cached NockedAmmo.
if drv.ranged_held && ranged_load == Some(target) {
commands.entity(entity).insert(super::NockLatch);
}
} 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() {
drv.ranged_held = false;
}
}
}
}
// ── **The mode machine** — the base track's whole decision, lifted into [`mode`]
// (decision 0933). It is the one phase of this pass with a clean input boundary: a fixed
// set of already-computed facts in, the driver + player + transitions out, and none of
// this loop's frame-local flags touched.
mode::run(
mode::Frame {
entity,
anims,
catalog,
mv,
special,
moving,
relaxed,
mounted,
looting,
engaged,
airborne_frozen,
auto_repeat,
ranged_hold,
cast_hold,
wielded,
store,
emote_sounds: emote_sounds.as_deref(),
walk,
model_scale,
traced,
subject,
},
&mut drv,
&mut tr,
&mut player,
&mut rng,
&mut commands,
);
// ── The base slot's **playback rate**, written once per frame over whatever the mode
// machine settled on — the client's rate write sits OUTSIDE the selector (`0x5fe2f0`), so
@@ -0,0 +1,566 @@
//! The **mode machine** — the `match` over [`AnimDriver::mode`] that decides what the base track
//! (bone 0) plays this frame, and the largest single phase of [`super::drive_animations`]'s
//! per-unit pass.
//!
//! It is the one phase with a clean input boundary, which is why it is the one that lifted out:
//! it reads a fixed set of already-computed per-frame facts ([`Frame`]) and writes only the
//! driver, the animation player and the transition set. In particular it touches **none** of the
//! frame-local flags the rest of the pass threads through itself (`base_played`, `masked_played`,
//! `played_oneshot`, `hold_played`, `sheath_frame_start`) — the seam is real, not just a line
//! count. The phases either side of it are not separable that way today; see decision 0933.
//!
//! The five modes and their transitions are documented on [`super::select::Mode`]; this file is
//! the executor, not the law.
use benilla_assets::ModelAnimations;
use benilla_formats::AnimDataCatalog;
use bevy::animation::transition::AnimationTransitions;
use bevy::prelude::*;
use crate::net::ObjectStore;
use crate::sound::EmoteSounds;
use super::super::select::{
self, gait_candidates, is_bare_stand, playback_rate, ready_anim, state_emote_gait, Mode, DEATH,
STAND,
};
use super::super::{find_resolved, move_flags, AnimDriver, CastHold, MovementState, Wielded};
use super::play::{enter_special, leave_special, oneshot_finished, play, play_clip, roll_loop};
use super::transplant_up;
/// Everything the mode machine reads about this unit this frame — the facts
/// [`super::drive_animations`]'s prologue has already computed. All `Copy`, so the machine
/// destructures it back into the same names the code was written against.
#[derive(Clone, Copy)]
pub(super) struct Frame<'a> {
pub(super) entity: Entity,
pub(super) anims: &'a ModelAnimations,
pub(super) catalog: Option<&'a AnimDataCatalog>,
pub(super) mv: MovementState,
pub(super) special: Option<select::Special>,
pub(super) moving: bool,
pub(super) relaxed: bool,
pub(super) mounted: bool,
pub(super) looting: bool,
pub(super) engaged: bool,
pub(super) airborne_frozen: bool,
pub(super) auto_repeat: bool,
pub(super) ranged_hold: bool,
pub(super) cast_hold: Option<&'a CastHold>,
pub(super) wielded: Option<&'a Wielded>,
pub(super) store: Option<&'a ObjectStore>,
pub(super) emote_sounds: Option<&'a EmoteSounds>,
pub(super) walk: f32,
pub(super) model_scale: f32,
/// Whether this body's plays go to the `WOW_MOVE_TRACE` anim trace, and under which label —
/// the rider's own, or the mount it is sitting on (decision 0906).
pub(super) traced: bool,
pub(super) subject: &'a str,
}
/// Run one frame of the mode machine over `drv`.
pub(super) fn run(
f: Frame<'_>,
drv: &mut AnimDriver,
tr: &mut AnimationTransitions,
player: &mut AnimationPlayer,
rng: &mut u32,
commands: &mut Commands,
) {
let Frame {
entity,
anims,
catalog,
mv,
special,
moving,
relaxed,
mounted,
looting,
engaged,
airborne_frozen,
auto_repeat,
ranged_hold,
cast_hold,
wielded,
store,
emote_sounds,
walk,
model_scale,
traced,
subject,
} = f;
match drv.mode {
Mode::Entering(sp) => {
// The swim re-latch does NOT cut the hop's kick: JumpStart PLAYS OUT over the
// re-latch and the swim gait resumes only at its end (decision 0517 —
// director-corrected against the ref; the §5's static cut-at-relatch law could
// not reproduce the screen and is flagged wow-re-side for a live capture).
// Only the swim re-latch holds — a ground landing, a water exit, or a new
// Special still cuts (0503's snapshot-freeze).
let swim_relatch_hold = sp == select::Special::Jump
&& special.is_none()
&& mv.flags & move_flags::SWIMMING != 0
&& !oneshot_finished(player, anims, sp.enter(), catalog);
if swim_relatch_hold {
// Hold: the kick keeps playing; the gait recompute waits at its end.
} else if special != Some(sp) {
// What we're entering is no longer wanted before the enter even finished — preempt
// to the new Special, to the gait (a pose cut by movement), or to this one's exit.
drv.mode = leave_special(
sp,
special,
moving,
relaxed,
mv.flags,
tr,
player,
anims,
catalog,
rng,
&mut drv.loop_window,
&mut drv.frozen,
);
} else if oneshot_finished(player, anims, sp.enter(), catalog) {
play(
tr,
player,
anims,
sp.loop_id(),
true,
relaxed,
1.0,
catalog,
rng,
&mut drv.loop_window,
);
drv.mode = Mode::Looping(sp);
}
}
Mode::Looping(sp) => {
if special != Some(sp) {
drv.mode = leave_special(
sp,
special,
moving,
relaxed,
mv.flags,
tr,
player,
anims,
catalog,
rng,
&mut drv.loop_window,
&mut drv.frozen,
);
}
}
Mode::Land { id, flags } => {
// The jump landing (39/187) as a freely-overwritten pick (decisions 0083/0087 (d)).
if let Some(sp) = special {
// A new jump or a pose interrupts (a second jump, or sitting right after landing).
drv.mode = enter_special(
sp,
relaxed,
tr,
player,
anims,
catalog,
rng,
&mut drv.loop_window,
);
} else if mv.flags != flags {
// Any movement-flag change re-picks from live state *immediately* — land-then-press
// runs, land-then-release stands, a direction flip drops the stale-direction land.
drv.mode = Mode::Gait;
drv.gait = None;
} else if oneshot_finished(player, anims, id, catalog) {
// Input held steady through the whole landing: fall through to a fresh gait pick.
drv.mode = Mode::Gait;
drv.gait = None;
}
}
Mode::Exiting(sp, exit) => {
// Pose stand-ups only now (Jump lands via `Mode::Land`, not this bracket).
if let Some(next) = special {
// A new Special interrupts the exit — re-sitting during the stand-up, say. Enter
// it straight away instead of waiting the stand-up out.
drv.mode = enter_special(
next,
relaxed,
tr,
player,
anims,
catalog,
rng,
&mut drv.loop_window,
);
} else if sp.interruptible_by_move() && moving {
// Started moving mid stand-up: drop the rest, let the gait cross-fade take over.
drv.mode = Mode::Gait;
drv.gait = None;
} else if oneshot_finished(player, anims, exit, catalog) {
drv.mode = Mode::Gait;
drv.gait = None; // recompute a fresh gait next frame
}
}
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
// latch's Fall (`Jump → Fall`), the `0x602c60` land pick at touchdown
// (`Some → None`) — each a plain PlayAnimation over bone 0, never a
// "restore". Leaving an airborne/pose `under` routes through
// [`leave_special`] exactly like the un-replaced machine: the latch
// handoff, the landing pick, and the pose exits all apply unchanged.
//
// …but FIRST the **transplant** (decision 0878): when what the base is about
// to play is a LOCOMOTION clip — a jump entry (37), a land pick (39/187) — and
// this one-shot is a live CAST/COMBAT clip, the client moves it up onto the
// key-bone rather than letting the request overwrite it. Fall(40) and the pose
// enters/exits are NOT locomotion ids, so a FALLINGFAR latch or a sit-down
// still replaces the clip on bone 0, exactly as the bytes order it.
let incoming_locomotion = match (under, special) {
(_, Some(next)) => select::is_locomotion(next.enter()),
(Some(select::Special::Jump | select::Special::Fall), None) => {
select::jump_land_pick(mv.flags).is_some_and(select::is_locomotion)
}
_ => false,
};
if incoming_locomotion {
transplant_up(drv, player, tr, anims, entity, id);
}
drv.mode = if let Some(sp) = under {
leave_special(
sp,
special,
moving,
relaxed,
mv.flags,
tr,
player,
anims,
catalog,
rng,
&mut drv.loop_window,
&mut drv.frozen,
)
} else if let Some(sp) = special {
enter_special(
sp,
relaxed,
tr,
player,
anims,
catalog,
rng,
&mut drv.loop_window,
)
} else {
Mode::Gait // unreachable: special != under with under None ⇒ special Some
};
} else if matches!(under, Some(select::Special::Jump | select::Special::Fall)) {
// The airborne-freeze (`0x5fd8e8` keep-current): mid-arc nothing re-picks
// bone 0 — a finished clip clamps and holds its last frame for the rest of
// the arc (the §6 clamp path), and a mid-air flag change is a keep-current
// no-op. The only exits are the edges above: the FALLINGFAR latch's Fall
// and the land pick at touchdown. This holds over Fall too: 0864's per-tick
// Fall(40) re-assert was §5-REFUTED (decision 0868 — Fall plays ONCE, at the
// latch edge `0x61a820@0x61a9eb`; `0x5ff030` is a wire-apply path, not a
// tick), so a clip that takes bone 0 after the latch holds until landing.
} else if let Some(sp) = under {
// A pose held under the one-shot: on finish, back to the pose LOOP directly
// (decision 0083 (c) — the enter never replays after an interruption).
if oneshot_finished(player, anims, id, catalog) {
play(
tr,
player,
anims,
sp.loop_id(),
true,
relaxed,
1.0,
catalog,
rng,
&mut drv.loop_window,
);
drv.mode = Mode::Looping(sp);
}
} 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.
//
// 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 **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(drv, player, tr, anims, entity, id);
}
}
drv.mode = Mode::Gait;
drv.gait = None; // recompute a fresh gait next frame
}
}
Mode::Gait => {
if let Some(sp) = special {
// Enter a Special state.
drv.mode = enter_special(
sp,
relaxed,
tr,
player,
anims,
catalog,
rng,
&mut drv.loop_window,
);
drv.gait = None;
} else if airborne_frozen && drv.gait.is_some_and(|g| g != DEATH) {
// The airborne-freeze on the STEP-OFF arc — the exact §5-verified gate
// (`0x5fd8e8`: `FALLING && (FALLINGFAR || vz ≠ 0)`, decisions 0864/0868;
// the selector chain's leg right after death): mid-air the selector never
// re-picks, so the takeoff-frozen gait keeps rolling mid-cycle AND the live
// pins further down the chain (the stationary cast hold, the loot kneel, the
// Ready/ranged idles, the state-emote idle) cannot swap the clip until
// touchdown. Rate stays synced — the client's per-frame rate write is outside
// the selector, so it is [`play::sync_base_rate`]'s below and this arm does
// nothing at all. (DEATH is excluded: the dead-override owns that gait, and a
// mid-air revive must re-select, not hold the corpse pose.)
} else {
// A bracket-less step-off fall landing needs NO case of its own: the arc never
// latched FALLINGFAR, so the `0x602c60` land dispatcher is a verified no-op
// (decision 0179) — and a no-op means the gait must keep rolling mid-cycle,
// not be re-picked (a re-pick replays the run cycle from its head: the
// landing-frame leg pop, decision 0187). Falling through keeps the clip when
// the flags still agree and cross-fades normally when they changed mid-air.
// Normal gait: select, cross-fade on change, keep the rate synced each frame.
// The engaged standing idle: the weapon-class Ready pick (decision 0073).
let ready = (engaged && !moving).then(|| ready_anim(wielded.and_then(|w| w.main)));
// The ranged standing idle (0099 phase 5): sheath ranged + either armed
// bit → the ranged weapon's Load/Hold clip. `auto_repeat` is the local
// `0x200` (the resolver `0x5fd460`'s own gate); `ranged_hold` is the
// any-caster `0x400` weapon-visual hold — what puts a REMOTE shooter (an
// NPC archer, another hunter) in the drawn idle between shots (HoldBow
// sustains on `0x200|0x400`, rifle/thrown on `0x400`; the per-weapon
// asymmetry is folded into this one gate — no benilla-visible difference).
// After one full pass of the Load clip the idle swaps to the drawn HOLD
// twin — nock once, then HOLD through every shot: a mid-volley fire clip
// returns straight to the hold, never a full re-pull (director-refuted;
// decision 0409). The pull replays only when the idle is LEFT and
// re-entered (volley start, movement, sheath change — the reset at this
// arm's tail). Named deviation: a remote's post-movement re-entry replays
// the pull like the local player's, where the byte corollary (the
// `0x200`-gated resolver) would leave a remote in plain Stand — unobserved
// on the ref, and the derivation rests on the LOAD-ANIM-RECOMPUTE-MODE
// open item; revisit if the ref's remote hunters visibly differ.
let ranged_load =
((auto_repeat || ranged_hold) && !moving && drv.sheath_cur == Some(2))
.then(|| select::ranged_load_anim(wielded.and_then(|w| w.ranged)))
.map(|id| {
if drv.ranged_held {
select::ranged_hold_anim(id)
} else {
id
}
});
let cands = gait_candidates(&mv, walk, ready, ranged_load);
// The stationary cast/channel hold pins its pose **full-body in the gait slot**
// (decision 0107 — the client's `[CGUnit+0xb4]` stationary-cast gate),
// outranking the Ready idle and the state-emote idle below. "Stationary" is
// the client's `[9e8] & 0x20000f` test ([`move_flags::CAST_PIN_MOVE`]:
// translation + swim, NEVER the turn bits) — a turning caster keeps the pin,
// feet sliding; only a translating/swimming one falls through to the masked
// hold overlay (the hold block after the mode machine). Testing the turn bits
// here was the frostbolt right-drag jitter (decision 0491).
let hold_cands;
let cands: &[u16] = match cast_hold {
Some(h) if mv.flags & move_flags::CAST_PIN_MOVE == 0 => {
hold_cands = [h.anim_id, STAND];
&hold_cands
}
_ => cands,
};
// The looping state-emote idle (`UNIT_NPC_EMOTESTATE`: `/dance`, NPC
// cooking/sweeping flavor loops): fills exactly the bare-Stand slot
// (`is_bare_stand`) — everything that already outranks Stand (movement, turn,
// swim, the Ready idle, a chair-loop stand-state) has already routed `cands`
// elsewhere, and Special is handled entirely above this arm. Cleared field
// (`unit_emote_state() == 0`, or the catalog has no `AnimID` for it) falls
// straight through to `cands` unchanged — back to Stand.
let state_emote_cands;
let cands: &[u16] = if is_bare_stand(cands) {
let emote_anim = store
.and_then(|s| emote_sounds.and_then(|e| e.anim(s.0.unit_emote_state())));
match emote_anim {
Some(id) => {
state_emote_cands = state_emote_gait(id as u16);
&state_emote_cands
}
None => cands,
}
} else {
cands
};
// The loot kneel (see [`select::LOOT`]): while the loot trigger is up
// (self: the latch; remote: the flag — the `looting` predicate above) on a
// stationary, unmounted unit, the gait slot holds Loot 50 — over the cast
// pin, the Ready/ranged idles, the chair loops and the state-emote idle
// alike (the `0x5fd8b0` chain order, §5-verified: locomotion → LOOT →
// standState → combat/channel). The trigger dropping cross-fades back to
// whatever the slot picks next.
let loot_cands;
let cands: &[u16] = if looting {
loot_cands = [select::LOOT, STAND];
&loot_cands
} else {
cands
};
// The mounted pin outranks the whole gait slot (decision 0442 confirms 0441's
// B1): the rider holds Mount(91) — moving, turning, engaged — while the mount
// child's own driver plays the locomotion this selector would have picked. The
// real client has no mount leg in this chain at all — it arms 91 once at
// attach (`0x607b44`) and re-forces it on every PlayAnimation
// (`0x5fe803`–`0x5fe816`) instead of selecting it here. The pin renders that
// **steady state** exactly; what it does NOT render is the *attach* arm's
// displacement of a full-body one-shot already holding bone 0 (the pin waits
// on `Mode::Swing`, the reference's play does not) — which is why the
// transition edge above arms separately (decision 0927, B203). 91 is not
// rate-scaled, and the resolver's Stand fallback covers a body that doesn't
// author it.
let mount_cands;
let cands: &[u16] = if mounted {
mount_cands = [select::MOUNT, STAND];
&mount_cands
} else {
cands
};
let target = cands[0];
// Each RF-0057 candidate, in priority order, resolved through the model's own
// baked fallback (decision 0082) before moving to the next candidate — a model
// missing the exact id still plays its baked substitute rather than stepping
// down the selector's own list early. The state-emote idle's id (above) resolves
// through the same call, like every other candidate.
let clip = cands
.iter()
.find_map(|&id| find_resolved(anims, id, catalog));
if drv.gait == Some(target) {
if let Some(c) = clip {
// The gait is already armed and stays armed — its rate is
// [`play::sync_base_rate`]'s per-frame write below, which finds
// whichever *rolled variation* (decision 0123) is actually the live
// one rather than sweeping every node of the id.
//
// One completed pass of the ranged Load clip arms the HOLD twin
// (the `ranged_load` map above) — next frame the idle cross-fades
// to the drawn hold instead of replaying the nock.
if select::is_ranged_load(target)
&& anims
.clips
.iter()
.filter(|v| v.anim_id == c.anim_id)
.any(|v| {
player
.animation(v.node)
.is_some_and(|a| a.completions() >= 1)
})
{
drv.ranged_held = true;
}
}
} else if let Some(c) = clip {
// A looping base arm rolls its variation when relaxed (decision 0123 —
// the client's base-arm `variationIdx = −1`; a combat/cast arm keeps the
// deterministic head) AND its replay budget (decision 0516 §7d — the
// watchdog window). A re-armed Stand landing on its rare look-around
// variations IS the idle fidget.
let (c, budget) = roll_loop(anims, c, relaxed, rng);
if traced && crate::dbg_trace::enabled() {
// Every fresh gait play, including a same-clip replay (which the
// settled-state diff below cannot see) — the exact restart-from-head
// event a "frames snap" report is hunting.
crate::dbg_trace::line(
"anim",
&format!(
"{subject}PLAY gait {} (was {:?}) rate {:.2}",
c.anim_id,
drv.gait,
playback_rate(c, mv.speed, model_scale)
),
);
}
// The ranged Load plays ONCE and freezes at full draw: as a Forever
// gait it WRAPPED to its head at completion — the frames + cross-fade
// against the restarted reach-to-quiver were the director's "jumps
// back to the start the moment it gets fully pulled", in every build
// that replayed a pull (trace-caught, decision 0412). The promotion
// then cross-fades the hold from the matching drawn pose.
// Loot 50 is likewise authored clamp — one 0.5 s kneel-down that must
// FREEZE in the rummage pose; as Forever it would wrap back to standing
// and re-kneel every half second.
let repeat = if select::is_ranged_load(target) || target == select::LOOT {
// A deliberate freeze — no window either: the client's ranged
// hand-off is the HOLD promotion, not a watchdog re-pull.
drv.loop_window = None;
bevy::animation::RepeatAnimation::Never
} else {
drv.loop_window = Some((c.node, budget));
bevy::animation::RepeatAnimation::Forever
};
drv.gait_rate = playback_rate(c, mv.speed, model_scale);
play_clip(tr, player, c, repeat, drv.gait_rate);
drv.gait = Some(target);
// A fresh pick of the drawn HOLD re-nocks (INTERIM, decision 0409): the
// hold resuming after a fire clip (or after the Load promotion) shows
// the arrow + drawn string again without a full re-pull — the `$BWP`
// re-latch only lives in the Load clip our mid-volley cycle no longer
// replays. The ref's exact mid-volley re-attach source is the Q-I
// dispatch; the arrow itself still gates on a cached NockedAmmo.
if drv.ranged_held && ranged_load == Some(target) {
commands.entity(entity).insert(super::super::NockLatch);
}
} 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() {
drv.ranged_held = false;
}
}
}
}
}
@@ -25,11 +25,57 @@ pub(super) fn play_clip(
repeat: RepeatAnimation,
rate: f32,
) {
let active = tr.play(
arm(
tr,
player,
c.node,
c,
repeat,
rate,
Duration::from_secs_f32(c.blend_time.max(0.0)),
);
}
/// Arm an already-resolved **looping** clip with the cross-fade SUPPRESSED — op4 `0x7121a0` called
/// with `crossFadeFlag = 0`, which is a genuinely different arm, not a fast blend:
///
/// - the outgoing pose is **not** carried. `0x71253e`–`0x712543` tests arg6 and, when it is zero,
/// jumps clean past the whole blend block — including the `rep movsd` at `0x7125d9`–`0x7125ea`
/// that copies the primary block `[bone+0x98..]` into the secondary `[bone+0xc4..]`. So the new
/// clip's first frame IS the pose, immediately, and a **full-body secondary overlay survives**
/// (only the blended arm evicts it — decision 0114's shared-slot eviction is a *blend* law).
/// - the primary's own bookkeeping still runs: `[bone+0xf8] = animId` at `0x71252f` is gated on
/// arg7 (the slot selector) alone, so bone 0 changes hands either way.
///
/// The dismount teardown `0x607ce0` is the call site this exists for (decision 0931); the mount-up
/// build `0x607b44` passes `1` and takes [`play_clip`]'s ordinary cross-fade.
pub(super) fn cut_loop(
tr: &mut AnimationTransitions,
player: &mut AnimationPlayer,
anims: &ModelAnimations,
id: u16,
catalog: Option<&AnimDataCatalog>,
rng: &mut u32,
window: &mut Option<(bevy::animation::graph::AnimationNodeIndex, u32)>,
) {
let Some(head) = find_resolved(anims, id, catalog) else {
return;
};
// op4's arg3 here is a literal `-1` (`0x607d25`), so the variation rolls unconditionally — the
// teardown is not one of the arms [`select::arm_forces_head`] pins to the head.
let (c, r) = roll_loop(anims, head, true, rng);
*window = Some((c.node, r));
arm(tr, player, c, RepeatAnimation::Forever, 1.0, Duration::ZERO);
}
fn arm(
tr: &mut AnimationTransitions,
player: &mut AnimationPlayer,
c: &AnimClip,
repeat: RepeatAnimation,
rate: f32,
blend: Duration,
) {
let active = tr.play(player, c.node, blend);
active.set_repeat(repeat);
active.set_speed(rate);
}
@@ -2448,3 +2448,182 @@ fn a_mounted_landing_runs_at_the_gaits_rate_not_at_one_times() {
fn drv_of(app: &App, unit: Entity) -> &AnimDriver {
app.world().entity(unit).get::<AnimDriver>().unwrap()
}
/// **B203 — the mount transition's own bone-0 arm.** The mount summon's cast-stage kit plays
/// `SpellCastOmni` (54) at SPELL_GO, while the caster is still *unmounted*, so it takes the
/// full-body route and `Mode::Swing` owns bone 0 for the clip's whole span. The mount field then
/// lands a beat later — and the gait slot's mounted pin cannot help, because it only picks when
/// the mode is already `Mode::Gait`. The reference has no such wait: `0x607a00`'s tail
/// (`0x607b44`) is an ordinary op4 PRIMARY play of **91 `Mount`** on bone 0 of the body, so the
/// cast clip is displaced the moment the mount arrives — which is why the director sees no cast
/// animation at mount-up, only the poof.
#[test]
fn the_mount_transition_takes_bone_0_back_from_a_full_body_one_shot() {
use benilla_protocol::ObjectFields;
/// `UNIT_FIELD_MOUNTDISPLAYID` (index 133, decision 0441).
const FIELD_MOUNTDISPLAYID: u16 = 133;
const SPELL_CAST_OMNI: u16 = 54;
const MOUNT: u16 = 91;
let mut app = app();
let model = ModelAnimations {
graph: Handle::default(),
clips: vec![
clip(0, 1, true), // Stand
clip(MOUNT, 2, true), // Mount — the seat pose
clip(SPELL_CAST_OMNI, 3, false), // the cast release, FULL BODY (no upper_node)
],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
};
let unit = app
.world_mut()
.spawn((
model,
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
crate::net::ObjectStore(ObjectFields::from_pairs(&[(FIELD_MOUNTDISPLAYID, 0)])),
MovementState::default(),
))
.id();
let mount_field = |app: &mut App, v: u32| {
app.world_mut()
.entity_mut(unit)
.insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[(
FIELD_MOUNTDISPLAYID,
v,
)])));
};
let playing = |app: &App| drv_of(app, unit).active_anim();
app.update();
assert_eq!(playing(&app), Some(0), "standing, unmounted");
// SPELL_GO: the cast-stage kit's release clip, full-body over bone 0.
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id: SPELL_CAST_OMNI,
seq: 1,
});
app.update();
assert_eq!(
playing(&app),
Some(SPELL_CAST_OMNI),
"the release clip takes bone 0 while the caster is still on foot"
);
// …and the mount field lands, mid-clip. The transition's own arm reclaims bone 0.
mount_field(&mut app, 2404);
app.update();
assert_eq!(
playing(&app),
Some(MOUNT),
"B203: the mount arm displaces the cast clip — it does not wait for it to finish"
);
// The edge is a CHANGE, not a level: a steady mounted frame re-picks nothing new, and the
// seat pose simply holds (the reference's per-play re-force, rendered by the gait pin).
app.update();
assert_eq!(playing(&app), Some(MOUNT));
// Dismount is the same watcher's other leg (`0x607ce0` arms seq 0 Stand on the same bone).
mount_field(&mut app, 0);
app.update();
assert_eq!(playing(&app), Some(0), "back on its own feet, standing");
}
/// **B204 — the dismount CUTS the saddle pose; the mount-up blends into it** (decision 0931).
/// The two legs of the `UNIT_FIELD_MOUNTDISPLAYID` watcher issue the same seven-argument op4
/// `0x7121a0` call and differ in exactly one literal: `0x607b35 push 0x1` (build, cross-fade) vs
/// `0x607d1c push 0x0` (teardown, no cross-fade). Fading Mount(91) out instead of cutting it is
/// what read as a landing — 91 splays and bends the legs, and easing that into Stand over the
/// clip's blend is a body absorbing an impact.
///
/// The assertion is on the OUTGOING clip's weight, because that is the whole difference: after
/// the build frame the old pose is still weighted in (a blend in progress), after the teardown
/// frame the saddle pose contributes nothing at all.
#[test]
fn the_dismount_cuts_the_saddle_pose_where_the_mount_up_blends_into_it() {
use benilla_protocol::ObjectFields;
const FIELD_MOUNTDISPLAYID: u16 = 133;
const MOUNT: u16 = 91;
let stand_node = AnimationNodeIndex::new(1);
let mount_node = AnimationNodeIndex::new(2);
let mut app = app();
// A deliberately LONG blend on both clips, so "did it fade or did it cut" cannot come down to
// how long a headless frame happened to take: at the harness's real-time `dt` the shipped
// 0.15 s blend is all but finished after a single update, and the difference the test is
// about would sit inside the noise.
let mut stand = clip(0, 1, true);
let mut mount = clip(MOUNT, 2, true);
stand.blend_time = 2.0;
mount.blend_time = 2.0;
let model = ModelAnimations {
graph: Handle::default(),
clips: vec![stand, mount],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
};
let unit = app
.world_mut()
.spawn((
model,
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
crate::net::ObjectStore(ObjectFields::from_pairs(&[(FIELD_MOUNTDISPLAYID, 0)])),
MovementState::default(),
))
.id();
let mount_field = |app: &mut App, v: u32| {
app.world_mut()
.entity_mut(unit)
.insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[(
FIELD_MOUNTDISPLAYID,
v,
)])));
};
let weight = |app: &App, node| {
app.world()
.entity(unit)
.get::<AnimationPlayer>()
.unwrap()
.animation(node)
.map_or(0.0, |a| a.weight())
};
app.update();
assert_eq!(drv_of(&app, unit).active_anim(), Some(0));
// Build leg — `0x607b35 push 0x1`: Stand is still fading out under the seat pose.
mount_field(&mut app, 2404);
app.update();
assert_eq!(drv_of(&app, unit).active_anim(), Some(MOUNT));
assert!(
weight(&app, stand_node) > 0.5,
"the mount-up cross-fades: the outgoing pose is still weighted in"
);
// Teardown leg — `0x607d1c push 0x0`: the saddle pose is gone on the arm's own frame, not
// eased away over the clip's blend time.
mount_field(&mut app, 0);
app.update();
assert_eq!(drv_of(&app, unit).active_anim(), Some(0));
assert_eq!(
weight(&app, mount_node),
0.0,
"the dismount CUTS: Mount(91) contributes nothing the frame the field clears"
);
}
@@ -32,16 +32,28 @@ const ERROR_CUBE: &str = "Spells\\ErrorCube.mdx";
/// one loot effect (the client's flag8 dedup), which [`arm_loot_fx`]'s edge cache guarantees.
const LOOT_FX_KEY: u32 = u32::MAX;
/// The M2 attachment the client hangs its engine-spawned effects from (`0x13` — the loot
/// sparkle and the level-up ding both). It is also the `spell_fx` attach cascade's own last
/// The M2 attachment the three engine-spawned effects we ship hang from — the loot sparkle, the
/// level-up ding and the mount poof. It is also the `spell_fx` attach cascade's own last
/// fallback, so a model lacking the point lands on the unit base — exactly the reference's
/// root fallback.
/// root fallback (`0x61fb4f`–`0x61fb5f`: `HasAttachment(tag)` on `[unit+0xd8]`, else `0x13`).
///
/// **It is a table value, not an engine-wide constant** (wow-re `mount-composition.md` Q4b.1, §5
/// 2026-08-03 — the 14-entry `DAT_0080c968`, dumped and joined to the `0x8617b8` name table). It
/// happens to be `0x13` for indices **4** Loot Art, **5** Unit Level Up and **6** Mount Poof —
/// our three — and for the PetLoyalty/Meeting-Stone/Reputation rows; it is `0x11` for the two
/// breath effects and Inebriated Bubbles, and the sentinel `0x25` (spawn refused outright) for
/// the two footstep sprays. A fourth consumer must read the table row, not reuse this.
const HARDCODED_FX_ATTACH: u16 = 0x13;
/// The engine-spawned level-up effect's baked lookup name (the client's string `0x8618e0`,
/// matched by the boot name-resolve `0x61f5b0`; wow-re `levelup-ding.md` — decision 0305).
const LEVEL_UP_EFFECT: &str = "HARDCODED Unit Level Up";
/// The mount transition's cloud — hardcoded index **6**, shipped as `SpellVisualEffectName` row
/// 1185 → `Spells\DruidMorph_Impact_Base.mdx`, i.e. literally the druid-morph puff (wow-re
/// `mount-composition.md` Q4b, decision 0927).
const MOUNT_POOF_EFFECT: &str = "HARDCODED Mount Poof";
/// `SpellVisual.dbc` × `SpellVisualKit.dbc` — the stage-kit chain + each kit's anim/sound
/// (`crate::creature_anim::spell_visual` module docs). Optional like every other DBC-backed
/// resource: absent (no client data) degrades to no spell-visual playback, same shape as
@@ -1125,3 +1137,71 @@ pub(super) fn arm_level_up_fx(
// Streamed units despawn on range-out — drop their level memory with them.
levels.retain(|e, _| units.contains(*e));
}
/// The **mount poof** — the cloud the director reports seeing at mount-up and we never drew.
///
/// The reference's `UNIT_FIELD_MOUNTDISPLAYID` change-watcher (`0x604329` → `0x604570` →
/// `0x5ffa50`) does three things on its **build** leg, and only there: tear down any old mount
/// (`0x607ce0`, gated on the OLD value), build the new one (`0x607a00`), and then spawn a
/// `HARDCODED` effect — `SMemAlloc(0x90)` → `Effect_C` ctor `0x61f490` → `0x61fae0(index=6,
/// &ownerGUID, cb=0x5fbf50)` → `0x6210e0` (`5ffa90`–`5ffad9`). Index **6** resolves through the
/// boot name-match table to `"HARDCODED Mount Poof"` → row 1185 → `Spells\DruidMorph_Impact_Base`
/// — the druid-morph cloud, which is exactly the "cloud shapeshift animation" of the report.
/// Byte-for-byte the same spawn shape as [`arm_level_up_fx`]'s index 5, so it is the same edge
/// here (wow-re `mount-composition.md` Q4b, §5 2026-08-03; decision 0927).
///
/// The three properties that decide the shape of this arm, all VERIFIED there:
/// - **Mount side only.** The build *and the entire allocation* sit behind `5ffa87 je 0x5ffade`
/// on the **NEW** field value. `N→0` jumps clean past it: **no poof on dismount.** `0→N` and
/// `N→N′` both spawn one.
/// - **Any unit.** No active-player, local-player or distance gate exists anywhere in `0x5ffa50`
/// — the same shape as the level-up ding. Range is the server's replication, not a filter.
/// - **One-shot, on the rider's own body.** `[node+0x2c] = 0` (no world-plant, no dedup marker,
/// not cancel-immune), tag `0x13` re-resolved against `[owner+0xd8]` every tick, and the
/// registered callback is `0x5fbf50` — the CEffect *end-of-clip terminator* (its looping twin
/// `0x600640` is what makes the loot sparkle repeat). So: `FxStage::OneShot`, non-persistent,
/// 2.8 s of its own clock. Because the rider's body is the mount's child at slot 0 (0917), the
/// cloud rides the saddle for its whole span with no extra plumbing.
///
/// **Stated divergence.** The reference's tail `0x6208e0` is a same-record+same-tag dedup that
/// destroys a still-running poof when a new one spawns on that unit; our one-shot instances carry
/// no reap key, so a mount *swap* inside 2.8 s would show two overlapping clouds. Unreachable
/// today (the mounted gate refuses a second mount spell) and bounded at "one extra puff", so it
/// is named rather than modelled.
pub(super) fn arm_mount_poof_fx(
changed: Query<(Entity, &ObjectStore), Changed<ObjectStore>>,
units: Query<(), With<ObjectStore>>,
visuals: Option<Res<SpellVisuals>>,
mut displays: Local<EntityHashMap<u32>>,
mut fx: MessageWriter<SpellKitFx>,
) {
for (entity, store) in &changed {
let mount_display = store.0.unit_mount_display_id();
match displays.insert(entity, mount_display) {
// The build leg's gate is on the NEW value, so a dismount (`mount_display == 0`)
// spawns nothing — and first sight arms silently, exactly like the ding: a unit that
// streams in already mounted did not just mount.
Some(prev) if prev != mount_display && mount_display != 0 => {
let Some(path) = visuals
.as_ref()
.and_then(|v| v.0.hardcoded_effect(MOUNT_POOF_EFFECT))
else {
continue; // no client data / no such row (the DBC-resource degrade shape)
};
debug!("anim: mount display {prev} → {mount_display}, the poof puffs ({entity})");
fx.write(SpellKitFx::Begin {
entity,
spell_id: 0,
persistent: false,
class: FxClass::Hold,
// `0x5fbf50` — destroy at the first completion; the shipped model runs 2.8 s.
stage: FxStage::OneShot,
effects: vec![(HARDCODED_FX_ATTACH, path.to_string())],
});
}
_ => {}
}
}
// Streamed units despawn on range-out — drop their mount memory with them.
displays.retain(|e, _| units.contains(*e));
}
@@ -746,3 +746,79 @@ fn ranged_visual_play_arms_the_any_caster_hold_and_a_non_ranged_play_clears_it()
"the next ranged play re-arms"
);
}
/// **The mount poof** ([`super::arm_mount_poof_fx`], decision 0927) — the three properties the
/// reference's `UNIT_FIELD_MOUNTDISPLAYID` watcher gives it, each a real fork in `0x5ffa50`:
/// **the build leg only** (the whole allocation sits behind `5ffa87 je 0x5ffade` on the NEW
/// value, so a dismount spawns nothing), **any changed value** (0→N and N→N′ alike), and
/// **first sight silent** (a unit that streams in already mounted did not just mount — the
/// level-up ding's own treatment, decision 0305).
#[test]
fn the_mount_poof_puffs_on_the_build_leg_only() {
use benilla_protocol::ObjectFields;
/// `UNIT_FIELD_MOUNTDISPLAYID` (index 133, decision 0441).
const FIELD_MOUNTDISPLAYID: u16 = 133;
/// `SpellVisualEffectName` row 1185's shipped path — the druid-morph cloud.
const POOF: &str = "Spells\\DruidMorph_Impact_Base.mdx";
/// The M2 attach the hardcoded-effect spawn stamps (`DAT_0080c968[6]`).
const BASE_ATTACH: u16 = 0x13;
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<SpellKitFx>();
app.insert_resource(SpellVisuals(
SpellVisualCatalog::from_tables(HashMap::new(), HashMap::new())
.with_hardcoded("HARDCODED Mount Poof", POOF),
));
app.add_systems(Update, super::arm_mount_poof_fx);
let store =
|v: u32| crate::net::ObjectStore(ObjectFields::from_pairs(&[(FIELD_MOUNTDISPLAYID, v)]));
// Streams in ALREADY mounted: first sight arms the memory, silently.
let unit = app.world_mut().spawn(store(2404)).id();
app.update();
let puffs = |app: &mut App| -> Vec<(u16, String)> {
let mut out = Vec::new();
let world = app.world_mut();
let mut msgs = world.resource_mut::<Messages<SpellKitFx>>();
for m in msgs.drain() {
if let SpellKitFx::Begin {
entity, effects, ..
} = m
{
assert_eq!(entity, unit);
out.extend(effects);
}
}
out
};
assert!(
puffs(&mut app).is_empty(),
"a rider that streams into view did not just mount"
);
// Dismount — the NEW value is 0, so the build leg (and the whole allocation) is skipped.
app.world_mut().entity_mut(unit).insert(store(0));
app.update();
assert!(puffs(&mut app).is_empty(), "no poof on the way down");
// Mount: 0 → N.
app.world_mut().entity_mut(unit).insert(store(2404));
app.update();
assert_eq!(
puffs(&mut app),
vec![(BASE_ATTACH, POOF.to_string())],
"the build leg puffs the druid-morph cloud at the base attach"
);
// A steady mounted frame is not an edge — the watcher fires on the field CHANGING.
app.world_mut().entity_mut(unit).insert(store(2404));
app.update();
assert!(puffs(&mut app).is_empty(), "no re-puff while just riding");
// A swap (N → N′) is a change, and the reference rebuilds and puffs again.
app.world_mut().entity_mut(unit).insert(store(2405));
app.update();
assert_eq!(puffs(&mut app), vec![(BASE_ATTACH, POOF.to_string())]);
}
+45 -2
View File
@@ -82,6 +82,30 @@ fn twist_shares(gap: f32) -> (f32, f32) {
(spine, head)
}
/// The shares actually ARMED on a unit — [`twist_shares`] with the **mounted spine gate** applied.
///
/// The SpineLow channel is armed only on an UNMOUNTED unit: `CGUnit+0xdc == 0` (wow-re
/// `body-facing-pipeline.md` Q3, alongside the per-model capability bit `+0xd58 & 0x80` we model as
/// "the skeleton authors the key bone"). `+0xdc` is the **mount model** pointer — the same field
/// `0x614cd0` passes to `0x7106c0` as "model `[+0xd8]` or mount `[+0xdc]`". The HEAD channel
/// (`+0xd58 & 0x100`) carries no mount gate. So a strafing rider counter-twists its head alone
/// while the shoulders ride the saddle rigidly; applying both wobbled a mounted strafe's head AND
/// shoulders far past the reference (the director's mounted A/B — the observation that found this).
///
/// The gate is on the channel ARM, not on the share math, so the head keeps its full `gap − spine`
/// share and — unlike the unmounted case — no longer lands exactly on the aim. And the spine's
/// share goes to **zero** rather than the channel being skipped: the zero path rewrites the bone
/// back to its animated base, which is the reference's disarm (`0x711f10(4, 0, 0x80)`); skipping
/// would freeze our last twist into any frame the clip does not re-key.
///
/// (`+0xdc`'s semantics are flagged INFERRED in that note. It is taken here because the mechanism
/// PREDICTS the reference: the director reported the mounted over-wobble before this gate was
/// found, and it is the gate that accounts for it.)
fn armed_shares(gap: f32, mounted: bool) -> (f32, f32) {
let (spine, head) = twist_shares(gap);
(if mounted { 0.0 } else { spine }, head)
}
/// Compose the counter-twist onto the animated bone locals — PostUpdate, in the pose post-pass
/// window ([`super::PosePost`]: after the evaluator wrote this frame's pose, before the model
/// compose folds it).
@@ -104,9 +128,13 @@ pub(super) fn apply_body_twist(
anchors: Query<&super::RigAnchor>,
parents: Query<&ChildOf>,
locals: Query<&Transform>,
stores: Query<&crate::net::ObjectStore>,
) {
for (unit, mut twist) in &mut units {
let (spine, head) = twist_shares(twist.yaw_gap);
let mounted = stores
.get(unit)
.is_ok_and(|s| s.0.unit_mount_display_id() != 0);
let (spine, head) = armed_shares(twist.yaw_gap, mounted);
let twist = &mut *twist;
for (channel, angle) in [(&mut twist.spine, spine), (&mut twist.head, head)] {
let Some(ch) = channel else { continue };
@@ -189,9 +217,24 @@ pub(super) fn plugin(app: &mut App) {
#[cfg(test)]
mod tests {
use super::twist_shares;
use super::{armed_shares, twist_shares};
use std::f32::consts::{FRAC_PI_2, FRAC_PI_4, PI};
/// **A mounted rider counter-twists its HEAD only** — the `CGUnit+0xdc == 0` gate on the
/// SpineLow channel. The head's share is untouched by the gate (it is on the channel arm, not
/// the share math), so the two no longer sum to the gap and the head sits off the aim.
#[test]
fn mounted_disarms_the_spine_and_leaves_the_head_share_alone() {
let (spine, head) = armed_shares(-FRAC_PI_2, true);
assert_eq!(spine, 0.0, "the saddle holds the shoulders rigid");
assert_eq!(
head, -FRAC_PI_4,
"the head keeps its full gap − spine share"
);
// Unmounted, the same gap arms both — the 45°+45° close.
assert_eq!(armed_shares(-FRAC_PI_2, false), twist_shares(-FRAC_PI_2));
}
#[test]
fn pure_strafe_gap_closes_exactly_at_the_head() {
// 90° gap: spine 45°, head 45° — the head lands back on the aim.
+4 -12
View File
@@ -152,7 +152,6 @@ pub(crate) struct DisplayedCursor(pub(crate) crate::target::WorldCursor);
fn overlay_ui_cursor(
base: Res<crate::target::WorldCursor>,
script: Option<bevy::ecs::system::NonSend<benilla_ui::script::UiScript>>,
pending_pick: Option<Res<crate::ui_craft::PendingItemCast>>,
mut displayed: ResMut<DisplayedCursor>,
) {
use crate::target::{CursorKind, WorldCursor};
@@ -160,17 +159,10 @@ fn overlay_ui_cursor(
let mut out = *base;
// Spell-targeting pre-empts the WHOLE classifier — the real dispatcher's step 2 runs before
// any object resolve (wow-re cursor-system.md §5, VERIFIED; the 0446/0452 named deferral).
// benilla's one targeting state is the armed enchant pick. INTERIM: plain Cast everywhere
// while armed — the ref's valid/invalid split (Cast in range / UnableCast out, computed by
// `0x6e6810`/`0x6e6350`) needs the item-target validity law derived before it's honest to
// gray anything.
if pending_pick.is_some_and(|p| p.spell_id != 0) {
displayed.0 = WorldCursor {
kind: CursorKind::Cast,
unable: false,
};
return;
}
// There is no branch for it HERE any more (decision 0923): the one targeting state writes the
// BASE cursor itself (`ui_action::targeting::drive_targeting_cursor`, which runs late in the
// target chain), so a live targeting word already reads as Cast — and, being non-Point, it
// falls past every overlay below by construction. That is the pre-emption, structurally.
if out == WorldCursor::default() {
if let Some(script) = script.as_ref() {
// Repair is the locked base-mode override (`ShowRepairCursor`) and wins over the
+6 -4
View File
@@ -569,10 +569,12 @@ impl Plugin for EntitiesPlugin {
// authority composes into its own pick — so classify runs before `ModelVisSet`, and
// the pick sees this frame's side; everything else (equipment, spell-fx, fade twins)
// is swapped here directly, after the fade resolve whose choice it re-derives from
// the current handle. The self feather stays deliberately unordered: it only writes
// while the camera is within arm's reach of the player's own parts, and the 0905
// waterline snap keeps the eye a full band off the surface — eye and part are then
// on the same side of the plane, where both writers agree on the near handle.
// the current handle. The self feather stays deliberately unordered: it composes the
// same marker through `far_resolved`, so the two writers can only disagree on a
// frame the marker itself flips — a one-frame stale side on the player's own parts
// at the instant the eye crosses the surface, inside the whole-screen atmosphere
// swap that crossing already is (the same accepted class as the interior law's
// one-frame lag, 0919).
.init_resource::<crate::model_render::FarSideTwins>()
.add_systems(
Update,
@@ -845,6 +845,10 @@ pub(super) fn attach_entity_visuals(
MeshMaterial3d(material),
Transform::from_translation(Vec3::Y * lift),
object,
// The cube has no resident `RenderSubmesh` to cast against — its `Aabb` IS
// its shape, which it must SAY (decision 0929): the picker requires pick
// geometry rather than inferring a box from its absence.
crate::interact::PickBox,
));
});
}
+113 -13
View File
@@ -45,10 +45,26 @@ mod journal;
/// triangles from THIS instead, through the same WoW→Bevy bake the render form was built with
/// (`submesh_to_static_mesh`: `wow_to_bevy` per vertex, billboard cards centred at their pivot).
/// Attached beside `Mesh3d` at every spawn site that also attaches a pick key ([`WorldObject`] /
/// `ModelPart`); a keyed entity without one (the model-less cube fallback) is picked by its `Aabb`.
/// `ModelPart`). A keyed entity carrying neither this nor [`PickBox`] is **not pickable** — pick
/// geometry is declared, never inferred from a bound (decision 0929).
#[derive(Component, Clone)]
pub struct PickMesh(pub std::sync::Arc<benilla_formats::RenderSubmesh>);
/// "My `Aabb` **is** my pick geometry" — the model-less cube fallback ([`crate::entities::attach`]),
/// whose cuboid is exactly its bound, so a slab test is the exact answer and 12 triangles would only
/// be a slower way to say it.
///
/// It exists as a **positive declaration** because the alternative — inferring it from the *absence*
/// of a [`PickMesh`] — silently promoted every other keyed-but-mesh-less drawn entity into a solid
/// invisible box. That is what broke the inspector inside a city WMO (decision 0929): a WMO group's
/// MLIQ pool is a drawn `Mesh3d` with no resident geometry, it inherits the building's
/// [`WorldObject`] from the placement's blanket tag, and its render mesh keeps the **whole** MLIQ
/// vertex grid — dry cells included, sitting at height 0 while the drawn lava is 86–127 yd below.
/// Ironforge's thirteen pools therefore hung ~100-yard invisible boxes over the entire city, and the
/// box-entry hit won every pick: every hover, on anything, answered "Wmo · ironforge.wmo".
#[derive(Component, Clone, Copy)]
pub struct PickBox;
/// One triangle-accurate hit from [`cast_pick_ray`]: the world-space point, the hit triangle's
/// geometric normal (two-sided — the orientation is the authored winding's), and the distance along
/// the ray.
@@ -60,7 +76,8 @@ pub struct RayHit {
/// The one query every mesh picker casts against: a part's resident geometry, its world pose, the
/// render world's own visibility verdict (the cast honours it, as `RayCastVisibility::VisibleInView`
/// did), and its bound for the broad phase.
/// did), its bound for the broad phase, and whether that bound is itself the pick shape
/// ([`PickBox`]).
pub type PickParts<'w, 's> = Query<
'w,
's,
@@ -69,6 +86,7 @@ pub type PickParts<'w, 's> = Query<
&'static GlobalTransform,
&'static ViewVisibility,
Option<&'static Aabb>,
Has<PickBox>,
),
>;
@@ -195,10 +213,16 @@ pub fn pick_at_cursor(
///
/// Broad phase: a world-space slab test on each candidate's `Aabb` (entry distance), nearest entry
/// first, so the narrow walk stops as soon as a confirmed hit is closer than every remaining box. A
/// part with no bound (a `NoFrustumCulling` fx part) is narrow-tested unconditionally; a keyed
/// entity with a bound but no [`PickMesh`] (the model-less cube fallback, whose cuboid *is* its
/// box) takes its box entry as the hit. Triangles test **two-sided**, like the unit picker's narrow
/// phase — a generous pick beats a strict one at silhouette edges.
/// part with no bound (a `NoFrustumCulling` fx part) is narrow-tested unconditionally; a
/// [`PickBox`] entity takes its box entry as the hit, its cuboid being exactly its bound.
/// Triangles test **two-sided**, like the unit picker's narrow phase — a generous pick beats a
/// strict one at silhouette edges.
///
/// **Pick geometry is required, never inferred** (decision 0929): an entity that is neither a
/// [`PickMesh`] nor a [`PickBox`] is not pickable, however identified or bounded it is. The box hit
/// used to be the fallback for *any* keyed entity with an `Aabb` and no mesh, which quietly turned
/// a WMO's MLIQ pool — a drawn mesh with no resident geometry, wearing its building's identity —
/// into a city-sized invisible occluder. See [`PickBox`].
pub fn cast_pick_ray(
ray: Ray3d,
pickable: &HashSet<Entity>,
@@ -208,22 +232,24 @@ pub fn cast_pick_ray(
let (origin, dir) = (ray.origin, *ray.direction);
let mut candidates: Vec<(f32, Entity)> = Vec::new();
for &entity in pickable {
let Ok((mesh, gt, vis, aabb)) = parts.get(entity) else {
let Ok((mesh, gt, vis, aabb, is_box)) = parts.get(entity) else {
continue;
};
if !vis.get() {
continue; // not drawn in any view this frame — the old cast's VisibleInView rule
}
let entry = match (aabb, mesh) {
(Some(aabb), _) => {
let entry = match (aabb, mesh, is_box) {
(Some(aabb), Some(_), _) | (Some(aabb), None, true) => {
let (min, max) = world_aabb(aabb, gt);
match ray_aabb(origin, dir, min, max) {
Some(t) => t,
None => continue,
}
}
(None, Some(_)) => 0.0,
(None, None) => continue,
(None, Some(_), _) => 0.0,
// No pick geometry — a drawn mesh nobody armed for the ray (a liquid surface), or a
// `PickBox` with no bound to be. Not pickable, rather than pickable as its bound.
(_, None, _) => continue,
};
candidates.push((entry, entity));
}
@@ -234,12 +260,13 @@ pub fn cast_pick_ray(
if !all_hits && best < entry {
break; // every remaining box starts beyond the confirmed nearest hit
}
let Ok((mesh, gt, _, _)) = parts.get(entity) else {
let Ok((mesh, gt, _, _, _)) = parts.get(entity) else {
continue;
};
let hit = match mesh {
Some(m) => ray_pick_mesh(m, gt, origin, dir),
// The cube fallback: its box entry is exactly its surface (the cuboid IS its Aabb).
// A `PickBox` (the broad phase admitted no other mesh-less candidate): its box entry is
// exactly its surface, the cuboid BEING its Aabb.
None => Some(RayHit {
point: origin + dir * entry,
normal: -dir,
@@ -814,6 +841,79 @@ mod tests {
assert!(ray_pick_mesh(&mesh, &gt, Vec3::new(20.0, 6.0, 0.0), Vec3::NEG_Y).is_none());
}
/// Cast a ray at a hand-built world and report `(entity, distance)` for every hit, nearest
/// first — the whole ray, so a candidate that was silently dropped is distinguishable from one
/// that merely lost.
fn cast_in(world: &mut World, origin: Vec3, dir: Vec3) -> Vec<(Entity, f32)> {
use bevy::ecs::system::RunSystemOnce;
fn cast(
In((origin, dir)): In<(Vec3, Vec3)>,
ids: Query<Entity, With<ViewVisibility>>,
parts: PickParts,
) -> Vec<(Entity, f32)> {
let all: HashSet<Entity> = ids.iter().collect();
let ray = Ray3d::new(origin, Dir3::new(dir).expect("a non-zero ray"));
cast_pick_ray(ray, &all, &parts, true)
.into_iter()
.map(|(e, h)| (e, h.distance))
.collect()
}
world
.run_system_once_with(cast, (origin, dir))
.expect("cast")
}
/// Everything a drawn, bounded, visible part needs to be a pick candidate — minus the geometry,
/// which each test supplies (or deliberately withholds).
fn drawn(world: &mut World, at: Vec3, half: Vec3) -> EntityWorldMut<'_> {
use bevy::camera::visibility::SetViewVisibility;
let mut e = world.spawn((
GlobalTransform::from(Transform::from_translation(at)),
Aabb::from_min_max(-half, half),
ViewVisibility::HIDDEN,
));
e.get_mut::<ViewVisibility>()
.expect("just spawned")
.set_visible();
e
}
/// **Decision 0929.** A drawn, identified, bounded entity with NO pick geometry — a WMO group's
/// MLIQ pool, whose render mesh keeps the whole (mostly dry) vertex grid and so bounds a box
/// ~100 yd tall over a city — must be transparent to the ray. It used to be picked at its box
/// entry, which put an invisible occluder in front of everything: inside Ironforge every hover,
/// on any NPC or GameObject, answered "Wmo · ironforge.wmo".
#[test]
fn a_bounded_mesh_less_entity_is_not_a_pick_occluder() {
let mut world = World::new();
// The pool's box straddles the camera (entry 0.0 — it wins any distance sort it enters).
let pool = drawn(&mut world, Vec3::ZERO, Vec3::new(60.0, 60.0, 60.0)).id();
// The NPC behind it: real geometry at 10 yd (`wow_tri` spans x,z ∈ [-1,1] at y = 0).
let npc = drawn(&mut world, Vec3::new(0.0, -10.0, 0.0), Vec3::splat(1.0))
.insert(PickMesh(std::sync::Arc::new(wow_tri())))
.id();
let hits = cast_in(&mut world, Vec3::ZERO, Vec3::NEG_Y);
assert!(
!hits.iter().any(|(e, _)| *e == pool),
"the mesh-less pool must not be pickable at all, yet it hit: {hits:?}",
);
assert_eq!(hits.first().map(|(e, _)| *e), Some(npc));
}
/// The other half of the same law: an entity that DECLARES its box is its shape ([`PickBox`] —
/// the model-less cube fallback) is still picked, at the box entry.
#[test]
fn a_declared_pick_box_is_picked_at_its_box_entry() {
let mut world = World::new();
let cube = drawn(&mut world, Vec3::new(0.0, -10.0, 0.0), Vec3::splat(2.0))
.insert(PickBox)
.id();
let hits = cast_in(&mut world, Vec3::ZERO, Vec3::NEG_Y);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].0, cube);
assert!((hits[0].1 - 8.0).abs() < 1e-4, "{hits:?}"); // the box's near face
}
/// A billboard card's render form is centred at its pivot (`build_submesh_mesh`); the pick must
/// subtract the same pivot or it tests the card a whole pivot-offset away from where it draws.
#[test]
+11 -1
View File
@@ -38,12 +38,15 @@
//! ## Layout
//!
//! Two concerns, so two files behind this face:
//! Three concerns, so three files behind this face:
//!
//! * [`query`] — **where the liquid is, and whether you are in it.** The world-space grid every
//! surface publishes ([`WaterChunkInfo`]), the position queries the swim/sound/foam/lighting
//! systems ask it ([`liquid_at`], [`water_surface_at`]), and the per-frame camera submersion
//! verdict ([`Underwater`]). Nothing here renders.
//! * [`spatial`] — **which surfaces are near an XY**, without walking them all: the grid-hash
//! [`WaterIndex`] the per-draw consumers (the water-plane interleave's three lanes) pre-filter
//! through. The once-a-frame askers keep the plain walk.
//! * [`surface`] — **the Bevy render glue.** The per-kind animated materials, the two spawn paths
//! (MCLQ and WMO MLIQ), the flat mesh build, and the 24 fps frame cycler.
//!
@@ -59,6 +62,7 @@ use crate::terrain::LiquidMaterial;
mod query;
#[cfg(test)]
mod real_data;
mod spatial;
mod surface; // the against-real-client-files tests — they span both halves
// The submodules are private, so this list IS the subsystem's face: everything the rest of the
@@ -68,6 +72,7 @@ pub(crate) use query::{
camera_claim, describe_at, liquid_at, player_claim, surfaces_at, unit_claim, water_surface_at,
FoamPatch, LiquidClaim, LiquidSource, Underwater, WaterChunkInfo, WmoPool,
};
pub(crate) use spatial::WaterIndex;
pub(crate) use surface::{spawn_liquids, spawn_wmo_liquids, LiquidAssets, LiquidSoundSource};
/// The frame slot where [`Underwater`] is written — the label every consumer of the submersion
@@ -88,6 +93,11 @@ impl Plugin for LiquidPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(MaterialPlugin::<LiquidMaterial>::default())
.init_resource::<Underwater>()
.init_resource::<WaterIndex>()
// PreUpdate: surfaces stream in/out via Update-side commands, so the edge is visible
// here the frame after — before any of that frame's consumers ask. A despawn's stale
// entry in between self-filters at the consumer (`Query::get` misses).
.add_systems(PreUpdate, spatial::maintain_water_index)
.add_systems(Startup, surface::setup_liquid.after(AssetSet::Open))
.add_systems(
Update,
+74 -9
View File
@@ -428,6 +428,13 @@ impl WaterChunkInfo {
x >= self.min_x && x <= self.max_x && y >= self.min_y && y <= self.max_y
}
/// The wet footprint's XY box as `[[min_x, min_y], [max_x, max_y]]`, or `None` for an empty
/// grid (which claims no area at all) — what [`super::spatial::WaterIndex`] buckets by.
pub(super) fn xy_bounds(&self) -> Option<[[f32; 2]; 2]> {
(self.min_x <= self.max_x && self.min_y <= self.max_y)
.then_some([[self.min_x, self.min_y], [self.max_x, self.max_y]])
}
/// Does this surface answer for a subject holding `claim`? — the delegation, in one place.
///
/// An [`Inside`](LiquidClaim::Inside) subject reads its **own placement's** MLIQ and nothing
@@ -665,10 +672,10 @@ pub(crate) fn describe_at<'a>(
/// Every **admitted** liquid surface height over `wow`'s XY under the given claim — the same
/// delegation [`submersion_at`] applies (the 0634/0696 scoping: a pool in another building or
/// storey never answers), with no over/under verdict attached: the consumer that needs surfaces
/// the subject is merely *near* — above or below — is the camera's waterline snap
/// (`player::camera::waterline_snap`), which keeps the eye out of the near-plane band around
/// every admitted plane. Kept here so the delegation rule has one owner; `answers` stays private.
/// storey never answers), with no over/under verdict attached: the consumer that needs the
/// surfaces a subject is merely *near* — above or below — is the effect lane's water-side
/// classification (`particles::sim::far_side_of_water_at`, decisions 0911/0921). Kept here so
/// the delegation rule has one owner; `answers` stays private.
pub(crate) fn surfaces_at<'a>(
liquids: impl Iterator<Item = &'a WaterChunkInfo> + 'a,
wow: [f32; 3],
@@ -777,20 +784,56 @@ pub(super) fn submersion_at<'a>(
.unwrap_or_default()
}
/// How far the near rectangle's **lowest corner** sits below the eye (≤ 0, WoW-Z = Bevy-Y yards) —
/// `min(0, corner heights − eye)`, so `eye_z + drop` is the reference's probe height
/// `min(eye.z, corner[0..3].z)`. Zero when the camera pitches up enough that every corner clears
/// the eye — the min with the eye itself is what keeps a skyward camera from probing above its
/// own head. Pure (rotation + the projection's shape in, one height out) so the geometry is
/// testable without ECS scaffolding, like [`submersion_at`].
///
/// The corners sit `near` ahead of the eye, `±tan(fov/2)·near` up/down and that times the aspect
/// ratio sideways, in CAMERA space (Bevy: forward = −Z) — the same four points the reference
/// builds from NDC z = −1 (`0x5c43b0`; wow-re `water-frame-straddle.md` §4c).
pub(super) fn lowest_near_corner_drop(rotation: Quat, fov: f32, aspect: f32, near: f32) -> f32 {
let half_h = (fov * 0.5).tan() * near;
let half_w = half_h * aspect;
let mut drop: f32 = 0.0;
for sx in [-1.0, 1.0] {
for sy in [-1.0, 1.0] {
drop = drop.min((rotation * Vec3::new(sx * half_w, sy * half_h, -near)).y);
}
}
drop
}
pub(super) fn detect_submersion(
mut underwater: ResMut<Underwater>,
camera: Query<&Transform, With<WorldCamera>>,
camera: Query<(&Transform, &Projection), With<WorldCamera>>,
water: Query<&WaterChunkInfo>,
eye_claim: Res<crate::wmo_portal::CameraInteriorClaim>,
time: Res<Time>,
mut last_dump: Local<Option<u32>>,
) {
let Ok(cam) = camera.single() else {
let Ok((cam, proj)) = camera.single() else {
return;
};
let claim = camera_claim(&eye_claim);
let eye = bevy_to_wow(cam.translation); // [x, y, z] WoW yards
underwater.0 = submersion_at(water.iter(), eye, claim);
// The probe HEIGHT is not the eye's — it is the lowest point of the NEAR RECTANGLE (or the
// eye itself if every corner sits above it): the reference's `0x6809c0` tests X,Y = the
// eye's, Z = `min(eye.z, corner[0..3].z)` over the frustum corners built at NDC z = −1 —
// VERIFIED, wow-re `water-frame-straddle.md` §4c. This is the whole no-straddle mechanism
// (§4d): the frame flips submerged the moment the visible rectangle's leading corner reaches
// the surface, before any under-surface viewpoint can render dry — so the crossing needs no
// camera constraint at all (the 0905 eye snap this replaces), and with the reference's 1/9
// near plane the band it owns is a few inches tall.
let probe_z = match proj {
Projection::Perspective(p) => {
eye[2] + lowest_near_corner_drop(cam.rotation, p.fov, p.aspect_ratio, p.near)
}
_ => eye[2],
};
underwater.0 = submersion_at(water.iter(), [eye[0], eye[1], probe_z], claim);
// `WOW_FOG_DUMP` also explains *this* decision (`frame` drops the 1 Hz throttle, as there). The
// committed fog is whichever submerged atmosphere the verdict names, so a fog line alone cannot
// say whether an atmosphere that reads wrong is the wrong record or the right record never
@@ -816,7 +859,7 @@ pub(super) fn detect_submersion(
Some(o) => format!("g{}", o.group),
None => "adt".into(),
},
if w.answers(claim, eye[2]) {
if w.answers(claim, probe_z) {
""
} else {
" (not yours)"
@@ -827,7 +870,7 @@ pub(super) fn detect_submersion(
.collect();
cands.sort();
eprintln!(
"[submerged] {:?} claim {claim:?} eye [{:.1} {:.1} {:.2}] over-xy {}",
"[submerged] {:?} claim {claim:?} eye [{:.1} {:.1} {:.2}] probe-z {probe_z:.2} over-xy {}",
underwater.0,
eye[0],
eye[1],
@@ -846,6 +889,28 @@ pub(super) fn detect_submersion(
mod tests {
use super::*;
/// The corner-min probe geometry (`min(eye.z, corner z's)` as an offset from the eye): level
/// pitch reaches half the near rectangle's height down, straight down reaches the full near
/// distance, straight up reaches nothing (the min with the eye itself), and yaw never enters
/// (no roll ⇒ the rectangle's width is world-horizontal at every heading).
#[test]
fn near_corner_drop_is_the_rectangles_lowest_point() {
let (fov, aspect, near) = (std::f32::consts::FRAC_PI_4, 16.0 / 9.0, 1.0 / 9.0);
let half_h = (fov * 0.5).tan() * near;
let at = |yaw: f32, pitch: f32| {
lowest_near_corner_drop(
Quat::from_euler(EulerRot::YXZ, yaw, pitch, 0.0),
fov,
aspect,
near,
)
};
assert!((at(0.0, 0.0) + half_h).abs() < 1e-6);
assert!((at(0.0, -std::f32::consts::FRAC_PI_2) + near).abs() < 1e-6);
assert_eq!(at(0.0, std::f32::consts::FRAC_PI_2), 0.0);
assert!((at(1.23, -0.4) - at(0.0, -0.4)).abs() < 1e-6);
}
/// A flat 10×10 yd wet quad at WoW z = `z` — one cell, four corners.
fn flat_quad(z: f32) -> LiquidMesh {
LiquidMesh {
+162
View File
@@ -0,0 +1,162 @@
//! The liquid **spatial index** — which surfaces are over an XY, without walking them all.
//!
//! [`super::surfaces_at`] answers "what liquid is at this XY" by testing every loaded
//! [`WaterChunkInfo`] in turn. That is fine for the consumers that ask once or twice a frame
//! (the camera waterline, the submersion verdict, a footstep) — and it detonates the moment a
//! consumer asks per *draw*: the water-plane interleave's mesh lane (0919) classified ~13k
//! transparent batches against ~2.2k loaded surfaces every frame — 29 M box tests, 54 ms, the
//! 2026-08-03 "60 → 12 fps" regression, measured at the Stormwind pin by the live FPS probe.
//!
//! The index is a plain XY grid hash: one bucket per [`CELL`]-sized cell, each holding every
//! surface whose wet-footprint box overlaps that cell. A point query is one hash lookup, and the
//! hot path re-uses the exact same predicates ([`WaterChunkInfo::contains`] et al.) on the
//! handful of candidates it returns — the verdict set is identical to the full walk by
//! construction, because a surface is registered in every cell its box overlaps.
//!
//! Membership only changes when surfaces stream in or out with their tiles, so the rebuild is
//! edge-triggered (`Added` / `RemovedComponents`) and costs nothing on a steady frame. A stale
//! entry between despawn and rebuild self-filters at the consumer (`Query::get` on a dead entity
//! misses), and a surface spawned this frame is indexed before the next frame's consumers run.
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use super::query::WaterChunkInfo;
/// Cell pitch, yards: one MCNK (100/3). An ADT surface's wet footprint fits one-to-few cells and
/// a WMO pool's larger box lands in a handful (Stormwind's biggest MLIQ box, 95 × 80 yd, spans
/// ~12) — buckets stay short, and a dry-land query misses the map entirely.
const CELL: f32 = 100.0 / 3.0;
/// The XY grid hash over every loaded liquid surface (module docs). Read via [`Self::over`];
/// maintained by [`maintain_water_index`].
#[derive(Resource, Default)]
pub(crate) struct WaterIndex {
cells: HashMap<[i32; 2], Vec<Entity>>,
}
impl WaterIndex {
/// The cell containing a WoW-space XY.
fn cell_of(x: f32, y: f32) -> [i32; 2] {
[(x / CELL).floor() as i32, (y / CELL).floor() as i32]
}
/// Every surface whose wet-footprint box overlaps the cell containing this WoW-space XY — the
/// candidate set for [`super::surfaces_at`], a superset of the surfaces actually containing
/// the point (the caller's own `contains`/`answers` tests still decide).
pub(crate) fn over(&self, x: f32, y: f32) -> &[Entity] {
self.cells
.get(&Self::cell_of(x, y))
.map_or(&[], Vec::as_slice)
}
}
/// Rebuild [`WaterIndex`] when the surface population changed — tile stream edges only. A full
/// rebuild over ~2k surfaces is microseconds, so incremental bookkeeping would be complexity
/// with nothing to buy.
pub(super) fn maintain_water_index(
mut index: ResMut<WaterIndex>,
added: Query<(), Added<WaterChunkInfo>>,
mut removed: RemovedComponents<WaterChunkInfo>,
chunks: Query<(Entity, &WaterChunkInfo)>,
) {
if removed.read().next().is_none() && added.is_empty() {
return;
}
index.cells.clear();
for (entity, chunk) in &chunks {
let Some([lo, hi]) = chunk.xy_bounds() else {
continue; // an empty grid claims no area
};
let [x0, y0] = WaterIndex::cell_of(lo[0], lo[1]);
let [x1, y1] = WaterIndex::cell_of(hi[0], hi[1]);
for cx in x0..=x1 {
for cy in y0..=y1 {
index.cells.entry([cx, cy]).or_default().push(entity);
}
}
}
}
#[cfg(test)]
mod tests {
use super::super::query::{LiquidClaim, LiquidSource};
use super::*;
use crate::liquid::surfaces_at;
use benilla_formats::LiquidKind;
/// A flat all-wet `cols × rows` grid at height `z` with vertex `(0,0)` at `(x0, y0)`,
/// 10 yd pitch.
fn chunk(x0: f32, y0: f32, z: f32, cols: usize, rows: usize) -> WaterChunkInfo {
let positions = (0..rows)
.flat_map(|j| (0..cols).map(move |i| [x0 + 10.0 * i as f32, y0 + 10.0 * j as f32, z]))
.collect();
WaterChunkInfo::new(
LiquidSource::AdtChunk,
LiquidKind::Still,
[cols, rows],
positions,
vec![true; (cols - 1) * (rows - 1)],
)
}
/// An app with just the maintainer, so `Added`/`RemovedComponents` drive it as they do live.
fn app() -> App {
let mut app = App::new();
app.init_resource::<WaterIndex>()
.add_systems(Update, maintain_water_index);
app
}
/// The index answers exactly like the full walk — same surfaces, same heights — from inside
/// a footprint, at its edge, and from dry land; and a surface spanning many cells is found
/// from every one of them.
#[test]
fn indexed_candidates_match_the_full_walk() {
let mut app = app();
// One small pool and one large one overlapping it, both crossing CELL boundaries.
app.world_mut().spawn(chunk(-20.0, -20.0, 5.0, 3, 3));
app.world_mut().spawn(chunk(-50.0, -50.0, 8.0, 12, 12));
app.update();
let world = app.world_mut();
let index = world.remove_resource::<WaterIndex>().unwrap();
let mut chunks = world.query::<&WaterChunkInfo>();
for x in (-60..=70).step_by(7) {
for y in (-60..=70).step_by(7) {
let wow = [x as f32, y as f32, 0.0];
let mut walk: Vec<f32> =
surfaces_at(chunks.iter(world), wow, LiquidClaim::Outdoors).collect();
let candidates: Vec<&WaterChunkInfo> = index
.over(wow[0], wow[1])
.iter()
.filter_map(|&e| chunks.get(world, e).ok())
.collect();
let mut indexed: Vec<f32> =
surfaces_at(candidates.into_iter(), wow, LiquidClaim::Outdoors).collect();
walk.sort_by(f32::total_cmp);
indexed.sort_by(f32::total_cmp);
assert_eq!(walk, indexed, "divergence at ({x}, {y})");
}
}
}
/// Despawning the last surface empties the index on the next pass — the stream-out edge.
#[test]
fn a_despawned_surface_leaves_the_index() {
let mut app = app();
let e = app.world_mut().spawn(chunk(0.0, 0.0, 5.0, 3, 3)).id();
app.update();
assert!(!app
.world()
.resource::<WaterIndex>()
.over(5.0, 5.0)
.is_empty());
app.world_mut().entity_mut(e).despawn();
app.update();
assert!(app
.world()
.resource::<WaterIndex>()
.over(5.0, 5.0)
.is_empty());
}
}
+211 -93
View File
@@ -612,23 +612,61 @@ pub(crate) struct FarSideOfWater;
/// else (equipment, spell-fx attachments, entity fade twins) is swapped here directly, after the
/// fade resolve, deriving near-vs-far from the CURRENT handle so an upstream overwrite is
/// re-classified the same frame instead of fought over.
#[allow(clippy::type_complexity)]
///
/// **Reactive, not per-frame** (decision 0930; 0922 named the lever). A batch's verdict is a
/// function of its transform, its handle, its layers, its ancestors' room claim, the loaded
/// surfaces, and the eye's side — so a *clean* batch is skipped, and only the changed set
/// re-classifies (~2.6k of ~40k parts on a measured Stormwind frame; the full sweep cost 2.2 ms).
/// The frame promotes to the full walk — byte-identical to the old per-frame behaviour — whenever
/// a global term moves: the eye crosses the surface (every verdict inverts), surfaces stream
/// in/out, or any part despawned (the twin GC below needs its full mark; between full frames a
/// pair orphaned by a handle overwrite — a fade settling to its cutout — lingers harmlessly, kept
/// fresh by the mirror above, until the next edge sweeps it). The room-claim trigger fans DOWN:
/// the claim lives on the unit root, the batches are its descendants, and a claim can change with
/// the unit standing still (a building streaming in under it re-claims at the same spot).
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub(crate) fn classify_water_side(
interleave: crate::particles::WaterInterleave,
mut twins: ResMut<FarSideTwins>,
mut materials: ResMut<Assets<WowModelMaterial>>,
mut near_edits: MessageReader<AssetEvent<WowModelMaterial>>,
mut commands: Commands,
mut parts: Query<(
Entity,
&GlobalTransform,
Option<&bevy::camera::visibility::RenderLayers>,
&mut MeshMaterial3d<WowModelMaterial>,
Has<crate::model_fade::DoodadFade>,
Has<FarSideOfWater>,
// The material-handle leg needs a ParamSet: `Changed<MeshMaterial3d>` reads the same ticks
// the walk's `&mut` writes (Bevy B0001), so the dirty-entity list is collected from `p0`
// before `p1` is borrowed. The transform/layers legs read ticks the walk only reads, so they
// stay plain sibling queries.
mut set: ParamSet<(
Query<
Entity,
(
With<GlobalTransform>,
Changed<MeshMaterial3d<WowModelMaterial>>,
),
>,
Query<(
Entity,
&GlobalTransform,
Option<&bevy::camera::visibility::RenderLayers>,
&mut MeshMaterial3d<WowModelMaterial>,
Has<crate::model_fade::DoodadFade>,
Has<FarSideOfWater>,
)>,
)>,
moved: Query<
Entity,
(
With<MeshMaterial3d<WowModelMaterial>>,
Or<(
Changed<GlobalTransform>,
Changed<bevy::camera::visibility::RenderLayers>,
)>,
),
>,
reclaimed: Query<Entity, Changed<crate::wmo_portal::UnitWmoRoom>>,
children: Query<&Children>,
mut removed: RemovedComponents<MeshMaterial3d<WowModelMaterial>>,
mut eye_was_submerged: Local<Option<bool>>,
) {
use bevy::camera::visibility::RenderLayers;
// A texanim/tint ticker mutates the NEAR asset in place every drawn frame
// (`doodad_anim::tick_anim_materials`); mirror the edit into the live twin, or a far-side
// waterfall batch freezes its scroll the moment it classifies far. Our own twin writes touch
@@ -649,96 +687,176 @@ pub(crate) fn classify_water_side(
.expect("far twin slot is strongly held");
}
}
let mut used: std::collections::HashSet<AssetId<WowModelMaterial>> =
std::collections::HashSet::new();
for (entity, gt, layers, mut mat, authority_owned, marked) in &mut parts {
// A booth-layered batch belongs to its own camera and scene — no world water applies
// (the effect lane's own booth guard, `particles::sim`).
if layers.is_some_and(|l| !l.intersects(&RenderLayers::default())) {
continue;
}
let cur = mat.0.id();
let (near, swapped) = match twins.to_near.get(&cur) {
Some(n) => (n.clone(), true),
None => (mat.0.clone(), false),
};
// This lane's current verdict: the marker (what every composing owner reads); the raw
// handle state seconds it for the lanes this system swaps itself.
let far_now = marked || swapped;
let qualifies = match materials.get(near.id()) {
Some(m) => {
matches!(m.base.alpha_mode, AlphaMode::Blend) && m.extension.model_flags.x <= 0.5
}
None => false,
};
if !qualifies {
// A marked part that settled back onto its opaque cutout (fade over, aura released)
// stops being a transparent draw at all — its side is nobody's business until it
// feathers again.
if marked {
commands.entity(entity).remove::<FarSideOfWater>();
}
continue;
}
used.insert(near.id());
let far = crate::particles::far_side_of_water(&interleave, Some(entity), gt.translation());
if far == far_now {
continue;
}
// The swap's own trace (`WOW_MOVE_TRACE_TAGS=fx`): which batches crossed the plane this
// frame and which way — the numeric read for a sort question no pixel can answer, and
// naturally sparse (transitions only, never per-frame spam).
if crate::dbg_trace::enabled() {
let p = gt.translation();
crate::dbg_trace::line(
"fx",
&format!(
"{} mesh e={entity} at=[{:.1},{:.1},{:.1}]",
if far { "far-side" } else { "near-side" },
p.x,
p.y,
p.z
),
let eye = interleave.eye_submerged();
// `last()` drains the reader — `next()` would leave the tail readable and promote NEXT frame
// to a spurious second full walk.
let full = *eye_was_submerged != Some(eye)
|| interleave.surfaces_changed()
|| removed.read().last().is_some();
*eye_was_submerged = Some(eye);
if full {
// The full walk — every part, plus the twin GC's exact mark-and-sweep.
let mut used: std::collections::HashSet<AssetId<WowModelMaterial>> =
std::collections::HashSet::new();
for item in set.p1().iter_mut() {
classify_part(
&interleave,
&mut twins,
&mut materials,
&mut commands,
Some(&mut used),
item,
);
}
if far {
// Build (or fetch) the twin either way — every composing owner needs it live in the
// map before its own pick can resolve it.
let far_h = if let Some(h) = twins.to_far.get(&near.id()) {
h.clone()
} else {
// `qualifies` above already proved the near asset exists.
let twin = far_twin_of(materials.get(near.id()).unwrap());
let h = materials.add(twin);
twins.to_far.insert(near.id(), h.clone());
twins.to_near.insert(h.id(), near.clone());
h
};
commands.entity(entity).insert(FarSideOfWater);
if !authority_owned {
mat.0 = far_h;
// Drop twin pairs whose near identity no live batch carries any more — the twin dies
// with its users. A respawned model refetches its near handle from its spawner's cache
// and the pair rebuilds lazily on the next far classification.
if !twins.to_far.is_empty() {
let stale: Vec<AssetId<WowModelMaterial>> = twins
.to_far
.keys()
.filter(|k| !used.contains(*k))
.copied()
.collect();
for k in stale {
if let Some(f) = twins.to_far.remove(&k) {
twins.to_near.remove(&f.id());
}
}
} else {
commands.entity(entity).remove::<FarSideOfWater>();
if !authority_owned {
mat.0 = near;
}
} else {
// The reactive frame: only the changed set. `p0` collects first — the ParamSet frees it
// before `p1` — then the transform/layers leg and the room-claim fan-out come straight
// off their filters. An entity dirty on two legs classifies twice; the second pass
// no-ops (`far == far_now`).
let handle_dirty: Vec<Entity> = set.p0().iter().collect();
let mut parts = set.p1();
for e in moved.iter().chain(handle_dirty) {
if let Ok(item) = parts.get_mut(e) {
classify_part(
&interleave,
&mut twins,
&mut materials,
&mut commands,
None,
item,
);
}
}
// A changed claim re-classifies the holder's whole subtree — the claim lives on the unit
// root, the batches are its descendants (a nested holder shadows for its own subtree;
// re-classifying it from here anyway is idempotent).
let mut stack: Vec<Entity> = reclaimed.iter().collect();
while let Some(e) = stack.pop() {
if let Ok(ch) = children.get(e) {
stack.extend(ch.iter());
}
if let Ok(item) = parts.get_mut(e) {
classify_part(
&interleave,
&mut twins,
&mut materials,
&mut commands,
None,
item,
);
}
}
}
// Drop twin pairs whose near identity no live batch carries any more — the twin dies with
// its users. A respawned model refetches its near handle from its spawner's cache and the
// pair rebuilds lazily on the next far classification.
if !twins.to_far.is_empty() {
let stale: Vec<AssetId<WowModelMaterial>> = twins
.to_far
.keys()
.filter(|k| !used.contains(*k))
.copied()
.collect();
for k in stale {
if let Some(f) = twins.to_far.remove(&k) {
twins.to_near.remove(&f.id());
}
}
/// One batch entity's classification — the shared body of [`classify_water_side`]'s full and
/// reactive paths. `used` collects the near identities live batches carry (the twin GC's mark);
/// the reactive path passes `None` and leaves the sweep to the next full frame.
fn classify_part(
interleave: &crate::particles::WaterInterleave,
twins: &mut FarSideTwins,
materials: &mut Assets<WowModelMaterial>,
commands: &mut Commands,
used: Option<&mut std::collections::HashSet<AssetId<WowModelMaterial>>>,
(entity, gt, layers, mut mat, authority_owned, marked): (
Entity,
&GlobalTransform,
Option<&bevy::camera::visibility::RenderLayers>,
Mut<MeshMaterial3d<WowModelMaterial>>,
bool,
bool,
),
) {
use bevy::camera::visibility::RenderLayers;
// A booth-layered batch belongs to its own camera and scene — no world water applies
// (the effect lane's own booth guard, `particles::sim`).
if layers.is_some_and(|l| !l.intersects(&RenderLayers::default())) {
return;
}
let cur = mat.0.id();
let (near, swapped) = match twins.to_near.get(&cur) {
Some(n) => (n.clone(), true),
None => (mat.0.clone(), false),
};
// This lane's current verdict: the marker (what every composing owner reads); the raw
// handle state seconds it for the lanes this system swaps itself.
let far_now = marked || swapped;
let qualifies = match materials.get(near.id()) {
Some(m) => {
matches!(m.base.alpha_mode, AlphaMode::Blend) && m.extension.model_flags.x <= 0.5
}
None => false,
};
if !qualifies {
// A marked part that settled back onto its opaque cutout (fade over, aura released)
// stops being a transparent draw at all — its side is nobody's business until it
// feathers again.
if marked {
commands.entity(entity).remove::<FarSideOfWater>();
}
return;
}
if let Some(used) = used {
used.insert(near.id());
}
let far = crate::particles::far_side_of_water(interleave, Some(entity), gt.translation());
if far == far_now {
return;
}
// The swap's own trace (`WOW_MOVE_TRACE_TAGS=fx`): which batches crossed the plane this
// frame and which way — the numeric read for a sort question no pixel can answer, and
// naturally sparse (transitions only, never per-frame spam).
if crate::dbg_trace::enabled() {
let p = gt.translation();
crate::dbg_trace::line(
"fx",
&format!(
"{} mesh e={entity} at=[{:.1},{:.1},{:.1}]",
if far { "far-side" } else { "near-side" },
p.x,
p.y,
p.z
),
);
}
if far {
// Build (or fetch) the twin either way — every composing owner needs it live in the
// map before its own pick can resolve it.
let far_h = if let Some(h) = twins.to_far.get(&near.id()) {
h.clone()
} else {
// `qualifies` above already proved the near asset exists.
let twin = far_twin_of(materials.get(near.id()).unwrap());
let h = materials.add(twin);
twins.to_far.insert(near.id(), h.clone());
twins.to_near.insert(h.id(), near.clone());
h
};
commands.entity(entity).insert(FarSideOfWater);
if !authority_owned {
mat.0 = far_h;
}
} else {
commands.entity(entity).remove::<FarSideOfWater>();
if !authority_owned {
mat.0 = near;
}
}
}
+2
View File
@@ -652,10 +652,12 @@ pub(super) fn apply_net_updates(
reason,
required_level,
item_guid,
bag_slot,
} => inventory_failure(
reason,
required_level,
item_guid,
bag_slot,
&mut ui_actions.2,
&mut ui_actions.6,
&mut ui_actions.7,
+8 -3
View File
@@ -13,7 +13,7 @@ use bevy::prelude::*;
use super::super::SelfGuid;
use crate::items::Items;
use crate::pending_item_ops::{LockClearedByFailure, PendingItemOps};
use crate::ui_items::EquipErrors;
use crate::ui_items::{EquipError, EquipErrors};
use crate::ui_loot::{LootErrors, LootLatch, LootState};
use crate::ui_loot_roll::LootRolls;
@@ -176,12 +176,17 @@ pub(super) fn inventory_failure(
reason: u8,
required_level: Option<u32>,
item_guid: u64,
bag_slot: u8,
equip_errors: &mut EquipErrors,
pending: &mut PendingItemOps,
lock_cleared: &mut LockClearedByFailure,
) {
debug!("net: inventory failure {reason:#04x} (item {item_guid:#x})");
equip_errors.0.push((reason, required_level));
debug!("net: inventory failure {reason:#04x} (item {item_guid:#x}, bag slot {bag_slot})");
equip_errors.0.push(EquipError {
reason,
required_level,
bag_slot,
});
lock_cleared.0.extend(pending.clear_by_failure(item_guid));
}
+14 -3
View File
@@ -7,7 +7,7 @@
use benilla_protocol::messages::{mail_action, mail_error, MailListEntry};
use crate::net::{ClientCommand, NetCommands};
use crate::ui_items::EquipErrors;
use crate::ui_items::{EquipError, EquipErrors};
use crate::ui_mail::{mail_error_text, MailOpen, MailPending, MailSendAck};
/// `SessionEvent::MailList` (`SMSG_MAIL_LIST_RESULT`) — replace the session's rows + fire the inbox
@@ -63,7 +63,14 @@ pub(super) fn send_mail_result(
// nothing extra with it here (the GetMailList re-sync below reflects the emptied row). [_item]
if action == mail_action::SEND {
if error == mail_error::EQUIP_ERROR {
equip_errors.0.push((equip_error.unwrap_or(0) as u8, None));
equip_errors.0.push(EquipError {
reason: equip_error.unwrap_or(0) as u8,
required_level: None,
// SMSG_SEND_MAIL_RESULT carries only the code, never a bag slot. 255 is the
// wire's own "the player's own array" sentinel — reason 16's substitution
// correctly declines to name a container it was never told about.
bag_slot: 255,
});
}
mail.send_acks.push(MailSendAck {
ok: error == mail_error::OK,
@@ -80,7 +87,11 @@ pub(super) fn send_mail_result(
let _ = commands.0.send(ClientCommand::GetMailList { mailbox });
}
}
mail_error::EQUIP_ERROR => equip_errors.0.push((equip_error.unwrap_or(0) as u8, None)),
mail_error::EQUIP_ERROR => equip_errors.0.push(EquipError {
reason: equip_error.unwrap_or(0) as u8,
required_level: None,
bag_slot: 255,
}),
other => mail.errors.push(mail_error_text(other)),
}
}
+46 -16
View File
@@ -182,11 +182,31 @@ pub(super) struct SceneGates<'w> {
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct WaterInterleave<'w, 's> {
water: Query<'w, 's, &'static crate::liquid::WaterChunkInfo>,
/// The spatial pre-filter every classification routes through: these lanes ask per DRAW
/// (every admitted emitter, every ribbon, every transparent mesh batch), and the full-walk
/// `surfaces_at` at that grain was the 2026-08-03 12-fps regression (`liquid::spatial`).
index: Res<'w, crate::liquid::WaterIndex>,
underwater: Res<'w, crate::liquid::Underwater>,
rooms: Query<'w, 's, &'static crate::wmo_portal::UnitWmoRoom>,
parents: Query<'w, 's, &'static ChildOf>,
}
impl WaterInterleave<'_, '_> {
/// The eye's side of the water — the term that inverts EVERY verdict (`far = above XOR
/// submerged`). The mesh lane's reactive classifier re-runs its full walk on the frame this
/// flips; per-draw callers just read it through [`far_side_of_water_at`].
pub(crate) fn eye_submerged(&self) -> bool {
self.underwater.0.any()
}
/// True when the loaded-surface population changed since the borrowing system last ran (the
/// [`crate::liquid::WaterIndex`] rebuild edge) — any batch may have gained or lost the water
/// plane under it, so the mesh lane's reactive classifier promotes the frame to a full walk.
pub(crate) fn surfaces_changed(&self) -> bool {
self.index.is_changed()
}
}
/// Is this draw on the EYE's **far side** of its local water plane — the point-and-slack core.
///
/// The reference splits its transparents into the above-water and below-water lists and the
@@ -209,23 +229,33 @@ pub(crate) fn far_side_of_water_at(
r: f32,
) -> bool {
let wow = benilla_assets::coords::bevy_to_wow(point_world);
let mut seed = claim_seed;
let mut room = None;
for _ in 0..8 {
let Some(e) = seed else { break };
if let Ok(rm) = w.rooms.get(e) {
room = Some(rm);
break;
// The spatial pre-filter first — these lanes ask per DRAW, and the full `surfaces_at` walk
// at that grain was the 2026-08-03 12-fps regression (`liquid::spatial`). No candidate
// surface over this XY (the dominant dry-land case) is "no admitted surface" under every
// claim, so the room walk below is skipped with the scan.
let candidates = w.index.over(wow[0], wow[1]);
let above = if candidates.is_empty() {
true
} else {
let mut seed = claim_seed;
let mut room = None;
for _ in 0..8 {
let Some(e) = seed else { break };
if let Ok(rm) = w.rooms.get(e) {
room = Some(rm);
break;
}
seed = w.parents.get(e).ok().map(ChildOf::parent);
}
let claim = crate::liquid::unit_claim(room);
let surfaces = candidates.iter().filter_map(|&e| w.water.get(e).ok());
match crate::liquid::surfaces_at(surfaces, wow, claim)
.map(|z| wow[2] - z)
.min_by(|a, b| a.abs().total_cmp(&b.abs()))
{
Some(d) => is_above(d, r),
None => true,
}
seed = w.parents.get(e).ok().map(ChildOf::parent);
}
let claim = crate::liquid::unit_claim(room);
let above = match crate::liquid::surfaces_at(w.water.iter(), wow, claim)
.map(|z| wow[2] - z)
.min_by(|a, b| a.abs().total_cmp(&b.abs()))
{
Some(d) => is_above(d, r),
None => true,
};
if w.underwater.0.any() {
above
-10
View File
@@ -363,16 +363,10 @@ fn control(
// (decisions 0634 + 0696, the "swim in air" family). Fourth slot here rather than a 17th
// top-level param.
Res<crate::wmo_portal::PlayerWmoRoom>,
// The CAMERA eye's own claim, for the waterline snap — the eye's room is not the player's
// (0696's lesson, applied to this consumer): a pool in another storey must not snap a dry
// camera. Published by the portal pass AFTER input, so this is last frame's room — a
// one-frame stale claim at a doorway-plus-waterline crossing, accepted.
Res<crate::wmo_portal::CameraInteriorClaim>,
),
) {
let (water, transports, child_of) = (&world_q.0, &world_q.1, &world_q.2);
let claim = crate::liquid::player_claim(&world_q.3);
let cam_claim = crate::liquid::camera_claim(&world_q.4);
let (left_click, right_click) = (&mut *click_test.0, &mut *click_test.1);
let Ok((mut cam_t, mut cam, camera)) = cameras.single_mut() else {
return;
@@ -583,8 +577,6 @@ fn control(
&mut cam_t,
&move_and_slide,
cam_probe,
water,
cam_claim,
);
return;
}
@@ -1377,8 +1369,6 @@ fn control(
&mut cam_t,
&move_and_slide,
cam_probe,
water,
cam_claim,
);
// The cast bar's local self-cancel trigger (`ui_cast::local_self_cancel`): a fresh
+25 -103
View File
@@ -86,55 +86,25 @@ pub(crate) fn head_height(pivot: Option<&CameraPivot>, scale: f32) -> f32 {
(p.height_local * scale).max(CAM_PIVOT_FLOOR)
})
}
/// The camera **near-plane** distance (yd), shared by the projection ([`setup`]) and the self-avatar
/// fade's `nearclip` ([`crate::model_fade::self_model_fade_alpha`]) so the model finishes fading exactly
/// as the near plane would begin to slice it (the reference couples the two; ours is larger than the
/// client's ~0.1 because our depth range keeps a 1.0 near for precision).
pub(crate) const CAM_NEAR: f32 = 1.0;
/// The camera **near-plane** distance (yd) — the reference's own **1/9**, hardcoded in its camera
/// ctor (`0x50a6c0`: `+0x38 = 0x3de38e39`; the `nearclip` console cvar stores to a global with zero
/// readers — dead plumbing; wow-re `water-frame-straddle` §4d). Shared by the projection ([`setup`])
/// and the self-avatar fade's `nearclip` ([`crate::model_fade::self_model_fade_alpha`]) so the model
/// finishes fading exactly as the near plane would begin to slice it — the reference couples the
/// two the same way (`cam+0x38 ≈ 0.1`, set per frame in the driver `0x511bc0`).
///
/// It was 1.0 from 0062 to 0905 "for depth precision" — a rationale that predates knowing the
/// pipeline: the projection is `perspective_infinite_reverse_rh` on a float depth buffer
/// ([`crate::capture::depth_probe`]'s tests draw with the real one), where `depth = near/z` makes
/// relative precision — and our ULP-relative bias ladder ([`crate::sky_order`]) — independent of
/// the near value. The small near is what keeps the whole waterline-crossing band (the corner-min
/// submersion probe, `liquid::detect_submersion`) inches tall instead of a yard.
pub(crate) const CAM_NEAR: f32 = 1.0 / 9.0;
/// The camera's vertical field of view (radians) — one constant shared by the projection
/// ([`setup`]) and the waterline band ([`waterline_band`], which needs the near rectangle's true
/// height). 45°, the value the projection has always used (Bevy's `PerspectiveProjection` default,
/// ≈ the reference's 44.1° — [`crate::sun`]'s projection note); naming it here just stops the two
/// consumers drifting apart.
/// ([`setup`]) and every consumer that needs the near rectangle's true shape. 45°, the value the
/// projection has always used (Bevy's `PerspectiveProjection` default, ≈ the reference's 44.1° —
/// [`crate::sun`]'s projection note); naming it here just stops the consumers drifting apart.
pub(crate) const CAM_FOVY: f32 = std::f32::consts::FRAC_PI_4;
/// Margin (yd) added to the geometric near-plane band of the waterline snap ([`waterline_band`]):
/// float slack + a visual guard so the surface never grazes the near rectangle's very edge.
const WATERLINE_MARGIN: f32 = 0.05;
/// The half-height of the eye band a liquid plane must never enter: the camera near rectangle's
/// **world-vertical reach** at the current pitch, plus [`WATERLINE_MARGIN`].
///
/// The near rectangle sits `CAM_NEAR` ahead of the eye, spans `±tan(fovy/2)·CAM_NEAR` vertically,
/// and pitches with the camera (no roll, so its width never contributes world height). Its corners'
/// world-Y offset from the eye is therefore bounded by `half_h·cos(pitch) + near·sin(|pitch|)` —
/// with the eye farther than that from a horizontal plane, the rectangle cannot intersect it, so
/// the waterline can never lie across the screen. ~0.46 yd looking level, ~1.05 yd at steep pitch:
/// our band is ~10× the reference's because our near plane is (`CAM_NEAR`'s doc) — the *mechanism*
/// is faithful, the magnitude is necessarily our own.
pub(super) fn waterline_band(pitch: f32) -> f32 {
let half_h = (CAM_FOVY * 0.5).tan() * CAM_NEAR;
half_h * pitch.cos().abs() + CAM_NEAR * pitch.sin().abs() + WATERLINE_MARGIN
}
/// The waterline **snap** (director's call, 2026-08-03: "it's either snap under or above" — never
/// the half-submerged view with the waterline across the screen): given the desired eye height and
/// the admitted liquid surface heights under/over its XY, return the corrected eye height, or
/// `None` when the eye already clears every surface by [`waterline_band`]'s `band`.
///
/// The eye keeps the **side it was already on** (`d = eye − surface` signed; `d = 0` counts as
/// above) and is pushed to `surface ± band` — the nearest surface by |d| wins where several stack.
/// Pure, in WoW-Z (== Bevy Y), so the rule is testable without ECS scaffolding, like
/// [`crate::liquid`]'s `submersion_at`.
pub(super) fn waterline_snap(
eye_z: f32,
band: f32,
surfaces: impl Iterator<Item = f32>,
) -> Option<f32> {
let (d, z) = surfaces
.map(|z| (eye_z - z, z))
.min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()))?;
(d.abs() < band).then_some(if d >= 0.0 { z + band } else { z - band })
}
/// A small sphere swept from the camera pivot toward the desired camera seat each frame to keep walls
/// from sliding between the camera and the character (camera collision). Built once at startup like
@@ -421,8 +391,6 @@ pub(super) fn seat_camera(
cam_t: &mut Transform,
move_and_slide: &MoveAndSlide,
cam_probe: &Collider,
water: &Query<&crate::liquid::WaterChunkInfo>,
eye_claim: crate::liquid::LiquidClaim,
) {
// A keyboard turn carries the camera RIGIDLY (char and camera rotate as one — the reference
// look, director's call closing 0050's open "camera follow on turn"): an eased chase of a
@@ -478,22 +446,14 @@ pub(super) fn seat_camera(
};
let frac = (rig.collision_distance / boom_len).clamp(0.0, 1.0);
cam_t.translation = head + boom * frac;
// The waterline snap: the eye never sits within the near rectangle's reach of a liquid plane,
// so the screen is always wholly above or wholly below the surface — never the half-and-half
// straddle with the waterline across it (director's call, 2026-08-03; [`waterline_snap`]).
// Candidates take the EYE's own room claim, not the player's — a pool in another storey must
// not snap a dry camera (the 0696 delegation, applied to this consumer too). Runs before the
// dump below so probes read the realized pose, and before the self-fade so a snapped-in eye
// still fades the avatar by where the camera actually is. (Known edge, accepted: the vertical
// nudge happens after the collision sweep, so waterline + overhead geometry within a yard can
// momentarily overlap the probe radius — rare enough to take over re-sweeping per frame.)
{
let eye_wow = benilla_assets::coords::bevy_to_wow(cam_t.translation);
let admitted = crate::liquid::surfaces_at(water.iter(), eye_wow, eye_claim);
if let Some(z) = waterline_snap(eye_wow[2], waterline_band(cam.pitch), admitted) {
cam_t.translation.y = z; // WoW Z is Bevy Y — no round-trip needed for a height fix
}
}
// No waterline handling here — deliberately. The reference NEVER moves the eye for liquid
// (verified negative, wow-re `water-frame-straddle` §4a: zero liquid-height queries in the
// camera TU); the no-straddle experience is the *submersion probe's* — the frame flips
// submerged the moment the lowest near-plane corner reaches the surface
// (`liquid::detect_submersion`, the corner-min probe), and with [`CAM_NEAR`] at the
// reference's 1/9 the whole crossing band is a few inches tall. 0905's eye snap — the local
// compensation for the old 1.0-yd near plane — is removed with its cause (its record is
// superseded; see the 0905-successor decision).
// `WOW_CAM_DUMP=frame`: the REALIZED pose, per frame, bit-exact — not the pose that was asked for.
//
// Every scripted probe sets `yaw`/`pitch`/`distance` and we then reason as though the camera is
@@ -844,44 +804,6 @@ mod tests {
use crate::billboard::BillboardCard;
use crate::mesh_tag::alpha_bits;
/// The waterline band is the near rectangle's true world-vertical reach: at level pitch the
/// half-height alone, at steep pitch the near distance dominates, and it never collapses below
/// the margin — the geometry the snap's whole guarantee rests on.
#[test]
fn waterline_band_tracks_the_near_rectangle() {
let half_h = (CAM_FOVY * 0.5).tan() * CAM_NEAR;
assert!((waterline_band(0.0) - (half_h + 0.05)).abs() < 1e-6);
// Steeper pitch ⇒ the rectangle's far edge dips farther below the eye than half_h alone.
assert!(waterline_band(1.0) > waterline_band(0.0));
// Symmetric in look-up vs look-down.
assert!((waterline_band(0.7) - waterline_band(-0.7)).abs() < 1e-6);
}
/// The snap rule: inside the band the eye is pushed to `surface ± band` on the side it was
/// already on (0 counts as above); outside the band, and with no surface at all, it is left
/// alone; where surfaces stack the nearest one judges.
#[test]
fn waterline_snap_keeps_the_side_and_clears_the_band() {
let band = 0.5;
// Above, inside the band → pushed up to surface + band.
assert_eq!(waterline_snap(10.2, band, [10.0].into_iter()), Some(10.5));
// Below, inside the band → pushed down to surface − band.
assert_eq!(waterline_snap(9.8, band, [10.0].into_iter()), Some(9.5));
// Exactly on the plane counts as above (a deterministic tie, not a flicker source).
assert_eq!(waterline_snap(10.0, band, [10.0].into_iter()), Some(10.5));
// Clear of the band → untouched.
assert_eq!(waterline_snap(10.6, band, [10.0].into_iter()), None);
assert_eq!(waterline_snap(9.4, band, [10.0].into_iter()), None);
// No admitted surface → untouched.
assert_eq!(waterline_snap(10.0, band, std::iter::empty()), None);
// Two stacked surfaces: the NEAREST judges — an eye under a high lake but hovering over a
// low pool snaps off the pool, not the lake 5 yd up.
assert_eq!(
waterline_snap(10.1, band, [15.0, 10.0].into_iter()),
Some(10.5)
);
}
/// The self-avatar's zoom-to-first-person fade reaches its BILLBOARD cards — the night-elf eye
/// glow (ledger B71: two additive quads left burning in mid-air after the body was hidden).
/// A card is a world ROOT following an anchor inside the model (decision 0153), so the fade's
+151 -1
View File
@@ -101,7 +101,69 @@ pub(crate) struct CastLadder<'w, 's> {
pub(crate) ground: ResMut<'w, super::targeting::SpellTargeting>,
}
/// What a targeting-cursor click bound — the two things `BindLocation 0x6e60f0` /
/// `BindTarget 0x6e5b40` can fill into a standing flag_word once the ladder has already run.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum TargetedBind {
/// The terrain click's point, in WoW coords (decision 0792).
Dest([f32; 3]),
/// The bag / paper-doll click's item guid (decision 0923).
Item(u64),
}
impl CastLadder<'_, '_> {
/// **The targeting cursor's commit tail**, shared by both halves. The ladder itself already
/// ran when the cursor went up — the click owes only what `SendCast 0x6e54f0`'s tail owes:
/// the packet (same block, two opcodes, [`CastCommit`] picking which), the pending arm, and
/// the GCD. Then the word clears.
///
/// One function because there is one tail: the ground click and the item click differ in
/// exactly one field of one packet, and a tail written twice is a tail that drifts (the
/// lesson decision 0914 wrote up one layer above this).
pub(crate) fn commit_targeted(
&mut self,
spell_id: u32,
commit: CastCommit,
bound: TargetedBind,
) {
let cmd = match commit {
// Two opcodes on the spell side (`CMSG_CAST_SPELL` carries the dest and the item bit
// in different builders), one on the item side — the block itself is the same block.
CastCommit::Spell => match bound {
TargetedBind::Dest(dest) => ClientCommand::CastSpellAtDest { spell_id, dest },
TargetedBind::Item(item_guid) => ClientCommand::CastSpellItem {
spell_id,
item_guid,
},
},
CastCommit::Item {
bag_index,
slot,
spell_index,
..
} => ClientCommand::UseItem {
bag_index,
slot,
spell_index,
target: match bound {
TargetedBind::Dest(dest) => UseItemTarget::Dest(dest),
TargetedBind::Item(guid) => UseItemTarget::Item(guid),
},
},
};
let _ = self.commands.0.send(cmd);
let now = Instant::now();
if commit.is_item() {
self.pending.arm_item(spell_id, now);
} else {
self.pending.arm(spell_id, now);
}
if let Some(d) = self.spells.as_ref().and_then(|s| s.catalog.get(spell_id)) {
self.cooldowns.start_gcd(spell_id, d, now);
}
self.ground.clear();
}
/// Run the ladder for `spell_id` and commit as `commit` says — the only entry point.
pub(crate) fn send(
&mut self,
@@ -346,7 +408,17 @@ fn send_spell_cast(
// `0xceac48` included) across the cursor, so the world click's `0x6e54f0` still
// emits USE_ITEM for a thrown grenade or a stick of dynamite.
debug!("ui_action: cast {spell_id} awaits its ground click — targeting cursor up");
ground.enter(spell_id, commit);
ground.enter(spell_id, commit, super::targeting::TargetingWants::Location);
return;
}
cast_target::CastWireTarget::ItemTargeting => {
// The same cursor's item half (decision 0923) — the bag / paper-doll click seam
// binds, and its `0x495d60` owes the commit tail. A poison, a sharpening stone,
// an enchanting scroll, a Craft-window enchant: same entry, same word, same
// pending-cast block carried across the cursor, so the click still emits
// USE_ITEM for an item's own ON_USE and CAST_SPELL for a known enchant.
debug!("ui_action: cast {spell_id} awaits its item click — targeting cursor up");
ground.enter(spell_id, commit, super::targeting::TargetingWants::Item);
return;
}
cast_target::CastWireTarget::Refused(reason) => {
@@ -677,4 +749,82 @@ mod tests {
})
));
}
/// The targeting cursor's ONE commit tail (decision 0923), all four cells of its
/// commit × bind grid. Both halves of the cursor share it precisely so this table can't grow
/// a fifth, divergent copy: a thrown grenade and a poison bottle are the same `CMSG_USE_ITEM`
/// with a different bit set, and a Flamestrike and a Craft-window enchant are the same
/// pending-cast block under two `CMSG_CAST_SPELL` builders. Every cell also arms the pending
/// guard and clears the word — asserted once, since the tail is one function.
#[test]
fn the_targeting_commit_tail_covers_both_halves() {
use benilla_protocol::messages::UseItemTarget;
const DEST: [f32; 3] = [1.0, 2.0, 3.0];
const ITEM: u64 = 0xF150_0000_0000_ABCD;
let (mut world, rx) = world();
let commit = |world: &mut World, commit: CastCommit, bound: TargetedBind| {
world.insert_resource(crate::ui_cast::PendingCast::default());
world
.resource_mut::<super::super::targeting::SpellTargeting>()
.enter(
HEARTHSTONE,
commit,
super::super::targeting::TargetingWants::Item,
);
world
.run_system_once(move |mut ladder: CastLadder| {
ladder.commit_targeted(HEARTHSTONE, commit, bound);
})
.expect("the tail runs as a one-shot system");
};
commit(&mut world, CastCommit::Spell, TargetedBind::Dest(DEST));
assert!(matches!(
rx.try_recv(),
Ok(ClientCommand::CastSpellAtDest { dest: DEST, .. })
));
assert!(
!world
.resource::<super::super::targeting::SpellTargeting>()
.active(),
"the commit clears the one word"
);
assert!(
world
.resource::<crate::ui_cast::PendingCast>()
.in_flight(Instant::now()),
"and arms the in-flight guard the click owes"
);
commit(&mut world, CastCommit::Spell, TargetedBind::Item(ITEM));
assert!(matches!(
rx.try_recv(),
Ok(ClientCommand::CastSpellItem {
item_guid: ITEM,
..
})
));
commit(&mut world, HEARTH_COMMIT, TargetedBind::Dest(DEST));
assert!(matches!(
rx.try_recv(),
Ok(ClientCommand::UseItem {
bag_index: 255,
slot: 24,
target: UseItemTarget::Dest(DEST),
..
})
));
commit(&mut world, HEARTH_COMMIT, TargetedBind::Item(ITEM));
assert!(matches!(
rx.try_recv(),
Ok(ClientCommand::UseItem {
bag_index: 255,
slot: 24,
target: UseItemTarget::Item(ITEM),
..
})
));
}
}
+55 -10
View File
@@ -20,13 +20,15 @@
//! is unmodeled here (INTERIM): we refuse locally with the client's own error strings instead —
//! `0x09` "You have no target." / `0x0A` "Invalid target" — and never ship an unbindable cast.
//!
//! The **location half of the targeting cursor is modeled** (decision 0792): switch enum 16 and
//! the bare DEST word (`Targets = 0x40`) resolve to [`CastWireTarget::GroundTargeting`], which
//! [`super::targeting`] turns into the Cast/UnableCast cursor + the world-click commit. Still
//! deferred, refused-not-guessed: the pure SOURCE word (0x20 — NPC-cast data only), item bits
//! 4/14 (enchants/poisons cast *at* an item), gameobject bit 11 (the OPEN_LOCK path already has
//! its own shape), STRING bit 13, and the *unit* hand-cursor mode (the residual-unit-word
//! machine behind the autoSelfCast stand-in above).
//! **Both halves of the targeting cursor are modeled.** The *location* half (decision 0792):
//! switch enum 16 and the bare DEST word (`Targets = 0x40`) resolve to
//! [`CastWireTarget::GroundTargeting`], which [`super::targeting`] turns into the Cast/UnableCast
//! cursor + the world-click commit. The *item* half (decision 0923): the bare ITEM word
//! (`Targets = 0x10`) resolves to [`CastWireTarget::ItemTargeting`], the same module's bag /
//! paper-doll click commit. Still deferred, refused-not-guessed: the pure SOURCE word (0x20 —
//! NPC-cast data only), the LOCKED word (`0x4000`, whose lock machinery the world-click OPEN_LOCK
//! path owns), gameobject bit 11, STRING bit 13, and the *unit* hand-cursor mode (the
//! residual-unit-word machine behind the autoSelfCast stand-in above).
use benilla_formats::SpellDisplay;
use bevy::ecs::system::SystemParam;
@@ -63,6 +65,11 @@ pub(crate) const ERR_INVALID_TARGET: u8 = 0x0A;
/// The dest-location bit — the ground-cast wire mask (`BindLocation 0x6e60f0`'s bit-6 arm; the
/// source bit 5 completes `TargetingWantsLocation 0x6e6320`'s `0x60`, still refused below).
const TF_DEST_LOCATION: u16 = 0x0040;
/// The item bit — the *other* half of the targeting cursor (decision 0923). Its predicate twin is
/// `TargetingWantsItem 0x6e6330` (`flag_word & 0x4010`), which the bag and paper-doll click seams
/// consult before binding the clicked item ([`super::targeting`]). Only the bare bit resolves here:
/// see [`resolve_cast_target`] for why `0x4000` stays refused.
const TF_ITEM: u16 = 0x0010;
/// What the wire's target block should carry for this cast.
#[derive(Debug, PartialEq, Eq)]
@@ -75,6 +82,10 @@ pub(crate) enum CastWireTarget {
/// A ground-targeted cast (decision 0792) — no send yet: enter the targeting-cursor mode's
/// location half ([`super::targeting`]); the world click commits mask `0x40` + the point.
GroundTargeting,
/// An **item**-targeted cast (decision 0923) — no send yet either: the same cursor mode's item
/// half; the next bag or paper-doll click commits mask `0x10` + the clicked item's packed guid.
/// Poisons, sharpening/weightstones, weapon oils, scopes, every enchant, Disenchant.
ItemTargeting,
/// Nothing bindable — do NOT send; surface this client error instead.
Refused(u8),
}
@@ -313,6 +324,22 @@ pub(crate) fn resolve_cast_target(
if word == TF_DEST_LOCATION {
return CastWireTarget::GroundTargeting;
}
// The item half of the same cursor (decision 0923). The reference never decides this at
// arm time — it lets the bind walk fail, leaves the word standing, and asks
// `TargetingWantsItem 0x6e6330` (`word & 0x4010`) at *click* time. Forking here is
// byte-equivalent on shipped data because the two item bits are never mixed with a unit
// bit: a whole-`Spell.dbc` sweep finds every `Targets & 0x4010` row is exactly `0x10`
// (363 rows — enchants, poisons, stones, oils, scopes, Disenchant) or exactly `0x4000`
// (103 rows — the OPEN_LOCK family), and that pin is a test
// (`benilla_formats::spells::catalog_tests::real_item_target_family_and_its_gate_columns`).
//
// Only the bare `0x10` resolves. `0x4000` is `TARGET_FLAG_LOCKED`, whose spells are the
// Mining/Herb Gathering/Opening/Pick Lock family: implicit arm 23 also sets the GAMEOBJECT
// bit, and their *item* leg (a lockbox in your bag) needs the lock machinery the world
// click already owns — the GO gap decision 0914 named. It stays refused, deliberately.
if word == TF_ITEM {
return CastWireTarget::ItemTargeting;
}
return CastWireTarget::Refused(ERR_INVALID_TARGET);
}
// Candidate 1: the current selection (ArmCast's explicit-guid leg — for a player caster the
@@ -469,11 +496,11 @@ mod tests {
);
}
/// The still-deferred non-unit masks (source-location, item bits, string) refuse instead of
/// shipping a guess — the machines named in the module docs.
/// The still-deferred non-unit masks (source-location, LOCKED, string, the 0x60 pair) refuse
/// instead of shipping a guess — the machines named in the module docs.
#[test]
fn non_unit_masks_refuse() {
for targets in [0x20u32, 0x10, 0x2000, 0x60] {
for targets in [0x20u32, 0x4000, 0x2000, 0x60] {
let s = spell(targets, 0);
assert_eq!(
resolve_cast_target(Some(&s), Some(42), Some(1), true, &rel_none()),
@@ -483,6 +510,24 @@ mod tests {
}
}
/// The bare ITEM word enters the cursor's item half (decision 0923) — and it does so whether
/// or not a unit is selected, because there is no unit leg to try: the word carries no unit
/// bit (pinned across the whole shipped table by the formats-side family test). Its LOCKED
/// sibling `0x4000` stays refused above; the word==0 immediate commit still precedes both.
#[test]
fn the_item_word_enters_the_cursors_item_half() {
let poison = spell(0x10, 0);
assert_eq!(
resolve_cast_target(Some(&poison), Some(42), Some(1), true, &rel_none()),
CastWireTarget::ItemTargeting
);
assert_eq!(
resolve_cast_target(Some(&poison), None, Some(1), false, &rel_none()),
CastWireTarget::ItemTargeting,
"no selection and no autoSelfCast change nothing — the item bit is the whole word"
);
}
fn rel_none() -> TargetRelations<'static> {
static EMPTY: Reputations = Reputations(Vec::new());
TargetRelations {
+5
View File
@@ -285,6 +285,11 @@ impl Plugin for UiActionPlugin {
.in_set(UnitFeed)
.before(UiInput),
targeting::drain_stop_targeting.after(UiInput),
// The item half's commit (decision 0923) — the bag / paper-doll click seam's
// `0x495d60`. A UI drain like the others: after the input pass, so a click
// this frame binds this frame. It is deliberately NOT in the target chain —
// the clicks it consumes never reach the world.
targeting::commit_item_cast_on_pick.after(UiInput),
),
);
}
+56 -1
View File
@@ -342,7 +342,11 @@ pub(super) fn feed_action_state(
}
ACTION_KIND_ITEM => {
let template = items.template(button.action, 0, &commands).cloned();
st.consumable = template.as_ref().is_some_and(|t| t.class == 0);
// `IsConsumableAction 0x4e5250` — ammo/thrown by InventoryType, or an ON_USE
// block with NEGATIVE charges ([`ItemInfo::is_consumable`], byte-cited there).
// NOT `Class == 0`: that read was ours, it is not the reference's, and it is what
// put a stack number under a mount (Class 15) on the bar.
st.consumable = template.as_ref().is_some_and(|t| t.is_consumable());
let count = me
.map(|(s, _, _, _)| crate::ui_items::count_of(&s.0, &items, button.action))
.unwrap_or(0);
@@ -459,6 +463,57 @@ mod tests {
}
}
/// `IsConsumableAction 0x4e5250` — the gate on the bar's Count fontstring
/// ([`benilla_protocol::ItemInfo::is_consumable`], fed into [`ActionState::consumable`]).
/// The director's B201 is the mount row: an on-use item with no charges wore a stack number
/// under it because we tested `Class == 0` instead of the reference's two clauses.
#[test]
fn is_consumable_is_ammo_thrown_or_a_negative_charge_use_block() {
use benilla_protocol::messages::ItemSpellEntry;
let block = |trigger: u32, charges: i32| ItemSpellEntry {
index: 0,
spell_id: 439,
trigger,
charges,
cooldown_ms: -1,
category: 0,
category_cooldown_ms: -1,
};
// The report: a mount. Class 15 Miscellaneous, InventoryType 0, one ON_USE block whose
// SpellCharges is 0 — the item is not destroyed by using it.
let mut mount = crate::items::test_template("Red Skeletal Horse");
mount.class = 15;
mount.spells = vec![block(0, 0)];
assert!(!mount.is_consumable(), "a mount has no stack to show");
// A potion: Class 0, but that is not what decides it — the ON_USE block's -1 charges is.
let mut potion = crate::items::test_template("Minor Healing Potion");
potion.spells = vec![block(0, -1)];
assert!(potion.is_consumable());
// …and Class 0 alone (a conjured-water-shaped template with no on-use block at all) is
// NOT enough, which is exactly what the old `class == 0` read got wrong in reverse.
let classless = crate::items::test_template("Trade Good");
assert!(!classless.is_consumable());
// The InventoryType clause, both members — ammo and thrown always count, charges or not.
for inv in [24u32, 25] {
let mut ammo = crate::items::test_template("Rough Arrow");
ammo.inventory_type = inv;
assert!(ammo.is_consumable(), "InventoryType {inv} is consumable");
}
let mut trinket = crate::items::test_template("Trinket");
trinket.inventory_type = 12;
assert!(!trinket.is_consumable());
// An ON_EQUIP proc with negative charges is not an ON_USE block: the trigger must be 0.
let mut proc_item = crate::items::test_template("Proc Weapon");
proc_item.spells = vec![block(1, -1)];
assert!(!proc_item.is_consumable());
}
/// The `GetMinMaxRange 0x6e3480` transcription: melee reach floor, the ranged reach pad on
/// both bounds, the self-cast short-circuit, and the rangeless self row.
#[test]
+357 -73
View File
@@ -1,29 +1,37 @@
//! Ground-target casting — the targeting-cursor machine's **location half** (decision 0792,
//! closing B132: "ground-targeted AOE all Invalid target").
//! **The targeting cursor** — the client's one "this cast is waiting for a click" machine, both
//! halves: the **location** half (decision 0792, closing B132: "ground-targeted AOE all Invalid
//! target") and the **item** half (decision 0923: poisons, stones, oils, scopes, enchants).
//!
//! The reference's targeting mode IS a nonzero flag_word (`IsTargeting 0x6e48a0`); this module
//! holds benilla's mirror of that state and the systems around it, each transcribing a
//! byte-verified piece (wow-re `wave-cast.md` + `cursor-system.md` §5 + the world-click path in
//! `world-click-targeting.md`, the 0792 dispatch's synthesis):
//! `world-click-targeting.md`, plus 0923's own read of the two pickup seams):
//!
//! - **Cursor** ([`drive_targeting_cursor`]): while targeting, the world classifier is
//! pre-empted (the ref's dispatcher step 2 runs before any object resolve) — **Cast** when the
//! hovered ground point passes the range gate, **UnableCast** otherwise (`0x4820f0`'s split,
//! computed by `CheckGroundPointInRange 0x6e6810` over `GetMinMaxRange`).
//! - **Commit** ([`commit_ground_cast_on_click`]): the terrain leg's action-1 arm tries the
//! ground commit first, gated only on the word (`0x492580` → `BindLocation 0x6e60f0`): bind
//! the clicked point, and with the word cleared, send — mask `0x40` + the point in WoW coords
//! (`SendCast 0x6e54f0`), arming the pending cast + the GCD exactly like the unit send.
//! computed by `CheckGroundPointInRange 0x6e6810` over `GetMinMaxRange`). The item half takes
//! plain Cast: that range gate is a *location* predicate.
//! - **Location commit** ([`commit_ground_cast_on_click`]): the terrain leg's action-1 arm tries
//! the ground commit first, gated only on the word (`0x492580` → `BindLocation 0x6e60f0`).
//! - **Item commit** ([`commit_item_cast_on_pick`]): the bag click (`PickupContainerItem
//! 0x4f9b30` @ `4f9c54`) and the paper-doll click (`0x4c7300` @ `4c76df`) each carry the same
//! three-instruction rung — IsTargeting, `TargetingWantsItem 0x6e6330`, then
//! `0x495d60(itemGuid)` and return — and `0x495d60` is [`item_target_refusal`] plus the bind.
//! - Both end in **one commit tail** (`CastLadder::commit_targeted`): the packet (`SendCast
//! 0x6e54f0`'s same block, two opcodes), the pending arm, the GCD, and the word cleared.
//! - **The ESC chain** ([`feed_targeting_to_vm`] / [`drain_stop_targeting`]): the real
//! `UIParent.lua:1490` rung (`elseif ( SpellStopTargeting() ) then`) runs in our live VM; the
//! feed pushes the state its `SpellIsTargeting`/`SpellStopTargeting` bindings read, the drain
//! commits the cancel. AbortCast in targeting mode clears the word and sends **nothing**.
//! feed pushes the state its `SpellIsTargeting`/`SpellStopTargeting` bindings read (and the
//! item half's arm, which gates the VM's click reroute), the drain commits the cancel.
//! AbortCast in targeting mode clears the word and sends **nothing**.
//!
//! Entry and the two press-cancel shapes live in the cast path itself: the resolver yields
//! [`super::cast_target::CastWireTarget::GroundTargeting`] (arm 16 / the bare DEST word), the
//! one cast-send path enters the mode here, a NEW spell's press aborts-and-proceeds (`TryCast
//! 6e4d62`), and the action bar's re-press of the SAME spell toggles the mode off (`UseAction
//! 0x4e5ee0`'s `GetTargetingSpellId`+`StopTargeting` — [`super::drain`]).
//! [`super::cast_target::CastWireTarget::GroundTargeting`] (arm 16 / the bare DEST word) or
//! `ItemTargeting` (the bare ITEM word), the one cast-send path enters the mode here, a NEW
//! spell's press aborts-and-proceeds (`TryCast 6e4d62`), and the action bar's re-press of the
//! SAME spell toggles the mode off (`UseAction 0x4e5ee0`'s
//! `GetTargetingSpellId`+`StopTargeting` — [`super::drain`]).
//!
//! The click path is byte-pinned by wow-re's `world-click-targeting.md` (the 0792 dispatch's
//! answers): the terrain-leg commit `0x492580` has **no range gate and no error path** — it
@@ -37,33 +45,52 @@
//! IsTargeting-skip). The ground reticle draws in [`crate::target`]'s `reticle` module
//! (decision 0797) off [`ground_cast_radius`] + the cursor's range verdict.
use std::time::Instant;
use bevy::prelude::*;
use benilla_assets::coords::bevy_to_wow;
use benilla_formats::SpellRange;
use crate::interact::{WorldClick, WorldRightPress};
use crate::net::{ClientCommand, NetCommands, SelfPlayer};
use crate::net::SelfPlayer;
use crate::target::{CursorKind, PickOcclusion, WorldCursor};
use super::Spells;
/// The targeting-cursor mode — benilla's `flag_word != 0` mirror for the location half: `Some`
/// while a ground-targeted cast awaits its world click. Entered by the one cast-send path
/// ([`super::send_spell_cast`]'s `GroundTargeting` arm), cleared by the commit, the two press
/// Which click the standing flag_word is waiting for — the reference's two *wants* predicates,
/// each a one-instruction mask test on the same word `0xcecac0`, each consulted by exactly one
/// click seam:
///
/// - `TargetingWantsLocation 0x6e6320` (`word & 0x60`) → the terrain click's commit.
/// - `TargetingWantsItem 0x6e6330` (`word & 0x4010`) → the **bag** click's bind
/// (`PickupContainerItem 0x4f9b30` @ `4f9c5d`) and the **paper-doll** click's
/// (`0x4c7300` @ `4c76e8`) — the identical three-instruction rung in both.
///
/// The word itself is one state, which is why this is a mode of one resource and not two
/// resources: every cancel (ESC, right-press, a new cast's abort-and-proceed) clears the one word,
/// and the reference has exactly one `IsTargeting`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum TargetingWants {
/// Decision 0792 — the location half.
Location,
/// Decision 0923 — the item half.
Item,
}
/// The targeting-cursor mode — benilla's `flag_word != 0` mirror: `Some` while a cast awaits the
/// click that binds its target. Entered by the one cast-send path ([`super::cast_send`]'s
/// `GroundTargeting`/`ItemTargeting` arms), cleared by whichever commit fires, the two press
/// cancels, and the ESC drain.
#[derive(Resource, Default)]
pub(crate) struct SpellTargeting(Option<GroundTargeting>);
pub(crate) struct SpellTargeting(Option<Targeting>);
struct GroundTargeting {
struct Targeting {
spell_id: u32,
/// What the world click will commit. The ref keeps the whole pending-cast block across the
/// cursor — the cast **item's** guid at `0xceac48` included — so `0x6e54f0`'s discriminator
/// still picks `CMSG_USE_ITEM` when the click lands: a thrown grenade, a stick of dynamite, a
/// Goblin Mortar (decision 0914; 46 of the 1.12 on-use item spells arm this cursor).
/// What the click will commit. The ref keeps the whole pending-cast block across the cursor —
/// the cast **item's** guid at `0xceac48` included — so `0x6e54f0`'s discriminator still picks
/// `CMSG_USE_ITEM` when the click lands: a thrown grenade for the location half (decision
/// 0914), a poison bottle for the item half (decision 0923).
commit: super::cast_send::CastCommit,
wants: TargetingWants,
}
impl SpellTargeting {
@@ -78,13 +105,31 @@ impl SpellTargeting {
self.0.as_ref().map(|t| t.spell_id)
}
pub(crate) fn enter(&mut self, spell_id: u32, commit: super::cast_send::CastCommit) {
self.0 = Some(GroundTargeting { spell_id, commit });
/// Which click seam owns the standing word — `None` when nothing is targeting.
pub(crate) fn wants(&self) -> Option<TargetingWants> {
self.0.as_ref().map(|t| t.wants)
}
/// What the pending ground cast will commit as when the click lands.
fn commit(&self) -> Option<super::cast_send::CastCommit> {
self.0.as_ref().map(|t| t.commit)
pub(crate) fn enter(
&mut self,
spell_id: u32,
commit: super::cast_send::CastCommit,
wants: TargetingWants,
) {
self.0 = Some(Targeting {
spell_id,
commit,
wants,
});
}
/// The pending cast's `(spell, commit)` when the word wants `wants` — the shape both commit
/// systems open with, so neither can fire on the other half's word.
fn pending_for(&self, wants: TargetingWants) -> Option<(u32, super::cast_send::CastCommit)> {
self.0
.as_ref()
.filter(|t| t.wants == wants)
.map(|t| (t.spell_id, t.commit))
}
pub(crate) fn clear(&mut self) {
@@ -145,7 +190,17 @@ pub(crate) fn ground_cast_radius(spells: Option<&Spells>, spell_id: u32, level:
/// §5): **Cast** over an in-range ground point, **UnableCast** out of range / too close / no
/// ground hit at all (sky, mouselook). Runs right after [`crate::target`]'s classifier in the
/// target chain and overwrites its verdict — the ref runs this branch before the object
/// classifier ever executes, and the visible result is identical.
/// classifier ever executes, and the visible result is identical. Because it writes the *base*
/// [`WorldCursor`], it also pre-empts every UI overlay downstream ([`crate::cursor`]'s
/// repair/sell latches only arm while the base is Point) — which is the same total pre-emption
/// the reference's step 2 has.
///
/// The **item** half takes plain `Cast`, never the grayed twin: the range verdict below comes
/// from `CheckGroundPointInRange 0x6e6810`, which is a *location* predicate (its one caller
/// binary-wide is this classifier, over the ground point), and an item-targeting word has no
/// ground point to judge. Whether the reference grays the item cursor per hovered slot is
/// unpinned — the honest read is that its validity gate runs at BIND time (`0x495d60`, whose
/// mismatch is error `0x0a`), not at hover time. Named INTERIM, decision 0923.
pub(crate) fn drive_targeting_cursor(
targeting: Res<SpellTargeting>,
occlusion: Res<PickOcclusion>,
@@ -156,17 +211,20 @@ pub(crate) fn drive_targeting_cursor(
let Some(spell_id) = targeting.spell() else {
return;
};
let in_range = match (occlusion.point, self_tf.single()) {
(Some(point), Ok(tf)) => ground_point_in_range(
range_row(spells.as_deref(), spell_id),
tf.translation,
point,
),
_ => false,
let unable = match targeting.wants() {
Some(TargetingWants::Item) | None => false,
Some(TargetingWants::Location) => !match (occlusion.point, self_tf.single()) {
(Some(point), Ok(tf)) => ground_point_in_range(
range_row(spells.as_deref(), spell_id),
tf.translation,
point,
),
_ => false,
},
};
*cursor = WorldCursor {
kind: CursorKind::Cast,
unable: !in_range,
unable,
};
}
@@ -184,14 +242,10 @@ pub(crate) fn drive_targeting_cursor(
/// so the commit that clears it must come later in the same frame.
pub(crate) fn commit_ground_cast_on_click(
mut clicks: MessageReader<WorldClick>,
mut targeting: ResMut<SpellTargeting>,
occlusion: Res<PickOcclusion>,
spells: Option<Res<Spells>>,
net: Res<NetCommands>,
mut pending: ResMut<crate::ui_cast::PendingCast>,
mut cooldowns: ResMut<crate::cooldowns::Cooldowns>,
mut ladder: super::CastLadder,
) {
if !targeting.active() {
if !ladder.ground.active() {
// Keep the reader current so a click buffered while idle can never replay as a commit
// the frame the mode turns on.
clicks.clear();
@@ -200,7 +254,9 @@ pub(crate) fn commit_ground_cast_on_click(
if clicks.read().last().is_none() {
return;
}
let (Some(spell_id), Some(commit)) = (targeting.spell(), targeting.commit()) else {
// `TargetingWantsLocation 0x6e6320` — an item-targeting word has no location leg, so the
// terrain click's `BindLocation` binds nothing and the mode simply stays up.
let Some((spell_id, commit)) = ladder.ground.pending_for(TargetingWants::Location) else {
return;
};
let Some(point) = occlusion.point else {
@@ -213,32 +269,130 @@ pub(crate) fn commit_ground_cast_on_click(
"ui_action: ground cast {spell_id} committed at wow ({:.2}, {:.2}, {:.2})",
dest[0], dest[1], dest[2]
);
// Same block, two opcodes — `SendCast 0x6e54f0`'s one discriminator survives the cursor
// (decision 0914): a thrown grenade commits as `CMSG_USE_ITEM` with the DEST block.
let _ = net.0.send(match commit {
super::cast_send::CastCommit::Spell => ClientCommand::CastSpellAtDest { spell_id, dest },
super::cast_send::CastCommit::Item {
bag_index,
slot,
spell_index,
..
} => ClientCommand::UseItem {
bag_index,
slot,
spell_index,
target: benilla_protocol::messages::UseItemTarget::Dest(dest),
},
});
let now = Instant::now();
if commit.is_item() {
pending.arm_item(spell_id, now);
} else {
pending.arm(spell_id, now);
// The shared commit tail — same block, two opcodes (`SendCast 0x6e54f0`'s one discriminator
// survives the cursor, decision 0914: a thrown grenade commits as `CMSG_USE_ITEM` with the
// DEST block), then the pending arm, the GCD, and the word cleared.
ladder.commit_targeted(spell_id, commit, super::cast_send::TargetedBind::Dest(dest));
}
/// `0x495d60`'s equipped-item gate — the whole local validity law for an item target, run at
/// BIND time (the click), not at hover time. For each of the spell's three effects that is
/// `ENCHANT_ITEM` (53) / `ENCHANT_ITEM_TEMPORARY` (54) the reference tests, in this order:
///
/// - `EquippedItemSubClassMask [+0xec] != 0` ⇒ the item's class must equal `EquippedItemClass
/// [+0xe8]` **and** `(1 << subclass)` must be in the mask (`495e10`–`495e28`);
/// - `EquippedItemInventoryTypeMask [+0xf0] != 0` ⇒ `(1 << InventoryType)` must be in it
/// (`495e4d`–`495e70`).
///
/// Either miss lands on `0x496068`: `0x6e1a00(spell, 0x0a)` — the client's own **"Invalid
/// target"** red line, no packet, and the targeting word is *not* cleared (the ref returns before
/// `BindTarget`), so the cursor stays up for another try. That last detail is the one that makes
/// this feel right: a mis-click on the wrong weapon doesn't eat your poison press.
///
/// `Some(reason)` = refuse with that code; `None` = bind. A spell with no enchant effect (the
/// bare-`Targets 0x10` rows that are Disenchant and kin) has no leg to fail — the reference walks
/// its loop and falls straight through to `496056: call 0x6e5b40`, and so do we.
///
/// One narrowing, measured rather than assumed: the reference tests **all three** effect slots,
/// [`benilla_formats::SpellDisplay`] carries only slot 0, and across the whole 363-row item-target
/// family not one row hides its enchant effect in slot 1 or 2 — pinned by the formats-side family
/// test, which fails if that ever stops being true.
pub(crate) fn item_target_refusal(
def: &benilla_formats::SpellDisplay,
item_class: u32,
item_subclass: u32,
item_inventory_type: u32,
) -> Option<u8> {
let enchants = def.effect_1 == benilla_formats::SPELL_EFFECT_ENCHANT_ITEM
|| def.effect_1 == benilla_formats::SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY;
if !enchants {
return None;
}
if let Some(d) = spells.as_ref().and_then(|s| s.catalog.get(spell_id)) {
cooldowns.start_gcd(spell_id, d, now);
if def.equipped_item_subclass_mask != 0 {
let class_ok = i64::from(item_class) == i64::from(def.equipped_item_class);
let sub_ok =
item_subclass < 32 && def.equipped_item_subclass_mask & (1u32 << item_subclass) != 0;
if !(class_ok && sub_ok) {
return Some(super::cast_target::ERR_INVALID_TARGET);
}
}
if def.equipped_item_inventory_type_mask != 0
&& !(item_inventory_type < 32
&& def.equipped_item_inventory_type_mask & (1u32 << item_inventory_type) != 0)
{
return Some(super::cast_target::ERR_INVALID_TARGET);
}
None
}
/// The item half's commit — the bag and paper-doll click seams (`PickupContainerItem 0x4f9b30`
/// @ `4f9c54`–`4f9c6d` and its byte-identical doll twin `0x4c7300` @ `4c76df`–`4c76fb`: *if
/// IsTargeting and TargetingWantsItem, then `0x495d60(itemGuidLo, itemGuidHi)` and return —
/// nothing is picked up*). The VM half of that reroute lives in `benilla_ui`'s cursor seam; this
/// drain is `0x495d60` itself: resolve the clicked slot's live item, run [`item_target_refusal`],
/// and on a pass do what `496056` does — hand the item to the ONE binder, which fills the word's
/// item bit and lets `SendCast 0x6e54f0` commit. Same block, two opcodes: `CMSG_CAST_SPELL` for
/// an enchant off the Craft window, `CMSG_USE_ITEM` for a poison bottle's own ON_USE.
///
/// The post-send tail is the ground commit's (decision 0792): arm the pending cast + the GCD, and
/// clear the word. A click on an EMPTY slot binds nothing and keeps the mode — the ref's
/// `0x495d60` returns at its own null-item guard (`495da1`).
pub(crate) fn commit_item_cast_on_pick(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
self_q: Query<&crate::net::ObjectStore, With<SelfPlayer>>,
mut ladder: super::CastLadder,
) {
let Some(mut script) = script else {
return;
};
for (bag, slot) in script.take_item_picks() {
let Some((spell_id, commit)) = ladder.ground.pending_for(TargetingWants::Item) else {
continue; // a click raced a cancel — the word is gone, so there is nothing to bind
};
let slot0 = u8::try_from(slot.saturating_sub(1)).unwrap_or(0);
let Some(item_guid) = self_q
.iter()
.next()
.and_then(|store| crate::ui_items::slot_guid(&store.0, bag, slot0, &ladder.items))
else {
debug!("ui_action: item pick on an empty slot (bag {bag} slot {slot}) — mode kept");
continue;
};
// The gate needs the clicked item's template; an unresolved one (never seen in practice —
// the bag needed it for the icon) binds ungated and lets the server judge, the same
// permissive shape the rest of the click law uses.
let entry = ladder
.items
.object(item_guid)
.and_then(|o| o.object_entry());
let clicked = match entry {
Some(entry) => ladder
.items
.template(entry, item_guid, &ladder.commands)
.map(|t| (t.class, t.subclass, t.inventory_type)),
None => None,
};
let def = ladder
.spells
.as_deref()
.and_then(|s| s.catalog.get(spell_id));
if let (Some(d), Some((class, subclass, inv))) = (def, clicked) {
if let Some(reason) = item_target_refusal(d, class, subclass, inv) {
debug!(
"ui_action: cast {spell_id} refused at the item bind ({reason:#x}) — \
the cursor stays up"
);
ladder.cast_errors.0.push((spell_id, reason));
continue;
}
}
debug!("ui_action: item pick — cast {spell_id} at item {item_guid:#x}");
ladder.commit_targeted(
spell_id,
commit,
super::cast_send::TargetedBind::Item(item_guid),
);
}
targeting.clear();
}
/// Right-click cancels targeting — on the **DOWN edge**, the reference's WorldFrame
@@ -273,14 +427,17 @@ pub(crate) fn cancel_targeting_on_right_press(
targeting.clear();
}
/// Push the targeting state into the live VM each frame, **before** the input pass runs the ESC
/// chain — what `SpellIsTargeting()` reads and `SpellStopTargeting()` gates on.
/// Push the targeting state into the live VM each frame, **before** the input pass — so a word
/// armed last frame is already standing when this frame's clicks run. Two consumers, one push:
/// `SpellIsTargeting()` / `SpellStopTargeting()`'s ESC chain reads the word itself, and the
/// engine's bag / doll pickup reroute reads the item half (`TargetingWantsItem`'s mirror).
pub(crate) fn feed_targeting_to_vm(
targeting: Res<SpellTargeting>,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
) {
if let Some(mut script) = script {
script.set_spell_targeting(targeting.active());
script.set_item_pick_armed(targeting.wants() == Some(TargetingWants::Item));
}
}
@@ -304,6 +461,133 @@ pub(crate) fn drain_stop_targeting(
mod tests {
use super::*;
/// `0x495d60`'s equipped-item gate, on the shapes the real rows have (the columns themselves
/// are pinned against the shipped `Spell.dbc` in `benilla_formats`). Three legs, in the
/// reference's order, and one refusal code for all of them — `0x0a` "Invalid target", the
/// same red line a wrong unit target draws.
#[test]
fn the_item_bind_gate_mirrors_0x495d60() {
use benilla_formats::SpellDisplay;
// Item classes/subclasses/inventory types, 1.12 values.
const CLASS_WEAPON: u32 = 2;
const CLASS_ARMOR: u32 = 4;
const SUB_DAGGER: u32 = 15;
const SUB_SHIELD: u32 = 6;
const INVTYPE_WRIST: u32 = 9;
const INVTYPE_CHEST: u32 = 5;
const ENCHANT: u32 = benilla_formats::SPELL_EFFECT_ENCHANT_ITEM;
// Enchant Bracer - Minor Health (7418): armor, any subclass, WRIST only.
let bracer = SpellDisplay {
effect_1: ENCHANT,
equipped_item_class: CLASS_ARMOR as i32,
equipped_item_subclass_mask: 0x1f,
equipped_item_inventory_type_mask: 1 << INVTYPE_WRIST,
..Default::default()
};
assert_eq!(
item_target_refusal(&bracer, CLASS_ARMOR, 1, INVTYPE_WRIST),
None,
"a bracer takes the bracer enchant"
);
assert_eq!(
item_target_refusal(&bracer, CLASS_ARMOR, 1, INVTYPE_CHEST),
Some(super::super::cast_target::ERR_INVALID_TARGET),
"the inventory-type leg (495e4d) refuses a chestpiece"
);
assert_eq!(
item_target_refusal(&bracer, CLASS_WEAPON, 1, INVTYPE_WRIST),
Some(super::super::cast_target::ERR_INVALID_TARGET),
"the class leg (495e10) refuses a weapon"
);
// Instant Poison (8679): weapon class, a subclass mask, NO inventory-type requirement.
let poison = SpellDisplay {
effect_1: benilla_formats::SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY,
equipped_item_class: CLASS_WEAPON as i32,
equipped_item_subclass_mask: 0x2a5f3,
equipped_item_inventory_type_mask: 0,
..Default::default()
};
// 8679's real mask carries dagger (15), so a rogue's own weapon passes the subclass leg.
assert_eq!(
item_target_refusal(&poison, CLASS_WEAPON, SUB_DAGGER, 13),
None
);
assert_eq!(
item_target_refusal(&poison, CLASS_WEAPON, 1, 13),
None,
"and two-handed axe (1) is in it too — poisons are broad, the class leg is the fence"
);
assert_eq!(
item_target_refusal(&poison, CLASS_ARMOR, SUB_SHIELD, 14),
Some(super::super::cast_target::ERR_INVALID_TARGET),
"a shield is armor — the class leg alone stops it"
);
// Disenchant (13262): an item-targeted spell with NO enchant effect. The reference walks
// its loop, finds no 53/54 arm, and falls straight through to the bind — anything goes,
// and the server judges.
let disenchant = SpellDisplay {
effect_1: 99,
equipped_item_class: -1,
..Default::default()
};
assert_eq!(
item_target_refusal(&disenchant, CLASS_ARMOR, 1, INVTYPE_CHEST),
None
);
assert_eq!(
item_target_refusal(&disenchant, CLASS_WEAPON, SUB_DAGGER, 13),
None
);
// A subclass past the mask's 32 bits can never be in it — shifted, that would be UB-ish
// nonsense, so the gate refuses instead of wrapping.
assert_eq!(
item_target_refusal(&poison, CLASS_WEAPON, 40, 13),
Some(super::super::cast_target::ERR_INVALID_TARGET)
);
}
/// The two halves of the ONE word never answer each other's click (the reference asks
/// `TargetingWantsLocation`/`TargetingWantsItem` at each seam, decision 0923). Without this,
/// a terrain click while a poison is armed would ship a DEST block for an item spell.
#[test]
fn each_click_seam_only_sees_its_own_half() {
let commit = super::super::cast_send::CastCommit::Spell;
let mut t = SpellTargeting::default();
assert_eq!(
t.pending_for(TargetingWants::Location),
None,
"idle binds nothing"
);
t.enter(2120, commit, TargetingWants::Location);
assert!(t.active());
assert_eq!(
t.pending_for(TargetingWants::Location),
Some((2120, commit))
);
assert_eq!(
t.pending_for(TargetingWants::Item),
None,
"a bag click cannot commit a Blizzard"
);
t.enter(8679, commit, TargetingWants::Item);
assert_eq!(t.pending_for(TargetingWants::Item), Some((8679, commit)));
assert_eq!(
t.pending_for(TargetingWants::Location),
None,
"a terrain click cannot commit a poison"
);
// The spell id is the word's, either half — what the action bar's re-press toggle reads.
assert_eq!(t.spell(), Some(8679));
t.clear();
assert!(!t.active());
}
/// The `0x6e6810` mirror: min²/max² against the squared caster↔point distance — the
/// CURSOR's verdict and nothing else (its one caller binary-wide is the hover classifier;
/// the click never asks). Permissive with no row (Blizzard's row 4 is 0–30 yd; a synthetic
+30 -88
View File
@@ -6,15 +6,16 @@
//! through the tooltip arc's substitution engine.
//!
//! **The item pick** (the one thing the TradeSkill window doesn't have): an enchant is an
//! item-targeted cast (`SPELL_EFFECT_ENCHANT_ITEM`/`_TEMPORARY`). `DoCraft` on one arms
//! [`PendingItemCast`]; the engine's `PickupContainerItem` reroute (byte-VERIFIED — the wow-re
//! `tradeskill` TU-F seam at `0x4f9b30`) turns the next bag CLICK into the target pick, and the
//! drain completes the cast (`CMSG_CAST_SPELL` with `TARGET_FLAG_ITEM` + the item's packed guid —
//! the byte-pinned SPELLCAST item field). A non-item craft (a Runed rod's CREATE_ITEM) casts
//! immediately through the one cast-send path, like a tradeskill recipe. **Named INTERIMs:** no
//! targeting-cursor art while armed; no replace-enchant confirm popup yet (event 0x193 is pure
//! client gating and 5875 has no replace opcode — needs the item's enchantment fields, a later
//! slice); the paper-doll and trade-window targets are out of scope (bags only).
//! item-targeted cast (`SPELL_EFFECT_ENCHANT_ITEM`/`_TEMPORARY`, `Targets = 0x10`). Since
//! decision 0923 this window owns none of that machinery — `DoCraft` goes down the ONE cast
//! ladder like every other caster surface, and the resolver's item arm raises the ONE targeting
//! cursor, which the bag and paper-doll click seams complete ([`crate::ui_action::targeting`]).
//! The private `PendingItemCast` this file used to carry — a second targeting state with its own
//! arm, its own bag-click completion and its own cursor overlay, bypassing every ladder rung
//! including the reagent check an enchant most needs — is gone.
//!
//! **Named INTERIM:** no replace-enchant confirm popup yet (event 0x193 is pure client gating and
//! 5875 has no replace opcode — it needs the item's enchantment fields, a later slice).
use bevy::prelude::*;
@@ -51,27 +52,17 @@ pub(crate) struct CraftOpen {
pub(crate) line: Option<u32>,
}
/// An armed item-targeted craft cast, waiting for the player to right-click the target item in a
/// bag (`0` = none armed). Completed by `ui_items::drain`'s use hook; cleared on completion,
/// window close, or a fresh `DoCraft`.
#[derive(Resource, Default)]
pub(crate) struct PendingItemCast {
pub(crate) spell_id: u32,
}
pub(crate) struct UiCraftPlugin;
impl Plugin for UiCraftPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<CraftOpen>()
.init_resource::<PendingItemCast>()
.add_systems(
Update,
(
feed_craft.in_set(UnitFeed).before(UiInput),
drain_craft.after(UiInput),
),
);
app.init_resource::<CraftOpen>().add_systems(
Update,
(
feed_craft.in_set(UnitFeed).before(UiInput),
drain_craft.after(UiInput),
),
);
}
}
@@ -260,86 +251,37 @@ fn feed_craft(
*last = fresh;
}
/// Drain the Lua intents: `DoCraft` on an item-targeted enchant ARMS the pick ([`PendingItemCast`]
/// — the container drain completes it); on a self-craft (rod making) casts through the one
/// cast-send path immediately. `CloseCraft` closes the window and disarms any pick.
/// Drain the Lua intents: every `DoCraft` goes down the ONE cast ladder, and the resolver decides
/// what happens next — an enchant's `Targets = 0x10` word arms the targeting cursor's item half
/// (decision 0923; the bag / paper-doll click completes it, in `ui_action::targeting`), a rod
/// craft's zero word commits immediately. `CloseCraft` closes the window; a pick armed by it is
/// the one targeting word, cancelled the ordinary ways (ESC, right-click, a new cast).
///
/// Before 0923 this drain owned a private `PendingItemCast` with its own arm, its own bag-click
/// completion and its own cursor overlay — a second targeting machine that skipped every rung the
/// ladder runs (reagents included, which is what an enchant most needs). It is gone: the window
/// is now just another caster surface.
fn drain_craft(
script: Option<NonSendMut<UiScript>>,
mut open: ResMut<CraftOpen>,
mut pending_pick: ResMut<PendingItemCast>,
skill_lines: Option<Res<SkillLines>>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
targeting: cast_target::CastTargeting,
mut ladder: CastLadder,
) {
let Some(mut script) = script else {
return;
};
let _ = skill_lines; // the feed resolves lines; the drain keys only on the spell's effects
let _ = skill_lines; // the feed resolves lines; the drain just casts what it is handed
for spell_id in script.take_craft_dos() {
if open.line.is_none() {
debug!("ui_craft: DoCraft({spell_id}) with no open window — ignored");
continue;
}
let needs_item = ladder
.spells
.as_deref()
.and_then(|s| s.catalog.get(spell_id))
.map(|d| {
matches!(
d.effect_1,
SPELL_EFFECT_ENCHANT_ITEM | SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY
)
});
match needs_item {
Some(true) => {
debug!("ui_craft: DoCraft({spell_id}) — item pick armed (right-click the target)");
pending_pick.spell_id = spell_id;
}
Some(false) => {
debug!("ui_craft: DoCraft({spell_id}) — self craft");
pending_pick.spell_id = 0;
ladder.send(spell_id, &targeting.context(), CastCommit::Spell);
}
None => debug!("ui_craft: DoCraft({spell_id}) — unknown spell, ignored"),
}
debug!("ui_craft: DoCraft({spell_id})");
ladder.send(spell_id, &targeting.context(), CastCommit::Spell);
}
if script.take_craft_close() {
debug!("ui_craft: client-side close (no packet)");
open.line = None;
pending_pick.spell_id = 0;
}
// Complete the armed pick: a bag click the engine rerouted (the PickupContainerItem seam)
// names the target item — resolve its guid and send the item-targeted cast. One-shot.
for (bag, slot) in script.take_item_picks() {
let armed = pending_pick.spell_id;
if armed == 0 {
continue; // a stale click raced the disarm — nothing to cast
}
let slot0 = u8::try_from(slot.saturating_sub(1)).unwrap_or(0);
let guid = self_store
.single()
.ok()
.and_then(|store| crate::ui_items::slot_guid(&store.0, bag, slot0, &ladder.items));
match guid {
Some(item_guid) => {
debug!("ui_craft: enchant pick — cast {armed} at item {item_guid:#x}");
let _ = ladder
.commands
.0
.send(crate::net::ClientCommand::CastSpellItem {
spell_id: armed,
item_guid,
});
}
None => debug!("ui_craft: enchant pick on an empty slot — kept armed"),
}
if guid.is_some() {
pending_pick.spell_id = 0;
}
}
// Mirror the armed state into the engine each frame (cheap; the engine reroutes bag clicks
// only while set).
script.set_item_pick_armed(pending_pick.spell_id != 0);
}
+23
View File
@@ -138,6 +138,8 @@ pub(super) fn drain_container_uses(
mut equip_sound: MessageWriter<crate::sound::AutoEquipSound>,
mut item_text: ResMut<crate::ui_item_text::ItemTextOpen>,
targeting: crate::ui_action::cast_target::CastTargeting,
// The client-side pending ("gray") lock — the right-click-open arm arms it (decision 0916).
mut pending_items: ResMut<PendingItemOps>,
mut ladder: crate::ui_action::CastLadder,
) {
let Some(mut script) = script else {
@@ -365,6 +367,27 @@ pub(super) fn drain_container_uses(
"ui_items: open item {:#x} (lua bag {bag} → wire {bag_index}/{wire_slot})",
c.guid
);
// **The gray lock, armed before the send** — the reference's emitter `0x5edc80` calls
// the lock setter `0x4953e0` at `0x5edcd9` and only then ships `CMSG_OPEN_ITEM`
// (wow-re `inventory-change-failure-display.md` §8, decision 0916). So a clam,
// lockbox or loot bag greys the instant you right-click it and stays grey until the
// server answers — the loot landing (a resolving field update) or a refusal
// (`EQUIP_ERR_ITEM_LOCKED` on a still-locked junkbox), both of which
// `PendingItemOps` already clears on.
//
// Deliberately NOT armed on the gift-unwrap arm above, which sends the same opcode:
// its emitter `0x5edd60` contains no setter call. That asymmetry is the reference's,
// verified, and copying it is the point.
let (guid, count) = self_q
.iter()
.next()
.map(|store| slot_guid_count(Some(store), bag, slot, &ladder.items))
.unwrap_or((0, 0));
pending_items.add([(bag, slot, guid, count)]);
script.fire_event(
"ITEM_LOCK_CHANGED",
vec![ScriptValue::Int(bag), ScriptValue::Int(i64::from(slot))],
);
let _ = ladder.commands.0.send(ClientCommand::OpenItem {
bag_index,
slot: wire_slot,
@@ -152,6 +152,15 @@ mod tests {
assert_eq!(equip_error_key(59), "ERR_CANT_BE_DISENCHANTED");
}
/// Reason 16 keeps the GENERIC key in this table — the substitution is the drain's fork, not
/// a second table entry, because choosing it needs the named bag (`feed::bag_family_name`).
/// Both 15 and 16 map here; only 16 can be overridden, and only when its bag resolves.
#[test]
fn the_wrong_bag_reasons_share_the_generic_key() {
assert_eq!(equip_error_key(15), "ERR_WRONG_BAG_TYPE");
assert_eq!(equip_error_key(16), "ERR_WRONG_BAG_TYPE");
}
/// Past the enum the binary clamps rather than falling through: its jump table is bounded
/// `cmp ecx,0x42; ja`, and the default pad is errorId 9 = `ERR_BAG_FULL`. Ours used to print
/// a hex debug line here.
@@ -209,5 +218,18 @@ mod tests {
assert_eq!(g(equip_error_key(51)).unwrap(), "Your bank is full");
// The past-the-enum clamp resolves too — it is a real line, not a placeholder.
assert_eq!(g(equip_error_key(67)).unwrap(), "That bag is full.");
// Reason 16's two outcomes, both on real data: the generic line the table gives, and the
// substituted one the drain swaps in once `feed::bag_family_name` resolves a bag. The
// `%s` fill is a BagFamily name ("Arrows"), pinned in `benilla_formats::itembagfamily`.
assert_eq!(
g(equip_error_key(16)).unwrap(),
"That item doesn't go in that container."
);
assert_eq!(
g("ERR_WRONG_BAG_TYPE_SUBCLASS")
.unwrap()
.replace("%s", "Arrows"),
"Only Arrows can be placed in that."
);
}
}
+79 -6
View File
@@ -17,8 +17,8 @@ use crate::pending_item_ops::{LockClearedByFailure, PendingItemOps};
use super::equip_error::equip_error_key;
use super::{
find_equip_slot, has_key, item_link, keyring_size, slot_guid_count, EquipErrors, BAGS,
BAG_SLOT_FIRST, BANK_BAGS, BANK_BAG_ID_FIRST, BANK_CONTAINER, BANK_SLOTS, KEYRING_CONTAINER,
KEYRING_SLOTS, PACK_SLOTS,
BAG_SLOT_FIRST, BANK_BAGS, BANK_BAG_ID_FIRST, BANK_BAG_SLOT_FIRST, BANK_CONTAINER, BANK_SLOTS,
KEYRING_CONTAINER, KEYRING_SLOTS, PACK_SLOTS,
};
/// The feed's memory of what it last pushed, for per-bag change events. No cooldown-churn gate:
@@ -515,6 +515,51 @@ fn resolve_slot(
})
}
/// Reason 16's `%s`: the destination bag's **`BagFamily` name** — "Arrows", "Soul Shards",
/// "Herbs" — for *"Only %s can be placed in that."* `None` = no bag to name, so the caller keeps
/// the generic `ERR_WRONG_BAG_TYPE` line.
///
/// `bag_slot` is the wire's ABSOLUTE player slot (see
/// `benilla_protocol::messages::items::read_inventory_change_failure`), and the reference's helper
/// `0x5ede00` bails on exactly two shapes we mirror: `slot == 0xFF` (`INVENTORY_SLOT_BAG_0`, the
/// player's own array — a backpack/equipment refusal names no container), and a slot past the
/// player's slot array. Everything else it indexes straight into that array and resolves as an
/// item.
///
/// Despite the errorId's `_SUBCLASS` name this reads the bag's `BagFamily`, **not** its
/// ItemSubClass — which is what makes a quiver say "Only Arrows can be placed in that." rather
/// than naming the quiver's own type (`benilla_formats::itembagfamily`, and the DBC read there).
///
/// The bank-bag leg (63..=68) is ours by symmetry rather than byte-pinned: the reference bounds
/// this on `[player+0x1d38]`, whose value wow-re did not resolve, so whether a bank bag reaches
/// the substitution or falls to the generic line is unverified. Both outcomes are ordinary
/// sentences; resolving it is the strictly more useful one, and it is flagged here rather than
/// silently assumed.
fn bag_family_name(
player: Option<&ObjectStore>,
bag_slot: u8,
items: &mut Items,
families: Option<&benilla_formats::ItemBagFamilyCatalog>,
commands: &NetCommands,
) -> Option<String> {
let store = player?;
let families = families?;
let guid = match bag_slot {
// The equipped bag slots.
s if (BAG_SLOT_FIRST..BAG_SLOT_FIRST + BAGS).contains(&s) => store.0.player_inv_slot(s),
// The purchasable bank bag slots.
s if (BANK_BAG_SLOT_FIRST..BANK_BAG_SLOT_FIRST + BANK_BAGS).contains(&s) => {
store.0.player_bank_bag_slot(s - BANK_BAG_SLOT_FIRST)
}
// 255 = the player's own array, and anything past the bag slots: no container to name.
_ => None,
}
.filter(|&g| g != 0)?;
let entry = items.object(guid)?.object_entry().filter(|&e| e != 0)?;
let family = items.template(entry, guid, commands)?.bag_family;
families.name(family).map(str::to_string)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn feed_containers(
script: Option<NonSendMut<UiScript>>,
@@ -527,6 +572,8 @@ pub(super) fn feed_containers(
cooldowns: Res<crate::cooldowns::Cooldowns>,
spells: Option<Res<crate::ui_action::Spells>>,
mut equip_errors: ResMut<EquipErrors>,
// Reason 16's `%s` source (decision 0916); absent = every 16 keeps the generic line.
bag_families: Option<Res<crate::ui_items::ItemBagFamilies>>,
mut error_lines: ResMut<crate::ui_items::UiErrorLines>,
mut pending: ResMut<PendingItemOps>,
mut lock_cleared: ResMut<LockClearedByFailure>,
@@ -552,8 +599,29 @@ pub(super) fn feed_containers(
// `ui_action::cast_fail` already runs. No hex debug line on the player's screen: a code we
// failed to map can't reach here (the table is total), and a key we typo'd is caught by
// `equip_error`'s resolution test against the real `GlobalStrings.lua`, not at runtime.
for (reason, level) in equip_errors.0.drain(..) {
let key = equip_error_key(reason);
let player = self_q.iter().next();
for e in equip_errors.0.drain(..) {
// Reason 16's substitution — the ONE reason whose text is chosen by the app rather than
// by the table, because the choice needs the named bag (decision 0916). The reference's
// helper `0x5ede00(player, bagSlot)` resolves the bag and calls
// `DisplayError(ERR_WRONG_BAG_TYPE_SUBCLASS, familyName)` *itself*, returning 1 so the
// caller skips the generic line; a bag that doesn't resolve leaves the generic
// `ERR_WRONG_BAG_TYPE` standing. Same fork, same fallback.
let subclass_fill = (e.reason == 16)
.then(|| {
bag_family_name(
player,
e.bag_slot,
&mut items,
bag_families.as_deref().map(|c| &c.0),
&commands,
)
})
.flatten();
let key = match subclass_fill {
Some(_) => "ERR_WRONG_BAG_TYPE_SUBCLASS",
None => equip_error_key(e.reason),
};
let text = script
.lua()
.globals()
@@ -562,16 +630,21 @@ pub(super) fn feed_containers(
if text.is_empty() {
continue;
}
let text = match level {
// The two argument-taking reasons, each filling its own specifier. Neither code ever
// carries the other's fill, so the order is bookkeeping, not precedence.
let text = match e.required_level {
Some(d) => text.replace("%d", &d.to_string()),
None => text,
};
let text = match subclass_fill {
Some(family) => text.replace("%s", &family),
None => text,
};
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
for line in error_lines.0.drain(..) {
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(line)]);
}
let player = self_q.iter().next();
// The pending-lock resolving clear (decision 0216 §4 / 0218 §3 "the field-update watcher"):
// walk the SAME guids the slot loop below is about to read and release any op whose slots have
+137 -14
View File
@@ -162,10 +162,26 @@ pub(crate) fn wire_pos(bag: i64, slot1: u32) -> Option<(u8, u8)> {
}
}
/// One inventory refusal off the wire — everything the error line's two argument-taking reasons
/// need. Both fills are per-reason and neither is ever set for the other's code, so they ride as
/// plain fields rather than an enum (decision 0916: exactly two of the 67 reasons format an
/// argument, and the reference sources them from different places).
#[derive(Debug, Clone, Copy)]
pub(crate) struct EquipError {
/// The wire `InventoryResult`.
pub reason: u8,
/// Reason 1's `%d` — the packet's own `requiredLevel` field.
pub required_level: Option<u32>,
/// Reason 16's `%s` source — the destination bag's ABSOLUTE player slot (255 = the player's
/// own array, which names no bag). Not read off the wire for the message: it is a *slot*, and
/// the drain resolves it to the bag's `BagFamily` name.
pub bag_slot: u8,
}
/// Inventory refusals (`SMSG_INVENTORY_CHANGE_FAILURE`) queued by the net bridge for the UI error
/// line — `(reason, required_level)`; the equip twin of [`crate::ui_action::CastErrors`].
/// line — the equip twin of [`crate::ui_action::CastErrors`].
#[derive(Resource, Default)]
pub(crate) struct EquipErrors(pub Vec<(u8, Option<u32>)>);
pub(crate) struct EquipErrors(pub Vec<EquipError>);
/// Pre-formatted red error lines from the net drain (`UI_ERROR_MESSAGE` verbatim text — the
/// death durability notice today; anything whose message isn't a code map). Drained beside
@@ -498,6 +514,12 @@ pub(crate) enum ItemUseRoute {
/// Arm #3 (`0x5d8dd2`): a non-zero `StartQuest` offers its quest and **returns**, long before
/// the cast tail at `0x5d9249`. An offer is not a cast.
QuestOffer { npc: u64, quest: u32 },
/// The **toggle-cancel** arm (`0x5d9234`–`0x5d9246`): the item's own ON_USE spell is live on
/// the caster as a cancelable active-icon aura, so the click sends `CMSG_CANCEL_AURA` for it
/// and **returns `false` with no cast** — the item seam's separately-compiled twin of the
/// action button's spell-branch toggle (`0x4e55f0`/`0x4e60c1`, [`crate::ui_action::toggle`]).
/// This is what makes clicking your mount item while mounted **dismount** you.
ToggleCancel(u32),
/// The cast tail (`0x5d9249`–`0x5d9258` → `0x6e5a90` → `TryCast`): run the whole ladder.
Cast(u32),
/// `0x5d8c80` found no ON_USE block, so it returns 0 and the tail hands `TryCast` spell id
@@ -509,7 +531,12 @@ pub(crate) enum ItemUseRoute {
/// [`ItemUseRoute`]'s decision. `guid: None` (the instance never resolved) cannot address a
/// questgiver, so it falls through to the ordinary path — the same fallback the equip fork makes.
pub(crate) fn item_use_route(it: ItemUse) -> ItemUseRoute {
///
/// `aura_cancels` is the toggle predicate applied to the item's ON_USE spell id — the caller's
/// [`crate::ui_action::toggle::active_action_toggle`], passed in so this stays a pure function of
/// the click. The **order is the reference's**: the quest offer forks first (`0x5d8dcc`, well
/// above), then the toggle scan (`0x5d9157`), then the cast tail (`0x5d9249`).
pub(crate) fn item_use_route(it: ItemUse, aura_cancels: impl Fn(u32) -> bool) -> ItemUseRoute {
if let Some(npc) = it.guid.filter(|_| it.start_quest != 0) {
return ItemUseRoute::QuestOffer {
npc,
@@ -517,6 +544,7 @@ pub(crate) fn item_use_route(it: ItemUse) -> ItemUseRoute {
};
}
match it.use_spell {
Some(spell) if aura_cancels(spell) => ItemUseRoute::ToggleCancel(spell),
Some(spell) => ItemUseRoute::Cast(spell),
None => ItemUseRoute::Nothing,
}
@@ -545,13 +573,50 @@ pub(crate) fn item_use_route(it: ItemUse) -> ItemUseRoute {
/// moving item click sent a doomed packet, and the bar's ITEM arm carried a private duplicate of
/// the cooldown rung the bag and doll clicks never got.
///
/// The **toggle-cancel** arm is this seam's other half, and it is why clicking your mount item
/// while mounted dismounts you rather than drawing "You are mounted". `CGItem::Use` carries a
/// separately-compiled twin of the action button's spell-branch toggle, `0x5d9157`–`0x5d9246`,
/// sitting *above* the cast tail:
///
/// ```text
/// 5d915a edi = OnUseSpellId(item) ; 0x5d8c80, the same block scan use_spell reads
/// 5d917c SpellRec[edi] + 0x1d8 (ActiveIconID) != 0 else fall through to the cast
/// 5d9197 for slot in 0..0x30: ; UNIT_FIELD_AURA[48], block +0xa4
/// 5d919a aura[slot] == edi
/// 5d91b7 && AURAFLAGS nibble bit0 set ; block +0x164, 2 slots per byte
/// 5d91c5 -> 5d9234: CancelAura(edi) (0x6e7040 -> CMSG_CANCEL_AURA 0x136), return false
/// ```
///
/// It is the identical predicate to `0x4e55f0` — [`crate::ui_action::toggle::active_action_toggle`]
/// — just reached with the *item's* spell instead of the slot's, so the one predicate serves both
/// (wow-re `shapeshift-plaincast-toggle.md`'s own `0x6e7040` call-site census lists `0x5d9237`
/// under `0x5d8d00` as "action button, container-**item** branch").
///
/// Returns whether anything left for the server — `false` for the reference's silent no-op.
pub(crate) fn send_item_use(
it: ItemUse,
ctx: &crate::ui_action::cast_target::CastContext,
ladder: &mut crate::ui_action::CastLadder,
) -> bool {
match item_use_route(it) {
// The toggle predicate, resolved once against the caster's live aura slots. Both inputs are
// already here: the spell's ActiveIconID column and the caster's descriptor.
let aura_cancels = |spell: u32| {
let Some(d) = ladder.spells.as_ref().and_then(|s| s.catalog.get(spell)) else {
return false;
};
ctx.rel
.self_store
.is_some_and(|store| crate::ui_action::toggle::active_action_toggle(spell, d, store))
};
match item_use_route(it, aura_cancels) {
ItemUseRoute::ToggleCancel(spell) => {
debug!("ui_items: item use {spell} re-pressed — its aura cancels, no cast");
let _ = ladder
.commands
.0
.send(crate::net::ClientCommand::CancelAura { spell_id: spell });
true
}
ItemUseRoute::QuestOffer { npc, quest } => {
let _ = ladder
.commands
@@ -731,6 +796,11 @@ pub(crate) struct ItemSets(pub(crate) benilla_formats::ItemSetCatalog);
#[derive(Resource)]
pub(crate) struct ItemSubClasses(pub(crate) benilla_formats::ItemSubClassCatalog);
/// The ItemBagFamily.dbc catalog — reason 16's `%s`, i.e. what a specialised bag accepts
/// ("Only Arrows can be placed in that."). See [`feed`]'s `bag_family_name`, decision 0916.
#[derive(Resource)]
pub(crate) struct ItemBagFamilies(pub(crate) benilla_formats::ItemBagFamilyCatalog);
/// Startup (after the MPQ chain opens): the item-tooltip DBCs. On failure a resource is simply
/// absent — set items render without their SET block, subclass gates read as absent.
fn load_item_dbcs(mut commands: Commands, world_assets: Option<Res<crate::assets::WorldAssets>>) {
@@ -753,6 +823,16 @@ fn load_item_dbcs(mut commands: Commands, world_assets: Option<Res<crate::assets
}
Err(e) => warn!("ui_items: ItemSubClass.dbc failed to load: {e:#}"),
}
match benilla_formats::load_item_bag_families(&mut chain) {
Ok(cat) => {
info!(
"ui_items: ItemBagFamily.dbc loaded ({} families)",
cat.len()
);
commands.insert_resource(ItemBagFamilies(cat));
}
Err(e) => warn!("ui_items: ItemBagFamily.dbc failed to load: {e:#}"),
}
}
pub(crate) struct UiItemsPlugin;
@@ -843,13 +923,14 @@ mod tests {
assert_eq!(wire_pos(KEYRING_CONTAINER, 0), None);
}
/// The pure fork ([`item_use_route`], decisions 0664/0914), all three arms: a non-zero
/// The pure fork ([`item_use_route`], decisions 0664/0914), all four arms: a non-zero
/// `StartQuest` diverts to `CMSG_QUESTGIVER_QUERY_QUEST` addressed to the ITEM's guid (arm #3,
/// `0x5d8dd2` — it returns before the cast tail); an ON_USE spell runs the ladder; **no** ON_USE
/// block sends nothing at all, because `0x5d8c80` returns 0 and TryCast's null-rec bail
/// (`6e4bac`) refuses spell id 0. That last arm is the one 0914 changed: we used to ship a
/// `CMSG_USE_ITEM` vmangos answers `EQUIP_ERR_ITEM_NOT_FOUND` — a red "Item not found." on
/// every right-click of a plain trade good, which the reference never shows.
/// `0x5d8dd2` — it returns before the cast tail); an ON_USE spell whose aura is live on the
/// caster **cancels** (`0x5d9234`) instead of casting; an ON_USE spell otherwise runs the
/// ladder; **no** ON_USE block sends nothing at all, because `0x5d8c80` returns 0 and TryCast's
/// null-rec bail (`6e4bac`) refuses spell id 0. That last arm is the one 0914 changed: we used
/// to ship a `CMSG_USE_ITEM` vmangos answers `EQUIP_ERR_ITEM_NOT_FOUND` — a red "Item not
/// found." on every right-click of a plain trade good, which the reference never shows.
#[test]
fn the_item_use_fork_routes_quest_offer_cast_and_nothing() {
// "An Unsent Letter" (entry 2874, StartQuest 373 — live `mangos.item_template`): the item
@@ -865,31 +946,73 @@ mod tests {
use_spell,
on_object: None,
};
let never = |_| false;
assert_eq!(
item_use_route(it(Some(letter), 373, None)),
item_use_route(it(Some(letter), 373, None), never),
ItemUseRoute::QuestOffer {
npc: letter,
quest: 373
}
);
assert_eq!(
item_use_route(it(Some(letter), 0, Some(8690))),
item_use_route(it(Some(letter), 0, Some(8690)), never),
ItemUseRoute::Cast(8690),
"a hearthstone takes the cast tail"
);
assert_eq!(
item_use_route(it(Some(letter), 0, None)),
item_use_route(it(Some(letter), 0, None), never),
ItemUseRoute::Nothing,
"no ON_USE block — the ref sends nothing"
);
// No resolved instance (the template is still in flight): a query against guid 0 is
// impossible, so the ordinary path runs — the equip fork's own fallback.
assert_eq!(
item_use_route(it(None, 373, Some(8690))),
item_use_route(it(None, 373, Some(8690)), never),
ItemUseRoute::Cast(8690)
);
}
/// **The mount click, both ways** (the director, 08-03: "clicking the mount while mounted
/// should dismount"). `CGItem::Use`'s toggle scan sits above the cast tail, so an item whose
/// ON_USE spell is already live on the caster cancels its aura and never casts — and the same
/// item clicked while the aura is *not* live takes the ordinary ladder. The predicate itself
/// (ActiveIconID ≠ 0 ∧ a cancelable aura slot) is pinned in `ui_action::toggle`; here it is
/// stubbed, so this pins the FORK and its ORDER.
#[test]
fn a_live_aura_makes_the_item_click_cancel_instead_of_cast() {
const SUMMON_HORSE: u32 = 17462;
let it = |start_quest, use_spell| ItemUse {
guid: Some(0x4000_0000_0000_0BAD),
start_quest,
bag_index: 255,
slot: 23,
spell_index: 0,
use_spell,
on_object: None,
};
let mounted = |spell: u32| spell == SUMMON_HORSE;
assert_eq!(
item_use_route(it(0, Some(SUMMON_HORSE)), mounted),
ItemUseRoute::ToggleCancel(SUMMON_HORSE),
"mounted: the click dismounts — CMSG_CANCEL_AURA, no CMSG_USE_ITEM",
);
assert_eq!(
item_use_route(it(0, Some(SUMMON_HORSE)), |_| false),
ItemUseRoute::Cast(SUMMON_HORSE),
"not mounted: the very same click casts",
);
// Order: the quest offer forks at `0x5d8dcc`, above the toggle scan at `0x5d9157`.
assert_eq!(
item_use_route(it(373, Some(SUMMON_HORSE)), mounted),
ItemUseRoute::QuestOffer {
npc: 0x4000_0000_0000_0BAD,
quest: 373
},
);
// …and an item with no ON_USE block has nothing to cancel, whatever the predicate says.
assert_eq!(item_use_route(it(0, None), |_| true), ItemUseRoute::Nothing);
}
/// A dozen representative `InventoryType`s (decision 0208 phase 1b's own ask), spanning the
/// single-slot rows, the two-slot rows, the weapon rows' MAINHAND/OFFHAND split, and the
/// not-equippable rows — cross-checked against vmangos
@@ -415,11 +415,16 @@ fn drag_drop_onto_another_button_hops_the_displaced_action() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The Count fontstring (decision 0216 §7): shown only for an ITEM-kind action with a positive
/// `GetActionCount`, blank for a SPELL-kind action or a zero count — and it repaints on
/// `ACTIONBAR_SLOT_CHANGED` alongside the icon (the same event the identity resolve fires).
/// The Count fontstring, on the reference's own gate (`ActionButton_UpdateCount`, ref
/// ActionButton.lua:285-292): **`IsConsumableAction`**, never "count > 0". The director's report —
/// a mount on the bar wearing a stack number "1" — is the non-consumable case: a mount holds an
/// on-use spell with zero charges and `InventoryType` 0, so `IsConsumableAction 0x4e5250` answers
/// false and the ref paints nothing at all. It repaints on `ACTIONBAR_SLOT_CHANGED` alongside the
/// icon (the same event the identity resolve fires).
#[test]
fn count_fontstring_shows_only_for_item_kind_with_a_positive_count() {
fn count_fontstring_follows_is_consumable_action_not_the_bag_count() {
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
@@ -440,30 +445,73 @@ fn count_fontstring_shows_only_for_item_kind_with_a_positive_count() {
2,
Some(ActionSlot {
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
kind: 0x80, // ITEM
kind: 0x80, // ITEM — a stack of food
action: 117,
count: 15,
}),
);
s.set_action_state(
2,
Some(ActionState {
consumable: true,
..Default::default()
}),
);
// The report's own shape: an ITEM action the player holds exactly one of, which is NOT
// consumable (a mount). The count is fed all the same; the gate is what must suppress it.
s.set_action(
3,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_Mount_Undeadhorse".into()),
kind: 0x80,
action: 13332,
count: 11,
}),
);
s.set_action_state(
3,
Some(ActionState {
consumable: false,
..Default::default()
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
let has_count_text = |quads: &[benilla_ui::script::ExtractedQuad], text: &str| {
quads
.iter()
.any(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == text))
// Read the fontstrings by name, not by scanning painted text: a single-character count
// ("0") is indistinguishable from a neighbouring button's static HotKey label by content.
let count_of = |s: &UiScript, n: u32| {
s.eval::<String>(&format!(
"return BenillaActionButton{n}Count:GetText() or \"\""
))
.unwrap()
};
assert!(
!has_count_text(&s.extract(), "42"),
assert_eq!(
count_of(&s, 1),
"",
"SPELL kind never shows a count, however GetActionCount answers"
);
assert_eq!(
count_of(&s, 2),
"15",
"a consumable ITEM shows its bag count"
);
assert_eq!(
count_of(&s, 3),
"",
"B201: a NON-consumable ITEM (a mount) shows no stack number, whatever the count says"
);
// …and it really is painted, not just set (the multi-digit value is unambiguous in the quads).
assert!(
has_count_text(&s.extract(), "15"),
"ITEM kind with a positive count shows it"
s.extract()
.iter()
.any(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "15")),
"the consumable's count reaches the screen"
);
// Drop the item stack to 0 (a fresh push at the same action id) and confirm the repaint the
// event drives clears the text — the same ACTIONBAR_SLOT_CHANGED path the icon repaints on.
// Eat the stack down to nothing. A *consumable* keeps its fontstring and reads a literal "0"
// — the ref's `SetText(GetActionCount(...))` is unconditional inside the gate, and 0216 §7's
// `count > 0` blank was ours, not the reference's.
s.set_action(
2,
Some(ActionSlot {
@@ -475,7 +523,11 @@ fn count_fontstring_shows_only_for_item_kind_with_a_positive_count() {
);
s.fire_event("ACTIONBAR_SLOT_CHANGED", vec![ScriptValue::Int(2)]);
s.resolve();
assert!(!has_count_text(&s.extract(), "15"), "count text cleared");
assert_eq!(
count_of(&s, 2),
"0",
"a spent consumable reads 0, it does not go blank"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}