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:
Sam
2026-08-21 17:52:20 +07:00
co-authored by Claude Fable 5
parent d88aa932fe
commit 8709dd6bc9
20 changed files with 1338 additions and 168 deletions
@@ -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];
+11
View File
@@ -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)]);
}
}
+3
View File
@@ -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)
+12 -5
View File
@@ -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))
+22 -10
View File
@@ -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>();
}
}
}
+10 -1
View File
@@ -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>();
}
}
+512 -76
View File
@@ -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}");
}
}
+1 -18
View File
@@ -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;
+5 -5
View File
@@ -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,
+7 -1
View File
@@ -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> {
+10 -16
View File
@@ -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);
}
}
}
+19
View File
@@ -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,
+21
View File
@@ -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`],
+64
View File
@@ -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).
+1 -1
View File
@@ -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;
+46 -24
View File
@@ -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
+29 -1
View File
@@ -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"
);
}