diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index 04617da9..4e3aac2d 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -564,6 +564,16 @@ pub(crate) struct AnimDriver { /// The movement flags the base was last armed for: the reference has no per-one-shot latch, /// so a root landing with a cast one-shot (Ice Block) still lets the base overwrite bone 0. gait_flags: u32, + /// Whether the unit was the interact NPC last frame: `SetInteractNPC` (`0x4930d0`) re-picks + /// its base on open (`0x493198`) and on clear (`0x493219`), so a flip cuts a one-shot. + interacting: bool, + /// Whether the interaction in `interacting` (or the one just cleared) targets a player, whose + /// open and clear skip the re-pick (`0x493159`, `0x493203`: the PLAYER typemask bit). + interact_player: bool, + /// The clear's re-pick (`0x493219`) runs while `[0xb4e2d0]` still names the NPC, so it picks + /// Stand; the global is zeroed after (`0x49334b`). The state returns at the next re-pick: + /// the Stand window's end, a movement-flag edge, or leaving Gait. + interact_hold: bool, /// The reference's `[unit+0xd58] & 0xc0000`: while Knockdown, LiftOff or Land holds it, base /// requests are refused, so a stun's knockdown survives the root's Stand recompute. base_lock: driver::play::BaseAnimLock, @@ -673,6 +683,9 @@ impl Default for AnimDriver { mode: Mode::Gait, gait: None, gait_flags: 0, + interacting: false, + interact_player: false, + interact_hold: false, base_lock: driver::play::BaseAnimLock::default(), sheath_cur: None, sheath_byte: None, diff --git a/crates/benilla-app/src/creature_anim/driver.rs b/crates/benilla-app/src/creature_anim/driver.rs index 569d4775..b0d7eb56 100644 --- a/crates/benilla-app/src/creature_anim/driver.rs +++ b/crates/benilla-app/src/creature_anim/driver.rs @@ -280,13 +280,16 @@ pub(super) fn drive_animations( MessageReader, // A rider's mount child, where a mount-set request goes (`0x5fe7c1`). Query<(&ObjectStore, &crate::entities::mount::MountChild)>, + // The open NPC session's unit (`[0xb4e2d0]`): its state emote is passed over when the + // row's `EmoteFlags` carry `0x2000` (`0x5fd7e2`-`0x5fd7fd`). + Option>, ), // The variation roll's LCG, the reference's single CRT `_rand` stream shared by every play. mut rng: ResMut, // The last anim trace line per traced unit; the trace writes only on change. mut anim_trace_last: Local>, ) { - let (emote_sounds, loot_kneel, time, names, mut recomputes, riders) = aux; + let (emote_sounds, loot_kneel, time, names, mut recomputes, riders, interact) = aux; let dt = time.delta_secs(); // This frame's one-shot plays per unit, replayed in the reference's call order (`PlaySeq` // stamps): a later call overwrites an earlier, and the combat fast path keys on what is playing @@ -935,6 +938,7 @@ pub(super) fn drive_animations( { if drv.mode == Mode::Gait { drv.gait = None; + drv.interact_hold = false; // the window's end is a re-pick (`0x5fc45a`) } else { let head = anims .clips @@ -975,6 +979,34 @@ pub(super) fn drive_animations( } } + // `SetInteractNPC` re-picks the base at once on open and on clear (`0x5fd9e0(-1)`, sites + // `0x493198` and `0x493219`), over a live one-shot as any base arm; in Gait the per-frame + // pick already follows. A player's open and clear skip it (`0x493159`, `0x493203`), a pose + // under the one-shot re-picks to its own id and an airborne unit to the freeze + // (`0x5fd8e8`), which cut nothing. + let target = interact.as_ref().filter(|i| i.0 == Some(entity)); + let interacting = target.is_some(); + if let Some(guid) = target.and_then(|i| i.1) { + drv.interact_player = benilla_protocol::guid::is_player(guid); + } + if std::mem::replace(&mut drv.interacting, interacting) != interacting + && !drv.interact_player + { + // Opening re-picks with the NPC named, so it ends a hold; clearing re-picks with it + // still named, so the state stays passed over until the next re-pick. + drv.interact_hold = !interacting; + if matches!(drv.mode, Mode::Swing { under: None, .. }) && !airborne_frozen { + drv.deferred = None; + drv.mode = Mode::Gait; + drv.gait = None; + } + } + if drv.interact_hold + && (mv.flags != drv.gait_flags || !matches!(drv.mode, Mode::Gait | Mode::Swing { .. })) + { + drv.interact_hold = false; + } + // ── The mode machine: the base track's decision. mode::run( mode::Frame { @@ -994,6 +1026,7 @@ pub(super) fn drive_animations( wielded, store, emote_sounds: emote_sounds.as_deref(), + interacting: interacting || drv.interact_hold, walk, model_scale, traced, diff --git a/crates/benilla-app/src/creature_anim/driver/mode.rs b/crates/benilla-app/src/creature_anim/driver/mode.rs index 0c274a3c..bc4d1626 100644 --- a/crates/benilla-app/src/creature_anim/driver/mode.rs +++ b/crates/benilla-app/src/creature_anim/driver/mode.rs @@ -9,10 +9,7 @@ use bevy::prelude::*; use crate::net::ObjectStore; use crate::sound::EmoteSounds; -use super::super::select::{ - self, gait_candidates, is_bare_stand, playback_rate, ready_anim, state_emote_gait, Mode, DEATH, - STAND, -}; +use super::super::select::{self, base_candidates, playback_rate, ready_anim, Mode, DEATH, STAND}; use super::super::{find_resolved, move_flags, AnimDriver, CastHold, MovementState, Wielded}; use super::play::{enter_special, leave_special, oneshot_finished, play, play_clip, roll_loop}; use super::transplant_up; @@ -36,6 +33,8 @@ pub(super) struct Frame<'a> { pub(super) wielded: Option<&'a Wielded>, pub(super) store: Option<&'a ObjectStore>, pub(super) emote_sounds: Option<&'a EmoteSounds>, + /// Whether this unit is the current interact NPC. + pub(super) interacting: bool, pub(super) walk: f32, pub(super) model_scale: f32, /// Whether this body's plays go to the anim trace; `subject` labels them (rider or mount). @@ -68,6 +67,7 @@ pub(super) fn run( wielded, store, emote_sounds, + interacting, walk, model_scale, traced, @@ -315,7 +315,22 @@ pub(super) fn run( load } }); - let cands = gait_candidates(&mv, walk, ready, ranged_load); + // The looping state-emote idle (`UNIT_NPC_EMOTESTATE`: `/dance`, NPC work loops, and + // `/stand`'s state 26) is the resolver `0x5fd770`: it claims the base ahead of the + // idle fallbacks (swim, prowl, Stand) and behind everything else in the chain. + let state_anim = store.and_then(|s| { + let state = s.0.unit_emote_state(); + emote_sounds.and_then(|e| { + select::state_emote_anim( + state, + e.state_anim(state), + e.emote_flags(state), + interacting, + ) + }) + }); + let base = base_candidates(&mv, walk, ready, ranged_load, state_anim); + let cands = base.as_slice(); // The stationary cast/channel pin (`[CGUnit+0xb4]`), full-body over the Ready and // state-emote idles; stationary is `[9e8] & 0x20000f`, never the turn bits, so a // turning caster keeps it and a moving one gets the masked hold instead. @@ -327,22 +342,6 @@ pub(super) fn run( } _ => cands, }; - // The looping state-emote idle (`UNIT_NPC_EMOTESTATE`: `/dance`, NPC work loops) - // fills only the bare-Stand slot; whatever outranks Stand already routed `cands`. - let state_emote_cands; - let cands: &[u16] = if is_bare_stand(cands) { - let emote_anim = store - .and_then(|s| emote_sounds.and_then(|e| e.anim(s.0.unit_emote_state()))); - match emote_anim { - Some(id) => { - state_emote_cands = state_emote_gait(id as u16); - &state_emote_cands - } - None => cands, - } - } else { - cands - }; // The loot kneel (Loot 50) of a stationary, unmounted unit with the trigger up, // over the cast pin, the Ready and ranged idles, the chair loops and the state // emote: `0x5fd8b0` tries locomotion, then loot (`0x5fd260`), then the cast/channel diff --git a/crates/benilla-app/src/creature_anim/driver/tests.rs b/crates/benilla-app/src/creature_anim/driver/tests.rs index c153f6c9..b00b369d 100644 --- a/crates/benilla-app/src/creature_anim/driver/tests.rs +++ b/crates/benilla-app/src/creature_anim/driver/tests.rs @@ -4138,3 +4138,292 @@ mod routing { ); } } + +/// The `UNIT_NPC_EMOTESTATE` resolver (`0x5fd770`): the current interact NPC passes over a state +/// whose `Emotes.dbc` flags carry `0x2000`, and `SetInteractNPC` re-picks its base on open and +/// on clear. +mod emote_state { + use super::*; + use crate::creature_anim::Mode; + use crate::net::ObjectStore; + use crate::sound::EmoteSounds; + use crate::ui_session::InteractNpc; + use benilla_formats::EmoteSoundCatalog; + + /// Emote states: 28 plays AnimID 234 and yields to interaction (`0x2000`); 29 plays 235 and + /// does not. + const YIELDING: u32 = 28; + const STUBBORN: u32 = 29; + /// `/stand`'s state 26 (vmangos `ChatHandler.cpp` `HandleTextEmoteOpcode`): AnimID 0. + const STAND_STATE: u32 = 26; + const FIELD_NPC_EMOTESTATE: u16 = 148; + + /// Real graph nodes, so loop windows complete: every clip lasts 0.1 s, and the loops are one + /// pass per window. + fn model( + app: &mut App, + ) -> ( + ModelAnimations, + bevy::animation::graph::AnimationGraphHandle, + ) { + use bevy::animation::graph::{AnimationGraph, AnimationGraphHandle}; + use bevy::animation::AnimationClip; + let ids: [(u16, bool); 6] = [ + (0, true), // Stand + (234, true), // the yielding state's loop + (235, true), // the other state's loop + (118, false), // SpecialUnarmed, a one-shot + (41, true), // SwimIdle + (42, true), // Swim + ]; + let handles: Vec<_> = ids + .iter() + .map(|_| { + let mut c = AnimationClip::default(); + c.set_duration(0.1); + app.world_mut() + .resource_mut::>() + .add(c) + }) + .collect(); + let (graph, nodes) = AnimationGraph::from_clips(handles); + let graph = app + .world_mut() + .resource_mut::>() + .add(graph); + let clips = ids + .iter() + .zip(nodes) + .map(|(&(id, looping), node)| { + let mut c = clip(id, 0, looping); + c.node = node; + c.duration = 0.1; + c.blend_time = 0.0; + c.frequency = 0x4000; + c.replay = (1, 1); + c + }) + .collect(); + ( + ModelAnimations { + graph: graph.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(), + }, + AnimationGraphHandle(graph), + ) + } + + fn app_with_rows() -> App { + let mut app = app(); + app.init_resource::(); + app.insert_resource(EmoteSounds( + EmoteSoundCatalog::default() + .with_row(YIELDING, 234, 0x2000) + .with_row(STUBBORN, 235, 0) + .with_row(STAND_STATE, 0, 0), + )); + app + } + + fn unit(app: &mut App, state: u32) -> Entity { + let (model, graph) = model(app); + app.world_mut() + .spawn(( + model, + graph, + AnimationPlayer::default(), + AnimationTransitions::new(), + AnimDriver::default(), + ObjectStore(ObjectFields::from_pairs(&[(FIELD_NPC_EMOTESTATE, state)])), + )) + .id() + } + + fn gait(app: &App, unit: Entity) -> Option { + app.world().entity(unit).get::().unwrap().gait + } + + /// A creature's guid, which `SetInteractNPC` re-picks for; a player's does not. + const CREATURE: u64 = 0xF130_0000_0000_0042; + const PLAYER: u64 = 0x42; + + fn interact_guid(app: &mut App, npc: Option, guid: u64) { + *app.world_mut().resource_mut::() = InteractNpc(npc, npc.map(|_| guid)); + app.update(); + } + + fn interact(app: &mut App, npc: Option) { + interact_guid(app, npc, CREATURE); + } + + fn mode(app: &App, unit: Entity) -> Mode { + app.world().entity(unit).get::().unwrap().mode + } + + #[test] + fn a_yielding_state_stops_while_its_unit_is_the_interact_npc() { + let mut app = app_with_rows(); + let npc = unit(&mut app, YIELDING); + app.update(); + assert_eq!(gait(&app, npc), Some(234), "the work loop plays"); + + interact(&mut app, Some(npc)); + assert_eq!(gait(&app, npc), Some(0), "the window is open: Stand"); + + // The clear's re-pick runs with the NPC still named (`0x493219` before the zeroing at + // `0x49334b`), so Stand holds until the next re-pick: the end of its window. + interact(&mut app, None); + for _ in 0..3 { + advance(&mut app, 10); + assert_eq!( + gait(&app, npc), + Some(0), + "cleared: Stand until its window ends" + ); + } + for _ in 0..20 { + advance(&mut app, 25); + } + assert_eq!(gait(&app, npc), Some(234), "then the loop resumes"); + } + + /// A live one-shot is cut to Stand by the clear, not to the loop. + #[test] + fn the_clear_cuts_a_one_shot_to_stand() { + let mut app = app_with_rows(); + let npc = unit(&mut app, YIELDING); + interact(&mut app, Some(npc)); + app.world_mut().write_message(EmoteAnim { + entity: npc, + anim_id: 118, + seq: 1, + }); + app.update(); + assert!(matches!(mode(&app, npc), Mode::Swing { id: 118, .. })); + interact(&mut app, None); + assert_eq!(mode(&app, npc), Mode::Gait); + assert_eq!(gait(&app, npc), Some(0)); + } + + /// `0x493159` and `0x493203` skip the re-pick for a player (a trade partner). + #[test] + fn a_player_target_is_not_re_picked() { + let mut app = app_with_rows(); + let partner = unit(&mut app, YIELDING); + app.update(); + app.world_mut().write_message(EmoteAnim { + entity: partner, + anim_id: 118, + seq: 1, + }); + app.update(); + interact_guid(&mut app, Some(partner), PLAYER); + assert!( + matches!(mode(&app, partner), Mode::Swing { id: 118, .. }), + "open" + ); + interact_guid(&mut app, None, PLAYER); + assert!( + matches!(mode(&app, partner), Mode::Swing { id: 118, .. }), + "clear" + ); + } + + /// The re-pick of a seated unit goes to its pose (`0x5fd550`), so the one-shot over it is + /// left to finish and the sit-down entry is not replayed. + #[test] + fn a_pose_under_the_one_shot_is_not_cut() { + use crate::creature_anim::select::Special; + let mut app = app_with_rows(); + let npc = unit(&mut app, YIELDING); + app.world_mut().entity_mut(npc).insert(MovementState { + stand_state: 1, + ..Default::default() + }); + let swing = Mode::Swing { + id: 118, + under: Some(Special::Pose(1)), + }; + app.world_mut() + .entity_mut(npc) + .get_mut::() + .unwrap() + .mode = swing; + interact(&mut app, Some(npc)); + assert!( + !matches!(mode(&app, npc), Mode::Gait | Mode::Entering(_)), + "no forced Gait, so no replay of the entry: {:?}", + mode(&app, npc) + ); + } + + #[test] + fn a_state_without_the_bit_keeps_playing_while_interacted() { + let mut app = app_with_rows(); + let npc = unit(&mut app, STUBBORN); + interact(&mut app, Some(npc)); + assert_eq!(gait(&app, npc), Some(235)); + } + + #[test] + fn only_the_interact_npc_stops() { + let mut app = app_with_rows(); + let npc = unit(&mut app, YIELDING); + let other = unit(&mut app, YIELDING); + interact(&mut app, Some(npc)); + assert_eq!(gait(&app, npc), Some(0)); + assert_eq!(gait(&app, other), Some(234), "a neighbour keeps working"); + } + + /// The re-pick overwrites bone 0 like any base arm, so it cuts a live one-shot. + #[test] + fn opening_the_window_re_picks_over_a_live_one_shot() { + let mut app = app_with_rows(); + let npc = unit(&mut app, YIELDING); + app.update(); + app.world_mut().write_message(EmoteAnim { + entity: npc, + anim_id: 118, + seq: 1, + }); + app.update(); + assert!(matches!( + app.world().entity(npc).get::().unwrap().mode, + Mode::Swing { id: 118, .. } + )); + + interact(&mut app, Some(npc)); + assert_eq!(gait(&app, npc), Some(0), "the one-shot is cut for Stand"); + } + + /// State 26 holds Stand over the swim idle until the unit moves (`0x5fd770` before `0x5fd830`). + #[test] + fn a_set_state_holds_stand_over_the_swim_idle_until_the_unit_moves() { + let mut app = app_with_rows(); + let swimmer = unit(&mut app, STAND_STATE); + let bare = unit(&mut app, 0); + for u in [swimmer, bare] { + app.world_mut().entity_mut(u).insert(MovementState { + flags: move_flags::SWIMMING, + ..Default::default() + }); + } + app.update(); + assert_eq!(gait(&app, swimmer), Some(0), "state 26 stands in the water"); + assert_eq!(gait(&app, bare), Some(41), "no state: the swim idle"); + + app.world_mut().entity_mut(swimmer).insert(MovementState { + speed: 4.7, + flags: move_flags::SWIMMING | move_flags::FORWARD, + ..Default::default() + }); + app.update(); + assert_eq!(gait(&app, swimmer), Some(42), "moving, it swims"); + } +} diff --git a/crates/benilla-app/src/creature_anim/select.rs b/crates/benilla-app/src/creature_anim/select.rs index d9d3f233..2c35e9d9 100644 --- a/crates/benilla-app/src/creature_anim/select.rs +++ b/crates/benilla-app/src/creature_anim/select.rs @@ -281,17 +281,82 @@ pub(super) enum Mode { /// The ids a unit in [`Mode::Gait`] tries, most specific first: the `0x5fd8b0` chain without its /// Special states. `ready` is the engaged idle, which locomotion outranks. +/// +/// The pick without a state emote, which [`base_candidates`] adds. pub(super) fn gait_candidates( state: &MovementState, walk_speed: f32, ready: Option, ranged_load: Option, ) -> &'static [u16] { + claimed_candidates(state, walk_speed, ready, ranged_load) + .unwrap_or_else(|| idle_fallback(state)) +} + +/// What the base picks: a fixed candidate list, or the unit's emote state's loop, Stand behind it. +pub(super) enum BaseCands { + Fixed(&'static [u16]), + State([u16; 2]), +} + +impl BaseCands { + pub(super) fn as_slice(&self) -> &[u16] { + match self { + Self::Fixed(c) => c, + Self::State(c) => c, + } + } +} + +/// The full pick of the `0x5fd8b0` chain in its byte order: the resolvers [`claimed_candidates`] +/// covers, then the state-emote resolver (`0x5fd770`, `0x5fd9a1`), then the idle fallback +/// (`0x5fd830`). `state_anim` is the resolver's verdict ([`state_emote_anim`]); a set state claims +/// the base over the swim idle, the prowl idle and Stand, so `/stand`'s state 26 holds Stand +/// against both until the unit moves. +pub(super) fn base_candidates( + state: &MovementState, + walk_speed: f32, + ready: Option, + ranged_load: Option, + state_anim: Option, +) -> BaseCands { + if let Some(c) = claimed_candidates(state, walk_speed, ready, ranged_load) { + return BaseCands::Fixed(c); + } + match state_anim { + Some(anim) => BaseCands::State([anim, STAND]), + None => BaseCands::Fixed(idle_fallback(state)), + } +} + +/// The idle fallback (`0x5fd830`), the chain's last resolver: the swim idle on `SWIMMING` +/// (`0x5fd83c`, `[9e8] & 0x200000`), else the prowl idle, else Stand. The hover idle (193, +/// `0x5fd88d`) is not modelled. +fn idle_fallback(state: &MovementState) -> &'static [u16] { + if state.flags & move_flags::SWIMMING != 0 { + &[41, 0] + } else if state.stealthed { + &[STEALTH_STAND, STAND] + } else { + &[STAND] + } +} + +/// The resolvers ahead of the state-emote resolver, `None` where none claims the base. The +/// locomotion resolver (`0x5fd100`) runs on `[9e8] & 0xf` alone, so a stationary swimmer is not +/// its: it falls through the standing resolvers to the swim idle in [`idle_fallback`]. +fn claimed_candidates( + state: &MovementState, + walk_speed: f32, + ready: Option, + ranged_load: Option, +) -> Option<&'static [u16]> { use move_flags::*; let f = state.flags; - // Swimming (`0x5fd100`): turn, then strafe, then backward, then forward. - if f & SWIMMING != 0 { - return if f & (TURN_LEFT | TURN_RIGHT) != 0 { + let swimming = f & SWIMMING != 0; + // Swimming locomotion (`0x5fd100`): turn, then strafe, then backward, then forward. + if swimming && f & ANY_MOVE != 0 { + return Some(if f & (TURN_LEFT | TURN_RIGHT) != 0 { &[41, 0] } else if f & STRAFE_LEFT != 0 { &[43, 42, 41, 0] @@ -299,57 +364,58 @@ pub(super) fn gait_candidates( &[44, 42, 41, 0] } else if f & BACKWARD != 0 { &[45, 41, 0] - } else if f & FORWARD != 0 { - &[42, 41, 0] } else { - &[41, 0] - }; + &[42, 41, 0] + }); } // A flying spline, the taxi (`0x5fd19c`): Fly 135 ahead of backward and speed. if state.flying { - return &[135, 0]; + return Some(&[135, 0]); } // Backward dominates strafe (`0x5fd1bc`, `[9e8] & 2` → WalkBackwards 13). if f & BACKWARD != 0 { - return &[13, 4, 0]; + return Some(&[13, 4, 0]); } // Stealthed and moving, the prowl (`0x5fd1d3`); Walk is row 119's AnimationData fallback. if state.stealthed && f & ANY_MOVE != 0 { - return &[STEALTH_WALK, 4, 0]; + return Some(&[STEALTH_WALK, 4, 0]); } // Ground gaits by live speed (`0x5fd202`/`0x5fd224`): Sprint at 11 and above, Run above twice // walk, else Walk. Strafe takes these too: the reference has no ground strafe branch. if f & ANY_MOVE != 0 { let s = state.speed; - return if s >= FAST_RUN_SPEED { + return Some(if s >= FAST_RUN_SPEED { &[143, 5, 4, 0] } else if s > 2.0 * walk_speed { &[5, 4, 0] } else { &[4, 0] - }; + }); } - // Turning in place: the shuffle (`0x5fd3f0`, 11 left, 12 right). - if f & TURN_LEFT != 0 { - return &[SHUFFLE_LEFT, STAND]; + // Turning in place: the shuffle (`0x5fd3f0`, 11 left, 12 right); the fidget gate (`0x5fce30`) + // refuses a swimmer. + if !swimming && f & TURN_LEFT != 0 { + return Some(&[SHUFFLE_LEFT, STAND]); } - if f & TURN_RIGHT != 0 { - return &[SHUFFLE_RIGHT, STAND]; + if !swimming && f & TURN_RIGHT != 0 { + return Some(&[SHUFFLE_RIGHT, STAND]); } - // Standing and engaged: the Ready idle (`0x5fd360`), gated on engagement, not the sheath. + // Standing and engaged: the Ready idle (`0x5fd360`), gated on engagement, not the sheath; a + // swimmer's is the swim idle (`0x5fd3ac`). if let Some(r) = ready { - return match r { + return Some(match r { + _ if swimming => &[41, 0], 26 => &[26, 25, 0], 27 => &[27, 25, 0], 28 => &[28, 25, 0], _ => &[25, 0], - }; + }); } // Auto-repeat in the ranged stance (`0x5fd460`), ahead of the chair loops (`0x5fd550`). if let Some(l) = ranged_load { // A finished Load becomes its Hold through the completion dispatch (`0x5fc3f0`, fired at // `0x7075af`); each Hold falls back to its Load, so a model without it holds full draw. - return match l { + return Some(match l { 105 => &[105, 25, 0], 106 => &[106, 25, 0], 109 => &[109, 105, 25, 0], @@ -357,16 +423,15 @@ pub(super) fn gait_candidates( 111 => &[111, 25, 0], 112 => &[112, 25, 0], _ => &[25, 0], - }; + }); } - // Standing: a chair loop (4/5/6), else the prowl idle, else Stand; state 2 (`SIT_CHAIR`) - // writes no override in the reference (its row declines, `0x5fd644`), so it stands. + // Standing: a chair loop (4/5/6); state 2 (`SIT_CHAIR`) writes no override in the reference + // (its row declines, `0x5fd644`), so it falls through. match state.stand_state { - 4 => &[102, 0], - 5 => &[103, 0], - 6 => &[104, 0], - _ if state.stealthed => &[STEALTH_STAND, STAND], - _ => &[STAND], + 4 => Some(&[102, 0]), + 5 => Some(&[103, 0]), + 6 => Some(&[104, 0]), + _ => None, } } @@ -453,16 +518,21 @@ pub(super) fn is_cast_anim(id: u16) -> bool { matches!(id, 2 | 32 | 33 | 53 | 54) } -/// Whether `cands` is bare Stand, the one slot the state-emote idle (`UNIT_NPC_EMOTESTATE`) may -/// fill. The prowl idle counts as bare: the reference's emote-state resolver (`0x5fd770`) runs -/// before the fallback idle (`0x5fd830`) that picks it. -pub(super) fn is_bare_stand(cands: &[u16]) -> bool { - cands == [STAND] || cands == [STEALTH_STAND, STAND] -} - -/// The state-emote idle's candidates, Stand as the fallback; only for a bare-Stand frame. -pub(super) fn state_emote_gait(emote_anim: u16) -> [u16; 2] { - [emote_anim, STAND] +/// The state-emote resolver's verdict (`0x5fd770`): the row's `AnimID` for the unit's +/// `UNIT_NPC_EMOTESTATE`, `None` when it passes over. A state with no row passes over, a row +/// whose `AnimID` is 0 claims the base for Stand (`0x5fd816` stores `row+8` as it is), and the +/// current interact NPC passes over a row whose `EmoteFlags` carry `EMOTE_FLAG_INTERACTION` +/// (`0x5fd7e2`-`0x5fd7fd`), so a vendor stops hammering while his window is open. +pub(super) fn state_emote_anim( + state: u32, + anim: Option, + flags: Option, + interacting: bool, +) -> Option { + if state == 0 || (interacting && flags? & benilla_formats::EMOTE_FLAG_INTERACTION != 0) { + return None; + } + anim.map(|a| a as u16) } /// The mainhand swing id (`0x6246a0`); anything but an equipped melee weapon swings unarmed (16). diff --git a/crates/benilla-app/src/creature_anim/select/tests.rs b/crates/benilla-app/src/creature_anim/select/tests.rs index b036807f..6c45596e 100644 --- a/crates/benilla-app/src/creature_anim/select/tests.rs +++ b/crates/benilla-app/src/creature_anim/select/tests.rs @@ -472,13 +472,11 @@ fn ranged_load_idle_selects_by_weapon_and_ranks_below_ready() { gait_candidates(&moving_forward(3.0), 2.5, None, Some(105))[0], 4 ); - // It takes the bare-Stand slot, so it also blocks the state-emote idle. - assert!(!is_bare_stand(gait_candidates( - &standing, - 2.5, - None, - Some(105) - ))); + // It outranks the state-emote resolver (`0x5fd460` runs before `0x5fd770`). + assert_eq!( + base_candidates(&standing, 2.5, None, Some(105), Some(234)).as_slice(), + &[105, 25, 0] + ); } /// `0x5fd460` claims the drawn ranged idle ([`ranged_idle_gate`]) on the ranged sheath @@ -510,46 +508,85 @@ fn each_ranged_load_promotes_to_its_weapon_familys_hold() { assert_eq!(ranged_hold_anim(46), 46); // a fire id is no Load and maps onto no hold } +/// The state-emote resolver (`0x5fd770`) runs right after the chair loops and before the idle +/// fallback (`0x5fd830`, `0x5fd9a1`): a set state outranks the swim idle, the prowl idle and +/// Stand, and everything ahead of it outranks the state. #[test] -fn state_emote_idle_only_fills_the_bare_stand_slot() { - assert!(is_bare_stand(gait_candidates( - &MovementState::default(), - 2.5, - None, - None - ))); - assert_eq!(state_emote_gait(200), [200, STAND]); - - assert!(!is_bare_stand(gait_candidates( - &moving_forward(3.0), - 2.5, - None, - None - ))); - let turning = MovementState { - flags: move_flags::TURN_LEFT, - ..Default::default() +fn a_set_state_outranks_the_idle_fallbacks_and_nothing_before_them() { + let claimed = |state: &MovementState, ready, ranged, anim| { + base_candidates(state, 2.5, ready, ranged, anim) + .as_slice() + .to_vec() }; - assert!(!is_bare_stand(gait_candidates(&turning, 2.5, None, None))); + let still = MovementState::default(); + assert_eq!(claimed(&still, None, None, Some(234)), [234, STAND]); + assert_eq!(claimed(&still, None, None, None), [STAND]); + + // Behind it: the swim idle (`/stand`, state 26, plays AnimID 0) and the prowl idle. let swimming = MovementState { flags: move_flags::SWIMMING, ..Default::default() }; - assert!(!is_bare_stand(gait_candidates(&swimming, 2.5, None, None))); - assert!(!is_bare_stand(gait_candidates( - &MovementState::default(), - 2.5, - Some(26), - None - ))); - // The chair stand states 4, 5 and 6 have their own slot, above bare Stand. - for stand_state in [4, 5, 6] { - let chair = MovementState { - stand_state, - ..Default::default() - }; - assert!(!is_bare_stand(gait_candidates(&chair, 2.5, None, None))); - } + assert_eq!(claimed(&swimming, None, None, None), [41, 0]); + assert_eq!(claimed(&swimming, None, None, Some(0)), [0, STAND]); + assert_eq!(claimed(&swimming, None, None, Some(234)), [234, STAND]); + let prowling = MovementState { + stealthed: true, + ..Default::default() + }; + assert_eq!(claimed(&prowling, None, None, None), [STEALTH_STAND, STAND]); + assert_eq!(claimed(&prowling, None, None, Some(234)), [234, STAND]); + + // Ahead of it: locomotion (`0x5fd100`), swimming included, runs on the move bits alone. + let swim_forward = MovementState { + flags: move_flags::SWIMMING | move_flags::FORWARD, + ..Default::default() + }; + assert_eq!(claimed(&swim_forward, None, None, Some(234)), [42, 41, 0]); + assert_eq!(claimed(&moving_forward(3.0), None, None, Some(234)), [4, 0]); + // The turn shuffle, the Ready idle (a swimmer's is the swim idle), the ranged idle and the + // chair loops all come first. + let turning = MovementState { + flags: move_flags::TURN_LEFT, + ..Default::default() + }; + assert_eq!(claimed(&turning, None, None, Some(234)), [11, 0]); + assert_eq!(claimed(&still, Some(26), None, Some(234)), [26, 25, 0]); + assert_eq!(claimed(&swimming, Some(26), None, Some(234)), [41, 0]); + assert_eq!(claimed(&still, None, Some(105), Some(234)), [105, 25, 0]); + // `0x5fd460` has no swim gate: a swimmer takes it too. + assert_eq!(claimed(&swimming, None, Some(105), Some(234)), [105, 25, 0]); + let chair = MovementState { + stand_state: 4, + ..Default::default() + }; + assert_eq!(claimed(&chair, None, None, Some(234)), [102, 0]); + // A turning swimmer is no shuffle (`0x5fce30` refuses `SWIMMING`) and no mover: the state. + let swim_turning = MovementState { + flags: move_flags::SWIMMING | move_flags::TURN_LEFT, + ..Default::default() + }; + assert_eq!(claimed(&swim_turning, None, None, Some(234)), [234, STAND]); + assert_eq!(claimed(&swim_turning, None, None, None), [41, 0]); +} + +/// The resolver's verdict: a row's `AnimID` is taken as it is, 0 included (`0x5fd816`); a missing +/// row or no state passes over; the interact NPC passes over a row with `0x2000`. +#[test] +fn the_state_resolver_claims_a_row_even_when_its_anim_is_stand() { + assert_eq!(state_emote_anim(26, Some(0), Some(0), false), Some(0)); + assert_eq!( + state_emote_anim(28, Some(234), Some(0x2000), false), + Some(234) + ); + assert_eq!(state_emote_anim(28, Some(234), Some(0x2000), true), None); + assert_eq!(state_emote_anim(29, Some(235), Some(0), true), Some(235)); + assert_eq!(state_emote_anim(30, None, None, false), None, "no row"); + assert_eq!( + state_emote_anim(0, Some(0), Some(0), false), + None, + "no state" + ); } // ── The per-play one-shot route ── @@ -1030,9 +1067,10 @@ fn the_prowl_idle_is_the_lowest_priority_stand() { gait_candidates(&idle, 2.5, None, None), &[STEALTH_STAND, STAND], ); - assert!( - is_bare_stand(gait_candidates(&idle, 2.5, None, None)), - "the state-emote idle still owns this slot", + assert_eq!( + base_candidates(&idle, 2.5, None, None, Some(234)).as_slice(), + &[234, STAND], + "a set state outranks it", ); let chair = MovementState { stand_state: 4, diff --git a/crates/benilla-app/src/net/motion/facing.rs b/crates/benilla-app/src/net/motion/facing.rs index a37d2c69..046b7c44 100644 --- a/crates/benilla-app/src/net/motion/facing.rs +++ b/crates/benilla-app/src/net/motion/facing.rs @@ -36,7 +36,7 @@ const UNIT_FLAG_STUNNED: u32 = 0x0004_0000; /// The `EmoteFlags` bit that permits the target and interaction facing during a state emote /// (`600d98 test ch,0x20`); the name is ours, the tested bit is the reference's. -const EMOTE_PERMITS_FACING: u32 = 0x2000; +const EMOTE_PERMITS_FACING: u32 = benilla_formats::EMOTE_FLAG_INTERACTION; /// A stationary unit's display-facing state, the client's `CGUnit+0xc98` goal and /// `+0xc9c..+0xca8` delta ring; present only while [`drive_display_facing`] governs the unit. diff --git a/crates/benilla-app/src/sound/emote.rs b/crates/benilla-app/src/sound/emote.rs index 91746d93..89bb07fd 100644 --- a/crates/benilla-app/src/sound/emote.rs +++ b/crates/benilla-app/src/sound/emote.rs @@ -35,6 +35,11 @@ impl EmoteSounds { self.0.anim(emote_id) } + /// An `Emotes.dbc` row's raw `AnimID` for the `UNIT_NPC_EMOTESTATE` resolver, `0` included. + pub(crate) fn state_anim(&self, emote_id: u32) -> Option { + self.0.state_anim(emote_id) + } + /// A text emote's `Emotes.dbc` id (`EmotesText.dbc` `EmoteID`); 0 is chat-only (`/thank`). pub(crate) fn text_emote(&self, text_id: u32) -> Option { self.0.text_emote(text_id) diff --git a/crates/benilla-formats/src/emotes.rs b/crates/benilla-formats/src/emotes.rs index 99ff886a..a7a06f3b 100644 --- a/crates/benilla-formats/src/emotes.rs +++ b/crates/benilla-formats/src/emotes.rs @@ -11,7 +11,13 @@ use benilla_dbc::{FieldType, Schema, SchemaField}; use crate::dbc::{parse, str_at, u32_at}; +/// The `EmoteFlags` bit `0x2000`: a state emote that yields to an interaction. The state +/// resolver (`0x5fd7fa`) passes over it for the current interact NPC, and the facing chain +/// (`0x600d98`) lets the unit turn toward the interactor while it is set. +pub const EMOTE_FLAG_INTERACTION: u32 = 0x2000; + /// The joined emote tables. +#[derive(Default)] pub struct EmoteSoundCatalog { /// Uppercased `EmotesText.Name` ("WAVE") to text-emote id. by_name: HashMap, @@ -62,6 +68,22 @@ impl EmoteSoundCatalog { self.anim.get(&emote_id).copied().filter(|&a| a != 0) } + /// A catalog holding one `Emotes.dbc` row's `AnimID` and `EmoteFlags`, for tests of what + /// reads them. + #[doc(hidden)] + pub fn with_row(mut self, emote_id: u32, anim_id: u32, flags: u32) -> Self { + self.anim.insert(emote_id, anim_id); + self.emote_flags.insert(emote_id, flags); + self + } + + /// The raw `AnimID` of an `Emotes.dbc` row, `0` (Stand) included: the state resolver + /// (`0x5fd770`) claims the base with whatever the row holds (`0x5fd816`, `row+8`), and a + /// missing row claims nothing. + pub fn state_anim(&self, emote_id: u32) -> Option { + self.anim.get(&emote_id).copied() + } + /// The `Emotes.dbc` id in gesture `slot`, the `code` the client's gesture dispatcher /// `0x60bb30` takes; a slot no shipped row fills plays no gesture, as in the client. pub fn gesture(&self, slot: usize) -> Option { diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 679df4fc..97e2ae4c 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -162,7 +162,7 @@ pub use creature_sound::{load_creature_voice_catalog, CreatureVoice, CreatureVoi mod npc_greeting; pub use npc_greeting::{load_npc_greeting_catalog, NpcGreeting, NpcGreetingCatalog}; mod emotes; -pub use emotes::{load_emote_sound_catalog, EmoteSoundCatalog}; +pub use emotes::{load_emote_sound_catalog, EmoteSoundCatalog, EMOTE_FLAG_INTERACTION}; mod emote_text; pub use emote_text::{load_emote_text_catalog, EmoteLine, EmoteTextCatalog}; mod environmental_damage;