Emote voices play only for you and your party, as in 1.12.1 (#766)

Every text emote's voice played for every performer, so every `/cheer` in a city was heard. 1.12.1's composer tail (`0x623c80`) plays it only for you or a member of the four party slots (`0x623cc2`-`0x623cce`, `0x5efea0`), and drops an ignored sender before that (`0x49dc36`).

What changed:
- The voice plays at attachment `0x11` and stops the unit's previous emote voice once a new one opens (`0x623c10`).
- Race and sex come from the display override before the descriptor (`0x60c6a0`/`0x60c6c0`).
- `SMSG_EMOTE` no longer plays `EventSoundID`. The one-shot path never reads it (`0x5e66b0` → `0x5fcd20`); only the state emote's `$ESD` does.

Tests cover each relation class, an ignored performer and the per-unit voice slot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-02 08:32:29 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e620a0d095
commit 4f36c843f1
9 changed files with 244 additions and 46 deletions
@@ -333,6 +333,7 @@ fn text_emote(
chat_log: &mut crate::ui_chat::ChatLog,
) {
out.write(EmoteMessage {
guid,
source: index.0.get(&guid).copied(),
kind: EmoteKind::Text(text_emote),
});
@@ -342,6 +343,7 @@ fn text_emote(
/// `SMSG_EMOTE`: an `Emotes.dbc` id to play on a unit (NPC scripts, a `/`-emote's animation).
fn emote(guid: u64, emote_id: u32, index: &GuidIndex, out: &mut MessageWriter<EmoteMessage>) {
out.write(EmoteMessage {
guid,
source: index.0.get(&guid).copied(),
kind: EmoteKind::Anim(emote_id),
});
+5
View File
@@ -242,6 +242,11 @@ impl Creatures {
self.catalog.foley_material(display_id)
}
/// A display's override race and sex (`CreatureDisplayInfoExtra`), `None` without one.
pub(crate) fn display_race_sex(&self, display_id: u32) -> Option<(u8, u8)> {
self.catalog.display_race_sex(display_id)
}
/// A display's collision height in raw model units; [`CollisionHeight`] is the world value.
pub(crate) fn collision_height(&self, display_id: u32) -> Option<f32> {
self.catalog.collision_height(display_id)
+2
View File
@@ -2192,6 +2192,8 @@ pub(crate) enum ServerSoundKind {
/// and sex).
#[derive(Message, Clone, Copy)]
pub(crate) struct EmoteMessage {
/// The performer's wire guid, known even while its entity has not streamed.
pub(crate) guid: u64,
pub(crate) source: Option<Entity>,
pub(crate) kind: EmoteKind,
}
+15 -14
View File
@@ -24,7 +24,7 @@ use benilla_assets::WorldAssets;
use benilla_world::schedule::WorldStage;
use super::emitter_pool::AmbientEmitterPool;
use super::emote::EmoteSounds;
use super::emote::{play_emote_voice, EmoteSounds, VOICE_ATTACH};
use super::kit::{play_kit, KitRef, SoundCategory, SoundKits};
use super::{AudioListener, SoundConfig, SoundOutput};
@@ -32,9 +32,6 @@ use super::{AudioListener, SoundConfig, SoundOutput};
/// point.
const TRD_HEIGHT: f32 = 1.0;
/// The attachment the emote voice plays at (`0x623c3a push 0x11`).
const CSD_ATTACH: u16 = 17;
pub(super) fn route_anim_events(
mut events: MessageReader<AnimSoundEvent>,
// GlobalTransform: `$SND` can fire from a parented visual (a mount's model).
@@ -130,20 +127,24 @@ pub(super) fn route_anim_events(
b"$SND" | b"$DSO" if ev.data != 0 => {
ring(&mut kits, &mut out, ev.data, ev, &mut complained);
}
// `$CSD` plays at attachment 17, never the fired key: the CGUnit dispatcher hands
// `0x623c10` no position (`0x5ffeed`), and it reads attachment `0x11` (`0x623b90`),
// falling back to the origin + 2.0 z. The reference then binds the handle to follow the
// unit (`0x7a57e0`, `[unit+0xb28]`); this is a one-shot at the onset point.
// `$CSD` is the emote voice `0x623c10`: attachment 17, never the fired key (the CGUnit
// dispatcher hands it no position, `0x5ffeed`; origin + 2.0 z without the
// attachment), and it stops the unit's previous emote voice.
b"$CSD" if ev.data != 0 => {
let root = transforms
.get(ev.entity)
.map_or(Vec3::ZERO, |t| t.translation());
let at = attach.point(ev.entity, CSD_ATTACH, root);
let voiced = AnimSoundEvent {
pos: Some(at),
..*ev
};
ring(&mut kits, &mut out, ev.data, &voiced, &mut complained);
let at = attach.point(ev.entity, VOICE_ATTACH, root);
if let Err(e) = play_emote_voice(
&mut kits, &assets, &mut out, &config, listener, ev.data, ev.entity, at,
) {
if complained.insert(ev.data) {
warn!(
"anim event kit {}: {e:#} (further reports for this kit suppressed)",
ev.data
);
}
}
}
b"$ESD" => {
let Some(emotes) = emotes.as_deref() else {
+181 -28
View File
@@ -1,17 +1,23 @@
//! Emote sounds: `SMSG_TEXT_EMOTE` plays the performer's race/sex voice kit
//! (`EmotesTextSound`), `SMSG_EMOTE` plays the anim emote's `EventSoundID` (`Emotes.dbc`). Our
//! own `/wave` goes out as `CMSG_TEXT_EMOTE` and plays through the server echo, as in 1.12. The
//! catalog also serves `crate::ui_chat` and `crate::creature_anim`.
//! Emote sounds: `SMSG_TEXT_EMOTE` plays the performer's race/sex voice kit (`EmotesTextSound`)
//! for the active player and the party only. Our own `/wave` goes out as `CMSG_TEXT_EMOTE` and
//! plays through the server echo, as in 1.12. The catalog also serves `crate::ui_chat` and
//! `crate::creature_anim`.
use bevy::prelude::*;
use benilla_formats::EmoteSoundCatalog;
use benilla_protocol::messages::ObjectType;
use crate::net::{EmoteKind, EmoteMessage, ObjectStore};
use crate::entities::{AttachPoints, Creatures};
use crate::net::{EmoteKind, EmoteMessage, ObjectStore, SelfGuid};
use crate::spell::group_relation::GroupRoster;
use crate::ui_social::SocialState;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::schedule::WorldStage;
use super::kit::{play_kit, KitRef, SoundCategory, SoundKits};
use super::kit::{
play_kit_ext, stop_older_emote_voices, KitRef, Latch, PlayExtras, SoundCategory, SoundKits,
};
use super::{AudioListener, SoundConfig, SoundOutput};
/// The emote-audio catalog (also the chat sender's `/name` → text-id resolver).
@@ -75,14 +81,76 @@ fn load_emote_sounds(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
}
}
/// Route the bridged emotes: a text emote plays the performer's race/sex voice; an anim emote
/// plays its event kit. A performer whose race/sex has not arrived yet stays silent.
/// The attachment the emote voice plays at (`0x623c3a push 0x11`).
pub(super) const VOICE_ATTACH: u16 = 17;
/// Who hears a text emote's voice: the composer's tail (`0x623c80`) plays it for relation class
/// 0, the active player, or 2, a player in one of the four party slots (`0x623cc2`-`0x623cce`,
/// `0x5efea0`). A raid-only member is class 4, a hostile player 6, a creature 1/3/5/7/8 and an
/// unstreamed performer 9, none of which play. An ignored performer never reaches the composer
/// (`0x49dc36`).
fn voice_audible(
performer: u64,
me: Option<u64>,
performer_is_player: bool,
roster: &GroupRoster,
ignored: bool,
) -> bool {
if ignored {
return false;
}
Some(performer) == me || performer_is_player && roster.in_party(me, performer)
}
/// `0x623c10`: play `kit` at the unit's attachment `0x11`, then stop the unit's previous emote
/// voice (`[unit+0xb28]`, `0x623c63`-`0x623c70`); a play that opens no channel stops nothing
/// (`0x623c55`). Shared by the text-emote voice and the `$CSD` anim event.
pub(super) fn play_emote_voice(
kits: &mut SoundKits,
assets: &WorldAssets,
out: &mut SoundOutput,
config: &SoundConfig,
listener: Vec3,
kit: u32,
unit: Entity,
at: Vec3,
) -> anyhow::Result<bool> {
let extras = PlayExtras {
source: Some(unit),
latch: Latch::EmoteVoice,
..PlayExtras::default()
};
let opened = play_kit_ext(
kits,
assets,
out,
config,
listener,
KitRef::Id(kit),
Some(at),
SoundCategory::Sfx,
extras,
)?;
if opened {
stop_older_emote_voices(out, unit);
}
Ok(opened)
}
/// Route a text emote to the performer's race/sex voice. A performer whose race/sex has not
/// arrived yet stays silent. `SMSG_EMOTE` plays no sound: the one-shot path never reads
/// `EventSoundID` (`0x5e66b0` -> `0x5fcd20`), which only the state emote's `$ESD` does (`0x623a21`).
fn emote_sounds(
mut msgs: MessageReader<EmoteMessage>,
units: Query<(&ObjectStore, &Transform)>,
emotes: Option<Res<EmoteSounds>>,
kits: Option<ResMut<SoundKits>>,
assets: Option<Res<WorldAssets>>,
creatures: Option<Res<Creatures>>,
roster: Option<Res<GroupRoster>>,
social: Res<SocialState>,
self_guid: Res<SelfGuid>,
attach: AttachPoints,
mut out: NonSendMut<SoundOutput>,
config: Res<SoundConfig>,
listener: Res<AudioListener>,
@@ -94,36 +162,56 @@ fn emote_sounds(
return;
};
let listener = listener.pos;
let no_roster = GroupRoster::default();
for m in msgs.read() {
let Some((store, transform)) = m.source.and_then(|e| units.get(e).ok()) else {
let EmoteKind::Text(text_id) = m.kind else {
continue;
};
let kit = match m.kind {
EmoteKind::Text(text_id) => {
// The `EmoteSounds` CVar gates only the text-emote voice, read at play time; a
// zero never fetches the kit. The `Anim` one-shot has no such gate.
if !config.emote_sounds {
continue;
}
// The `EmoteSounds` CVar gates the text-emote voice, read at play time; a zero never
// fetches the kit.
if !config.emote_sounds {
continue;
}
let Some((unit, (store, transform))) =
m.source.and_then(|e| units.get(e).ok().map(|row| (e, row)))
else {
continue;
};
let is_player = store.0.object_type() == Some(ObjectType::Player);
if !voice_audible(
m.guid,
self_guid.0,
is_player,
roster.as_deref().unwrap_or(&no_roster),
social.is_ignored(m.guid),
) {
continue;
}
// The display override's race and sex before the descriptor's (`0x60c6a0`/`0x60c6c0`).
let overridden = store
.0
.unit_displayid()
.and_then(|d| u32::try_from(d).ok())
.and_then(|d| creatures.as_deref()?.display_race_sex(d));
let (race, sex) = match overridden {
Some(rs) => rs,
None => {
let (Some(race), Some(sex)) = (store.0.unit_race(), store.0.unit_gender()) else {
continue;
};
emotes.0.voice(text_id, race as u32, sex as u32)
(race, sex)
}
EmoteKind::Anim(emote_id) => emotes.0.event_sound(emote_id),
};
let Some(kit) = kit.filter(|&k| k != 0) else {
let Some(kit) = emotes
.0
.voice(text_id, race as u32, sex as u32)
.filter(|&k| k != 0)
else {
continue; // most emotes are voiceless (/wave)
};
if let Err(e) = play_kit(
&mut kits,
&assets,
&mut out,
&config,
listener,
KitRef::Id(kit),
Some(transform.translation),
SoundCategory::Sfx,
let at = attach.point(unit, VOICE_ATTACH, transform.translation);
if let Err(e) = play_emote_voice(
&mut kits, &assets, &mut out, &config, listener, kit, unit, at,
) {
warn!("emote sound (kit {kit}): {e:#}");
}
@@ -135,3 +223,68 @@ pub(super) fn plugin(app: &mut App) {
app.add_systems(Startup, load_emote_sounds.after(AssetSet::Open))
.add_systems(Update, emote_sounds.in_set(WorldStage::Present));
}
#[cfg(test)]
mod tests {
use super::super::kit::occupies_emote_slot;
use super::*;
const ME: u64 = 1;
const MATE: u64 = 2;
const RAIDER: u64 = 3;
const STRANGER: u64 = 4;
fn roster() -> GroupRoster {
GroupRoster {
party: vec![MATE],
raid: vec![ME, MATE, RAIDER],
}
}
/// `0x623cc2`-`0x623cce`: class 0 and class 2 play, every other class is silent.
#[test]
fn only_you_and_the_party_are_heard() {
let r = roster();
let heard = |who, player, ignored| voice_audible(who, Some(ME), player, &r, ignored);
assert!(heard(ME, true, false), "class 0, you");
assert!(heard(MATE, true, false), "class 2, a party member");
assert!(
!heard(RAIDER, true, false),
"class 4: the raid is not the party slots"
);
assert!(
!heard(STRANGER, true, false),
"class 4 or 6, any other player"
);
assert!(
!heard(MATE, false, false),
"a creature never classes as a player"
);
assert!(!voice_audible(
MATE,
None,
true,
&GroupRoster::default(),
false
));
}
/// An ignored performer is dropped before the composer (`0x49dc36`), even you or a mate.
#[test]
fn an_ignored_performer_is_silent() {
let r = roster();
assert!(!voice_audible(MATE, Some(ME), true, &r, true));
assert!(!voice_audible(ME, Some(ME), true, &r, true));
}
/// The slot holder is the unit's own emote voice, never its bark or another unit's.
#[test]
fn the_emote_slot_is_the_units_own() {
let mut world = World::new();
let (a, b) = (world.spawn_empty().id(), world.spawn_empty().id());
assert!(occupies_emote_slot(Some(a), Latch::EmoteVoice, a));
assert!(!occupies_emote_slot(Some(b), Latch::EmoteVoice, a));
assert!(!occupies_emote_slot(Some(a), Latch::Voice(0), a));
assert!(!occupies_emote_slot(None, Latch::EmoteVoice, a));
}
}
+29 -2
View File
@@ -145,6 +145,10 @@ pub(super) enum Latch {
/// `0x62f880` has no such gate. It ends with the sound or when the emitter leaves
/// `DistanceCutoff` (`0x457a50`).
ObjectSound,
/// The unit's emote voice, `[unit+0xb28]` (`0x623c10`): it blocks nothing, a new emote voice
/// stops the sounding one (`0x623c68`-`0x623c70`, `Sound::Stop 0x7a5700`). Shared by the
/// text-emote voice and the `$CSD` anim event.
EmoteVoice,
}
/// The class-bark chance roll (`0x623520`): `r = MulHi32(101, rand32)` in 0..=100, pass iff
@@ -287,9 +291,9 @@ fn claim_voice(out: &mut SoundOutput, candidate_amp: f32) -> bool {
/// Can the voice cap steal this channel? Never a loop, whose loss leaves a hole that stays open,
/// and never a latch holder: its liveness is the unit's latch, so stealing it would let the unit
/// fire again at once.
/// fire again at once. The emote voice holds no latch, only a slot the next one stops.
fn stealable(looping: bool, latch: Latch) -> bool {
!looping && latch == Latch::None
!looping && matches!(latch, Latch::None | Latch::EmoteVoice)
}
/// What [`claim_voice`] decided.
@@ -591,6 +595,25 @@ pub(super) fn occupies_voice_slot(source: Option<Entity>, latch: Latch, unit: En
source == Some(unit) && matches!(latch, Latch::Voice(_))
}
/// Stop every emote voice of `unit` but the newest channel (`0x623c68`-`0x623c70`).
pub(super) fn stop_older_emote_voices(out: &mut SoundOutput, unit: Entity) {
let newest = out.channels.len().saturating_sub(1);
let mut i = 0;
out.channels.retain_mut(|c| {
let stop = i != newest && occupies_emote_slot(c.source, c.latch, unit);
i += 1;
if stop {
c.handle.stop(mixer::declick());
}
!stop
});
}
/// Does this channel hold `unit`'s emote-voice slot (`[unit+0xb28]`)?
pub(super) fn occupies_emote_slot(source: Option<Entity>, latch: Latch, unit: Entity) -> bool {
source == Some(unit) && latch == Latch::EmoteVoice
}
/// Does this channel hold `unit`'s greeting latch (`[unit+0xb1c]`)?
pub(super) fn occupies_greeting_latch(source: Option<Entity>, latch: Latch, unit: Entity) -> bool {
source == Some(unit) && latch == Latch::Greeting
@@ -1241,6 +1264,10 @@ mod tests {
!stealable(false, Latch::Voice(0)),
"a bark holds [unit+0xb20] — stealing it lets the unit re-bark at once"
);
assert!(
stealable(false, Latch::EmoteVoice),
"an emote voice blocks nothing, so losing it re-fires nothing"
);
assert!(
!stealable(true, Latch::None),
"beds are held for their own reason"
@@ -36,7 +36,7 @@ pub(crate) struct GroupRoster {
impl GroupRoster {
/// `0x4e7f70`: a nonzero guid that is the active player's (`4e7f84`) or in a party slot.
fn in_party(&self, me: Option<u64>, guid: u64) -> bool {
pub(crate) fn in_party(&self, me: Option<u64>, guid: u64) -> bool {
guid != 0 && (me == Some(guid) || self.party.contains(&guid))
}
+1 -1
View File
@@ -16,7 +16,7 @@ mod bind_gates;
mod cast_send;
pub(crate) mod cast_target;
pub(crate) mod cooldowns;
mod group_relation;
pub(crate) mod group_relation;
mod inflight;
mod mods;
pub(crate) mod net;
+8
View File
@@ -248,6 +248,14 @@ impl CreatureCatalog {
})
}
/// The `(race, sex)` of a display's `CreatureDisplayInfoExtra` row, the unit's display
/// override (`[unit+0xb38]`, `0x60afb0`); `None` for a display with no extra row.
pub fn display_race_sex(&self, display_id: u32) -> Option<(u8, u8)> {
let extra = self.display.get(&display_id)?.extended_id;
let row = (extra != 0).then(|| self.extra.get(&extra)).flatten()?;
Some((row.race, row.sex))
}
/// Number of display entries (for logging/diagnostics).
pub fn len(&self) -> usize {
self.display.len()