From 4f36c843f19591e65e5a72e100c978c4c4b9d8db Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:32:29 +0700 Subject: [PATCH] Emote voices play only for you and your party, as in 1.12.1 (#766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/benilla-app/src/creature_anim/net.rs | 2 + crates/benilla-app/src/entities.rs | 5 + crates/benilla-app/src/net.rs | 2 + crates/benilla-app/src/sound/anim_events.rs | 29 +-- crates/benilla-app/src/sound/emote.rs | 209 +++++++++++++++--- crates/benilla-app/src/sound/kit.rs | 31 ++- .../benilla-app/src/spell/group_relation.rs | 2 +- crates/benilla-app/src/spell/mod.rs | 2 +- crates/benilla-formats/src/creatures.rs | 8 + 9 files changed, 244 insertions(+), 46 deletions(-) diff --git a/crates/benilla-app/src/creature_anim/net.rs b/crates/benilla-app/src/creature_anim/net.rs index dcd1173e..93076de8 100644 --- a/crates/benilla-app/src/creature_anim/net.rs +++ b/crates/benilla-app/src/creature_anim/net.rs @@ -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) { out.write(EmoteMessage { + guid, source: index.0.get(&guid).copied(), kind: EmoteKind::Anim(emote_id), }); diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index a324a5f2..345ed244 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -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 { self.catalog.collision_height(display_id) diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index f54e7258..eaaca9f7 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -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, pub(crate) kind: EmoteKind, } diff --git a/crates/benilla-app/src/sound/anim_events.rs b/crates/benilla-app/src/sound/anim_events.rs index bf397113..04705601 100644 --- a/crates/benilla-app/src/sound/anim_events.rs +++ b/crates/benilla-app/src/sound/anim_events.rs @@ -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, // 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 { diff --git a/crates/benilla-app/src/sound/emote.rs b/crates/benilla-app/src/sound/emote.rs index 3db89621..91746d93 100644 --- a/crates/benilla-app/src/sound/emote.rs +++ b/crates/benilla-app/src/sound/emote.rs @@ -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>) { } } -/// 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, + 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 { + 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, units: Query<(&ObjectStore, &Transform)>, emotes: Option>, kits: Option>, assets: Option>, + creatures: Option>, + roster: Option>, + social: Res, + self_guid: Res, + attach: AttachPoints, mut out: NonSendMut, config: Res, listener: Res, @@ -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)); + } +} diff --git a/crates/benilla-app/src/sound/kit.rs b/crates/benilla-app/src/sound/kit.rs index 6a61104a..d5ea5c83 100644 --- a/crates/benilla-app/src/sound/kit.rs +++ b/crates/benilla-app/src/sound/kit.rs @@ -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, 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, 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, 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" diff --git a/crates/benilla-app/src/spell/group_relation.rs b/crates/benilla-app/src/spell/group_relation.rs index 78754b6f..9307df5e 100644 --- a/crates/benilla-app/src/spell/group_relation.rs +++ b/crates/benilla-app/src/spell/group_relation.rs @@ -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, guid: u64) -> bool { + pub(crate) fn in_party(&self, me: Option, guid: u64) -> bool { guid != 0 && (me == Some(guid) || self.party.contains(&guid)) } diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index edfc1637..ef851862 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -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; diff --git a/crates/benilla-formats/src/creatures.rs b/crates/benilla-formats/src/creatures.rs index 5f242e5c..9237d989 100644 --- a/crates/benilla-formats/src/creatures.rs +++ b/crates/benilla-formats/src/creatures.rs @@ -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()