From 143a734b9104d6a4b398e0b55d331cc8a91ffdd3 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 2 Aug 2026 18:00:46 +0700 Subject: [PATCH] slow fall, levitate and water walking work, and Z sheathes your weapon - the granted movement modes are one system now: slow fall slows, levitate hovers, water walking holds the surface, a root pins you even mid-air - casting while moving refuses with the real error, and a jump cast plays its animation in the air - Z cycles melee, ranged, stowed, every landed swing draws the weapon, and Alt-Z hides the UI - login faces where you logged out, and the dev shortcuts moved to Ctrl+Shift off the Windows key Co-Authored-By: Claude Fable 5 --- .../benilla-formats/src/spells/cast_times.rs | 52 ++ .../src/spells/catalog_tests.rs | 32 ++ crates/benilla-formats/src/spells/display.rs | 14 + crates/benilla-formats/src/spells/mod.rs | 32 +- .../src/bin/benilla-world/world.rs | 74 ++- crates/benilla-protocol/src/events.rs | 16 +- crates/benilla-protocol/src/events/decode.rs | 13 +- crates/benilla-protocol/src/lib.rs | 2 +- .../benilla-protocol/src/messages/client.rs | 18 +- crates/benilla-protocol/src/messages/mod.rs | 2 +- .../benilla-protocol/src/messages/movement.rs | 59 ++ .../benilla-protocol/src/messages/opcode.rs | 27 +- .../benilla-protocol/src/messages/packet.rs | 34 +- crates/benilla-protocol/src/messages/parse.rs | 44 +- .../messages/update_object/fields/player.rs | 15 + .../messages/update_object/fields/tests.rs | 11 +- crates/benilla-protocol/src/world/session.rs | 48 +- .../src/world/writer/self_movement.rs | 62 +-- .../benilla-protocol/tests/simple_packets.rs | 91 +-- crates/benilla/assets/shaders/wow_model.wgsl | 17 + crates/benilla/src/creature_anim.rs | 13 +- crates/benilla/src/creature_anim/driver.rs | 154 +++++- .../benilla/src/creature_anim/driver/tests.rs | 519 +++++++++++++++++- crates/benilla/src/creature_anim/select.rs | 58 +- crates/benilla/src/creature_anim/sheath.rs | 68 +++ crates/benilla/src/death.rs | 23 - crates/benilla/src/debug_panel/mod.rs | 121 +++- crates/benilla/src/debug_panel/world.rs | 4 +- crates/benilla/src/doodad_anim/lazy.rs | 437 +++++++++++++++ crates/benilla/src/doodad_anim/mat_anim.rs | 348 ++++++++++++ .../{doodad_anim.rs => doodad_anim/mod.rs} | 446 +++------------ crates/benilla/src/entities.rs | 11 +- crates/benilla/src/entities/attach/mod.rs | 18 +- crates/benilla/src/entities/display.rs | 29 +- .../benilla/src/entities/equipment/spawn.rs | 80 +++ crates/benilla/src/entities/live_display.rs | 124 +++++ crates/benilla/src/entities/wmo_props.rs | 2 - crates/benilla/src/interact.rs | 26 +- crates/benilla/src/mesh_tag.rs | 9 + crates/benilla/src/model_fade.rs | 9 +- crates/benilla/src/net.rs | 50 +- crates/benilla/src/net/apply.rs | 28 +- crates/benilla/src/net/apply/combat.rs | 21 +- crates/benilla/src/net/apply/death.rs | 55 +- crates/benilla/src/net/io.rs | 15 +- crates/benilla/src/net/motion/remote.rs | 5 + crates/benilla/src/perf.rs | 12 +- crates/benilla/src/player.rs | 103 ++-- crates/benilla/src/player/land.rs | 2 +- crates/benilla/src/player/movement_net.rs | 9 + crates/benilla/src/player/mover.rs | 82 ++- crates/benilla/src/player/server_ride.rs | 11 +- crates/benilla/src/player/state.rs | 291 +++++++++- crates/benilla/src/player/swim.rs | 8 +- crates/benilla/src/player/wire_in.rs | 94 ++-- crates/benilla/src/quest_markers/mod.rs | 27 +- crates/benilla/src/rig_palette.rs | 108 ++++ crates/benilla/src/sound/mod.rs | 7 +- .../src/terrain_stream/spawn/assemble.rs | 79 ++- .../benilla/src/terrain_stream/spawn/mod.rs | 15 +- crates/benilla/src/ui_action/cast_send.rs | 23 +- crates/benilla/src/ui_action/cast_target.rs | 6 + crates/benilla/src/ui_action/mod.rs | 20 + crates/benilla/src/ui_action/state.rs | 110 +++- crates/benilla/src/ui_action/usable.rs | 41 +- crates/benilla/src/ui_cast.rs | 4 +- crates/benilla/src/ui_hide.rs | 169 +++--- crates/benilla/src/ui_script/input.rs | 10 +- 68 files changed, 3498 insertions(+), 1069 deletions(-) create mode 100644 crates/benilla/src/doodad_anim/lazy.rs create mode 100644 crates/benilla/src/doodad_anim/mat_anim.rs rename crates/benilla/src/{doodad_anim.rs => doodad_anim/mod.rs} (69%) diff --git a/crates/benilla-formats/src/spells/cast_times.rs b/crates/benilla-formats/src/spells/cast_times.rs index b7fbe306..6cd60946 100644 --- a/crates/benilla-formats/src/spells/cast_times.rs +++ b/crates/benilla-formats/src/spells/cast_times.rs @@ -37,6 +37,19 @@ pub struct SpellCastTime { pub minimum_ms: u32, } +impl SpellCastTime { + /// The level-scaled cast time, ms — `Spell_C::GetCastTime 0x6e3340`'s walk over this row + /// (module docs): `base + perLevel·(casterLevel − spellLevel)`, floored to the row minimum + /// and to zero. `spell_level` is the `SpellRec+0x70` column the client scales against + /// ([`crate::spells::SpellDisplay::spell_level`]). Spellmod op `0xa` (SPELLMOD_CASTING_TIME) + /// is the caller's concern — no downstream consumer models spellmods yet. + pub fn resolved_ms(&self, caster_level: u32, spell_level: u32) -> u32 { + let delta = i64::from(caster_level.saturating_sub(spell_level)); + let scaled = i64::from(self.base_ms) + i64::from(self.per_level_ms) * delta; + scaled.max(i64::from(self.minimum_ms)).max(0) as u32 + } +} + /// `SpellCastTimes.dbc`, by row id ([`crate::spells::SpellDisplay::casting_time_index`]). #[derive(Default)] pub struct SpellCastTimeCatalog { @@ -92,6 +105,45 @@ pub fn load_spell_cast_times(chain: &mut Chain) -> Result mod tests { use super::*; + /// `GetCastTime 0x6e3340`'s scaling walk on the module doc's own probe rows: the instant + /// sentinel stays 0, a flat row ignores level, and the signed per-level row shrinks down to + /// its minimum floor (never below, never negative). + #[test] + fn resolved_ms_scales_and_floors() { + let instant = SpellCastTime { + base_ms: 0, + per_level_ms: 0, + minimum_ms: 0, + }; + assert_eq!(instant.resolved_ms(60, 0), 0); + + let fireball = SpellCastTime { + base_ms: 1500, + per_level_ms: 0, + minimum_ms: 1500, + }; + assert_eq!(fireball.resolved_ms(60, 1), 1500); + + // Row 10's real shape {1000, -100, 500}: at spell level it reads base, then shrinks + // 100 ms/level until the 500 floor catches it. + let scaling = SpellCastTime { + base_ms: 1000, + per_level_ms: -100, + minimum_ms: 500, + }; + assert_eq!(scaling.resolved_ms(10, 10), 1000); + assert_eq!(scaling.resolved_ms(13, 10), 700); + assert_eq!(scaling.resolved_ms(60, 10), 500, "the minimum floor"); + + // A hypothetical floor-less shrink clamps at zero rather than going negative. + let floorless = SpellCastTime { + base_ms: 100, + per_level_ms: -100, + minimum_ms: 0, + }; + assert_eq!(floorless.resolved_ms(60, 1), 0); + } + /// `SpellCastTimes.dbc` on the real data — the module doc's own probe rows. Skips without /// client data. #[test] diff --git a/crates/benilla-formats/src/spells/catalog_tests.rs b/crates/benilla-formats/src/spells/catalog_tests.rs index 2dd69702..8138c558 100644 --- a/crates/benilla-formats/src/spells/catalog_tests.rs +++ b/crates/benilla-formats/src/spells/catalog_tests.rs @@ -504,6 +504,38 @@ fn real_spell_catalog_reads_usable_walk_columns() { let execute = cat.get(5308).unwrap(); assert_eq!((execute.stances, execute.target_aura_state), (0x50000, 2)); assert_eq!(cat.get(6572).unwrap().caster_aura_state, 1, "Revenge"); + + // Leg 5, the combo-point gate (0869): Overpower carries NO aura state — its window rides + // `AttributesEx` b20 (`FINISHING_MOVE_DAMAGE`) exactly like the rogue/druid finishers, which + // is why the aura-state legs alone left it permanently lit. Every rank, the finishers with + // it, and the neighbouring warrior abilities as the control. + for rank in [7384, 7887, 11584, 11585] { + let op = cat.get(rank).unwrap(); + assert!(op.needs_combo_points(), "Overpower {rank}"); + assert_eq!( + (op.caster_aura_state, op.target_aura_state), + (0, 0), + "Overpower {rank} has no aura-state gate — leg 5 is all that holds it" + ); + } + for finisher in [ + 2098, /* Eviscerate */ + 1943, /* Rupture */ + 5171, /* Slice and Dice */ + ] { + assert!( + cat.get(finisher).unwrap().needs_combo_points(), + "{finisher}" + ); + } + for plain in [ + 78, /* Heroic Strike */ + 5308, /* Execute */ + 6572, /* Revenge */ + ] { + assert!(!cat.get(plain).unwrap().needs_combo_points(), "{plain}"); + } + // Auto Shot: bows/guns/crossbows. Slow Fall: one Light Feather. let auto_shot = cat.get(75).unwrap(); assert_eq!( diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index c1d57f14..f9ef7a6b 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -81,6 +81,12 @@ pub struct SpellDisplay { /// `SPELL_INTERRUPT_FLAG_MOVEMENT`) gates the cast bar's local self-cancel /// (`benilla::ui_cast`, where the client-side semantics are documented). pub interrupt_flags: u32, + /// `AuraInterruptFlags` ([`COL_AURA_INTERRUPT_FLAGS`]) — what breaks this spell's applied + /// aura (the food/drink "sit still" bits live here). The cast-initiation moving gate + /// (`0x609de3`, wow-re `moving-cast-gate.md`; decision 0862) reads its `0x18` + /// (MOVING|TURNING) bits as one of the three "would movement matter" arms. `0` for most + /// direct casts. + pub aura_interrupt_flags: u32, /// `ChannelInterruptFlags` ([`COL_CHANNEL_INTERRUPT_FLAGS`]) — what breaks this spell's /// running channel; the bits live in the *aura*-interrupt space (vmangos tests it with /// `AURA_INTERRUPT_MOVING_CANCELS`), not `InterruptFlags`'. `0` for non-channels. @@ -237,6 +243,7 @@ impl Default for SpellDisplay { category: 0, recovery_ms: 0, interrupt_flags: 0, + aura_interrupt_flags: 0, channel_interrupt_flags: 0, category_recovery_ms: 0, start_recovery_category: 0, @@ -444,6 +451,13 @@ impl SpellDisplay { self.attributes & ATTR_COOLDOWN_ON_EVENT != 0 } + /// A **combo-point consumer** (`AttributesEx` bits 20/22, [`ATTR_EX_FINISHING_MOVE`]) — the + /// usable walk's leg 5 greys it while the caster's combo-point byte is 0 (decision 0869). The + /// rogue/druid finishers, and Overpower. + pub fn needs_combo_points(&self) -> bool { + self.attributes_ex & ATTR_EX_FINISHING_MOVE != 0 + } + /// The on-next-swing class (`Attributes & 0x404`, [`ATTR_ON_NEXT_SWING`]) — the spell queues /// on the server's melee slot and fires on the next swing instead of casting. The client's /// one mask for the class: the already-casting exemption (a queued one never blocks another diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index d565e293..d744dca1 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -153,17 +153,19 @@ const COL_CAST_UI: usize = 3; /// [`COL_CATEGORY`] (Feign Death 30000 rec, Charge 15000 catRec, Lay on Hands 3600000 catRec). const COL_RECOVERY_TIME: usize = 19; const COL_CATEGORY_RECOVERY_TIME: usize = 20; -/// `InterruptFlags` (21, `SpellRec+0x54`) / `ChannelInterruptFlags` (23, `SpellRec+0x5c`) — what -/// breaks a cast / a running channel. Pinned empirically 2026-07-17 by the module's established -/// cross-check (every vmangos `spell_template` row at its `MAX(build) ≤ 5875`): column 21 is the -/// **unique** column matching `interruptFlags` on all 6389 nonzero rows, column 23 unique on all -/// 343 nonzero `channelInterruptFlags` rows (`AuraInterruptFlags` = 22, 582/582, stays unread — -/// no consumer yet), and the raw-row arithmetic agrees (offset = column×4, chain-locked between -/// the verified neighbors `+0x50`/col 20 and col 25). Spot checks: Fireball 133 interrupt `0xf`, -/// channel 0; Arcane Missiles 5143 channel `0x7c0c`; First Aid 746 interrupt 0, channel `0x3c0e`. -/// Consumed by the cast bar's local self-cancel (`benilla::ui_cast`), where the bit semantics — -/// the client-side movement gate — are documented. +/// `InterruptFlags` (21, `SpellRec+0x54`) / `AuraInterruptFlags` (22, `SpellRec+0x58`) / +/// `ChannelInterruptFlags` (23, `SpellRec+0x5c`) — what breaks a cast / a live aura / a running +/// channel. Pinned empirically 2026-07-17 by the module's established cross-check (every vmangos +/// `spell_template` row at its `MAX(build) ≤ 5875`): column 21 is the **unique** column matching +/// `interruptFlags` on all 6389 nonzero rows, column 22 unique on all 582 nonzero +/// `auraInterruptFlags` rows, column 23 unique on all 343 nonzero `channelInterruptFlags` rows, +/// and the raw-row arithmetic agrees (offset = column×4, chain-locked between the verified +/// neighbors `+0x50`/col 20 and col 25). Spot checks: Fireball 133 interrupt `0xf`, channel 0; +/// Arcane Missiles 5143 channel `0x7c0c`; First Aid 746 interrupt 0, channel `0x3c0e`. Consumed +/// by the cast bar's local self-cancel (`benilla::ui_cast`) and the cast-initiation moving gate +/// (`benilla::ui_action`, decision 0862), where the bit semantics are documented. const COL_INTERRUPT_FLAGS: usize = 21; +const COL_AURA_INTERRUPT_FLAGS: usize = 22; const COL_CHANNEL_INTERRUPT_FLAGS: usize = 23; /// `powerType` (31) / `manaCost` (32) / `ManaCostPercentage` (156) — the cast-cost triple /// `IsUsableAction`'s not-enough-power verdict reads. Same empirical pin (Taunt/Charge pwr 1 = @@ -416,6 +418,15 @@ pub const ATTR_NOT_IN_COMBAT: u32 = 0x1000_0000; /// `AttributesEx2` bit 19 — the form requirement is waived while unshifted (vmangos /// `SPELL_ATTR_EX2_ALLOW_WHILE_NOT_SHAPESHIFTED`; a form-gate input). const ATTR_EX2_ALLOW_WHILE_NOT_SHAPESHIFTED: u32 = 0x0008_0000; +/// The **combo-point consumers** — `AttributesEx` bits 20 and 22 (vmangos +/// `SPELL_ATTR_EX_FINISHING_MOVE_DAMAGE` / `_DURATION`, both commented "Uses combo points"; the +/// pair `SpellEntry::NeedsComboPoints` tests). The usable walk's leg 5 reads exactly this pair +/// (wow-re §2a: "AttributesEx b20/b22") before consulting the caster's combo-point byte — +/// [`SpellDisplay::needs_combo_points`], decision 0869. In 5875 the set is the six rogue/druid +/// finishers (Eviscerate, Expose Armor, Ferocious Bite, Kidney Shot, Rip, Rupture, Slice and +/// Dice) **plus Overpower**, whose own `AttributesEx` bit 30 vmangos names `COMBO_ON_BLOCK` and +/// annotates, in one word, "Overpower". +const ATTR_EX_FINISHING_MOVE: u32 = 0x0050_0000; /// `SpellEffects` value `47` — `SPELL_EFFECT_TRADE_SKILL`: the usable walk's early-out /// (`0x6e3d99`, `Effect[0]==0x2f` ⇒ usable, skipping every gate). Also the crafting book's open /// key (0437): `Spell_C::TryCast 0x6e4b60` branches on `Effect[0]==0x2f` and opens the window @@ -600,6 +611,7 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result { category: u32_at(r, COL_CATEGORY).unwrap_or(0), recovery_ms: u32_at(r, COL_RECOVERY_TIME).unwrap_or(0), interrupt_flags: u32_at(r, COL_INTERRUPT_FLAGS).unwrap_or(0), + aura_interrupt_flags: u32_at(r, COL_AURA_INTERRUPT_FLAGS).unwrap_or(0), channel_interrupt_flags: u32_at(r, COL_CHANNEL_INTERRUPT_FLAGS).unwrap_or(0), category_recovery_ms: u32_at(r, COL_CATEGORY_RECOVERY_TIME).unwrap_or(0), start_recovery_category: u32_at(r, COL_START_RECOVERY_CATEGORY).unwrap_or(0), diff --git a/crates/benilla-protocol/src/bin/benilla-world/world.rs b/crates/benilla-protocol/src/bin/benilla-world/world.rs index 8adf69b5..8c6ac340 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/world.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/world.rs @@ -1,6 +1,6 @@ //! The shared world state every probe reads: the entity tracker, the merged self descriptor, the -//! item/vendor stores, the opcode tally — plus the session-keeping acks (teleport, force-speed, -//! root, water-walk) that keep *any* session alive regardless of probe mix, and the shared +//! item/vendor stores, the opcode tally — plus the session-keeping acks (teleport, force-speed, the +//! granted movement-mode family) that keep *any* session alive regardless of probe mix, and the shared //! [`DeathArc`] scenario machinery (`--death`/`--spirit` both read it, so neither owns it). use std::collections::{BTreeMap, HashMap}; @@ -8,7 +8,8 @@ use std::time::Instant; use anyhow::Result; use benilla_protocol::{ - Character, EntityKind, ObjectFields, ServerPacket, SessionEvent, SpeedKind, WorldSession, + Character, EntityKind, MoveMode, ObjectFields, ServerPacket, SessionEvent, SpeedKind, + WorldSession, }; /// A Kobold Vermin spawn in Northshire (mangos `creature` guid 79992) — the `--attack` teleport @@ -481,51 +482,38 @@ impl World { } } } - // Root/unroot must be acked (the echoed counter + our current pose) or the change never - // reaches observers, and the app stays gated on its own input while rooted (decision 0308 - // §1/§4). Unconditional session-keeping (like teleport/force-speed): a force-root arriving - // outside the death arc would otherwise go un-acked, and the real client always acks. - SessionEvent::MoveRoot { + // **A granted mover mode** (root / water-walk / feather-fall / hover — decision 0866) + // must be acked with the echoed counter + our current pose, or the server never applies + // the change and observers never see it. Unconditional session-keeping (like + // teleport/force-speed): a mode granted outside the death arc would otherwise go + // un-acked, and the real client always acks. + SessionEvent::MoveMode { guid, counter, - rooted, + mode, + apply, } if *guid == self.self_guid => { - let (pos, orientation) = self.self_pose(); - // A root-APPLY ack must carry MOVEFLAG_ROOT (0x1000) in its - // MovementInfo — the server KICKS one without it (vmangos - // `HandleMoveRootAck:715-723`; live-verified: the flags-0 ack drew - // "movement info does not have rooted movement flag" in Movement.log - // and the root never confirmed, so release sent no unroot). - let flags = if *rooted { 0x1000 } else { 0 }; - session.move_root_ack(*guid, *counter, *rooted, flags, pos, orientation)?; - if *rooted { - if let Some(arc) = &mut self.death_arc { - arc.rooted_seen = true; + let pose = self.self_pose(); + // The ack's MovementInfo must carry the applied mode's own bit: for root the server + // KICKS one without it (vmangos `HandleMoveRootAck:715-723`; live-verified — the + // flags-0 ack drew "movement info does not have rooted movement flag" in + // Movement.log and the root never confirmed, so release sent no unroot), and for the + // other three the word IS the mover's new flags. This probe holds no mover state of + // its own, so the applied bit alone is the honest word. + let flags = if *apply { mode.flag() } else { 0 }; + session.move_mode_ack(*guid, *counter, *mode, *apply, flags, pose)?; + if let Some(arc) = &mut self.death_arc { + match (mode, apply) { + (MoveMode::Root, true) => arc.rooted_seen = true, + (MoveMode::Root, false) => arc.unroot_seen = true, + (MoveMode::WaterWalk, true) => arc.water_walk_seen = true, + _ => {} } - println!("SMSG_FORCE_MOVE_ROOT — acked (rooted at death)"); - } else { - if let Some(arc) = &mut self.death_arc { - arc.unroot_seen = true; - } - println!("SMSG_FORCE_MOVE_UNROOT — acked (released)"); - } - } - // The ghost form's walk-on-water grant/removal — same ack shape as - // MoveRoot, plus the trailing apply flag; both directions ack here. - SessionEvent::WaterWalk { guid, counter, on } if *guid == self.self_guid => { - let (pos, orientation) = self.self_pose(); - // The faithful echo carries MOVEFLAG_WATERWALKING (0x10000000) while - // applied (the toggle-ack path doesn't hard-require it like root). - let flags = if *on { 0x1000_0000 } else { 0 }; - session.water_walk_ack(*guid, *counter, *on, flags, pos, orientation)?; - if *on { - if let Some(arc) = &mut self.death_arc { - arc.water_walk_seen = true; - } - println!("SMSG_MOVE_WATER_WALK — acked (ghost walk-on-water granted)"); - } else { - println!("SMSG_MOVE_LAND_WALK — acked (water-walk removed)"); } + println!( + "mover mode {mode:?} {} — acked", + if *apply { "granted" } else { "revoked" } + ); } SessionEvent::CorpseReclaimDelay { delay_ms } => { if let Some(arc) = &mut self.death_arc { diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index 62f4fd15..81e2c64f 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -826,17 +826,17 @@ pub enum SessionEvent { /// gossip spirit-healer option): fires the CONFIRM_XP_LOSS popup; its Accept sends /// `CMSG_SPIRIT_HEALER_ACTIVATE` back with this guid. SpiritHealerConfirm { npc: u64 }, - /// The server (un)rooted a mover (`SMSG_FORCE_MOVE_ROOT`/`UNROOT` — ours at death/release). - /// Must be acked with the echoed `counter` (+ our current `MovementInfo`) or observers never - /// see the change; the app also gates its own input while rooted. - MoveRoot { + /// **A granted mover mode changed** — root, water-walk, feather-fall or hover (the ack'd + /// movement-mode family, decision 0866). Must be acked with the echoed `counter` (+ our current + /// `MovementInfo`), or the server never applies it and observers never see the change. The app + /// also **applies the mode to its own mover**: each one changes how the mover behaves — see + /// [`crate::messages::MoveMode`] for what each does. + MoveMode { guid: u64, counter: u32, - rooted: bool, + mode: crate::messages::MoveMode, + apply: bool, }, - /// Water-walking granted/removed on a mover (`SMSG_MOVE_WATER_WALK`/`LAND_WALK` — the ghost - /// form's walk-on-water). Ack like [`Self::MoveRoot`], with the trailing apply flag. - WaterWalk { guid: u64, counter: u32, on: bool }, /// Someone invited us to their group (`SMSG_GROUP_INVITE`) — the invite popup. GroupInvite { inviter: String }, /// An invite we sent was declined (`SMSG_GROUP_DECLINE`). diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index 1dfb627d..bd09a7d3 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -369,18 +369,17 @@ pub fn decode(packet: ServerPacket) -> Vec { ServerPacket::SpiritHealerConfirm { npc } => { vec![SessionEvent::SpiritHealerConfirm { npc }] } - ServerPacket::MoveRoot { + ServerPacket::MoveMode { guid, counter, - rooted, - } => vec![SessionEvent::MoveRoot { + mode, + apply, + } => vec![SessionEvent::MoveMode { guid, counter, - rooted, + mode, + apply, }], - ServerPacket::WaterWalk { guid, counter, on } => { - vec![SessionEvent::WaterWalk { guid, counter, on }] - } ServerPacket::GroupInvite { inviter } => vec![SessionEvent::GroupInvite { inviter }], ServerPacket::GroupDecline { name } => vec![SessionEvent::GroupDecline { name }], ServerPacket::GroupUninvited => vec![SessionEvent::GroupUninvited], diff --git a/crates/benilla-protocol/src/lib.rs b/crates/benilla-protocol/src/lib.rs index a2809d6e..3fc5e51c 100644 --- a/crates/benilla-protocol/src/lib.rs +++ b/crates/benilla-protocol/src/lib.rs @@ -16,7 +16,7 @@ pub use auth::AuthReject; pub use events::{decode, CharAction, EntityKind, LoginStage, MoveSpeeds, Poll, SessionEvent}; pub use messages::{ CharCreateReq, CharEnumItem, Character, CreateSpline, ItemInfo, JumpInfo, MonsterMoveFacing, - ObjectFields, ServerPacket, SpeedKind, TransportPose, CHARACTER_FLAG_GHOST, + MoveMode, ObjectFields, ServerPacket, SpeedKind, TransportPose, CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME, }; pub use world::{WardenRequired, WorldReader, WorldSession, WorldWriter, WORLD_PORT}; diff --git a/crates/benilla-protocol/src/messages/client.rs b/crates/benilla-protocol/src/messages/client.rs index c76e3f29..143b0c1c 100644 --- a/crates/benilla-protocol/src/messages/client.rs +++ b/crates/benilla-protocol/src/messages/client.rs @@ -327,12 +327,18 @@ pub fn ping(sequence: u32, last_rtt_ms: u32) -> Vec { body } -/// Body of a movement-flag-change ack (`CMSG_FORCE_MOVE_[UN]ROOT_ACK` / `CMSG_MOVE_WATER_WALK_ACK` -/// — the death arc's root/water-walk family, decision 0308): a **full** `u64` guid, the echoed -/// `u32` movement counter, our current `MovementInfo`, and — for the water-walk ack ONLY — a -/// trailing `u32 apply` (VERIFIED vmangos `Server/Packets/Movement.cpp:38-59`: -/// `MoveFlagChangeAck` reads the apply dword, `MoveRootAck` does not). The counter MUST be the one -/// the server sent — a zero/greater counter trips its cheat log (`HandleMoveRootAck`). +/// Body of a **movement-mode ack** — the whole ack'd family (root / water-walk / feather-fall / +/// hover; decisions 0308, 0866): a **full** `u64` guid, the echoed `u32` movement counter, our +/// current `MovementInfo`, and — for every mode *except root* — a trailing `u32 apply` (VERIFIED +/// vmangos `Server/Packets/Movement.cpp:38-59`: `MoveFlagChangeAck` reads the apply dword, +/// `MoveRootAck` does not; [`MoveMode::ack_carries_apply`](super::MoveMode::ack_carries_apply) is +/// that rule). The counter MUST be the one the server sent — a zero/greater counter trips its cheat +/// log (`HandleMoveRootAck`). +/// +/// **`info.flags` must carry the applied mode bit.** vmangos hard-requires it for root +/// (`HandleMoveRootAck:715` KICKS an apply-ack whose `MovementInfo` lacks `MOVEFLAG_ROOT`) and takes +/// the word as the mover's new flags for the rest — so an ack that drops the bit un-grants the very +/// mode it is accepting. pub fn move_flag_ack(guid: u64, counter: u32, info: &MovementInfo, apply: Option) -> Vec { let mut body = Vec::with_capacity(48); body.extend_from_slice(&guid.to_le_bytes()); diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 4052bc1b..c4d0946a 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -118,7 +118,7 @@ pub use mail::{ mail_mark_as_read, mail_message_type, mail_return_to_sender, mail_take_item, mail_take_money, send_mail, MailAttachment, MailListEntry, }; -pub use movement::{JumpInfo, MovementInfo, SpeedKind, TransportPose}; +pub use movement::{JumpInfo, MoveMode, MovementInfo, SpeedKind, TransportPose}; pub use opcode_names::opcode_name; pub use packet::{CreatureQueryInfo, MonsterMoveFacing, ServerPacket}; pub use parse::parse_server; diff --git a/crates/benilla-protocol/src/messages/movement.rs b/crates/benilla-protocol/src/messages/movement.rs index 547bf003..3afeff5e 100644 --- a/crates/benilla-protocol/src/messages/movement.rs +++ b/crates/benilla-protocol/src/messages/movement.rs @@ -109,6 +109,65 @@ impl SpeedKind { } } +/// **A granted mover *mode*** — one of the four the server hands the controlling client, each a +/// single `MOVEMENTFLAGS` bit that changes how the mover behaves rather than where it is going +/// (decision 0866). Sibling of [`SpeedKind`]: same shape (a mode per opcode pair, an ack per mode), +/// same reason for existing — the server treats the set as one family (`IsFlagAckOpcode`, +/// `MovementChangeType`), so we do too, and a fifth mode is a variant rather than a new lane. +/// +/// The bit values are 1.12's, VERIFIED vmangos `Objects/MovementInfo.h:25-62`. What each one *does* +/// is the client's own business — the server only grants it — and all four are byte-verified in the +/// reference's CMovement setter cluster (`0x7c7280`–`0x7c7370`, wow-re `system/collision`): +/// +/// | mode | bit | reference effect | granted by | +/// |---|---|---|---| +/// | [`Root`](Self::Root) | `0x0000_1000` | translation dies; the fall stops. **Turning stays live** — `StartTurn 0x7c6c50` has no root gate. | root/stun/death | +/// | [`WaterWalk`](Self::WaterWalk) | `0x1000_0000` | the liquid surface becomes walkable ground | `SPELL_AURA_WATER_WALK` (104) | +/// | [`FeatherFall`](Self::FeatherFall) | `0x2000_0000` | terminal fall velocity drops to 7 yd/s from 60.148 (`0x7c5d20`) | `SPELL_AURA_FEATHER_FALL` (105) | +/// | [`Hover`](Self::Hover) | `0x4000_0000` | ground contact rises by 1.0 yd (walk resolver `0x6367b0`) | `SPELL_AURA_HOVER` (106) | +/// +/// **Levitate (1706) grants three of them at once** — feather fall + hover + water walk — which is +/// what makes them one system rather than four coincidences. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MoveMode { + Root, + WaterWalk, + FeatherFall, + Hover, +} + +impl MoveMode { + /// This mode's `MOVEMENTFLAGS` bit. + pub fn flag(self) -> u32 { + match self { + MoveMode::Root => 0x0000_1000, + MoveMode::WaterWalk => 0x1000_0000, + MoveMode::FeatherFall => 0x2000_0000, + MoveMode::Hover => 0x4000_0000, + } + } + + /// The `CMSG_*_ACK` opcode answering this mode's SMSG. Root is the only mode whose two + /// directions ack on *different* opcodes (vmangos routes both to `HandleMoveRootAck`). + pub fn ack_opcode(self, apply: bool) -> u16 { + use super::opcode; + match (self, apply) { + (MoveMode::Root, true) => opcode::CMSG_FORCE_MOVE_ROOT_ACK, + (MoveMode::Root, false) => opcode::CMSG_FORCE_MOVE_UNROOT_ACK, + (MoveMode::WaterWalk, _) => opcode::CMSG_MOVE_WATER_WALK_ACK, + (MoveMode::FeatherFall, _) => opcode::CMSG_MOVE_FEATHER_FALL_ACK, + (MoveMode::Hover, _) => opcode::CMSG_MOVE_HOVER_ACK, + } + } + + /// Whether the ack body carries the trailing `u32 apply` dword. Root's does **not**: it lands on + /// vmangos's `HandleMoveRootAck` (which infers apply from the opcode), while the other three land + /// on `HandleMovementFlagChangeToggleAck`, which reads it (`Server/Packets/Movement.cpp:38-59`). + pub fn ack_carries_apply(self) -> bool { + !matches!(self, MoveMode::Root) + } +} + /// One jump's ballistic launch parameters — the conditional `MovementInfo` tail present iff the /// `MOVEFLAG_JUMPING` (0x2000) flag is set. VERIFIED byte-for-byte against vmangos `MovementInfo::Read` /// (build 1.12.1): wire order is `zspeed, cosAngle, sinAngle, xyspeed` (note cos *before* sin). The diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index a9e54ed5..4b097d48 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -578,12 +578,21 @@ pub const SMSG_CORPSE_RECLAIM_DELAY: u16 = 0x0269; // 617 /// "Your equipped items suffer a 10%% durability loss." error line). pub const SMSG_DURABILITY_DAMAGE_DEATH: u16 = 0x02BD; // 701 -// The ack'd movement-flag family the death arc exercises (root at death / unroot + water-walk at -// release — VERIFIED vmangos `Opcodes_1_12_1.h`: 222-223, 232-235, 720 and -// `MovementPacketSender.cpp:306-397`). The SMSGs to the mover carry `packed guid + u32 counter`; -// the acks echo `full u64 guid + u32 counter + MovementInfo` (+ a trailing `u32 apply` on the -// water-walk ack only — `Server/Packets/Movement.cpp:38-59`). Un-acked changes never reach -// observers, and wrong/zero counters trip the server's cheat log — the counter must be echoed. +// **The ack'd movement-mode family** — four granted mover modes, one wire shape (decision 0866). +// This is not our grouping: the server names the set itself in `IsFlagAckOpcode` +// (`Server/Protocol/Opcodes.h`) and routes all four through one sender +// (`MovementPacketSender::AddMovementFlagChangeToController`, whose `MovementChangeType` is exactly +// `{ROOT, WATER_WALK, SET_HOVER, FEATHER_FALL}`). Opcode numbers VERIFIED vmangos `Opcodes_1_1x.h` +// (unchanged into 1.12.1) and cross-checked against the client's own name table +// ([`super::opcode_names`]). +// +// **The wire shape is uniform.** The SMSG to the mover carries `packed guid + u32 counter`; the ack +// echoes `full u64 guid + u32 counter + MovementInfo`, plus a trailing `u32 apply` for every mode +// **except root** — the root ack lands on vmangos's `HandleMoveRootAck`, the other three on +// `HandleMovementFlagChangeToggleAck`, which reads that extra dword +// (`Server/Packets/Movement.cpp:38-59`). Un-acked changes never reach observers, and wrong/zero +// counters trip the server's cheat log — the counter must be echoed. Modelled as +// [`super::MoveMode`], whose docs carry what each flag does to the mover. pub const SMSG_MOVE_WATER_WALK: u16 = 0x00DE; // 222 pub const SMSG_MOVE_LAND_WALK: u16 = 0x00DF; // 223 pub const CMSG_MOVE_WATER_WALK_ACK: u16 = 0x02D0; // 720 @@ -591,6 +600,12 @@ pub const SMSG_FORCE_MOVE_ROOT: u16 = 0x00E8; // 232 pub const CMSG_FORCE_MOVE_ROOT_ACK: u16 = 0x00E9; // 233 pub const SMSG_FORCE_MOVE_UNROOT: u16 = 0x00EA; // 234 pub const CMSG_FORCE_MOVE_UNROOT_ACK: u16 = 0x00EB; // 235 +pub const SMSG_MOVE_FEATHER_FALL: u16 = 0x00F2; // 242 +pub const SMSG_MOVE_NORMAL_FALL: u16 = 0x00F3; // 243 +pub const SMSG_MOVE_SET_HOVER: u16 = 0x00F4; // 244 +pub const SMSG_MOVE_UNSET_HOVER: u16 = 0x00F5; // 245 +pub const CMSG_MOVE_HOVER_ACK: u16 = 0x00F6; // 246 +pub const CMSG_MOVE_FEATHER_FALL_ACK: u16 = 0x02CF; // 719 // The social family — the friend list, the ignore list, and `/who` (VERIFIED vmangos // `Opcodes_1_12_1.h` + `Server/Packets/Social.{h,cpp}`, `Handlers/MiscHandler.cpp`, diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index 1a184464..6effa028 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -9,7 +9,7 @@ use super::{ CorpseLocation, DamageShield, EnvironmentalDamageLog, ExplorationXp, FriendEntry, FriendStatusUpdate, GameObjectQueryInfo, GossipOption, GroupLootInfo, GroupMemberEntry, InitWorldStates, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed, LootItem, - LootRoll, LootRollWon, LootStartRoll, MailListEntry, Object, PartyMemberStatsInfo, + LootRoll, LootRollWon, LootStartRoll, MailListEntry, MoveMode, Object, PartyMemberStatsInfo, PeriodicAuraLog, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate, ResurrectRequestBody, SpeedKind, SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog, SpellLogMiss, SpellStart, TaxiMask, @@ -736,19 +736,15 @@ pub enum ServerPacket { SpiritHealerConfirm { npc: u64, }, - /// `SMSG_FORCE_MOVE_ROOT` / `SMSG_FORCE_MOVE_UNROOT` — the server (un)roots our mover; must be - /// acked with the echoed `counter` (`CMSG_FORCE_MOVE_[UN]ROOT_ACK`) or observers never see it. - MoveRoot { + /// **A granted mover mode changed** — the ack'd movement-mode family (decision 0866): root, + /// water-walk, feather-fall or hover, granted or revoked on our mover. `apply` is the direction. + /// Must be acked with the echoed `counter` ([`MoveMode::ack_opcode`]) or the server never applies + /// the change and observers never see it. + MoveMode { guid: u64, counter: u32, - rooted: bool, - }, - /// `SMSG_MOVE_WATER_WALK` / `SMSG_MOVE_LAND_WALK` — water-walking granted/removed on our mover - /// (the ghost form's walk-on-water); ack with `CMSG_MOVE_WATER_WALK_ACK` + the echoed counter. - WaterWalk { - guid: u64, - counter: u32, - on: bool, + mode: MoveMode, + apply: bool, }, /// `SMSG_LOGOUT_COMPLETE` — the world session is over; we are back at character select. LogoutComplete, @@ -1141,10 +1137,16 @@ impl ServerPacket { ServerPacket::DurabilityDamageDeath => "SMSG_DURABILITY_DAMAGE_DEATH".into(), ServerPacket::ResurrectRequest(_) => "SMSG_RESURRECT_REQUEST".into(), ServerPacket::SpiritHealerConfirm { .. } => "SMSG_SPIRIT_HEALER_CONFIRM".into(), - ServerPacket::MoveRoot { rooted: true, .. } => "SMSG_FORCE_MOVE_ROOT".into(), - ServerPacket::MoveRoot { rooted: false, .. } => "SMSG_FORCE_MOVE_UNROOT".into(), - ServerPacket::WaterWalk { on: true, .. } => "SMSG_MOVE_WATER_WALK".into(), - ServerPacket::WaterWalk { on: false, .. } => "SMSG_MOVE_LAND_WALK".into(), + ServerPacket::MoveMode { mode, apply, .. } => match (mode, apply) { + (MoveMode::Root, true) => "SMSG_FORCE_MOVE_ROOT".into(), + (MoveMode::Root, false) => "SMSG_FORCE_MOVE_UNROOT".into(), + (MoveMode::WaterWalk, true) => "SMSG_MOVE_WATER_WALK".into(), + (MoveMode::WaterWalk, false) => "SMSG_MOVE_LAND_WALK".into(), + (MoveMode::FeatherFall, true) => "SMSG_MOVE_FEATHER_FALL".into(), + (MoveMode::FeatherFall, false) => "SMSG_MOVE_NORMAL_FALL".into(), + (MoveMode::Hover, true) => "SMSG_MOVE_SET_HOVER".into(), + (MoveMode::Hover, false) => "SMSG_MOVE_UNSET_HOVER".into(), + }, ServerPacket::LogoutComplete => "SMSG_LOGOUT_COMPLETE".into(), ServerPacket::LogoutResponse { .. } => "SMSG_LOGOUT_RESPONSE".into(), ServerPacket::LogoutCancelAck => "SMSG_LOGOUT_CANCEL_ACK".into(), diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs index 218d433c..b3a288cb 100644 --- a/crates/benilla-protocol/src/messages/parse.rs +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -14,7 +14,7 @@ use super::{ action_bar, area_trigger, attack, bank, channel, chat, combat_log, death, duel, gameobject, gossip, group, items, loot, mail, monster_move, movement, opcode, progression, quest, social, spellbook, spells, taxi, trade, trainer, update_object, vendor, world_state, Character, - CreatureQueryInfo, ServerPacket, SpeedKind, + CreatureQueryInfo, MoveMode, ServerPacket, SpeedKind, }; /// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`, @@ -662,26 +662,36 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result { opcode::SMSG_SPIRIT_HEALER_CONFIRM => ServerPacket::SpiritHealerConfirm { npc: death::read_spirit_healer_confirm(&mut r)?, }, - // The ack'd movement-flag family (root at death / water-walk in ghost form): the mover- - // addressed shape is `packed guid + u32 counter` (VERIFIED vmangos - // `MovementPacketSender.cpp:342-366`, the `> CLIENT_BUILD_1_9_4` branch). The app must ack - // with the echoed counter or observers never see the change. - opcode::SMSG_FORCE_MOVE_ROOT | opcode::SMSG_FORCE_MOVE_UNROOT => { + // **The ack'd movement-mode family** (decision 0866) — root, water-walk, feather-fall and + // hover, granted to the controlling client. ONE wire shape for all eight opcodes: + // `packed guid + u32 counter` (VERIFIED vmangos `MovementPacketSender.cpp:342-366`, the + // `> CLIENT_BUILD_1_9_4` branch). The app must ack with the echoed counter, or the server + // never applies the change and observers never see it. + opcode::SMSG_FORCE_MOVE_ROOT + | opcode::SMSG_FORCE_MOVE_UNROOT + | opcode::SMSG_MOVE_WATER_WALK + | opcode::SMSG_MOVE_LAND_WALK + | opcode::SMSG_MOVE_FEATHER_FALL + | opcode::SMSG_MOVE_NORMAL_FALL + | opcode::SMSG_MOVE_SET_HOVER + | opcode::SMSG_MOVE_UNSET_HOVER => { + let (mode, apply) = match opcode { + opcode::SMSG_FORCE_MOVE_ROOT => (MoveMode::Root, true), + opcode::SMSG_FORCE_MOVE_UNROOT => (MoveMode::Root, false), + opcode::SMSG_MOVE_WATER_WALK => (MoveMode::WaterWalk, true), + opcode::SMSG_MOVE_LAND_WALK => (MoveMode::WaterWalk, false), + opcode::SMSG_MOVE_FEATHER_FALL => (MoveMode::FeatherFall, true), + opcode::SMSG_MOVE_NORMAL_FALL => (MoveMode::FeatherFall, false), + opcode::SMSG_MOVE_SET_HOVER => (MoveMode::Hover, true), + _ => (MoveMode::Hover, false), + }; let guid = read_packed_guid(&mut r)?; let counter = read_u32_le(&mut r)?; - ServerPacket::MoveRoot { + ServerPacket::MoveMode { guid, counter, - rooted: opcode == opcode::SMSG_FORCE_MOVE_ROOT, - } - } - opcode::SMSG_MOVE_WATER_WALK | opcode::SMSG_MOVE_LAND_WALK => { - let guid = read_packed_guid(&mut r)?; - let counter = read_u32_le(&mut r)?; - ServerPacket::WaterWalk { - guid, - counter, - on: opcode == opcode::SMSG_MOVE_WATER_WALK, + mode, + apply, } } opcode::SMSG_INITIALIZE_FACTIONS => { diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs index 59409baf..7bb3a37a 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -349,6 +349,21 @@ impl ObjectFields { pub fn player_release_timer_running(&self) -> bool { self.get_u32(FIELD_PLAYER_FIELD_BYTES).unwrap_or(0) & 0x08 != 0 } + /// `PLAYER_FIELD_BYTES` byte 1 — the player's **combo points** (0..5; vmangos + /// `PLAYER_FIELD_BYTES_OFFSET_COMBO_POINTS`, written by `Player::SetComboPoints`). PRIVATE, so + /// it streams only for our own player — the only consumer. The client reads this byte at + /// `[player+0xe68]+0x1029` in the usable walk's leg 5 (the combo-point gate, decision 0869): + /// the same dword whose byte 3 is [`Self::player_honor_rank`]'s byte-verified `+0x102b`, off + /// the player-block base the duel-arbiter comment pins at `[player+0xe68]`. + /// + /// **Not rogue/druid-only.** vmangos awards one here when a *warrior's* victim **dodges** + /// (`Unit.cpp` `PROC_EX_DODGE` → `AddComboPoints(target, 1)` plus a 4 s `REACTIVE_OVERPOWER` + /// timer whose expiry `ClearComboPoints`es it) — the server's chosen marker for "Overpower is + /// up", and the only way that window reaches the client. `None` before the field streams. + pub fn player_combo_points(&self) -> Option { + self.get_u32(FIELD_PLAYER_FIELD_BYTES) + .map(|b| ((b >> 8) & 0xff) as u8) + } /// `PLAYER_FIELD_BYTES` byte 3 — the player's **highest lifetime honor rank** (vmangos /// `PLAYER_FIELD_BYTES_OFFSET_HIGHEST_HONOR_RANK`, written by `HonorMgr`): what the client's /// item-usable gate compares an item's `RequiredHonorRank` against (`0x5ea930` reads the diff --git a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs index ce7ab88f..0a3bd4c2 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs @@ -206,11 +206,18 @@ fn player_ammo_id_reads_the_field() { } #[test] -fn player_honor_rank_reads_byte_3_of_player_field_bytes() { - // flags=0x01 / combo=0x02 / actionBars=0x03 / highestHonorRank=5 packed little-endian. +fn player_field_bytes_splits_into_combo_points_and_honor_rank() { + // flags=0x01 / combo=0x02 / actionBars=0x03 / highestHonorRank=5 packed little-endian: the + // dword's two live readers take byte 1 and byte 3 without bleeding into each other. let f = ObjectFields::from_pairs(&[(1222, 0x05_03_02_01)]); assert_eq!(f.player_honor_rank(), Some(5)); + assert_eq!(f.player_combo_points(), Some(2)); + // A full five points with everything else clear. + let capped = ObjectFields::from_pairs(&[(1222, 0x00_00_05_00)]); + assert_eq!(capped.player_combo_points(), Some(5)); + assert_eq!(capped.player_honor_rank(), Some(0)); assert_eq!(ObjectFields::default().player_honor_rank(), None); + assert_eq!(ObjectFields::default().player_combo_points(), None); } /// The four aura arrays tile exactly, and the block lands on `BASEATTACKTIME` — an index this diff --git a/crates/benilla-protocol/src/world/session.rs b/crates/benilla-protocol/src/world/session.rs index 2361e2c5..d8dcd291 100644 --- a/crates/benilla-protocol/src/world/session.rs +++ b/crates/benilla-protocol/src/world/session.rs @@ -4,7 +4,7 @@ use anyhow::{anyhow, bail, Context, Result}; use benilla_srp::vanilla_header::{HeaderCrypto, ProofSeed}; use benilla_srp::{NormalizedString, SESSION_KEY_LENGTH}; -use crate::messages::{self, opcode, Character, ServerPacket}; +use crate::messages::{self, opcode, Character, MoveMode, ServerPacket}; use super::movement::{client_uptime_ms, movement_info, MOVEMENT_FLAG_FORWARD}; use super::reader::WorldReader; @@ -564,46 +564,24 @@ impl WorldSession { ) } - /// Acknowledge a server root/unroot on our mover (`SMSG_FORCE_MOVE_[UN]ROOT` → the matching - /// `CMSG_FORCE_MOVE_[UN]ROOT_ACK`) — the unsplit twin of [`WorldWriter::move_root_ack`], for the - /// `--death` probe: the server roots us at death and unroots us at release, and observers never - /// see the change until we echo the counter (+ our current pose) back. - pub fn move_root_ack( + /// **Acknowledge a granted mover mode** — root, water-walk, feather-fall or hover (the ack'd + /// family; decision 0866) — the unsplit twin of [`WorldWriter::move_mode_ack`], for the + /// `--death` probe: the server roots us at death, unroots and grants walk-on-water at release, + /// and applies nothing until we echo the counter (+ our current `pose`) back. + pub fn move_mode_ack( &mut self, guid: u64, counter: u32, - rooted: bool, + mode: MoveMode, + apply: bool, flags: u32, - pos: [f32; 3], - orientation: f32, + pose: ([f32; 3], f32), ) -> Result<()> { - let info = movement_info(pos, orientation, flags); + let info = movement_info(pose.0, pose.1, flags); + let trailing = mode.ack_carries_apply().then_some(apply); self.send( - if rooted { - opcode::CMSG_FORCE_MOVE_ROOT_ACK - } else { - opcode::CMSG_FORCE_MOVE_UNROOT_ACK - }, - &messages::move_flag_ack(guid, counter, &info, None), - ) - } - - /// Acknowledge a water-walk grant/removal on our mover (`SMSG_MOVE_WATER_WALK`/`LAND_WALK` → - /// `CMSG_MOVE_WATER_WALK_ACK`) — the unsplit twin of [`WorldWriter::water_walk_ack`], for the - /// `--death` probe: the ghost form grants walk-on-water at release and removes it at revive. - pub fn water_walk_ack( - &mut self, - guid: u64, - counter: u32, - on: bool, - flags: u32, - pos: [f32; 3], - orientation: f32, - ) -> Result<()> { - let info = movement_info(pos, orientation, flags); - self.send( - opcode::CMSG_MOVE_WATER_WALK_ACK, - &messages::move_flag_ack(guid, counter, &info, Some(on)), + mode.ack_opcode(apply), + &messages::move_flag_ack(guid, counter, &info, trailing), ) } diff --git a/crates/benilla-protocol/src/world/writer/self_movement.rs b/crates/benilla-protocol/src/world/writer/self_movement.rs index d7758d71..0d6f9be4 100644 --- a/crates/benilla-protocol/src/world/writer/self_movement.rs +++ b/crates/benilla-protocol/src/world/writer/self_movement.rs @@ -1,6 +1,6 @@ //! Our own mover's `WorldWriter` sends — the self-movement stream (`MSG_MOVE_*`) and the acks the -//! server demands of a player mover (speed change, near-teleport, worldport, root/unroot, -//! water-walk, spline-done). Bodies in [`crate::messages`]'s `movement`/`move_*`/`teleport_ack`/ +//! server demands of a player mover (speed change, near-teleport, worldport, the granted +//! movement-mode family, spline-done). Bodies in [`crate::messages`]'s `movement`/`move_*`/`teleport_ack`/ //! `force_speed_ack` builders; the `MovementInfo` stamping is //! [`crate::world::movement`]'s. Split out of `writer/mod.rs` (decision 0636), the family the //! writer type itself was originally built for. @@ -12,7 +12,7 @@ use anyhow::Result; -use crate::messages::{self, opcode, JumpInfo, TransportPose}; +use crate::messages::{self, opcode, JumpInfo, MoveMode, TransportPose}; use crate::world::movement::{client_uptime_ms, movement_info}; use super::WorldWriter; @@ -118,47 +118,33 @@ impl WorldWriter { ) } - /// Acknowledge a server root/unroot on our mover (`SMSG_FORCE_MOVE_[UN]ROOT` → the matching - /// `CMSG_FORCE_MOVE_[UN]ROOT_ACK`): full guid + the echoed counter + our current - /// `MovementInfo`. Un-acked, the change never reaches observers; a wrong/zero counter trips - /// the server's cheat log (`HandleMoveRootAck`). While rooted, `flags` must not carry moving - /// bits (vmangos `MovementInfo.h`: moving flags with `MOVEFLAG_ROOT` freeze the real client). - pub fn move_root_ack( + /// **Acknowledge a granted mover mode** — root, water-walk, feather-fall or hover (the ack'd + /// family; decision 0866): full guid + the echoed counter + our current `MovementInfo`, plus the + /// trailing `u32 apply` for every mode but root ([`MoveMode::ack_carries_apply`]). Un-acked, the + /// server never applies the change and observers never see it; a wrong/zero counter trips its + /// cheat log (`HandleMoveRootAck`). + /// + /// `flags` is the caller's live wire word and must already carry the applied mode's bit. Two + /// server rules constrain it: an apply-ack without the bit is a KICK for root + /// (`HandleMoveRootAck:715`), and **moving bits must never accompany `MOVEFLAG_ROOT`** — they + /// freeze the real client (vmangos `MovementInfo.h`) and trip `CHEAT_TYPE_ROOT_MOVE`. Turn bits + /// are not moving bits (`MOVEFLAG_MASK_MOVING` excludes them) and are fine alongside root. + /// + /// `pose` is the mover's live `(position, orientation)` — the pair `World::self_pose` returns. + pub fn move_mode_ack( &mut self, guid: u64, counter: u32, - rooted: bool, + mode: MoveMode, + apply: bool, flags: u32, - pos: [f32; 3], - orientation: f32, + pose: ([f32; 3], f32), ) -> Result<()> { - let info = movement_info(pos, orientation, flags); + let info = movement_info(pose.0, pose.1, flags); + let trailing = mode.ack_carries_apply().then_some(apply); self.send( - if rooted { - opcode::CMSG_FORCE_MOVE_ROOT_ACK - } else { - opcode::CMSG_FORCE_MOVE_UNROOT_ACK - }, - &messages::move_flag_ack(guid, counter, &info, None), - ) - } - - /// Acknowledge a water-walk grant/removal on our mover (`SMSG_MOVE_WATER_WALK`/`LAND_WALK` → - /// `CMSG_MOVE_WATER_WALK_ACK`, one ack opcode for both directions): the root ack's shape plus - /// the trailing `u32 apply` the server's `MoveFlagChangeAck` reader expects. - pub fn water_walk_ack( - &mut self, - guid: u64, - counter: u32, - on: bool, - flags: u32, - pos: [f32; 3], - orientation: f32, - ) -> Result<()> { - let info = movement_info(pos, orientation, flags); - self.send( - opcode::CMSG_MOVE_WATER_WALK_ACK, - &messages::move_flag_ack(guid, counter, &info, Some(on)), + mode.ack_opcode(apply), + &messages::move_flag_ack(guid, counter, &info, trailing), ) } } diff --git a/crates/benilla-protocol/tests/simple_packets.rs b/crates/benilla-protocol/tests/simple_packets.rs index 0095a042..dcd2a7ec 100644 --- a/crates/benilla-protocol/tests/simple_packets.rs +++ b/crates/benilla-protocol/tests/simple_packets.rs @@ -7,7 +7,7 @@ mod common; use benilla_protocol::events::{decode, SessionEvent}; use benilla_protocol::messages; -use benilla_protocol::ServerPacket; +use benilla_protocol::{MoveMode, ServerPacket}; use common::hx; #[test] @@ -283,43 +283,66 @@ fn death_arc_family_parses_and_decodes() { other => panic!("spirit healer confirm decode: {other:?}"), } - // SMSG_FORCE_MOVE_ROOT / SMSG_MOVE_WATER_WALK: PACKED guid + u32 counter. Guid 0x2A packs as - // mask 0x01 + one byte. + // **The whole ack'd movement-mode family, all eight opcodes** (decision 0866): one wire shape, + // PACKED guid + u32 counter. Guid 0x2A packs as mask 0x01 + one byte. Every pair is exercised, + // because the family's whole point is that adding a mode must not need a new lane — and because + // the four that were missing (feather-fall, hover) were missing *silently*. let body = hx("012a07000000"); // packed(0x2A) + counter 7 - let pkt = messages::parse_server(opcode::SMSG_FORCE_MOVE_ROOT, &body).unwrap(); - match decode(pkt).as_slice() { - [SessionEvent::MoveRoot { - guid: 0x2A, - counter: 7, - rooted: true, - }] => {} - other => panic!("move root decode: {other:?}"), + for (op, mode, apply) in [ + (opcode::SMSG_FORCE_MOVE_ROOT, MoveMode::Root, true), + (opcode::SMSG_FORCE_MOVE_UNROOT, MoveMode::Root, false), + (opcode::SMSG_MOVE_WATER_WALK, MoveMode::WaterWalk, true), + (opcode::SMSG_MOVE_LAND_WALK, MoveMode::WaterWalk, false), + (opcode::SMSG_MOVE_FEATHER_FALL, MoveMode::FeatherFall, true), + (opcode::SMSG_MOVE_NORMAL_FALL, MoveMode::FeatherFall, false), + (opcode::SMSG_MOVE_SET_HOVER, MoveMode::Hover, true), + (opcode::SMSG_MOVE_UNSET_HOVER, MoveMode::Hover, false), + ] { + let pkt = messages::parse_server(op, &body).unwrap(); + match decode(pkt).as_slice() { + [SessionEvent::MoveMode { + guid: 0x2A, + counter: 7, + mode: got_mode, + apply: got_apply, + }] if *got_mode == mode && *got_apply == apply => {} + other => panic!("move mode decode for opcode {op:#06x}: {other:?}"), + } + } + + // The mode→bit map, VERIFIED vmangos `Objects/MovementInfo.h:25-62`. These are the bits the + // mover reads and the ack echoes, so a transcription slip here is silent everywhere else. + assert_eq!(MoveMode::Root.flag(), 0x0000_1000); + assert_eq!(MoveMode::WaterWalk.flag(), 0x1000_0000); + assert_eq!(MoveMode::FeatherFall.flag(), 0x2000_0000); + assert_eq!(MoveMode::Hover.flag(), 0x4000_0000); + + // Root is the ONLY mode whose ack opcode differs by direction, and the only one whose ack body + // carries no trailing apply dword — vmangos routes it to `HandleMoveRootAck`, the other three to + // `HandleMovementFlagChangeToggleAck` (`Server/Protocol/Opcodes.cpp:314-816`). + assert_eq!( + MoveMode::Root.ack_opcode(true), + opcode::CMSG_FORCE_MOVE_ROOT_ACK + ); + assert_eq!( + MoveMode::Root.ack_opcode(false), + opcode::CMSG_FORCE_MOVE_UNROOT_ACK + ); + assert!(!MoveMode::Root.ack_carries_apply()); + for mode in [MoveMode::WaterWalk, MoveMode::FeatherFall, MoveMode::Hover] { + assert_eq!( + mode.ack_opcode(true), + mode.ack_opcode(false), + "{mode:?}: one ack opcode both ways" + ); + assert!(mode.ack_carries_apply(), "{mode:?}: ack carries apply"); } - let pkt = messages::parse_server(opcode::SMSG_FORCE_MOVE_UNROOT, &body).unwrap(); - assert!(matches!( - decode(pkt).as_slice(), - [SessionEvent::MoveRoot { rooted: false, .. }] - )); - let pkt = messages::parse_server(opcode::SMSG_MOVE_WATER_WALK, &body).unwrap(); - assert!(matches!( - decode(pkt).as_slice(), - [SessionEvent::WaterWalk { - on: true, - counter: 7, - .. - }] - )); - let pkt = messages::parse_server(opcode::SMSG_MOVE_LAND_WALK, &body).unwrap(); - assert!(matches!( - decode(pkt).as_slice(), - [SessionEvent::WaterWalk { on: false, .. }] - )); } -/// The move-flag ack bodies (client side): full guid + echoed counter + MovementInfo, with the -/// trailing `u32 apply` on the water-walk ack ONLY (vmangos `Movement.cpp:38-59` — `MoveRootAck` -/// has no apply dword). The root ack must be exactly 4 bytes shorter than the water-walk ack for -/// the same inputs. +/// The movement-mode ack bodies (client side): full guid + echoed counter + MovementInfo, with the +/// trailing `u32 apply` on every mode EXCEPT root (vmangos `Movement.cpp:38-59` — `MoveRootAck` has +/// no apply dword). The root ack must be exactly 4 bytes shorter than the others for the same +/// inputs. #[test] fn move_flag_ack_bodies_differ_by_the_apply_tail() { use benilla_protocol::messages::{move_flag_ack, MovementInfo}; diff --git a/crates/benilla/assets/shaders/wow_model.wgsl b/crates/benilla/assets/shaders/wow_model.wgsl index fe3edd01..ab272e9c 100644 --- a/crates/benilla/assets/shaders/wow_model.wgsl +++ b/crates/benilla/assets/shaders/wow_model.wgsl @@ -915,6 +915,23 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp if (is_additive) { out_rgb = out_rgb * faded_alpha; } + // MULTIPLY batches (Mod bit 7 / Mod2x bit 8 — the ARMORREFLECT sheen family, 0528): their + // blend equation reads no source alpha, so the instance fade cannot ride the alpha channel — + // the reference genuinely pops these at full strength through every instanceAlpha ramp + // (byte-confirmed for items: wow-re `m2-item-texture-fill.md` — the type-3 stage is never + // bound and draws the flat primary colour, ~white, so Mod2x is a full-strength ×2 layer + // mid-fade). benilla fades them anyway, a deliberate deviation (decision 0865): lerp the + // source colour toward the blend IDENTITY — Mod: white (src·dst = dst); Mod2x: 0.5 grey + // (2·0.5·dst = dst) — by the instance fade `obj_fade` (never the texture alpha). At + // obj_fade 1 this is exact parity with the steady look; at 0 the layer contributes nothing, + // so the sheen rides the appear/despawn/stealth/distance ramps like every other batch + // instead of popping over a body that hasn't faded in. + let is_mod = (u32(m.clutter_fade.z) & 128u) != 0u; + let is_mod2x = (u32(m.clutter_fade.z) & 256u) != 0u; + if (is_mod || is_mod2x) { + let identity = select(vec3(1.0), vec3(0.5), is_mod2x); + out_rgb = mix(identity, out_rgb, obj_fade); + } // GAMMA LANE (0161): raw gamma out — blending (alpha AND additive) happens in gamma like // the reference's byte framebuffer; the frame decodes once in the FFXGlow combine. (The old // `lin` A/B emitted linear for the sRGB encode — subsumed by the lane.) diff --git a/crates/benilla/src/creature_anim.rs b/crates/benilla/src/creature_anim.rs index e2494724..8eb8f9a3 100644 --- a/crates/benilla/src/creature_anim.rs +++ b/crates/benilla/src/creature_anim.rs @@ -46,8 +46,8 @@ use crate::schedule::WorldStage; /// picks, playback-rate math) — kept in its own file as it carries the bulk of the unit-tested selector /// logic, separate from the Bevy driver systems in [`driver`]. mod select; -use select::Mode; pub(crate) use select::{ease_strafe_yaw, move_flags, strafe_body_offset, MovementState}; +use select::{Mode, Special}; /// The display-facing counter-twist (the strafe body pose): the [`BodyTwist`] component + the /// post-animation system that composes the SpineLow/Head counter-rotation onto the frame's pose. @@ -448,7 +448,9 @@ pub(crate) struct SpellGoTargets { /// executes them, so every sheath transition has exactly one author. mod sheath; use sheath::{load_anim_data, SheathSwap}; -pub(crate) use sheath::{AnimData, SheathRequest, SheathSwapMessage, VisualSheath}; +pub(crate) use sheath::{ + toggle_sheath_next, AnimData, SheathRequest, SheathSwapMessage, VisualSheath, +}; /// The `SMSG_EMOTE` → [`EmoteAnim`] consumer (Part 1 of the emote-animation wiring) — kept /// alongside `sheath` as its own small concern. @@ -556,6 +558,12 @@ pub(crate) struct AnimDriver { /// walks a multi-part dance. `None` = the main arm is a one-shot (its budget is a `Count` /// repeat) or a deliberate freeze (ranged Load / Loot). loop_window: Option<(bevy::animation::graph::AnimationNodeIndex, 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`); + /// the *level* must not (mid-air the airborne-freeze issues no plays, so a fast-path park + /// survives to its clip's end). + last_special: Option, } impl AnimDriver { @@ -622,6 +630,7 @@ impl Default for AnimDriver { deferred: None, ranged_held: false, loop_window: None, + last_special: None, } } } diff --git a/crates/benilla/src/creature_anim/driver.rs b/crates/benilla/src/creature_anim/driver.rs index 41d31141..3053e951 100644 --- a/crates/benilla/src/creature_anim/driver.rs +++ b/crates/benilla/src/creature_anim/driver.rs @@ -326,6 +326,14 @@ pub(super) fn drive_animations( if falling && !was_falling { drv.jump_arc = mv.vertical_speed > JUMP_ARC_MIN_UP; } + // The airborne-freeze's exact gate (`0x5fd8e8`, §5-arbitrated — decision 0868): + // keep-current iff `FALLING && (FALLINGFAR || vz ≠ 0)`. The vz clause pins every real + // arc (ballistics make vz ≠ 0 from the first integrated substep); the one uncovered + // case is a fresh walk-off's vz == 0 substep, where the client genuinely may re-pick. + // Gates the gait slot's mid-air keep-current AND the deferred-cache consumer below + // (the `+0xd60` read sits downstream of the freeze: a parked clip never plays mid-air). + let airborne_frozen = + falling && (mv.flags & move_flags::FALLING_FAR != 0 || mv.vertical_speed != 0.0); // The idle re-face turn-shuffle (decision 0123 — the client's facing-delta latch // `0x607ed0`, wow-re `loop-replay-fidget.md` §5b): a stationary creature easing its yaw // toward its target reads as *turning* to the anim layer, so the gait picks @@ -451,6 +459,14 @@ pub(super) fn drive_animations( // under the seat pin, so the equivalent is refusing the draw at the setter — no // commit, no volunteered CMSG (named deviation: same observable, earlier site). if req.state != cur && !(mounted && req.state != 0) { + // The setter's own trace, the twin of the reconcile's below: between them every + // sheath transition names itself and its author, so "why is the weapon out" + // is one `RUST_LOG=benilla=debug` grep rather than a re-derivation of the + // 24-site policy from the symptom. + debug!( + "sheath: unit {entity} {cur} -> {} (request, ceremony {})", + req.state, req.ceremony + ); drv.sheath_cur = Some(req.state); if is_self { let _ = net.0.send(ClientCommand::SetSheathed { @@ -562,8 +578,12 @@ pub(super) fn drive_animations( // swing the Eviscerate spin deferred fires once the spin ends. A frame with fresh // requests consumes it implicitly instead (a normal arm clears the cache; a fast-path // hit re-parks its own request — both what the client's `0x5fe48e`/`0x5fe480` do). + // NEVER mid-air: the read (`0x5fd392`, inside the `0x5fd360` recompute arm) sits + // downstream of the airborne-freeze, so a park made mid-arc waits — and dies at the + // landing play's clear (§5-verified, decision 0868). if requests.is_empty() && drv.deferred.is_some() + && !airborne_frozen && live_oneshot(&drv, &player, &tr, anims, catalog).is_none() { requests.extend(drv.deferred.take()); @@ -616,7 +636,6 @@ pub(super) fn drive_animations( } continue; } - let mut dropped = false; // A prior overlay is superseded by this play — stop its node so the new route owns the // subtree (a fresh masked node re-takes it below, or a full-body route reclaims the base). if let Some(ov) = drv.overlay.take() { @@ -650,28 +669,40 @@ pub(super) fn drive_animations( }); masked_played = true; played_oneshot = Some(id); - } else if special.is_none() { - // Full-body route (standing idle), or the split-boneless masked fallback: the clip - // replaces the base on bone 0. A wanted Special keeps precedence when we *can't* mask - // (no split bone) — that one-shot is dropped, as benilla can't stack it over the base. + } else { + // Full-body route (standing idle / airborne non-combat), or the split-boneless + // masked fallback (the client's −1 key-bone sentinel arms bone 0 too): the clip + // replaces the base on bone 0 — **even over a Special**. The client never drops a + // play: one slot, last-writer-wins (decisions 0083/0087; the wow-re §3 route puts + // a jump-in-place cast/emote on bone 0, replacing the hang — decision 0864 is the + // ref's mid-air cast). Cutting an airborne clip freezes the outgoing node first — + // the pose-snapshot decay of the client's op4 blend, scoped exactly like + // [`leave_special`]'s (decision 0503). + if matches!(special, Some(select::Special::Jump | select::Special::Fall)) { + if let Some(active) = tr + .get_main_animation() + .and_then(|n| player.animation_mut(n)) + { + active.set_speed(0.0); + } + } if let Some((c, repeat)) = picked { play_clip(&mut tr, &mut player, c, repeat, 1.0); } drv.mode = Mode::Swing { id, flags: mv.flags, + under: special, }; drv.gait = None; base_played = true; played_oneshot = Some(id); - } else { - dropped = true; // wanted a mask we can't build while a Special holds } if crate::dbg_trace::enabled() { crate::dbg_trace::line( "fct", &format!( - "anim play unit={entity} id={id} masked={masked_played} base={base_played} dropped={dropped}" + "anim play unit={entity} id={id} masked={masked_played} base={base_played} under={special:?}" ), ); } @@ -697,10 +728,15 @@ pub(super) fn drive_animations( } } - // A Special (jump/pose/fall) claiming the body is a normal PlayAnimation — it clears - // the deferred-combat cache (the client's `0x5fe48e`): a swing parked behind the spin - // dies when a jump cuts the spin, exactly like the ref. - if special.is_some() { + // A Special EDGE is a play (the jump/pose entry, the FALLINGFAR latch's Fall, the land + // pick — each a normal PlayAnimation), and every normal arm clears the deferred-combat + // cache (the client's `0x5fe48e`): a swing parked behind the spin dies when a jump cuts + // the spin. The *level* must not clear (decision 0864 corrects the old per-frame kill): + // mid-air the airborne-freeze issues no plays, so a fast-path park made mid-arc + // survives to its clip's end, exactly like the ref. + let special_edge = special != drv.last_special; + drv.last_special = special; + if special_edge { drv.deferred = None; } // The looping-variation ADVANCE (decision 0516 — wow-re `loop-replay-fidget.md` §7/§7d, @@ -852,19 +888,70 @@ pub(super) fn drive_animations( drv.gait = None; // recompute a fresh gait next frame } } - Mode::Swing { id, flags } => { - if let Some(sp) = special { - // A jump/pose/fall preempts the swing one-shot. - drv.mode = enter_special( - sp, - relaxed, - &mut tr, - &mut player, - anims, - catalog, - &mut rng, - &mut drv.loop_window, - ); + Mode::Swing { id, flags, 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. + 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, + ) + } 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 != flags { // Finished — or a movement-flag change: the client's locomotion re-arm lands // on the change and blindly overwrites bone 0, one-shot or not (the same @@ -897,6 +984,23 @@ pub(super) fn drive_animations( &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. (DEATH is excluded: the dead-override owns that gait, and a + // mid-air revive must re-select, not hold the corpse pose.) + if let Some(c) = drv.gait.and_then(|g| find_resolved(anims, g, catalog)) { + for v in anims.clips.iter().filter(|v| v.anim_id == c.anim_id) { + if let Some(active) = player.animation_mut(v.node) { + active.set_speed(playback_rate(v, mv.speed)); + } + } + } } 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 diff --git a/crates/benilla/src/creature_anim/driver/tests.rs b/crates/benilla/src/creature_anim/driver/tests.rs index dbe92b5a..861e605f 100644 --- a/crates/benilla/src/creature_anim/driver/tests.rs +++ b/crates/benilla/src/creature_anim/driver/tests.rs @@ -64,7 +64,8 @@ fn caster_model() -> ModelAnimations { } /// The real 5875 rows this chain rests on (decode-verified in `anim_data::tests`): -/// ReadySpellDirected carries the force-stow WeaponFlags `&4`, Ready2HL the force-draw `&0x20`. +/// ReadySpellDirected carries the force-stow WeaponFlags `&4`, Ready2HL and Attack1H the +/// force-draw `&0x20`. fn catalog() -> AnimData { AnimData(AnimDataCatalog::from_rows([ ( @@ -74,6 +75,13 @@ fn catalog() -> AnimData { fallback: 0, }, ), + ( + 17, + AnimEntry { + weapon_flags: 0x20, + fallback: 0, + }, + ), ( 28, AnimEntry { @@ -636,7 +644,11 @@ fn same_frame_collision_fast_paths_the_second_combat_clip() { // spin parks (it plays when the swing ends; the ref's swing-first batch shows exactly this). assert_eq!( drv(&app, spin_last).mode, - super::super::select::Mode::Swing { id: 16, flags: 0 }, + super::super::select::Mode::Swing { + id: 16, + flags: 0, + under: None, + }, "the first arrival holds the body" ); assert_eq!(drv(&app, spin_last).deferred, Some(57), "the spin parks"); @@ -645,7 +657,11 @@ fn same_frame_collision_fast_paths_the_second_combat_clip() { // doubled, with the swing parked behind it. The old last-call-wins model ate the spin here. assert_eq!( drv(&app, swing_last).mode, - super::super::select::Mode::Swing { id: 57, flags: 0 }, + super::super::select::Mode::Swing { + id: 57, + flags: 0, + under: None, + }, "the spin holds the body through the later swing" ); assert_eq!(drv(&app, swing_last).deferred, Some(16), "the swing parks"); @@ -691,7 +707,11 @@ fn deferred_combat_clip_plays_once_the_body_frees() { let drv = app.world().entity(unit).get::().unwrap(); assert_eq!( drv.mode, - super::super::select::Mode::Swing { id: 16, flags: 0 }, + super::super::select::Mode::Swing { + id: 16, + flags: 0, + under: None, + }, "the parked swing armed" ); assert_eq!(drv.deferred, None, "the cache is consumed"); @@ -754,7 +774,11 @@ fn a_movement_flag_change_cuts_a_full_body_oneshot_immediately() { app.update(); assert_eq!( mode(&app), - super::super::select::Mode::Swing { id: 16, flags: 0 }, + super::super::select::Mode::Swing { + id: 16, + flags: 0, + under: None, + }, "standing swing holds the base track" ); @@ -1225,3 +1249,488 @@ fn a_looping_arm_advances_through_its_variations_at_window_end() { (saw {seen:?}) — an arm-once-wrap-forever driver never leaves the first" ); } + +/// A jumper's model for the decision 0864 suite: the airborne bracket (JumpStart/Jump hang/ +/// JumpEnd/Fall), the gaits, a spell-kit cast anim (SpellCastOmni 54, with a masked variant), +/// and the combat pair (Special1H 57 / AttackUnarmed 16) for the mid-air fast-path tenant. +fn jumper_model() -> ModelAnimations { + let mut cast = clip(54, 7, false); // SpellCastOmni — the kit release anim + cast.upper_node = Some(AnimationNodeIndex::new(8)); + ModelAnimations { + graph: Handle::default(), + clips: vec![ + clip(0, 1, true), // Stand + clip(5, 2, true), // Run + clip(37, 3, false), // JumpStart + clip(38, 4, true), // Jump hang + clip(39, 5, false), // JumpEnd + clip(40, 6, true), // Fall + cast, + clip(57, 9, false), // Special1H — a spell kit's combat one-shot + clip(16, 10, false), // AttackUnarmed — the bare-hands swing + ], + hand_close: [None, None], + playable_animation_lookup: Vec::new(), + animation_lookup: Vec::new(), + global_bones: Vec::new(), + first_seq: None, + pose: Default::default(), + } +} + +fn jumper(app: &mut App) -> Entity { + app.world_mut() + .spawn(( + jumper_model(), + AnimationPlayer::default(), + AnimationTransitions::new(), + AnimDriver::default(), + MovementState::default(), + )) + .id() +} + +/// The ref's jump-in-place cast (decision 0864 — the report this pins): a cast id is CLASS_A +/// but NOT COMBAT, so the airborne route test doesn't mask it — with no move bits it routes +/// FULL-BODY and REPLACES the jump hang on bone 0 (the client's one-slot last-writer-wins; the +/// old machine dropped it, which is why the cast only showed on *walking* jumps). The clip then +/// rides the airborne-freeze: the continuing Jump level must NOT re-preempt it — the client +/// issues no plays mid-arc, so the clip plays out (and a finished one clamps and holds) until +/// an edge. +#[test] +fn a_jump_in_place_cast_replaces_the_hang_and_survives_the_arc() { + let mut app = app(); + let unit = jumper(&mut app); + app.update(); // settle: Stand + app.world_mut().entity_mut(unit).insert(MovementState { + flags: move_flags::FALLING, + vertical_speed: 7.9, // an upward launch: a jump arc, in place + ..Default::default() + }); + app.update(); + let mode = |app: &App| app.world().entity(unit).get::().unwrap().mode; + assert_eq!( + mode(&app), + super::super::select::Mode::Entering(super::super::select::Special::Jump), + "airborne: the jump bracket enters" + ); + // The instant AoE releases mid-air: the kit anim arrives. + app.world_mut().write_message(EmoteAnim { + entity: unit, + anim_id: 54, + seq: 1, + }); + app.update(); + fn drv(app: &App, unit: Entity) -> &AnimDriver { + app.world().entity(unit).get::().unwrap() + } + assert_eq!( + drv(&app, unit).mode, + super::super::select::Mode::Swing { + id: 54, + flags: move_flags::FALLING, + under: Some(super::super::select::Special::Jump), + }, + "the cast replaces the hang full-body — never dropped, never masked" + ); + assert!( + drv(&app, unit).overlay.is_none(), + "no move bits, non-combat id: not the overlay route" + ); + // The arc continues: the Jump *level* must not preempt the clip back to the bracket. + app.update(); + app.update(); + assert!( + matches!( + drv(&app, unit).mode, + super::super::select::Mode::Swing { id: 54, .. } + ), + "the airborne-freeze holds the one-shot through the arc" + ); +} + +/// Touchdown while a mid-air one-shot holds bone 0: the Special edge (`Some → None`) routes +/// through `leave_special` — the `0x602c60` land dispatcher's pick replaces the clip like any +/// plain play (a stationary landing picks JumpEnd 39). +#[test] +fn landing_mid_cast_plays_the_land_pick() { + let mut app = app(); + let unit = jumper(&mut app); + app.update(); + app.world_mut().entity_mut(unit).insert(MovementState { + flags: move_flags::FALLING, + vertical_speed: 7.9, + ..Default::default() + }); + app.update(); + app.world_mut().write_message(EmoteAnim { + entity: unit, + anim_id: 54, + seq: 1, + }); + app.update(); + app.world_mut() + .entity_mut(unit) + .insert(MovementState::default()); // touchdown, stationary + app.update(); + let mode = app.world().entity(unit).get::().unwrap().mode; + assert_eq!( + mode, + super::super::select::Mode::Land { id: 39, flags: 0 }, + "the land pick cuts the held cast at touchdown" + ); +} + +/// The FALLINGFAR latch mid-one-shot is an edge (`Jump → Fall`): the client plays Fall(40) +/// ONCE, on the substep it latches (`0x61a820` — the 0864-era per-tick re-assert was +/// §5-refuted, decision 0868), replacing the clip. A fresh cast armed AFTER the latch then +/// holds bone 0 like any other airborne one-shot, until the landing pick cuts it. +#[test] +fn a_cast_over_the_fall_loop_holds_until_landing() { + let mut app = app(); + let unit = jumper(&mut app); + app.update(); + app.world_mut().entity_mut(unit).insert(MovementState { + flags: move_flags::FALLING, + vertical_speed: 7.9, + ..Default::default() + }); + app.update(); + app.world_mut().write_message(EmoteAnim { + entity: unit, + anim_id: 54, + seq: 1, + }); + app.update(); // Swing { 54, under: Jump } + app.world_mut().entity_mut(unit).insert(MovementState { + flags: move_flags::FALLING | move_flags::FALLING_FAR, + vertical_speed: -5.0, + ..Default::default() + }); + app.update(); + let mode = |app: &App| app.world().entity(unit).get::().unwrap().mode; + assert_eq!( + mode(&app), + super::super::select::Mode::Looping(super::super::select::Special::Fall), + "the latch's Fall play replaces the held cast" + ); + // A second cast while falling far arms (last-writer-wins) … + app.world_mut().write_message(EmoteAnim { + entity: unit, + anim_id: 54, + seq: 2, + }); + app.update(); + assert!( + matches!( + mode(&app), + super::super::select::Mode::Swing { + id: 54, + under: Some(super::super::select::Special::Fall), + .. + } + ), + "the cast arms over the Fall loop" + ); + // … and HOLDS through the rest of the fall: no per-tick re-assert exists (0868), and the + // Fall level is not an edge. + app.update(); + app.update(); + assert!( + matches!( + mode(&app), + super::super::select::Mode::Swing { + id: 54, + under: Some(super::super::select::Special::Fall), + .. + } + ), + "the cast holds bone 0 through the fall — Fall plays only at its latch edge" + ); + // Touchdown cuts it with the land pick, as every airborne one-shot. + app.world_mut() + .entity_mut(unit) + .insert(MovementState::default()); + app.update(); + assert_eq!( + mode(&app), + super::super::select::Mode::Land { id: 39, flags: 0 }, + "the landing pick replaces the held cast" + ); +} + +/// The walking jump keeps the masked route (the already-working half, now pinned): the +/// takeoff-frozen FORWARD bit routes the cast to the SpineLow overlay — the torso casts, the +/// legs keep the arc, and the base machine is untouched. +#[test] +fn a_moving_jump_cast_masks_onto_the_overlay() { + let mut app = app(); + let unit = jumper(&mut app); + app.update(); + app.world_mut().entity_mut(unit).insert(MovementState { + flags: move_flags::FORWARD | move_flags::FALLING, + vertical_speed: 7.9, + speed: 7.0, + ..Default::default() + }); + app.update(); + app.world_mut().write_message(EmoteAnim { + entity: unit, + anim_id: 54, + seq: 1, + }); + app.update(); + let drv = app.world().entity(unit).get::().unwrap(); + assert!( + drv.overlay.is_some_and(|ov| ov.id == 54), + "frozen-in move bits: the cast masks onto the overlay" + ); + assert_eq!( + drv.mode, + super::super::select::Mode::Entering(super::super::select::Special::Jump), + "the base machine keeps the bracket untouched" + ); +} + +/// The airborne-freeze in the GAIT slot (the step-off arc, decision 0864): live pins — here +/// the stationary cast hold — cannot swap the clip mid-air; the takeoff gait keeps rolling and +/// the pin applies at touchdown. +#[test] +fn the_step_off_arc_freezes_the_gait_against_live_pins() { + let mut app = app(); + let unit = app + .world_mut() + .spawn(( + caster_model(), + AnimationPlayer::default(), + AnimationTransitions::new(), + AnimDriver::default(), + MovementState::default(), + )) + .id(); + app.update(); // settle: Stand + let gait = |app: &App| app.world().entity(unit).get::().unwrap().gait; + assert_eq!(gait(&app), Some(0)); + // Step off a ledge (downward launch: no jump arc, no Special; vz ≠ 0 — the §5-verified + // freeze gate `FALLING && (FALLINGFAR || vz ≠ 0)`, decision 0868) … + app.world_mut().entity_mut(unit).insert(MovementState { + flags: move_flags::FALLING, + vertical_speed: -3.0, + ..Default::default() + }); + // … and the precast lands mid-fall: the stationary pin must NOT re-pick mid-air. + app.world_mut().entity_mut(unit).insert(CastHold { + ranged: false, + anim_id: 51, + spell_id: 20793, + }); + app.update(); + app.update(); + assert_eq!( + gait(&app), + Some(0), + "the selector never re-picks mid-air — the takeoff gait holds" + ); + // Touchdown: the freeze lifts and the pin applies immediately. + app.world_mut() + .entity_mut(unit) + .insert(MovementState::default()); + app.update(); + assert_eq!(gait(&app), Some(51), "the pin lands with the unit"); +} + +/// A mid-air fast-path park survives the arc's LEVEL (decision 0864's edge-clear): the old +/// per-frame kill cleared the deferred cache on every airborne frame; the client clears only +/// at plays (state EDGES) — and the landing's pick, a play, still kills it. +#[test] +fn a_midair_deferred_park_survives_the_level_and_dies_at_the_landing_play() { + let mut app = app(); + let unit = jumper(&mut app); + app.update(); + app.world_mut().entity_mut(unit).insert(MovementState { + flags: move_flags::FALLING, + vertical_speed: 7.9, + ..Default::default() + }); + app.update(); // Entering(Jump) + // A kit combat one-shot replaces the bracket (57 is forced-full-body), then a swing lands + // the same wire batch: combat-over-combat fast-paths — the swing parks. + app.world_mut().write_message(EmoteAnim { + entity: unit, + anim_id: 57, + seq: 1, + }); + app.world_mut().write_message(SwingMessage { + attacker: unit, + victim: None, + hit_info: 0x2, + victim_state: 1, + damage: 21, + seq: 2, + }); + app.update(); + fn drv(app: &App, unit: Entity) -> &AnimDriver { + app.world().entity(unit).get::().unwrap() + } + assert_eq!( + drv(&app, unit).deferred, + Some(16), + "the swing parks behind the kit clip" + ); + // The arc's level must not kill the park (the old per-frame clear did). + app.update(); + app.update(); + assert_eq!( + drv(&app, unit).deferred, + Some(16), + "no play mid-arc — the park survives" + ); + // Touchdown: the land pick is a play — the park dies with it (`0x5fe48e`). + app.world_mut() + .entity_mut(unit) + .insert(MovementState::default()); + app.update(); + assert_eq!( + drv(&app, unit).deferred, + None, + "the landing play clears the cache" + ); +} + +/// The deferred cache's consuming read sits DOWNSTREAM of the airborne-freeze (`0x5fd392` +/// inside the `0x5fd360` recompute arm; §5-verified, decision 0868): a park made mid-arc is +/// never consumed mid-air, even with the body free — it waits, and the landing edge's play +/// clears it. +#[test] +fn a_midair_park_is_not_consumed_before_landing() { + let mut app = app(); + let unit = jumper(&mut app); + app.update(); + app.world_mut().entity_mut(unit).insert(MovementState { + flags: move_flags::FALLING, + vertical_speed: 7.9, + ..Default::default() + }); + app.update(); // Entering(Jump), body otherwise free + app.world_mut() + .entity_mut(unit) + .get_mut::() + .unwrap() + .deferred = Some(16); + app.update(); + app.update(); + fn drv(app: &App, unit: Entity) -> &AnimDriver { + app.world().entity(unit).get::().unwrap() + } + assert_eq!( + drv(&app, unit).deferred, + Some(16), + "the freeze blocks the consuming read — the park waits mid-air" + ); + assert!( + drv(&app, unit).overlay.is_none(), + "the parked swing never played mid-air" + ); + // Touchdown: the landing play clears the cache (`0x5fe48e`) — the park dies unplayed. + app.world_mut() + .entity_mut(unit) + .insert(MovementState::default()); + app.update(); + assert_eq!( + drv(&app, unit).deferred, + None, + "the landing play clears the cache" + ); +} + +/// **The ranged→melee handoff at every landed swing** (`0x625829`, wow-re `sheath-policy.md` §1's +/// `0x6255b0` row) — the director's report: a bow drawn by a shot that never fired, then a melee +/// attack, and the swings keep coming out of the bow. The reconcile provably cannot fix it, and +/// this pins both halves: the CONTROL (a sword swing while ranged-drawn leaves the stance at 2 — +/// the client's melee force is gated `CUR != 2`, `0x5fe0f9`/`0x5fe13b`, so the ranged stance is +/// stable under any number of swings), and the FIX (the packet arm's own snap moves it, and the +/// reconcile then holds it there). +#[test] +fn a_landed_swing_snaps_its_attacker_out_of_the_ranged_stance() { + let mut app = app(); + let model = ModelAnimations { + graph: Handle::default(), + clips: vec![clip(0, 1, true), clip(17, 2, false)], // Stand + Attack1H + hand_close: [None, None], + playable_animation_lookup: Vec::new(), + animation_lookup: Vec::new(), + global_bones: Vec::new(), + first_seq: None, + pose: Default::default(), + }; + // A warrior wearing a 1H sword and a bow: the exact loadout of the report. + let unit = app + .world_mut() + .spawn(( + model, + AnimationPlayer::default(), + AnimationTransitions::new(), + AnimDriver::default(), + crate::net::SelfPlayer, + Wielded { + main: Some((2, 0x7)), // 1H sword -> Attack1H (17) + off: None, + ranged: Some((2, 0x2)), // bow + main_sheath: 3, + off_sheath: 0, + }, + )) + .id(); + let sheath = |app: &App| { + app.world() + .entity(unit) + .get::() + .unwrap() + .sheath_state() + }; + let swing = |app: &mut App| { + app.world_mut().write_message(SwingMessage { + attacker: unit, + victim: None, + hit_info: 0, + victim_state: 1, + damage: 7, + seq: 0, + }); + app.update(); + }; + + // The shot's draw (`SetSheatheState(2, SNAP)` — the cast-send arm). + app.world_mut().write_message(SheathRequest { + entity: unit, + state: 2, + ceremony: false, + }); + app.update(); + assert_eq!(sheath(&app), Some(2), "the shot draws the bow"); + + // CONTROL: swings alone. Attack1H's WeaponFlags `&0x20` is a force-DRAW-MELEE the client only + // consults on the `CUR != 2` path, so nothing in the reconcile can leave the ranged stance. + swing(&mut app); + swing(&mut app); + assert_eq!( + sheath(&app), + Some(2), + "the reconcile alone never leaves the ranged stance — this is the bug's shape" + ); + + // The packet arm's snap (what `net::apply::combat::attacker_state` now writes beside the + // swing): the sword comes out on the first landed blow. + app.world_mut().write_message(SheathRequest { + entity: unit, + state: 1, + ceremony: false, + }); + swing(&mut app); + assert_eq!(sheath(&app), Some(1), "the landed swing draws melee"); + + // …and holds: every later swing re-requests 1, which the setter refuses as idempotent, and + // Attack1H's `&0x20` re-asserts melee on the `CUR != 2` path. + swing(&mut app); + assert_eq!(sheath(&app), Some(1), "melee holds across the volley"); +} diff --git a/crates/benilla/src/creature_anim/select.rs b/crates/benilla/src/creature_anim/select.rs index d5ec0d76..8bb4044f 100644 --- a/crates/benilla/src/creature_anim/select.rs +++ b/crates/benilla/src/creature_anim/select.rs @@ -139,10 +139,23 @@ pub(crate) mod move_flags { /// `0x02000000`; NOT the TBC-era 0x200). Set while the mover rides a transport's platform /// frame; every packet carrying it also carries the local-pose tail (decision 0438 phase 2). pub const ON_TRANSPORT: u32 = 0x0200_0000; - /// MOVEFLAG_WATERWALKING — the ghost form's walk-on-water (decision 0308). Carried in the - /// water-walk apply ack like the real client (the server's toggle-ack doesn't demand it the - /// way the root ack does, but the faithful ack echoes the applied state). + /// MOVEFLAG_WATERWALKING — the liquid surface counts as walkable ground (Water Walking, + /// Levitate, and the ghost form; decisions 0308/0866). Reference consumers: the liquid-mask + /// selector `0x6315f0` (`0x63160d test eax,0x10000000`, taken *only* when not swimming) and the + /// opcode-0x22 apply `0x61a430`. pub const WATER_WALKING: u32 = 0x1000_0000; + /// MOVEFLAG_SAFE_FALL — **feather fall** (Slow Fall, Levitate; decision 0866). It has exactly + /// one effect: the gravity integrate `0x7c5d20` picks its terminal clamp on this bit + /// (`0x7c5d23 test [ecx+0x40],0x20000000`) — 7.0 yd/s `[0x87d898]` instead of the ordinary + /// 60.148 `[0x87d894]`. wow-re's ledger labels it "in-water; selects swim gravity", which is the + /// bit's *shape* read without the server-side name: vmangos sets it only from + /// `SPELL_AURA_FEATHER_FALL`, and swimming is the separate [`SWIMMING`] (0x200000). + pub const SAFE_FALL: u32 = 0x2000_0000; + /// MOVEFLAG_HOVER — the body rests 1.0 yd above the ground (Levitate; decision 0866). VERIFIED + /// as hover rather than a wade bit in wow-re `system/collision/collision.md`: the WALK resolver + /// `0x6367b0` gates a `[0x7ff9d8]` = +1.0-yd surface offset on it, and the step-down reach + /// widens by the same yard (`0x633e35`). + pub const HOVER: u32 = 0x4000_0000; /// The bits a **server-authored move packet owns**, and the only ones it may write — the /// reference's flag *merge* mask, VERIFIED at `0x618c30 @0x618deb` @@ -374,16 +387,28 @@ pub(super) enum Mode { /// stale-direction flash. Plays through to Gait only if the input holds steady. Not a /// non-preemptible bracket. Land { id: u16, flags: u32 }, - /// Playing an action one-shot **full-body on the base track** (the standing-idle route of decision - /// 0087): a standing melee swing (decision 0073) or a standing `/cheer`-class emote whose live - /// state routed it to bone 0. Returns to Gait when it finishes; a new one restarts it; a wanted - /// Special (jump/pose) preempts it — and, like [`Mode::Land`], any movement-flag change from - /// `flags` (captured at play time) re-picks immediately: the client's locomotion re-arm - /// overwrites bone 0 blindly on the change (the same re-arm decision 0280 named), so a shot - /// fired standing yields to the run the instant the player moves instead of sliding the clip - /// out. The *masked* route never enters here — it plays as an overlay beside `Mode`, leaving - /// the base machine (this enum) running underneath. - Swing { id: u16, flags: u32 }, + /// Playing an action one-shot **full-body on the base track** (the bone-0 route of decision + /// 0087): a standing melee swing (decision 0073), a standing `/cheer`-class emote — or a + /// **mid-air cast/emote** (decision 0864: the airborne route test masks only COMBAT ids, so a + /// jump-in-place cast replaces the hang on bone 0, exactly the ref's full-body mid-air cast). + /// `under` is the Special whose base clip this play replaced (`None`: it replaced a gait). + /// While `under` is airborne the one-shot obeys the client's airborne-freeze: a finished clip + /// clamps and holds its last frame, flag changes are keep-current no-ops, and only a Special + /// *edge* exits — the FALLINGFAR latch's Fall (played ONCE, at the latch edge + /// `0x61a820` — 0864's per-tick re-assert was §5-refuted, decision 0868; a clip armed + /// after the latch holds like any other) and the `0x602c60` land pick at touchdown. Grounded + /// (`under == None`) it returns to Gait when it finishes; a new one restarts it — and, like + /// [`Mode::Land`], any movement-flag change from `flags` (captured at play time) re-picks + /// immediately: the client's locomotion re-arm overwrites bone 0 blindly on the change (the + /// same re-arm decision 0280 named), so a shot fired standing yields to the run the instant + /// the player moves instead of sliding the clip out. The *masked* route never enters here — + /// it plays as an overlay beside `Mode`, leaving the base machine (this enum) running + /// underneath. + Swing { + id: u16, + flags: u32, + under: Option, + }, } /// The gait an idle/moving unit should play in [`Mode::Gait`], most-specific id first with fallbacks so a @@ -861,7 +886,8 @@ pub(super) enum OneShotRoute { /// state-route block; a non-CLASS_A id is always full-body. The load-bearing memberships were /// byte-decoded (17/66/68/80 ∈; the 37–45 jump/swim/locomotion band excluded); the patchy interior of /// the wide ranges is INFERRED (wow-re note §3 Open) but never load-bearing here — the only ids that -/// reach [`route_oneshot`] are swings and emotes, all squarely inside these ranges. +/// reach [`route_oneshot`] are swings, emotes, and the spell-kit cast anims (32/33/51–54 — wow-re's +/// own CLASS_A gloss: "swings, emotes, casts"), all squarely inside these ranges. fn is_class_a(id: u16) -> bool { matches!(id, 2 | 8..=10 | 14..=36 | 46..=49 | 51..=90 | 105..=113 | 117..=118 | 122..=138 | 185..=186 | 195) @@ -869,7 +895,9 @@ fn is_class_a(id: u16) -> bool { /// The **COMBAT** membership (wow-re `0x5fcc10`) — the set whose airborne test can route to the mask /// (a mid-jump swing). Byte-decoded memberships: **17 ∈**, and **66/68/80 ∉** (the emotes never mask -/// on airborne alone). Every swing id (16–19/85/87/88/117) is in it; no emote id is. +/// on airborne alone). Every swing id (16–19/85/87/88/117) is in it; no emote id is — and no CAST id +/// (32/33/51–54) either, which is why a jump-in-place cast routes FULL-BODY and replaces the hang +/// (decision 0864), while the same cast over a moving jump masks via the frozen-in move bits. fn is_combat(id: u16) -> bool { matches!(id, 10 | 16..=24 | 30 | 36 | 57..=59 | 85..=88 | 95 | 117 | 118) } diff --git a/crates/benilla/src/creature_anim/sheath.rs b/crates/benilla/src/creature_anim/sheath.rs index 62e28dad..35a90f5e 100644 --- a/crates/benilla/src/creature_anim/sheath.rs +++ b/crates/benilla/src/creature_anim/sheath.rs @@ -56,6 +56,38 @@ pub(crate) struct SheathRequest { pub(crate) ceremony: bool, } +/// The manual `ToggleSheath` **cycle** — the state a Z press asks for next, or `None` where the +/// ref makes no `SetSheatheState` call at all. Byte-read off `0x5eb642`–`0x5eb6a8` (the four +/// ToggleSheath call sites tabulated in wow-re `sheath-policy.md` §1; the dispatch is a +/// `sub 0; je` / `dec; je` / `dec; jne` walk over the committed state `[unit+0xd40]`): +/// +/// ```text +/// CUR 0 mainhand or offhand worn -> 1 melee (0x5eb6a0: push 1, 0, 1) +/// else ranged worn -> 2 ranged (0x5eb699: push 1, ebx=0, 2) +/// else -> no call (0x5eb697: je 0x5eb6ad) +/// CUR 1 ranged worn -> 2 ranged (0x5eb671) +/// else -> 0 stowed (0x5eb67f) +/// CUR 2 -> 0 stowed (0x5eb653) +/// ``` +/// +/// So the press walks **three** states — melee, then ranged, then stowed — never a two-state +/// flip. `melee`/`ranged` are the ref's three `GetWeapon(0/1/2)` results (vtable `+0x98`, called +/// at `0x5eb5f0`/`0x5eb600`/`0x5eb610`); ours are [`Wielded`]'s slots, carrying the same "an item +/// is worn there" fact. **Named deviation:** the ref also zeroes its ranged candidate when the +/// unit's class byte fails a `DAT_00c0def4` "can hold a ranged weapon" lookup +/// (`0x5eb616`–`0x5eb640`, INFERRED semantics) — unobservable here, since a class that fails it +/// cannot equip a ranged item to begin with. +pub(crate) fn toggle_sheath_next(cur: u8, (melee, ranged): (bool, bool)) -> Option { + match cur { + 0 if melee => Some(1), + 0 if ranged => Some(2), + 0 => None, + 1 if ranged => Some(2), + 1 | 2 => Some(0), + _ => None, // the ref's `dec ecx; jne` tail — no call for a state outside {0, 1, 2} + } +} + /// One executed draw/stow swap at its hand-touches-weapon moment — fired only by the *ceremony* /// (the [`VisualSheath`] release at the authored `$SHL`/`$SHR` event). Snap transitions never /// fire it: in the client the draw/stow sound rides the ceremony clip's own keyframes, and a @@ -144,3 +176,39 @@ pub(super) fn start_sheath_ceremony( commands.entity(entity).insert(VisualSheath(old_state)); } } + +#[cfg(test)] +mod tests { + use super::toggle_sheath_next; + + /// The `ToggleSheath` cycle exactly as the bytes branch (`0x5eb642`–`0x5eb6a8`): three states, + /// each leg gated on what is actually worn. The director's report — "Z should take out swords + /// then range then nothing" — is the first row. + #[test] + fn the_z_press_walks_melee_then_ranged_then_stowed() { + const MELEE_AND_BOW: (bool, bool) = (true, true); + const MELEE_ONLY: (bool, bool) = (true, false); + const BOW_ONLY: (bool, bool) = (false, true); + const EMPTY: (bool, bool) = (false, false); + + // Sword + bow: the full three-state walk, and round. + assert_eq!(toggle_sheath_next(0, MELEE_AND_BOW), Some(1)); + assert_eq!(toggle_sheath_next(1, MELEE_AND_BOW), Some(2)); + assert_eq!(toggle_sheath_next(2, MELEE_AND_BOW), Some(0)); + + // No ranged weapon: the CUR=1 leg falls through to the stow (`0x5eb67f`) — the two-state + // flip, which is correct only here. + assert_eq!(toggle_sheath_next(0, MELEE_ONLY), Some(1)); + assert_eq!(toggle_sheath_next(1, MELEE_ONLY), Some(0)); + + // Nothing in either hand: the draw goes straight to ranged (`0x5eb699`). + assert_eq!(toggle_sheath_next(0, BOW_ONLY), Some(2)); + assert_eq!(toggle_sheath_next(2, BOW_ONLY), Some(0)); + + // Nothing worn at all: the ref makes no call (`0x5eb697: je 0x5eb6ad`) — the press is a + // silent no-op, not a draw of empty hands. + assert_eq!(toggle_sheath_next(0, EMPTY), None); + assert_eq!(toggle_sheath_next(1, EMPTY), Some(0)); + assert_eq!(toggle_sheath_next(2, EMPTY), Some(0)); + } +} diff --git a/crates/benilla/src/death.rs b/crates/benilla/src/death.rs index af60805e..75878829 100644 --- a/crates/benilla/src/death.rs +++ b/crates/benilla/src/death.rs @@ -77,27 +77,6 @@ pub(crate) struct DeathNet { pub(crate) water_walk: bool, } -/// The server rooted/unrooted our mover (`SMSG_FORCE_MOVE_ROOT`/`UNROOT` — at death/release). The -/// controller acks with its live pose (`CMSG_FORCE_MOVE_[UN]ROOT_ACK`) and gates its own movement -/// input while rooted — the faithful "can't move between death and release" mechanism. -#[derive(Message)] -pub(crate) struct MoveRootMessage { - /// Our mover's guid (the bridge only emits ours) — echoed in the ack body. - pub(crate) guid: u64, - pub(crate) counter: u32, - pub(crate) rooted: bool, -} - -/// Water-walking granted/removed on our mover (`SMSG_MOVE_WATER_WALK`/`LAND_WALK` — the ghost -/// form). The controller acks (`CMSG_MOVE_WATER_WALK_ACK`); the mover regime is deferred (0308). -#[derive(Message)] -pub(crate) struct WaterWalkMessage { - /// Our mover's guid (the bridge only emits ours) — echoed in the ack body. - pub(crate) guid: u64, - pub(crate) counter: u32, - pub(crate) on: bool, -} - /// The feed's change-tracking memory (the [`crate::ui_unit::UnitFeedState`] pattern — guid-keyed, /// first-snapshot-counts-as-edge so logging in dead brings the popup up). #[derive(Resource, Default)] @@ -399,8 +378,6 @@ impl Plugin for DeathPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() - .add_message::() - .add_message::() .add_systems( Update, ( diff --git a/crates/benilla/src/debug_panel/mod.rs b/crates/benilla/src/debug_panel/mod.rs index 3b9f2c63..e0cea3d3 100644 --- a/crates/benilla/src/debug_panel/mod.rs +++ b/crates/benilla/src/debug_panel/mod.rs @@ -280,23 +280,57 @@ fn toggle_panel( } } -/// Did the **dev-overlay chord** — `Ctrl`+`Cmd`+*key* — just fire? (decision 0585.) +/// How the dev plane is written on screen. Every surface that names a chord reads this instead of +/// spelling one out, so the panel footer, the inspector badge and the mute checkbox can't drift from +/// what [`dev_chord`] actually listens for. +pub(crate) const DEV_CHORD: &str = "Ctrl+Shift"; + +/// Did the **dev-overlay chord** — [`DEV_CHORD`]+*key* — just fire? (decisions 0585, 0867, 0870.) /// /// The dev instruments used to sit on bare letters, which is a namespace we don't own: every letter is -/// a *game* binding in the reference client, so `P` both opened the spellbook and toggled the perf HUD. -/// `Ctrl`+`Cmd` is a plane no in-game binding can ever claim (1.12 builds its binding names from -/// `ALT-`/`CTRL-`/`SHIFT-` only, and the Mac client's Cmd isn't among them), so an instrument parked -/// there can never collide with a key the player wants. The other half of the rule lives at the -/// gameplay key feed (`ui_script::input`): a bare-key binding doesn't fire while a command modifier is -/// held, so this chord doesn't *also* trigger the letter's game action. +/// a *game* binding in the reference client, so `P` both opened the spellbook and toggled the perf HUD +/// (0585). They moved to `Ctrl`+`Cmd`, and then off it: on Windows that is `Ctrl`+`Win`, a plane the +/// shell owns and keeps extending — `Win+Ctrl+M` is Magnifier settings, which had our mute (0867). /// -/// Both sides of each modifier count, and extra modifiers don't block. Deliberately **not** gated on -/// [`crate::ui_script::UiKeyboardCapture`] the way the bare-key toggles were: a chord can't be mistaken -/// for typed text, so the dev overlays stay reachable with the chat bar open. +/// **`Ctrl`+`Shift`, one plane on every OS** (0870, director's call). The alternative was keeping +/// `Ctrl`+`Cmd` on macOS for its one real advantage — Cmd is outside the reference's binding namespace +/// (1.12 builds binding names from `ALT-`/`CTRL-`/`SHIFT-` only), so nothing in game could *ever* claim +/// it. That buys protection against a binding no default declares and no player has yet written, and +/// it costs a per-OS split in the docs, the hints and the reader's head. One plane wins. It also drops +/// our dependence on winit's `sendEvent:` swizzle, without which AppKit's swallowed `keyUp` under Cmd +/// would latch a chord after one use (0585's macOS risk, now simply not run). +/// +/// `Ctrl`+`Shift` is the emptiest plane the reference *can* name: of its 152 defaults, exactly two +/// carry two modifiers — `CTRL-SHIFT-TAB` and `CTRL-SHIFT-PAGEDOWN` — and no letter at all. +/// `Ctrl`+`Alt` was never available: that is AltGr, which European layouts type real characters with +/// (decision 0702). +/// +/// **Exactly those two modifiers and no others**, both sides of each. The block is what makes this +/// plane safe to leave ungated below: AltGr+Shift+*key* is `Ctrl`+`Alt`+`Shift`+*key*, and a German +/// layout typing one of those into chat must not fire an overlay. It is also the same exact-set rule +/// every binding here obeys — the reference matches a binding by string equality, so `CTRL-SHIFT-P` is +/// a different entry from `SHIFT-P` and neither answers for the other. +/// +/// Deliberately **not** gated on [`crate::ui_script::UiKeyboardCapture`] the way the bare-key toggles +/// were: a chord can't be mistaken for typed text, so the dev overlays stay reachable with the chat bar +/// open. The other half of the rule lives at the gameplay key feed (`ui_script::input`): a bare-key +/// binding doesn't fire while *any* modifier is held, so this chord doesn't also trigger the letter's +/// game action. pub(crate) fn dev_chord(keys: &ButtonInput, key: KeyCode) -> bool { - let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight); - let cmd = keys.pressed(KeyCode::SuperLeft) || keys.pressed(KeyCode::SuperRight); - ctrl && cmd && keys.just_pressed(key) + dev_plane(keys) && keys.just_pressed(key) +} + +/// The modifier half of [`dev_chord`]. See there for why the plane is what it is. +fn dev_plane(keys: &ButtonInput) -> bool { + let ctrl = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]); + let shift = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]); + let blocked = keys.any_pressed([ + KeyCode::AltLeft, + KeyCode::AltRight, + KeyCode::SuperLeft, + KeyCode::SuperRight, + ]); + ctrl && shift && !blocked } /// Draw the panel as a translucent **overlay** on the right — the world renders full-screen @@ -563,7 +597,7 @@ fn debug_panel_ui( } }); - // The object inspector is its own Ctrl+Cmd+I surface (see `interact.rs`), not a + // The object inspector is its own dev-chord `I` surface (see `interact.rs`), not a // section here — identifying a thing shouldn't need the whole panel open. egui::CollapsingHeader::new("Particles") .default_open(true) @@ -584,7 +618,7 @@ fn debug_panel_ui( .default_open(false) .show(ui, |ui| { ui.checkbox(&mut sound_cfg.enabled, "enabled"); - ui.checkbox(&mut sound_cfg.muted, "muted (Ctrl+Cmd+M)"); + ui.checkbox(&mut sound_cfg.muted, format!("muted ({DEV_CHORD}+M)")); ui.add( egui::Slider::new(&mut sound_cfg.master, 0.0..=1.0).text("master"), ); @@ -655,7 +689,9 @@ fn debug_panel_ui( // discoverable from the one surface people find first. ui.separator(); ui.label( - egui::RichText::new("` panel · Ctrl+Cmd: P perf · I inspect · M mute") + egui::RichText::new(format!( + "` panel · {DEV_CHORD}: P perf · I inspect · M mute" + )) .small() .color(OVERLAY_TEXT_DIM), ); @@ -680,3 +716,56 @@ fn debug_panel_ui( }); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn keys(held: &[KeyCode]) -> ButtonInput { + let mut k = ButtonInput::default(); + for &c in held { + k.press(c); + } + k + } + + /// The plane fires on its two modifiers, either side of each. + #[test] + fn ctrl_shift_is_the_plane() { + for pair in [ + [KeyCode::ControlLeft, KeyCode::ShiftRight], + [KeyCode::ControlRight, KeyCode::ShiftLeft], + ] { + assert!(dev_plane(&keys(&pair)), "{pair:?} fires"); + } + } + + /// One modifier is not the chord, and a third one names something else. The case that matters: + /// AltGr *is* `Ctrl`+`Alt`, so AltGr+Shift+key is a character a European layout types, never a + /// dev chord — the overlays are ungated while the chat bar is open. `Ctrl`+`Cmd` is likewise + /// nothing of ours now (0870): on Windows it is the shell's, `Win+Ctrl+M` being Magnifier + /// settings. + #[test] + fn a_lone_or_extra_modifier_is_not_the_chord() { + for held in [ + vec![KeyCode::ControlLeft], + vec![KeyCode::ShiftLeft], + vec![KeyCode::ControlLeft, KeyCode::SuperLeft], + vec![KeyCode::ControlLeft, KeyCode::ShiftLeft, KeyCode::AltLeft], + vec![KeyCode::ControlLeft, KeyCode::ShiftLeft, KeyCode::SuperLeft], + ] { + assert!(!dev_plane(&keys(&held)), "{held:?} is not the chord"); + } + } + + /// The label a hint prints is the plane actually listened for — one const, one predicate, so a + /// player is never told to press a key we stopped reading. + #[test] + fn the_label_matches_the_plane() { + assert_eq!(DEV_CHORD, "Ctrl+Shift"); + assert!(dev_plane(&keys(&[ + KeyCode::ControlLeft, + KeyCode::ShiftLeft + ]))); + } +} diff --git a/crates/benilla/src/debug_panel/world.rs b/crates/benilla/src/debug_panel/world.rs index 80e4dc13..373f31b2 100644 --- a/crates/benilla/src/debug_panel/world.rs +++ b/crates/benilla/src/debug_panel/world.rs @@ -7,7 +7,7 @@ //! looks wrong *here*" becomes a pasteable coordinate for the headless probes //! (`WOW_PROBE_CHAT`, live-shot runs) and the FPS-journal loop closes without hand-copying //! numbers. -//! - **land here** — the button half of [`crate::player::land`] (whose chord is `Ctrl`+`Cmd`+`G`), +//! - **land here** — the button half of [`crate::player::land`] (whose chord is the dev chord + `G`), //! shown only while free-flying. //! //! **While free-flying, the spot is the CAMERA's** — the section gains a `camera` line and the @@ -202,7 +202,7 @@ pub(super) fn world_section(ui: &mut egui::Ui, world: &mut WorldReadout) { }; ui.ctx().copy_text(line); } - // Land here — the button half of the `Ctrl`+`Cmd`+`G` chord. Only while detached, because + // Land here — the button half of the dev chord's `G`. Only while detached, because // attached the camera sits behind the avatar and "land at the camera" means a step backwards. if detached_at.is_some() && ui.button("land here").clicked() { world.land.write(LandHere); diff --git a/crates/benilla/src/doodad_anim/lazy.rs b/crates/benilla/src/doodad_anim/lazy.rs new file mode 100644 index 00000000..d9d5bba3 --- /dev/null +++ b/crates/benilla/src/doodad_anim/lazy.rs @@ -0,0 +1,437 @@ +//! The lazy palette-rig lane (decision 0863): a placed doodad's slot in the 2048-entry skin +//! palette is claimed at its **first draw-gate wake**, not at spawn — and given back under table +//! pressure while the host is parked. +//! +//! Why: the slot table — not the 131 k-bone slab — is the palette's scarce axis, and the eager +//! design spent it on the wrong population. The 0863 census across an Elwynn/Stormwind/Westfall +//! hop leg: 1300–1750 of ~1900 live slots were **parked** doodad hosts (drawn by nobody, player +//! stopped, slot held), the drawn-set peak was ~630, and the table hit its 2047 cap — at which +//! point every creature streaming in at the visibility boundary was refused a rig and froze at +//! bind pose *permanently* (the director's "statue mobs after flying around"). Slots that follow +//! the drawn set leave the table sized 3× over the real demand. +//! +//! The lane's three laws: +//! - **Promote at wake** ([`promote_lazy_rig`], called from the draw gate): allocate, write the +//! current pose's rows (so the swapped-in skinned mesh never renders zeroed — origin-collapsed +//! — rows), insert the [`RigSkin`], swap every [`SkinnedTwin`] part `static → skinned` and +//! write its tag's rig field. A denial (table momentarily full) is retried every frame the +//! host stays drawn — never permanent. +//! - **Keep while parked** — parking alone frees nothing, so a camera pan is zero churn, +//! exactly the pre-0863 behaviour when the table has room. +//! - **Reap under pressure** ([`reap_parked_rigs`]): when slot headroom drops under +//! [`REAP_LOW_WATER`], the longest-parked hosts demote (skinned → static, rig field cleared, +//! `RigSkin` removed — the component hook frees the slot). A parked host's meshes are all +//! `Visibility::Hidden`, so the demote is invisible by construction. +//! +//! An emitter-only host (a chimney's smoke plume: joints for the emitter to ride, no skinned +//! parts) never gets a [`LazyRig`], so it never takes a slot at all — under the eager design it +//! held one nobody could read. + +use bevy::mesh::skinning::SkinnedMeshInverseBindposes; +use bevy::mesh::MeshTag; +use bevy::prelude::*; + +use crate::rig_palette::{RigPalettes, RigSkin}; + +use super::DoodadAnimHost; + +/// Start reclaiming parked rigs when the palette's slot headroom drops below this. Sized to keep +/// real room for the eager lanes (units at ~120 peak, held items, spell fx) plus a stream-in +/// burst, while never engaging at all in ordinary scenes. +pub(crate) const REAP_LOW_WATER: usize = 256; + +/// Demotions per frame while under pressure — enough to outpace any stream-in burst (a full +/// tile's placements land over several frames) without a single-frame command spike. +const REAP_PER_FRAME: usize = 64; + +/// A host must have been parked this long (secs) before the reaper may take its slot — a camera +/// swing across the pack must not demote what the swing-back re-promotes. +const REAP_MIN_PARKED_SECS: f32 = 2.0; + +/// On the anim-host root: what the wake-edge allocation needs, held until the host is first +/// drawn. Present ⇔ the placement has skinned parts worth a slot. +#[derive(Component)] +pub(crate) struct LazyRig { + /// The host's joint entities, bone-indexed — [`RigSkin`]'s joint list at promote. + pub(crate) joints: Vec, + pub(crate) ibp: Handle, + /// The [`SkinnedTwin`]-carrying part entities the promote/demote swaps. + pub(crate) parts: Vec, +} + +/// On a part spawned on its static form with a skinned twin waiting: the promote swaps +/// `Mesh3d` to `skinned`, the demote back to `stat`. Both forms are app-built (decision 0834) +/// and the handles held here keep them resident across the swaps. +#[derive(Component)] +pub(crate) struct SkinnedTwin { + pub(crate) skinned: Handle, + pub(crate) stat: Handle, +} + +/// The part query both edges rewrite. A part that despawned mid-frame (tile streaming out under +/// the gate) just skips — its host root is on the same placement list and follows it this frame. +pub(super) type TwinParts<'w, 's> = Query< + 'w, + 's, + ( + &'static mut Mesh3d, + &'static mut MeshTag, + &'static SkinnedTwin, + ), +>; + +/// The wake edge: allocate the slot, seed its rows from the joints' CURRENT worlds, and swap the +/// parts. Returns whether the host now has a rig (so the gate can keep retrying a denial while +/// the host stays drawn). Rows are seeded before the meshes swap: the first skinned frame shows +/// the parked pose the static mesh was already showing, and the palette sweep takes over the +/// same frame (the wake re-arms the player, whose targets mark the joints changed). +pub(super) fn promote_lazy_rig( + commands: &mut Commands, + palettes: &mut RigPalettes, + ibps: &Assets, + worlds: &Query<&GlobalTransform>, + root: Entity, + lazy: &LazyRig, + parts: &mut TwinParts, +) -> bool { + let Some(rig) = RigSkin::allocate(palettes, lazy.joints.clone(), lazy.ibp.clone()) else { + return false; // table full — the warn fired; the gate retries while drawn + }; + let slot = rig.slot; + if let Some(ibp) = ibps.get(&lazy.ibp) { + let joint_worlds: Vec = lazy + .joints + .iter() + .map(|&j| worlds.get(j).copied().unwrap_or_default()) + .collect(); + palettes.write_rig_worlds(&rig, &joint_worlds, ibp); + } + // Queued, liveness-checked at APPLY time: the slot's only free path is the component's + // `on_replace` hook, so a plain `insert` racing this frame's tile-unload despawn would drop + // the `RigSkin` un-attached and leak the slot for the session. (`try_insert` has the same + // hole — a failed insert still never runs the hook.) + commands.queue( + move |world: &mut bevy::ecs::world::World| match world.get_entity_mut(root) { + Ok(mut e) => { + e.insert(rig); + } + Err(_) => { + let slot = rig.slot; + world.resource_mut::().free(slot); + } + }, + ); + for &part in &lazy.parts { + let Ok((mut mesh, mut tag, twin)) = parts.get_mut(part) else { + continue; + }; + mesh.0 = twin.skinned.clone(); + tag.0 = crate::mesh_tag::with_rig(tag.0, slot); + } + true +} + +/// The demote — the promote's exact inverse. Removing the [`RigSkin`] is what frees the slot +/// (its `on_replace` hook), so whoever demotes cannot leak. +fn demote_lazy_rig(commands: &mut Commands, root: Entity, lazy: &LazyRig, parts: &mut TwinParts) { + for &part in &lazy.parts { + let Ok((mut mesh, mut tag, twin)) = parts.get_mut(part) else { + continue; + }; + mesh.0 = twin.stat.clone(); + tag.0 = crate::mesh_tag::with_rig(tag.0, 0); + } + // Liveness-checked like the promote's insert: a host despawned this frame already freed its + // slot through the hook, and a plain `entity(root)` would panic at apply. + commands.queue(move |world: &mut bevy::ecs::world::World| { + if let Ok(mut e) = world.get_entity_mut(root) { + e.remove::(); + } + }); +} + +/// The pressure reaper: under [`REAP_LOW_WATER`] slot headroom, demote the longest-parked +/// rigged hosts, oldest first, up to [`REAP_PER_FRAME`]. Zero work — one headroom read — when +/// the table has room, which is the ordinary case; the eager lanes (units, quest markers, spell +/// fx, booths) are never touched, only hosts this module promoted. +pub(super) fn reap_parked_rigs( + time: Res