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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
(
|
||||
|
||||
@@ -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
|
||||
])));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user