From 4fd0c643a6a218830b8965965c30867d8d20d2ea Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:58:50 +0700 Subject: [PATCH] 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) --- crates/benilla-app/src/creature_anim.rs | 8 + .../benilla-app/src/creature_anim/driver.rs | 76 +++++- .../src/creature_anim/driver/tests.rs | 243 ++++++++++++++++++ .../src/creature_anim/emote_anim.rs | 192 +++++++++++++- .../benilla-app/src/creature_anim/gesture.rs | 3 +- .../src/creature_anim/spell_visual.rs | 1 + crates/benilla-app/src/ui_chat.rs | 7 +- crates/benilla-app/src/ui_chat/input/mod.rs | 35 +++ 8 files changed, 539 insertions(+), 26 deletions(-) diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index 4e3aac2d..d98bb775 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -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 diff --git a/crates/benilla-app/src/creature_anim/driver.rs b/crates/benilla-app/src/creature_anim/driver.rs index b0d7eb56..80bf98bd 100644 --- a/crates/benilla-app/src/creature_anim/driver.rs +++ b/crates/benilla-app/src/creature_anim/driver.rs @@ -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 { + 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 = pending + let mut requests: Vec<(u16, Option)> = 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() { diff --git a/crates/benilla-app/src/creature_anim/driver/tests.rs b/crates/benilla-app/src/creature_anim/driver/tests.rs index b00b369d..b918bb1c 100644 --- a/crates/benilla-app/src/creature_anim/driver/tests.rs +++ b/crates/benilla-app/src/creature_anim/driver/tests.rs @@ -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::().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::().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::().unwrap(), + e.get::().unwrap(), + e.get::().unwrap(), + e.get::().unwrap(), + ) + }; + assert_eq!(armed(&app), Some(0), "Stand requested, Stand playing"); + app.world_mut() + .entity_mut(unit) + .get_mut::() + .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::().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::() + .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::() + .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::() + .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::() + .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::>() + .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::>() + .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::().unwrap().mode { + super::super::select::Mode::Swing { id, .. } => Some(id), + _ => None, + }; + let time = |app: &App, node| { + app.world() + .entity(unit) + .get::() + .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)); +} diff --git a/crates/benilla-app/src/creature_anim/emote_anim.rs b/crates/benilla-app/src/creature_anim/emote_anim.rs index 251bca99..39692544 100644 --- a/crates/benilla-app/src/creature_anim/emote_anim.rs +++ b/crates/benilla-app/src/creature_anim/emote_anim.rs @@ -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] diff --git a/crates/benilla-app/src/creature_anim/gesture.rs b/crates/benilla-app/src/creature_anim/gesture.rs index b14979b9..cc56a16a 100644 --- a/crates/benilla-app/src/creature_anim/gesture.rs +++ b/crates/benilla-app/src/creature_anim/gesture.rs @@ -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, }); } } diff --git a/crates/benilla-app/src/creature_anim/spell_visual.rs b/crates/benilla-app/src/creature_anim/spell_visual.rs index c977ee9b..bc2b91f6 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual.rs @@ -443,6 +443,7 @@ fn play_kit( entity, anim_id, seq, + via_player: false, }); } } diff --git a/crates/benilla-app/src/ui_chat.rs b/crates/benilla-app/src/ui_chat.rs index 02a527a3..3d3be2f8 100644 --- a/crates/benilla-app/src/ui_chat.rs +++ b/crates/benilla-app/src/ui_chat.rs @@ -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, diff --git a/crates/benilla-app/src/ui_chat/input/mod.rs b/crates/benilla-app/src/ui_chat/input/mod.rs index 8043b0c5..b3095a3b 100644 --- a/crates/benilla-app/src/ui_chat/input/mod.rs +++ b/crates/benilla-app/src/ui_chat/input/mod.rs @@ -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, With)>, + loot_kneel: Option>, } /// 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,