dark overcast skies, a talk gesture, and NPCs that face you
- the Tainted Scar sky reads its dark red overcast instead of almost pure white: a light-band row with no keyframes now commits the client's own constant — black for colour rows, +0.0 for scalars — instead of an invented neutral-daytime palette; Blasted Lands authors cloud density 0.85, so the cloud gradient IS the sky there, and sky pixels go `rgb(255,255,255)` → `rgb(145,55,25)` while the terrain control stays bit-identical and the blessed capture sweep holds MAE 0.000 - an NPC you interact with turns to face you and holds the bearing while the window is open — client-side, a per-frame facing-goal chain whose smoother halves the remaining angle each frame (π settles in 8–9 frames); the server sends nothing, and a patrolling guard or a creature in combat is unaffected - sending a chat line plays the talk gesture, like every 1.12 client: a say reads its last byte (`?` question, `!` exclamation, else talk), a yell always shouts, a laugh-word laughs — five gestures resolved from Emotes.dbc flag bits, and talking stows a drawn weapon - a standing capture viewpoint pins the Tainted Scar sky for regression Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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::<SwingSlowdown>()
|
||||
.init_resource::<PendingImpacts>()
|
||||
.init_resource::<PlaySeq>()
|
||||
.init_resource::<gesture::GestureQueue>()
|
||||
.add_message::<EmoteAnim>()
|
||||
.add_message::<MountFlourish>()
|
||||
.add_message::<WoundAnim>()
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<String>,
|
||||
) -> Option<Gesture> {
|
||||
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<GestureQueue>,
|
||||
mut out: MessageWriter<EmoteAnim>,
|
||||
mut play_seq: ResMut<PlaySeq>,
|
||||
index: Res<GuidIndex>,
|
||||
emotes: Option<Res<crate::sound::EmoteSounds>>,
|
||||
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<String> {
|
||||
["snrk", "guffaw", "tehe"]
|
||||
.get(n as usize - 1)
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
fn no_words(_: u32) -> Option<String> {
|
||||
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::<GestureQueue>()
|
||||
.init_resource::<PlaySeq>()
|
||||
.init_resource::<GuidIndex>()
|
||||
.insert_resource(crate::sound::EmoteSounds(cat))
|
||||
.add_message::<EmoteAnim>()
|
||||
.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::<GuidIndex>();
|
||||
index.0.insert(11, speaker);
|
||||
index.0.insert(22, fighter);
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<GestureQueue>()
|
||||
.push(11, Gesture::Exclamation);
|
||||
app.world_mut()
|
||||
.resource_mut::<GestureQueue>()
|
||||
.push(22, Gesture::Talk);
|
||||
// A speaker we have never streamed must not panic or produce anything.
|
||||
app.world_mut()
|
||||
.resource_mut::<GestureQueue>()
|
||||
.push(999, Gesture::Talk);
|
||||
app.update();
|
||||
|
||||
let played: Vec<_> = app
|
||||
.world_mut()
|
||||
.resource_mut::<bevy::ecs::message::Messages<EmoteAnim>>()
|
||||
.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::<GestureQueue>().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)]);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -204,7 +204,7 @@ pub(super) fn object_create(
|
||||
commands.entity(e).remove::<Spline>();
|
||||
}
|
||||
}
|
||||
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::<Spline>();
|
||||
// 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::<crate::net::motion::DisplayFacing>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<DisplayFacing>();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<f32> {
|
||||
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<f32> {
|
||||
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<u32>) -> 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::<f32>() * 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<Time>,
|
||||
index: Res<GuidIndex>,
|
||||
// `Option<Res<_>>` throughout: a headless test mounts this system without the UI plugins.
|
||||
interact: Option<Res<crate::ui_session::InteractNpc>>,
|
||||
emotes: Option<Res<crate::sound::EmoteSounds>>,
|
||||
candidates: Query<
|
||||
(Entity, &NetEntity, &ObjectStore),
|
||||
(Without<Spline>, Without<RemoteMotion>, Without<ActiveMover>),
|
||||
>,
|
||||
self_q: Query<Entity, With<SelfPlayer>>,
|
||||
mut transforms: Query<&mut Transform>,
|
||||
// A remote player's latch belongs to the facing interp in [`super::remote`] — the cleanup
|
||||
// here must not sweep it (the two systems would fight over the component every frame).
|
||||
mut facings: Query<&mut DisplayFacing>,
|
||||
// A remote player's latch belongs to the facing interp in [`super::remote`] — the cleanup here
|
||||
// must not sweep it (the two systems would fight over the component every frame).
|
||||
latched: Query<Entity, (With<FacingStep>, Without<RemoteMotion>)>,
|
||||
// A unit that has started walking or become a relay mover is no longer governed: its transform
|
||||
// belongs to the sampler / extrapolator, and its state must go so the next seed reads the pose
|
||||
// they left rather than a stale pre-walk heading.
|
||||
ungoverned: Query<Entity, (With<DisplayFacing>, Or<(With<Spline>, With<RemoteMotion>)>)>,
|
||||
) {
|
||||
let max_step = FACE_TARGET_TURN_RATE * time.delta_secs();
|
||||
// Collect (unit, goal facing) from immutable position reads first, then apply — so the target's
|
||||
// Transform and the unit's Transform are never borrowed mutably at the same time.
|
||||
let mut turns: Vec<(Entity, f32)> = Vec::new();
|
||||
let self_pos = self_q
|
||||
.iter()
|
||||
.next()
|
||||
.and_then(|e| transforms.get(e).ok())
|
||||
.map(|t| bevy_to_wow(t.translation));
|
||||
let interact_npc = interact.and_then(|r| r.0);
|
||||
|
||||
// Collect (unit, goal, source) from immutable position reads first, then apply — so the
|
||||
// target's transform and the unit's are never borrowed mutably at the same time.
|
||||
let mut goals: Vec<(Entity, Option<f32>, GoalSource)> = Vec::new();
|
||||
for (e, net, store) in &candidates {
|
||||
if net.kind != EntityKind::Unit {
|
||||
continue; // a GameObject sits at its authored facing; players are excluded above
|
||||
}
|
||||
let Some(target) = store.0.unit_target() else {
|
||||
continue; // no target → nothing to face
|
||||
};
|
||||
let Some(&te) = index.0.get(&target) else {
|
||||
continue; // target not streamed to us
|
||||
};
|
||||
let (Ok(unit_t), Ok(target_t)) = (transforms.get(e), transforms.get(te)) else {
|
||||
let fields = &store.0;
|
||||
// Rows 2-4: the posture/state gates that pin a unit to its raw facing.
|
||||
if fields.unit_stand_state() != 0
|
||||
|| fields.unit_flags() & UNIT_FLAG_STUNNED != 0
|
||||
|| emote_suppresses_facing(fields.unit_emote_state(), |id| {
|
||||
emotes.as_ref().and_then(|c| c.emote_flags(id))
|
||||
})
|
||||
{
|
||||
goals.push((e, None, GoalSource::Raw));
|
||||
continue;
|
||||
}
|
||||
let Ok(unit_t) = transforms.get(e) else {
|
||||
continue;
|
||||
};
|
||||
let (u, t) = (
|
||||
bevy_to_wow(unit_t.translation),
|
||||
bevy_to_wow(target_t.translation),
|
||||
);
|
||||
let (dx, dy) = (t[0] - u[0], t[1] - u[1]);
|
||||
if dx * dx + dy * dy < 1.0e-4 {
|
||||
continue; // essentially on top of each other — no meaningful bearing
|
||||
let unit_pos = bevy_to_wow(unit_t.translation);
|
||||
// Row 7: the bearing to UNIT_FIELD_TARGET, when it resolves to something streamed.
|
||||
let target = fields
|
||||
.unit_target()
|
||||
.and_then(|g| index.0.get(&g).copied())
|
||||
.and_then(|te| transforms.get(te).ok())
|
||||
.and_then(|t| bearing(unit_pos, bevy_to_wow(t.translation)));
|
||||
if let Some(goal) = target {
|
||||
goals.push((e, Some(goal), GoalSource::Target));
|
||||
continue;
|
||||
}
|
||||
// Row 9: the interaction face-me. `InteractNpc` is the benilla-side twin of the client's
|
||||
// one global `[0xb4e2d0]` — whichever NPC window is open, and nothing when none is.
|
||||
let facing_me = (interact_npc == Some(e))
|
||||
.then_some(self_pos)
|
||||
.flatten()
|
||||
.and_then(|p| bearing(unit_pos, p));
|
||||
match facing_me {
|
||||
Some(goal) => goals.push((e, Some(goal), GoalSource::Interact)),
|
||||
None => goals.push((e, None, GoalSource::Raw)),
|
||||
}
|
||||
turns.push((e, dy.atan2(dx)));
|
||||
}
|
||||
|
||||
let mut stepping: bevy::ecs::entity::EntityHashSet = default();
|
||||
for (e, goal) in turns {
|
||||
if let Ok(mut tf) = transforms.get_mut(e) {
|
||||
let cur = yaw_of(tf.rotation);
|
||||
let new = turn_toward(cur, goal, max_step);
|
||||
tf.rotation = Quat::from_rotation_y(new);
|
||||
// The turn-shuffle latch (decision 0123): while the ease still has meaningful yaw
|
||||
// to cover, the anim layer sees a signed turning step; settled drops it below.
|
||||
let remaining = crate::creature_anim::wrap_pi(goal - new);
|
||||
if remaining.abs() > FACING_SETTLED {
|
||||
commands.entity(e).insert(FacingStep(remaining));
|
||||
stepping.insert(e);
|
||||
}
|
||||
for (e, goal, source) in goals {
|
||||
let Ok(mut tf) = transforms.get_mut(e) else {
|
||||
continue;
|
||||
};
|
||||
let cur = yaw_of(tf.rotation).rem_euclid(std::f32::consts::TAU);
|
||||
let Ok(mut state) = facings.get_mut(e) else {
|
||||
// First frame governed: the transform still holds exactly what the wire (a pose, a
|
||||
// finished path) left there, so it *is* the raw facing. Seed and let the next pump
|
||||
// start from a clean ring — the client's `0x601020` shape.
|
||||
commands.entity(e).insert(DisplayFacing {
|
||||
wire: cur,
|
||||
hist: [0.0; 4],
|
||||
});
|
||||
continue;
|
||||
};
|
||||
// Every row but the two turn rows returns the unit to its raw facing — the fallback is a
|
||||
// *goal*, not a freeze, which is what swings an NPC back when you close its window.
|
||||
let goal = goal.unwrap_or(state.wire);
|
||||
let new = filter_step(cur, goal, &mut state.hist);
|
||||
tf.rotation = Quat::from_rotation_y(new);
|
||||
// The turn-shuffle latch (decision 0123): while the ease still has meaningful yaw to
|
||||
// cover, the anim layer sees a signed turning step; settled drops it below.
|
||||
let remaining = crate::creature_anim::wrap_pi(goal - new);
|
||||
if remaining.abs() > FACING_SETTLED {
|
||||
commands.entity(e).insert(FacingStep(remaining));
|
||||
stepping.insert(e);
|
||||
}
|
||||
trace_face(e, source, goal, cur, new);
|
||||
}
|
||||
// Drop stale latches: units that settled, lost their target, or started moving this frame.
|
||||
// Drop stale latches: units that settled, lost their goal, or started moving this frame.
|
||||
for e in &latched {
|
||||
if !stepping.contains(&e) {
|
||||
commands.entity(e).remove::<FacingStep>();
|
||||
}
|
||||
}
|
||||
for e in &ungoverned {
|
||||
commands.entity(e).remove::<DisplayFacing>();
|
||||
}
|
||||
}
|
||||
|
||||
/// The facing instrument (`WOW_MOVE_TRACE=<path> WOW_MOVE_TRACE_TAGS=face`) — one line per governed
|
||||
/// unit per frame that actually turns, naming which goal-chain row won. "Why is this NPC pointing
|
||||
/// there" is a numbers question; this is the number.
|
||||
fn trace_face(e: Entity, source: GoalSource, goal: f32, cur: f32, new: f32) {
|
||||
if !benilla_assets::trace::enabled_for("face") || (new - cur).abs() < 1.0e-6 {
|
||||
return;
|
||||
}
|
||||
benilla_assets::trace::line(
|
||||
"face",
|
||||
&format!(
|
||||
"e={e} src={} goal={goal:.4} cur={cur:.4} new={new:.4} step={:.4}",
|
||||
source.tag(),
|
||||
new - cur
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use benilla_assets::coords::wow_to_bevy;
|
||||
use std::f32::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
/// The dead-band is INCLUSIVE and snaps the goal verbatim, resetting the ring — so a player
|
||||
/// strolling around a vendor is tracked with no lag at all.
|
||||
#[test]
|
||||
fn inside_the_dead_band_the_goal_is_taken_verbatim() {
|
||||
let mut hist = [0.3; 4];
|
||||
let out = filter_step(1.0, 1.0 + DEAD_BAND, &mut hist);
|
||||
assert!((out - (1.0 + DEAD_BAND)).abs() < 1.0e-6, "snapped: {out}");
|
||||
assert_eq!(hist, [0.0; 4], "the ring resets inside the dead-band");
|
||||
}
|
||||
|
||||
/// The first pump off an empty ring takes the FILL leg: no averaging, half the delta.
|
||||
#[test]
|
||||
fn the_first_pump_fills_the_ring_and_halves_the_error() {
|
||||
let mut hist = [0.0; 4];
|
||||
let out = filter_step(0.0, FRAC_PI_2, &mut hist);
|
||||
assert!((out - FRAC_PI_2 / 2.0).abs() < 1.0e-5, "half way: {out}");
|
||||
assert_eq!(hist, [FRAC_PI_2; 4], "the ring fills with the delta");
|
||||
}
|
||||
|
||||
/// The steady behaviour is a per-frame HALVING — not a per-second rate — and π converges into
|
||||
/// the dead-band in ~8-9 pumps. This is the shape benilla had wrong before decision 1467.
|
||||
#[test]
|
||||
fn the_error_halves_per_pump_and_pi_settles_in_nine() {
|
||||
let mut hist = [0.0; 4];
|
||||
let (goal, mut cur) = (PI, 0.0);
|
||||
let mut pumps = 0;
|
||||
while crate::creature_anim::wrap_pi(goal - cur).abs() > DEAD_BAND && pumps < 64 {
|
||||
let prev = crate::creature_anim::wrap_pi(goal - cur).abs();
|
||||
cur = filter_step(cur, goal, &mut hist);
|
||||
let now = crate::creature_anim::wrap_pi(goal - cur).abs();
|
||||
assert!(now < prev, "each pump closes the gap: {prev} -> {now}");
|
||||
pumps += 1;
|
||||
}
|
||||
assert!(
|
||||
(8..=9).contains(&pumps),
|
||||
"pi settles in 8-9 pumps, took {pumps}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A reversal empties the ring so the new direction is not averaged against the deltas it is
|
||||
/// undoing — the client's sign-flip reset.
|
||||
#[test]
|
||||
fn a_reversal_resets_the_ring_head() {
|
||||
let mut hist = [0.4; 4];
|
||||
filter_step(0.0, -1.0, &mut hist);
|
||||
assert!(
|
||||
hist[0] < 0.0,
|
||||
"the head carries the new direction: {hist:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
hist[1], hist[0],
|
||||
"and the ring refilled rather than averaged"
|
||||
);
|
||||
}
|
||||
|
||||
/// The turn takes the short way round the circle, never the long one.
|
||||
#[test]
|
||||
fn the_turn_takes_the_short_arc() {
|
||||
let mut hist = [0.0; 4];
|
||||
// Goal is 0.2 rad counterclockwise of `cur`, expressed the long way (cur near TAU).
|
||||
let out = filter_step(TAU - 0.1, 0.1, &mut hist);
|
||||
let moved = crate::creature_anim::wrap_pi(out - (TAU - 0.1));
|
||||
assert!(moved > 0.0 && moved < 0.2, "short way, part way: {moved}");
|
||||
}
|
||||
|
||||
/// A state emote suppresses the whole chain only when its record LACKS the permitting bit; an
|
||||
/// absent id and an unknown record both suppress nothing (the client's `bl = 0` legs).
|
||||
#[test]
|
||||
fn the_emote_state_gate_matches_the_clients_bl() {
|
||||
assert!(!emote_suppresses_facing(0, |_| Some(0)), "no emote state");
|
||||
assert!(!emote_suppresses_facing(7, |_| None), "unknown record");
|
||||
assert!(
|
||||
emote_suppresses_facing(7, |_| Some(0)),
|
||||
"a valid record without the bit suppresses"
|
||||
);
|
||||
assert!(
|
||||
!emote_suppresses_facing(7, |_| Some(EMOTE_PERMITS_FACING)),
|
||||
"the bit permits the facing"
|
||||
);
|
||||
}
|
||||
|
||||
/// Drive the real system in a minimal app: a vendor 5 yd north of us, an interaction window
|
||||
/// open on it, and nothing else. It must turn to face us, settle, and — the client's
|
||||
/// distinctive observable — **swing back** to its authored heading when the window closes.
|
||||
///
|
||||
/// This is the test that would have caught the mechanism being built and never firing: it
|
||||
/// exercises the goal chain and the `InteractNpc` wiring, not just [`filter_step`].
|
||||
#[test]
|
||||
fn the_interaction_npc_turns_to_face_us_and_swings_back_on_close() {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<GuidIndex>()
|
||||
.init_resource::<crate::ui_session::InteractNpc>()
|
||||
.add_systems(Update, drive_display_facing);
|
||||
// Us at the origin; the vendor 5 yd along +x, authored facing +x (away from us, which is
|
||||
// exactly the reporter's screenshot).
|
||||
let me = app
|
||||
.world_mut()
|
||||
.spawn((SelfPlayer, ActiveMover, Transform::default()))
|
||||
.id();
|
||||
let _ = me;
|
||||
let npc = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
NetEntity {
|
||||
kind: EntityKind::Unit,
|
||||
display_id: None,
|
||||
scale: 1.0,
|
||||
},
|
||||
ObjectStore::default(),
|
||||
Transform {
|
||||
translation: wow_to_bevy([5.0, 0.0, 0.0]),
|
||||
rotation: Quat::from_rotation_y(0.0),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let yaw = |app: &App| yaw_of(app.world().entity(npc).get::<Transform>().unwrap().rotation);
|
||||
// The first pump only seeds the state (the transform still holds the wire's own pose).
|
||||
app.update();
|
||||
assert!(
|
||||
(yaw(&app) - 0.0).abs() < 1.0e-4,
|
||||
"the seeding frame does not turn: {}",
|
||||
yaw(&app)
|
||||
);
|
||||
assert!(
|
||||
app.world().entity(npc).contains::<DisplayFacing>(),
|
||||
"the seeding frame inserts the smoother state"
|
||||
);
|
||||
// No window open yet: the goal is the raw facing, so it stays put however long we run.
|
||||
for _ in 0..16 {
|
||||
app.update();
|
||||
}
|
||||
assert!(
|
||||
(yaw(&app) - 0.0).abs() < 1.0e-4,
|
||||
"no window, no turn: {}",
|
||||
yaw(&app)
|
||||
);
|
||||
|
||||
// Open a window on it. We are due WEST of the vendor, so the bearing it must adopt is pi.
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::ui_session::InteractNpc>()
|
||||
.0 = Some(npc);
|
||||
for _ in 0..16 {
|
||||
app.update();
|
||||
}
|
||||
let facing_us = yaw(&app);
|
||||
assert!(
|
||||
crate::creature_anim::wrap_pi(facing_us - PI).abs() <= DEAD_BAND,
|
||||
"the vendor faces us within the dead-band, got {facing_us}"
|
||||
);
|
||||
|
||||
// Close it. The goal reverts to the untouched raw facing and the same filter swings the
|
||||
// body back — the client never wrote the raw facing, so neither did we.
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::ui_session::InteractNpc>()
|
||||
.0 = None;
|
||||
for _ in 0..16 {
|
||||
app.update();
|
||||
}
|
||||
let back = yaw(&app);
|
||||
assert!(
|
||||
crate::creature_anim::wrap_pi(back - 0.0).abs() <= DEAD_BAND,
|
||||
"the vendor swings back to its authored heading, got {back}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Row 7 sits above row 9: a vendor that is *fighting* keeps facing its attacker, not us.
|
||||
#[test]
|
||||
fn a_target_outranks_the_interaction_face_me() {
|
||||
use benilla_protocol::ObjectFields;
|
||||
let mut app = App::new();
|
||||
app.init_resource::<GuidIndex>()
|
||||
.init_resource::<crate::ui_session::InteractNpc>()
|
||||
.add_systems(Update, drive_display_facing);
|
||||
app.world_mut()
|
||||
.spawn((SelfPlayer, ActiveMover, Transform::default()));
|
||||
// The vendor's victim is due NORTH of it (+y in WoW), i.e. a bearing of +pi/2.
|
||||
let victim = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
NetEntity {
|
||||
kind: EntityKind::Unit,
|
||||
display_id: None,
|
||||
scale: 1.0,
|
||||
},
|
||||
ObjectStore::default(),
|
||||
Transform::from_translation(wow_to_bevy([5.0, 5.0, 0.0])),
|
||||
))
|
||||
.id();
|
||||
// UNIT_FIELD_TARGET (index 16) is a guid pair; give the victim guid 7.
|
||||
let npc = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
NetEntity {
|
||||
kind: EntityKind::Unit,
|
||||
display_id: None,
|
||||
scale: 1.0,
|
||||
},
|
||||
ObjectStore(ObjectFields::from_pairs(&[(16, 7), (17, 0)])),
|
||||
Transform::from_translation(wow_to_bevy([5.0, 0.0, 0.0])),
|
||||
))
|
||||
.id();
|
||||
app.world_mut()
|
||||
.resource_mut::<GuidIndex>()
|
||||
.0
|
||||
.insert(7, victim);
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::ui_session::InteractNpc>()
|
||||
.0 = Some(npc);
|
||||
for _ in 0..24 {
|
||||
app.update();
|
||||
}
|
||||
let yaw = yaw_of(app.world().entity(npc).get::<Transform>().unwrap().rotation);
|
||||
assert!(
|
||||
crate::creature_anim::wrap_pi(yaw - FRAC_PI_2).abs() <= DEAD_BAND,
|
||||
"the vendor squares up on its victim (+pi/2), not on us (pi): {yaw}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A coincident pair yields no bearing — a degenerate `atan2` would spin the unit to 0.
|
||||
#[test]
|
||||
fn a_coincident_pair_has_no_bearing() {
|
||||
assert!(bearing([1.0, 2.0, 3.0], [1.0, 2.0, 9.0]).is_none());
|
||||
let b = bearing([0.0, 0.0, 0.0], [1.0, 1.0, 0.0]).expect("a real bearing");
|
||||
assert!((b - std::f32::consts::FRAC_PI_4).abs() < 1.0e-5, "{b}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use bevy::prelude::Quat;
|
||||
use crate::creature_anim::move_flags;
|
||||
use crate::player::{GRAVITY, TERMINAL_VELOCITY};
|
||||
|
||||
use super::facing::{resolve_facing, turn_toward};
|
||||
use super::facing::resolve_facing;
|
||||
use super::relay::RelayChain;
|
||||
use super::remote::{facing_lerp, fall_arc_step, jump_seed, reconcile_lerp};
|
||||
use super::spline::monster_move_spline;
|
||||
@@ -100,23 +100,6 @@ fn swim_dead_reckon_folds_the_pitch_into_the_travel() {
|
||||
assert_eq!((pos[0], pos[2], speed), (0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_toward_caps_and_takes_short_way() {
|
||||
use std::f32::consts::{FRAC_PI_2, PI, TAU};
|
||||
// Big turn, small cap: advances by exactly the cap, positive (short) direction (0 → +π/2 caps).
|
||||
let a = turn_toward(0.0, FRAC_PI_2, 0.1);
|
||||
assert!((a - 0.1).abs() < 1e-5, "caps the step: {a}");
|
||||
// Short way is negative: from 0.1 toward (2π − 0.1) the client turns −, not + all the way round.
|
||||
let b = turn_toward(0.1, TAU - 0.1, 0.05);
|
||||
assert!((b - 0.05).abs() < 1e-5, "turns the short (−) way: {b}");
|
||||
// Within a step of the goal → lands on the goal (no overshoot/oscillation).
|
||||
let c = turn_toward(0.0, 0.03, 1.0);
|
||||
assert!((c - 0.03).abs() < 1e-5, "reaches the goal: {c}");
|
||||
// Already facing (Δ≈0) → unchanged.
|
||||
let d = turn_toward(PI, PI, 1.0);
|
||||
assert!((d - PI).abs() < 1e-4, "no turn when aligned: {d}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_facing_angle_spot_and_target() {
|
||||
let none = |_g: u64| None;
|
||||
|
||||
@@ -711,17 +711,17 @@ impl Plugin for PortraitPlugin {
|
||||
.init_resource::<BoothPanes>()
|
||||
.init_resource::<GxAspect>()
|
||||
.init_resource::<BoothLight>()
|
||||
.init_resource::<crate::ui_session::InteractNpc>()
|
||||
.add_systems(Startup, setup_booths)
|
||||
// The variant-cache reaper: booth twins die with their world source material.
|
||||
.add_systems(Update, (light::reap_dead_variants, feed_gx_aspect))
|
||||
// `feed_interact_npc` resolves the `"npc"` token's entity first; then the test bake owns
|
||||
// the booths when its env is set (the live syncs yield to it). The paper-doll sync runs
|
||||
// last (it shares the camera/booth/image resources, so the chain keeps the access ordered).
|
||||
// The `"npc"` token's entity is resolved by `ui_session`'s own plugin (it is shared with
|
||||
// the interaction face-me, decision 1467) — the booths read whatever it last published.
|
||||
// Here the test bake owns the booths when its env is set (the live syncs yield to it),
|
||||
// and the paper-doll sync runs last (it shares the camera/booth/image resources, so the
|
||||
// chain keeps the access ordered).
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
crate::ui_session::feed_interact_npc,
|
||||
test_bake::sync_test_portraits,
|
||||
sync_portraits,
|
||||
sync_paperdoll,
|
||||
|
||||
@@ -26,7 +26,7 @@ use super::{AudioListener, SoundConfig, SoundOutput};
|
||||
|
||||
/// The emote-audio catalog (also the chat sender's `/name` → text-id resolver).
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct EmoteSounds(EmoteSoundCatalog);
|
||||
pub(crate) struct EmoteSounds(pub(crate) EmoteSoundCatalog);
|
||||
|
||||
impl EmoteSounds {
|
||||
/// Resolve a `/command` name (case-insensitive) to its EmotesText id.
|
||||
@@ -46,6 +46,12 @@ impl EmoteSounds {
|
||||
self.0.text_emote(text_id)
|
||||
}
|
||||
|
||||
/// The `Emotes.dbc` id in one of the five hard-coded **gesture slots** — promoted for
|
||||
/// `crate::creature_anim::gesture`, the client-local chat/interact gesture producer.
|
||||
pub(crate) fn gesture(&self, slot: usize) -> Option<u32> {
|
||||
self.0.gesture(slot)
|
||||
}
|
||||
|
||||
/// An `Emotes.dbc` id's raw `EmoteFlags` bits — promoted for `crate::ui_chat`'s send-side
|
||||
/// posture-eligibility gate (wow-re `object-layer/scratch/emote-posture-gate.md`, `0x47db40`).
|
||||
pub(crate) fn emote_flags(&self, emote_id: u32) -> Option<u32> {
|
||||
|
||||
@@ -124,12 +124,6 @@ pub(super) fn select_on_click(
|
||||
}
|
||||
}
|
||||
|
||||
/// EmoteTalk's `AnimationData.dbc` id (60) — the one-shot talk the client plays on our avatar when
|
||||
/// we interact with an NPC. Its WeaponFlags `0x10` drives the per-animation sheath reconcile to stow
|
||||
/// the drawn weapon — a committed change that persists after the talk (nothing restores it; the
|
||||
/// interact stow is the talk emote, not a sheath wire — decisions 0080/0081).
|
||||
const EMOTE_TALK: u16 = 60;
|
||||
|
||||
/// On a clean right-*click* (vanilla's context action — [`WorldRightClick`], never a turn-drag):
|
||||
/// select the hovered unit, then act by the same classification the cursor used (wow-re
|
||||
/// cursor-system.md §6). Three branches (decision 0081):
|
||||
@@ -157,8 +151,11 @@ pub(super) fn act_on_right_click(
|
||||
mut selection: ResMut<Selection>,
|
||||
mut seam: crate::creature_anim::AttackSeam,
|
||||
self_player: Query<(Entity, &Guid, Has<Engaged>), With<SelfPlayer>>,
|
||||
mut emote: MessageWriter<crate::creature_anim::EmoteAnim>,
|
||||
mut play_seq: ResMut<crate::creature_anim::PlaySeq>,
|
||||
// The interact talk gesture. The reference's NPC-interact dispatcher calls the SAME gesture
|
||||
// entry point the chat display path does, always with code 0 — so this goes through
|
||||
// `creature_anim::gesture` rather than writing a raw AnimID, and inherits its gate chain
|
||||
// (decision 1469; before that it played unconditionally, even asleep or mid-combat).
|
||||
mut gestures: ResMut<crate::creature_anim::GestureQueue>,
|
||||
// The GameObject lock-routing inputs (decisions 0239 / 0545 / 0752) as one [`GoLockInputs`]
|
||||
// (the 16-SystemParam ceiling).
|
||||
mut go_inputs: GoLockInputs,
|
||||
@@ -418,14 +415,11 @@ pub(super) fn act_on_right_click(
|
||||
if let Some(cmd) = interact_command(cursor.kind, guid, npc_flags) {
|
||||
debug!("right-click interact: {guid:#x} ({:?})", cursor.kind);
|
||||
let _ = seam.net.0.send(cmd);
|
||||
// Play EmoteTalk on our avatar — the reconcile stows a drawn weapon, persistently
|
||||
// (decisions 0080/0081; no sheath wiring here).
|
||||
if let Some((e, _, _)) = me {
|
||||
emote.write(crate::creature_anim::EmoteAnim {
|
||||
entity: e,
|
||||
anim_id: EMOTE_TALK,
|
||||
seq: play_seq.next(),
|
||||
});
|
||||
// Talk at the NPC. The gesture's own anim carries WeaponFlags `0x10`, so the
|
||||
// per-animation sheath reconcile stows a drawn weapon — a committed change that
|
||||
// persists after the talk (decisions 0080/0081; no sheath wiring here).
|
||||
if let Some((_, my_guid, _)) = me {
|
||||
gestures.push(my_guid.0, crate::creature_anim::Gesture::Talk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,6 +538,9 @@ pub(super) fn feed_chat(
|
||||
mut names: ResMut<NameCache>,
|
||||
mut bubbles: ResMut<crate::chat_bubble::BubbleQueue>,
|
||||
bubble_cfg: Res<crate::chat_bubble::BubbleConfig>,
|
||||
// The client-local talk gesture (decision 1469) — armed from this same display path, beside
|
||||
// the bubble, exactly as the reference arms both from `ChatFrame.cpp`.
|
||||
mut gestures: ResMut<crate::creature_anim::GestureQueue>,
|
||||
commands: Res<NetCommands>,
|
||||
time: Res<Time>,
|
||||
// The text-emote sentence seam (decision 1274): the tables, plus the guid the composer's
|
||||
@@ -683,6 +686,22 @@ pub(super) fn feed_chat(
|
||||
// expander, so a `$n` must never survive into it either.
|
||||
bubbles.push(&bubble_cfg, msg.sender_guid, kind, &text);
|
||||
}
|
||||
// …and the speaker gestures. Independent of the bubble in the reference too
|
||||
// (different function, different gates: the bubble has a 20 yd range test and two
|
||||
// CVars, the gesture has neither). The selector reads the RAW wire type and the
|
||||
// expanded text, and takes its laugh words off the player's own FrameXML globals —
|
||||
// the enumeration is the mechanism, the words are content (decision 1469).
|
||||
if let Some(gesture) =
|
||||
crate::creature_anim::select_gesture(msg.chat_type, &text, |n| {
|
||||
script
|
||||
.lua()
|
||||
.globals()
|
||||
.get::<String>(format!("LAUGH_WORD{n}"))
|
||||
.ok()
|
||||
})
|
||||
{
|
||||
gestures.push(msg.sender_guid, gesture);
|
||||
}
|
||||
}
|
||||
Pending::Notice {
|
||||
notice,
|
||||
|
||||
@@ -14,6 +14,27 @@ use bevy::prelude::*;
|
||||
|
||||
use crate::net::{GuidIndex, SelfPlayer};
|
||||
use crate::target::SERVICE_RANGE_SQ;
|
||||
use benilla_world::schedule::WorldStage;
|
||||
|
||||
/// Owns the cross-window session state: [`InteractNpc`] and the one system that feeds it.
|
||||
///
|
||||
/// It has a plugin of its own because [`InteractNpc`] has **two** consumers that must not depend on
|
||||
/// each other — the portrait booth's `"npc"` token (decision 0081) and the interaction face-me
|
||||
/// (`crate::net::motion`'s display-facing chain, decision 1467). It used to be initialised and fed
|
||||
/// from inside the portrait plugin, which silently made a *facing* law contingent on the portrait
|
||||
/// plugin being mounted; a shared resource belongs to the thing it describes.
|
||||
pub(crate) struct UiSessionPlugin;
|
||||
|
||||
impl Plugin for UiSessionPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// In `WorldStage::Net` so the facing chain can order itself against it. Deliberately
|
||||
// unordered w.r.t. the apply pass: the sessions it reads are open for *seconds*, so
|
||||
// whether a window's first frame is seen now or next frame is invisible against an
|
||||
// ~8-frame facing ease.
|
||||
app.init_resource::<InteractNpc>()
|
||||
.add_systems(Update, feed_interact_npc.in_set(WorldStage::Net));
|
||||
}
|
||||
}
|
||||
|
||||
/// A UI session bound to a live NPC (or, for the mailbox, a GameObject): the shared face the range
|
||||
/// guard closes through. Implemented by [`crate::ui_merchant::MerchantOpen`],
|
||||
|
||||
@@ -55,8 +55,20 @@ pub struct EmoteSoundCatalog {
|
||||
/// `Emotes.dbc` id → its `EmoteSpecProcParam` (column 5). For `EmoteSpecProc == 1` this is the
|
||||
/// **stand state** the emote sets — see [`EmoteSoundCatalog::posture_state`].
|
||||
spec_proc_param: HashMap<u32, u32>,
|
||||
/// The five **chat/interact gesture slots**, in [`GESTURE_FLAG_BITS`] order — see
|
||||
/// [`EmoteSoundCatalog::gesture`].
|
||||
gesture: [Option<u32>; GESTURE_FLAG_BITS.len()],
|
||||
}
|
||||
|
||||
/// The five `EmoteFlags` bits the client hard-codes to build its gesture table (`0x603a60`, wow-re
|
||||
/// `object-layer/scratch/chat-talk-gesture.md` §3), in slot order — **talk, question, exclamation,
|
||||
/// shout, laugh**. The *bits* are the fidelity fact: the client never names an `Emotes.dbc` id or an
|
||||
/// `AnimationData.dbc` id here, it scans the table for whichever row carries each bit. In the
|
||||
/// shipped 1.12.1 data each bit is carried by exactly one row (ids 1 / 6 / 5 / 22 / 11 → anims
|
||||
/// 60 / 65 / 64 / 81 / 70), but transcribing those numbers instead of the scan would bake content
|
||||
/// into code.
|
||||
pub const GESTURE_FLAG_BITS: [u32; 5] = [0x08, 0x10, 0x20, 0x40, 0x100];
|
||||
|
||||
impl EmoteSoundCatalog {
|
||||
/// Resolve a `/command` name (case-insensitive) to its text-emote id.
|
||||
pub fn text_id(&self, name: &str) -> Option<u32> {
|
||||
@@ -85,6 +97,13 @@ impl EmoteSoundCatalog {
|
||||
self.anim.get(&emote_id).copied().filter(|&a| a != 0)
|
||||
}
|
||||
|
||||
/// The `Emotes.dbc` id in gesture slot `slot` — an index into [`GESTURE_FLAG_BITS`], which is
|
||||
/// exactly the `code` the client's gesture dispatcher `0x60bb30` takes. `None` when no shipped
|
||||
/// row carries that bit (the client's own "empty slot ⇒ no gesture" leg).
|
||||
pub fn gesture(&self, slot: usize) -> Option<u32> {
|
||||
self.gesture.get(slot).copied().flatten()
|
||||
}
|
||||
|
||||
/// An `Emotes.dbc` id's raw `EmoteFlags` bits (`None` when the id isn't in the catalog; `0` is a
|
||||
/// real, meaningful value — "no gate bits set" — so unlike [`Self::anim`]/[`Self::event_sound`]
|
||||
/// it is *not* filtered out). Feeds `benilla::chat`'s send-side posture-eligibility gate — see
|
||||
@@ -175,8 +194,19 @@ pub fn load_emote_sound_catalog(chain: &mut Chain) -> Result<EmoteSoundCatalog>
|
||||
let mut emote_flags = HashMap::with_capacity(rs.records().len());
|
||||
let mut spec_proc = HashMap::with_capacity(rs.records().len());
|
||||
let mut spec_proc_param = HashMap::with_capacity(rs.records().len());
|
||||
// The gesture slots. The client walks the record array from the LAST index DOWN and its bit
|
||||
// tests are an else-if chain, so a row carrying two of the bits lands only in the first
|
||||
// matching slot and a duplicated bit resolves to the LOWEST array index. Iterating forward and
|
||||
// letting an earlier row win reproduces both, without depending on either: no shipped row
|
||||
// carries more than one of these bits, and none of them is duplicated.
|
||||
let mut gesture: [Option<u32>; GESTURE_FLAG_BITS.len()] = [None; GESTURE_FLAG_BITS.len()];
|
||||
for r in rs.records() {
|
||||
let Some(id) = u32_at(r, 0) else { continue };
|
||||
if let Some(flags) = u32_at(r, 3) {
|
||||
if let Some(slot) = GESTURE_FLAG_BITS.iter().position(|b| flags & b != 0) {
|
||||
gesture[slot].get_or_insert(id);
|
||||
}
|
||||
}
|
||||
if let Some(kit) = u32_at(r, 6) {
|
||||
event_sound.insert(id, kit);
|
||||
}
|
||||
@@ -203,6 +233,7 @@ pub fn load_emote_sound_catalog(chain: &mut Chain) -> Result<EmoteSoundCatalog>
|
||||
emote_flags,
|
||||
spec_proc,
|
||||
spec_proc_param,
|
||||
gesture,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -210,6 +241,39 @@ pub fn load_emote_sound_catalog(chain: &mut Chain) -> Result<EmoteSoundCatalog>
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The five gesture slots resolve off the REAL shipped `Emotes.dbc` — the check that the
|
||||
/// flag-bit scan finds the right rows, and that each bit really is carried by exactly one of
|
||||
/// them. The expected ids/anims are the byte-verified table in wow-re
|
||||
/// `object-layer/scratch/chat-talk-gesture.md` §3; the *code* never names them.
|
||||
#[test]
|
||||
fn the_five_gesture_slots_scan_to_the_shipped_rows() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_emote_sound_catalog(&mut chain).expect("load emote catalog");
|
||||
// slot, expected Emotes.dbc id, expected AnimationData id, name
|
||||
for (slot, emote_id, anim_id, what) in [
|
||||
(0usize, 1u32, 60u32, "talk"),
|
||||
(1, 6, 65, "question"),
|
||||
(2, 5, 64, "exclamation"),
|
||||
(3, 22, 81, "shout"),
|
||||
(4, 11, 70, "laugh"),
|
||||
] {
|
||||
assert_eq!(cat.gesture(slot), Some(emote_id), "{what} slot -> emote id");
|
||||
assert_eq!(cat.anim(emote_id), Some(anim_id), "{what} emote -> AnimID");
|
||||
}
|
||||
// Each of the five bits is carried by exactly one shipped row, so the else-if chain and
|
||||
// the walk direction cannot matter. If a patch ever broke this, the scan would silently
|
||||
// pick a different row and the gestures would drift — so assert it, do not assume it.
|
||||
for bit in GESTURE_FLAG_BITS {
|
||||
let carriers = cat
|
||||
.emote_flags
|
||||
.iter()
|
||||
.filter(|(_, f)| *f & bit != 0)
|
||||
.count();
|
||||
assert_eq!(carriers, 1, "exactly one shipped row carries {bit:#x}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The joins hold on real 5875 data: WAVE resolves by name to the byte-decoded id 101 with
|
||||
/// anim emote 3; some voice row exists for a human (race 1) male; the voice map carries the
|
||||
/// male/female split (the survey's sample rows pair sexes per race).
|
||||
|
||||
@@ -101,7 +101,7 @@ pub use ground_effects::{
|
||||
GroundEffectCatalog,
|
||||
};
|
||||
mod light;
|
||||
pub use light::{Atmosphere, LightCatalog, Submersion};
|
||||
pub use light::{Atmosphere, LightCatalog, Submersion, ZERO_KEY_COLOR, ZERO_KEY_SCALAR};
|
||||
mod loading_screen;
|
||||
pub use loading_screen::{load_loading_screens, LoadingScreenCatalog};
|
||||
mod liquid;
|
||||
|
||||
@@ -35,6 +35,7 @@ use atmosphere::{
|
||||
IB_RIVER_SHALLOW, IB_SKY0, IB_SUN_COLOR, LP_GLOW, LP_HIGHLIGHT, LP_OCEAN_DEEP_ALPHA,
|
||||
LP_OCEAN_SHALLOW_ALPHA, LP_WATER_DEEP_ALPHA, LP_WATER_SHALLOW_ALPHA,
|
||||
};
|
||||
pub use atmosphere::{ZERO_KEY_COLOR, ZERO_KEY_SCALAR};
|
||||
use tables::{band_schema, load_bands, load_float_bands, sample_color, sample_float, Band, DAY};
|
||||
|
||||
const LIGHT: &str = "DBFilesClient\\Light.dbc";
|
||||
@@ -393,6 +394,15 @@ impl LightCatalog {
|
||||
self.debug_param(p, time);
|
||||
}
|
||||
|
||||
/// Resolve an explicitly named `LightParams` id at a time of day, with no position and no slot
|
||||
/// selection — the machine-readable twin of [`Self::debug_param`]. The band-row id for params
|
||||
/// `p`, sub `b` is `(p-1)*per + b + 1`, an **ID** and not a row ordinal: `LightParams` ids run
|
||||
/// 1..499 with 73 gaps, so an ordinal keying reads another zone's bands for every group past the
|
||||
/// first gap. Exposed so a test can pin a record above a gap.
|
||||
pub fn sample_params_id(&self, p: u32, time: u32) -> Option<Atmosphere> {
|
||||
(p >= 1 && self.has_param(p)).then(|| self.sample_param(p, time))
|
||||
}
|
||||
|
||||
/// Debug: [`Self::debug_bands`] for an explicitly named `LightParams` id, with no position and no
|
||||
/// slot selection. The instrument for the rows **nothing in `Light.dbc` references**: magma and
|
||||
/// slime submersion do not read a zone's underwater slot at all, they read the fixed global rows
|
||||
@@ -744,6 +754,10 @@ impl LightCatalog {
|
||||
fn sample_param(&self, p: u32, t: u32) -> Atmosphere {
|
||||
let ib = |b: u32| self.int_bands.get(&((p - 1) * 18 + b + 1));
|
||||
let fb = |b: u32| self.float_bands.get(&((p - 1) * 6 + b + 1));
|
||||
// `d` survives only for the four `LightParams` **record fields** below (glow, highlightSky,
|
||||
// the water alphas). Those are not band rows: their "missing" case is a record that failed
|
||||
// to parse, not a keyless row, so they keep the record-level fallback. Every BAND row on
|
||||
// this record takes the reference's zero-key constant instead.
|
||||
let d = Atmosphere::DEFAULT;
|
||||
// Fog distances carry the ×36 storage scale (yards = raw/36): Elwynn clear 18000→500 yd,
|
||||
// storm 10000→278 yd. Byte-VERIFIED (0327, wow-re rf-weather-fog-veil Q2·LOAD): the client
|
||||
@@ -751,37 +765,49 @@ impl LightCatalog {
|
||||
// (`0x6d6090`, ×1/36 @0x7ff9d0) over each float-band record with `rowIndex % 6 == 0`, i.e.
|
||||
// ONLY the sub-0 fog-END band; the sub-1 start FRACTION is never scaled. We apply the same
|
||||
// /36 at sample time (a time-interp of scaled values ≡ the scale of the interp).
|
||||
// A row with no keyframes commits the reference's own constant, never an invented one
|
||||
// (`ZERO_KEY_SCALAR`/`ZERO_KEY_COLOR`, decision 1465). The `.filter(|v| v > 1.0)` that used
|
||||
// to sit here substituted `d.fog_end` for an authored ZERO as well as for a keyless row —
|
||||
// it is gone, and no shipped row lands in the (0, 1] yd window it also covered (checked
|
||||
// across all 426 params: the only sub-1 values are the exact 0.0 that params 9 and 93
|
||||
// author). `min(end − start, 0.001)` in the fog shaders is what keeps end = 0 finite.
|
||||
let fog_end = fb(FB_FOG_END)
|
||||
.and_then(|b| sample_float(b, t))
|
||||
.map(|v| v / POS_SCALE)
|
||||
.filter(|&v| v > 1.0)
|
||||
.unwrap_or(d.fog_end);
|
||||
.unwrap_or(ZERO_KEY_SCALAR);
|
||||
let fog_start_frac = fb(FB_FOG_START_MULT)
|
||||
.and_then(|b| sample_float(b, t))
|
||||
.unwrap_or(d.fog_start_frac);
|
||||
let col = |idx: u32, fallback: [f32; 3]| {
|
||||
ib(idx).and_then(|b| sample_color(b, t)).unwrap_or(fallback)
|
||||
.unwrap_or(ZERO_KEY_SCALAR);
|
||||
let col = |idx: u32| {
|
||||
ib(idx)
|
||||
.and_then(|b| sample_color(b, t))
|
||||
.unwrap_or(ZERO_KEY_COLOR)
|
||||
};
|
||||
Atmosphere {
|
||||
fog_end,
|
||||
// UNCLAMPED, negative under storm (Elwynn −0.5) — the negative start IS the constant
|
||||
// near veil the reference shows in rain; clamping it to 0 was the veil-killing bug.
|
||||
fog_start_frac,
|
||||
fog_color: col(IB_FOG_COLOR, d.fog_color),
|
||||
sun_diffuse: col(IB_DIFFUSE, d.sun_diffuse),
|
||||
sun_color: col(IB_SUN_COLOR, d.sun_color),
|
||||
ambient: col(IB_AMBIENT, d.ambient),
|
||||
sky: std::array::from_fn(|i| col(IB_SKY0 + i as u32, d.sky[i])),
|
||||
fog_color: col(IB_FOG_COLOR),
|
||||
sun_diffuse: col(IB_DIFFUSE),
|
||||
sun_color: col(IB_SUN_COLOR),
|
||||
ambient: col(IB_AMBIENT),
|
||||
sky: std::array::from_fn(|i| col(IB_SKY0 + i as u32)),
|
||||
// Dedicated water-tint rows (RAW, no scale): 16/17 river/lake, 14/15 ocean — the 2-endpoint
|
||||
// depth-swatch lerp (`FUN_0068a830`). These are real per-zone rows, NOT the sky gradient.
|
||||
water_river: [
|
||||
col(IB_RIVER_SHALLOW, d.water_river[0]),
|
||||
col(IB_RIVER_DEEP, d.water_river[1]),
|
||||
],
|
||||
water_ocean: [
|
||||
col(IB_OCEAN_SHALLOW, d.water_ocean[0]),
|
||||
col(IB_OCEAN_DEEP, d.water_ocean[1]),
|
||||
],
|
||||
//
|
||||
// ⚠ The zero-key arm is WIDEST here and least self-corroborating. 125/149/59/59 of the
|
||||
// 426 params leave ocean-shallow/ocean-deep/river-shallow/river-deep unkeyed, and 53
|
||||
// CLEAR-slot spheres on maps 0/1 are among them — so this is where black actually
|
||||
// reaches player water. Unlike the cloud base (209 of 308 keyed rows are exactly
|
||||
// `(0,0,0)`, not one is pale — black is that lane's modal authored value), only 3-10 of
|
||||
// ~300 keyed water rows are black: a keyless water row answering black is out of family
|
||||
// for the authored data. Both halves are byte-verified — the slot is black (1465) and
|
||||
// the swatch builder reads the slot (0686) — so this is what the reference computes;
|
||||
// what it LOOKS like in one of those 53 zones is the director's to judge, and this note
|
||||
// is the pointer back if it reads wrong.
|
||||
water_river: [col(IB_RIVER_SHALLOW), col(IB_RIVER_DEEP)],
|
||||
water_ocean: [col(IB_OCEAN_SHALLOW), col(IB_OCEAN_DEEP)],
|
||||
// Per-zone water-blend alphas (static LightParams fields 5–8); fall back to the DEFAULT
|
||||
// ramp endpoints when the record didn't carry them.
|
||||
water_river_alpha: self
|
||||
@@ -800,12 +826,8 @@ impl LightCatalog {
|
||||
// the coverage threshold and the visible dome's colors (wow-re cloud pipeline §3c/§4).
|
||||
cloud_density: fb(FB_CLOUD_DENSITY)
|
||||
.and_then(|b| sample_float(b, t))
|
||||
.unwrap_or(d.cloud_density),
|
||||
cloud_colors: [
|
||||
col(IB_CLOUD_SUN, d.cloud_colors[0]),
|
||||
col(IB_CLOUD_SLOPE, d.cloud_colors[1]),
|
||||
col(IB_CLOUD_GBASE, d.cloud_colors[2]),
|
||||
],
|
||||
.unwrap_or(ZERO_KEY_SCALAR),
|
||||
cloud_colors: [col(IB_CLOUD_SUN), col(IB_CLOUD_SLOPE), col(IB_CLOUD_GBASE)],
|
||||
// Per-zone highlightSky flag (static); gates the dawn/dusk dome warp. Fallback 0.0.
|
||||
highlight_sky: self
|
||||
.light_params_highlight
|
||||
|
||||
@@ -63,6 +63,31 @@ pub(super) const LP_WATER_DEEP_ALPHA: usize = 6;
|
||||
pub(super) const LP_OCEAN_SHALLOW_ALPHA: usize = 7;
|
||||
pub(super) const LP_OCEAN_DEEP_ALPHA: usize = 8;
|
||||
|
||||
/// The reference's answer for a band row that exists but carries **no keyframes**
|
||||
/// (`numKeys == 0`) — the case 973 of the 7668 shipped `LightIntBand` rows and 132 of the 2556
|
||||
/// `LightFloatBand` rows are in, and the one this crate used to answer with an invented
|
||||
/// "neutral daytime" constant.
|
||||
///
|
||||
/// Byte-VERIFIED (decision 1465, wow-re `system/lighting/scratch/band-zero-key-contract.md`):
|
||||
/// the colour evaluator `0x6d62e0` early-outs *before* touching the key/value arrays — key-count
|
||||
/// load `0x6d62ec`, guard `0x6d62ef`/`0x6d62f4`, store `0x6d62f6 mov [edi], 0xff000000` — an
|
||||
/// immediate, so the slot is opaque **black**: not zero-alpha, not stale, not skipped, and the
|
||||
/// driver's copy into the colour table (`0x6d6643`/`0x6d664c` for sub-12) is unconditional.
|
||||
/// The scalar evaluator does the same at `0x6d6489`/`0x6d648e` with `fld [0x7ffd74]` = `+0.0f`.
|
||||
/// We keep only the RGB; the forced 0xff alpha has no consumer on this side.
|
||||
///
|
||||
/// This is NOT [`Atmosphere::DEFAULT`]'s job. That constant answers "there is no light record at
|
||||
/// all", which decision 0987 showed is unreachable in world (a rowless map takes `Light.dbc`
|
||||
/// record 1); the reference's own whole-record default is the pre-first-map-load white table at
|
||||
/// `0x6d22ce`. Using a *daytime palette* to fill a *single missing row* was the actual defect
|
||||
/// behind report B90: `LightParams` 36 (Blasted Lands) leaves the cloud gradient base
|
||||
/// (`IB_CLOUD_GBASE`) unkeyed, so the sky took a pale grey where the data says black — under a
|
||||
/// zone that authors cloud density 0.85, that is the whole sky.
|
||||
pub const ZERO_KEY_COLOR: [f32; 3] = [0.0, 0.0, 0.0];
|
||||
|
||||
/// The scalar half of [`ZERO_KEY_COLOR`] — a keyless `LightFloatBand` row commits `+0.0f`.
|
||||
pub const ZERO_KEY_SCALAR: f32 = 0.0;
|
||||
|
||||
/// Sampled atmosphere for a position + time of day. Distances are yards; colors are sRGB 0..1
|
||||
/// (feed straight into Bevy `Color::srgb`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -128,7 +153,10 @@ pub struct Atmosphere {
|
||||
}
|
||||
|
||||
impl Atmosphere {
|
||||
/// Neutral daytime fallback when a position has no light / the bands are missing.
|
||||
/// Neutral daytime fallback for **no lighting data at all** — a chain with no `Light.dbc`, or a
|
||||
/// catalog that failed to load. It is not reachable from a shipped install: every map resolves a
|
||||
/// record (0987) and every record's 24 band rows exist, so a *row* with no keys takes
|
||||
/// [`ZERO_KEY_COLOR`]/[`ZERO_KEY_SCALAR`] instead — this constant is never spliced into one.
|
||||
pub const DEFAULT: Atmosphere = Atmosphere {
|
||||
fog_end: 1000.0,
|
||||
fog_start_frac: 0.4,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! **A band row with no keyframes commits the reference's constant — black, or `+0.0` — never an
|
||||
//! invented daytime value.**
|
||||
//!
|
||||
//! 973 of the 7668 shipped `LightIntBand` rows and 132 of the 2556 `LightFloatBand` rows carry
|
||||
//! `numKeys == 0`. The colour evaluator `0x6d62e0` early-outs before touching the key arrays
|
||||
//! (count load `0x6d62ec`, guard `0x6d62ef`/`0x6d62f4`, store `0x6d62f6 mov [edi], 0xff000000`) and
|
||||
//! the scalar one does the same at `0x6d6489`/`0x6d648e` with `fld [0x7ffd74]` = `+0.0f`; both
|
||||
//! stores are immediates, and the copy into the colour table is unconditional — so the answer is
|
||||
//! not stale, not skipped, and not a static initialiser. (wow-re
|
||||
//! `system/lighting/scratch/band-zero-key-contract.md`; decision 1465.)
|
||||
//!
|
||||
//! benilla used to answer every one of those rows out of `Atmosphere::DEFAULT`, a hand-written
|
||||
//! "neutral daytime" palette meant for *no lighting data at all*. Report **B90** is what that cost:
|
||||
//! `LightParams` 36 (Blasted Lands / the Tainted Scar) leaves the cloud gradient base unkeyed, so
|
||||
//! the cloud layer took a pale grey `[0.75, 0.78, 0.82]` where the data says black — and the zone
|
||||
//! authors cloud density 0.85, so that layer *is* the sky there. The reporter saw pure white.
|
||||
|
||||
use benilla_formats::{Chain, LightCatalog, ZERO_KEY_COLOR, ZERO_KEY_SCALAR};
|
||||
|
||||
/// Half-minutes; 1440 = noon.
|
||||
const NOON: u32 = 1440;
|
||||
/// Blasted Lands' clear-weather `LightParams` — the B90 record. Its `LightIntBand` sub-12
|
||||
/// (cloud gradient base) is id `(36-1)*18 + 13 = 643`, `numKeys = 0`.
|
||||
const PARAMS_BLASTED_LANDS: u32 = 36;
|
||||
/// Elwynn / the Azeroth global — the control. Its sub-12 is authored, and authored *black*.
|
||||
const PARAMS_ELWYNN: u32 = 12;
|
||||
|
||||
fn params(id: u32) -> Option<benilla_formats::Atmosphere> {
|
||||
let data = benilla_formats::wow_data_or_skip!(None);
|
||||
let mut chain = Chain::open(&data).expect("open vanilla patch chain");
|
||||
let cat = LightCatalog::load(&mut chain).expect("load Light/LightParams/*Band");
|
||||
Some(
|
||||
cat.sample_params_id(id, NOON)
|
||||
.expect("shipped LightParams id"),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_blasted_lands_cloud_base_is_black_not_a_pale_invention() {
|
||||
let Some(atmo) = params(PARAMS_BLASTED_LANDS) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// The defect: this used to be `[0.75, 0.78, 0.82]`.
|
||||
assert_eq!(
|
||||
atmo.cloud_colors[2], ZERO_KEY_COLOR,
|
||||
"sub-12 is keyless for LightParams 36 — the cloud gradient base must be black"
|
||||
);
|
||||
// Its two neighbours ARE authored, and must be untouched by the change: this is what proves the
|
||||
// fix reads the row rather than blanking the palette.
|
||||
let rgb = |c: [f32; 3]| c.map(|v| (v * 255.0).round() as i32);
|
||||
assert_eq!(
|
||||
rgb(atmo.cloud_colors[0]),
|
||||
[133, 47, 0],
|
||||
"sub-10 sun glow tint"
|
||||
);
|
||||
assert_eq!(
|
||||
rgb(atmo.cloud_colors[1]),
|
||||
[0, 34, 88],
|
||||
"sub-11 gradient slope"
|
||||
);
|
||||
// And the density that makes this zone show the defect at all.
|
||||
assert!(
|
||||
(atmo.cloud_density - 0.85).abs() < 1e-6,
|
||||
"Blasted Lands authors C = 0.85 (Elwynn's is 0.50): near-total overcast"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_authored_black_and_a_keyless_row_agree_which_is_why_the_data_reads_as_it_does() {
|
||||
let Some(elwynn) = params(PARAMS_ELWYNN) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Elwynn's sub-12 has keys — and they are (0,0,0). 209 of the 308 keyed gbase rows are exactly
|
||||
// that, 288 have max channel < 64, and NOT ONE is pale. A pale fallback was never plausible.
|
||||
assert_eq!(
|
||||
elwynn.cloud_colors[2], ZERO_KEY_COLOR,
|
||||
"LightParams 12 authors its cloud base as black"
|
||||
);
|
||||
// The control that must not move: Elwynn's authored slope/sun.
|
||||
let rgb = |c: [f32; 3]| c.map(|v| (v * 255.0).round() as i32);
|
||||
assert_eq!(
|
||||
rgb(elwynn.cloud_colors[1]),
|
||||
[43, 105, 132],
|
||||
"sub-11 slope, authored"
|
||||
);
|
||||
assert_eq!(rgb(elwynn.sky[0]), [0, 31, 73], "SkyColor0, authored");
|
||||
assert_eq!(
|
||||
rgb(elwynn.fog_color),
|
||||
[77, 120, 143],
|
||||
"fog row is authored, unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keyless_scalar_row_is_zero_not_a_thousand_yards() {
|
||||
// LightParams 95 is a clear-underwater slot on map 269 whose fog-end AND fog-start rows are
|
||||
// both keyless. The old `.filter(|v| v > 1.0)` turned that into 1000 yd of daylight fog.
|
||||
let Some(atmo) = params(95) else { return };
|
||||
assert_eq!(
|
||||
atmo.fog_end, ZERO_KEY_SCALAR,
|
||||
"keyless fog-end commits +0.0"
|
||||
);
|
||||
assert_eq!(atmo.fog_start_frac, ZERO_KEY_SCALAR);
|
||||
assert_eq!(atmo.fog_color, ZERO_KEY_COLOR, "its fog row is keyless too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_params_id_above_the_id_gaps_still_reads_its_own_bands() {
|
||||
// `LightParams` ids run 1..499 across 426 records — 73 gaps. The band-row id is derived from the
|
||||
// params **ID** (`(p-1)*18 + b + 1`), so keying that lookup by row *position* would hand every
|
||||
// group past the first gap another zone's bands, and the zero-key arm would then be firing on
|
||||
// the wrong record entirely. 499 is the very last id; these are its own authored rows.
|
||||
let Some(atmo) = params(499) else { return };
|
||||
let rgb = |c: [f32; 3]| c.map(|v| (v * 255.0).round() as i32);
|
||||
assert_eq!(rgb(atmo.ambient), [75, 97, 124], "sub-1");
|
||||
assert_eq!(rgb(atmo.sun_diffuse), [255, 148, 0], "sub-0");
|
||||
assert_eq!(rgb(atmo.fog_color), [107, 114, 136], "sub-7");
|
||||
assert_eq!(rgb(atmo.sky[0]), [54, 56, 73], "sub-2");
|
||||
assert_eq!(rgb(atmo.sky[4]), [117, 124, 149], "sub-6");
|
||||
// ...and its own keyless rows still answer black, on a record reached through the gaps.
|
||||
assert_eq!(atmo.cloud_colors[2], ZERO_KEY_COLOR, "sub-12 keyless");
|
||||
assert!(
|
||||
(atmo.fog_end - 14000.0 / 36.0).abs() < 1e-3,
|
||||
"fsub-0 authored"
|
||||
);
|
||||
assert!(
|
||||
(atmo.fog_start_frac + 0.2).abs() < 1e-6,
|
||||
"fsub-1 authored, negative"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user