From 2dc7d8a9e613b0b623746d5934b3a85d30c88998 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:23:08 +0700 Subject: [PATCH] /wave targets the named player; two DoEmote gates follow 1.12.1 (#769) Three ways `DoEmote` and the chat talk gesture differed from 1.12.1: - `/wave Bob` ignored the name and targeted the selection. 1.12.1 resolves a name that does not start with `%` as a player, at any range (`0x49fdb1`-`0x49fdf0`, `0x493aa0`), and sends the emote untargeted on a miss. `DoEmote(token, name)` from Lua does the same. - A polymorphed, stunned or sleeping speaker still played the talk gesture. `UNIT_FIELD_FLAGS 0x20000000` suppresses it when the sender's name is cached (`0x49d7f7`). - `DoEmote` sent while our unit was possessed (`0x5ef57e`). A chat-only emote now reads `Emotes.dbc` row 0's flags, so it is refused while asleep or dead (`0x5ef5b1`, `0x47db8e`). Tests cover the name, miss, `%t` and self cases, the gesture gate, and both `DoEmote` gates against the install's row 0. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/target/by_name.rs | 163 ++++++++++++------ crates/benilla-app/src/target/mod.rs | 2 +- crates/benilla-app/src/ui_chat/feed.rs | 18 ++ crates/benilla-app/src/ui_chat/input/mod.rs | 82 +++++++-- crates/benilla-app/src/ui_chat/input/parse.rs | 7 +- crates/benilla-app/src/ui_chat/tests.rs | 149 ++++++++++++++-- crates/benilla-formats/src/emotes.rs | 8 + crates/benilla-ui/src/script/chat_misc.rs | 31 +++- 8 files changed, 373 insertions(+), 87 deletions(-) diff --git a/crates/benilla-app/src/target/by_name.rs b/crates/benilla-app/src/target/by_name.rs index 5cf1e50c..70095e8a 100644 --- a/crates/benilla-app/src/target/by_name.rs +++ b/crates/benilla-app/src/target/by_name.rs @@ -170,16 +170,7 @@ fn rank(query: &str, name: &str, dist2: f32, mode: Match) -> Option { #[allow(clippy::type_complexity)] // one bundled param, the app's convention for big query sets pub(crate) struct ByNameScan<'w, 's> { /// Every known unit, our own avatar included: the reference has no self-exclusion here. - units: Query< - 'w, - 's, - ( - Entity, - &'static Guid, - &'static Transform, - Option<&'static ObjectStore>, - ), - >, + units: UnitQuery<'w, 's>, self_q: Query< 'w, 's, @@ -255,8 +246,7 @@ impl ByNameScan<'_, '_> { ) } - /// Resolve a name to a unit, logging one `by-name:` line per call, ungated since it runs per - /// command: the candidates counted, the winner and on what. + /// Resolve a name to a unit through [`scan_units`], the mode argument deciding the filter. fn resolve( &self, query: &str, @@ -264,51 +254,116 @@ impl ByNameScan<'_, '_> { filter: Filter, mode: Match, ) -> Option<(Entity, u64, String)> { - let query = query.trim(); - if query.is_empty() { - return None; + scan_units( + self.units + .iter() + .map(|(e, g, tf, store)| (e, g.0, tf.translation, store)), + self.origin(), + &self.names, + query, + search, + mode, + |store| filter != Filter::AssistableAlive || self.assistable_alive(store), + ) + } +} + +/// One unit query's candidates, shared by [`ByNameScan`] and [`PlayerLookup`]. +type UnitQuery<'w, 's> = Query< + 'w, + 's, + ( + Entity, + &'static Guid, + &'static Transform, + Option<&'static ObjectStore>, + ), +>; + +/// The resolver's walk (`0x493aa0`): a name to a unit, logging one `by-name:` line per call, +/// ungated since it runs per command: the candidates counted, the winner and on what. `keep` is +/// the per-candidate filter mode; `origin` is the active player's position, without which the +/// reference resolves nothing (`0x493ae0`). +fn scan_units<'a>( + units: impl Iterator)>, + origin: Option, + names: &NameCache, + query: &str, + search: NameSearch, + mode: Match, + keep: impl Fn(Option<&ObjectStore>) -> bool, +) -> Option<(Entity, u64, String)> { + let query = query.trim(); + if query.is_empty() { + return None; + } + let Some(origin) = origin else { + info!("by-name: \"{query}\" — no active player object; nothing resolves"); + return None; + }; + let mut considered = 0usize; + let mut nameless = 0usize; + let mut best: Option<(Entity, u64, Rank, String)> = None; + for (entity, guid, pos, store) in units { + if !search.accepts(guid) || !keep(store) { + continue; } - let Some(origin) = self.origin() else { - info!("by-name: \"{query}\" — no active player object; nothing resolves"); - return None; + let Some(name) = names.peek(guid) else { + nameless += 1; + continue; }; - let mut considered = 0usize; - let mut nameless = 0usize; - let mut best: Option<(Entity, u64, Rank, String)> = None; - for (entity, guid, tf, store) in &self.units { - if !search.accepts(guid.0) { - continue; - } - if filter == Filter::AssistableAlive && !self.assistable_alive(store) { - continue; - } - let Some(name) = self.names.peek(guid.0) else { - nameless += 1; - continue; - }; - considered += 1; - let dist2 = tf.translation.distance_squared(origin); - let Some(r) = rank(query, name, dist2, mode) else { - continue; - }; - if best.as_ref().is_none_or(|(_, _, b, _)| r.beats(*b)) { - best = Some((entity, guid.0, r, name.to_string())); - } + considered += 1; + let dist2 = pos.distance_squared(origin); + let Some(r) = rank(query, name, dist2, mode) else { + continue; + }; + if best.as_ref().is_none_or(|(_, _, b, _)| r.beats(*b)) { + best = Some((entity, guid, r, name.to_string())); } - match &best { - Some((_, guid, r, name)) => info!( - "by-name: \"{query}\" ({search:?}) -> \"{name}\" guid {guid:#x} \ - ({}, prefix {}, {:.1} yd) over {considered} named candidates ({nameless} unnamed)", - if r.exact { "exact" } else { "prefix" }, - r.prefix, - r.dist2.sqrt(), - ), - None => info!( - "by-name: \"{query}\" ({search:?}) -> NO MATCH over {considered} named candidates \ - ({nameless} unnamed, {mode:?}); target left untouched" - ), - } - best.map(|(e, g, _, name)| (e, g, name)) + } + match &best { + Some((_, guid, r, name)) => info!( + "by-name: \"{query}\" ({search:?}) -> \"{name}\" guid {guid:#x} \ + ({}, prefix {}, {:.1} yd) over {considered} named candidates ({nameless} unnamed)", + if r.exact { "exact" } else { "prefix" }, + r.prefix, + r.dist2.sqrt(), + ), + None => info!( + "by-name: \"{query}\" ({search:?}) -> NO MATCH over {considered} named candidates \ + ({nameless} unnamed, {mode:?}); target left untouched" + ), + } + best.map(|(e, g, _, name)| (e, g, name)) +} + +/// The player-only name search for a caller that holds the [`NameCache`] mutably and so cannot +/// take [`ByNameScan`]: `DoEmote`'s target (`0x49fdb1`). +#[derive(SystemParam)] +pub(crate) struct PlayerLookup<'w, 's> { + /// World positions, [`GlobalTransform`]: the chat drain already reads it, where `Transform` + /// would put it in an undeclared order against every system that moves a unit. + units: Query<'w, 's, (Entity, &'static Guid, &'static GlobalTransform)>, + self_q: Query<'w, 's, &'static GlobalTransform, With>, +} + +impl PlayerLookup<'_, '_> { + /// `0x493aa0` with typemask `0x10`, filter mode 0 and no exact-only flag, as `/assist ` + /// calls it: any player in the object list, whole name or longest prefix. Selects nothing. + pub(crate) fn find(&self, names: &NameCache, name: &str) -> Option<(Entity, u64)> { + let origin = self.self_q.single().ok().map(|tf| tf.translation()); + scan_units( + self.units + .iter() + .map(|(e, g, tf)| (e, g.0, tf.translation(), None)), + origin, + names, + name, + NameSearch::PlayerOnly, + Match::PrefixOk, + |_| true, + ) + .map(|(e, g, _)| (e, g)) } } diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index fa71c603..f4e34338 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -69,7 +69,7 @@ pub(crate) use relations::{ // pet bar's; the melee probe's press with nothing selected. pub(crate) use scan::{AttackNearestRequest, AttackPick}; // The chat layer's by-name asks (`/target`, `/assist`). -pub(crate) use by_name::{AssistRequest, TargetByNameRequest}; +pub(crate) use by_name::{AssistRequest, PlayerLookup, TargetByNameRequest}; // The reaction decode and its faction catalog, which also tint the target frame // (`TargetFrame_CheckFaction`); `duel_rung` is the same walk, for `/reaction`. pub(crate) use ring::{duel_rung, ring_reaction, ring_variant, Factions, RingVariant}; diff --git a/crates/benilla-app/src/ui_chat/feed.rs b/crates/benilla-app/src/ui_chat/feed.rs index 017e4a48..7f9d20b3 100644 --- a/crates/benilla-app/src/ui_chat/feed.rs +++ b/crates/benilla-app/src/ui_chat/feed.rs @@ -596,6 +596,17 @@ pub(super) struct SpeakerEffects<'w> { gestures: ResMut<'w, crate::creature_anim::GestureQueue>, } +/// `UNIT_FIELD_FLAGS` bit `0x20000000` (`PREVENT_ANIM`: Polymorph, Kidney Shot, Gouge, Sleep). +const UNIT_FLAG_PREVENT_ANIM: u32 = 0x2000_0000; + +/// Whether the speaker's flags suppress the chat talk gesture. Only the handler's own copy of +/// the selector tests the bit (`0x49d7f7`, the one `0xa0` test of `0x20000000` in the image), +/// which runs when the sender's name is already cached (`tries == 0`); a line held for the name +/// query gestures from the deferred copies (`0x49cf2b`, `0x49d3bb`), which do not. +pub(super) fn gesture_prevented(tries: u16, speaker_flags: Option) -> bool { + tries == 0 && speaker_flags.is_some_and(|f| f & UNIT_FLAG_PREVENT_ANIM != 0) +} + /// Drain [`ChatLog`]: resolve names (ask-once, bounded), build the events and [`route`] them. pub(super) fn feed_chat( script: Option>, @@ -758,6 +769,12 @@ pub(super) fn feed_chat( // The gesture reads the raw type and `plain`, not the garbled text: the selector is // in the parser (`0x49d560`, `0x49d820`-`0x49d8ae`) on the buffer `0x49dbc2` hands // `0x49a870`, so a Horde `lol` laughs for every observer. + // The immediate path alone refuses a speaker that cannot animate (`0x49d7f7`). + let speaker_flags = guids + .0 + .get(&msg.sender_guid) + .and_then(|e| stores.get(*e).ok()) + .map(|s| s.0.unit_flags()); if let Some(gesture) = crate::creature_anim::select_gesture(msg.chat_type, &plain, |n| { script @@ -766,6 +783,7 @@ pub(super) fn feed_chat( .get::(format!("LAUGH_WORD{n}")) .ok() }) + .filter(|_| !gesture_prevented(tries, speaker_flags)) { speaker.gestures.push(msg.sender_guid, gesture); } diff --git a/crates/benilla-app/src/ui_chat/input/mod.rs b/crates/benilla-app/src/ui_chat/input/mod.rs index 8d26b348..8043b0c5 100644 --- a/crates/benilla-app/src/ui_chat/input/mod.rs +++ b/crates/benilla-app/src/ui_chat/input/mod.rs @@ -29,12 +29,27 @@ fn target_player_name( names.resolve(guid, commands).map(str::to_string) } -/// The target guid a `CMSG_TEXT_EMOTE` carries: the selection, except that an emote at yourself -/// goes out untargeted (`0x5ef611`), so 1.12 has no self-emote sentence. Compared by entity, which -/// agrees with the guid: [`Selection`]'s one writer sets both from one streamed entity. -pub(super) fn emote_target(selection: &Selection, me: Option) -> u64 { - match selection.guid { - Some(guid) if !(me.is_some() && selection.target == me) => guid, +/// The target guid a `CMSG_TEXT_EMOTE` carries (`DoEmote`'s caller, `0x49fdb1`): the selection, +/// unless the argument is a non-empty word not starting with `%`, which names a player through +/// `named` (the `0x493aa0` player search) and sends guid 0 on a miss. Either way an emote at +/// yourself goes out untargeted (`0x5ef611`), so 1.12 has no self-emote sentence. Compared by +/// entity, which agrees with the guid: [`Selection`]'s one writer sets both from one streamed +/// entity. +pub(super) fn emote_target( + arg: Option<&str>, + selection: &Selection, + named: impl FnOnce(&str) -> Option<(Entity, u64)>, + me: Option, +) -> u64 { + let (entity, guid) = match arg.filter(|a| !a.is_empty() && !a.starts_with('%')) { + Some(name) => match named(name) { + Some((entity, guid)) => (Some(entity), Some(guid)), + None => (None, None), + }, + None => (selection.target, selection.guid), + }; + match guid { + Some(guid) if !(me.is_some() && entity == me) => guid, _ => 0, } } @@ -73,6 +88,8 @@ pub(super) struct ChatOut<'w, 's> { target: MessageWriter<'w, crate::target::TargetByNameRequest>, assist: MessageWriter<'w, crate::target::AssistRequest>, follow: MessageWriter<'w, crate::player::FollowRequest>, + /// `/wave `'s player search, which selects nothing. + players: crate::target::PlayerLookup<'w, 's>, /// `/partytest ping` seats a group member's ping through the wire arm's `seat`. ping: ResMut<'w, crate::minimap::MinimapPing>, /// The red error line by GlobalStrings key. The system's only `UiErrorKeys` access: a second @@ -91,7 +108,13 @@ fn engine_verbs( for e in script.take_emote_requests() { // The token is the `EmotesText.dbc` name (`EMOTE_TOKEN`, "WAVE"). match emotes.and_then(|c| c.text_id(&e.token)) { - Some(id) => out.push((String::new(), ParsedChat::TextEmote(id))), + Some(text_id) => out.push(( + String::new(), + ParsedChat::TextEmote { + text_id, + arg: e.target, + }, + )), None => warn!( "chat: DoEmote({:?}): no EmotesText row for that token", e.token @@ -650,22 +673,25 @@ pub(super) fn drain_chat_input( } } // `DoEmote` (`0x5ef560`): the gates in the client's order, then posture and packet. - ParsedChat::TextEmote(text_id) => { + ParsedChat::TextEmote { text_id, arg } => { let (stand_state, flags) = self_player .single() .map_or((0, 0), |(_, m, _)| (m.stand_state, m.flags)); let swimming = flags & move_flags::SWIMMING != 0; let emote_id = emotes.as_deref().and_then(|e| e.text_emote(text_id)); - // A chat-only text emote (no Emotes.dbc row) has no flags or posture: it sends. let posture = emote_id.and_then(|id| emotes.as_deref()?.posture_state(id)); - // Gate A (`CheckEmoteEligible`, `0x47db40`) suppresses the anim and the packet, - // except its `0x4000` arm, which refuses out loud. + // `DoEmote`'s gates (`0x5ef57e`-`0x5ef5c3`): a possessed unit, then the `Emotes.dbc` + // row, then `CheckEmoteEligible` (`0x47db40`); only its `0x4000` arm refuses out + // loud. A chat-only text emote (`EmoteID` 0) reads row 0. + let self_unit_flags = self_store.single().map_or(0, |s| s.0.unit_flags()); let gate = match emotes.as_deref() { - Some(e) => emote_id - .and_then(|id| e.emote_flags(id)) - .map_or(EmoteGate::Send, |f| { - emote_send_eligible(f, stand_state, swimming, flags) - }), + Some(e) => emote_gate( + e.emote_flags(emote_id.unwrap_or(0)), + self_unit_flags, + stand_state, + swimming, + flags, + ), None => EmoteGate::Send, }; // `DoEmote`'s half of the `0x4000` arm (`0x5ef5d0`): the red line only while @@ -713,7 +739,9 @@ pub(super) fn drain_chat_input( .write(crate::player::StandStateRequest { state: state as u8 }); } let target = emote_target( + arg.as_deref(), &selection, + |name| chat_out.players.find(&names, name), self_player.single().ok().map(|(entity, _, _)| entity), ); match commands @@ -1316,6 +1344,28 @@ pub(super) fn emote_send_eligible( EmoteGate::Send } +/// `UNIT_FLAG_POSSESSED`, `UNIT_FIELD_FLAGS` bit 24: `DoEmote` returns silently for a unit that +/// has it (`0x5ef57e`, `descriptor+0xa3 & 1`). +const UNIT_FLAG_POSSESSED: u32 = 0x0100_0000; + +/// `DoEmote`'s entry gates (`0x5ef560`) in the client's order: the unit's own `UNIT_FIELD_FLAGS` +/// (`0x5ef57e`), the `Emotes.dbc` row by `EmoteID` (`0x5ef59c`-`0x5ef5b3`; `None` is a missing +/// row, which returns), then `CheckEmoteEligible` over the row's flags. +pub(super) fn emote_gate( + row_flags: Option, + unit_flags: u32, + stand_state: u8, + swimming: bool, + move_flags: u32, +) -> EmoteGate { + match row_flags { + Some(flags) if unit_flags & UNIT_FLAG_POSSESSED == 0 => { + emote_send_eligible(flags, stand_state, swimming, move_flags) + } + _ => EmoteGate::Suppressed, + } +} + /// `PLAYER_FLAGS_DND` (`0x4`) off our live descriptor. Live, not mirrored, as in the reference /// (`0x49f3f0`): AFK keeps an optimistic global and DND nothing, so a second `/dnd` before the /// server's update re-marks where a second `/afk` clears. diff --git a/crates/benilla-app/src/ui_chat/input/parse.rs b/crates/benilla-app/src/ui_chat/input/parse.rs index 59928bb1..48b198c6 100644 --- a/crates/benilla-app/src/ui_chat/input/parse.rs +++ b/crates/benilla-app/src/ui_chat/input/parse.rs @@ -52,7 +52,7 @@ pub(in crate::ui_chat) enum ParsedChat { /// `/help`. Help, /// An `EmotesText` command (`/wave` is 101), sent as `CMSG_TEXT_EMOTE` at the selection. - TextEmote(u32), + TextEmote { text_id: u32, arg: Option }, /// A channel verb the stock handler parsed and the VM queued; never from [`parse_line`]. Channel(benilla_ui::script::ChannelCommand), /// `/castvis`: a locally synthesized [`crate::creature_anim::CastEvent`], at the selection @@ -128,7 +128,10 @@ pub(in crate::ui_chat) fn parse_line(table: &SlashCommands, line: &str) -> Parse match table.lookup(cmd) { Some(Command::Slash(index)) => slash_command(index, args), // The alias table did the `DoEmote(token)` resolve at boot: `/lol` arrives as LAUGH's id. - Some(Command::Emote { text_id }) => ParsedChat::TextEmote(text_id), + Some(Command::Emote { text_id }) => ParsedChat::TextEmote { + text_id, + arg: (!args.is_empty()).then(|| args.to_string()), + }, Some(Command::Dev(dev)) => dev_command(dev, args), // `HELP_TEXT_SIMPLE`'s case, after the drain offers the line to `SlashCmdList`. None => ParsedChat::Unknown, diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs index 7bb9ecca..27c39408 100644 --- a/crates/benilla-app/src/ui_chat/tests.rs +++ b/crates/benilla-app/src/ui_chat/tests.rs @@ -1,5 +1,5 @@ use super::event::{default_color, ChatEvent, ChatEventKind as K}; -use super::input::{emote_send_eligible, emote_target, EmoteGate, ParsedChat}; +use super::input::{emote_gate, emote_send_eligible, emote_target, EmoteGate, ParsedChat}; thread_local! { /// The shipped `GlobalStrings.lua`, run in a VM once per test thread; built lazily, so a @@ -165,23 +165,60 @@ fn emoting_at_your_own_selection_sends_an_untargeted_emote() { guid: Some(0xdead_beef), ..Default::default() }; - assert_eq!(emote_target(&sel, Some(me)), 0); + assert_eq!(emote_target(None, &sel, no_name, Some(me)), 0); let sel = Selection { target: Some(them), guid: Some(0xdead_beef), ..Default::default() }; - assert_eq!(emote_target(&sel, Some(me)), 0xdead_beef); + assert_eq!(emote_target(None, &sel, no_name, Some(me)), 0xdead_beef); // No selection is untargeted, and with no self entity yet a selection goes out untouched. - assert_eq!(emote_target(&Selection::default(), Some(me)), 0); + assert_eq!( + emote_target(None, &Selection::default(), no_name, Some(me)), + 0 + ); let sel = Selection { target: Some(them), guid: Some(0xdead_beef), ..Default::default() }; - assert_eq!(emote_target(&sel, None), 0xdead_beef); + assert_eq!(emote_target(None, &sel, no_name, None), 0xdead_beef); +} + +fn no_name(_: &str) -> Option<(bevy::prelude::Entity, u64)> { + None +} + +/// `0x49fdb1`: a word not starting with `%` names a player and replaces the selection; a miss is +/// guid 0; no argument or a `%` word is the selection. +#[test] +fn an_emote_argument_targets_the_named_player_and_not_the_selection() { + use crate::target::Selection; + use bevy::prelude::Entity; + + let me = Entity::from_raw_u32(7).unwrap(); + let bob = Entity::from_raw_u32(9).unwrap(); + let sel = Selection { + target: Some(Entity::from_raw_u32(11).unwrap()), + guid: Some(0x5e1), + ..Default::default() + }; + let find = |n: &str| (n == "Bob").then_some((bob, 0xb0b)); + assert_eq!(emote_target(Some("Bob"), &sel, find, Some(me)), 0xb0b); + assert_eq!( + emote_target(Some("Nobody"), &sel, find, Some(me)), + 0, + "a miss is untargeted" + ); + for arg in [None, Some(""), Some("%t"), Some("%x")] { + let panics = |_: &str| -> Option<(Entity, u64)> { panic!("no search for {arg:?}") }; + assert_eq!(emote_target(arg, &sel, panics, Some(me)), 0x5e1, "{arg:?}"); + } + // Naming yourself is the self-target edge: untargeted. + let own = |_: &str| Some((me, 0x111)); + assert_eq!(emote_target(Some("Me"), &sel, own, Some(me)), 0); } #[test] @@ -1188,16 +1225,24 @@ fn action_commands_parse() { ); } +fn emote(text_id: u32, arg: Option<&str>) -> ParsedChat { + ParsedChat::TextEmote { + text_id, + arg: arg.map(str::to_string), + } +} + #[test] fn emote_aliases_resolve_through_the_table() { let _data = benilla_formats::wow_data_or_skip!(); let t = stub_table(); let parse_line = |line: &str| super::input::parse_line(&t, line); - assert_eq!(parse_line("/wave"), ParsedChat::TextEmote(101)); + assert_eq!(parse_line("/wave"), emote(101, None)); // An alias that is not its token's `EmotesText` name resolves too, as `/lol` (LAUGH) does. - assert_eq!(parse_line("/hello"), ParsedChat::TextEmote(101)); + assert_eq!(parse_line("/hello"), emote(101, None)); // An emote takes an argument (`DoEmote(token, msg)`): the command is the first word only. - assert_eq!(parse_line("/wave Bob"), ParsedChat::TextEmote(101)); + assert_eq!(parse_line("/wave Bob"), emote(101, Some("Bob"))); + assert_eq!(parse_line("/wave %t"), emote(101, Some("%t"))); assert_eq!(parse_line("/nosuch"), ParsedChat::Unknown); } @@ -1329,7 +1374,7 @@ fn real_alias_table_resolves_the_shipped_commands() { let parse_line = |line: &str| super::input::parse_line(&table, line); // `/sit` is EmotesText 86, whose `Emotes.dbc` row 13 (STATE_SIT) sets stand state 1. - assert_eq!(parse_line("/sit"), ParsedChat::TextEmote(86)); + assert_eq!(parse_line("/sit"), emote(86, None)); assert_eq!( cat.text_emote(86).and_then(|e| cat.posture_state(e)), Some(1) @@ -1341,7 +1386,7 @@ fn real_alias_table_resolves_the_shipped_commands() { ("/liedown", 3), ("/kneel", 8), ] { - let ParsedChat::TextEmote(text_id) = parse_line(line) else { + let ParsedChat::TextEmote { text_id, .. } = parse_line(line) else { panic!("{line} is an emote"); }; let posture = cat.text_emote(text_id).and_then(|e| cat.posture_state(e)); @@ -1363,7 +1408,7 @@ fn real_alias_table_resolves_the_shipped_commands() { "/strong", ] { assert!( - matches!(parse_line(line), ParsedChat::TextEmote(_)), + matches!(parse_line(line), ParsedChat::TextEmote { .. }), "{line} resolves to an emote" ); } @@ -1566,6 +1611,52 @@ const CHEER: u32 = 0x0800; const SALUTE: u32 = 0x0800; const LAUGH: u32 = 0x0980; +/// `0x5ef57e`: a unit with `UNIT_FLAG_POSSESSED` (`UNIT_FIELD_FLAGS & 0x01000000`) refuses every +/// emote, silently; a missing `Emotes.dbc` row returns too (`0x5ef5b1`). +#[test] +fn a_possessed_unit_or_a_missing_row_refuses_the_emote() { + assert_eq!(emote_gate(Some(0), 0, 0, false, 0), EmoteGate::Send); + assert_eq!( + emote_gate(Some(0), 0x0100_0000, 0, false, 0), + EmoteGate::Suppressed + ); + assert_eq!( + emote_gate(Some(0), 0x0100_0008, 0, false, 0), + EmoteGate::Suppressed + ); + assert_eq!( + emote_gate(Some(0), 0x0200_0000, 0, false, 0), + EmoteGate::Send, + "a neighbour bit" + ); + assert_eq!(emote_gate(None, 0, 0, false, 0), EmoteGate::Suppressed); +} + +/// A chat-only text emote has `EmoteID` 0, so `DoEmote` reads `Emotes.dbc` row 0 (flags 0), which +/// lacks `0x200`: dead (7) and asleep (3) refuse it, as they refuse a `/wave` (`0x47db8e`). +#[test] +fn a_chat_only_emote_reads_row_zero_and_is_refused_dead_or_asleep() { + 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 smile = cat.text_id("smile").expect("SMILE"); + assert_eq!(cat.text_emote(smile), None, "SMILE is chat-only"); + let row = cat.emote_flags(cat.text_emote(smile).unwrap_or(0)); + assert_eq!(row, Some(0), "row 0, no flags"); + assert_eq!( + emote_gate(row, 0, 7, false, 0), + EmoteGate::Suppressed, + "dead" + ); + assert_eq!( + emote_gate(row, 0, 3, false, 0), + EmoteGate::Suppressed, + "asleep" + ); + assert_eq!(emote_gate(row, 0, 0, false, 0), EmoteGate::Send, "standing"); + assert_eq!(emote_gate(row, 0, 1, false, 0), EmoteGate::Send, "sitting"); +} + #[test] fn seated_stand_required_emotes_are_suppressed() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1824,6 +1915,42 @@ fn the_talk_gesture_reads_the_plaintext_not_the_garbled_line() { ); } +/// `0x49d7f7`: a speaker whose `UNIT_FIELD_FLAGS` hold `0x20000000` (Polymorph, Kidney Shot, ...) +/// plays no talk gesture on the immediate path; the copies behind a name query (`0x49ccc0`, +/// `0x49d230`) have no such test. +#[test] +fn a_speaker_that_cannot_animate_queues_no_talk_gesture() { + use super::feed::gesture_prevented; + use crate::creature_anim::select_gesture; + use benilla_protocol::messages::CHAT_MSG_SAY; + + let laugh_words = |n: u32| (n == 1).then(|| "lol".to_string()); + let speak = |tries: u16, flags: Option| { + select_gesture(CHAT_MSG_SAY, "lol", laugh_words) + .filter(|_| !gesture_prevented(tries, flags)) + .into_iter() + .count() + }; + assert_eq!(speak(0, Some(0x2000_0000)), 0, "a polymorphed speaker"); + assert_eq!(speak(0, Some(0x2000_0000 | 0x8)), 0, "among other bits"); + assert_eq!(speak(0, Some(0)), 1, "an unflagged speaker laughs"); + assert_eq!( + speak(0, Some(0x1000_0000 | 0x0100_0000)), + 1, + "neighbouring bits do not gate" + ); + assert_eq!( + speak(0, None), + 1, + "an unstreamed speaker is the queue's call, not this gate" + ); + assert_eq!( + speak(3, Some(0x2000_0000)), + 1, + "a line held for the name has no such test" + ); +} + // ── the combat log reaches addons ──────────────────────────────────────────────────────────── /// Combat-log addons parse arg1 with patterns built from the GlobalStrings, so arg1 is the whole diff --git a/crates/benilla-formats/src/emotes.rs b/crates/benilla-formats/src/emotes.rs index 06455408..99ff886a 100644 --- a/crates/benilla-formats/src/emotes.rs +++ b/crates/benilla-formats/src/emotes.rs @@ -230,6 +230,14 @@ mod tests { let cat = load_emote_sound_catalog(&mut chain).expect("load emote catalog"); assert_eq!(cat.text_id("wave"), Some(101), "case-insensitive by name"); assert_eq!(cat.text_emote(101), Some(3), "WAVE plays anim emote 3"); + // A chat-only text emote has `EmoteID` 0, which `DoEmote` (`0x5ef591`) reads as row 0. + assert_eq!( + cat.text_emote(cat.text_id("smile").unwrap()), + None, + "SMILE is chat-only" + ); + assert_eq!(cat.emote_flags(0), Some(0), "row 0 exists with no flags"); + assert_eq!(cat.spec_proc(0), Some(0), "row 0 is no posture emote"); assert_eq!(cat.anim(2), Some(66), "ONESHOT_BOW (id 2) plays AnimID 66"); // The posture-gate rows (`0x47db40`), ids from vmangos `SharedDefines.h`'s `Emote` enum. assert_eq!(cat.emote_flags(2), Some(0x4801), "ONESHOT_BOW EmoteFlags"); diff --git a/crates/benilla-ui/src/script/chat_misc.rs b/crates/benilla-ui/src/script/chat_misc.rs index b67b55ee..e54dc116 100644 --- a/crates/benilla-ui/src/script/chat_misc.rs +++ b/crates/benilla-ui/src/script/chat_misc.rs @@ -8,7 +8,8 @@ use mlua::{Lua, MultiValue, Value}; use super::Model; /// `DoEmote(token [, target])`: the `EmotesText.dbc` name token (`"WAVE"`, not `/wave`) and an -/// optional target name. +/// optional target string (`0x49fd30` reads arg 2 with `lua_tostring`): a player name, or a `%` +/// word for the selection. A non-string, non-number second argument reads as none. #[derive(Clone, Debug, PartialEq, Eq)] pub struct EmoteRequest { pub token: String, @@ -79,14 +80,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "DoEmote", - lua.create_function(|lua, (token, target): (Option, Option)| { + lua.create_function(|lua, (token, target): (Option, Value)| { let Some(token) = token.filter(|t| !t.is_empty()) else { return Ok(()); }; let mut model = lua.app_data_mut::().expect("model app_data"); model.emote_requests.push(EmoteRequest { token: token.to_ascii_uppercase(), - target: target.filter(|t| !t.trim().is_empty()), + target: match target { + Value::String(s) => Some(s.to_string_lossy()), + Value::Integer(i) => Some(i.to_string()), + Value::Number(n) => Some(n.to_string()), + _ => None, + } + .filter(|t| !t.is_empty()), }); Ok(()) })?, @@ -208,6 +215,24 @@ mod tests { assert!(s.take_emote_requests().is_empty(), "drained"); } + /// The second argument is a string, never a unit token: `"target"` is a name like any other, + /// and `%t` reaches the app's selection arm untouched (`0x49fdb1`). + #[test] + fn do_emote_second_argument_is_passed_as_a_string() { + let mut s = UiScript::new().unwrap(); + s.run("DoEmote('WAVE', 'target') DoEmote('WAVE', '%t') DoEmote('WAVE', {})") + .unwrap(); + let targets: Vec<_> = s + .take_emote_requests() + .into_iter() + .map(|e| e.target) + .collect(); + assert_eq!( + targets, + vec![Some("target".into()), Some("%t".into()), None] + ); + } + #[test] fn random_roll_coerces_the_strings_the_stock_handler_passes() { let mut s = UiScript::new().unwrap();