From 906e997aa61bf59d0dc69047d17371510311d34a Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 3 Aug 2026 17:54:27 +0700 Subject: [PATCH] click your mount again to dismount, and the poof puffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/bin/benilla-extract/m2dump.rs | 19 +- crates/benilla-formats/src/itembagfamily.rs | 119 ++++ crates/benilla-formats/src/lib.rs | 2 + crates/benilla-formats/src/spell_visual.rs | 16 +- .../src/spells/catalog_tests.rs | 93 +++ crates/benilla-formats/src/spells/display.rs | 7 + crates/benilla-formats/src/spells/mod.rs | 6 + crates/benilla-protocol/src/events.rs | 3 + crates/benilla-protocol/src/events/decode.rs | 2 + crates/benilla-protocol/src/messages/items.rs | 74 ++- .../benilla-protocol/src/messages/packet.rs | 2 + crates/benilla-protocol/src/messages/parse.rs | 4 +- crates/benilla-protocol/tests/items.rs | 22 + crates/benilla-ui/src/script/container.rs | 35 +- crates/benilla-ui/src/script/craft.rs | 14 - crates/benilla-ui/src/script/cursor.rs | 20 + crates/benilla-ui/src/script/cursor/doll.rs | 51 ++ crates/benilla-ui/src/script/model.rs | 9 +- crates/benilla/assets/ui/ActionBar.xml | 28 +- crates/benilla/src/creature_anim.rs | 17 +- crates/benilla/src/creature_anim/driver.rs | 617 ++++-------------- .../benilla/src/creature_anim/driver/mode.rs | 566 ++++++++++++++++ .../benilla/src/creature_anim/driver/play.rs | 50 +- .../benilla/src/creature_anim/driver/tests.rs | 179 +++++ .../benilla/src/creature_anim/spell_visual.rs | 86 ++- .../src/creature_anim/spell_visual/tests.rs | 76 +++ crates/benilla/src/creature_anim/twist.rs | 47 +- crates/benilla/src/cursor.rs | 16 +- crates/benilla/src/entities.rs | 10 +- crates/benilla/src/entities/attach/mod.rs | 4 + crates/benilla/src/interact.rs | 126 +++- crates/benilla/src/liquid/mod.rs | 12 +- crates/benilla/src/liquid/query.rs | 83 ++- crates/benilla/src/liquid/spatial.rs | 162 +++++ crates/benilla/src/model_render.rs | 304 ++++++--- crates/benilla/src/net/apply.rs | 2 + crates/benilla/src/net/apply/loot.rs | 11 +- crates/benilla/src/net/apply/mail.rs | 17 +- crates/benilla/src/particles/sim.rs | 62 +- crates/benilla/src/player.rs | 10 - crates/benilla/src/player/camera.rs | 128 +--- crates/benilla/src/ui_action/cast_send.rs | 152 ++++- crates/benilla/src/ui_action/cast_target.rs | 65 +- crates/benilla/src/ui_action/mod.rs | 5 + crates/benilla/src/ui_action/state.rs | 57 +- crates/benilla/src/ui_action/targeting.rs | 430 +++++++++--- crates/benilla/src/ui_craft.rs | 118 +--- crates/benilla/src/ui_items/drain.rs | 23 + crates/benilla/src/ui_items/equip_error.rs | 22 + crates/benilla/src/ui_items/feed.rs | 85 ++- crates/benilla/src/ui_items/mod.rs | 151 ++++- .../benilla/src/ui_script/action_bar_tests.rs | 84 ++- 52 files changed, 3275 insertions(+), 1028 deletions(-) create mode 100644 crates/benilla-formats/src/itembagfamily.rs create mode 100644 crates/benilla/src/creature_anim/driver/mode.rs create mode 100644 crates/benilla/src/liquid/spatial.rs diff --git a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs index f13a99aa..74c40e19 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs @@ -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), diff --git a/crates/benilla-formats/src/itembagfamily.rs b/crates/benilla-formats/src/itembagfamily.rs new file mode 100644 index 00000000..ea9d41c6 --- /dev/null +++ b/crates/benilla-formats/src/itembagfamily.rs @@ -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, +} + +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 { + 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"); + } +} diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 420d2899..2268195a 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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; diff --git a/crates/benilla-formats/src/spell_visual.rs b/crates/benilla-formats/src/spell_visual.rs index 92cbd6c2..a7dce6ce 100644 --- a/crates/benilla-formats/src/spell_visual.rs +++ b/crates/benilla-formats/src/spell_visual.rs @@ -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, } @@ -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) diff --git a/crates/benilla-formats/src/spells/catalog_tests.rs b/crates/benilla-formats/src/spells/catalog_tests.rs index 8138c558..9a772887 100644 --- a/crates/benilla-formats/src/spells/catalog_tests.rs +++ b/crates/benilla-formats/src/spells/catalog_tests.rs @@ -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 = (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) + ); + } +} diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index f9ef7a6b..2b8a3d13 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -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, diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index d744dca1..89a9ae01 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -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 { 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. diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index fc3da36b..c06fa520 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -476,6 +476,9 @@ pub enum SessionEvent { reason: u8, required_level: Option, 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`). diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index e0435430..7f0354ea 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -119,11 +119,13 @@ pub fn decode(packet: ServerPacket) -> Vec { reason, required_level, item_guid, + bag_slot, } if reason != 0 => { vec![SessionEvent::InventoryFailure { reason, required_level, item_guid, + bag_slot, }] } ServerPacket::AttackStart { attacker, victim } => { diff --git a/crates/benilla-protocol/src/messages/items.rs b/crates/benilla-protocol/src/messages/items.rs index 3da8d954..16b8bb52 100644 --- a/crates/benilla-protocol/src/messages/items.rs +++ b/crates/benilla-protocol/src/messages/items.rs @@ -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 { /// 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, 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, 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) ); } } diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index b6e513d5..0a7c2ed7 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -357,6 +357,8 @@ pub enum ServerPacket { reason: u8, required_level: Option, 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`]). diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs index 9d98412f..8b51c3fe 100644 --- a/crates/benilla-protocol/src/messages/parse.rs +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -360,11 +360,13 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result { } } 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 => { diff --git a/crates/benilla-protocol/tests/items.rs b/crates/benilla-protocol/tests/items.rs index 89591349..2c22fcb9 100644 --- a/crates/benilla-protocol/tests/items.rs +++ b/crates/benilla-protocol/tests/items.rs @@ -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()), } diff --git a/crates/benilla-ui/src/script/container.rs b/crates/benilla-ui/src/script/container.rs index 3c6c27ce..915d3bee 100644 --- a/crates/benilla-ui/src/script/container.rs +++ b/crates/benilla-ui/src/script/container.rs @@ -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::().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)) diff --git a/crates/benilla-ui/src/script/craft.rs b/crates/benilla-ui/src/script/craft.rs index f4c8f055..64434705 100644 --- a/crates/benilla-ui/src/script/craft.rs +++ b/crates/benilla-ui/src/script/craft.rs @@ -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. diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index c6f509ef..e0efd7e9 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -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`]. // ───────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/benilla-ui/src/script/cursor/doll.rs b/crates/benilla-ui/src/script/cursor/doll.rs index a35112a0..7b35d441 100644 --- a/crates/benilla-ui/src/script/cursor/doll.rs +++ b/crates/benilla-ui/src/script/cursor/doll.rs @@ -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::("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::("return PickupInventoryItem(1)").unwrap()); + assert!(!s + .eval::("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"); + } } diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 3912025b..145c52b8 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -304,10 +304,13 @@ pub(crate) struct Model { pub(crate) container_moves: Vec, /// 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 diff --git a/crates/benilla/assets/ui/ActionBar.xml b/crates/benilla/assets/ui/ActionBar.xml index 5165366b..5acc53a6 100644 --- a/crates/benilla/assets/ui/ActionBar.xml +++ b/crates/benilla/assets/ui/ActionBar.xml @@ -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) diff --git a/crates/benilla/src/creature_anim.rs b/crates/benilla/src/creature_anim.rs index 643e6ec7..aef38b1b 100644 --- a/crates/benilla/src/creature_anim.rs +++ b/crates/benilla/src/creature_anim.rs @@ -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, diff --git a/crates/benilla/src/creature_anim/driver.rs b/crates/benilla/src/creature_anim/driver.rs index 735f1261..514fdb72 100644 --- a/crates/benilla/src/creature_anim/driver.rs +++ b/crates/benilla/src/creature_anim/driver.rs @@ -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 diff --git a/crates/benilla/src/creature_anim/driver/mode.rs b/crates/benilla/src/creature_anim/driver/mode.rs new file mode 100644 index 00000000..65464bd6 --- /dev/null +++ b/crates/benilla/src/creature_anim/driver/mode.rs @@ -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, + 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; + } + } + } + } +} diff --git a/crates/benilla/src/creature_anim/driver/play.rs b/crates/benilla/src/creature_anim/driver/play.rs index a36d6f0c..ba6afaae 100644 --- a/crates/benilla/src/creature_anim/driver/play.rs +++ b/crates/benilla/src/creature_anim/driver/play.rs @@ -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); } diff --git a/crates/benilla/src/creature_anim/driver/tests.rs b/crates/benilla/src/creature_anim/driver/tests.rs index 94a8638e..e3c003a5 100644 --- a/crates/benilla/src/creature_anim/driver/tests.rs +++ b/crates/benilla/src/creature_anim/driver/tests.rs @@ -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::().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::() + .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" + ); +} diff --git a/crates/benilla/src/creature_anim/spell_visual.rs b/crates/benilla/src/creature_anim/spell_visual.rs index 4fb0062d..e638dd97 100644 --- a/crates/benilla/src/creature_anim/spell_visual.rs +++ b/crates/benilla/src/creature_anim/spell_visual.rs @@ -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>, + units: Query<(), With>, + visuals: Option>, + mut displays: Local>, + mut fx: MessageWriter, +) { + 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)); +} diff --git a/crates/benilla/src/creature_anim/spell_visual/tests.rs b/crates/benilla/src/creature_anim/spell_visual/tests.rs index a22af0ea..c2e4663b 100644 --- a/crates/benilla/src/creature_anim/spell_visual/tests.rs +++ b/crates/benilla/src/creature_anim/spell_visual/tests.rs @@ -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::(); + 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::>(); + 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())]); +} diff --git a/crates/benilla/src/creature_anim/twist.rs b/crates/benilla/src/creature_anim/twist.rs index 94438c98..0902c9a4 100644 --- a/crates/benilla/src/creature_anim/twist.rs +++ b/crates/benilla/src/creature_anim/twist.rs @@ -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. diff --git a/crates/benilla/src/cursor.rs b/crates/benilla/src/cursor.rs index b412c5ed..0e6db0fd 100644 --- a/crates/benilla/src/cursor.rs +++ b/crates/benilla/src/cursor.rs @@ -152,7 +152,6 @@ pub(crate) struct DisplayedCursor(pub(crate) crate::target::WorldCursor); fn overlay_ui_cursor( base: Res, script: Option>, - pending_pick: Option>, mut displayed: ResMut, ) { 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 diff --git a/crates/benilla/src/entities.rs b/crates/benilla/src/entities.rs index 277b2e75..5e0bfc15 100644 --- a/crates/benilla/src/entities.rs +++ b/crates/benilla/src/entities.rs @@ -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::() .add_systems( Update, diff --git a/crates/benilla/src/entities/attach/mod.rs b/crates/benilla/src/entities/attach/mod.rs index b6a7ba08..f7d25041 100644 --- a/crates/benilla/src/entities/attach/mod.rs +++ b/crates/benilla/src/entities/attach/mod.rs @@ -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, )); }); } diff --git a/crates/benilla/src/interact.rs b/crates/benilla/src/interact.rs index b9e71f68..7fc29482 100644 --- a/crates/benilla/src/interact.rs +++ b/crates/benilla/src/interact.rs @@ -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); +/// "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, ), >; @@ -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, @@ -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, >, 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>, + parts: PickParts, + ) -> Vec<(Entity, f32)> { + let all: HashSet = 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::() + .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] diff --git a/crates/benilla/src/liquid/mod.rs b/crates/benilla/src/liquid/mod.rs index b266ab28..6ec26c87 100644 --- a/crates/benilla/src/liquid/mod.rs +++ b/crates/benilla/src/liquid/mod.rs @@ -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::::default()) .init_resource::() + .init_resource::() + // 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, diff --git a/crates/benilla/src/liquid/query.rs b/crates/benilla/src/liquid/query.rs index 5ffa1159..72a0cc54 100644 --- a/crates/benilla/src/liquid/query.rs +++ b/crates/benilla/src/liquid/query.rs @@ -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 + '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, - camera: Query<&Transform, With>, + camera: Query<(&Transform, &Projection), With>, water: Query<&WaterChunkInfo>, eye_claim: Res, time: Res