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:
co-authored by
Claude Opus 5.5
parent
e620a0d095
commit
4f36c843f1
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user