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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-02 18:00:46 +07:00
co-authored by Claude Fable 5
parent eb239ddf25
commit 143a734b91
68 changed files with 3498 additions and 1069 deletions
@@ -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<SpellCastTimeCatalog>
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]
@@ -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!(
@@ -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
+22 -10
View File
@@ -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<SpellCatalog> {
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),
@@ -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 {
+8 -8
View File
@@ -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`).
+6 -7
View File
@@ -369,18 +369,17 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
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],
+1 -1
View File
@@ -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};
+12 -6
View File
@@ -327,12 +327,18 @@ pub fn ping(sequence: u32, last_rtt_ms: u32) -> Vec<u8> {
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<bool>) -> Vec<u8> {
let mut body = Vec::with_capacity(48);
body.extend_from_slice(&guid.to_le_bytes());
+1 -1
View File
@@ -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;
@@ -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
+21 -6
View File
@@ -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`,
+18 -16
View File
@@ -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(),
+27 -17
View File
@@ -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<ServerPacket> {
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 => {
@@ -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<u8> {
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
@@ -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
+13 -35
View File
@@ -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),
)
}
@@ -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),
)
}
}
+57 -34
View File
@@ -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};
@@ -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<f32>(1.0), vec3<f32>(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.)
+11 -2
View File
@@ -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<Special>,
}
impl AnimDriver {
@@ -622,6 +630,7 @@ impl Default for AnimDriver {
deferred: None,
ranged_held: false,
loop_window: None,
last_special: None,
}
}
}
+129 -25
View File
@@ -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
@@ -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::<AnimDriver>().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::<AnimDriver>().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::<AnimDriver>().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::<AnimDriver>().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::<AnimDriver>().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::<AnimDriver>().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::<AnimDriver>().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::<AnimDriver>().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::<AnimDriver>()
.unwrap()
.deferred = Some(16);
app.update();
app.update();
fn drv(app: &App, unit: Entity) -> &AnimDriver {
app.world().entity(unit).get::<AnimDriver>().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::<AnimDriver>()
.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");
}
+43 -15
View File
@@ -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<Special>,
},
}
/// 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)
}
@@ -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<u8> {
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));
}
}
-23
View File
@@ -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::<DeathNet>()
.init_resource::<DeathFeedState>()
.add_message::<MoveRootMessage>()
.add_message::<WaterWalkMessage>()
.add_systems(
Update,
(
+105 -16
View File
@@ -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<KeyCode>, 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<KeyCode>) -> 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<KeyCode> {
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
])));
}
}
+2 -2
View File
@@ -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);
+437
View File
@@ -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<Entity>,
pub(crate) ibp: Handle<SkinnedMeshInverseBindposes>,
/// The [`SkinnedTwin`]-carrying part entities the promote/demote swaps.
pub(crate) parts: Vec<Entity>,
}
/// 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<Mesh>,
pub(crate) stat: Handle<Mesh>,
}
/// 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<SkinnedMeshInverseBindposes>,
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<GlobalTransform> = 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::<RigPalettes>().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::<RigSkin>();
}
});
}
/// 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<Time>,
palettes: Res<RigPalettes>,
hosts: Query<(Entity, &DoodadAnimHost, &LazyRig), With<RigSkin>>,
mut parts: TwinParts,
mut commands: Commands,
) {
if palettes.slot_headroom() >= REAP_LOW_WATER {
return;
}
let now = time.elapsed_secs();
let mut parked: Vec<(f32, Entity)> = hosts
.iter()
.filter(|(_, host, _)| !host.active && now - host.parked_at >= REAP_MIN_PARKED_SECS)
.map(|(root, host, _)| (host.parked_at, root))
.collect();
parked.sort_by(|a, b| a.0.total_cmp(&b.0));
for &(_, root) in parked.iter().take(REAP_PER_FRAME) {
let Ok((_, _, lazy)) = hosts.get(root) else {
continue;
};
demote_lazy_rig(&mut commands, root, lazy, &mut parts);
// The RigSkin removal above is a command — the resource's headroom doesn't move until
// it applies, so the batch size is the cap here, not the headroom re-read.
}
}
#[cfg(test)]
mod tests {
use super::super::{gate_doodad_anim, DoodadAnimHost};
use super::*;
use bevy::animation::graph::AnimationNodeIndex;
use bevy::mesh::skinning::SkinnedMeshInverseBindposes;
/// The gate + the reaper on the schedule shape they ship with (gate stamps `parked_at`, the
/// reaper reads it), plus every resource the gate's params need. The camera query is empty —
/// mesh-backed hosts take the `Visibility` branch.
fn app() -> App {
let mut app = App::new();
// TimePlugin disabled, `Time` manual — the reroll harness's rule: the real-clock driver
// would clobber the whole-window advances the reap-hysteresis assertions need.
app.add_plugins((
bevy::MinimalPlugins
.build()
.disable::<bevy::time::TimePlugin>(),
AssetPlugin::default(),
));
app.init_resource::<Time>();
app.init_resource::<crate::view::ViewDistance>();
app.init_resource::<crate::wmo_portal::ExteriorWindows>();
app.init_resource::<crate::wmo_portal::CameraInteriorClaim>();
app.init_resource::<RigPalettes>();
app.init_asset::<SkinnedMeshInverseBindposes>();
app.init_asset::<Mesh>();
app.add_systems(Update, (gate_doodad_anim, reap_parked_rigs).chain());
app
}
/// One host: a hidden-by-default part carrying a [`SkinnedTwin`] of two distinct weak mesh
/// handles, a one-joint [`LazyRig`], and the mesh-backed draw-gate shape. Returns
/// `(host, part, mesh_vis_entity)` — the part IS the visibility carrier, like assemble's.
fn lazy_host(app: &mut App, visible: bool) -> (Entity, Entity) {
let stat = Handle::<Mesh>::default();
let skinned = app
.world_mut()
.resource_mut::<Assets<Mesh>>()
.reserve_handle();
let part = app
.world_mut()
.spawn((
Mesh3d(stat.clone()),
MeshTag(crate::mesh_tag::alpha_bits(1.0)),
SkinnedTwin {
skinned,
stat: stat.clone(),
},
if visible {
Visibility::Inherited
} else {
Visibility::Hidden
},
))
.id();
let joint = app
.world_mut()
.spawn((Transform::default(), GlobalTransform::default()))
.id();
let ibp = app
.world_mut()
.resource_mut::<Assets<SkinnedMeshInverseBindposes>>()
.add(SkinnedMeshInverseBindposes::from(vec![Mat4::IDENTITY]));
let host = app
.world_mut()
.spawn((
DoodadAnimHost {
meshes: vec![part],
fade: (1.0, Vec3::ZERO),
clip: Some((AnimationNodeIndex::new(1), 2.0)),
armed_at: 0.0,
window_hi: f32::INFINITY,
anim_id: Some(0),
active: false,
parked_at: 0.0,
},
LazyRig {
joints: vec![joint],
ibp,
parts: vec![part],
},
))
.id();
(host, part)
}
fn rig_slot(app: &App, host: Entity) -> Option<u16> {
app.world().entity(host).get::<RigSkin>().map(|r| r.slot)
}
fn part_state(app: &mut App, part: Entity) -> (Handle<Mesh>, u16) {
let e = app.world().entity(part);
(
e.get::<Mesh3d>().unwrap().0.clone(),
crate::mesh_tag::rig_of(e.get::<MeshTag>().unwrap().0),
)
}
/// The lazy law end to end: born hidden, a host holds NO slot; a wake allocates on its
/// SECOND consecutive drawn frame (the first is the spawn-default-visibility lie — see the
/// gate's promote comment), swaps the part to the skinned twin, writes its tag's rig field,
/// and seeds the palette rows (the joint's identity world × identity bindpose = a non-zero
/// row — the swapped-in mesh never renders zeroed rows).
#[test]
fn a_host_rigs_at_first_wake_not_at_spawn() {
let mut app = app();
let (host, part) = lazy_host(&mut app, false);
app.update();
assert_eq!(rig_slot(&app, host), None, "hidden ⇒ no slot claimed");
assert_eq!(
app.world().resource::<RigPalettes>().occupancy().0,
0,
"the table is untouched while the host is parked"
);
*app.world_mut()
.entity_mut(part)
.get_mut::<Visibility>()
.unwrap() = Visibility::Inherited;
app.update();
assert_eq!(
rig_slot(&app, host),
None,
"one drawn frame is the spawn-race lie — no slot yet"
);
app.update();
let slot = rig_slot(&app, host).expect("second drawn frame ⇒ slot");
let (mesh, tag_rig) = part_state(&mut app, part);
let twin = app
.world()
.entity(part)
.get::<SkinnedTwin>()
.unwrap()
.skinned
.clone();
assert_eq!(mesh, twin, "the part swapped to its skinned twin");
assert_eq!(tag_rig, slot, "the tag's rig field names the new slot");
assert_eq!(
app.world().resource::<RigPalettes>().computed_rigs(),
1,
"the promote seeded the rows — never a zeroed (origin-collapsed) first frame"
);
}
/// The retry law: a wake into a FULL table is a delay, not a sentence — the host stays on
/// the static mesh, and the frame the table has room again (here: one slot freed) the
/// still-drawn host promotes. This is the exact failure that used to be permanent.
#[test]
fn a_denied_wake_retries_until_the_table_has_room() {
let mut app = app();
// Fill every slot (2047 one-bone rigs), leaking them into a Vec so they stay live.
let hoard: Vec<RigSkin> = {
let mut palettes = app.world_mut().resource_mut::<RigPalettes>();
std::iter::from_fn(|| RigSkin::allocate_bones(&mut palettes, 1, Handle::default()))
.collect()
};
assert_eq!(app.world().resource::<RigPalettes>().slot_headroom(), 0);
let (host, _part) = lazy_host(&mut app, true);
app.update(); // frame 1: the spawn-race frame — never promotes
app.update(); // frame 2: a real drawn frame — denied against the full table
assert_eq!(
rig_slot(&app, host),
None,
"full table ⇒ denied, static mesh"
);
// One slot frees; the host is still drawn — the very next frame promotes.
let freed = hoard[0].slot;
app.world_mut().resource_mut::<RigPalettes>().free(freed);
app.update();
assert!(rig_slot(&app, host).is_some(), "room ⇒ the retry lands");
}
/// The reaper law: under low slot headroom, a host parked past the hysteresis window gives
/// its slot back — part demoted to the static form, tag cleared — and the next wake
/// re-promotes. With headroom, a parked host keeps its slot (a camera pan is zero churn).
#[test]
fn pressure_reaps_the_parked_and_the_next_wake_re_rigs() {
let mut app = app();
let (host, part) = lazy_host(&mut app, true);
app.update();
app.update(); // second consecutive drawn frame — the promote's confirm
assert!(rig_slot(&app, host).is_some(), "drawn ⇒ rigged");
// Park, and age past the reap hysteresis: with plenty of headroom, the slot stays.
*app.world_mut()
.entity_mut(part)
.get_mut::<Visibility>()
.unwrap() = Visibility::Hidden;
app.update();
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_secs_f32(
REAP_MIN_PARKED_SECS + 1.0,
));
app.update();
assert!(
rig_slot(&app, host).is_some(),
"parked with headroom ⇒ the slot is kept (zero churn)"
);
// Choke the table below the low-water mark: the parked host is reaped.
let _hoard: Vec<RigSkin> = {
let mut palettes = app.world_mut().resource_mut::<RigPalettes>();
(0..(crate::mesh_tag::MAX_RIG_SLOTS - 1 - REAP_LOW_WATER))
.filter_map(|_| RigSkin::allocate_bones(&mut palettes, 1, Handle::default()))
.collect()
};
app.update();
app.update(); // command application frame — the RigSkin removal lands
assert_eq!(
rig_slot(&app, host),
None,
"pressure ⇒ the parked slot is reaped"
);
let (mesh, tag_rig) = part_state(&mut app, part);
assert_eq!(
mesh,
app.world()
.entity(part)
.get::<SkinnedTwin>()
.unwrap()
.stat
.clone(),
"the part demoted to its static form"
);
assert_eq!(tag_rig, 0, "and its tag's rig field cleared");
// Re-wake: the still-pressured table has the reaped slot free again — re-promote (two
// drawn frames, the confirm).
*app.world_mut()
.entity_mut(part)
.get_mut::<Visibility>()
.unwrap() = Visibility::Inherited;
app.update();
app.update();
assert!(rig_slot(&app, host).is_some(), "the next wake re-rigs");
}
/// The emitter-only shape: a host with no [`LazyRig`] (assemble inserts none when nothing
/// skins) wakes and parks without ever touching the table — under the eager design every
/// chimney's smoke host burned a slot nobody could read.
#[test]
fn a_host_without_skinned_parts_never_takes_a_slot() {
let mut app = app();
let (host, _part) = lazy_host(&mut app, true);
app.world_mut().entity_mut(host).remove::<LazyRig>();
app.update();
assert_eq!(rig_slot(&app, host), None);
assert_eq!(app.world().resource::<RigPalettes>().occupancy().0, 0);
}
}
+348
View File
@@ -0,0 +1,348 @@
//! Animated **materials** on placed models (decision 0130 phases 2/3): the per-batch baked
//! colour-alpha/weight loops ([`MatAnim`] — composed into the render-alpha `MeshTag` by the
//! visibility authority) and the shared-clock UV-scroll / tint registries
//! ([`UvAnimMaterials`] / [`TintAnimMaterials`]) their samplers tick. Split from the host module
//! ([`super`]) along the concern seam: this file is about *material* channels; the host file is
//! about *bone* channels (the anim host, the variation windows, the draw gate).
use bevy::prelude::*;
use benilla_assets::ModelAnimations;
/// Per-submesh **animated material alpha** (decision 0130 phase 2): the batch's baked
/// colour-alpha/weight loops + how this instance clocks them. [`sample_mat_anim`] keeps
/// [`Self::current`] fresh; the model-`Visibility` authority multiplies it into the render-alpha
/// `MeshTag` it already owns (a composed *input*, not an extra tag writer — the 0066 protocol) and
/// hides the batch at combined 0 (the verified `A ≤ 0` cull, wow-re `m2-alpha-combine-cull`).
///
/// The loops are baked **per sequence** (`benilla_formats::AlphaAnim`), so an instance also has to
/// say *which* sequence it is playing — see [`Self::host`].
#[derive(Component)]
pub(crate) struct MatAnim {
anim: std::sync::Arc<benilla_formats::AlphaAnim>,
/// The entity whose `AnimationPlayer` decides which sequence's loops to read, re-resolved every
/// frame — the **unit lane**, where the played sequence changes constantly and the batch's
/// authored visibility changes with it (a voidwalker's upper armour is weight 0 in Stand and 1
/// only in Death). `None` for an instance pinned to one sequence for its life: a placed doodad
/// (armed once at load with `animations[0]`, wow-re `doodad-anim-host.md`) or a spell effect —
/// both then read [`Self::seq`] on the spawn clock, which is the pre-per-sequence behaviour.
host: Option<Entity>,
/// The sequence **file slot** to read: fixed at spawn for the pinned lanes, and the last one
/// resolved from [`Self::host`] for the unit lane. `None` ⇒ slot 0 (the bake's own degrade).
seq: Option<usize>,
/// `Time::elapsed_secs` at spawn — the clock origin (arm-time phase, like the bone host). Only
/// the pinned lanes use it; a hosted instance reads the player's own seek time instead, so its
/// alpha stays in phase with the pose that drives it.
spawned_at: f32,
/// Captures freeze the clock at 0 for deterministic frames (dimming constants still show).
frozen: bool,
/// This instance's sampled value drives the render-alpha `MeshTag` field **by itself** (no
/// `DoodadFade` on the entity): the spell-effect parts (`entities::spell_fx`), whose alpha
/// channel has no other writer. `false` on the doodad lane — there the visibility authority
/// owns the tag and composes [`Self::current`] in (for a fade holder multiplied with the
/// distance fade; for a lit interior prop written alone into the probe payload's alpha field,
/// bits 0..=15 since the 0355 re-lane) — and on the unit lane, whose own compose is
/// [`crate::entities::apply_unit_mat_alpha`].
pub(crate) drives_tag: bool,
/// This instance belongs to the **unit lane's** tag compose
/// ([`crate::entities::apply_unit_mat_alpha`]) even though it has no [`Self::host`] to read a
/// sequence from: the ATTACH-MODEL case (a held weapon, a helm, a pauldron). Such a model
/// spawns no rig — it rests in its file's first sequence, so its loops are pinned like a
/// placed doodad's — but it hangs off a unit, so the compose has to be the one ordered against
/// the wearer's appear-fade and interior classifier rather than the world-model visibility
/// authority's. See [`Self::resting`].
unit_lane: bool,
/// The gseq factors' ATTACH anchor (secs on the shared clock): `None` until the first
/// [`sample_mat_anim`] pass stamps it — the reference snapshots the scene clock once per
/// model instance at attach (`CM2Model+0x68`, wow-re `gseq-anchor.md`; decisions 0856/0858).
gseq_attach: Option<f64>,
/// The last sampled combined factor (colour-alpha × weight), read by the visibility authority.
pub current: f32,
}
impl MatAnim {
pub(crate) fn new(
anim: std::sync::Arc<benilla_formats::AlphaAnim>,
now: f32,
frozen: bool,
) -> Self {
// The seed sample: both clocks at 0 — nothing armed, and the attach anchor (stamped on
// the first live pass) makes the gseq cursor open at 0 too.
let current = anim.sample(None, 0.0, 0.0);
Self {
anim,
host: None,
seq: None,
spawned_at: now,
frozen,
drives_tag: false,
unit_lane: false,
gseq_attach: None,
current,
}
}
/// The spell-effect-lane constructor: never frozen (the `fxview` instrument ages effects
/// through captures; golden scenarios spawn no effects), the sampled alpha drives the part's
/// render-alpha tag directly (see [`Self::drives_tag`]), and the instance is pinned to the one
/// sequence its rig plays (`seq` — the missile's InFlight, else the file-order-first clip).
pub(crate) fn driving_tag(
anim: std::sync::Arc<benilla_formats::AlphaAnim>,
now: f32,
seq: Option<usize>,
) -> Self {
let mut m = Self::new(anim, now, false);
m.drives_tag = true;
m.seq = seq;
m.current = m.anim.sample(seq, 0.0, 0.0);
m
}
/// The **unit-lane** constructor: the sequence (and its clock) come from `host`'s live
/// `AnimationPlayer`, so a creature's batches appear and disappear with the animation exactly
/// as authored. The tag is composed by [`crate::entities::apply_unit_mat_alpha`], not driven
/// here — the interior classifier and the appear-fade already own that channel.
pub(crate) fn following(
anim: std::sync::Arc<benilla_formats::AlphaAnim>,
host: Entity,
) -> Self {
Self {
host: Some(host),
..Self::new(anim, 0.0, false)
}
}
/// The **attach-model** constructor (a held weapon, a helm, a pauldron): the loops are pinned
/// to the file's first sequence — an item model spawns no rig and rests there, the same reason
/// its emitters and ribbons read Stand — while the tag compose stays the unit lane's, ordered
/// against the wearer's appear-fade and interior classifier.
///
/// Its clock is irrelevant in practice (a rest-pose model's tracks are the constants the file
/// authors) so it takes no `now`; sampling still runs every frame like every other lane, so a
/// keyed track on a rest sequence animates rather than latching at its first key.
pub(crate) fn resting(anim: std::sync::Arc<benilla_formats::AlphaAnim>) -> Self {
Self {
unit_lane: true,
..Self::new(anim, 0.0, false)
}
}
/// Whether this instance's tag alpha is the unit lane's to compose (see
/// [`crate::entities::apply_unit_mat_alpha`]) — a hosted creature/player batch, or an
/// attach model's batch ([`Self::resting`]), and never a self-driving effect part.
pub(crate) fn composes_unit_tag(&self) -> bool {
(self.host.is_some() || self.unit_lane) && !self.drives_tag
}
}
/// The sequence slot + clip-local time a host is playing, for [`sample_mat_anim`]: the **base**
/// animation with the greatest blend weight. Masked overlays (a torso-only swing, an arm's draw
/// ceremony, the finger grip) run on their own graph nodes and are deliberately skipped — they
/// pose bones, they don't reselect the sequence the material tracks read. During a cross-fade two
/// base clips are live and the heavier one wins; the reference instead blends the two sampled
/// scalars by λ (wow-re `eval.md` FN 0x71af20's blend leg), a sub-blend-time difference on tracks
/// the corpus authors as 0/1 steps — recorded, not modelled.
pub(crate) fn playing_seq(
player: &AnimationPlayer,
anims: &ModelAnimations,
) -> Option<(usize, f32)> {
player
.playing_animations()
.filter_map(|(node, active)| {
let clip = anims.clips.iter().find(|c| c.node == *node)?;
Some((clip.seq_index, active.seek_time(), active.weight()))
})
.max_by(|a, b| a.2.total_cmp(&b.2))
.map(|(seq, t, _)| (seq, t))
}
/// Sample every instance's material-alpha loops: a hosted instance on its host's playing sequence
/// and clip clock, a pinned one on its own spawn clock. Frozen (capture) instances keep their t=0
/// sample. Hidden instances sample too — the visibility authority's alpha cull reads
/// [`MatAnim::current`], so a batch BORN at weight 0 (`HolyLight_Low_Head`'s light column, keyed
/// `0 → 1` at 300 ms) must keep its clock running or the alpha-hide latches forever (the old
/// skip-while-Hidden held `current` at the 0 that caused the hide — the invisible pala-heal flash).
/// Sampling is a pure function of the clock, so an instance hidden for any *other* reason (draw
/// gate, far-clip) still lands right on re-appear, and the reference animation-evaluates the tracks
/// every frame regardless of the cull (wow-re `m2-alpha-combine-cull`). Runs before the visibility
/// authority so the tag it composes is this frame's value.
pub(crate) fn sample_mat_anim(
time: Res<Time>,
hosts: Query<(&AnimationPlayer, &ModelAnimations)>,
mut q: Query<&mut MatAnim>,
) {
let now = time.elapsed_secs();
// The scene clock, full-precision (a long-uptime f32 elapsed drifts whole milliseconds,
// which a 433 ms twinkle shows): the base the per-instance gseq attach anchor subtracts
// from (0856).
let shared = time.elapsed_secs_f64();
for mut m in &mut q {
if m.frozen {
continue;
}
// A hosted instance whose host has no player yet (the frame before the rig arms, or a
// rest-pose GameObject) keeps its last resolved slot and reads it at t=0 — the sequence's
// opening pose, which is what a model sitting at bind pose shows.
let played = m
.host
.and_then(|h| hosts.get(h).ok())
.and_then(|(p, a)| playing_seq(p, a));
let (seq, elapsed) = match played {
Some((seq, t)) => {
m.seq = Some(seq);
(Some(seq), t)
}
None if m.host.is_some() => (m.seq, 0.0),
None => (m.seq, now - m.spawned_at),
};
// The instance's gseq cursor: sceneNow − attach, the anchor stamped on this first pass
// (decisions 0856/0858 — every lane, spell effects included: fresh instance per play).
let attach = *m.gseq_attach.get_or_insert(shared);
m.current = m.anim.sample(seq, elapsed, shared - attach);
}
}
/// The **UV-animated materials** registry (decision 0130 phase 3, wow-re `m2-texanim-uv`): each
/// batch material carrying a texture-transform translation loop, keyed by material asset id.
/// [`tick_anim_materials`] re-samples a *drawn* entry's offset into the material's `sun_scale.zw`
/// each frame — one shared uniform per material, so every instance of a model batch scrolls in
/// phase. A recorded, invisible divergence for BOTH clock laws (0856): the reference phases a
/// seq-band loop per play (arm cursor) and a gseq loop per instance (attach anchor,
/// `gseq-anchor.md`), but one uniform per material cannot phase per instance — meaningless for a
/// seamless scroll either way. Entries drop when the material asset does.
#[derive(Resource, Default)]
pub(crate) struct UvAnimMaterials(
pub std::collections::HashMap<
bevy::asset::AssetId<crate::terrain::WowModelMaterial>,
std::sync::Arc<benilla_formats::UvAnim>,
>,
);
/// Re-sample the **drawn** animated materials on the shared clock — the UV scroll
/// ([`UvAnimMaterials`]) and the RGB tint ([`TintAnimMaterials`]) together, because they share the
/// draw scan below. Skipped entirely in captures (materials keep their t = 0 seed — constants still
/// show, frames stay deterministic).
///
/// **The draw gate, and why it is the fix for B131.** Mutating a material asset marks it Modified,
/// which on the Metal non-bindless path re-creates its uniform buffers *and its bind group* that
/// frame. 0130 sized this as "a few uniform writes per frame" on the premise that the resident
/// population is tiny (113 texanim models exist game-wide, a handful in view) — true per view,
/// **false per map session**: the dedup caches hold every material they ever built until a
/// `MapChange` (decision 0729), and a registry entry only evicts when its material dies, so on a
/// single-map traverse both registries grow monotonically and every entry is re-uploaded every
/// frame for ever. Measured on a parked same-map leg by square-waving this system:
/// **+9.85 ms of CPU per frame at 174 resident entries (~57 µs each)** — and residency 4 → 248
/// entries inside ten minutes, recoverable only by a restart or a map change. That is B131.
///
/// Gating on the draw is not a workaround for that growth, it is this lane finally obeying the
/// module's own law: [`gate_doodad_anim`] already gates the *pose* on "any submesh actually drawn",
/// on exactly the byte ground that makes it free — sampling is clock-indexed, so a material that
/// re-appears is written from `now` and shows the value the shared clock dictates, with nothing to
/// catch up (the module docs' "pausing costs nothing and drifts nothing"). A material nothing draws
/// is a per-frame GPU rebuild with no pixel to show for it.
///
/// `Visibility != Hidden` is the same spelling [`gate_doodad_anim`] uses — the authority's verdict
/// (far clip + size-bucketed fade + portal cull), written in `Update` by
/// `debug_panel::ModelVisSet`, which this system is ordered after so the verdict is *this* frame's.
/// It over-includes (a part left `Inherited` under a hidden ancestor counts as drawn), which is the
/// safe direction: an extra write costs a frame's uniform upload, a missed one would freeze a
/// visible scroll.
pub(super) fn tick_anim_materials(
time: Res<Time>,
real: Res<Time<bevy::time::Real>>,
mut uv_reg: ResMut<UvAnimMaterials>,
mut tint_reg: ResMut<TintAnimMaterials>,
mut materials: ResMut<Assets<crate::terrain::WowModelMaterial>>,
parts: Query<(
&MeshMaterial3d<crate::terrain::WowModelMaterial>,
&Visibility,
)>,
mut drawn: Local<
bevy::platform::collections::HashSet<AssetId<crate::terrain::WowModelMaterial>>,
>,
) {
if (uv_reg.0.is_empty() && tint_reg.0.is_empty())
|| crate::capture::scenario_active()
|| matanim_off(&real)
{
return;
}
drawn.clear();
for (mat, vis) in &parts {
if *vis != Visibility::Hidden {
drawn.insert(mat.id());
}
}
let now = time.elapsed_secs();
// `contains` on the skip path, never `get_mut`: the entry must survive while its material does
// (that is the eviction predicate — an entry dies with its material, not with its visibility),
// and an immutable probe is what keeps a skipped material *un*-Modified.
uv_reg.0.retain(|id, anim| {
if !drawn.contains(id) {
return materials.contains(*id);
}
let Some(mat) = materials.get_mut(*id) else {
return false; // material unloaded with its cache — drop the entry
};
let uv = anim.sample(now);
mat.extension.sun_scale.z = uv[0];
mat.extension.sun_scale.w = uv[1];
true
});
tint_reg.0.retain(|id, anim| {
if !drawn.contains(id) {
return materials.contains(*id);
}
let Some(mat) = materials.get_mut(*id) else {
return false;
};
let rgb = anim.sample(now);
mat.extension.tint = bevy::math::Vec4::new(rgb[0], rgb[1], rgb[2], 1.0);
true
});
}
/// **The price-this-system knob** (`WOW_MATANIM_DUTY=<start_s>:<period_s>`, decision 0785): alternate
/// [`tick_anim_materials`] off/on every `period` seconds from `start`. Park at one pin, square-wave
/// it, and the difference between the ON and OFF buckets **is** this system's per-frame cost, with
/// residency, scene, entity count and camera all held identical — a measurement, not an argument.
/// It is how B131's ratchet was priced (+9.85 ms/frame over 174 resident entries) and how the draw
/// gate above was then shown to remove it (+0.22 ms). Kept, not deleted: the ~10 ms-floor ledger
/// (0729's residuals) has more per-frame systems queued for exactly this treatment.
///
/// A **square wave** rather than one flip, because this machine's frame cost drifts on its own —
/// a single before/after pair cannot tell the drift from the signal, and both legs of the verifying
/// run climbed ~10 ms while their ON/OFF difference stayed flat.
///
/// **`Time<Real>`, deliberately:** virtual time is clamped to `max_delta` (250 ms), so on a leg that
/// hitches it lags real time badly. That is what smeared the first attempt at this measurement into
/// nonsense — the probe-chat schedule reads virtual time, so once the leg hitched its hops drifted
/// 40 s → 75 s apart and windows labelled "parked, ticks off" in fact held a teleport and live ticks.
fn matanim_off(time: &Time<bevy::time::Real>) -> bool {
static SPEC: std::sync::OnceLock<Option<(f32, f32)>> = std::sync::OnceLock::new();
let spec = SPEC.get_or_init(|| {
let v = std::env::var("WOW_MATANIM_DUTY").ok()?;
let (start, period) = v.split_once(':')?;
Some((start.trim().parse().ok()?, period.trim().parse().ok()?))
});
spec.is_some_and(|(start, period)| {
let t = time.elapsed_secs() - start;
t >= 0.0 && period > 0.0 && ((t / period) as u32) % 2 == 1
})
}
/// The **tint-animated materials** registry — the M2Color-RGB twin of [`UvAnimMaterials`]: each
/// batch material whose colour track animates (the vertex bake is skipped for those —
/// `benilla-formats` `m2_batches`), keyed by material asset id. [`tick_anim_materials`]
/// re-samples the tint into the material's `tint` uniform on the same shared clock, for the drawn
/// (the same recorded seq-band phase divergence as the UV scroll — invisible for a placed
/// doodad's ambient loop). Spell-effect instances need real per-instance phase instead (one cast
/// = one 0.9 s pulse), so the effect lane clones its materials and ticks them on the instance
/// clock (`entities::spell_fx`), never through this registry.
#[derive(Resource, Default)]
pub(crate) struct TintAnimMaterials(
pub std::collections::HashMap<
bevy::asset::AssetId<crate::terrain::WowModelMaterial>,
std::sync::Arc<benilla_formats::RgbAnim>,
>,
);
@@ -35,6 +35,14 @@ use benilla_assets::{bone_target_id, AnimClip, M2Model, ModelAnimations, ModelSk
use crate::creature_anim::GlobalSeqDrive;
mod lazy;
mod mat_anim;
pub(crate) use lazy::{LazyRig, SkinnedTwin};
use mat_anim::tick_anim_materials;
pub(crate) use mat_anim::{
playing_seq, sample_mat_anim, MatAnim, TintAnimMaterials, UvAnimMaterials,
};
/// The client's single global `rand()` stream — the MSVC LCG at `0x7400e5`, returning `[0, 32767]`
/// (wow-re `doodad-anim-host.md` §5, decision 0768). Every doodad's variation roll draws from **one**
/// shared stream, which is what de-syncs a stand of identical props: not a per-placement seed, just
@@ -114,11 +122,11 @@ pub(crate) fn classify<'a>(
/// with the submesh list once it has spawned them.
pub(crate) struct AnimHostSpawn {
pub root: Entity,
/// The host's joint entities, bone-indexed. NO palette slot rides along any more (decision
/// 0863): the terrain-stream caller stores these in a [`LazyRig`] and the draw gate
/// allocates at first wake; a gate-less caller (quest markers) allocates eagerly itself.
pub joints: Vec<Entity>,
pub inverse_bindposes: Handle<SkinnedMeshInverseBindposes>,
/// The palette rig slot on `root` (decision 0720) — the spawn site tags it into each skinned
/// part's `MeshTag` rig field. `0` = the palette table was full: use the static mesh.
pub slot: u16,
/// The looping first-sequence node + duration, `None` on the gseq-only tier.
pub clip: Option<(AnimationNodeIndex, f32)>,
/// The **file sequence slot** the load arm seeded ([`AnimClip::seq_index`]) — the axis every
@@ -159,7 +167,6 @@ pub(crate) fn wants_rig(m: &M2Model) -> bool {
/// the caller keeps today's path untouched.
pub(crate) fn spawn_anim_host(
commands: &mut Commands,
palettes: &mut crate::rig_palette::RigPalettes,
m: &M2Model,
transform: Transform,
) -> Option<AnimHostSpawn> {
@@ -176,24 +183,16 @@ pub(crate) fn spawn_anim_host(
let anims = m.animations.as_ref().expect("animated tier ⇒ animations");
let root = commands.spawn((transform, Visibility::default())).id();
let joints = crate::entities::spawn_joints(commands, root, root, &m.skeleton);
// The owned palette rig (decision 0720): the placement's skinned parts tag this slot; the
// palette compute reads the joints' worlds (Bevy's animate_targets still drives them — the
// doodad lane keeps the graph path, 0712's "cold consumers"). Slot 0 (table full, warned) ⇒
// the spawn site falls back to the static mesh.
let slot = match crate::rig_palette::RigSkin::allocate(
palettes,
joints.clone(),
m.inverse_bindposes.clone(),
) {
Some(rig) => {
let slot = rig.slot;
commands.entity(root).insert(rig);
slot
}
None => 0,
};
// Billboard bones on an animated doodad (a swinging lamp's glow child): the palette-level
// camera facing, children inheriting.
// NO palette rig here (decision 0863). The host's slot allocation is the CALLER's policy:
// the terrain-stream lane goes lazy — parts spawn on the static form and the draw gate
// allocates at the first wake ([`LazyRig`]) — because the resident population is thousands
// of parked hosts while the 2048-slot table is the scarce axis (the 0863 census: 1300–1750
// parked of ~1900 live slots, active peak ~630). A gate-less caller (quest markers)
// allocates eagerly itself, or its host would never skin.
//
// Billboard bones on an animated doodad (a swinging lamp's glow child): the joint-level
// camera facing, children inheriting — independent of the palette slot (its pass rewrites
// joint GlobalTransforms, which the cards follow whether or not the parts skin yet).
if let Some(bb) = crate::billboard::BillboardJointRig::new(&m.skeleton, &joints, root) {
commands.entity(root).insert(bb);
}
@@ -234,7 +233,6 @@ pub(crate) fn spawn_anim_host(
root,
joints,
inverse_bindposes: m.inverse_bindposes.clone(),
slot,
clip: clip_info,
seq: armed_seq,
anim_id: arm_id,
@@ -273,6 +271,10 @@ pub(crate) struct DoodadAnimHost {
pub anim_id: Option<u16>,
/// Whether the host was ticking last frame (edge-triggered pause/resume).
pub active: bool,
/// `Time::elapsed_secs` when `active` last flipped false — the reaper's age key (decision
/// 0863): under table pressure the longest-parked rigged hosts demote first. Never read
/// while `active`.
pub parked_at: f32,
}
/// The doodad's self-sustaining re-arm (decision 0768, wow-re `doodad-anim-host.md` §5): when the
@@ -337,11 +339,14 @@ fn reroll_doodad_variation(
/// both clocks to the shared-clock position — when one is again. Runs before [`AnimationSystems`] so
/// a resume's seek lands the same frame. Steady state (nothing flipped) is one `Visibility` read per
/// mesh, no writes.
#[allow(clippy::too_many_arguments)] // one Bevy system's input set: the gate reads four authorities
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // one Bevy system's full input set
fn gate_doodad_anim(
time: Res<Time>,
mut hosts: Query<(
Entity,
&mut DoodadAnimHost,
Option<&lazy::LazyRig>,
Has<crate::rig_palette::RigSkin>,
Option<&mut AnimationPlayer>,
Option<&mut GlobalSeqDrive>,
)>,
@@ -361,6 +366,13 @@ fn gate_doodad_anim(
// WMO interior, is not in the frame's worklist and must not tick its bones either (0786).
exterior_windows: Res<crate::wmo_portal::ExteriorWindows>,
camera_claim: Res<crate::wmo_portal::CameraInteriorClaim>,
// The lazy-rig lane's wake half (decision 0863, [`lazy`]): a drawn host without a slot
// promotes here — allocation, row seed, part swap.
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
ibps: Res<Assets<SkinnedMeshInverseBindposes>>,
worlds: Query<&GlobalTransform>,
mut twin_parts: lazy::TwinParts,
mut commands: Commands,
mut logged: Local<bool>,
) {
// One breadcrumb per session, the first frame any host exists — the machine-readable "doodads
@@ -376,7 +388,7 @@ fn gate_doodad_anim(
world_cam.map(|(tf, _, proj)| (tf, proj)),
);
let camera_instance = camera_claim.0.map(|c| c.room.instance);
for (mut host, player, drive) in &mut hosts {
for (entity, mut host, lazy, has_rig, player, drive) in &mut hosts {
let drawn = if host.meshes.is_empty() {
// Meshless (particles-only) host: the emitters' own draw-set law (see the `fade`
// field doc) — the reference ticks animation for any model in the draw set, and a
@@ -414,10 +426,40 @@ fn gate_doodad_anim(
.iter()
.any(|&e| vis.get(e).is_ok_and(|v| *v != Visibility::Hidden))
};
// The lazy-rig promote (decision 0863, [`lazy`]): a host DRAWN two frames running (`&&
// host.active` — last frame's verdict) holding a [`lazy::LazyRig`] and no slot yet — its
// first wake, or a denial being retried — claims its palette slot now. Checked every
// frame it stays drawn, not just on the flip edge, so a momentarily full table is a
// short delay at bind pose, never the permanent statue this lane was built to kill.
//
// Two frames, not one, because the first frame is a LIE: a spawned part's `Visibility`
// defaults to `Inherited` (a `Mesh3d` required component) and the fade/cull authorities
// ran before the spawner's commands applied — so on its first frame every far doodad in
// the streamed tile reads "drawn". Promoting on that frame re-created the eager design
// one frame late (measured on the 0863 verification leg: ~1500 parked hosts holding
// slots, the pre-fix census's own number). The second frame's verdict is the
// authorities' real one; a genuinely visible host promotes one frame late, at fade-in
// distance, invisibly.
if drawn && host.active && !has_rig {
if let Some(lazy) = lazy {
lazy::promote_lazy_rig(
&mut commands,
&mut palettes,
&ibps,
&worlds,
entity,
lazy,
&mut twin_parts,
);
}
}
if drawn == host.active {
continue;
}
host.active = drawn;
if !drawn {
host.parked_at = now; // the reaper's age key (decision 0863)
}
if let Some(mut p) = player {
if drawn {
if let Some((node, duration)) = host.clip {
@@ -445,344 +487,6 @@ fn gate_doodad_anim(
}
}
/// Per-submesh **animated material alpha** (decision 0130 phase 2): the batch's baked
/// colour-alpha/weight loops + how this instance clocks them. [`sample_mat_anim`] keeps
/// [`Self::current`] fresh; the model-`Visibility` authority multiplies it into the render-alpha
/// `MeshTag` it already owns (a composed *input*, not an extra tag writer — the 0066 protocol) and
/// hides the batch at combined 0 (the verified `A ≤ 0` cull, wow-re `m2-alpha-combine-cull`).
///
/// The loops are baked **per sequence** (`benilla_formats::AlphaAnim`), so an instance also has to
/// say *which* sequence it is playing — see [`Self::host`].
#[derive(Component)]
pub(crate) struct MatAnim {
anim: std::sync::Arc<benilla_formats::AlphaAnim>,
/// The entity whose `AnimationPlayer` decides which sequence's loops to read, re-resolved every
/// frame — the **unit lane**, where the played sequence changes constantly and the batch's
/// authored visibility changes with it (a voidwalker's upper armour is weight 0 in Stand and 1
/// only in Death). `None` for an instance pinned to one sequence for its life: a placed doodad
/// (armed once at load with `animations[0]`, wow-re `doodad-anim-host.md`) or a spell effect —
/// both then read [`Self::seq`] on the spawn clock, which is the pre-per-sequence behaviour.
host: Option<Entity>,
/// The sequence **file slot** to read: fixed at spawn for the pinned lanes, and the last one
/// resolved from [`Self::host`] for the unit lane. `None` ⇒ slot 0 (the bake's own degrade).
seq: Option<usize>,
/// `Time::elapsed_secs` at spawn — the clock origin (arm-time phase, like the bone host). Only
/// the pinned lanes use it; a hosted instance reads the player's own seek time instead, so its
/// alpha stays in phase with the pose that drives it.
spawned_at: f32,
/// Captures freeze the clock at 0 for deterministic frames (dimming constants still show).
frozen: bool,
/// This instance's sampled value drives the render-alpha `MeshTag` field **by itself** (no
/// `DoodadFade` on the entity): the spell-effect parts (`entities::spell_fx`), whose alpha
/// channel has no other writer. `false` on the doodad lane — there the visibility authority
/// owns the tag and composes [`Self::current`] in (for a fade holder multiplied with the
/// distance fade; for a lit interior prop written alone into the probe payload's alpha field,
/// bits 0..=15 since the 0355 re-lane) — and on the unit lane, whose own compose is
/// [`crate::entities::apply_unit_mat_alpha`].
pub(crate) drives_tag: bool,
/// This instance belongs to the **unit lane's** tag compose
/// ([`crate::entities::apply_unit_mat_alpha`]) even though it has no [`Self::host`] to read a
/// sequence from: the ATTACH-MODEL case (a held weapon, a helm, a pauldron). Such a model
/// spawns no rig — it rests in its file's first sequence, so its loops are pinned like a
/// placed doodad's — but it hangs off a unit, so the compose has to be the one ordered against
/// the wearer's appear-fade and interior classifier rather than the world-model visibility
/// authority's. See [`Self::resting`].
unit_lane: bool,
/// The gseq factors' ATTACH anchor (secs on the shared clock): `None` until the first
/// [`sample_mat_anim`] pass stamps it — the reference snapshots the scene clock once per
/// model instance at attach (`CM2Model+0x68`, wow-re `gseq-anchor.md`; decisions 0856/0858).
gseq_attach: Option<f64>,
/// The last sampled combined factor (colour-alpha × weight), read by the visibility authority.
pub current: f32,
}
impl MatAnim {
pub(crate) fn new(
anim: std::sync::Arc<benilla_formats::AlphaAnim>,
now: f32,
frozen: bool,
) -> Self {
// The seed sample: both clocks at 0 — nothing armed, and the attach anchor (stamped on
// the first live pass) makes the gseq cursor open at 0 too.
let current = anim.sample(None, 0.0, 0.0);
Self {
anim,
host: None,
seq: None,
spawned_at: now,
frozen,
drives_tag: false,
unit_lane: false,
gseq_attach: None,
current,
}
}
/// The spell-effect-lane constructor: never frozen (the `fxview` instrument ages effects
/// through captures; golden scenarios spawn no effects), the sampled alpha drives the part's
/// render-alpha tag directly (see [`Self::drives_tag`]), and the instance is pinned to the one
/// sequence its rig plays (`seq` — the missile's InFlight, else the file-order-first clip).
pub(crate) fn driving_tag(
anim: std::sync::Arc<benilla_formats::AlphaAnim>,
now: f32,
seq: Option<usize>,
) -> Self {
let mut m = Self::new(anim, now, false);
m.drives_tag = true;
m.seq = seq;
m.current = m.anim.sample(seq, 0.0, 0.0);
m
}
/// The **unit-lane** constructor: the sequence (and its clock) come from `host`'s live
/// `AnimationPlayer`, so a creature's batches appear and disappear with the animation exactly
/// as authored. The tag is composed by [`crate::entities::apply_unit_mat_alpha`], not driven
/// here — the interior classifier and the appear-fade already own that channel.
pub(crate) fn following(
anim: std::sync::Arc<benilla_formats::AlphaAnim>,
host: Entity,
) -> Self {
Self {
host: Some(host),
..Self::new(anim, 0.0, false)
}
}
/// The **attach-model** constructor (a held weapon, a helm, a pauldron): the loops are pinned
/// to the file's first sequence — an item model spawns no rig and rests there, the same reason
/// its emitters and ribbons read Stand — while the tag compose stays the unit lane's, ordered
/// against the wearer's appear-fade and interior classifier.
///
/// Its clock is irrelevant in practice (a rest-pose model's tracks are the constants the file
/// authors) so it takes no `now`; sampling still runs every frame like every other lane, so a
/// keyed track on a rest sequence animates rather than latching at its first key.
pub(crate) fn resting(anim: std::sync::Arc<benilla_formats::AlphaAnim>) -> Self {
Self {
unit_lane: true,
..Self::new(anim, 0.0, false)
}
}
/// Whether this instance's tag alpha is the unit lane's to compose (see
/// [`crate::entities::apply_unit_mat_alpha`]) — a hosted creature/player batch, or an
/// attach model's batch ([`Self::resting`]), and never a self-driving effect part.
pub(crate) fn composes_unit_tag(&self) -> bool {
(self.host.is_some() || self.unit_lane) && !self.drives_tag
}
}
/// The sequence slot + clip-local time a host is playing, for [`sample_mat_anim`]: the **base**
/// animation with the greatest blend weight. Masked overlays (a torso-only swing, an arm's draw
/// ceremony, the finger grip) run on their own graph nodes and are deliberately skipped — they
/// pose bones, they don't reselect the sequence the material tracks read. During a cross-fade two
/// base clips are live and the heavier one wins; the reference instead blends the two sampled
/// scalars by λ (wow-re `eval.md` FN 0x71af20's blend leg), a sub-blend-time difference on tracks
/// the corpus authors as 0/1 steps — recorded, not modelled.
pub(crate) fn playing_seq(
player: &AnimationPlayer,
anims: &ModelAnimations,
) -> Option<(usize, f32)> {
player
.playing_animations()
.filter_map(|(node, active)| {
let clip = anims.clips.iter().find(|c| c.node == *node)?;
Some((clip.seq_index, active.seek_time(), active.weight()))
})
.max_by(|a, b| a.2.total_cmp(&b.2))
.map(|(seq, t, _)| (seq, t))
}
/// Sample every instance's material-alpha loops: a hosted instance on its host's playing sequence
/// and clip clock, a pinned one on its own spawn clock. Frozen (capture) instances keep their t=0
/// sample. Hidden instances sample too — the visibility authority's alpha cull reads
/// [`MatAnim::current`], so a batch BORN at weight 0 (`HolyLight_Low_Head`'s light column, keyed
/// `0 → 1` at 300 ms) must keep its clock running or the alpha-hide latches forever (the old
/// skip-while-Hidden held `current` at the 0 that caused the hide — the invisible pala-heal flash).
/// Sampling is a pure function of the clock, so an instance hidden for any *other* reason (draw
/// gate, far-clip) still lands right on re-appear, and the reference animation-evaluates the tracks
/// every frame regardless of the cull (wow-re `m2-alpha-combine-cull`). Runs before the visibility
/// authority so the tag it composes is this frame's value.
pub(crate) fn sample_mat_anim(
time: Res<Time>,
hosts: Query<(&AnimationPlayer, &ModelAnimations)>,
mut q: Query<&mut MatAnim>,
) {
let now = time.elapsed_secs();
// The scene clock, full-precision (a long-uptime f32 elapsed drifts whole milliseconds,
// which a 433 ms twinkle shows): the base the per-instance gseq attach anchor subtracts
// from (0856).
let shared = time.elapsed_secs_f64();
for mut m in &mut q {
if m.frozen {
continue;
}
// A hosted instance whose host has no player yet (the frame before the rig arms, or a
// rest-pose GameObject) keeps its last resolved slot and reads it at t=0 — the sequence's
// opening pose, which is what a model sitting at bind pose shows.
let played = m
.host
.and_then(|h| hosts.get(h).ok())
.and_then(|(p, a)| playing_seq(p, a));
let (seq, elapsed) = match played {
Some((seq, t)) => {
m.seq = Some(seq);
(Some(seq), t)
}
None if m.host.is_some() => (m.seq, 0.0),
None => (m.seq, now - m.spawned_at),
};
// The instance's gseq cursor: sceneNow − attach, the anchor stamped on this first pass
// (decisions 0856/0858 — every lane, spell effects included: fresh instance per play).
let attach = *m.gseq_attach.get_or_insert(shared);
m.current = m.anim.sample(seq, elapsed, shared - attach);
}
}
/// The **UV-animated materials** registry (decision 0130 phase 3, wow-re `m2-texanim-uv`): each
/// batch material carrying a texture-transform translation loop, keyed by material asset id.
/// [`tick_anim_materials`] re-samples a *drawn* entry's offset into the material's `sun_scale.zw`
/// each frame — one shared uniform per material, so every instance of a model batch scrolls in
/// phase. A recorded, invisible divergence for BOTH clock laws (0856): the reference phases a
/// seq-band loop per play (arm cursor) and a gseq loop per instance (attach anchor,
/// `gseq-anchor.md`), but one uniform per material cannot phase per instance — meaningless for a
/// seamless scroll either way. Entries drop when the material asset does.
#[derive(Resource, Default)]
pub(crate) struct UvAnimMaterials(
pub std::collections::HashMap<
bevy::asset::AssetId<crate::terrain::WowModelMaterial>,
std::sync::Arc<benilla_formats::UvAnim>,
>,
);
/// Re-sample the **drawn** animated materials on the shared clock — the UV scroll
/// ([`UvAnimMaterials`]) and the RGB tint ([`TintAnimMaterials`]) together, because they share the
/// draw scan below. Skipped entirely in captures (materials keep their t = 0 seed — constants still
/// show, frames stay deterministic).
///
/// **The draw gate, and why it is the fix for B131.** Mutating a material asset marks it Modified,
/// which on the Metal non-bindless path re-creates its uniform buffers *and its bind group* that
/// frame. 0130 sized this as "a few uniform writes per frame" on the premise that the resident
/// population is tiny (113 texanim models exist game-wide, a handful in view) — true per view,
/// **false per map session**: the dedup caches hold every material they ever built until a
/// `MapChange` (decision 0729), and a registry entry only evicts when its material dies, so on a
/// single-map traverse both registries grow monotonically and every entry is re-uploaded every
/// frame for ever. Measured on a parked same-map leg by square-waving this system:
/// **+9.85 ms of CPU per frame at 174 resident entries (~57 µs each)** — and residency 4 → 248
/// entries inside ten minutes, recoverable only by a restart or a map change. That is B131.
///
/// Gating on the draw is not a workaround for that growth, it is this lane finally obeying the
/// module's own law: [`gate_doodad_anim`] already gates the *pose* on "any submesh actually drawn",
/// on exactly the byte ground that makes it free — sampling is clock-indexed, so a material that
/// re-appears is written from `now` and shows the value the shared clock dictates, with nothing to
/// catch up (the module docs' "pausing costs nothing and drifts nothing"). A material nothing draws
/// is a per-frame GPU rebuild with no pixel to show for it.
///
/// `Visibility != Hidden` is the same spelling [`gate_doodad_anim`] uses — the authority's verdict
/// (far clip + size-bucketed fade + portal cull), written in `Update` by
/// `debug_panel::ModelVisSet`, which this system is ordered after so the verdict is *this* frame's.
/// It over-includes (a part left `Inherited` under a hidden ancestor counts as drawn), which is the
/// safe direction: an extra write costs a frame's uniform upload, a missed one would freeze a
/// visible scroll.
fn tick_anim_materials(
time: Res<Time>,
real: Res<Time<bevy::time::Real>>,
mut uv_reg: ResMut<UvAnimMaterials>,
mut tint_reg: ResMut<TintAnimMaterials>,
mut materials: ResMut<Assets<crate::terrain::WowModelMaterial>>,
parts: Query<(
&MeshMaterial3d<crate::terrain::WowModelMaterial>,
&Visibility,
)>,
mut drawn: Local<
bevy::platform::collections::HashSet<AssetId<crate::terrain::WowModelMaterial>>,
>,
) {
if (uv_reg.0.is_empty() && tint_reg.0.is_empty())
|| crate::capture::scenario_active()
|| matanim_off(&real)
{
return;
}
drawn.clear();
for (mat, vis) in &parts {
if *vis != Visibility::Hidden {
drawn.insert(mat.id());
}
}
let now = time.elapsed_secs();
// `contains` on the skip path, never `get_mut`: the entry must survive while its material does
// (that is the eviction predicate — an entry dies with its material, not with its visibility),
// and an immutable probe is what keeps a skipped material *un*-Modified.
uv_reg.0.retain(|id, anim| {
if !drawn.contains(id) {
return materials.contains(*id);
}
let Some(mat) = materials.get_mut(*id) else {
return false; // material unloaded with its cache — drop the entry
};
let uv = anim.sample(now);
mat.extension.sun_scale.z = uv[0];
mat.extension.sun_scale.w = uv[1];
true
});
tint_reg.0.retain(|id, anim| {
if !drawn.contains(id) {
return materials.contains(*id);
}
let Some(mat) = materials.get_mut(*id) else {
return false;
};
let rgb = anim.sample(now);
mat.extension.tint = bevy::math::Vec4::new(rgb[0], rgb[1], rgb[2], 1.0);
true
});
}
/// **The price-this-system knob** (`WOW_MATANIM_DUTY=<start_s>:<period_s>`, decision 0785): alternate
/// [`tick_anim_materials`] off/on every `period` seconds from `start`. Park at one pin, square-wave
/// it, and the difference between the ON and OFF buckets **is** this system's per-frame cost, with
/// residency, scene, entity count and camera all held identical — a measurement, not an argument.
/// It is how B131's ratchet was priced (+9.85 ms/frame over 174 resident entries) and how the draw
/// gate above was then shown to remove it (+0.22 ms). Kept, not deleted: the ~10 ms-floor ledger
/// (0729's residuals) has more per-frame systems queued for exactly this treatment.
///
/// A **square wave** rather than one flip, because this machine's frame cost drifts on its own —
/// a single before/after pair cannot tell the drift from the signal, and both legs of the verifying
/// run climbed ~10 ms while their ON/OFF difference stayed flat.
///
/// **`Time<Real>`, deliberately:** virtual time is clamped to `max_delta` (250 ms), so on a leg that
/// hitches it lags real time badly. That is what smeared the first attempt at this measurement into
/// nonsense — the probe-chat schedule reads virtual time, so once the leg hitched its hops drifted
/// 40 s → 75 s apart and windows labelled "parked, ticks off" in fact held a teleport and live ticks.
fn matanim_off(time: &Time<bevy::time::Real>) -> bool {
static SPEC: std::sync::OnceLock<Option<(f32, f32)>> = std::sync::OnceLock::new();
let spec = SPEC.get_or_init(|| {
let v = std::env::var("WOW_MATANIM_DUTY").ok()?;
let (start, period) = v.split_once(':')?;
Some((start.trim().parse().ok()?, period.trim().parse().ok()?))
});
spec.is_some_and(|(start, period)| {
let t = time.elapsed_secs() - start;
t >= 0.0 && period > 0.0 && ((t / period) as u32) % 2 == 1
})
}
/// The **tint-animated materials** registry — the M2Color-RGB twin of [`UvAnimMaterials`]: each
/// batch material whose colour track animates (the vertex bake is skipped for those —
/// `benilla-formats` `m2_batches`), keyed by material asset id. [`tick_anim_materials`]
/// re-samples the tint into the material's `tint` uniform on the same shared clock, for the drawn
/// (the same recorded seq-band phase divergence as the UV scroll — invisible for a placed
/// doodad's ambient loop). Spell-effect instances need real per-instance phase instead (one cast
/// = one 0.9 s pulse), so the effect lane clones its materials and ticks them on the instance
/// clock (`entities::spell_fx`), never through this registry.
#[derive(Resource, Default)]
pub(crate) struct TintAnimMaterials(
pub std::collections::HashMap<
bevy::asset::AssetId<crate::terrain::WowModelMaterial>,
std::sync::Arc<benilla_formats::RgbAnim>,
>,
);
pub struct DoodadAnimPlugin;
impl Plugin for DoodadAnimPlugin {
@@ -799,6 +503,9 @@ impl Plugin for DoodadAnimPlugin {
.chain()
.before(AnimationSystems),
);
// The lazy-rig pressure reaper (decision 0863): one headroom read per frame when the
// table has room; demotes longest-parked rigged hosts when it doesn't.
app.add_systems(Update, lazy::reap_parked_rigs);
// `sample_mat_anim` runs before the visibility authority (`ModelVisSet`): it composes
// `MatAnim::current` into the render-alpha tag the same frame. The material tick runs
// *after* it, because its draw gate reads the verdict that authority writes — one frame
@@ -1081,6 +788,7 @@ mod tests {
window_hi: f32::NEG_INFINITY,
anim_id: Some(0),
active: true,
parked_at: 0.0,
},
lightning_anims(),
AnimationPlayer::default(),
@@ -1168,6 +876,7 @@ mod tests {
window_hi: f32::NEG_INFINITY,
anim_id: None,
active: true,
parked_at: 0.0,
},
anims(Vec::new(), None, true),
AnimationPlayer::default(),
@@ -1239,15 +948,19 @@ mod tests {
#[test]
fn gate_pauses_hidden_and_resumes_on_the_shared_clock() {
let mut app = App::new();
app.add_plugins(MinimalPlugins); // Time
// The gate reads the far-clip wall (0678) — this host is mesh-BACKED, so it takes the
// `Visibility` branch and never consults it, but the system still needs the resource.
app.add_plugins((MinimalPlugins, AssetPlugin::default())); // Time + asset stores
// The gate reads the far-clip wall (0678) — this host is mesh-BACKED, so it takes the
// `Visibility` branch and never consults it, but the system still needs the resource.
app.init_resource::<crate::view::ViewDistance>();
// …and the exterior-window gate's two terms (0786), for the same reason: this host is
// mesh-BACKED so it never consults them, but the system's params must resolve. Their
// defaults are the outdoor case (`Unrestricted`, no room claimed).
app.init_resource::<crate::wmo_portal::ExteriorWindows>();
app.init_resource::<crate::wmo_portal::CameraInteriorClaim>();
// The lazy-rig promote's params (decision 0863) — this host carries no `LazyRig`, so
// the wake edge never fires here, but the params must resolve.
app.init_resource::<crate::rig_palette::RigPalettes>();
app.init_asset::<SkinnedMeshInverseBindposes>();
app.add_systems(Update, gate_doodad_anim);
let mesh = app.world_mut().spawn(Visibility::Inherited).id();
@@ -1273,6 +986,7 @@ mod tests {
window_hi: f32::INFINITY,
anim_id: Some(0),
active: true,
parked_at: 0.0,
},
player,
drive,
+9 -2
View File
@@ -470,8 +470,15 @@ impl Plugin for EntitiesPlugin {
refresh_mounts,
// A live display-id / scale change (decision 0695): the display swap is the
// same teardown-and-rebuild; the scale change arms the reference's 2 s ease.
refresh_live_display,
tick_scale_ease,
// The rig heal (decision 0863) rides the same nest (Bevy's 20-tuple ceiling):
// a unit denied a palette rig at attach rebuilds — the same teardown — once
// the table has headroom again; never a permanent statue.
(
refresh_live_display,
tick_scale_ease,
live_display::heal_rig_starved,
)
.chain(),
)
.chain()
.in_set(EntityVisualsSet)
+16 -2
View File
@@ -139,10 +139,24 @@ fn setup_skinned_instance(
match crate::rig_palette::RigSkin::allocate_bones(palettes, nbones as u32, ibp.clone()) {
Some(rig) => {
let slot = rig.slot;
commands.entity(entity).insert(rig);
// The marker's invariant is "currently slot-less": a heal-triggered (or any
// other) rebuild that lands a slot clears it, or the healer would loop.
commands
.entity(entity)
.insert(rig)
.remove::<crate::rig_palette::RigStarved>();
slot
}
None => 0, // table full (warned) — parts render the static bind-pose mesh
None => {
// Table full (warned): parts render the static bind-pose mesh — but no longer
// for ever. The marker hands the unit to `heal_rig_starved` (decision 0863),
// which rebuilds the visual once the table has headroom; without it a mob that
// streamed in during a full-table window stayed a statue for its whole life.
commands
.entity(entity)
.insert(crate::rig_palette::RigStarved);
0
}
};
if let Some(anims) = d.animations.as_ref() {
// **Every** GameObject instance gets the loader-idle seed, state machine or not — the
+18 -11
View File
@@ -481,9 +481,14 @@ pub(super) fn build_parts(
// The AlphaMode::Blend twin for the spawn appear-fade (decision 0032) — the exterior
// look feathered, the same variant DoodadFade uses. Reuse the exterior when it's
// already Blend (its cutout already feathers; no separate twin needed). A MULTIPLY
// batch (Mod/Mod2x) gets NO twin: its blend equation reads no alpha, so it cannot
// fade — the reference's instanceAlpha ramp leaves the sheen at full strength while
// the base fades under it (decision 0528); `None` spawns the part Steady.
// batch (Mod/Mod2x) also reuses its steady self: its blend equation reads no
// alpha, so it cannot feather through the alpha channel — the reference's
// instanceAlpha ramp leaves the sheen at full strength while the base fades under
// it (0528; re-confirmed for items by wow-re `m2-item-texture-fill.md`). benilla
// fades it anyway as a deliberate deviation: the part arms the ramp like any
// other, and `wow_model.wgsl` lerps a multiply batch's colour toward its blend
// IDENTITY by the tag alpha, so the sheen rides the same ramp instead of popping
// over a body that hasn't faded in (the director's login report; decision 0865).
// The depth-prime twin (wow-re `m2-blend-promotion-zfill.md` §4): every batch
// that can fade AND writes depth gets one. `cutout` mirrors what the part's
// colour pass discards while translucent — the fade twin's hard 224/255 for
@@ -506,7 +511,9 @@ pub(super) fn build_parts(
}
};
let fade_blend = match sub.blend {
ModelBlend::Mod | ModelBlend::Mod2x => None,
// The steady material IS the "twin": no swap, the ramp only drives the tag
// alpha the shader's identity-lerp reads (see the comment block above).
ModelBlend::Mod | ModelBlend::Mod2x => Some(exterior.clone()),
ModelBlend::Blend => Some(exterior.clone()),
// The SOURCE blend rides into the twin (Opaque or AlphaKey here): with
// fade_variant it still builds AlphaMode::Blend, but the source decides
@@ -568,14 +575,14 @@ pub(super) fn build_parts(
// a despawn ramp): same probe mode, alpha-blend state. Without it a fade swaps
// an indoor entity to the EXTERIOR blend twin and its light jumps to the lit
// outdoor intensity mid-feather (director-caught, 2026-07-13). Reuse the bake
// variant when the part is already Blend, like the exterior twin above; a
// multiply batch fades never (no fade twin at all), so it builds none either.
let interior_bake_blend = interior_bake.as_ref().and_then(|bake| {
// variant when the part is already Blend, like the exterior twin above — and
// for a multiply batch, whose steady bake IS the twin (the shader's
// identity-lerp does the feather, decision 0865).
let interior_bake_blend = interior_bake.as_ref().map(|bake| {
match sub.blend {
ModelBlend::Mod | ModelBlend::Mod2x => None,
ModelBlend::Blend => Some(bake.clone()),
ModelBlend::Mod | ModelBlend::Mod2x | ModelBlend::Blend => bake.clone(),
// Source blend through to the twin, like the exterior twin above (0842).
_ => Some(model_material(
_ => model_material(
cache,
materials,
texture.clone(),
@@ -597,7 +604,7 @@ pub(super) fn build_parts(
None,
false,
light,
)),
),
}
});
// The interior MATTE variant: the plain day/night pair at sun ×1.0 — the
@@ -1128,6 +1128,86 @@ mod tests {
);
}
/// **The director's login report on the Naxx items** (decision 0865): a MULTIPLY sheen batch
/// (Mod2x — the ARMORREFLECT family) used to spawn Steady, popping as a full-strength ×2 layer
/// over a body still fading in. Its blend equation reads no alpha, so no material swap can
/// feather it (0528, byte-reconfirmed by wow-re `m2-item-texture-fill.md`) — instead the part
/// now arms the ramp on its STEADY material (the "twin" is itself) and the shader lerps its
/// colour toward the blend identity by the tag alpha. Headless, this asserts the arm half:
/// joined ramp, tag alpha ≈ 0, no material swap, and a FadeMaterials record so despawn/stealth
/// ramps re-arm it too.
#[test]
fn a_multiply_sheen_joins_the_wearers_ramp_on_its_steady_material() {
const KIND: ItemModelKind = ItemModelKind::Weapon;
const SINCE: f32 = 3.0;
let steady: Handle<crate::terrain::WowModelMaterial> = Handle::Uuid(
bevy::asset::uuid::Uuid::from_u128(0x5133),
std::marker::PhantomData,
);
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<crate::rig_palette::RigPalettes>();
let mut displays = ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(HashMap::new()),
);
let mut dm = empty_display();
let mut sheen = part(false);
sheen.blend = benilla_formats::ModelBlend::Mod2x;
sheen.material = steady.clone();
// What `entities::display` now builds for a multiply batch: the steady self as the twin.
sheen.fade_blend = Some(steady.clone());
dm.parts = Some(vec![sheen]);
displays.models.insert((7, KIND), dm);
app.insert_resource(displays);
let joint = app.world_mut().spawn(Transform::default()).id();
let bones = BoneAttach {
anchors: HashMap::from([(3u16, joint)]),
points: HashMap::from([(attach_id::HAND_RIGHT, (3u16, Vec3::ZERO))]),
markers: HashMap::new(),
};
let mut items = HeldItems::default();
items.slots[0] = Some(HeldSlot {
display: 7,
kind: KIND,
attach: attach_id::HAND_RIGHT,
visual: NO_GLOW,
});
app.world_mut().spawn((
items,
bones,
Transform::default(),
UnitAppearFade::Pending { since: SINCE },
));
app.add_systems(Update, attach_held_items);
app.update();
let mut q = app.world_mut().query::<(
&MeshTag,
&MeshMaterial3d<crate::terrain::WowModelMaterial>,
Option<&crate::model_fade::FadeMaterials>,
Option<&crate::model_fade::PendingAppearFade>,
)>();
let found: Vec<_> = q.iter(app.world()).collect();
assert_eq!(found.len(), 1, "the one sheen batch spawned");
let (tag, mat, fm, pending) = found[0];
assert_eq!(
pending.map(|p| p.since),
Some(SINCE),
"the sheen joined the wearer's pending ramp (it used to spawn Steady and pop)",
);
assert_eq!(
mat.0, steady,
"no material swap — the steady multiply pipeline"
);
let fm = fm.expect("a FadeMaterials record, so despawn/stealth ramps re-arm it");
assert_eq!(fm.blend, steady, "…whose 'twin' is the steady self");
assert!(
crate::mesh_tag::alpha_of(tag.0) <= 1.0 / 63.0,
"…opening at tag alpha ≈ 0: the shader's identity-lerp makes it contribute nothing",
);
}
/// A wearer holding a **weapon** (mainhand, drawn in the hand) and wearing a **shoulder** that
/// carries emitters — the bench for the sheath-swap tests below. Returns the app and the wearer.
fn dress_a_wearer() -> (App, Entity) {
+124
View File
@@ -193,10 +193,134 @@ pub(super) fn tick_scale_ease(
}
}
/// Palette headroom below which the healer waits: rebuilding into a still-tight table would just
/// re-starve, and the doodad reaper (`doodad_anim::lazy`) is what makes room. Deliberately under
/// the reaper's low-water (256) so the reaper engages first and the heal follows into the space
/// it opened.
const HEAL_MIN_HEADROOM: usize = 128;
/// Visual rebuilds per frame — a starved *population* (a whole stream-in burst denied at once)
/// heals over a second or two instead of one spike of teardown+reattach commands.
const HEAL_PER_FRAME: usize = 2;
/// Rebuild the visuals of units whose attach was DENIED a palette rig (decision 0863 — the
/// [`RigStarved`](crate::rig_palette::RigStarved) marker): the same teardown set as the
/// display-id swap above (the reference's `0x60abe0` rebuild), fade-skipped via `Reattached` —
/// a heal is not a spawn. `attach_entity_visuals` rebuilds next frame(s), allocating with the
/// headroom this system waited for; if the table filled again in between, the attach re-marks
/// and the healer comes back. Before this system, a full-table denial froze the unit at bind
/// pose for its whole life — the "statue mobs at the stream-in boundary" bug.
#[allow(clippy::type_complexity)] // one query's two-marker filter
pub(super) fn heal_rig_starved(
mut commands: Commands,
palettes: Res<crate::rig_palette::RigPalettes>,
starved: Query<(Entity, &Guid), (With<crate::rig_palette::RigStarved>, With<VisualAttached>)>,
) {
if starved.is_empty() || palettes.slot_headroom() < HEAL_MIN_HEADROOM {
return;
}
for (entity, guid) in starved.iter().take(HEAL_PER_FRAME) {
info!(
"rig heal: guid {:016x} was denied a palette rig at attach — rebuilding (0x60abe0 shape)",
guid.0
);
commands
.entity(entity)
.despawn_related::<Children>()
.remove::<(
VisualAttached,
AppliedDisplay,
super::equipment::AppliedEquipment,
super::mount::AppliedMount,
super::mount::MountChild,
AnimationPlayer,
bevy::animation::transition::AnimationTransitions,
AnimationGraphHandle,
benilla_assets::ModelAnimations,
crate::creature_anim::AnimDriver,
(
crate::creature_anim::RigPose,
crate::creature_anim::BodyTwist,
crate::creature_anim::GlobalSeqDrive,
),
crate::rig_palette::RigSkin,
super::BoneAttach,
super::equipment::HeldAttached,
crate::rig_palette::RigStarved,
)>()
.insert(super::equipment::Reattached);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The heal law (decision 0863): a rig-starved unit rebuilds — visual children despawned,
/// the attach trigger re-armed (`VisualAttached` off, `Reattached` on), the marker consumed
/// — but ONLY when the palette has real headroom; against a still-tight table the healer
/// waits (rebuilding into it would just re-starve).
#[test]
fn a_starved_unit_rebuilds_when_the_table_has_room_and_waits_when_it_has_not() {
let mut app = App::new();
app.init_resource::<crate::rig_palette::RigPalettes>();
app.add_systems(Update, heal_rig_starved);
let child = app.world_mut().spawn_empty().id();
let unit = app
.world_mut()
.spawn((
crate::rig_palette::RigStarved,
VisualAttached,
crate::net::Guid(0xB0B),
AppliedDisplay(Some(7)),
))
.add_child(child)
.id();
// Choke the table under the heal's minimum headroom: the unit is left alone.
let hoard: Vec<crate::rig_palette::RigSkin> = {
let mut palettes = app
.world_mut()
.resource_mut::<crate::rig_palette::RigPalettes>();
(0..(crate::mesh_tag::MAX_RIG_SLOTS - 1 - HEAL_MIN_HEADROOM / 2))
.filter_map(|_| {
crate::rig_palette::RigSkin::allocate_bones(&mut palettes, 1, Handle::default())
})
.collect()
};
app.update();
assert!(
app.world().entity(unit).contains::<VisualAttached>(),
"no headroom ⇒ the healer waits"
);
// Room opens (the reaper's doing, in the live app): the rebuild teardown lands.
{
let mut palettes = app
.world_mut()
.resource_mut::<crate::rig_palette::RigPalettes>();
for rig in hoard.iter().take(HEAL_MIN_HEADROOM) {
let slot = rig.slot;
palettes.free(slot);
}
}
app.update();
let e = app.world().entity(unit);
assert!(!e.contains::<VisualAttached>(), "attach re-armed");
assert!(
!e.contains::<crate::rig_palette::RigStarved>(),
"marker consumed"
);
assert!(
e.contains::<super::super::equipment::Reattached>(),
"fade-skipped rebuild — a heal is not a spawn"
);
assert!(
app.world().get_entity(child).is_err(),
"the old visual's children despawned"
);
}
/// The ease's shape is the reference's (`0x614bbf`): starts at `from`, cosine-smooth (half-way
/// in value at half-way in time), lands exactly on `to` at 2 s and holds.
#[test]
-2
View File
@@ -191,7 +191,6 @@ pub(super) fn spawn_wmo_gameobject_props(
mut uv_reg: ResMut<crate::doodad_anim::UvAnimMaterials>,
mut tint_reg: ResMut<crate::doodad_anim::TintAnimMaterials>,
mut probes: ResMut<PropProbes>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
time: Res<Time>,
mut hosts: Query<(Entity, &GlobalTransform, &mut WmoProps)>,
) {
@@ -267,7 +266,6 @@ pub(super) fn spawn_wmo_gameobject_props(
&mut commands,
&mut entity_mats.0,
&mut materials,
&mut palettes,
light,
&m.submeshes,
crate::model_forms::FormSlices {
+14 -12
View File
@@ -13,10 +13,10 @@
//! `WorldObject` under the cursor, found by ray-casting against the **actual mesh geometry** (so it
//! works on colliderless props — most doodads, including the campfire — which a physics raycast
//! misses).
//! - the **inspector surface** ([`inspect_ui`]) — a standalone, key-toggleable overlay (**Ctrl+Cmd+I**): a
//! weak "armed" pill plus a small identity card that follows the cursor over any picked object. It's
//! its own surface, *not* a section of the backtick debug panel, so identifying a thing costs one
//! chord and no panel.
//! - the **inspector surface** ([`inspect_ui`]) — a standalone, key-toggleable overlay (**the dev
//! chord + `I`**): a weak "armed" pill plus a small identity card that follows the cursor over any
//! picked object. It's its own surface, *not* a section of the backtick debug panel, so
//! identifying a thing costs one chord and no panel.
//! - the **cast journal** ([`journal`]) — the *temporal* half of the same instrument: a spell is an
//! event, gone before a cursor could reach it, so every cast edge is recorded as it flows past and
//! the same inspector overlay lists the recent ones, click-to-copy.
@@ -31,7 +31,7 @@ use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass};
use crate::debug_panel::{overlay_text, ModelKind, OVERLAY_FILL, OVERLAY_TEXT_DIM};
use crate::debug_panel::{overlay_text, ModelKind, DEV_CHORD, OVERLAY_FILL, OVERLAY_TEXT_DIM};
use crate::net::ObjectStore;
use crate::player::WorldCamera;
use crate::ui_script::PointerOverUi;
@@ -109,7 +109,7 @@ pub struct WorldRightClick;
#[derive(Message, Clone, Copy)]
pub struct WorldRightPress;
/// Whether mouseover picking runs. Today it's armed/disarmed by the **Ctrl+Cmd+I** inspector toggle
/// Whether mouseover picking runs. Today it's armed/disarmed by the **dev chord + `I`** inspector toggle
/// ([`toggle_inspect`]).
#[derive(Resource, Default)]
pub struct InspectMode {
@@ -354,9 +354,9 @@ pub(crate) fn ray_aabb(origin: Vec3, dir: Vec3, min: Vec3, max: Vec3) -> Option<
(tmax >= tmin.max(0.0)).then_some(tmin.max(0.0))
}
/// **Ctrl+Cmd+I** (for *inspect*) arms/disarms the inspector — the dev-instrument chord, off the
/// bare-letter plane the game's own bindings own (decision 0585). Unmistakable as a chord, so unlike
/// the old bare `i` it needs no chat-bar/EditBox gate.
/// The **dev chord + `I`** (for *inspect*) arms/disarms the inspector — off the bare-letter plane the
/// game's own bindings own (0585), on whichever plane this OS leaves free (0867). Unmistakable as a
/// chord, so unlike the old bare `i` it needs no chat-bar/EditBox gate.
fn toggle_inspect(keys: Res<ButtonInput<KeyCode>>, mut inspect: ResMut<InspectMode>) {
if crate::debug_panel::dev_chord(&keys, KeyCode::KeyI) {
inspect.enabled = !inspect.enabled;
@@ -454,7 +454,9 @@ fn inspect_ui(
overlay_text(ui);
// Spelled out, not ⌃⌘ — egui's default font stack has no glyph for U+2303 and
// would draw tofu.
ui.label(egui::RichText::new("inspect · ctrl+cmd+I to exit").small());
ui.label(
egui::RichText::new(format!("inspect · {DEV_CHORD}+I to exit")).small(),
);
});
});
@@ -711,7 +713,7 @@ fn inspect_ui(
}
/// Registers the mouseover foundation (the [`MouseoverTarget`] + [`InspectMode`] resources and the
/// per-frame pick), the standalone Ctrl+Cmd+I inspector surface, and the cast journal (recording
/// per-frame pick), the standalone dev-chord `I` inspector surface, and the cast journal (recording
/// always, drawing under the same toggle).
pub struct InteractPlugin;
@@ -725,7 +727,7 @@ impl Plugin for InteractPlugin {
.add_message::<WorldRightPress>()
// After the UI keyboard feed because `update_mouseover` reads `PointerOverUi`, whose
// player-UI half `UiInput` writes — the pick must see this frame's hover, not last
// frame's. (`toggle_inspect` itself no longer needs the ordering: its Ctrl+Cmd+I chord
// frame's. (`toggle_inspect` itself no longer needs the ordering: its dev chord
// can't be typed text, so it reads no keyboard-capture flag — decision 0585.)
.add_systems(
Update,
+9
View File
@@ -181,6 +181,15 @@ pub(crate) fn rig_of(tag: u32) -> u16 {
((tag & RIG_MASK) >> RIG_SHIFT) as u16
}
/// Rewrite a tag's rig field in place, preserving every other field and both flag bits — the
/// lazy-rig promote/demote writer (decision 0863): a doodad part's slot arrives at its first
/// draw-wake and leaves under table pressure, and neither edge may touch the law payload, the
/// fade alpha, or the shade/probe field. "Written once at spawn" (the module doc) remains the
/// rule for every OTHER lane; this is the one sanctioned re-writer, and it owns only its field.
pub(crate) fn with_rig(tag: u32, slot: u16) -> u32 {
(tag & !RIG_MASK) | rig_bits(slot)
}
/// A fade alpha as `MeshTag` bits: 6-bit fraction in the low field, everything else zero. Handles
/// the shader's `0`-sentinel: `MeshTag == 0` means *untagged ⇒ opaque 1.0* in `wow_model.wgsl`,
/// so a true zero (or negative) alpha returns `1` (≈1/63, near-invisible) instead of an
+6 -3
View File
@@ -598,9 +598,12 @@ impl FadeMaterials {
}
/// The material handles one batch fades *through*, as a spawn site borrows them — the inputs
/// [`FadeMaterials`] is built from. `blend: None` means the batch cannot feather at all (a MULTIPLY
/// batch's blend equation reads no alpha — decision 0528 — and a WMO-display batch builds no twin),
/// which is the one legitimate reason for a part to spawn opaque while its unit is still ramping.
/// [`FadeMaterials`] is built from. `blend: None` means the batch cannot feather at all (a
/// WMO-display batch builds no twin), which is the one legitimate reason for a part to spawn
/// opaque while its unit is still ramping. A MULTIPLY batch (Mod/Mod2x) passes its **steady self**
/// as the twin: its blend equation reads no alpha (decision 0528), so no material swap can feather
/// it — instead it arms the ramp like any other part and `wow_model.wgsl` lerps its colour toward
/// the blend identity by the tag alpha (the deliberate deviation of decision 0865).
pub(crate) struct FadeSet<'a> {
pub(crate) steady: &'a Handle<WowModelMaterial>,
pub(crate) blend: Option<&'a Handle<WowModelMaterial>>,
+33 -17
View File
@@ -18,8 +18,8 @@ use std::collections::{HashMap, VecDeque};
use std::time::Instant;
use benilla_protocol::{
messages::WhoRequest, EntityKind, JumpInfo, MoveSpeeds, ObjectFields, SessionEvent, SpeedKind,
TransportPose,
messages::WhoRequest, EntityKind, JumpInfo, MoveMode, MoveSpeeds, ObjectFields, SessionEvent,
SpeedKind, TransportPose,
};
use bevy::prelude::*;
use crossbeam_channel::{Receiver, Sender};
@@ -88,6 +88,7 @@ impl Plugin for NetPlugin {
.add_message::<TeleportMessage>()
.add_message::<SelfMoveMessage>()
.add_message::<SpeedChangeMessage>()
.add_message::<MoveModeMessage>()
.add_message::<ServerSoundMessage>()
.add_message::<WeatherMessage>()
.add_message::<EmoteMessage>()
@@ -952,23 +953,20 @@ pub(crate) enum ClientCommand {
/// the flying cinematic camera (a first login's race intro) and every NPC around the body
/// despawns until relog. A future cinematic arc moves this send to the playback's end.
CompleteCinematic,
/// Acknowledge a server root/unroot on our mover (`CMSG_FORCE_MOVE_[UN]ROOT_ACK`, decision
/// 0308): the echoed `counter` + our live pose. Sent by the controller the frame the
/// [`crate::death::MoveRootMessage`] lands; un-acked, observers never see the change.
MoveRootAck {
/// **Acknowledge a granted mover mode** — root, water-walk, feather-fall or hover (decisions
/// 0308, 0866): the echoed `counter` + our live pose, on the opcode
/// [`MoveMode::ack_opcode`] picks. Sent by the controller the frame the
/// [`MoveModeMessage`] lands; un-acked, the server never applies the change and observers never
/// see it.
///
/// `flags` must already carry the applied mode bit — the controller ORs it in before sending
/// (vmangos KICKS a root-apply ack whose `MovementInfo` lacks `MOVEFLAG_ROOT`, and reads the
/// word as the mover's new flags for the other three).
MoveModeAck {
guid: u64,
counter: u32,
rooted: bool,
flags: u32,
pos: [f32; 3],
orientation: f32,
},
/// Acknowledge a water-walk grant/removal (`CMSG_MOVE_WATER_WALK_ACK` — one opcode both ways,
/// the trailing apply flag distinguishes). The ghost form's walk-on-water (decision 0308).
WaterWalkAck {
guid: u64,
counter: u32,
on: bool,
mode: MoveMode,
apply: bool,
flags: u32,
pos: [f32; 3],
orientation: f32,
@@ -1201,6 +1199,24 @@ pub(crate) struct SpeedChangeMessage {
pub(crate) speed: f32,
}
/// **The server granted or revoked a mover mode** on our own mover — root, water-walk, feather-fall
/// or hover (the ack'd movement-mode family; decision 0866). Sibling of [`SpeedChangeMessage`] in
/// every respect: written by [`apply_net_updates`] for our guid only, read by the player controller,
/// which is the one place that owns both the mode state and the honest pose the ack must carry.
///
/// This used to be two death-arc-shaped messages (`MoveRootMessage`/`WaterWalkMessage` in
/// `crate::death`), which is why the other two modes were never noticed as missing: root arrived at
/// death, water-walk arrived in ghost form, and nothing framed them as one family. They are one —
/// the server's own `IsFlagAckOpcode` set.
#[derive(Message, Clone, Copy)]
pub(crate) struct MoveModeMessage {
/// Our mover's guid (the bridge only emits ours) — echoed in the ack body.
pub(crate) guid: u64,
pub(crate) counter: u32,
pub(crate) mode: MoveMode,
pub(crate) apply: bool,
}
/// A cross-map worldport (`.tele Orgrimmar`, initial-login map, a boat crossing the sea): snap
/// the avatar, swap the map's ADTs, and ack if required. Written by [`apply_net_updates`], read
/// by the player controller.
+15 -13
View File
@@ -80,10 +80,9 @@ pub(super) fn apply_net_updates(
MessageWriter<EnteredWorldMessage>,
MessageWriter<LoggedOutMessage>,
MessageWriter<super::SpeedChangeMessage>,
// The death arc's controller-facing acks (decision 0308): the server root/water-walk
// changes the player controller answers with its live pose.
MessageWriter<crate::death::MoveRootMessage>,
MessageWriter<crate::death::WaterWalkMessage>,
// The ack'd movement-mode family (decisions 0308, 0866): a mode the server granted our
// mover, which the player controller applies and answers with its live pose.
MessageWriter<super::MoveModeMessage>,
// The login screen's dialog + reconnect-policy feed (decision 0539).
MessageWriter<super::LoginStageMessage>,
MessageWriter<super::LoginFailedMessage>,
@@ -210,11 +209,13 @@ pub(super) fn apply_net_updates(
MessageWriter<crate::creature_anim::MountFlourish>,
// The UNIT_COMBAT event feed (the portrait hit indicator, decision 0576) + the
// COMBAT_TEXT_UPDATE feed (the center combat text, decision 0578) — the spell arms'
// self-facing twins of the floating-text spawn. Nested pair: the tuple is at the
// self-facing twins of the floating-text spawn — and the sheath setter's queue, which the
// ATTACKERSTATEUPDATE arm's melee auto-draw writes into. Nested: the tuple is at the
// 16-param ceiling.
(
MessageWriter<crate::ui_unit::UnitCombatFeedback>,
MessageWriter<crate::ui_unit::CombatTextEvent>,
MessageWriter<crate::creature_anim::SheathRequest>,
),
),
// The aura feed's duration side-table + the clock to stamp arrivals (decisions 0255/0257): the
@@ -294,8 +295,7 @@ pub(super) fn apply_net_updates(
mut entered_world,
mut logged_out,
mut speed_changes,
mut move_roots,
mut water_walks,
mut move_modes,
mut login_stages,
mut login_failures,
mut disconnects,
@@ -818,18 +818,19 @@ pub(super) fn apply_net_updates(
SessionEvent::DurabilityDamageDeath => {
death::durability_damage_death(&mut ui_actions.14)
}
SessionEvent::MoveRoot {
SessionEvent::MoveMode {
guid,
counter,
rooted,
} => death::move_root(guid, counter, rooted, &self_guid, &mut move_roots),
SessionEvent::WaterWalk { guid, counter, on } => death::water_walk(
mode,
apply,
} => death::move_mode(
guid,
counter,
on,
mode,
apply,
&self_guid,
&mut death_net,
&mut water_walks,
&mut move_modes,
),
SessionEvent::ItemTemplate { entry, info } => {
item_template(entry, info.map(|b| *b), &mut items)
@@ -850,6 +851,7 @@ pub(super) fn apply_net_updates(
&mut audio.3,
&mut audio.8,
&mut audio.15 .1,
&mut audio.15 .2,
play_seq.next(),
),
SessionEvent::SpellDamageLog(s) => combat_log::spell_damage_log(
+20 -1
View File
@@ -6,7 +6,9 @@
use benilla_protocol::messages::AttackerState;
use bevy::prelude::*;
use crate::creature_anim::{Engaged, RangedHold, SwingFlush, SwingImpact, SwingMessage};
use crate::creature_anim::{
Engaged, RangedHold, SheathRequest, SwingFlush, SwingImpact, SwingMessage,
};
use crate::ui_unit::CombatTextEvent;
use super::super::{AiReactionMessage, GuidIndex, SelfGuid};
@@ -77,6 +79,7 @@ pub(super) fn attacker_state(
swings: &mut MessageWriter<SwingMessage>,
impacts: &mut MessageWriter<SwingImpact>,
center: &mut MessageWriter<CombatTextEvent>,
sheaths: &mut MessageWriter<SheathRequest>,
seq: u64,
) {
let victim = index.0.get(&s.victim).copied();
@@ -124,6 +127,22 @@ pub(super) fn attacker_state(
seq,
};
if let Some(&e) = index.0.get(&s.attacker) {
// The **observed attacker auto-draws melee** — the ref's SECOND melee draw, independent
// of the attack-start one, and the reason a swing is never delivered in the wrong stance:
// `0x625829 cmp [attacker+0xd40],1; jne` → `SetSheatheState(1, bInstant=1, bFireEvent=1)`
// at `0x62583a`, byte-read here, tabulated in wow-re `sheath-policy.md` §1. It sits
// immediately after the attacker resolve and **before** any hit-info handling, so even a
// swing whose animation is suppressed (`HitInfo & 0x10000`) still draws. Nothing else in
// the policy can do this job: the per-animation reconcile's melee force is gated to
// `CUR != 2` (`0x5fe0f9`/`0x5fe13b`), so a unit swinging with a bow drawn — a ranged
// stance a shot left behind — would otherwise keep swinging with the bow forever. The
// setter's own idempotency is the `cmp`: a request equal to the committed state is
// refused there, so this is free on every swing after the first.
sheaths.write(SheathRequest {
entity: e,
state: 1,
ceremony: false,
});
swings.write(SwingMessage {
attacker: e,
..swing
+25 -30
View File
@@ -1,14 +1,15 @@
//! Death-arc arm bodies for [`super::apply_net_updates`]'s dispatch match (decision 0308) — the
//! wire-fed [`DeathNet`] stores (the corpse marker and its guid latch, the reclaim clock, the
//! resurrect offer, the spirit-healer confirm, the death durability notice) plus the two
//! controller-facing acks the server addresses to our own mover. Each `pub(super)` fn here is
//! resurrect offer, the spirit-healer confirm, the death durability notice) plus the granted
//! movement-mode forward the server addresses to our own mover. Each `pub(super)` fn here is
//! exactly one arm's body — except the two corpse-guid latches, which ride the object-lifecycle
//! arms; the match at the call site stays the dispatcher, one call per arm.
use benilla_protocol::{EntityKind, ObjectFields};
use benilla_protocol::{EntityKind, MoveMode, ObjectFields};
use bevy::prelude::*;
use crate::death::{CorpsePoint, DeathNet, MoveRootMessage, ResurrectOffer, WaterWalkMessage};
use crate::death::{CorpsePoint, DeathNet, ResurrectOffer};
use crate::net::MoveModeMessage;
use crate::ui_items::UiErrorLines;
use super::super::SelfGuid;
@@ -90,36 +91,30 @@ pub(super) fn durability_damage_death(lines: &mut UiErrorLines) {
.push("Your equipped items suffer a 10% durability loss.".to_string());
}
/// `SMSG_FORCE_MOVE_ROOT`/`SMSG_FORCE_MOVE_UNROOT` — the server only addresses our own mover; the
/// guard keeps a stray relay harmless.
pub(super) fn move_root(
/// **The ack'd movement-mode family** (decision 0866) — root, water-walk, feather-fall, hover. The
/// server only ever addresses the controlling client's own mover; the guard keeps a stray relay
/// harmless. Water-walk is additionally mirrored into [`DeathNet`], which reads it as a ghost-form
/// cue — but the *mover* effect of every mode, this one included, is the controller's
/// ([`crate::player::wire_in`]).
pub(super) fn move_mode(
guid: u64,
counter: u32,
rooted: bool,
self_guid: &SelfGuid,
out: &mut MessageWriter<MoveRootMessage>,
) {
if self_guid.0 == Some(guid) {
out.write(MoveRootMessage {
guid,
counter,
rooted,
});
}
}
/// `SMSG_MOVE_WATER_WALK`/`SMSG_MOVE_LAND_WALK` — mirrored into [`DeathNet`] for the swim arc's
/// future mover regime; the ack itself is the controller's.
pub(super) fn water_walk(
guid: u64,
counter: u32,
on: bool,
mode: MoveMode,
apply: bool,
self_guid: &SelfGuid,
death_net: &mut DeathNet,
out: &mut MessageWriter<WaterWalkMessage>,
out: &mut MessageWriter<MoveModeMessage>,
) {
if self_guid.0 == Some(guid) {
death_net.water_walk = on;
out.write(WaterWalkMessage { guid, counter, on });
if self_guid.0 != Some(guid) {
return;
}
if mode == MoveMode::WaterWalk {
death_net.water_walk = apply;
}
out.write(MoveModeMessage {
guid,
counter,
mode,
apply,
});
}
+4 -11
View File
@@ -830,22 +830,15 @@ fn writer_loop(
ClientCommand::Logout => w.logout_request(),
ClientCommand::LogoutCancel => w.logout_cancel(),
ClientCommand::CompleteCinematic => w.complete_cinematic(),
ClientCommand::MoveRootAck {
ClientCommand::MoveModeAck {
guid,
counter,
rooted,
mode,
apply,
flags,
pos,
orientation,
} => w.move_root_ack(guid, counter, rooted, flags, pos, orientation),
ClientCommand::WaterWalkAck {
guid,
counter,
on,
flags,
pos,
orientation,
} => w.water_walk_ack(guid, counter, on, flags, pos, orientation),
} => w.move_mode_ack(guid, counter, mode, apply, flags, (pos, orientation)),
ClientCommand::RepopRequest => w.repop_request(),
ClientCommand::CorpseQuery => w.corpse_query(),
ClientCommand::ReclaimCorpse { corpse } => w.reclaim_corpse(corpse),
+5
View File
@@ -510,6 +510,11 @@ pub(in crate::net) fn extrapolate_remote_units(
from,
vel,
time.delta(),
// No hover offset: a remote's own granted modes are not modelled yet — decision
// 0866 builds the family for our own mover. A hovering *observed* player draws
// a yard low until the relayed `MSG_MOVE_HOVER` is read into per-unit mode
// state; the same shape as every other unmodelled remote mode.
0.0,
)
.center
};
+7 -5
View File
@@ -4,7 +4,8 @@
//! standard). Registers
//! Bevy's diagnostics (frame time / FPS, entity count, render-pass timing), keeps a rolling window of
//! recent real frame durations, and draws a **minimal, always-on FPS pill** (top-center) that you
//! **click to expand** into the full readout; **Ctrl+Cmd+P** toggles the whole HUD. Expanded, it shows:
//! **click to expand** into the full readout; the **dev chord + `P`** toggles the whole HUD
//! (`Ctrl+Shift+P` — decisions 0585/0867/0870). Expanded, it shows:
//! - FPS + last frame ms,
//! - **p50 / p99 / worst** frame ms over the window (worst-frame is the metric that matters, not the
//! average — averages hide the hitch you feel),
@@ -90,7 +91,7 @@ impl Plugin for PerfPlugin {
})
.add_systems(
Update,
// `toggle_hud` needs no ordering against the UI keyboard feed any more: its Ctrl+Cmd+P
// `toggle_hud` needs no ordering against the UI keyboard feed any more: its dev
// chord can't be typed text, so there's no `UiKeyboardCapture` to read (decision 0585).
(toggle_hud, sample_frame_time),
)
@@ -371,8 +372,9 @@ impl FrameStats {
}
}
/// HUD state. **Ctrl+Cmd+P** toggles `visible` (default on — it's a standing dev surface); `expanded` is the
/// click-to-open full readout (default off — a minimal FPS pill until clicked). `visible` is
/// HUD state. The **dev chord + `P`** toggles `visible` (default on — it's a standing dev surface);
/// `expanded` is the click-to-open full readout (default off — a minimal FPS pill until
/// clicked). `visible` is
/// `pub(crate)` so the capture harness ([`crate::capture`]) can force the overlay off for pristine,
/// UI-free screenshots.
#[derive(Resource)]
@@ -392,7 +394,7 @@ impl Default for PerfHud {
}
fn toggle_hud(keys: Res<ButtonInput<KeyCode>>, mut hud: ResMut<PerfHud>) {
// Ctrl+Cmd+P, not a bare `p` — `P` is the reference's TOGGLESPELLBOOK, and a dev instrument
// The dev chord + `P`, not a bare `p` — `P` is the reference's TOGGLESPELLBOOK, and a dev
// doesn't get to squat on a game binding (decision 0585). The chord can't be mistaken for typed
// text, so unlike the old bare key it needs no chat-bar/EditBox gate.
if crate::debug_panel::dev_chord(&keys, KeyCode::KeyP) {
+64 -39
View File
@@ -12,7 +12,7 @@
//! distance (clamped to the vanilla `cameraDistanceMax` range; the camera *glides* to the new
//! distance). A left-drag orbit offset *persists* — the vanilla `cameraSmoothStyle` auto-follow that
//! swung the camera back behind the character while moving is deliberately removed (director's call).
//! `F` toggles free-fly; `Ctrl`+`Cmd`+`G` **lands the avatar where the camera is** ([`land`]).
//! `F` toggles free-fly; the **dev chord + `G`** lands the avatar where the camera is ([`land`]).
//!
//! Movement is a thin kinematic capsule controller over avian's `MoveAndSlide` (decision 0009).
@@ -71,8 +71,8 @@ use state::{
// 0737 — residency releases the hold, not ground contact), which owns the deadline push while the
// resident world is still the departed map's (0710).
pub(crate) use state::{
Player, PlayerCapsule, CAPSULE_HEIGHT, DEFAULT_COLLISION_HEIGHT, GRAVITY, SETTLE_TIMEOUT,
TERMINAL_VELOCITY,
Player, PlayerCapsule, CAPSULE_HEIGHT, DEFAULT_COLLISION_HEIGHT, FEATHER_TERMINAL_VELOCITY,
GRAVITY, HOVER_HEIGHT, SETTLE_TIMEOUT, TERMINAL_VELOCITY,
};
/// The swim boundary `0.75·h` — and therefore the **wade ceiling**, since wading is the implicit
/// in-liquid-but-not-swimming state and the two cannot be different numbers. Read by the creature
@@ -221,10 +221,9 @@ fn control(
MessageReader<WorldportMessage>,
MessageWriter<crate::creature_anim::SheathRequest>,
MessageReader<crate::net::SpeedChangeMessage>,
// The death arc's server movement-flag changes (decision 0308): root at death / unroot +
// water-walk at release — acked here with the live pose.
MessageReader<crate::death::MoveRootMessage>,
MessageReader<crate::death::WaterWalkMessage>,
// The ack'd movement-mode family (decisions 0308, 0866): root / water-walk / feather-fall /
// hover, granted on our mover — applied to `player.modes` and acked here with the live pose.
MessageReader<crate::net::MoveModeMessage>,
// The landing report for the client-side hard-landing predictor (`0x602d00` — wound
// vocal + dust; the consumers gate on the threshold, `creature_anim::env_damage`).
MessageWriter<crate::creature_anim::HardLanding>,
@@ -268,6 +267,9 @@ fn control(
Has<crate::creature_anim::Engaged>,
Option<&crate::net::UnitSpeeds>,
Option<&mut BodyTwist>,
// What is worn in each hand and in the ranged slot — the Z toggle's cycle reads it
// (the ref's three `GetWeapon(0/1/2)` calls before the state walk, below).
Option<&crate::creature_anim::Wielded>,
),
(With<SelfPlayer>, Without<FlyCam>),
>,
@@ -418,10 +420,12 @@ fn control(
&mut net.2,
&mut net.4,
&mut net.5,
&mut net.6,
&mut net.10,
&mut net.9,
transports,
self_player.single().ok().map(|(_, t, ..)| t.translation),
self_player
.single()
.ok()
.map(|(_, t, ..)| (t.translation, server_ride::yaw_of(t.rotation))),
);
let flat = |v: Vec3| Vec3::new(v.x, 0.0, v.z).normalize_or_zero();
@@ -546,7 +550,7 @@ fn control(
keys_just_pressed(KeyCode::KeyW),
keys_just_pressed(KeyCode::KeyS),
both_buttons_engaged,
player.rooted || player.server_riding,
player.modes.rooted || player.server_riding,
) {
player.autorun = false;
}
@@ -629,7 +633,7 @@ fn control(
}
// Rooted (dead-unreleased): translation intent dies here — turning above stays live, the
// real rooted client's behavior (decision 0308 slice 1).
if player.rooted {
if player.modes.rooted {
dir = Vec3::ZERO;
}
let moving = dir != Vec3::ZERO;
@@ -642,7 +646,7 @@ fn control(
let stand_byte = self_player
.single()
.ok()
.and_then(|(.., store, _, _, _)| store.map(|s| s.0.unit_stand_state()))
.and_then(|(.., store, _, _, _, _)| store.map(|s| s.0.unit_stand_state()))
.unwrap_or(0);
if player.stand_pending == Some(stand_byte) {
player.stand_pending = None; // the echo landed
@@ -677,7 +681,7 @@ fn control(
// wow-re `sheath-policy.md` §4): entering any stand-state ∉ {0 STAND, 2 SIT_CHAIR}
// force-stows drawn weapons, through the anim layer's one setter.
if s != 0 && s != 2 {
if let Ok((e, _, _, _, drv, _, _, _, _)) = self_player.single() {
if let Ok((e, _, _, _, drv, _, _, _, _, _)) = self_player.single() {
if drv.and_then(|d| d.sheath_state()).unwrap_or(0) != 0 {
net.3.write(crate::creature_anim::SheathRequest {
entity: e,
@@ -690,7 +694,7 @@ fn control(
}
let stand_now = player.stand_pending.unwrap_or(stand_byte);
// Sheath toggle (Z) — vanilla's draw/stow, through the anim layer's ONE setter
// ([`crate::creature_anim::SheathRequest`], decision 0080): flip the *committed*
// ([`crate::creature_anim::SheathRequest`], decision 0080): walk the *committed*
// client-side state (the setter cache — attacking auto-draws and the anim reconcile
// force-stows, which a local bool or the raw echo byte would drift from), commit + send
// `CMSG_SETSHEATHED` there, and play the ceremony — the manual toggle is the ONLY path
@@ -698,7 +702,8 @@ fn control(
// `sheath-policy.md`). No body model yet (no driver) drops the toggle, the client's own
// refusal.
if bare_binding(KeyCode::KeyZ) {
if let Ok((e, _, _, _, Some(drv), store, engaged, _, _)) = self_player.single() {
if let Ok((e, _, _, _, Some(drv), store, engaged, _, _, wielded)) = self_player.single()
{
// The manual toggle's guard chain (decision 0080d) — the guards of the client's
// 12-deep silent-refusal chain (`ToggleSheath` `0x5eb480`) whose states exist
// today: dead · engaged in combat · not standing (`GetStandState() != 0` —
@@ -718,12 +723,22 @@ fn control(
drv.sheath_ceremony_active()
);
} else {
let drawn = drv.sheath_state().unwrap_or(0) != 0;
net.3.write(crate::creature_anim::SheathRequest {
entity: e,
state: u8::from(!drawn),
ceremony: true,
});
// The cycle proper ([`crate::creature_anim::toggle_sheath_next`], byte-read):
// melee → ranged → stowed, gated on what is actually worn — never a
// two-state flip. `None` = the ref makes no call at all (nothing equipped).
let w = wielded.copied().unwrap_or_default();
let worn = (w.main.is_some() || w.off.is_some(), w.ranged.is_some());
let next = crate::creature_anim::toggle_sheath_next(
drv.sheath_state().unwrap_or(0),
worn,
);
if let Some(state) = next {
net.3.write(crate::creature_anim::SheathRequest {
entity: e,
state,
ceremony: true,
});
}
}
}
}
@@ -756,7 +771,7 @@ fn control(
} else {
run_speed
};
let mut want_jump = keys_just_pressed(KeyCode::Space) && !player.rooted;
let mut want_jump = keys_just_pressed(KeyCode::Space) && !player.modes.rooted;
// Swim vs walk: the water over our feet decides. Hysteresis-latched (`update_swimming`,
// the verified `0x6030c0` boundary — B7 resolved, decision 0226) so wading the line
@@ -807,7 +822,7 @@ fn control(
}
// Rooted kills swim translation like the walk `dir` above (decision 0308's regime —
// the water arm reads raw keys, so it needs its own cut).
if player.rooted {
if player.modes.rooted {
swim_fwd = 0.0;
swim_side = 0.0;
}
@@ -826,12 +841,12 @@ fn control(
// (TU-F: Space is the Jump command; it is NOT a pitch or ascend input) — and never
// reaches this walk-side gate.
if want_jump && !moving && !swimming && player.airborne_since.is_none() {
if let Ok((e, .., store, _, _, _)) = self_player.single() {
if let Ok((e, .., store, _, _, _, _)) = self_player.single() {
if store.is_some_and(|s| s.0.unit_mount_display_id() != 0) {
want_jump = false;
if !turning {
let _ = net.0 .0.send(ClientCommand::MountSpecial);
net.9.write(crate::creature_anim::MountFlourish { unit: e });
net.8.write(crate::creature_anim::MountFlourish { unit: e });
}
}
}
@@ -899,7 +914,7 @@ fn control(
// gain height. Otherwise fall back to the current feet Y if the surface query briefly
// misses (a chunk seam): the cap is then at our own depth, so the avatar just holds for
// that frame — a transient miss in real water is not the same thing as flight.
let rest_line = (!player.levitating).then(|| surface_y.unwrap_or(player.pos.y));
let rest_line = (!player.modes.levitating).then(|| surface_y.unwrap_or(player.pos.y));
// Directional swim speed — **VERIFIED** (`0x7c4c90`'s swim arm, the §5's TU-H):
// forward or strafe-only → swim; the backward bit `0x2` → `min(swimBack, swim)` —
// byte-identical in template to the run arm's `min(runBack, run)`. Vanilla defaults
@@ -946,6 +961,16 @@ fn control(
} else {
// The kinematic mover step — walk/fall physics + the step-down snap (decisions
// 0009/0182/0190); the mechanism lives in [`mover`].
// **Water walking** (decision 0866): hand the mover the liquid surface as a floor. The
// `!swimming` half is the reference's own gate — the water-walk arm at `0x63160d` is
// skipped when `MOVEFLAG_SWIMMING` is set (`0x631617`) — so granting the aura to a
// submerged caster does not eject them; they surface onto the water on the way out.
// (This branch is already the non-swimming one, so `swimming` is false here; the
// condition is written out because the gate is the mechanism, not an accident of
// control flow.)
let water_floor = (player.modes.water_walking && !swimming)
.then_some(surface_y)
.flatten();
mover::step(
&mut player,
&time,
@@ -955,6 +980,7 @@ fn control(
dir,
speed,
want_jump,
water_floor,
)
};
@@ -1033,15 +1059,14 @@ fn control(
// animation (below) *and* the movement stream we send the server (further down), so the two can
// never disagree. Direction bits mirror the client's MOVEMENTFLAGS; FALLING marks the airborne
// arc (animation-only — it is masked off before going on the wire, see the send block).
let mut move_flags_now = 0u32;
// The granted mover mode rides every packet, in or out of the water — the reference's
// builder reads the one `[cmov+0x40]` the server's merge wrote it into, so it echoes back
// whatever the server granted (decision 0726). Ours has to put it back explicitly, because
// this word is rebuilt from state each frame; dropping it would make the server forget we
// are flying and the next server-authored move would clear the mode under us.
if player.levitating {
move_flags_now |= move_flags::LEVITATING;
}
// **Every granted mover mode rides every packet**, in or out of the water — the reference's
// builder reads the one `[cmov+0x40]` the server's merge wrote them into, so it echoes back
// whatever was granted for free (decisions 0726, 0866). Ours has to put them back
// explicitly, because this word is rebuilt from state each frame; drop one and the server
// forgets the mode, then the next server-authored move echoes a mode-less word back and
// clears it under us. Root rides too — moving bits are what must not accompany it, and
// rooted input can't produce any (`dir` is zeroed above, jumps refused).
let mut move_flags_now = player.modes.wire_flags();
// `landed`/`started_falling` gate the wire's jump/fall lifecycle; the swim branch never sets
// them (leaving the water resumes the ground mover from rest, no airborne report).
let landed;
@@ -1174,7 +1199,7 @@ fn control(
// `CAM_PIVOT_FLOOR`). Read the avatar's model-local pivot + its live scale here (where we hold the
// self entity); until the body attaches (no `CameraPivot` yet) fall back to a human neck height.
let mut cam_pivot_height = CAM_PIVOT_FALLBACK;
if let Ok((entity, mut t, motion, pivot, .., twist)) = self_player.single_mut() {
if let Ok((entity, mut t, motion, pivot, .., twist, _)) = self_player.single_mut() {
t.translation = player.pos;
// The swim body pitch (TU-A, `0x60a110`→`0x710620`): while swimming AND moving fwd/back
// the model root renders `Rz(yaw)·Ry(−pitch)` — in Bevy axes, the yaw then a nose-up
@@ -1196,7 +1221,7 @@ fn control(
// same way). `fall_start_y` still holds this arc's launch height here (it is only
// re-seeded at the next take-off).
if landed {
net.7.write(crate::creature_anim::HardLanding {
net.6.write(crate::creature_anim::HardLanding {
entity,
descent: player.fall_start_y - player.pos.y,
});
@@ -1260,7 +1285,7 @@ fn control(
|| jumped
|| autorun_armed
{
net.8 .0 = true;
net.7 .0 = true;
}
// Stream this frame's movement to the server — a `MSG_MOVE_*` per movement-axis transition, the
+1 -1
View File
@@ -31,7 +31,7 @@ use super::camera::WorldCamera;
use super::state::Player;
/// Ask to land the avatar at the free-flying camera. Written by the debug panel's **land here**
/// button; the `Ctrl`+`Cmd`+`G` chord is read directly by [`land_here`].
/// button; the dev chord's `G` is read directly by [`land_here`].
#[derive(Message)]
pub(crate) struct LandHere;
@@ -59,6 +59,15 @@ const OUTBOUND_FLAG_MASK: u32 = move_flags::FORWARD
// server-authored move (a `.go forward`, a forced speed change) echoes a LEVITATING-less word
// back at us, our merge clears the mode, and we fall out of the sky mid-flight.
| move_flags::LEVITATING
// …**and the rest of the granted movement-mode family** (decision 0866) — root, water-walk,
// safe-fall, hover: the same reasoning one step stronger. These four are the *ack'd* modes, so
// the server holds an explicit record of granting each one and notices our stream disagreeing.
// `MOVEFLAG_ROOT` especially: vmangos re-adds it to anything it writes for a rooted mover
// (`MovementHandler.cpp:1064`), and its absence from an apply-ack is a kick.
| move_flags::ROOT
| move_flags::WATER_WALKING
| move_flags::SAFE_FALL
| move_flags::HOVER
| move_flags::ON_TRANSPORT;
/// This frame's **arc edges** — the airborne lifecycle as the send law reads it. One struct rather
+70 -12
View File
@@ -32,9 +32,10 @@ use bevy::prelude::*;
use crate::collision::player_query_filter;
use super::{
move_trace, Player, AIR_NUDGE_SPEED, CAPSULE_HEIGHT, GRAVITY, GROUND_COS, GROUND_PROBE,
JUMP_SPEED, LAND_PROBE, SKIN_WIDTH, STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_HEIGHT,
TERMINAL_VELOCITY, WEDGE_MIN_FALL, WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
move_trace, Player, AIR_NUDGE_SPEED, CAPSULE_HEIGHT, FEATHER_TERMINAL_VELOCITY, GRAVITY,
GROUND_COS, GROUND_PROBE, HOVER_HEIGHT, JUMP_SPEED, LAND_PROBE, SKIN_WIDTH, STEP_SLOPE_RATIO,
STEP_SNAP_SLACK, STEP_UP_HEIGHT, TERMINAL_VELOCITY, WEDGE_MIN_FALL, WEDGE_STALL_RATIO,
WEDGE_STILL_FRAMES,
};
/// What the step decided — read by the move-flags / wire logic that follows it in `control`.
@@ -67,6 +68,7 @@ pub(super) fn step(
dir: Vec3,
speed: f32,
want_jump: bool,
water_floor: Option<f32>,
) -> Outcome {
let dt = time.delta_secs();
let input_horiz = if moving {
@@ -88,11 +90,20 @@ pub(super) fn step(
// ([`LAND_PROBE`], ~skin scale). The wider walking probe would end the arc up to 0.2 yd
// early and close the gap with a same-frame snap — the visible pop at every silent landing
// (decision 0190); the fall's own collision already stops the capsule exactly at contact.
let ground_reach = if player.airborne_since.is_some() {
LAND_PROBE
// A hovering body rests [`HOVER_HEIGHT`] above the floor (decision 0866), so every downward
// reach that decides "am I standing on something" has to grow by the same amount — otherwise
// the float reads as airborne and it falls, which is the hover bit doing nothing at all.
let hover_offset = if player.modes.hover {
HOVER_HEIGHT
} else {
GROUND_PROBE
0.0
};
let ground_reach = hover_offset
+ if player.airborne_since.is_some() {
LAND_PROBE
} else {
GROUND_PROBE
};
let classify = probe_down(center, ground_reach);
let on_walkable = classify.as_ref().is_some_and(|h| h.normal1.y >= GROUND_COS);
// Who we stand on (frame start); the end-of-frame snap probe below refreshes it post-move.
@@ -115,7 +126,11 @@ pub(super) fn step(
// The wedged rest (decision 0211) stands until real ground takes over or the support
// vanishes — we walked off the funnel wall into open air, which resumes a normal fresh fall.
if player.wedged && (on_floor || held || probe_down(center, LAND_PROBE).is_none()) {
// Its own reach stays [`LAND_PROBE`] (not the classify reach above), plus the hover offset so a
// hovering wedge is not read as having lost its support the moment the mode lands.
if player.wedged
&& (on_floor || held || probe_down(center, LAND_PROBE + hover_offset).is_none())
{
player.wedged = false;
}
let grounded = on_floor || player.wedged;
@@ -133,7 +148,18 @@ pub(super) fn step(
jumped = true;
}
} else {
player.vel_y = (player.vel_y - GRAVITY * dt).max(-TERMINAL_VELOCITY);
// **Feather fall is a terminal-velocity substitution, and nothing else** (decision 0866).
// The reference's gravity integrate `0x7c5d20` picks its clamp from one flag test
// (`0x7c5d23 test [ecx+0x40], 0x20000000`) — the ordinary 60.148 or 7.0 under
// `MOVEFLAG_SAFE_FALL`. Gravity itself is unchanged, so a Slow Fall still *accelerates*
// normally for the first ~0.36 s and only then rides the cap: the drop starts like any
// other and settles into a drift, which is what Slow Fall looks like.
let terminal = if player.modes.feather_fall {
FEATHER_TERMINAL_VELOCITY
} else {
TERMINAL_VELOCITY
};
player.vel_y = (player.vel_y - GRAVITY * dt).max(-terminal);
}
let mut air_nudged = false;
if grounded {
@@ -152,7 +178,15 @@ pub(super) fn step(
// frames keep their own slide here: no step-up, no snap, and gravity in the velocity.
let (mut climb, mut snap_probe) = (None, None);
if !held && grounded && !jumped {
let g = grounded_step(ms, capsule, &filter, center, player.horiz_vel, time.delta());
let g = grounded_step(
ms,
capsule,
&filter,
center,
player.horiz_vel,
time.delta(),
hover_offset,
);
center = g.center;
climb = g.climb;
snap_probe = g.snap;
@@ -204,7 +238,24 @@ pub(super) fn step(
}
// The frame that detects the wedge reports grounded immediately, so the falling pose ends
// and the wire sees a normal landing (`MSG_MOVE_FALL_LAND`) this frame, not next.
let grounded = grounded || player.wedged;
let mut grounded = grounded || player.wedged;
// **Water walking: the liquid surface IS the floor** (decision 0866). `water_floor` is the
// surface Y the caller resolved, and it is `Some` only while the mode is granted AND we are not
// already swimming — the reference's own gate, read at `0x631617` (`test eax,0x200000; jne`)
// right after the water-walk test: a caster who is already submerged keeps swimming, and only
// surfaces onto the water once out of it. Liquid is not a collider here (it is queried, not
// swept), so it cannot come out of the probes above; it lands as a floor clamp instead — the
// body may not sink past it, and resting on it is being grounded, which ends any arc.
if let Some(surface) = water_floor {
let feet = center.y - half_h.y;
if feet <= surface {
center.y = surface + half_h.y;
player.vel_y = 0.0;
player.wedged = false;
grounded = true;
}
}
player.pos = center - half_h;
move_trace::frame(move_trace::Frame {
@@ -270,6 +321,7 @@ pub(crate) fn grounded_step(
center: Vec3,
horiz_vel: Vec3,
dt: std::time::Duration,
surface_offset: f32,
) -> GroundedStep {
let cast = |from: Vec3, disp: Vec3| {
ms.cast_move(capsule, from, Quat::IDENTITY, disp, SKIN_WIDTH, filter)
@@ -334,13 +386,19 @@ pub(crate) fn grounded_step(
// *idle* body every frame: the small float a raw wire Z leaves a watched player standing on
// our terrain is taken out here, the same way [`crate::net::motion::ground_clamp_creatures`]
// takes it out of an idle NPC.
// `surface_offset` is HOVER (decision 0866): the reference's WALK resolver `0x6367b0` adds
// `[0x7ff9d8]` = 1.0 to this same surface offset while `MOVEFLAG_HOVER` is set, and widens the
// step-down reach by the same yard (`0x633e35`) so the float still follows the ground down.
// Both halves are here: the reach grows by the offset, and the snap stops that far short of the
// floor. Zero for everyone not hovering, which is the ordinary case and unchanged.
let d = slid - center;
let reach = d.x.hypot(d.z) * STEP_SLOPE_RATIO + STEP_SNAP_SLACK + CAPSULE_HEIGHT;
let reach =
d.x.hypot(d.z) * STEP_SLOPE_RATIO + STEP_SNAP_SLACK + CAPSULE_HEIGHT + surface_offset;
let hit = cast(slid, Vec3::NEG_Y * reach);
let snap = Some((reach, hit.as_ref().map(|h| (h.distance, h.normal1.y))));
let mut ground = None;
if let Some(h) = hit.filter(|h| h.normal1.y >= GROUND_COS) {
slid.y -= h.distance;
slid.y -= h.distance - surface_offset;
ground = Some(h.entity);
}
GroundedStep {
+6 -5
View File
@@ -29,11 +29,12 @@ use crate::net::{ClientCommand, NetCommands, SelfPlayer, Spline};
use super::Player;
/// Extract the Bevy Y-yaw of a facing quaternion. `sample_splines` writes the self entity's rotation
/// as `Quat::from_rotation_y(facing)` (a pure Y turn), and benilla's Bevy yaw equals the WoW
/// orientation (decision 0002), so this recovers both the controller's `face_yaw` and the wire
/// orientation for the ack.
fn yaw_of(rotation: Quat) -> f32 {
/// Extract the Bevy Y-yaw of a facing quaternion. The net bridge and `sample_splines` both write
/// the self entity's rotation as `Quat::from_rotation_y(facing)` (a pure Y turn), and benilla's
/// Bevy yaw equals the WoW orientation (decision 0002), so this recovers both the controller's
/// `face_yaw` and the wire orientation. Shared with the take-control edge (`wire_in`), which adopts
/// the streamed spawn pose's facing the same way.
pub(super) fn yaw_of(rotation: Quat) -> f32 {
rotation.to_euler(EulerRot::YXZ).0
}
+274 -17
View File
@@ -4,6 +4,7 @@
//! the concern modules beside it (`mover`, `swim`, `arc`, `movement_net`, …) all borrow from here.
use avian3d::prelude::*;
use benilla_protocol::MoveMode;
use bevy::prelude::*;
/// Backpedal speed as a fraction of run: vanilla `MOVE_RUN_BACK` 4.5 / `MOVE_RUN` 7.0. Moving backward
@@ -74,6 +75,22 @@ pub(super) const JUMP_SPEED: f32 = 7.955_547;
/// Terminal fall speed (yd/s) — binary-VERIFIED vanilla value (matches vmangos `terminalVelocity`).
/// Shared with [`crate::net`]'s ballistic integration (caps a long fall's vertical speed).
pub(crate) const TERMINAL_VELOCITY: f32 = 60.148_003;
/// **Terminal fall speed under feather fall** (yd/s) — the *whole* of what Slow Fall does. The
/// reference's gravity integrate `0x7c5d20` picks its clamp from one flag test
/// (`0x7c5d23 test [ecx+0x40], 0x20000000`): the ordinary cap `[0x87d894]` = 60.148, or this one
/// `[0x87d898]` when `MOVEFLAG_SAFE_FALL` is set. VERIFIED — wow-re `system/collision/ledger.tsv`
/// (`0x7c5d20`, sweep2 §5) and `scratch/spec-ground.md`'s terminal-vel select.
///
/// The same 7.0 shows up on the server: vmangos raises its anticheat's expected jump speed to
/// `max(current, 7.0f)` on receiving a feather-fall ack (`HandleMovementFlagChangeToggleAck:576`) —
/// it is matching this clamp.
pub(crate) const FEATHER_TERMINAL_VELOCITY: f32 = 7.0;
/// **How far above the ground a hovering body rests** (yd) — the whole of what Hover does. The
/// reference's WALK resolver `0x6367b0` adds `[0x7ff9d8]` = 1.0 to its surface offset iff
/// `MOVEFLAG_HOVER` (`0x40000000`) is set, and the step-down reach widens by the same yard
/// (`0x633e35`). VERIFIED — wow-re `system/collision/collision.md` ("0x40000000 is HOVER, not a wade
/// bit") + `scratch/step-vs-fall-election.md`.
pub(crate) const HOVER_HEIGHT: f32 = 1.0;
/// Standability gate: a surface is walkable iff its normal is within ~50° of straight up (cos 50° —
/// the vanilla threshold). Steeper than this you can't climb and you slide back down.
pub(super) const GROUND_COS: f32 = 0.642_788;
@@ -170,6 +187,128 @@ pub(super) struct MoveSpeed {
pub(super) env_override: bool,
}
/// **The granted mover modes — one system, five bits** (decision 0866).
///
/// A *mode* is a `MOVEMENTFLAGS` bit the **server grants** that changes how our mover behaves rather
/// than where it is heading. They live here as typed fields, not as bits in
/// [`Player::move_flags`], for the reason decision 0726 first hit: `move_flags` is last-streamed
/// wire bookkeeping, rebuilt from state every frame, so a mode parked there alone would be gone
/// before the mover ever read it. The flag word is *rebuilt from these fields* each frame instead —
/// which is also what keeps the server's copy of our mode alive (drop the bit from our stream and
/// the next server-authored move echoes a mode-less word back and clears it under us).
///
/// **Two arrival routes, and the difference matters.** Four of the five come through the *ack'd*
/// family ([`benilla_protocol::MoveMode`]) — an addressed opcode we must answer with the echoed
/// counter or the grant never lands. [`Self::levitating`] does not: it arrives merged out of a
/// server-authored `MSG_MOVE_*` (the `SERVER_AUTHORED` mask), unhandshaked.
///
/// **Each one's effect is the reference's, byte-verified** — the addresses are in
/// [`benilla_protocol::MoveMode`]'s table; here is where each is consumed:
///
/// - [`rooted`](Self::rooted) — translation and jumps die; **turning stays live** ([`super::control`]).
/// - [`feather_fall`](Self::feather_fall) — terminal fall speed becomes [`FEATHER_TERMINAL_VELOCITY`]
/// ([`super::mover::step`]).
/// - [`hover`](Self::hover) — ground contact rises by [`HOVER_HEIGHT`] ([`super::mover::step`]).
/// - [`water_walking`](Self::water_walking) — the liquid surface is walkable ground, and the swim
/// latch cannot arm ([`super::swim`]).
/// - [`levitating`](Self::levitating) — the swim/depth decision is suppressed entirely
/// ([`super::swim::update_swimming`]).
///
/// **Levitate (spell 1706) grants three at once** — feather fall + hover + water walk — which is why
/// they are one struct rather than four unrelated booleans.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) struct MoveModes {
/// The server rooted our mover (`SMSG_FORCE_MOVE_ROOT` — death until release, and every root or
/// stun; decision 0308). Translation input and jumps are dead — the faithful "can't move between
/// death and release" — but **turning stays live**: the reference's `StartTurn 0x7c6c50` has no
/// root gate at all, and `SetRoot 0x7c7340` clears the direction bits exactly once, at apply. A
/// character who cannot even turn is *stunned*, which is a different mechanism.
pub(crate) rooted: bool,
/// Water-walking (`SMSG_MOVE_WATER_WALK`, `SPELL_AURA_WATER_WALK` — Water Walking, Levitate, and
/// the ghost form): the liquid surface counts as walkable ground, so we stand on it instead of
/// sinking, and the swim latch cannot arm underneath us.
pub(crate) water_walking: bool,
/// Feather fall (`SMSG_MOVE_FEATHER_FALL`, `SPELL_AURA_FEATHER_FALL` — Slow Fall, Levitate): the
/// fall integrator's terminal velocity drops to [`FEATHER_TERMINAL_VELOCITY`]. Nothing else
/// changes — the arc, the flags and the landing report are the ordinary ones.
pub(crate) feather_fall: bool,
/// Hover (`SMSG_MOVE_SET_HOVER`, `SPELL_AURA_HOVER` — Levitate): ground contact sits
/// [`HOVER_HEIGHT`] above the surface, so the body floats and walks along a yard up.
pub(crate) hover: bool,
/// **Free flight** (`MOVEFLAG_LEVITATING`, GM `.cheat fly` — decision 0726). The one mode that
/// arrives *unhandshaked*, merged out of a server-authored move ([`super::wire_in`]).
///
/// It does exactly one thing, and it does it by suppression: while set, the water/depth swim
/// decision does not run at all ([`super::swim::update_swimming`] — the reference's
/// `0x6030d2 test ah,4` bail). So a [`Player::swimming`] the server switched on stays on with no
/// water under it — which *is* GM flight — and, symmetrically, real water can no longer put us
/// into swim while it is set.
pub(crate) levitating: bool,
}
impl MoveModes {
/// Grant or revoke one ack'd mode. [`Self::levitating`] is deliberately unreachable here — it
/// has no opcode of its own and arrives through the server-authored flag merge instead.
pub(crate) fn set(&mut self, mode: MoveMode, apply: bool) {
match mode {
MoveMode::Root => self.rooted = apply,
MoveMode::WaterWalk => self.water_walking = apply,
MoveMode::FeatherFall => self.feather_fall = apply,
MoveMode::Hover => self.hover = apply,
}
}
/// The granted modes as `MOVEMENTFLAGS` bits — **what every outbound packet must carry**.
///
/// This is the half that is easy to skip and expensive to skip: the reference has one
/// `[cmov+0x40]` that is both the live state and the wire word, so a real client echoes back
/// whatever the server granted for free. Ours rebuilds the word from state each frame, so
/// dropping a mode here makes the server's copy forget it — and the next server-authored move
/// echoes a mode-less word back, our merge clears the mode, and the aura silently stops working
/// (decision 0726 hit exactly this with GM flight).
pub(crate) fn wire_flags(&self) -> u32 {
use crate::creature_anim::move_flags as f;
let mut flags = 0;
if self.rooted {
flags |= f::ROOT;
}
if self.water_walking {
flags |= f::WATER_WALKING;
}
if self.feather_fall {
flags |= f::SAFE_FALL;
}
if self.hover {
flags |= f::HOVER;
}
if self.levitating {
flags |= f::LEVITATING;
}
flags
}
/// Lift the granted modes **out of a server-authored flag word** into typed state (decision
/// 0726's step, now the whole family's). All five bits sit inside the reference's
/// `SERVER_AUTHORED` merge mask (`0x75a0_7dff`), so a bare `MSG_MOVE_*` the server writes for
/// our mover can grant or revoke them with no handshake — `.cheat fly` is the case that proved
/// it, and it is why LEVITATING has no opcode of its own.
///
/// **Root is a deliberate divergence, and not because the mask excludes it** — bit 12 is inside
/// the mask, so the reference *does* merge root from a server-authored pose. We don't: in this
/// client the ack'd opcode is root's only grant path, and letting a bare pose clear it would put
/// a locally-unrooted body against a server that still has us rooted — which streams moving bits
/// and trips `CHEAT_TYPE_ROOT_MOVE`. The reference gets away with it because vmangos re-adds
/// `MOVEFLAG_ROOT` to anything it writes for a rooted mover (`MovementHandler.cpp:1064`), so its
/// merge is a no-op in practice. Revisit if a case ever needs the wire to unroot.
pub(crate) fn merge_from_wire(&mut self, wire: u32) {
use crate::creature_anim::move_flags as f;
self.water_walking = wire & f::WATER_WALKING != 0;
self.feather_fall = wire & f::SAFE_FALL != 0;
self.hover = wire & f::HOVER != 0;
self.levitating = wire & f::LEVITATING != 0;
}
}
/// Our controllable avatar. Until `active`, the camera free-flies; once the server reports our
/// position we take control (third-person) and drive movement. Toggle free-fly with `F`.
/// `active`/`pos`/`detached` are `pub(crate)` so terrain streaming can center the loaded block on the
@@ -177,23 +316,9 @@ pub(super) struct MoveSpeed {
#[derive(Resource, Default)]
pub(crate) struct Player {
pub(crate) active: bool,
/// The server rooted our mover (`SMSG_FORCE_MOVE_ROOT` — at death, until release; decision
/// 0308). While set, translation input and jumps are dead (the faithful "can't move between
/// death and release"); turning stays live, like a real rooted client. Set/cleared by the
/// root-ack handler in [`super::wire_in`].
pub(super) rooted: bool,
/// **The server put us in free flight** (`MOVEFLAG_LEVITATING`, GM `.cheat fly` — decision
/// 0726). Set/cleared only from a server-authored move ([`super::wire_in`]), like
/// [`Player::rooted`] — a granted mover *mode*, which in this architecture is typed state rather
/// than a raw bit ([`Player::move_flags`] is last-streamed wire bookkeeping, rebuilt from state
/// every frame).
///
/// It does exactly one thing, and it does it by suppression: while set, the water/depth swim
/// decision does not run at all ([`super::swim::update_swimming`] — the reference's
/// `0x6030d2 test ah,4` bail). So a [`Player::swimming`] the server switched on stays on with no
/// water under it — which *is* GM flight — and, symmetrically, real water can no longer put us
/// into swim while it is set.
pub(super) levitating: bool,
/// **The granted mover modes** — what the server has switched on for our mover, as typed state
/// rather than raw bits. See [`MoveModes`].
pub(super) modes: MoveModes,
/// **Autorun** latched on — the reference's input bit `0x1000` in the local mover's input word
/// `[MOVE+4]`, flipped by `ToggleAutoRun 0x513de0` (a read+invert: the command family's only
/// *toggle*, where every directional command is a set/clear pair). VERIFIED, wow-re
@@ -536,3 +661,135 @@ mod autorun_tests {
assert!(autorun_cancelled(false, false, false, true));
}
}
#[cfg(test)]
mod move_mode_tests {
use super::{MoveModes, FEATHER_TERMINAL_VELOCITY, GRAVITY, HOVER_HEIGHT, TERMINAL_VELOCITY};
use crate::creature_anim::move_flags as f;
use benilla_protocol::MoveMode;
/// Every ack'd mode round-trips: granted through [`MoveModes::set`], out as its own
/// `MOVEMENTFLAGS` bit, and revoked again. The bit values are the ones vmangos reads back out of
/// our acks, so a mismatch is silent on our side and wrong on the server's.
#[test]
fn each_granted_mode_rides_the_wire_word_as_its_own_bit() {
for (mode, bit) in [
(MoveMode::Root, f::ROOT),
(MoveMode::WaterWalk, f::WATER_WALKING),
(MoveMode::FeatherFall, f::SAFE_FALL),
(MoveMode::Hover, f::HOVER),
] {
let mut modes = MoveModes::default();
assert_eq!(modes.wire_flags(), 0);
modes.set(mode, true);
assert_eq!(
modes.wire_flags(),
bit,
"{mode:?} must ride the wire as {bit:#x}"
);
assert_eq!(
mode.flag(),
bit,
"{mode:?}: our bit and the protocol's agree"
);
modes.set(mode, false);
assert_eq!(modes.wire_flags(), 0, "{mode:?} revokes cleanly");
}
}
/// **Levitate grants three modes at once** (spell 1706 = feather fall + hover + water walk), so
/// the word has to carry all three together — the case that proves this is one system and not
/// four independent booleans.
#[test]
fn levitate_carries_its_three_modes_at_once() {
let mut modes = MoveModes::default();
for mode in [MoveMode::FeatherFall, MoveMode::Hover, MoveMode::WaterWalk] {
modes.set(mode, true);
}
assert_eq!(
modes.wire_flags(),
f::SAFE_FALL | f::HOVER | f::WATER_WALKING
);
// …and dropping one leaves the other two standing.
modes.set(MoveMode::Hover, false);
assert_eq!(modes.wire_flags(), f::SAFE_FALL | f::WATER_WALKING);
}
/// The server-authored merge owns the four unhandshaked bits and **must not touch root**. Root
/// is inside the reference's `0x75a0_7dff` mask, so this is our deliberate divergence, not the
/// mask's doing — and the failure it prevents is concrete: a bare pose clearing root locally
/// while the server still holds us rooted streams moving bits into `CHEAT_TYPE_ROOT_MOVE`.
#[test]
fn the_wire_merge_never_unroots_us() {
let mut modes = MoveModes {
rooted: true,
..Default::default()
};
modes.merge_from_wire(0); // a pose claiming no modes at all
assert!(modes.rooted, "only the ack'd opcode may unroot");
assert!(!modes.hover && !modes.feather_fall && !modes.water_walking && !modes.levitating);
// The other four do follow the wire, in both directions — this is how `.cheat fly` arrives.
modes.merge_from_wire(f::SAFE_FALL | f::HOVER | f::WATER_WALKING | f::LEVITATING);
assert!(modes.hover && modes.feather_fall && modes.water_walking && modes.levitating);
assert!(modes.rooted, "still ours alone");
}
/// **Feather fall is a terminal-velocity substitution and nothing more** (`0x7c5d20`): gravity
/// is untouched, so the drop *accelerates normally* and only then rides the lower cap. Pinning
/// the shape, not just the constant — an implementation that scaled gravity instead would give
/// a fall that starts wrong, and would pass a constants-only test.
#[test]
fn feather_fall_caps_the_descent_without_softening_gravity() {
let dt = 1.0 / 60.0;
let step = |v: f32, terminal: f32| (v - GRAVITY * dt).max(-terminal);
// One frame from rest is identical with and without the aura — gravity is the same.
assert_eq!(
step(0.0, FEATHER_TERMINAL_VELOCITY),
step(0.0, TERMINAL_VELOCITY),
"the first frame of a slow fall is an ordinary fall"
);
// Held long enough, the two settle at their own caps.
let settle = |terminal: f32| {
let mut v = 0.0;
for _ in 0..600 {
v = step(v, terminal);
}
v
};
assert_eq!(
settle(FEATHER_TERMINAL_VELOCITY),
-FEATHER_TERMINAL_VELOCITY
);
assert_eq!(settle(TERMINAL_VELOCITY), -TERMINAL_VELOCITY);
// And the cap is reached in well under half a second, which is why Slow Fall reads as a
// drift rather than a fall: v = g·t ⇒ t = 7.0/19.291105 ≈ 0.363 s.
let mut v = 0.0f32;
let mut frames = 0;
while v > -FEATHER_TERMINAL_VELOCITY {
v = step(v, FEATHER_TERMINAL_VELOCITY);
frames += 1;
}
assert!(
(frames as f32 * dt - 0.363).abs() < 0.02,
"capped after {frames} frames ({:.3} s), expected ≈0.363 s",
frames as f32 * dt
);
}
/// The two mover constants are the reference's, not round numbers we liked. A drift here is
/// invisible in play and wrong everywhere.
#[test]
fn the_mode_constants_are_the_verified_ones() {
assert_eq!(FEATHER_TERMINAL_VELOCITY, 7.0); // [0x87d898]
assert_eq!(HOVER_HEIGHT, 1.0); // [0x7ff9d8]
assert_eq!(TERMINAL_VELOCITY, 60.148_003); // [0x87d894]
}
/// Feather fall must be the SLOWER cap — the whole point of the aura. A *compile-time* assert,
/// because both sides are constants: swapped, they would otherwise only show up in play.
const _: () = assert!(FEATHER_TERMINAL_VELOCITY < TERMINAL_VELOCITY);
}
+4 -4
View File
@@ -157,7 +157,7 @@ pub(super) fn update_swimming(player: &mut Player, surface_y: Option<f32>, now:
// it IS the mechanism of GM flight (decision 0726). The server sets SWIMMING and LEVITATING in
// one packet, and the second is what stops the dry ground under us clearing the first on the
// very next frame.
if player.levitating {
if player.modes.levitating {
return player.swimming;
}
let Some(surface) = surface_y else {
@@ -461,7 +461,7 @@ mod tests {
// The server just sent `.cheat fly on`: SWIMMING + LEVITATING, standing on dry ground.
let mut flying = player_at(0.0);
flying.swimming = true;
flying.levitating = true;
flying.modes.levitating = true;
assert!(
update_swimming(&mut flying, None, 0.0),
"no liquid at all must NOT stop a levitating swimmer — this is the whole of GM flight"
@@ -473,14 +473,14 @@ mod tests {
// The other arm, and it is the same instruction: water cannot latch swim while levitating.
let mut dry = player_at(0.0);
dry.levitating = true;
dry.modes.levitating = true;
assert!(
!update_swimming(&mut dry, Some(swim_enter_depth(HUMAN_MALE) + 1.0), 0.0),
"the ENTER arm is skipped too — the bail is before the branch, not inside it"
);
// And clearing the mode (`.cheat fly off` sends flags 0) hands the water its job back.
flying.levitating = false;
flying.modes.levitating = false;
assert!(
!update_swimming(&mut flying, None, 0.0),
"with the mode gone, no liquid stops swimming again"
+51 -43
View File
@@ -1,8 +1,9 @@
//! Server-authored movement edges applied to our mover — the inbound mirror of
//! [`super::movement_net`] (which streams our own movement out). One entry point,
//! [`apply_server_moves`], called by [`super::control`] before input integrates: cross-map
//! worldports (incl. the riding-through-the-seam branch, decision 0455), same-map teleports,
//! root/unroot (death — decision 0308), water-walk grants, the one-shot take-control edge, the
//! worldports (incl. the riding-through-the-seam branch, decision 0455), same-map teleports, the
//! **granted movement-mode family** (root, water-walk, feather-fall, hover — decisions 0308/0866,
//! applied to [`super::state::MoveModes`] and acked here), the one-shot take-control edge, the
//! pre-control forced-speed acks (the controlled branch answers those through the movement
//! stream's own per-frame payload instead — the returned list), and the **bare self-addressed
//! move** ([`apply_self_move`], decision 0725).
@@ -13,15 +14,15 @@
//! mover-guid gate at all — and sends nothing back; the next ordinary heartbeat carries the
//! server's own pose home, which is what makes `.go forward` stick.
use benilla_protocol::MoveMode;
use bevy::prelude::*;
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use crate::creature_anim::{move_flags, wrap_pi};
use crate::death::{MoveRootMessage, WaterWalkMessage};
use crate::net::{
ClientCommand, Guid, MoveKind, NetCommands, SelfMoveMessage, SelfPlayer, SpeedChangeMessage,
TeleportMessage, WorldportMessage,
ClientCommand, Guid, MoveKind, MoveModeMessage, NetCommands, SelfMoveMessage, SelfPlayer,
SpeedChangeMessage, TeleportMessage, WorldportMessage,
};
use crate::transport::Transport;
use crate::world_map::CurrentMap;
@@ -32,7 +33,8 @@ use super::{movement_net, Player, SETTLE_TIMEOUT};
/// Drain this frame's server-authored movement messages, apply them to the mover, and send each
/// ack. Returns the frame's forced-speed changes: pre-control/detached they were acked here with
/// the parked pose; controlled, the caller's movement stream acks them with its live payload.
/// `self_pos` is the streamed self entity's current translation (the take-control snap target).
/// `self_pose` is the streamed self entity's current translation + facing yaw (the take-control
/// snap target).
// One system phase's full input set (the spawner precedent); the transports query type is
// `control`'s own param shape passed through.
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
@@ -45,14 +47,13 @@ pub(super) fn apply_server_moves(
teleports: &mut MessageReader<TeleportMessage>,
worldports: &mut MessageReader<WorldportMessage>,
speed_msgs: &mut MessageReader<SpeedChangeMessage>,
root_msgs: &mut MessageReader<MoveRootMessage>,
waterwalk_msgs: &mut MessageReader<WaterWalkMessage>,
mode_msgs: &mut MessageReader<MoveModeMessage>,
self_moves: &mut MessageReader<SelfMoveMessage>,
transports: &Query<
(&Transform, &Guid),
(With<Transport>, Without<SelfPlayer>, Without<FlyCam>),
>,
self_pos: Option<Vec3>,
self_pose: Option<(Vec3, f32)>,
) -> Vec<SpeedChangeMessage> {
// Cross-map worldport (`.tele Orgrimmar`, initial-login map, a boat crossing the sea): the net
// bridge surfaced it as a message earlier this frame (WorldStage::Net). Snap the avatar, bump
@@ -180,67 +181,74 @@ pub(super) fn apply_server_moves(
}
}
// Server root/unroot on our mover (death/release — decision 0308): apply the change locally,
// THEN ack with the resulting flags — the real client's shape, and the server's law: a
// root-apply ack whose MovementInfo lacks MOVEFLAG_ROOT is a KICK (vmangos
// `HandleMoveRootAck:715-723`, live-verified against the deploy's Movement.log). Moving bits
// never accompany ROOT (they freeze the real client), so the walk stream parks first.
for m in root_msgs.read() {
player.rooted = m.rooted;
if m.rooted {
// **The ack'd movement-mode family** (decisions 0308, 0866) — root, water-walk, feather-fall,
// hover. Apply the mode to our typed state FIRST, then ack with the flag word that state
// rebuilds to: that ordering is the real client's, and it is also the server's law — an
// apply-ack whose `MovementInfo` lacks the mode bit un-grants the very mode it is accepting,
// and for root it is an outright KICK (vmangos `HandleMoveRootAck:715-723`, live-verified
// against the deploy's `Movement.log`).
//
// Root additionally **parks the walk stream and ends the arc**: moving bits must never
// accompany `MOVEFLAG_ROOT` (they freeze the real client, and vmangos raises
// `CHEAT_TYPE_ROOT_MOVE`), and the reference's own `SetRoot 0x7c7340` clears the direction bits
// and calls stop-fall `0x7c6290` at apply. Turn bits are *not* moving bits —
// `MOVEFLAG_MASK_MOVING` excludes them — the same asymmetry that keeps turning live while
// rooted.
for m in mode_msgs.read() {
player.modes.set(m.mode, m.apply);
if m.mode == MoveMode::Root && m.apply {
movement_net::park_mover(&net_cmds.0, player);
player.airborne_since = None;
player.vel_y = 0.0;
player.fall_far = false;
}
let facing = player.face_yaw.rem_euclid(std::f32::consts::TAU);
let _ = net_cmds.0.send(ClientCommand::MoveRootAck {
let _ = net_cmds.0.send(ClientCommand::MoveModeAck {
guid: m.guid,
counter: m.counter,
rooted: m.rooted,
flags: if m.rooted { move_flags::ROOT } else { 0 },
mode: m.mode,
apply: m.apply,
flags: player.modes.wire_flags(),
pos: bevy_to_wow(player.pos),
orientation: facing,
});
info!(
"mover {} (acked)",
if m.rooted { "rooted" } else { "unrooted" }
"mover mode {:?} {} (acked)",
m.mode,
if m.apply { "granted" } else { "revoked" }
);
}
// Water-walk grant/removal (the ghost form): ack with the applied flag (the faithful echo;
// the toggle-ack path doesn't hard-require it the way the root ack does). The walk-on-water
// mover regime itself is the swim arc's deferred seam (0308 §8).
for m in waterwalk_msgs.read() {
let facing = player.face_yaw.rem_euclid(std::f32::consts::TAU);
let _ = net_cmds.0.send(ClientCommand::WaterWalkAck {
guid: m.guid,
counter: m.counter,
on: m.on,
flags: if m.on { move_flags::WATER_WALKING } else { 0 },
pos: bevy_to_wow(player.pos),
orientation: facing,
});
}
// Take control once the server first reports our position (the streamed `SelfPlayer` entity,
// whose transform is already in Bevy space). From here the controller drives that entity
// directly; the entity renderer attaches its body model (0041) the same way it does for any
// other player.
if !player.active {
if let Some(pos) = self_pos {
if let Some((pos, yaw)) = self_pose {
player.pos = pos;
player.active = true;
player.settling = true; // settle onto the initial ground once it loads (don't fall through)
player.settle_deadline = time.elapsed_secs() + SETTLE_TIMEOUT;
player.world_stale = true; // nothing is streamed yet at all — see [`Player::world_stale`]
cam.yaw = 0.0;
// The streamed spawn pose carries the server's facing (the character's logout
// orientation on a fresh login) — adopt it whole, camera seated behind, like the
// reference. Zeroing here is what made every login face due north regardless.
cam.yaw = yaw;
cam.pitch = -0.45;
player.face_yaw = 0.0;
player.model_yaw = 0.0;
player.face_yaw = yaw;
player.model_yaw = yaw;
// Seed the facing-change detector with the adopted facing — the server gave it to us,
// so the first controlled frame owes no `SET_FACING` (the real client sends nothing at
// login until the mouse actually turns).
player.last_facing = yaw.rem_euclid(std::f32::consts::TAU);
// The avatar's `MovementState` (the animation selector's motion source) is NOT inserted
// here: it rides the `SelfPlayer` tag (`net::apply::tag_self_player`), because a
// cross-map worldport despawns and re-streams this entity while `player.active` stays
// true — per-entity state attached only on this one-shot edge would be lost on transfer.
info!(
"took control of player @ {:?} ('F' toggles free-fly)",
player.pos
"took control of player @ {:?} facing {:.3} ('F' toggles free-fly)",
player.pos,
yaw.rem_euclid(std::f32::consts::TAU)
);
}
}
@@ -330,7 +338,7 @@ fn apply_self_move(
// runs `0x61a1af → 0x61a230 → SetSwim` (wow-re `swim-transition.md`, "the local unit's server
// echo"). This pair is GM flight: `.cheat fly` sends SWIMMING + LEVITATING together.
player.swimming = player.move_flags & move_flags::SWIMMING != 0;
player.levitating = player.move_flags & move_flags::LEVITATING != 0;
player.modes.merge_from_wire(player.move_flags);
// Neither mode touches `settling`: the settle release is the terrain streamer's, keyed on the
// destination's residency in every mover mode alike (decision 0737). The pre-0737 special case
// here (swim clears the hold) existed only because the old release was a walk-mover ground
+19 -8
View File
@@ -322,10 +322,24 @@ fn build_markers(
.submeshes
.iter()
.any(|s| s.billboard.is_none())
.then(|| {
crate::doodad_anim::spawn_anim_host(&mut commands, &mut palettes, model, seat_tf)
})
.then(|| crate::doodad_anim::spawn_anim_host(&mut commands, model, seat_tf))
.flatten();
// EAGER slot allocation — this lane has no draw gate to promote lazily (decision 0863
// made laziness the terrain-stream caller's policy, not the host's). A handful of
// markers exist at once, so eager is the right spend; slot 0 (table full, warned)
// falls back to the static mesh below exactly as before.
let marker_slot = host.as_ref().map_or(0, |h| {
crate::rig_palette::RigSkin::allocate(
&mut palettes,
h.joints.clone(),
h.inverse_bindposes.clone(),
)
.map_or(0, |rig| {
let slot = rig.slot;
commands.entity(h.root).insert(rig);
slot
})
});
let seat = match &host {
Some(h) => h.root,
None => commands.spawn((seat_tf, Visibility::default())).id(),
@@ -397,7 +411,7 @@ fn build_markers(
// rig when the model animates (the `!` bob — decision 0720); the static mesh
// otherwise (capture mode keeps every marker static, like the doodad rail),
// including the palette-full fallback (slot 0).
let use_rig = host.as_ref().is_some_and(|h| h.slot != 0);
let use_rig = marker_slot != 0;
let mesh = if use_rig {
skin_forms.get(pi).cloned().unwrap_or_default()
} else {
@@ -406,10 +420,7 @@ fn build_markers(
.map(|(h, _)| h.clone())
.unwrap_or_default()
};
let rig_tag = host
.as_ref()
.filter(|_| use_rig)
.map_or(0, |h| crate::mesh_tag::rig_bits(h.slot));
let rig_tag = crate::mesh_tag::rig_bits(marker_slot);
let child = commands
.spawn((
Mesh3d(mesh),
+108
View File
@@ -99,6 +99,9 @@ pub(crate) struct RigPalettes {
peak_slots: usize,
peak_bones: u32,
live_bones: u32,
/// Allocations refused since the last census line (`WOW_RIG_CENSUS`) — the exhaustion
/// diagnosis's live denial rate (decision 0863). Read-and-reset by [`census_rig_palettes`].
denied: u32,
/// `WOW_RIG_COST` meters (decision 0736 premise check): whole-vec deep copies this frame
/// (`Arc::make_mut` clones when the extract still holds last publish's reference), the µs
/// they took, and the rows (bones) written. Printed + reset by [`publish_rig_palettes`].
@@ -122,6 +125,7 @@ impl Default for RigPalettes {
peak_slots: 0,
peak_bones: 0,
live_bones: 0,
denied: 0,
cost_copies: 0,
cost_copy_us: 0.0,
cost_rows: 0,
@@ -355,8 +359,23 @@ impl RigPalettes {
self.peak_bones,
)
}
/// Slots still allocatable right now — the pressure signal (decision 0863): the doodad
/// reaper starts reclaiming parked rigs under [`crate::doodad_anim::REAP_LOW_WATER`], and
/// the unit heal only rebuilds a starved visual when there is real room to succeed.
pub(crate) fn slot_headroom(&self) -> usize {
MAX_RIG_SLOTS - self.slot_high + self.free_slots.len()
}
}
/// The unit whose visual was built WITHOUT a palette rig because the table was full at attach
/// time (decision 0863) — it renders the static bind pose. [`crate::entities`]' heal system
/// rebuilds it (the 0x60abe0 display-swap teardown, `Reattached` so it doesn't re-fade) once the
/// table has headroom again; without this marker the denial was PERMANENT, the "statue mobs at
/// the stream-in boundary" bug.
#[derive(Component)]
pub(crate) struct RigStarved;
/// A registered rig, on the entity that owns its pose (the unit / doodad-host / booth root):
/// its palette slot + the joint entities + the shared inverse bindposes. The hook frees the slot
/// whoever despawns the rig — and `on_replace` (not `on_remove`) because a gear/display rebuild
@@ -418,6 +437,7 @@ impl RigSkin {
ibp,
}),
None => {
palettes.denied += 1;
let (s, b, ps, pb) = palettes.occupancy();
warn!(
"rig palette exhausted ({bones} bones wanted; live {s} slots / {b} bones, \
@@ -614,12 +634,100 @@ fn coalesce_ranges(mut ranges: Vec<(u32, u32)>) -> Vec<(u32, u32)> {
out
}
/// `WOW_RIG_CENSUS=<secs>` (any unparseable value = 5): a periodic one-line breakdown of WHO
/// holds the palette's slots — per-lane slots(bones) with the doodad lane split live/parked by
/// its draw gate, a bones-per-slot histogram, denials since the last line, and the prop-probe
/// table's live/peak. The last matters because probes and rigs are the two `MeshTag`-slot-indexed
/// tables competing for the same 30 payload bits — a re-split of either field must read both
/// occupancies (decision 0863's exhaustion diagnosis). Zero-cost when off: one env read, once.
fn rig_census_every() -> Option<f32> {
static EVERY: std::sync::OnceLock<Option<f32>> = std::sync::OnceLock::new();
*EVERY.get_or_init(|| {
std::env::var("WOW_RIG_CENSUS")
.ok()
.map(|v| v.parse().unwrap_or(5.0))
})
}
/// The `[rig-census]` printer (`WOW_RIG_CENSUS=<secs>`) — see [`rig_census_every`].
#[allow(clippy::type_complexity)] // one census's full lane classification
fn census_rig_palettes(
time: Res<Time<bevy::time::Real>>,
rigs: Query<(
&RigSkin,
Option<&crate::doodad_anim::DoodadAnimHost>,
Has<crate::net::NetEntity>,
)>,
probes: Res<crate::lighting::PropProbes>,
mut palettes: ResMut<RigPalettes>,
mut next: Local<f32>,
) {
let Some(every) = rig_census_every() else {
return;
};
let now = time.elapsed_secs();
if now < *next {
return;
}
*next = now + every;
// Lanes: doodad anim hosts (split by their draw gate), net units, everything else (held-item
// billboard rigs, spell-fx instances, booth rigs). `(slots, bones)` each.
let (mut doodad, mut doodad_parked, mut unit, mut other) =
((0u32, 0u32), (0u32, 0u32), (0u32, 0u32), (0u32, 0u32));
let mut hist = [0u32; 6]; // bones/slot: ≤2, 3–4, 5–8, 9–16, 17–32, 33+
for (rig, host, is_unit) in &rigs {
let b = rig.bones();
let lane = match (host, is_unit) {
(Some(h), _) if h.active => &mut doodad,
(Some(_), _) => &mut doodad_parked,
(None, true) => &mut unit,
(None, false) => &mut other,
};
lane.0 += 1;
lane.1 += b;
hist[match b {
0..=2 => 0,
3..=4 => 1,
5..=8 => 2,
9..=16 => 3,
17..=32 => 4,
_ => 5,
}] += 1;
}
let denied = std::mem::take(&mut palettes.denied);
let (s, b, ps, pb) = palettes.occupancy();
let (p_live, p_peak) = probes.occupancy();
eprintln!(
"[rig-census] slots={s}/{} bones={b}/{MAX_PALETTE_BONES} peak={ps}/{pb} | \
doodad={}({}) parked={}({}) unit={}({}) other={}({}) | \
bones/slot ≤2:{} 3-4:{} 5-8:{} 9-16:{} 17-32:{} 33+:{} | \
denied +{denied} | probes {p_live}/{p_peak} of {}",
MAX_RIG_SLOTS - 1,
doodad.0,
doodad.1,
doodad_parked.0,
doodad_parked.1,
unit.0,
unit.1,
other.0,
other.1,
hist[0],
hist[1],
hist[2],
hist[3],
hist[4],
hist[5],
crate::lighting::MAX_PROP_PROBES,
);
}
pub(crate) fn plugin(app: &mut App) {
app.init_resource::<RigPalettes>()
.init_resource::<RigPaletteExtract>()
.init_resource::<RigPaletteMirrors>()
.add_plugins(ExtractResourcePlugin::<RigPaletteExtract>::default())
.add_plugins(ExtractResourcePlugin::<RigPaletteMirrors>::default())
.add_systems(Update, census_rig_palettes)
.add_systems(
PostUpdate,
(compute_rig_palettes, publish_rig_palettes)
+4 -3
View File
@@ -53,8 +53,9 @@ pub(crate) use zone::ExplorationSounds;
pub(crate) struct SoundConfig {
/// Master enable — mirrors the client's `-nosound` gate as a live toggle.
pub enabled: bool,
/// Quick mute — toggled by `Ctrl`+`Cmd`+`M` (the dev-chord plane, decision 0585). Zeroes the
/// **main track only**: selection and channel life go on untouched, so unmute is instant
/// Quick mute — toggled by the dev chord + `M` (its plane is per-OS: decisions 0585, 0867).
/// Zeroes the **main track only**: selection and channel life go on untouched, so unmute is
/// instant
/// (unlike `enabled`, which stops sounds from being picked at all). Starts `true`: the client
/// boots silent until deliberately unmuted.
pub muted: bool,
@@ -232,7 +233,7 @@ fn update_audio_listener(
}
}
/// `Ctrl`+`Cmd`+`M` — flip [`SoundConfig::muted`]. Lives on the dev-chord plane
/// The dev chord + `M` — flip [`SoundConfig::muted`]. Lives on the dev-chord plane
/// ([`crate::debug_panel::dev_chord`], decision 0585) so it can never collide with a game binding
/// and stays reachable with the chat bar open.
fn toggle_mute(keys: Res<ButtonInput<KeyCode>>, mut config: ResMut<SoundConfig>) {
@@ -51,8 +51,6 @@ pub(crate) fn spawn_model_entities(
commands: &mut Commands,
mat_cache: &mut MaterialCache,
materials: &mut Assets<WowModelMaterial>,
// The owned skin-palette table (decision 0720): an animated placement's rig claims a slot.
palettes: &mut crate::rig_palette::RigPalettes,
light: &Buffer,
submeshes: &[ModelSubmesh],
// The model's app-built render forms (decision 0834), index-parallel with `submeshes`: the
@@ -120,17 +118,20 @@ pub(crate) fn spawn_model_entities(
|| !m.emitters.is_empty()
|| !m.ribbons.is_empty()
})
.and_then(|(m, _)| crate::doodad_anim::spawn_anim_host(commands, palettes, m, transform));
.and_then(|(m, _)| crate::doodad_anim::spawn_anim_host(commands, m, transform));
// Captures freeze material-alpha clocks at 0 (deterministic frames) — read the env once.
let mat_frozen = crate::capture::scenario_active();
let skin = host
.as_ref()
.map(|h| (h.joints.clone(), h.inverse_bindposes.clone()));
let (rig_root, rig_slot) = host.as_ref().map_or((None, 0), |h| (Some(h.root), h.slot));
let rig_root = host.as_ref().map(|h| h.root);
if let Some(h) = &host {
out.push(h.root);
}
let mut skinned_meshes: Vec<Entity> = Vec::new();
// The parts the lazy-rig wake will promote static → skinned (decision 0863) — the ones that
// got a [`crate::doodad_anim::SkinnedTwin`] below.
let mut lazy_parts: Vec<Entity> = Vec::new();
for (batch_idx, sub) in submeshes.iter().enumerate() {
// The batch's app-built render form (decision 0834). Callers gate spawning on the forms
// being complete, so a miss here is a broken contract — skip the batch rather than panic.
@@ -196,8 +197,11 @@ pub(crate) fn spawn_model_entities(
);
// The blend twin for the distance-fade feather pass (reuse the cutout when already blend, or when
// this is a non-fading interior prop). A MULTIPLY batch (Mod/Mod2x — the weapon-rack
// ARMORREFLECT sheen) also reuses its steady self: its blend equation reads no alpha, so it
// cannot feather — the reference's instanceAlpha fade leaves it at full strength (0528).
// ARMORREFLECT sheen) also reuses its steady self: its blend equation reads no alpha, so no
// material swap can feather it — the reference's instanceAlpha fade leaves it at full
// strength (0528). Since decision 0865 the tag alpha it already carries is no longer inert:
// `wow_model.wgsl` lerps a multiply batch's colour toward the blend identity by it, so the
// rack sheen now rides the distance fade too (the deliberate deviation).
let blend = if steady_interior_prop
|| matches!(
sub.blend,
@@ -300,24 +304,18 @@ pub(crate) fn spawn_model_entities(
}
(card_entity.id(), Vec3::ZERO)
} else {
// An animated doodad's ordinary submesh draws the skinned twin bound to the placement's
// palette rig (decision 0720 — the rig slot in the tag; the entity keeps the placement
// transform — culling/fade read it; the skinned draw itself is palette-driven).
// Everything else — and the palette-full fallback (slot 0) — draws the static mesh.
// The twin comes from the app-built forms (0834); a lane that didn't request it
// (or a contract break) falls back to the static form, un-rigged, rather than warn
// the picker with a joint-less "skinned" mesh.
let skinned_mesh = (skin.is_some() && rig_slot != 0)
// An animated doodad's ordinary submesh spawns on the STATIC form with its skinned
// twin waiting beside it (`SkinnedTwin`): the palette slot no longer exists at spawn
// — the draw gate allocates it at the placement's first wake and swaps the mesh in
// (decision 0863, `doodad_anim::lazy`). The twin comes from the app-built forms
// (0834); a lane that didn't request it (or a contract break) simply never promotes,
// rather than arm the picker with a joint-less "skinned" mesh.
let skinned_mesh = skin
.is_some()
.then(|| forms.skin.and_then(|s| s.get(batch_idx)).cloned())
.flatten();
let use_rig = skinned_mesh.is_some();
let part_tag = if use_rig {
MeshTag(crate::mesh_tag::rig_bits(rig_slot) | mesh_tag.0)
} else {
mesh_tag
};
let mut part_entity = commands.spawn((
Mesh3d(skinned_mesh.unwrap_or_else(|| stat_mesh.clone())),
Mesh3d(stat_mesh.clone()),
MeshMaterial3d(cutout.clone()),
transform,
ModelPart {
@@ -326,15 +324,20 @@ pub(crate) fn spawn_model_entities(
},
// The picker's triangles (decision 0857) — same rule as the card above.
crate::interact::PickMesh(sub.geometry.clone()),
part_tag,
mesh_tag,
));
// Static parts carry the build-time Aabb (the RENDER_WORLD rule above); a rigged
// part's skinned mesh keeps main-world data, so `calculate_bounds` covers it as
// before.
if !use_rig {
if let Some(aabb) = stat_aabb {
part_entity.insert(*aabb);
}
// Every part now spawns static, so every part carries the build-time Aabb (the
// RENDER_WORLD rule above). It stays through a promote: the skinned twin shares the
// bind-pose geometry, so the bound is the same one `calculate_bounds` used to derive.
if let Some(aabb) = stat_aabb {
part_entity.insert(*aabb);
}
if let Some(sm) = skinned_mesh {
part_entity.insert(crate::doodad_anim::SkinnedTwin {
skinned: sm,
stat: stat_mesh.clone(),
});
lazy_parts.push(part_entity.id());
}
let entity = part_entity.id();
if skin.is_some() {
@@ -396,8 +399,24 @@ pub(crate) fn spawn_model_entities(
// arm over the loader's var-0 seed — the reference's own two-stage load (decision 0768).
window_hi: f32::NEG_INFINITY,
anim_id: h.anim_id,
active: true,
// Born PARKED (decision 0863): the spawn frame's `Visibility` is the default-visible
// lie (the fade/cull authorities haven't classified the fresh parts yet), and the
// gate's promote requires a drawn frame ON TOP of `active` — so the first real
// verdict, not the spawn race, decides the slot. The gate flips this true the same
// first pass either way, so pose/resume semantics are unchanged.
active: false,
parked_at: now,
});
// The lazy palette rig (decision 0863): what the draw gate's first wake allocates and
// promotes. Only when the placement has skinnable parts at all — an emitter-only host
// (chimney smoke) never takes a slot, which under the eager design it wasted one on.
if !lazy_parts.is_empty() {
commands.entity(h.root).insert(crate::doodad_anim::LazyRig {
joints: h.joints.clone(),
ibp: h.inverse_bindposes.clone(),
parts: lazy_parts,
});
}
}
(
out,
@@ -47,11 +47,11 @@ const SPAWN_COUNT_CAP: usize = 150;
/// gets the production [`WowModelMaterial`] (cutout + blend twin) and the same `ModelPart`/`DoodadFade`/
/// `MeshTag` components the old spawn used — so the existing visibility/fade/lighting systems take over.
/// The nested resource tuple of [`spawn_loaded_placements`] (the 16-SystemParam ceiling): the
/// prop-probe table, the skin-palette table (0720), the stream-trace counters, the live/settling
/// state the landing cap reads, and the model-forms cache (0834).
/// prop-probe table, the stream-trace counters, the live/settling state the landing cap reads,
/// and the model-forms cache (0834). The skin-palette table left with decision 0863 — a
/// placement's rig slot is the draw gate's to claim at first wake now, not the spawner's.
type SpawnTables<'w> = (
ResMut<'w, PropProbes>,
ResMut<'w, crate::rig_palette::RigPalettes>,
ResMut<'w, crate::perf::StreamActivity>,
Option<Res<'w, crate::player::Player>>,
ResMut<'w, ModelForms>,
@@ -78,11 +78,11 @@ pub(super) fn spawn_loaded_placements(
mut uv_reg: ResMut<crate::doodad_anim::UvAnimMaterials>,
mut tint_reg: ResMut<crate::doodad_anim::TintAnimMaterials>,
// Nested to stay inside Bevy's 16-element system-param tuple limit: the prop-probe table +
// the skin-palette table (decision 0720) + the stream-trace counters + the live/settling
// state the landing cap reads + the model-forms cache (decision 0834).
// the stream-trace counters + the live/settling state the landing cap reads + the
// model-forms cache (decision 0834).
tables: SpawnTables,
) {
let (mut probes, mut palettes, mut activity, player, mut forms) = tables;
let (mut probes, mut activity, player, mut forms) = tables;
let Some(shared_light) = shared_light else {
return;
};
@@ -154,7 +154,6 @@ pub(super) fn spawn_loaded_placements(
&mut commands,
mat_cache,
materials,
&mut palettes,
light,
&m.submeshes,
FormSlices {
@@ -256,7 +255,6 @@ pub(super) fn spawn_loaded_placements(
&mut commands,
mat_cache,
materials,
&mut palettes,
light,
&m.submeshes,
FormSlices {
@@ -520,7 +518,6 @@ pub(super) fn spawn_loaded_placements(
&mut commands,
mat_cache,
materials,
&mut palettes,
light,
&m.submeshes,
FormSlices {
+22 -1
View File
@@ -5,7 +5,8 @@
//! `TryCast 0x6e4b60` → commit `0x6e54f0` is *one* function with a long ladder of local gates and a
//! post-send tail, and duplicating any part of it per caller is how the two paths drift. The ladder
//! below is that function's order, gate for gate — profession intercept, auto-repeat toggle,
//! cooldown, GCD, in-flight guard, mounted, reagents/totems, target binding, range — followed by
//! cooldown, GCD, in-flight guard, mounted, moving, form, reagents/totems, target binding,
//! range — followed by
//! the commit's own tail (ranged stance, auto-repeat arm, the send, the auto-attack start, the GCD
//! arm).
//!
@@ -147,6 +148,26 @@ pub(crate) fn send_spell_cast(
cast_errors.0.push((spell_id, 0x39));
return;
}
// The moving leg of the SAME requirement validator (`0x609de3`, after the mounted/posture/
// environment blocks, before the form leg — wow-re `moving-cast-gate.md`, decision 0862): a
// cast-time (or movement-sensitive) press while already moving refuses locally with the
// client's own reason 0x2e "Can't do that while moving" and NEVER sends. The gate must be
// local: vmangos accepts the sent cast (its CheckCast moving-reject covers only
// autorepeat/sit-still spells) and then its movement interrupt cancels it mid-bar — the
// start-then-die cast bar this gate removes. The full condition is [`state`]'s.
if let Some(d) = def {
let caster_level = ctx
.rel
.self_store
.and_then(|s| s.0.unit_level())
.unwrap_or(0);
let cast_time_ms = spells.map_or(0, |s| s.cast_time_ms(d, caster_level));
if state::cast_moving_refusal(ctx.self_move_flags, cast_time_ms, def) {
debug!("ui_action: cast {spell_id} refused locally — moving (0x2e)");
cast_errors.0.push((spell_id, 0x2e));
return;
}
}
// The shapeshift-form leg of the SAME requirement validator (`0x6094f0` at `0x609e49` →
// the form gate `0x612480`; wow-re `shapeshift-plaincast-toggle.md` §Q3, which corrected
// `mounted-action-gate.md`'s `0x609ca2` gloss — that address is the POSTURE gate, reason
@@ -99,6 +99,10 @@ pub(crate) struct CastContext<'a> {
/// The local range gate's inputs (`IsTargetInRange 0x6e47b0` over `GetMinMaxRange
/// 0x6e3480`) — the caster's and the selection's position + combat reach.
pub(crate) range: RangeInputs,
/// The caster's live CMovement flags word (the client's `[unit+0x9e8]`,
/// [`crate::creature_anim::move_flags`] layout) — what the requirement validator's moving
/// gate reads at cast initiation.
pub(crate) self_move_flags: u32,
}
/// Positions + combat reaches for the pre-send range refusal ([`super::send_spell_cast`]'s
@@ -138,6 +142,7 @@ pub(crate) struct CastTargeting<'w, 's> {
reputations: Res<'w, Reputations>,
self_transform: Query<'w, 's, &'static Transform, With<SelfPlayer>>,
transforms: Query<'w, 's, &'static Transform>,
player: Res<'w, crate::player::Player>,
}
impl CastTargeting<'_, '_> {
@@ -168,6 +173,7 @@ impl CastTargeting<'_, '_> {
.map_or(1.5, |s| s.0.unit_combat_reach()),
target_reach: target_store.map(|s| s.0.unit_combat_reach()),
},
self_move_flags: self.player.move_flags(),
}
}
}
+20
View File
@@ -134,6 +134,26 @@ pub(crate) struct Spells {
pub(crate) radii: benilla_formats::SpellRadiusCatalog,
}
impl Spells {
/// The resolved cast time, ms — `GetCastTime 0x6e3340`'s level-scaled walk (wow-re
/// `wave-cooldown.md`/`moving-cast-gate.md`, byte-verified): `CastingTimeIndex` resolves the
/// [`Self::cast_times`] row, `base + perLevel·(casterLevel − spellLevel)` floors to the
/// row's minimum (row 1, the all-zero instant sentinel, resolves 0). The level term keys on
/// the `SpellRec+0x70` column ([`SpellDisplay::spell_level`]); spellmod op `0xa`
/// (SPELLMOD_CASTING_TIME) is unmodeled — benilla has no spellmod system — a named
/// micro-divergence (a talent-shortened 0-second cast doesn't exist in the 1.12 data).
/// A missing row reads 0 (instant), like a failed catalog load everywhere else.
pub(crate) fn cast_time_ms(
&self,
def: &benilla_formats::SpellDisplay,
caster_level: u32,
) -> u32 {
self.cast_times
.get(def.casting_time_index)
.map_or(0, |row| row.resolved_ms(caster_level, def.spell_level))
}
}
#[cfg(test)]
impl Spells {
/// An empty catalog set — the usable walk's unit tests need only the `forms` map.
+106 -4
View File
@@ -103,6 +103,52 @@ pub(super) fn cast_mounted_refusal(mounted: bool, spell: Option<&SpellDisplay>)
mounted && spell.is_none_or(|d| d.attributes & 0x0100_0000 == 0)
}
/// The AuraInterruptFlags-space MOVING|TURNING pair (`0x18`) — the moving gate's
/// "movement would matter anyway" arms test it on BOTH `AuraInterruptFlags` (+0x58) and
/// `ChannelInterruptFlags` (+0x5c), byte-verified at `0x609e0e`/`0x609e1c`.
const AURA_INTERRUPT_MOVING_TURNING: u32 = 0x18;
/// The **pre-send** moving refusal (decision 0862) — the requirement validator `0x6094f0`'s
/// moving block (`0x609de3–0x609e48`; the sole client-local emitter of reason `0x2e` "Can't do
/// that while moving". wow-re `moving-cast-gate.md`, §5 byte-verified): a press while the
/// caster's live CMovement flags carry any of {forward, backward, strafe L/R, JUMPING} refuses
/// locally — no packet, no cast bar, no GCD. Without it, vmangos *accepts* the cast (its
/// CheckCast moving-reject covers only autorepeat/sit-still spells, `Spell.cpp:5432`) and then
/// `Spell::update`'s 0.5-yd movement interrupt kills it — the start-then-cancel grief this gate
/// exists to prevent. The full reject condition, gate for gate:
///
/// - **entry**: `InterruptFlags & 0x1` (movement-interruptible — instants without it pass);
/// - **the movement word**: the WIRE `MovementFlags` layout (`[unit+0x9a8]+0x40`), mask
/// `0x200f` = forward|backward|strafe + JUMPING — turning and pitch are outside it, and so is
/// FALLINGFAR (`0x4000`; the client has NO falling/Stuck exemption — that's vmangos-only);
/// - **exemption**: an auto-repeat spell (`AttributesEx2 & 0x20` — Auto Shot, Shoot) never
/// refuses, whatever else it carries;
/// - **would movement matter**: a nonzero resolved cast time ([`super::Spells::cast_time_ms`]),
/// OR the [`AURA_INTERRUPT_MOVING_TURNING`] bits on the aura/channel interrupt columns — the
/// OR-arms are how a zero-cast-time *channel* is still refused at initiation.
///
/// An uncataloged spell passes (every record-read above needs the row; the ladder's other
/// data-driven legs — cooldown, GCD, range — share the disposition, and the server's own
/// interrupt stays the safety net). In the validator's order this sits after the mounted block
/// and before the shapeshift-form leg (`0x609e50`), which is where [`super::send_spell_cast`]
/// runs it.
pub(super) fn cast_moving_refusal(
move_flags_word: u32,
cast_time_ms: u32,
spell: Option<&SpellDisplay>,
) -> bool {
use crate::creature_anim::move_flags;
// The verified 0x200f: ANY_MOVE (0xf) | FALLING (0x2000), in our identical wire layout.
const MOVING_MASK: u32 = move_flags::ANY_MOVE | move_flags::FALLING;
let Some(d) = spell else { return false };
d.interrupt_flags & crate::ui_cast::SPELL_INTERRUPT_MOVEMENT != 0
&& move_flags_word & MOVING_MASK != 0
&& !d.auto_repeat()
&& (cast_time_ms != 0
|| d.aura_interrupt_flags & AURA_INTERRUPT_MOVING_TURNING != 0
|| d.channel_interrupt_flags & AURA_INTERRUPT_MOVING_TURNING != 0)
}
/// The resolved {min, max} for one action against one target — the `GetMinMaxRange 0x6e3480`
/// law over our descriptor reaches.
///
@@ -249,10 +295,10 @@ pub(super) fn feed_action_state(
|| (form_byte != 0 && d.shapeshift_form == Some(u32::from(form_byte)))
};
st.auto_repeat = auto_repeat.0 == Some(button.action);
// The full usable walk (`0x6e3d60` §2a — [`super::usable`]): reagents, forms,
// stealth, aura states, the bit-25 cooldown fold, and the power gate (the sole
// notEnoughMana writer). Target-dependent for the Execute family only. `spells`
// is necessarily Some here — `d` came out of it.
// The full usable walk (`0x6e3d60` §2a — [`super::usable`]): reagents, combo
// points, forms, stealth, aura states, the bit-25 cooldown fold, and the power
// gate (the sole notEnoughMana writer). Target-dependent for the Execute family
// only. `spells` is necessarily Some here — `d` came out of it.
if let (Some((store, _, _, _)), Some(sp)) = (me, spells.as_deref()) {
let ctx = usable::UsableCtx {
store,
@@ -545,4 +591,60 @@ mod tests {
assert!(!cast_mounted_refusal(false, None));
assert!(cast_mounted_refusal(true, None), "no record, no exemption");
}
/// The moving refusal (`0x609de3`) — every leg of the byte-verified condition: the
/// `InterruptFlags & 0x1` entry, the `0x200f` wire mask (turn/FALLINGFAR outside it,
/// JUMPING inside), the auto-repeat exemption, and the "would movement matter" arms (cast
/// time / aura / channel `0x18` bits).
#[test]
fn cast_moving_refusal_follows_the_validator_condition() {
use crate::creature_anim::move_flags as mf;
// Fireball's shape: ordinary timed cast (interrupt 0xf, nonzero cast time).
let timed = SpellDisplay {
interrupt_flags: 0xf,
..Default::default()
};
// Moving forward refuses; standing still doesn't.
assert!(cast_moving_refusal(mf::FORWARD, 1500, Some(&timed)));
assert!(!cast_moving_refusal(0, 1500, Some(&timed)));
// Strafe and JUMPING are in the mask; turn and FALLING_FAR are not (`0x200f`).
assert!(cast_moving_refusal(mf::STRAFE_LEFT, 1500, Some(&timed)));
assert!(cast_moving_refusal(mf::FALLING, 1500, Some(&timed)));
assert!(!cast_moving_refusal(mf::TURN_LEFT, 1500, Some(&timed)));
assert!(!cast_moving_refusal(mf::FALLING_FAR, 1500, Some(&timed)));
// An instant WITHOUT the movement interrupt bit passes (Fire Blast's shape)…
let instant = SpellDisplay {
interrupt_flags: 0xe,
..Default::default()
};
assert!(!cast_moving_refusal(mf::FORWARD, 0, Some(&instant)));
// …and even WITH it, a zero cast time passes unless an 0x18 arm bites.
assert!(!cast_moving_refusal(mf::FORWARD, 0, Some(&timed)));
// Arcane Missiles' shape: zero cast time, but the channel column's moving bits refuse
// at initiation (the OR-arm; 0x7c0c & 0x18 != 0).
let channel = SpellDisplay {
interrupt_flags: 0xf,
channel_interrupt_flags: 0x7c0c,
..Default::default()
};
assert!(cast_moving_refusal(mf::FORWARD, 0, Some(&channel)));
// The aura-column arm (food/drink sit-still bits).
let sit_still = SpellDisplay {
interrupt_flags: 0x1,
aura_interrupt_flags: 0x18,
..Default::default()
};
assert!(cast_moving_refusal(mf::FORWARD, 0, Some(&sit_still)));
// Auto-repeat (AttributesEx2 & 0x20) is unconditionally exempt — Auto Shot fires on
// the run whatever its columns say.
let auto_shot = SpellDisplay {
interrupt_flags: 0x1,
attributes_ex2: 0x20,
aura_interrupt_flags: 0x18,
..Default::default()
};
assert!(!cast_moving_refusal(mf::FORWARD, 0, Some(&auto_shot)));
// No record: nothing to read, the press passes (the server stays the net).
assert!(!cast_moving_refusal(mf::FORWARD, 1500, None));
}
}
+32 -9
View File
@@ -5,18 +5,17 @@
//! `notEnoughMana` — the §5's B2, re-confirmed.
//!
//! Modeled legs: the TRADE_SKILL early-out · dead (leg 1) · reagents/totems (leg 3) · required
//! equipped item (leg 4) · the shapeshift-form gate (leg 6, [`SpellDisplay::usable_in_form`]) ·
//! only-stealthed (leg 7) · not-in-combat (leg 8) · CasterAuraState (leg 9) · TargetAuraState +
//! its CanAttack/CanAssist fork (legs 10/10b — the ONE target-dependent pair; our per-frame
//! diff-push recomputes it on target change for free, where the ref re-runs its cache on events)
//! · the bit-25 cooldown fold (leg 11) · power (leg 12).
//! equipped item (leg 4) · the combo-point gate (leg 5) · the shapeshift-form gate (leg 6,
//! [`SpellDisplay::usable_in_form`]) · only-stealthed (leg 7) · not-in-combat (leg 8) ·
//! CasterAuraState (leg 9) · TargetAuraState + its CanAttack/CanAssist fork (legs 10/10b — the
//! ONE target-dependent pair; our per-frame diff-push recomputes it on target change for free,
//! where the ref re-runs its cache on events) · the bit-25 cooldown fold (leg 11) · power (leg 12).
//!
//! Deferred, named (each answers usable=true until modeled): leg 2's caster aura-immunity
//! helpers (`0x6e9f20/40/60` — silence/pacify vs the spell's school/mechanic; needs an aura-type
//! model), leg 5's self-only identity (`AttributesEx` b20/b22 + `caster+0xe68+0x1029`), leg 4's
//! `AttributesEx3` sub-conditions and the broken-durability exclusion (no durability model), and
//! the ghost state beyond plain death. CanAssist inside 10b is the reaction-rank stand-in the
//! ring/`can_attack` share, pending the true `0x6066f0` walk.
//! model), leg 4's `AttributesEx3` sub-conditions and the broken-durability exclusion (no
//! durability model), and the ghost state beyond plain death. CanAssist inside 10b is the
//! reaction-rank stand-in the ring/`can_attack` share, pending the true `0x6066f0` walk.
use std::time::Instant;
@@ -113,6 +112,16 @@ pub(crate) fn spell_usable(
if !equipped_item_fits(d, ctx.store, items, commands) {
return (false, false);
}
// Leg 5 (`0x6e3e95`–`0x6e3eb2`): the combo-point gate. A finishing move (`AttributesEx`
// b20/b22) is unusable while the caster's combo-point byte is 0 — `[caster+0xe68]+0x1029`,
// i.e. `PLAYER_FIELD_BYTES` byte 1 off the player-block base, one byte below the honor rank
// the item-usable gate reads at `+0x102b`. CASTER-only: the client never checks that the
// points sit on the CURRENT target (the server does, and refuses a mismatch with
// `SPELL_FAILED_BAD_TARGETS`) — see decision 0869. This is Overpower's whole window: it has
// no aura state, so every other leg passes and the button stayed lit forever without it.
if d.needs_combo_points() && ctx.store.0.player_combo_points().unwrap_or(0) == 0 {
return (false, false);
}
// Leg 6 (`0x612480`): the shapeshift-form gate — the form's stance flag from
// SpellShapeshiftForm.dbc decides whether it counts as "shapeshifted".
let form = ctx.store.0.unit_shapeshift_form();
@@ -264,6 +273,20 @@ mod tests {
};
assert_eq!(walk(&reagent, &alive), (false, false));
// Leg 5: a finishing move with no combo points banked (field 1222 byte 1). Overpower's
// own shape — no aura state anywhere, so this leg is the only thing that greys it.
let overpower = SpellDisplay {
attributes_ex: 0x4810_0200,
..Default::default()
};
assert!(overpower.needs_combo_points());
assert_eq!(walk(&overpower, &alive), (false, false));
let dodged = player(&[(1222, 0x05_03_01_01)]);
assert_eq!(walk(&overpower, &dodged), (true, false));
// The neighbouring bytes of that dword must not read as combo points.
let ranked = player(&[(1222, 0x05_03_00_01)]);
assert_eq!(walk(&overpower, &ranked), (false, false));
// Leg 6: a cat-form spell out of form.
let claw = SpellDisplay {
stances: 0x1,
+3 -1
View File
@@ -235,7 +235,9 @@ impl ActiveChannel {
/// own movement interrupt (`Spell::update`'s 0.5-yd position delta) still kills anything we
/// miss. The per-spell gates, byte-verified at the call sites (`f6 46 54 01` @`0x51511e`,
/// `f6 42 5c 08` @`0x515175`; identities corroborated by vmangos `SpellDefines.h`):
const SPELL_INTERRUPT_MOVEMENT: u32 = 0x1; // SpellRec+0x54 InterruptFlags (SPELL_INTERRUPT_FLAG_MOVEMENT)
/// (Also the entry test of the cast-INITIATION moving gate — `ui_action::state`'s
/// `cast_moving_refusal`, decision 0862 — which is a different mechanism from this self-cancel.)
pub(crate) const SPELL_INTERRUPT_MOVEMENT: u32 = 0x1; // SpellRec+0x54 InterruptFlags (SPELL_INTERRUPT_FLAG_MOVEMENT)
const CHANNEL_INTERRUPT_MOVING: u32 = 0x8; // SpellRec+0x5c ChannelInterruptFlags (AURA_INTERRUPT_MOVING_CANCELS)
/// The controller's report that a cancel-worthy local move edge happened this frame — a
+78 -91
View File
@@ -1,18 +1,16 @@
//! **TOGGLEUI** — the hide-the-interface binding (`CTRL-Z`, and `Cmd-Z` as its Mac twin).
//! **TOGGLEUI** — the hide-the-interface binding (`ALT-Z`).
//!
//! The state ([`UiHidden`]) and the binding that flips it; the two things that *obey* it are the
//! quad pass's mesh rebuild (`ui_pass::rebuild_ui_mesh` draws nothing) and the UI's pointer feed
//! (`ui_script::input::feed_ui_input` stops hit-testing). Kept out of `ui_pass` deliberately: this
//! is a *game binding*, not part of the render substrate.
use bevy::input::keyboard::KeyboardInput;
use bevy::input::ButtonState;
use bevy::prelude::*;
use crate::char_select::ClientState;
use crate::ui_script::{UiInput, UiKeyboardCapture};
/// Is the player UI hidden right now? (`CTRL-Z`/`Cmd-Z` — [`toggle_ui_hidden`].)
/// Is the player UI hidden right now? (`ALT-Z` — [`toggle_ui_hidden`].)
///
/// Hidden means *"draw nothing"*, not *"stop producing"*: the widget arena keeps ticking and both
/// quad lanes keep filling, so open panels, tooltip state, cooldown sweeps and chat scrollback are
@@ -20,7 +18,7 @@ use crate::ui_script::{UiInput, UiKeyboardCapture};
/// **Everything that lands in `ui_pass::UiQuads` goes dark together**: the FrameXML layer, the
/// minimap, the V-plates, chat bubbles and floating combat text — the world and nothing else,
/// which is the point of the binding. What stays up: the dev overlays (their own camera and their
/// own `Ctrl`+`Cmd` chords — an instrument, not the player's UI), the glue/loading screens (Bevy UI
/// own dev chords — an instrument, not the player's UI), the glue/loading screens (Bevy UI
/// nodes, not quads), and the cursor.
///
/// The UI also stops taking the **mouse** while hidden: an invisible action bar must not eat a
@@ -47,54 +45,52 @@ impl Plugin for UiHidePlugin {
}
}
/// `CTRL-Z` / `Cmd-Z` — the reference's TOGGLEUI binding (`BINDING_NAME_TOGGLEUI` in
/// `GlobalStrings.lua`; the 1.12.1 install's own `bindings-cache.wtf` reads `bind CTRL-Z TOGGLEUI`
/// on all three accounts in it), plus the `Cmd` twin — the Mac keyboard the director plays the
/// reference on presses `Cmd` for that binding, and nothing in game binds the Cmd plane.
/// `ALT-Z` — the reference's own TOGGLEUI default (`BINDING_NAME_TOGGLEUI` in `GlobalStrings.lua`),
/// on every platform.
///
/// Exactly one of the two, and no `Alt`/`Shift`: the reference names a binding
/// `[ALT-][CTRL-][SHIFT-]<key>` and matches it by string equality, so `CTRL-ALT-Z` is a *different*
/// entry and a modified press must never fall through to another binding (decision 0585's
/// bare-binding rule — the discipline `vplates::toggle_vplates` spends `SHIFT` under), while
/// `Ctrl`+`Cmd` together is the dev-overlay plane. Inert while an EditBox holds the keyboard: a
/// `Z` in a chat line is text.
/// **This binding was `CTRL-Z` here until 0870, and that was wrong** — a fidelity error worth naming
/// because of *how* it got in. The source was this install's `bindings-cache.wtf`, which reads
/// `bind CTRL-Z TOGGLEUI` on all three of its accounts; that file is what the client last *wrote*,
/// not what it ships with, and all three accounts descend from one profile whose TOGGLEUI had been
/// rebound. `ALT-Z` is the shipped default (director's correction; vanilla binding lists agree, and
/// `ALT-Z` is unbound in that same cache — exactly the hole a rebind leaves). A saved-state file is
/// evidence about a *player*, never about the client.
///
/// Driven off the key **message stream** rather than `ButtonInput`'s edges, because AppKit swallows
/// `keyUp` for a key released while `Cmd` is held: the release never reaches winit, `ButtonInput`
/// goes on believing `Z` is down, and the *second* `Cmd-Z` would never produce a `just_pressed`
/// edge — the UI would hide and refuse to come back. `repeat` presses are dropped so a held chord
/// doesn't strobe.
/// `ALT` and nothing else: the reference names a binding `[ALT-][CTRL-][SHIFT-]<key>` and matches it
/// by string equality, so `CTRL-ALT-Z` is a *different* entry and a modified press must never fall
/// through to another binding (decision 0585's bare-binding rule — the discipline
/// `vplates::toggle_vplates` spends `SHIFT` under). That block also keeps this clear of the
/// dev-overlay plane (`Ctrl`+`Shift` — [`crate::debug_panel::dev_chord`]). Inert while an EditBox
/// holds the keyboard: an `Alt-Z` in a chat line is the box's business.
///
/// Read off `ButtonInput`'s edge. It used to be driven off the raw key message stream because AppKit
/// swallows `keyUp` for a key released while **Cmd** is held, which would have latched the old
/// `Cmd-Z` twin after one use; with no Cmd anywhere in the binding that hazard doesn't exist, and
/// `just_pressed` doesn't repeat, so the repeat filter went with it (0870).
fn toggle_ui_hidden(
mut keyboard: MessageReader<KeyboardInput>,
keys: Res<ButtonInput<KeyCode>>,
ui_capture: Res<UiKeyboardCapture>,
mut hidden: ResMut<UiHidden>,
) {
let pressed = keyboard
.read()
.any(|ev| ev.key_code == KeyCode::KeyZ && ev.state == ButtonState::Pressed && !ev.repeat);
if !pressed || ui_capture.0 || !toggle_chord(&keys) {
if !keys.just_pressed(KeyCode::KeyZ) || ui_capture.0 || !toggle_chord(&keys) {
return;
}
hidden.0 = !hidden.0;
info!(
"ui: {} (Ctrl/Cmd-Z)",
if hidden.0 { "HIDDEN" } else { "shown" }
);
info!("ui: {} (Alt-Z)", if hidden.0 { "HIDDEN" } else { "shown" });
}
/// The modifier half of the chord: exactly one of `Ctrl`/`Cmd` held, and neither `Alt` nor `Shift`.
/// See [`toggle_ui_hidden`] for why each arm is what it is.
/// The modifier half of the binding: `ALT` held, and nothing else. See [`toggle_ui_hidden`].
fn toggle_chord(keys: &ButtonInput<KeyCode>) -> bool {
let ctrl = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]);
let cmd = keys.any_pressed([KeyCode::SuperLeft, KeyCode::SuperRight]);
let alt = keys.any_pressed([KeyCode::AltLeft, KeyCode::AltRight]);
let blocked = keys.any_pressed([
KeyCode::AltLeft,
KeyCode::AltRight,
KeyCode::ControlLeft,
KeyCode::ControlRight,
KeyCode::ShiftLeft,
KeyCode::ShiftRight,
KeyCode::SuperLeft,
KeyCode::SuperRight,
]);
(ctrl ^ cmd) && !blocked
alt && !blocked
}
/// Leaving the world clears the hide — safety, not fidelity: the binding is `InWorld`-only, so a UI
@@ -105,11 +101,13 @@ fn show_ui(mut hidden: ResMut<UiHidden>) {
#[cfg(test)]
mod tests {
use bevy::input::keyboard::KeyboardInput;
use bevy::input::ButtonState;
use super::*;
/// The plugin's own wiring, driven the way winit drives it: a real [`KeyboardInput`] message
/// plus the modifier mirror. Covers what the pure-chord tests can't — the message path (the
/// `Cmd`-swallowed-`keyUp` insurance), the repeat filter, and the typing gate.
/// The plugin's own wiring, driven the way winit drives it: the modifier mirror plus a real
/// `Z` edge. Covers what the pure-chord tests can't — the typing gate and the toggle itself.
fn app(held: &[KeyCode]) -> App {
let mut app = App::new();
app.add_plugins((
@@ -128,60 +126,55 @@ mod tests {
app
}
fn press_z(app: &mut App, repeat: bool) {
let window = Entity::PLACEHOLDER;
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::KeyZ,
logical_key: bevy::input::keyboard::Key::Character("z".into()),
state: ButtonState::Pressed,
text: Some("z".into()),
repeat,
window,
});
app.update();
/// One `Z` keystroke, down then up, fed the way winit feeds it — as [`KeyboardInput`] messages
/// that Bevy's own input system turns into the `just_pressed` edge this binding reads. The
/// release matters: `ButtonInput::press` only raises the edge for a key that wasn't already
/// down, so a second tap without one is not a second press (which is also why a held key's
/// auto-repeat can't strobe the toggle).
fn tap_z(app: &mut App) {
for state in [ButtonState::Pressed, ButtonState::Released] {
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::KeyZ,
logical_key: bevy::input::keyboard::Key::Character("z".into()),
state,
text: Some("z".into()),
repeat: false,
window: Entity::PLACEHOLDER,
});
app.update();
}
}
fn hidden(app: &App) -> bool {
app.world().resource::<UiHidden>().0
}
/// The binding toggles both ways off the message stream — the second press is the one an
/// edge-driven read would lose on macOS (AppKit eats the `keyUp` under `Cmd`).
/// `ALT-Z` toggles both ways — the reference's own TOGGLEUI default (0870).
#[test]
fn cmd_z_hides_and_shows_again() {
let mut app = app(&[KeyCode::SuperLeft]);
press_z(&mut app, false);
assert!(hidden(&app), "Cmd-Z hides");
press_z(&mut app, false);
assert!(!hidden(&app), "Cmd-Z again shows");
fn alt_z_hides_and_shows_again() {
let mut app = app(&[KeyCode::AltLeft]);
tap_z(&mut app);
assert!(hidden(&app), "Alt-Z hides");
tap_z(&mut app);
assert!(!hidden(&app), "Alt-Z again shows");
}
/// A held chord auto-repeats; only the real press counts, or the UI would strobe.
#[test]
fn repeats_do_not_strobe() {
let mut app = app(&[KeyCode::ControlLeft]);
press_z(&mut app, false);
press_z(&mut app, true);
press_z(&mut app, true);
assert!(hidden(&app), "the repeats after the press changed nothing");
}
/// While an EditBox owns the keyboard the chord is inert — `Ctrl-Z` in a chat line is the
/// While an EditBox owns the keyboard the binding is inert — an `Alt-Z` in a chat line is the
/// box's business, not a binding.
#[test]
fn inert_while_typing() {
let mut app = app(&[KeyCode::ControlLeft]);
let mut app = app(&[KeyCode::AltLeft]);
app.world_mut().resource_mut::<UiKeyboardCapture>().0 = true;
press_z(&mut app, false);
assert!(!hidden(&app), "a captured keyboard eats the chord");
tap_z(&mut app);
assert!(!hidden(&app), "a captured keyboard eats the binding");
}
/// The bare-binding rule at the wiring level: an unmodified `Z` is the sheath toggle
/// (`player::control`), and it must not also hide the UI.
/// (`player::control`, and `Z TOGGLESHEATH` in the reference), and it must not also hide the UI.
#[test]
fn bare_z_is_not_the_binding() {
let mut app = app(&[]);
press_z(&mut app, false);
tap_z(&mut app);
assert!(!hidden(&app), "bare Z belongs to the sheath toggle");
}
@@ -193,33 +186,27 @@ mod tests {
k
}
/// The chord accepts either command modifier alone — `CTRL-Z` is the reference's own entry,
/// `Cmd-Z` the Mac twin — and both sides of each family.
/// `ALT` alone is the binding, either side of the family.
#[test]
fn either_command_modifier_alone_fires() {
for held in [
KeyCode::ControlLeft,
KeyCode::ControlRight,
KeyCode::SuperLeft,
KeyCode::SuperRight,
] {
fn either_alt_alone_fires() {
for held in [KeyCode::AltLeft, KeyCode::AltRight] {
assert!(toggle_chord(&keys(&[held])), "{held:?} alone fires");
}
}
/// Decision 0585's bare-binding rule, both directions: an unmodified `Z` is the *sheath*
/// binding and must not hide the UI, and a press carrying an extra modifier names a different
/// binding entry (`CTRL-ALT-Z`, `CTRL-SHIFT-Z`) that this one must not answer for. `Ctrl`+`Cmd`
/// is the dev-overlay plane and is likewise not ours.
/// binding, and a press carrying an extra modifier names a different entry (`CTRL-ALT-Z`,
/// `ALT-SHIFT-Z`) that this one must not answer for. The old `CTRL-Z`/`Cmd-Z` arms are gone
/// (0870) — neither is the reference's default, and `Win+Z` is Snap Layouts.
#[test]
fn bare_and_over_modified_presses_are_not_the_binding() {
assert!(!toggle_chord(&keys(&[])), "bare Z is the sheath binding");
for held in [
vec![KeyCode::ControlLeft, KeyCode::AltLeft],
vec![KeyCode::ControlLeft, KeyCode::ShiftLeft],
vec![KeyCode::SuperLeft, KeyCode::AltRight],
vec![KeyCode::SuperLeft, KeyCode::ShiftRight],
vec![KeyCode::ControlLeft, KeyCode::SuperLeft],
vec![KeyCode::ControlLeft],
vec![KeyCode::SuperLeft],
vec![KeyCode::AltLeft, KeyCode::ControlRight],
vec![KeyCode::AltLeft, KeyCode::ShiftRight],
vec![KeyCode::AltLeft, KeyCode::SuperLeft],
] {
assert!(!toggle_chord(&keys(&held)), "{held:?} is another binding");
}
+5 -5
View File
@@ -197,11 +197,11 @@ pub(super) fn feed_ui_input(
// around `arg1` and compared to `GetBindingKey("TOGGLEWORLDMAP")`), and `ALT-`/`CTRL-`/`SHIFT-`
// are three separate strings in `WoW.exe`. So `CTRL-P` is a *different* entry from `P` and a
// modified press never falls through to the unmodified binding. Ours must not either — otherwise
// the dev-overlay chords (Ctrl+Cmd+P / Ctrl+Cmd+I, decision 0585) would ALSO fire the letter's
// game action. `sup` is our one addition to the reference's three: Cmd isn't a binding modifier in
// the Mac client's scheme, but we've claimed the Cmd plane for the dev instruments and nothing in
// game binds it. (A binding that *wants* a modifier declares it — see `vplates::toggle_vplates`,
// which spends SHIFT itself for the V / SHIFT-V pair.)
// the dev-overlay chords would ALSO fire the letter's game action (`Ctrl`+`Shift`+key —
// decisions 0585/0867/0870). `sup` is our one addition to the reference's three: Cmd is not a
// binding modifier in any client's scheme, so a bare binding has no business firing under it
// either. (A binding that *wants* a modifier declares it — see `vplates::toggle_vplates`, which
// spends SHIFT itself for the V / SHIFT-V pair.)
let modified = shift || ctrl || alt || sup;
for ev in keyboard.read() {
// ACTIONBUTTON1..12 (the number row 1..9,0,-,= — the bar's own HotKey labels): the ref's