One-shot emotes: LiftOff/Land in combat, no restarts, local play (#773)

Three ways the one-shot emote player differed from 1.12.1.

- **LiftOff and Land were refused in combat.** `0x5fcd20` exempts AnimIDs 192 and 200 from its combat test only (`0x5fcd5f`-`0x5fcd92`), so a dragon's take-off and landing now play mid-fight.
- **An emote could restart a clip already armed.** The player skips an id already armed on the key bone or bone 0 (`0x5fcd56`, `0x5fdb50`). It compares the requested id, which op4 stores at `+0xf8` (`0x71252f`), not the fallback that plays.
- **Your own emote waited for the server.** `DoEmote` plays it locally while a direction key is held (`0x5fd680`, called at `0x5ef660`), so a moving `/wave` shows at once and its echo is skipped.

Tests cover the exemption, the armed test (including on a rider), each local-play gate, and the skipped echo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-02 10:58:50 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 56d8011ab7
commit 4fd0c643a6
8 changed files with 539 additions and 26 deletions
+8
View File
@@ -424,6 +424,10 @@ pub(crate) struct EmoteAnim {
pub(crate) anim_id: u16,
/// `PlaySeq` stamp; a kit anim inherits its `CastEvent`'s.
pub(crate) seq: u64,
/// Came through the shared one-shot emote player (`0x5fcd20`: `SMSG_EMOTE`, the gesture
/// dispatcher), which skips an id already armed on the key bone or bone 0 (`0x5fcd56`). A kit
/// anim and `DoEmote`'s local play call `0x5fe2f0` directly and take no such test.
pub(crate) via_player: bool,
}
/// A state kit's `AnimID`, compared and never played: both field-4 callers (`0x5ff4c6`,
@@ -465,6 +469,7 @@ fn flourish_to_anim(
entity: child.0,
anim_id: select::MOUNT_SPECIAL,
seq: play_seq.next(),
via_player: false,
});
}
}
@@ -511,6 +516,7 @@ mod emote_anim;
/// The client-local gestures (chat, NPC interact), the reference's `0x60bb30`.
mod gesture;
use emote_anim::emote_to_anim;
pub(crate) use emote_anim::{local_play_eligible, LocalPlay};
pub(crate) use gesture::{select_gesture, Gesture, GestureQueue};
/// The Bevy systems that execute the state machine `select` picks.
@@ -845,6 +851,8 @@ impl Plugin for CreatureAnimPlugin {
// Same frame as the loot kneel: the reference force-plays Loot 50 in the
// handler that arms the latch.
.after(crate::ui_loot::resolve_loot_kneel)
// `DoEmote`'s local play, written in the chat drain.
.after(crate::ui_chat::ChatDrain)
// After Input so a sheath request (the Z toggle) executes the same frame.
.after(WorldStage::Input)
// `VisualSheath` must land before `resolve_equipment` reads it, or a sheath
+64 -12
View File
@@ -56,7 +56,8 @@ const JUMP_ARC_MIN_UP: f32 = 0.5;
#[derive(Clone, Copy)]
enum OneShotReq {
Swing(u32),
Emote(u16),
/// The id, and whether it came through the emote player's armed-id test.
Emote(u16, bool),
}
/// The live one-shot the combat fast path tests: the key bone's clip, else bone 0's (`0x5fe422`).
@@ -80,6 +81,31 @@ fn live_oneshot(
None
}
/// The id armed on the unit, as `0x5fdb50` reads it: the key bone's while one runs, else bone 0's.
/// It is the REQUESTED id, not the fallback clip that plays: op4 stores its raw argument at
/// `+0xf8` (`0x71252f`) and the getter returns it (`0x7120d3`). A clip that has finished counts as
/// gone: its completion callback re-arms the base.
fn armed_id(
drv: &AnimDriver,
player: &AnimationPlayer,
tr: &AnimationTransitions,
anims: &ModelAnimations,
) -> Option<u16> {
let running = |n| player.animation(n).is_some_and(|a| !a.is_finished());
if let Some(ov) = drv.overlay.filter(|ov| running(ov.node)) {
return Some(ov.id);
}
let node = tr.get_main_animation().filter(|&n| running(n))?;
// The clip's own id only when the mode holds no requested id.
drv.active_anim().or_else(|| {
anims
.clips
.iter()
.find(|c| c.node == node)
.map(|c| c.anim_id)
})
}
/// Whether a one-shot is live on the unit; the missile queue polls it to launch a missile whose
/// release keyframe never fired when the cast ends (the reference's `0x5fc920` → `0x60c9b0`).
pub(crate) fn oneshot_is_live(
@@ -338,10 +364,11 @@ pub(super) fn drive_animations(
pending
.entry(e.entity)
.or_default()
.push((OneShotReq::Emote(e.anim_id), e.seq));
.push((OneShotReq::Emote(e.anim_id, e.via_player), e.seq));
}
// A mounted rider's mount-set emotes go to the mount model (`0x5fe7c1`); the rider keeps the
// request for its own CLASS_A and nowhere routes.
// request for its own CLASS_A and nowhere routes. The emote player's armed test (`0x5fcd56`)
// runs once, on the rider, ahead of that routing, so the forwarded copy carries none.
let mut to_mount: Vec<(Entity, (OneShotReq, u64))> = Vec::new();
for (rider, reqs) in pending.iter() {
let Ok((store, child)) = riders.get(*rider) else {
@@ -351,8 +378,18 @@ pub(super) fn drive_animations(
continue;
}
for &(req, seq) in reqs {
if matches!(req, OneShotReq::Emote(id) if route_mounted(id).mount) {
to_mount.push((child.0, (req, seq)));
let OneShotReq::Emote(id, via_player) = req else {
continue;
};
if !route_mounted(id).mount {
continue;
}
let armed = via_player
.then(|| units.get(*rider).ok())
.flatten()
.and_then(|u| armed_id(u.4, u.2, u.3, u.1));
if armed != Some(id) {
to_mount.push((child.0, (OneShotReq::Emote(id, false), seq)));
}
}
}
@@ -731,7 +768,7 @@ pub(super) fn drive_animations(
});
// In `PlaySeq` order, defense last: the reference plays it from the deferred impact scan,
// after every message handler.
let mut requests: Vec<u16> = pending
let mut requests: Vec<(u16, Option<u16>)> = pending
.remove(&entity)
.map(|mut v| {
v.sort_by_key(|&(_, seq)| seq);
@@ -747,19 +784,21 @@ pub(super) fn drive_animations(
swing_anim_main(w.armed_main())
};
debug!("swing: unit {entity} anim {id} (hitInfo {hit_info:#x})");
id
(id, None)
}
OneShotReq::Emote(id) => id,
// The armed-id test compares the record's own AnimID, before the play
// seam's unarmed remap.
OneShotReq::Emote(id, via_player) => (id, via_player.then_some(id)),
})
.collect()
})
.unwrap_or_default();
requests.extend(defense);
requests.extend(defense.map(|id| (id, None)));
// At the play seam (`0x5fe2f0`), Special1H/2H with both hands empty becomes
// SpecialUnarmed(118), for every request.
{
let w = wielded.copied().unwrap_or_default();
for id in &mut requests {
for (id, _) in &mut requests {
*id = select::unarmed_special(*id, w.armed_main(), w.armed_off());
}
}
@@ -771,9 +810,22 @@ pub(super) fn drive_animations(
&& !airborne_frozen
&& live_oneshot(&drv, &player, &tr, anims, catalog).is_none()
{
requests.extend(drv.deferred.take());
requests.extend(drv.deferred.take().map(|id| (id, None)));
}
for id in requests {
for (id, armed_test) in requests {
// The emote player's own test first (`0x5fcd56`): an id already armed on the key bone
// or bone 0 plays nothing, ahead of the lock and the fast path in `0x5fe2f0`.
if let Some(raw) = armed_test {
if armed_id(&drv, &player, &tr, anims) == Some(raw) {
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!("anim armed-skip unit={entity} id={raw}"),
);
}
continue;
}
}
// The lock first: `0x5fe2f0`'s head guard returns before routing, the fast path and
// the dedup, so a locked unit gets no arm and no deferral.
if drv.base_lock.refuses() {
@@ -662,6 +662,7 @@ fn whiff_slowdown_spares_a_non_swing_oneshot() {
entity: spinner,
anim_id: 57,
seq: 1,
via_player: false,
});
app.world_mut().write_message(SwingMessage {
attacker: swinger,
@@ -747,12 +748,14 @@ fn same_frame_collision_fast_paths_the_second_combat_clip() {
entity: spin_last,
anim_id: 57,
seq: 2,
via_player: false,
});
// swing_last: the spin arrives first.
app.world_mut().write_message(EmoteAnim {
entity: swing_last,
anim_id: 57,
seq: 3,
via_player: false,
});
app.world_mut().write_message(SwingMessage {
attacker: swing_last,
@@ -1450,6 +1453,7 @@ fn a_jump_in_place_cast_replaces_the_hang_and_survives_the_arc() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
fn drv(app: &App, unit: Entity) -> &AnimDriver {
@@ -1494,6 +1498,7 @@ fn landing_mid_cast_plays_the_land_pick() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
app.world_mut()
@@ -1525,6 +1530,7 @@ fn a_cast_over_the_fall_loop_holds_until_landing() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update(); // Swing { 54, under: Jump }
app.world_mut().entity_mut(unit).insert(MovementState {
@@ -1543,6 +1549,7 @@ fn a_cast_over_the_fall_loop_holds_until_landing() {
entity: unit,
anim_id: 54,
seq: 2,
via_player: false,
});
app.update();
assert!(
@@ -1597,6 +1604,7 @@ fn a_moving_jump_cast_masks_onto_the_overlay() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
let drv = app.world().entity(unit).get::<AnimDriver>().unwrap();
@@ -1622,6 +1630,7 @@ fn a_jump_over_a_live_cast_transplants_it_to_the_torso() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
{
@@ -1774,6 +1783,7 @@ fn a_finished_masked_cast_fades_out_instead_of_snapping() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
{
@@ -1903,6 +1913,7 @@ fn a_midair_deferred_park_survives_the_level_and_dies_at_the_landing_play() {
entity: unit,
anim_id: 57,
seq: 1,
via_player: false,
});
app.world_mut().write_message(SwingMessage {
attacker: unit,
@@ -2226,6 +2237,7 @@ fn a_root_landing_with_the_cast_returns_the_body_to_neutral() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
assert_eq!(
@@ -2252,6 +2264,7 @@ fn a_run_starting_under_a_cast_still_transplants_it_up() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
let mode = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().mode;
@@ -2553,6 +2566,7 @@ fn the_mount_transition_takes_bone_0_back_from_a_full_body_one_shot() {
entity: unit,
anim_id: SPELL_CAST_OMNI,
seq: 1,
via_player: false,
});
app.update();
assert_eq!(
@@ -2872,6 +2886,7 @@ fn a_mid_volley_fire_clip_re_pulls_and_the_pull_promotes_to_the_hold() {
entity: unit,
anim_id: 46,
seq: 1,
via_player: false,
});
app.update();
assert_eq!(
@@ -3022,6 +3037,7 @@ fn every_shot_of_a_volley_re_arms_the_fire_clip_through_the_emote_lane() {
entity: unit,
anim_id: 46,
seq: shot,
via_player: false,
});
app.update();
assert_eq!(mode(&app), FIRING, "shot {shot} takes bone 0");
@@ -3429,6 +3445,7 @@ fn a_special_goes_unarmed_only_when_both_hands_are_empty() {
entity: unit,
anim_id: 57,
seq: 1,
via_player: false,
});
unit
};
@@ -3698,6 +3715,7 @@ fn a_gait_change_raises_the_anim_edge_and_a_steady_frame_does_not() {
entity: unit,
anim_id: 57,
seq: 1,
via_player: false,
});
app.update();
assert!(edge(&app), "and so does a one-shot");
@@ -3729,6 +3747,7 @@ fn a_combat_over_combat_fast_path_does_not_raise_the_anim_edge() {
entity: unit,
anim_id: 57,
seq: 1,
via_player: false,
});
app.update();
assert!(edge(&app), "the first combat play arms normally");
@@ -3806,6 +3825,7 @@ mod base_anim_lock {
entity: unit,
anim_id,
seq: 1,
via_player: false,
});
app.update();
}
@@ -4041,6 +4061,7 @@ mod routing {
entity,
anim_id,
seq: 1,
via_player: false,
});
app.update();
}
@@ -4137,6 +4158,136 @@ mod routing {
"no full-body fallback"
);
}
/// The armed id is the requested one (`0x712090` returns `+0xf8`, op4's raw argument), not
/// the clip a fallback plays: Stand runs on bone 0 while 68 is the requested id.
#[test]
fn the_armed_id_is_the_requested_one_not_the_fallback_clip() {
use super::super::super::select::Mode;
let mut app = app();
let unit = spawn(&mut app, model(vec![clip(0, 1, true)]), 0);
app.update();
let armed = |app: &App| {
let e = app.world().entity(unit);
super::super::armed_id(
e.get::<AnimDriver>().unwrap(),
e.get::<AnimationPlayer>().unwrap(),
e.get::<AnimationTransitions>().unwrap(),
e.get::<ModelAnimations>().unwrap(),
)
};
assert_eq!(armed(&app), Some(0), "Stand requested, Stand playing");
app.world_mut()
.entity_mut(unit)
.get_mut::<AnimDriver>()
.unwrap()
.mode = Mode::Swing {
id: CHEER,
under: None,
};
assert_eq!(armed(&app), Some(CHEER), "the requested id, not clip 0");
}
/// A mounted rider's mount-set emote is tested once, on the rider: an id the rider has armed
/// is not forwarded to the mount child.
#[test]
fn a_rider_armed_id_stops_the_forward_to_the_mount() {
use super::super::super::select::Mode;
let mut app = app();
let rider = spawn(&mut app, model(vec![clip(0, 1, true)]), 1);
let child = app
.world_mut()
.spawn((
model(vec![clip(0, 1, true), clip(SHUFFLE, 2, false)]),
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
))
.id();
app.world_mut()
.entity_mut(rider)
.insert(crate::entities::mount::MountChild(child));
app.update();
let child_mode = |app: &App| app.world().entity(child).get::<AnimDriver>().unwrap().mode;
let send = |app: &mut App, via_player: bool| {
app.world_mut().write_message(EmoteAnim {
entity: rider,
anim_id: SHUFFLE,
seq: 1,
via_player,
});
app.update();
};
// A control: a kit anim (no armed test) is forwarded.
send(&mut app, false);
assert!(
matches!(child_mode(&app), Mode::Swing { id: SHUFFLE, .. }),
"the mount-set emote reaches the mount"
);
app.world_mut()
.entity_mut(child)
.get_mut::<AnimDriver>()
.unwrap()
.mode = Mode::Gait;
// The rider has it armed: the player's test refuses before the forward.
app.world_mut()
.entity_mut(rider)
.get_mut::<AnimDriver>()
.unwrap()
.mode = Mode::Swing {
id: SHUFFLE,
under: None,
};
send(&mut app, true);
assert_eq!(child_mode(&app), Mode::Gait, "not forwarded to the mount");
}
/// `DoEmote`'s local play (`0x5fe2f0`) arms the clip at once; the `SMSG_EMOTE` echo that follows
/// through the emote player finds the id armed (`0x5fcd56`) and leaves it running.
#[test]
fn the_echo_of_a_local_emote_does_not_restart_it() {
let mut app = app();
let unit = spawn(
&mut app,
model(vec![clip(0, 1, true), masked(CHEER, 2, 12)]),
0,
);
app.world_mut().entity_mut(unit).insert(MovementState {
flags: move_flags::FORWARD,
..Default::default()
});
app.update();
let send = |app: &mut App, via_player: bool| {
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id: CHEER,
seq: 1,
via_player,
});
app.update();
};
let node = AnimationNodeIndex::new(12);
let speed = |app: &App| {
app.world()
.entity(unit)
.get::<AnimationPlayer>()
.unwrap()
.animation(node)
.map(bevy::animation::ActiveAnimation::speed)
};
send(&mut app, false);
assert_eq!(speed(&app), Some(1.0), "the local play armed the key bone");
// A fresh arm resets the rate to 1.0, so a marker rate shows a restart.
app.world_mut()
.entity_mut(unit)
.get_mut::<AnimationPlayer>()
.unwrap()
.animation_mut(node)
.unwrap()
.set_speed(0.25);
send(&mut app, true);
assert_eq!(speed(&app), Some(0.25), "the echo did not re-arm the clip");
}
}
/// The `UNIT_NPC_EMOTESTATE` resolver (`0x5fd770`): the current interact NPC passes over a state
@@ -4303,6 +4454,7 @@ mod emote_state {
entity: npc,
anim_id: 118,
seq: 1,
via_player: false,
});
app.update();
assert!(matches!(mode(&app, npc), Mode::Swing { id: 118, .. }));
@@ -4321,6 +4473,7 @@ mod emote_state {
entity: partner,
anim_id: 118,
seq: 1,
via_player: false,
});
app.update();
interact_guid(&mut app, Some(partner), PLAYER);
@@ -4391,6 +4544,7 @@ mod emote_state {
entity: npc,
anim_id: 118,
seq: 1,
via_player: false,
});
app.update();
assert!(matches!(
@@ -4427,3 +4581,92 @@ mod emote_state {
assert_eq!(gait(&app, swimmer), Some(42), "moving, it swims");
}
}
/// The emote player's armed-id test (`0x5fcd56`, `0x5fdb50`): an id already running on bone 0 is
/// not played again, whether it is the gait clip or an earlier one-shot; a different id is, and a
/// kit anim (`0x5fe2f0` directly) takes no such test.
#[test]
fn the_emote_player_skips_an_id_already_armed_but_plays_a_different_one() {
use bevy::animation::graph::{AnimationGraph, AnimationGraphHandle};
use bevy::animation::AnimationClip;
let mut app = app();
let mut asset = |secs: f32| {
let mut c = AnimationClip::default();
c.set_duration(secs);
app.world_mut()
.resource_mut::<Assets<AnimationClip>>()
.add(c)
};
let (stand, dance, wave) = (asset(10.0), asset(10.0), asset(10.0));
let (graph, nodes) = AnimationGraph::from_clips([stand, dance, wave]);
let graph_handle = app
.world_mut()
.resource_mut::<Assets<AnimationGraph>>()
.add(graph);
let mut clips = vec![clip(0, 0, true), clip(69, 0, true), clip(67, 0, false)];
for (c, n) in clips.iter_mut().zip(nodes.iter().copied()) {
c.node = n;
c.duration = 10.0;
}
let unit = app
.world_mut()
.spawn((
ModelAnimations {
graph: graph_handle.clone(),
clips,
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
},
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimationGraphHandle(graph_handle),
AnimDriver::default(),
))
.id();
app.update(); // settle: Stand holds bone 0
let play = |app: &mut App, anim_id: u16, via_player: bool| {
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id,
seq: 1,
via_player,
});
advance(app, 500);
};
let swing = |app: &App| match app.world().entity(unit).get::<AnimDriver>().unwrap().mode {
super::super::select::Mode::Swing { id, .. } => Some(id),
_ => None,
};
let time = |app: &App, node| {
app.world()
.entity(unit)
.get::<AnimationPlayer>()
.unwrap()
.animation(node)
.map(|a| a.seek_time())
};
// The gait's own id over itself: nothing arms.
play(&mut app, 0, true);
assert_eq!(swing(&app), None, "Stand over Stand is skipped");
// A kit anim has no such test: it arms the same id.
play(&mut app, 0, false);
assert_eq!(swing(&app), Some(0), "a kit anim is not skipped");
// A different id arms and runs.
play(&mut app, 69, true);
assert_eq!(swing(&app), Some(69), "a different id plays");
let t = time(&app, nodes[1]).expect("armed");
assert!(t > 0.4, "the dance has run since its arm: {t}");
// The same id over itself does not restart: its clip time keeps advancing.
play(&mut app, 69, true);
let t2 = time(&app, nodes[1]).expect("armed");
assert!(t2 > t, "the dance kept advancing, {t} then {t2}");
// A different id replaces it.
play(&mut app, 67, true);
assert_eq!(swing(&app), Some(67));
}
@@ -13,7 +13,7 @@ use bevy::prelude::*;
use crate::net::{EmoteKind, EmoteMessage, ObjectStore, RemoteMotion};
use crate::sound::EmoteSounds;
use super::{move_flags, EmoteAnim, Engaged, MovementState};
use super::{move_flags, select, EmoteAnim, Engaged, MovementState};
/// The performer's gate inputs: stand state, move flags (`MovementState` for us, `RemoteMotion`
/// for a remote player; a creature's spline carries no swim bit) and the [`Engaged`] marker.
@@ -45,7 +45,7 @@ pub(super) fn emote_to_anim(
};
let (store, movement, remote, engaged) =
units.get(entity).unwrap_or((None, None, None, false));
if !play_eligible(store, movement, remote, engaged) {
if !play_eligible(store, movement, remote, engaged, anim_id) {
debug!("emote_anim: suppressed anim {anim_id} for {entity:?}");
continue;
}
@@ -53,6 +53,7 @@ pub(super) fn emote_to_anim(
entity,
anim_id: anim_id as u16,
seq: play_seq.next(),
via_player: true,
});
}
}
@@ -65,10 +66,13 @@ pub(super) fn receive_eligible(stand_state: u8, swimming: bool) -> bool {
}
/// The shared player's half, `0x5fcd20`: no play while channeling (`0x5fcd83`) or in combat
/// (`0x5fcd9d`). Its already-armed test (`0x5fcd5d`) is the driver's same-id dedup, and its
/// `[+0xd58] & 0x400` test (`0x5fcd8e`) reads an anim-state bit with no counterpart here.
fn player_eligible(channeling: bool, in_combat: bool) -> bool {
!channeling && !in_combat
/// (`0x5fcd9d`), and LiftOff (192) and Land (200) skip the combat test alone (`0x5fcd5f`/`0x5fcd67`
/// set the flag that `0x5fcd90` branches past `0x60ecd0` on; the channel test at `0x5fcd83` runs
/// first). Its already-armed test (`0x5fcd5d`) is the driver's `armed_id`, and its
/// `[+0xd58] & 0x400` test (`0x5fcd8e`, the weapon-visual hold) is [`super::RangedHold`].
fn player_eligible(anim_id: u32, channeling: bool, in_combat: bool) -> bool {
let exempt = anim_id == u32::from(select::LIFT_OFF) || anim_id == u32::from(select::LAND);
!channeling && (exempt || !in_combat)
}
/// The whole gate for one unit, as both producers call it; `engaged` stands for the client's
@@ -78,6 +82,7 @@ pub(super) fn play_eligible(
movement: Option<&MovementState>,
remote: Option<&RemoteMotion>,
engaged: bool,
anim_id: u32,
) -> bool {
let fields = store.map(|s| &s.0);
let stand_state = fields.map_or(0, |f| f.unit_stand_state());
@@ -89,7 +94,53 @@ pub(super) fn play_eligible(
!= 0;
let channeling = fields.is_some_and(|f| f.unit_channel_spell() != 0);
let in_combat = engaged || fields.is_some_and(|f| f.unit_flags() & UNIT_FLAGS_COMBAT_BIT != 0);
receive_eligible(stand_state, swimming) && player_eligible(channeling, in_combat)
receive_eligible(stand_state, swimming) && player_eligible(anim_id, channeling, in_combat)
}
/// Our own unit's inputs to `DoEmote`'s local-play gate `0x5fd680`.
#[derive(Clone, Copy, Default)]
pub(crate) struct LocalPlay {
/// The shared dead test `0x605f90`: health 0, `UNIT_DYNFLAG_DEAD` or stand state 7.
pub(crate) reads_dead: bool,
pub(crate) stand_state: u8,
pub(crate) move_flags: u32,
pub(crate) vertical_speed: f32,
/// On a flying server spline (a taxi), which exempts the turn-shuffle test (`0x5fce47`).
pub(crate) flying: bool,
/// The loot-kneel latch with its kneeling target (`0x6126b0` and `0x612710`).
pub(crate) loot_kneel: bool,
pub(crate) channeling: bool,
/// The auto-attack target `[+0xc48]` alone (`0x60ecb0`), not the combat flag bit.
pub(crate) engaged: bool,
}
/// Move flags that void the turn-shuffle test: HOVER, SWIMMING and `0x800` (`0x5fce5d`).
const SHUFFLE_VOID: u32 = move_flags::HOVER | move_flags::SWIMMING | 0x800;
/// `0x5fd680`, the gate on `DoEmote`'s local play (`0x5ef660`, then `0x5fe2f0` at `0x5ef66f`): the emote plays at once only while
/// a direction key is held, and every other case waits for the server's `SMSG_EMOTE`. It refuses
/// the dead (`0x605f90`), a unit both jumping and falling far (`0x5fd6a9`), a jump with vertical
/// speed (`0x5fd6c5`), a kneeling looter (`0x5fd6e7`), a channel (`0x5fd701`), an auto-attack
/// target (`0x5fd721`), a turn shuffle (`0x5fd72c`, approximated) and any stand state but 0 (`0x5fd74d`, and
/// the cached `[+0xc1c]` at `0x5fd757`, which tracks it). The `[+0xd58]` test at `0x5fd711` refuses
/// only when both 0x400 ([`super::RangedHold`]) and 0x10000 (unmodelled) are set, and so is not
/// tested here; the turn-shuffle test approximates `0x5fce30`, which also exempts on the armed id,
/// exempts on `d58 & 0xc` and fires on the mouse-turn latch `d58 & 0x1800`. The ranged auto-repeat
/// test (`0x5fd735`)
/// needs sheath state 2, which the stow `DoEmote` runs first (`0x611cf0(0)`) has cleared.
pub(crate) fn local_play_eligible(i: &LocalPlay) -> bool {
let f = i.move_flags;
let turning = f & (move_flags::TURN_LEFT | move_flags::TURN_RIGHT) != 0;
let shuffling = turning && !i.flying && f & SHUFFLE_VOID == 0;
!i.reads_dead
&& !(f & move_flags::FALLING != 0 && f & move_flags::FALLING_FAR != 0)
&& (f & move_flags::FALLING == 0 || i.vertical_speed == 0.0)
&& f & move_flags::ANY_MOVE != 0
&& !i.loot_kneel
&& !i.channeling
&& !i.engaged
&& !shuffling
&& i.stand_state == 0
}
/// The flag half of the in-combat test `0x60ecd0` (`UNIT_FIELD_FLAGS` bit 11); the other half is
@@ -132,11 +183,24 @@ mod tests {
#[test]
fn channeling_or_combat_suppresses_the_play() {
assert!(
player_eligible(false, false),
player_eligible(66, false, false),
"idle and out of combat plays"
);
assert!(!player_eligible(true, false), "channeling suppresses");
assert!(!player_eligible(false, true), "in combat suppresses");
assert!(!player_eligible(66, true, false), "channeling suppresses");
assert!(!player_eligible(66, false, true), "in combat suppresses");
}
/// `0x5fcd5f`/`0x5fcd67`: LiftOff (192) and Land (200) skip the combat test, not the channel.
#[test]
fn lift_off_and_land_play_in_combat_but_not_while_channeling() {
assert!(player_eligible(192, false, true), "LiftOff in combat");
assert!(player_eligible(200, false, true), "Land in combat");
assert!(!player_eligible(66, false, true), "any other id refuses");
assert!(!player_eligible(193, false, true), "the neighbours refuse");
assert!(
!player_eligible(192, true, true),
"channeling still refuses"
);
}
/// The attack-target half of `0x60ecd0` (`[+0xc48]`, from ATTACKSTART to ATTACKSTOP) refuses
@@ -144,10 +208,114 @@ mod tests {
#[test]
fn an_engaged_unit_refuses_the_play_without_the_flag_bit() {
assert!(
play_eligible(None, None, None, false),
play_eligible(None, None, None, false, 66),
"idle, no store: plays"
);
assert!(!play_eligible(None, None, None, true), "engaged: refused");
assert!(
!play_eligible(None, None, None, true, 66),
"engaged: refused"
);
assert!(
play_eligible(None, None, None, true, 192),
"engaged LiftOff"
);
assert!(play_eligible(None, None, None, true, 200), "engaged Land");
}
fn moving() -> LocalPlay {
LocalPlay {
move_flags: move_flags::FORWARD,
..Default::default()
}
}
#[test]
fn a_local_emote_plays_only_while_a_direction_key_is_held() {
assert!(local_play_eligible(&moving()));
for dir in [
move_flags::BACKWARD,
move_flags::STRAFE_LEFT,
move_flags::STRAFE_RIGHT,
] {
let i = LocalPlay {
move_flags: dir,
..moving()
};
assert!(local_play_eligible(&i), "{dir:#x}");
}
assert!(
!local_play_eligible(&LocalPlay::default()),
"standing still"
);
let turn_only = LocalPlay {
move_flags: move_flags::TURN_LEFT,
..Default::default()
};
assert!(!local_play_eligible(&turn_only), "turning in place");
}
#[test]
fn each_local_play_gate_refuses_on_its_own() {
let base = moving();
let refused = [
LocalPlay {
reads_dead: true,
..base
},
LocalPlay {
stand_state: 1,
..base
},
LocalPlay {
loot_kneel: true,
..base
},
LocalPlay {
channeling: true,
..base
},
LocalPlay {
engaged: true,
..base
},
// Both jumping and falling far.
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::FALLING | move_flags::FALLING_FAR,
..base
},
// A jump still rising.
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::FALLING,
vertical_speed: 3.0,
..base
},
// Turning while moving is a turn shuffle.
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::TURN_RIGHT,
..base
},
];
for (n, i) in refused.iter().enumerate() {
assert!(!local_play_eligible(i), "case {n}");
}
// A jump whose vertical speed reads exactly 0.0 (a step-off fall's first tick), and a turn while swimming or on a flying spline, pass.
for i in [
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::FALLING,
..base
},
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::TURN_RIGHT | move_flags::SWIMMING,
..base
},
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::TURN_RIGHT,
flying: true,
..base
},
] {
assert!(local_play_eligible(&i));
}
}
#[test]
@@ -114,7 +114,7 @@ pub(super) fn drive_gestures(
};
let (store, movement, remote, engaged) =
units.get(entity).unwrap_or((None, None, None, false));
if !super::emote_anim::play_eligible(store, movement, remote, engaged) {
if !super::emote_anim::play_eligible(store, movement, remote, engaged, anim_id) {
debug!("gesture: {gesture:?} suppressed for {entity:?}");
continue;
}
@@ -122,6 +122,7 @@ pub(super) fn drive_gestures(
entity,
anim_id: anim_id as u16,
seq: play_seq.next(),
via_player: true,
});
}
}
@@ -443,6 +443,7 @@ fn play_kit(
entity,
anim_id,
seq,
via_player: false,
});
}
}
+6 -1
View File
@@ -60,6 +60,11 @@ pub(crate) use settings::restore_chat_looks;
pub(crate) struct UiChatPlugin;
/// The chat line drain, whose `DoEmote` arm writes the local [`crate::creature_anim::EmoteAnim`]:
/// the animation chain runs after it, so the same frame's driver sees the play.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct ChatDrain;
impl Plugin for UiChatPlugin {
fn build(&self, app: &mut App) {
net::register(app);
@@ -152,7 +157,7 @@ impl Plugin for UiChatPlugin {
// After the clears, so a press that stamps the clock and drops the flag is
// settled before the idle timer reads it.
idle::idle_handler,
input::drain_chat_input,
input::drain_chat_input.in_set(ChatDrain),
// After the box's drain, so a `SendChatMessage` or `SendAddonMessage` from a
// slash handler it just ran goes out this frame.
input::drain_addon_chat_sends,
@@ -75,6 +75,9 @@ pub(super) struct ChatProbes<'w, 's> {
kinds: Query<'w, 's, &'static crate::net::NetEntity>,
/// `/partytest raid` seats us as the leader, and the wire names a leader by guid.
self_guid: Res<'w, crate::net::SelfGuid>,
/// Our own auto-attack target, a gate of `DoEmote`'s local play.
engaged: Query<'w, 's, (), (With<SelfPlayer>, With<crate::creature_anim::Engaged>)>,
loot_kneel: Option<Res<'w, crate::ui_loot::LootKneel>>,
}
/// What the drain hands to another subsystem's setter, bundled for the same ceiling. Chat never
@@ -85,6 +88,8 @@ pub(super) struct ChatOut<'w, 's> {
console: Commands<'w, 's>,
stand: MessageWriter<'w, crate::player::StandStateRequest>,
sheath: MessageWriter<'w, crate::creature_anim::SheathRequest>,
/// `DoEmote`'s local play (`0x5ef660`).
anim: MessageWriter<'w, crate::creature_anim::EmoteAnim>,
target: MessageWriter<'w, crate::target::TargetByNameRequest>,
assist: MessageWriter<'w, crate::target::AssistRequest>,
follow: MessageWriter<'w, crate::player::FollowRequest>,
@@ -187,6 +192,8 @@ pub(super) fn drain_chat_input(
guids,
kinds,
self_guid,
engaged,
loot_kneel,
} = &probes;
let Some(mut script) = script else {
return;
@@ -738,6 +745,34 @@ pub(super) fn drain_chat_input(
.stand
.write(crate::player::StandStateRequest { state: state as u8 });
}
// The play (`0x5ef660`, `0x5fe2f0` at `0x5ef66f`) is the posture branch's else: a posture emote plays no clip,
// and the rest play here only while moving. Its `SMSG_EMOTE` echo then finds the
// id armed and is skipped (`0x5fcd56`).
else if let (Some(anim), Ok((entity, m, _)), Ok(store)) = (
emote_id.and_then(|id| emotes.as_deref()?.anim(id)),
self_player.single(),
self_store.single(),
) {
let fields = &store.0;
let gate = crate::creature_anim::LocalPlay {
reads_dead: fields.unit_reads_dead(),
stand_state: m.stand_state,
move_flags: m.flags,
vertical_speed: m.vertical_speed,
flying: m.flying,
loot_kneel: loot_kneel.as_deref().is_some_and(|k| k.0),
channeling: fields.unit_channel_spell() != 0,
engaged: !engaged.is_empty(),
};
if crate::creature_anim::local_play_eligible(&gate) {
chat_out.anim.write(crate::creature_anim::EmoteAnim {
entity,
anim_id: anim as u16,
seq: play_seq.next(),
via_player: false,
});
}
}
let target = emote_target(
arg.as_deref(),
&selection,