/wave <name> 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) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-02 09:23:08 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent fbea2d9f3a
commit 2dc7d8a9e6
8 changed files with 373 additions and 87 deletions
+109 -54
View File
@@ -170,16 +170,7 @@ fn rank(query: &str, name: &str, dist2: f32, mode: Match) -> Option<Rank> {
#[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<Item = (Entity, u64, Vec3, Option<&'a ObjectStore>)>,
origin: Option<Vec3>,
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<SelfPlayer>>,
}
impl PlayerLookup<'_, '_> {
/// `0x493aa0` with typemask `0x10`, filter mode 0 and no exact-only flag, as `/assist <name>`
/// 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))
}
}
+1 -1
View File
@@ -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};
+18
View File
@@ -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<u32>) -> 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<NonSendMut<benilla_ui::script::UiScript>>,
@@ -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::<String>(format!("LAUGH_WORD{n}"))
.ok()
})
.filter(|_| !gesture_prevented(tries, speaker_flags))
{
speaker.gestures.push(msg.sender_guid, gesture);
}
+66 -16
View File
@@ -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<Entity>) -> 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<Entity>,
) -> 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 <name>`'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<i>_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<u32>,
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.
@@ -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<String> },
/// 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,
+138 -11
View File
@@ -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<u32>| {
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
+8
View File
@@ -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");
+28 -3
View File
@@ -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<String>, Option<String>)| {
lua.create_function(|lua, (token, target): (Option<String>, Value)| {
let Some(token) = token.filter(|t| !t.is_empty()) else {
return Ok(());
};
let mut model = lua.app_data_mut::<Model>().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();