diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index c9ca3045..0b4c7d69 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -411,6 +411,20 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ minute: 720, ui: None, }, + // ---- The Tainted Scar, Blasted Lands (report B90: "the skybox is almost pure white") ---- + // A level look across the crater from the reported spot, so the top half of the frame is + // nothing but sky. The zone's `LightParams` 36 is the shape that matters: cloud density + // **0.85** (Elwynn's is 0.50) puts near-total overcast on the dome, so whatever colour the + // cloud layer resolves IS the sky here — which is why this zone shows a cloud-palette defect + // that a half-clouded zone hides. Kept as the standing reproducer for the cloud palette. + Scenario { + name: "tainted-scar-noon", + map: MAP_AZEROTH, + eye: SCAR_EYE, + look: SCAR_LOOK, + minute: 720, + ui: None, + }, // ---- Evicted from the blessed sweep by decision 0817, NOT deleted ---- // The sweep had grown from 0632's six to twenty-one, i.e. 42 windows on the director's screen per // `selfcheck`, and the director cut it back to three spots x two day times. Everything below this @@ -1033,3 +1047,12 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ /// model-space coords. pub(super) const TRAM_EYE: [f32; 3] = [-2.44, -1250.0, -120.0]; pub(super) const TRAM_LOOK: [f32; 3] = [-2.44, -1400.0, -118.0]; + +/// The Tainted Scar — see the `tainted-scar-noon` scenario. The eye is the ledger's own pin for +/// report B90 (`.go xyz -11892.70 -2647.08 -4.68 0`, `game_tele TheTaintedScar`) lifted clear of +/// the crater floor; the look is 25 deg above level, north. The pitch is the whole point: the crater +/// rim is tall enough that a dead-level look photographs nothing but rock, and the subject here is +/// the dome. At 25 deg the ridge line sits low in the frame as the control and the top two thirds +/// are sky. +pub(super) const SCAR_EYE: [f32; 3] = [-11892.7, -2647.1, 20.0]; +pub(super) const SCAR_LOOK: [f32; 3] = [-11792.7, -2647.1, 66.6]; diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index 5132256d..5de6d6a9 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -649,7 +649,13 @@ pub(crate) use sheath::{ /// The `SMSG_EMOTE` → [`EmoteAnim`] consumer (Part 1 of the emote-animation wiring) — kept /// alongside `sheath` as its own small concern. mod emote_anim; + +/// The client-local gesture producers (decision 1469): the chat talk/question/exclamation/shout/ +/// laugh, and the NPC-interact talk. The reference's `0x60bb30`, which shares the one-shot player +/// with `SMSG_EMOTE`. +mod gesture; use emote_anim::emote_to_anim; +pub(crate) use gesture::{select_gesture, Gesture, GestureQueue}; /// The Bevy driver systems that execute the state machine [`select`] picks (decision 0049 + 0073, /// decision 0087's one-shot routing, decision 0080's sheath execution) — kept in its own file as @@ -982,6 +988,7 @@ impl Plugin for CreatureAnimPlugin { .add_message::() .init_resource::() .init_resource::() + .init_resource::() .add_message::() .add_message::() .add_message::() @@ -1027,6 +1034,10 @@ impl Plugin for CreatureAnimPlugin { // The landing predictor's dust leg (the vocal leg lives in `sound`). hard_landing_dust, emote_to_anim, + // The client-local gestures (decision 1469) — the chat talk/shout/laugh and + // the NPC-interact talk. The client's second (and only other) producer of a + // one-shot emote; same place in the order as the wire one, for the same reason. + gesture::drive_gestures, // The flourish hop (unit → mount child) — before the driver so the // one-shot lands the same frame the packet (or space press) arrived. flourish_to_anim, diff --git a/crates/benilla-app/src/creature_anim/emote_anim.rs b/crates/benilla-app/src/creature_anim/emote_anim.rs index 28e698db..70a02a89 100644 --- a/crates/benilla-app/src/creature_anim/emote_anim.rs +++ b/crates/benilla-app/src/creature_anim/emote_anim.rs @@ -60,15 +60,8 @@ pub(super) fn emote_to_anim( continue; }; let (store, movement, remote) = units.get(entity).unwrap_or((None, None, None)); - let stand_state = store.map_or(0, |s| s.0.unit_stand_state()); - let swimming = movement - .map(|m| m.flags) - .or_else(|| remote.map(|r| r.flags)) - .unwrap_or(0) - & move_flags::SWIMMING - != 0; - if !receive_eligible(stand_state, swimming) { - debug!("emote_anim: suppressed anim {anim_id} for {entity:?} (SLEEP or swimming)"); + if !play_eligible(store, movement, remote) { + debug!("emote_anim: suppressed anim {anim_id} for {entity:?}"); continue; } out.write(EmoteAnim { @@ -79,14 +72,53 @@ pub(super) fn emote_to_anim( } } -/// The receive-side posture gate (module doc — `EmoteFlags`-BLIND, unlike the send-side gate in +/// The **posture** half of the gate (module doc — `EmoteFlags`-BLIND, unlike the send-side gate in /// `crate::ui_chat::emote_send_eligible`): suppress only at stand-state 3 (SLEEP) or while swimming. /// A merely-seated performer (SIT=1 / SIT_CHAIR=2 / KNEEL=8 / chair 4-6) is **not** suppressed — /// its anim plays and the composition layer masks it to waist-up. +/// +/// Both producers apply this identically — `SMSG_EMOTE`'s handler at `0x5e6706`/`0x5e66f7`, and the +/// client-local gesture dispatcher at `0x60bb52`/`0x60bb61` — which is why it is a free function and +/// not a step of either. pub(super) fn receive_eligible(stand_state: u8, swimming: bool) -> bool { stand_state != 3 && !swimming } +/// The **shared player's** half of the gate — the client's `0x5fcd20`, the single function both +/// emote producers tail into (wow-re `chat-talk-gesture.md` §4.2/§8, gates 10 and 12). Beyond the +/// posture pair it refuses to play while the unit is **channeling** or **in combat**. +/// +/// Two of `0x5fcd20`'s own tests are not repeated here because benilla already enforces them +/// downstream or cannot see them: the *already-armed* test (gate 9) is the anim driver's same-id +/// dedup, and `[+0xd58] & 0x400` (gate 11) is an internal anim-state bit with no benilla equivalent. +fn player_eligible(channeling: bool, in_combat: bool) -> bool { + !channeling && !in_combat +} + +/// The whole gate for one unit, from its live components — the predicate both producers call. +pub(super) fn play_eligible( + store: Option<&ObjectStore>, + movement: Option<&MovementState>, + remote: Option<&RemoteMotion>, +) -> bool { + let fields = store.map(|s| &s.0); + let stand_state = fields.map_or(0, |f| f.unit_stand_state()); + let swimming = movement + .map(|m| m.flags) + .or_else(|| remote.map(|r| r.flags)) + .unwrap_or(0) + & move_flags::SWIMMING + != 0; + let channeling = fields.is_some_and(|f| f.unit_channel_spell() != 0); + let in_combat = fields.is_some_and(|f| f.unit_flags() & UNIT_FLAG_IN_COMBAT != 0); + receive_eligible(stand_state, swimming) && player_eligible(channeling, in_combat) +} + +/// `UNIT_FIELD_FLAGS & 0x800` — `UNIT_FLAG_IN_COMBAT`. The client's gate-12 test is "this flag **or** +/// a local auto-attack target is set"; benilla checks the wire flag alone, so a unit that has swung +/// but whose combat flag has not yet streamed can still gesture for a moment. +const UNIT_FLAG_IN_COMBAT: u32 = 0x800; + /// The pure mapping: an `Anim` emote resolves through `lookup` (the catalog's `Emotes.dbc` → /// `AnimID`); a `Text` emote never carries an anim id through this path — its animation, when it /// has one, arrives separately as the *same action's* `SMSG_EMOTE` (see the module doc). @@ -125,6 +157,18 @@ mod tests { assert!(!receive_eligible(0, true)); } + /// The shared player refuses while channeling or in combat — the two `0x5fcd20` tests benilla + /// was missing on BOTH producers, not just the new one (wow-re `chat-talk-gesture.md` §9 claim 2). + #[test] + fn channeling_or_combat_suppresses_the_play() { + assert!( + player_eligible(false, false), + "idle and out of combat plays" + ); + assert!(!player_eligible(true, false), "channeling suppresses"); + assert!(!player_eligible(false, true), "in combat suppresses"); + } + #[test] fn merely_seated_is_not_suppressed_on_receive() { // Unlike the send-side gate, sit/sit-chair/chair/kneel all pass here — only SLEEP does not. diff --git a/crates/benilla-app/src/creature_anim/gesture.rs b/crates/benilla-app/src/creature_anim/gesture.rs new file mode 100644 index 00000000..c748b739 --- /dev/null +++ b/crates/benilla-app/src/creature_anim/gesture.rs @@ -0,0 +1,356 @@ +//! The **client-local gesture** — the second producer of a one-shot emote, and the one benilla was +//! missing (bug B157, decision 1469). +//! +//! Nothing on the wire announces it. The real client's chat *display* path picks a gesture code from +//! the message itself and calls `0x60bb30(unit, code)`; that resolves the code through a five-slot +//! table of `Emotes.dbc` rows and hands the emote id to `0x5fcd20` — the **same** function +//! `SMSG_EMOTE`'s handler calls, and the only one in the whole client that turns an `Emotes.dbc` id +//! into a played animation. So the gesture is not a new animation path: it is a new *producer* for +//! the one benilla already has ([`super::EmoteAnim`]). A client that only reacts to `SMSG_EMOTE` +//! plays nothing at all, which is exactly what was reported. +//! +//! The same dispatcher has a second caller — the **NPC-interact** path, always with code 0 (talk) — +//! which is why [`crate::target::click`] pushes through here too instead of writing a raw AnimID. +//! +//! Byte-exact spec: wow-re `object-layer/scratch/chat-talk-gesture.md`. + +use bevy::prelude::*; + +use crate::net::{GuidIndex, ObjectStore, RemoteMotion}; + +use super::{EmoteAnim, MovementState, PlaySeq}; + +/// A gesture code — an index into `Emotes.dbc`'s five hard-coded `EmoteFlags` slots +/// (`benilla_formats::emotes::GESTURE_FLAG_BITS`), which is literally the integer the client's +/// `0x60bb30` takes. **The ids and AnimIDs behind these are content, not code**: the client scans +/// the DBC for the flag bit, and so do we ([`benilla_formats::EmoteSoundCatalog::gesture`]). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Gesture { + /// `EmoteFlags 0x08` — a plain say, and every NPC interaction. + Talk, + /// `EmoteFlags 0x10` — a say whose last byte is `?`. + Question, + /// `EmoteFlags 0x20` — a say whose last byte is `!`. + Exclamation, + /// `EmoteFlags 0x40` — any yell, whatever its text. + Shout, + /// `EmoteFlags 0x100` — a message that IS one of the `LAUGH_WORDn` globals, whole and + /// case-insensitively. + Laugh, +} + +impl Gesture { + /// The slot index — the client's `code`. + pub(crate) fn slot(self) -> usize { + match self { + Self::Talk => 0, + Self::Question => 1, + Self::Exclamation => 2, + Self::Shout => 3, + Self::Laugh => 4, + } + } +} + +/// Gestures asked for this frame and not yet played: `(speaker guid, code)`. Filled by the chat +/// display path and the interact click — the client's two callers of `0x60bb30` — and drained by +/// [`drive_gestures`]. A queue rather than a direct write because the producers sit in the UI lane +/// and the consumer needs the world's object index; the chat bubble is queued the same way and for +/// the same reason. +#[derive(Resource, Default)] +pub(crate) struct GestureQueue(Vec<(u64, Gesture)>); + +impl GestureQueue { + /// Ask for `gesture` on the unit with `guid`. A zero guid (a system line, a channel notice) has + /// no speaker and is dropped here rather than failing the lookup later. + pub(crate) fn push(&mut self, guid: u64, gesture: Gesture) { + if guid != 0 { + self.0.push((guid, gesture)); + } + } +} + +/// Pick the gesture a chat line plays, from the **raw wire chat type** and the message text — the +/// client's selector at `0x49d7d0`–`0x49d8ae`, in its exact order. +/// +/// 1. Only `SAY`, `YELL` and `PARTY` are eligible at all. Note this is the *wire* type, so +/// `MONSTER_SAY`/`MONSTER_YELL` — what a creature's ambient barks arrive as — are **not** +/// eligible: a shouting vendor does not gesture. +/// 2. The message is compared **whole and case-insensitively** against `LAUGH_WORD1`, `2`, …, +/// stopping at the first global that is missing or empty. A match is [`Gesture::Laugh`] — and +/// this is the one branch `PARTY` can reach, so a party line that IS a laugh word laughs, while `/p hello!` does nothing. +/// 3. Otherwise `PARTY` stops here. +/// 4. `YELL` is always [`Gesture::Shout`] — its text is never inspected, so a yell ending in `!` +/// still shouts rather than exclaiming. +/// 5. `SAY` looks at the **last byte only** — `?` → [`Gesture::Question`], `!` → +/// [`Gesture::Exclamation`], anything else (including an empty message) → [`Gesture::Talk`]. +/// Not "ends with `!!`", not a punctuation count, no trailing-whitespace trim. +/// +/// `laugh_word(n)` returns the `LAUGH_WORDn` global — `None` ends the list. It is a callback so the +/// words come off the player's own FrameXML at call time, which is where they belong: the +/// *enumeration* is the mechanism, the word list is content. +pub(crate) fn select_gesture( + chat_type: u8, + text: &str, + mut laugh_word: impl FnMut(u32) -> Option, +) -> Option { + use benilla_protocol::messages as m; + if !matches!( + chat_type, + m::CHAT_MSG_SAY | m::CHAT_MSG_YELL | m::CHAT_MSG_PARTY + ) { + return None; + } + for n in 1.. { + match laugh_word(n) { + Some(word) if word.is_empty() => break, + Some(word) if word.eq_ignore_ascii_case(text) => return Some(Gesture::Laugh), + Some(_) => continue, + None => break, + } + } + match chat_type { + m::CHAT_MSG_YELL => Some(Gesture::Shout), + m::CHAT_MSG_SAY => Some(match text.as_bytes().last() { + Some(b'?') => Gesture::Question, + Some(b'!') => Gesture::Exclamation, + _ => Gesture::Talk, + }), + _ => None, // PARTY, having failed the laugh scan + } +} + +/// Drain [`GestureQueue`] into the one-shot player: resolve the speaker, resolve the code to an +/// `Emotes.dbc` id through the DBC's own flag-bit scan, gate, and write [`EmoteAnim`] — the client's +/// `0x60bb30` and its tail-call into the shared `0x5fcd20`. +pub(super) fn drive_gestures( + mut queue: ResMut, + mut out: MessageWriter, + mut play_seq: ResMut, + index: Res, + emotes: Option>, + units: Query<( + Option<&ObjectStore>, + Option<&MovementState>, + Option<&RemoteMotion>, + )>, +) { + let asked = std::mem::take(&mut queue.0); + let Some(emotes) = emotes else { return }; + for (guid, gesture) in asked { + let Some(&entity) = index.0.get(&guid) else { + continue; // the speaker is not streamed to us — nothing to animate + }; + // The slot may be empty if a patch ever ships without a row carrying the bit; the client + // bails the same way (`0x60bb43`). + let Some(emote_id) = emotes.gesture(gesture.slot()) else { + continue; + }; + let Some(anim_id) = emotes.anim(emote_id) else { + continue; + }; + let (store, movement, remote) = units.get(entity).unwrap_or((None, None, None)); + if !super::emote_anim::play_eligible(store, movement, remote) { + debug!("gesture: {gesture:?} suppressed for {entity:?}"); + continue; + } + out.write(EmoteAnim { + entity, + anim_id: anim_id as u16, + seq: play_seq.next(), + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use benilla_protocol::messages as m; + + /// A stand-in `LAUGH_WORDn` list. The words are **invented on purpose**: the shipped ones are + /// the install's own `GlobalStrings.lua` content, which never enters this repo (the contract), and + /// the mechanism under test is the enumeration, not the vocabulary. A test that passes with + /// nonsense words is a test that proves the list is not baked into the code. + fn words(n: u32) -> Option { + ["snrk", "guffaw", "tehe"] + .get(n as usize - 1) + .map(|s| s.to_string()) + } + fn no_words(_: u32) -> Option { + None + } + + /// The reporter's own message: `fdfdf!!` said aloud is an EXCLAMATION, because only the last + /// byte is read. This is the case the screenshot shows. + #[test] + fn the_last_byte_alone_picks_the_say_gesture() { + let say = |t: &str| select_gesture(m::CHAT_MSG_SAY, t, no_words); + assert_eq!(say("fdfdf!!"), Some(Gesture::Exclamation)); + assert_eq!(say("what?"), Some(Gesture::Question)); + assert_eq!(say("hello there"), Some(Gesture::Talk)); + assert_eq!( + say("! leading"), + Some(Gesture::Talk), + "the LAST byte, not any" + ); + assert_eq!(say("trailing space! "), Some(Gesture::Talk), "no trim"); + assert_eq!(say(""), Some(Gesture::Talk), "an empty say still talks"); + } + + /// A yell never reads its text — punctuation and all. + #[test] + fn a_yell_always_shouts() { + assert_eq!( + select_gesture(m::CHAT_MSG_YELL, "run!", no_words), + Some(Gesture::Shout) + ); + assert_eq!( + select_gesture(m::CHAT_MSG_YELL, "where?", no_words), + Some(Gesture::Shout) + ); + } + + /// The laugh scan is whole-string and case-insensitive, and it runs BEFORE the say/yell split — + /// so it is the only gesture a party line can reach, and it beats a yell's shout. + #[test] + fn the_laugh_scan_is_whole_string_and_outranks_the_rest() { + assert_eq!( + select_gesture(m::CHAT_MSG_SAY, "SnRk", words), + Some(Gesture::Laugh), + "case-insensitive" + ); + assert_eq!( + select_gesture(m::CHAT_MSG_SAY, "snrk that was good", words), + Some(Gesture::Talk), + "whole-string equality, never a substring" + ); + assert_eq!( + select_gesture(m::CHAT_MSG_YELL, "guffaw", words), + Some(Gesture::Laugh), + "the scan runs before the yell branch" + ); + assert_eq!( + select_gesture(m::CHAT_MSG_PARTY, "tehe", words), + Some(Gesture::Laugh), + "the one gesture a party line reaches" + ); + assert_eq!( + select_gesture(m::CHAT_MSG_PARTY, "hello!", words), + None, + "a party line with no laugh word gestures nothing" + ); + } + + /// The list ends at the first missing OR empty global, so a hole hides everything after it. + #[test] + fn an_empty_global_ends_the_laugh_list() { + let holed = |n: u32| match n { + 1 => Some("snrk".to_string()), + 2 => Some(String::new()), + 3 => Some("tehe".to_string()), + _ => None, + }; + assert_eq!( + select_gesture(m::CHAT_MSG_SAY, "snrk", holed), + Some(Gesture::Laugh) + ); + assert_eq!( + select_gesture(m::CHAT_MSG_SAY, "tehe", holed), + Some(Gesture::Talk), + "the scan stopped at the empty LAUGH_WORD2" + ); + } + + /// Everything else on the wire is ineligible — including a creature's ambient bark, which + /// arrives as MONSTER_SAY/MONSTER_YELL, not SAY/YELL. + #[test] + fn only_say_yell_and_party_are_eligible() { + for ty in [ + m::CHAT_MSG_MONSTER_SAY, + m::CHAT_MSG_MONSTER_YELL, + m::CHAT_MSG_WHISPER, + m::CHAT_MSG_GUILD, + m::CHAT_MSG_CHANNEL, + m::CHAT_MSG_EMOTE, + m::CHAT_MSG_SYSTEM, + ] { + assert_eq!( + select_gesture(ty, "hello!", words), + None, + "chat type {ty:#x} must not gesture" + ); + } + } + + /// Drive the real system over the real `Emotes.dbc`: a queued gesture on a streamed unit must + /// come out as an [`EmoteAnim`] carrying the shipped AnimID — and a unit **in combat** must come + /// out with nothing (the shared player's gate). + /// + /// This is the test that catches the mechanism being built and never firing: it exercises the + /// queue, the guid resolve, the DBC scan and the gate, not just the selector. + #[test] + fn a_queued_gesture_reaches_the_one_shot_player() { + use crate::net::{GuidIndex, ObjectStore}; + use benilla_protocol::ObjectFields; + + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let cat = benilla_formats::load_emote_sound_catalog(&mut chain).expect("emote catalog"); + + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(crate::sound::EmoteSounds(cat)) + .add_message::() + .add_systems(Update, drive_gestures); + + // A plain streamed unit, and a second one flagged IN_COMBAT (UNIT_FIELD_FLAGS = field 46). + let speaker = app.world_mut().spawn(ObjectStore::default()).id(); + let fighter = app + .world_mut() + .spawn(ObjectStore(ObjectFields::from_pairs(&[(46, 0x800)]))) + .id(); + { + let mut index = app.world_mut().resource_mut::(); + index.0.insert(11, speaker); + index.0.insert(22, fighter); + } + app.world_mut() + .resource_mut::() + .push(11, Gesture::Exclamation); + app.world_mut() + .resource_mut::() + .push(22, Gesture::Talk); + // A speaker we have never streamed must not panic or produce anything. + app.world_mut() + .resource_mut::() + .push(999, Gesture::Talk); + app.update(); + + let played: Vec<_> = app + .world_mut() + .resource_mut::>() + .drain() + .collect(); + assert_eq!(played.len(), 1, "only the eligible speaker gestures"); + assert_eq!(played[0].entity, speaker); + assert_eq!( + played[0].anim_id, 64, + "EXCLAMATION resolves through the shipped DBC to EmoteTalkExclamation" + ); + assert!( + app.world().resource::().0.is_empty(), + "the queue drains every frame" + ); + } + + /// A zero guid has no speaker and never reaches the lookup. + #[test] + fn a_speakerless_line_is_dropped_at_the_queue() { + let mut q = GestureQueue::default(); + q.push(0, Gesture::Talk); + q.push(7, Gesture::Talk); + assert_eq!(q.0, vec![(7, Gesture::Talk)]); + } +} diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index d8014824..53f3865e 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -526,6 +526,9 @@ pub fn run(build: BuildId) -> AppExit { // The glyph atlas (client TTFs -> baked bitmap) `ui_script`'s extraction draws `FontString` // regions through. Loads at Startup, after the asset chain opens (decision 0068 §2). .add_plugins(UiTextPlugin) + // The one "which NPC am I interacting with" answer, shared by the portrait booth's `"npc"` + // token and the interaction face-me (decision 1467) — hence its own plugin, ahead of both. + .add_plugins(ui_session::UiSessionPlugin) // Unit-frame portraits: the token -> off-screen-baked-face bridge the UI extract samples for a // `SetPortraitTexture`-bound region (the modern high-res 2D model bake). .add_plugins(PortraitPlugin) diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 5c6dc4b0..672b7221 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -37,7 +37,7 @@ use apply::{apply_net_updates, tag_self_player}; // and are pulled in here for the plugin + the event bridge. pub(crate) use io::LoginRequest; use motion::{ - drain_pending_moves, extrapolate_remote_units, face_target, ground_clamp_creatures, + drain_pending_moves, drive_display_facing, extrapolate_remote_units, ground_clamp_creatures, mark_swimming_creatures, sample_splines, }; pub(crate) use motion::{ @@ -137,12 +137,19 @@ impl Plugin for NetPlugin { // the freshly-applied state and reconcile-lerps toward the next queued head. drain_pending_moves, extrapolate_remote_units, - // Idle creatures turn to face their target here — after the movers (spline / remote) - // have set their poses, so a face turn reads the target's fresh position this frame. - face_target, + // The client-local facing turn runs here — after the movers (spline / remote) + // have set their poses, so a turn reads the goal's fresh position this frame. + // A stationary unit squares up on its target, and the NPC whose interaction + // window is open turns to face us (decision 1467). + drive_display_facing, ) .chain() - .in_set(WorldStage::Net), + .in_set(WorldStage::Net) + // `drive_display_facing` reads `InteractNpc`; ordering the chain after its + // writer keeps the read deterministic rather than schedule-order-dependent. + // The cost is that the writer sees last frame's window state, which cannot + // matter: a window is open for seconds and the ease takes ~8 frames. + .after(crate::ui_session::feed_interact_npc), ) // Not part of the movement chain above: one send on the world-enter message. .add_systems(Update, send_query_time.in_set(WorldStage::Net)) diff --git a/crates/benilla-app/src/net/apply/objects.rs b/crates/benilla-app/src/net/apply/objects.rs index 0ee34482..d9f7f171 100644 --- a/crates/benilla-app/src/net/apply/objects.rs +++ b/crates/benilla-app/src/net/apply/objects.rs @@ -204,7 +204,7 @@ pub(super) fn object_create( commands.entity(e).remove::(); } } - write_pose(transforms, e, position, placement); + write_pose(commands, transforms, e, position, placement); // Overlay the fresh snapshot's descriptor fields onto the existing store. merge_fields(stores, pending, e, guid, fields); } else { @@ -268,7 +268,7 @@ pub(super) fn object_move( commands.entity(e).remove::(); // A movement block carries a yaw and nothing else — the only GameObjects that get one are // transports, whose pose the transport tick owns from the next frame (decision 0438). - write_pose(transforms, e, position, wire_yaw(orientation)); + write_pose(commands, transforms, e, position, wire_yaw(orientation)); } } @@ -382,7 +382,13 @@ pub(super) fn unit_move( debug_assert_eq!(fire_ms, now_ms, "a seeding packet fires at arrival"); trace_relay(guid, &mv, &rm.relay, now_ms, 0, RelayOutcome::Seed); apply_move(e, &mv, &mut rm, now_ms, commands, landings); - write_pose(transforms, e, mv.position, wire_yaw(mv.orientation)); + write_pose( + commands, + transforms, + e, + mv.position, + wire_yaw(mv.orientation), + ); commands.entity(e).insert(rm); } } @@ -486,14 +492,17 @@ pub(super) fn monster_move( duration_ms, ); // Apply the dictated final facing (moveType 2/3/4) as a **snap** — faithful to the - // client, which stores it straight into the unit's movement facing (`0x7c6f30`). + // client, which stores it straight into the unit's **raw** movement facing (`0x7c6f30`). // This is the *packet*-driven re-face — a scripted/emote/aggro `SetFacingTo` the - // server actually sends. (The stationary combat re-face carries no packet — vmangos - // `SetInFront` is server-only — and is handled client-side by `face_target`.) When a - // real path follows, `sample_splines` overwrites the rotation with the travel - // direction each frame (faithful — the client's spline-follow snaps the mesh yaw to - // the path tangent; wow-re body-facing §4). The receipt snap thus only sticks for a - // path-less move (a `Stop`/in-place re-face); a moving unit ends on its last tangent. + // server actually sends. (The client-local re-faces — squaring up on a target, and + // turning to face you while an interaction window is open — carry no packet at all and + // are `motion::drive_display_facing`'s, decision 1467.) Because it is the raw facing + // that moved, the display smoother's state goes with it: the unit re-seeds from this + // pose instead of swinging back to the heading it was snapped off. When a real path + // follows, `sample_splines` overwrites the rotation with the travel direction each + // frame (faithful — the client's spline-follow snaps the mesh yaw to the path tangent; + // wow-re body-facing §4). The receipt snap thus only sticks for a path-less move (a + // `Stop`/in-place re-face); a moving unit ends on its last tangent. if !matches!(facing, MonsterMoveFacing::None) { let target_pos = |g: u64| { index @@ -505,6 +514,9 @@ pub(super) fn monster_move( if let Some(orientation) = resolve_facing(facing, start, target_pos) { if let Ok(mut t) = transforms.get_mut(e) { t.rotation = Quat::from_rotation_y(orientation); + commands + .entity(e) + .remove::(); } } } diff --git a/crates/benilla-app/src/net/motion.rs b/crates/benilla-app/src/net/motion.rs index 12711e96..230973e8 100644 --- a/crates/benilla-app/src/net/motion.rs +++ b/crates/benilla-app/src/net/motion.rs @@ -29,8 +29,9 @@ mod spline; #[cfg(test)] mod tests; +pub(in crate::net) use facing::DisplayFacing; pub(crate) use facing::FacingStep; -pub(super) use facing::{face_target, resolve_facing}; +pub(super) use facing::{drive_display_facing, resolve_facing}; pub(crate) use relay::{PendingMove, RelayMove}; pub(crate) use remote::jump_seed; pub(crate) use remote::RemoteMotion; @@ -95,7 +96,14 @@ pub(super) fn pose_transform(position: [f32; 3], rotation: Quat) -> Transform { /// (the renderer bakes per-display model scale into the transform; a move must not reset it). The /// entity may not be query-visible the very frame it spawned — then the move is simply skipped (the /// spawn pose already placed it, and the next move lands once commands flush). +/// +/// **A wire pose is authoritative over the client-local facing turn**, so it drops any +/// [`DisplayFacing`]: the smoother re-seeds its raw facing and its history ring from the pose +/// written here, which is the client's spawn/resync shape (`0x601020` sets the goal and wipes the +/// ring). Doing it here rather than at the call sites is deliberate — a future pose writer cannot +/// forget it and leave a unit swinging back to a heading the server has since moved it off. pub(super) fn write_pose( + commands: &mut Commands, transforms: &mut Query<&mut Transform>, e: Entity, position: [f32; 3], @@ -104,6 +112,7 @@ pub(super) fn write_pose( if let Ok(mut t) = transforms.get_mut(e) { t.translation = wow_to_bevy(position); t.rotation = rotation; + commands.entity(e).remove::(); } } diff --git a/crates/benilla-app/src/net/motion/facing.rs b/crates/benilla-app/src/net/motion/facing.rs index 885bf2d1..75a45233 100644 --- a/crates/benilla-app/src/net/motion/facing.rs +++ b/crates/benilla-app/src/net/motion/facing.rs @@ -1,20 +1,32 @@ -//! Facing: the wire's `MonsterMoveFacing` resolution ([`resolve_facing`]) and the client-local -//! idle re-face ([`face_target`] — a stationary mob squares up on its target with no packet at -//! all), with the [`FacingStep`] latch the anim layer's turn-shuffle reads (decision 0123). +//! Facing: the wire's `MonsterMoveFacing` resolution ([`resolve_facing`]) and the unit +//! **display-facing smoother** ([`drive_display_facing`]) — the client's `0x600cd0` goal chain and +//! its `+0xc98` box filter — with the [`FacingStep`] latch the anim layer's turn-shuffle reads +//! (decision 0123). +//! +//! **A unit has two facings and only one of them is the wire's** (wow-re +//! `object-layer/scratch/interaction-facing.md`, `body-facing-pipeline.md`'s 2026-07-04 CORRECTION). +//! The raw movement facing `CMovement+0x1c` is what the server put there; the *rendered character +//! body root* is the smoothed display facing `CGUnit+0xc94`, and the client turns that one +//! client-side, every frame, toward a goal it picks from an ordered chain. Two of that chain's goals +//! are local decisions the server never sends: a stationary unit squares up on its +//! `UNIT_FIELD_TARGET`, and **an NPC turns to face you for as long as its interaction window is +//! open** (bug B110, decision 1467). +//! +//! benilla collapses the two facings into the unit's `Transform.rotation`, which is therefore the +//! *display* facing; [`DisplayFacing::wire`] keeps the raw one alongside it, because the goal chain +//! falls back to the raw facing — and that fallback is what swings an NPC **back** to its authored +//! heading when you close its window. In the client the raw facing separately places the +//! collision/cull node, so a turning NPC's collision does not move; ours does. That asymmetry is a +//! named approximation, not fidelity: a unit's collision footprint is a capsule and its cull volume +//! a sphere, so rotating them is very nearly a no-op. use benilla_assets::coords::bevy_to_wow; use benilla_protocol::{EntityKind, MonsterMoveFacing}; use bevy::prelude::*; -use super::super::{ActiveMover, GuidIndex, NetEntity, ObjectStore}; +use super::super::{ActiveMover, GuidIndex, NetEntity, ObjectStore, SelfPlayer}; use super::{yaw_of, RemoteMotion, Spline}; -/// Radians/second an idle creature turns to face its target — a smooth turn, not a snap. Tunable -/// (the *look* is the director's): the real client caps a per-frame face turn at a `turnRate` and the -/// display-facing chase runs it fast (wow-re object-layer: `facing_interp 0x6103d0`, capped by -/// `turnRate`; the display chase `~turnRate·8`). This is the one knob if the turn reads too fast/slow. -const FACE_TARGET_TURN_RATE: f32 = 8.0; - /// The remaining yaw error (radians) under which a facing ease counts as **settled** — the /// [`FacingStep`] latch drops and the anim layer's turn-shuffle releases back to Stand. The /// client's own small/large-delta thresholds (`0x80c5c4`/`0x80c5c8`, decision 0123) aren't @@ -22,30 +34,72 @@ const FACE_TARGET_TURN_RATE: f32 = 8.0; /// ([`super::remote`]) — the same latch drives a standing remote's mouse-turn shuffle. pub(super) const FACING_SETTLED: f32 = 0.05; -/// A stationary unit's idle re-face ([`face_target`]) is still **stepping its yaw** this frame: -/// the signed remaining delta (WoW yaw, positive = counterclockwise = turning left). The anim -/// layer folds it into the unit's turn view — the client's facing-delta shuffle latch -/// (`0x607ed0` bits `0x800`/`0x1000`, wow-re `loop-replay-fidget.md` §5b; decision 0123) — so a -/// squaring-up creature foot-shuffles instead of pivoting frozen, and each shuffle's return to -/// Stand re-rolls the idle variation. Removed the frame the ease settles. +/// The filter's **dead-band** (`[0x8029d0]` = 0.01 rad ≈ 0.57°), tested on the *unfolded* goal−current +/// delta and **inclusive** (`fcomp` + `jp` at `600eb5`–`600ec0`). Inside it the goal is taken +/// verbatim and the history ring is reset, so a slowly-turning player is tracked with zero lag. +const DEAD_BAND: f32 = 0.01; + +/// The box filter's average weight (`[0x8029b0]`) — a plain mean over the 4-sample ring. +const RING_AVG: f32 = 0.25; + +/// The fraction of the (clamped) averaged delta applied per pump (`[0x7ffa24]`). With the +/// overshoot clamp binding on every approach pass this makes the steady behaviour a **per-frame +/// halving** of the error: ~8–9 frames from π down to [`DEAD_BAND`]. +const STEP_FRACTION: f32 = 0.5; + +/// `UNIT_FIELD_FLAGS & 0x40000` — `UNIT_FLAG_STUNNED`, goal-chain row 4. +const UNIT_FLAG_STUNNED: u32 = 0x0004_0000; + +/// The `Emotes.dbc` `EmoteFlags` bit that **permits** the target/interaction facing while a looping +/// state emote is active (`600d95`/`600d98 test ch,0x20`). A valid emote-state record *without* this +/// bit suppresses the whole chain and pins the unit to its raw facing; the bit's *name* is INFERRED +/// (wow-re `interaction-facing.md` §2) — the byte fact is the condition tested. +const EMOTE_PERMITS_FACING: u32 = 0x2000; + +/// A stationary unit's display-facing state — the client's `CGUnit+0xc98` goal plus its +/// `+0xc9c..+0xca8` delta-history ring. Inserted the first frame the unit is governed by +/// [`drive_display_facing`] and removed the moment it stops being (a path, a relay stream, a fresh +/// wire pose), so it is only ever alive while the client-local turn actually owns the body. +#[derive(Component)] +pub(crate) struct DisplayFacing { + /// The **raw** (server-authoritative) yaw — the client's `CMovement+0x1c`. Seeded from the + /// transform the wire left behind, and the goal every chain row but the two turn rows returns + /// to. Nothing client-local ever writes it, which is exactly why the NPC swings back. + wire: f32, + /// The 4-sample delta history. A zero head means "start fresh" — the ring-FILL leg. + hist: [f32; 4], +} + +/// A stationary unit's display facing is still **stepping** this frame: the signed remaining delta +/// (WoW yaw, positive = counterclockwise = turning left). The anim layer folds it into the unit's +/// turn view — the client's facing-delta shuffle latch (`0x607ed0` bits `0x800`/`0x1000`, wow-re +/// `loop-replay-fidget.md` §5b; decision 0123) — so a squaring-up creature foot-shuffles instead of +/// pivoting frozen, and each shuffle's return to Stand re-rolls the idle variation. Removed the +/// frame the ease settles. #[derive(Component)] pub(crate) struct FacingStep(pub(crate) f32); -/// Turn `cur` toward `goal` by at most `max_step` radians, the **short way** — the byte-verified client -/// facing interp (`facing_interp`, wow-re object-layer `0x6103d0`): wrap the delta into `[0, 2π)`, take -/// the shorter arc, cap the step at `max_step`. Returns `cur` (wrapped) when already within `~0`. -pub(super) fn turn_toward(cur: f32, goal: f32, max_step: f32) -> f32 { - use std::f32::consts::{PI, TAU}; - let delta = (goal - cur).rem_euclid(TAU); // wrap to [0, 2π) - let (mag, dir) = if delta > PI { - (TAU - delta, -1.0) // shorter to turn negative - } else { - (delta, 1.0) - }; - if mag < 1.0e-4 { - return cur.rem_euclid(TAU); +/// Which row of the goal chain a unit's facing goal came from — trace-only ([`trace_face`]), so a +/// "why is this NPC pointing there" question is answered from numbers instead of by eye. +#[derive(Clone, Copy, PartialEq, Eq)] +enum GoalSource { + /// Every row that pins the unit to its raw facing: moving, posed, emote-suppressed, stunned, + /// or simply nothing to look at. + Raw, + /// Row 7 — the bearing to `UNIT_FIELD_TARGET`. + Target, + /// Row 9 — the bearing to the local player, because this unit is the interaction NPC. + Interact, +} + +impl GoalSource { + fn tag(self) -> &'static str { + match self { + Self::Raw => "raw", + Self::Target => "target", + Self::Interact => "interact", + } } - (cur + dir * mag.min(max_step)).rem_euclid(TAU) } /// Resolve a [`MonsterMoveFacing`] to a WoW orientation (radians) for a unit at `unit_pos` (raw WoW). @@ -59,84 +113,466 @@ pub(in crate::net) fn resolve_facing( unit_pos: [f32; 3], target_pos: impl FnOnce(u64) -> Option<[f32; 3]>, ) -> Option { - let bearing = |to: [f32; 3]| { + let bear = |to: [f32; 3]| { let (dx, dy) = (to[0] - unit_pos[0], to[1] - unit_pos[1]); (dx * dx + dy * dy > 1e-6).then(|| dy.atan2(dx)) }; match facing { MonsterMoveFacing::None => None, MonsterMoveFacing::Angle(a) => Some(a), - MonsterMoveFacing::Spot(spot) => bearing(spot), - MonsterMoveFacing::Target(guid) => target_pos(guid).and_then(bearing), + MonsterMoveFacing::Spot(spot) => bear(spot), + MonsterMoveFacing::Target(guid) => target_pos(guid).and_then(bear), } } -/// Turn each **idle** creature to face its `UNIT_FIELD_TARGET` — the client's own local re-face. A -/// stationary meleeing mob squares up on its victim even though the server sends **no** facing for it -/// (vmangos `SetInFront` is server-only; verified by a live sniff — no `MONSTER_MOVE` arrives when you -/// attack a standing mob). A moving unit (a [`Spline`]) faces its travel direction instead; a remote -/// player ([`RemoteMotion`]) and the body we steer ([`ActiveMover`]) own their facing — all excluded. The -/// turn is capped per frame at [`FACE_TARGET_TURN_RATE`], toward the horizontal bearing to the target. -#[allow(clippy::type_complexity)] -pub(in crate::net) fn face_target( +/// The horizontal bearing from `from` to `to` (raw WoW positions), or `None` when the two are +/// essentially coincident — a degenerate `atan2` that would snap the unit to 0. +fn bearing(from: [f32; 3], to: [f32; 3]) -> Option { + let (dx, dy) = (to[0] - from[0], to[1] - from[1]); + (dx * dx + dy * dy >= 1.0e-4).then(|| dy.atan2(dx)) +} + +/// Row 3 of the goal chain — the **emote-state gate** (`600d6c`–`600d9d`). A unit in a looping state +/// emote (`UNIT_NPC_EMOTESTATE`) is pinned to its raw facing *unless* the emote's `Emotes.dbc` +/// `EmoteFlags` carries [`EMOTE_PERMITS_FACING`]. An absent or unknown emote id suppresses nothing — +/// the client's `bl = 0` legs. +fn emote_suppresses_facing(emote_state: u32, flags: impl FnOnce(u32) -> Option) -> bool { + if emote_state == 0 { + return false; + } + match flags(emote_state) { + Some(f) => f & EMOTE_PERMITS_FACING == 0, + None => false, // no such record — the client's `rec == 0` leg + } +} + +/// One pump of the client's `+0xc98` box filter (`0x600cd0`, `600ea8`–`600fd5`), byte-for-byte. +/// Returns the new display yaw given the previous one, the goal and the unit's history ring. +/// +/// **There is no `dt` in this filter** — it is per-*pump*, not per-second. The overshoot clamp binds +/// on every approach pass, so the steady behaviour is `delta_{n+1} = 0.5 · delta_n`. That is a +/// deliberate departure from a frame-rate-independent ease: it is the law the client runs, and at +/// any plausible frame rate it settles inside a sixth of a second either way. +fn filter_step(cur: f32, goal: f32, hist: &mut [f32; 4]) -> f32 { + use std::f32::consts::TAU; + // The dead-band is tested on the UNFOLDED delta and is inclusive (`600eb5`/`600ec0`). + let raw_delta = goal - cur; + if raw_delta.abs() <= DEAD_BAND { + *hist = [0.0; 4]; + return goal.rem_euclid(TAU); + } + let delta = crate::creature_anim::wrap_pi(raw_delta); + // A reversal empties the ring, so a turn that changes direction does not average against the + // deltas it is undoing (`600f12`–`600f3b`). + if delta * hist[0] < 0.0 { + hist[0] = 0.0; + } + let step = if hist[0] == 0.0 { + *hist = [delta; 4]; // the ring FILL leg — no averaging on this pass (`600f50`–`600f6a`) + delta + } else { + hist.copy_within(0..3, 1); // `600f76 memmove(+0xca0, +0xc9c, 12)` + hist[0] = delta; + let avg = hist.iter().sum::() * RING_AVG; + // Never overshoot the goal (`600f9e`–`600fc9`). + if avg.abs() > delta.abs() { + delta + } else { + avg + } + }; + (cur + step * STEP_FRACTION).rem_euclid(TAU) +} + +/// Turn every **stationary, client-governed** unit toward the goal the client's `0x600cd0` chain +/// picks for it, through the one smoother that chain feeds ([`filter_step`]). +/// +/// The chain, in the client's exact branch order (wow-re `interaction-facing.md` §2) — the ordering +/// *is* the behaviour: +/// +/// | row | test | goal | +/// |---|---|---| +/// | 0 | the body we steer | *(excluded by [`ActiveMover`])* | +/// | 1 | moving | raw — *(excluded by [`Spline`]; a patrolling NPC does not re-face)* | +/// | 2 | `standState != 0` — sitting, kneeling, dead | raw | +/// | 3 | a state emote whose `EmoteFlags` lacks [`EMOTE_PERMITS_FACING`] | raw | +/// | 4 | [`UNIT_FLAG_STUNNED`] | raw | +/// | 5 | the combat scripted override `+0xc58 & 1` → `+0xcac` | *(not modelled — see below)* | +/// | 6 | a remote player character | raw — *(excluded by [`RemoteMotion`])* | +/// | 7 | `UNIT_FIELD_TARGET` resolves | bearing to that unit | +/// | 9 | **this unit is the interaction NPC** | **bearing to the local player** | +/// | — | none of the above | raw | +/// +/// Row 7 sitting above row 9 is why a vendor that is *fighting* keeps facing its attacker rather +/// than you. Row 5 is a combat-side facing override with a single writer in the client +/// (`0x624f10`, `UnitCombat_C.cpp`) and no benilla equivalent yet; it is a separate goal source, not +/// part of this one, and its absence only means we never pin a unit the client would have pinned. +/// +/// **No range, line-of-sight, faction or is-alive test appears anywhere on this path** — the +/// `CanInteract` family is ruled out at the client's call graph. The only leash is the window's own +/// range close, which [`crate::ui_session::close_npc_session_out_of_range`] already owns. +#[allow(clippy::type_complexity, clippy::too_many_arguments)] // one Bevy system's full input set +pub(in crate::net) fn drive_display_facing( mut commands: Commands, - time: Res