NPCs pause their work loop while you talk to them, as in 1.12.1 (#772)
Two ways the emote-state base pick differed from 1.12.1. - A blacksmith kept hammering while you used his vendor window. The resolver `0x5fd770` passes over a state whose `Emotes.dbc` flags have `0x2000` (work, mining, ready, dance) while the unit is the interact NPC (`0x5fd7e2`-`0x5fd7fd`). `SetInteractNPC` re-picks on open (`0x493198`) and on close (`0x493219`, before the name clears at `0x49334b`), never for a player. So the NPC stands, and the loop resumes at its next re-pick. - A set state outranks the idle fallbacks: `0x5fd770` runs before `0x5fd830`, and a row with AnimID 0 claims Stand. After `/stand` (state 26), a swimmer at rest shows Stand until it moves. Tests cover the open, the close hold, players, a pose under a one-shot, and the precedence order. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2dc7d8a9e6
commit
56d8011ab7
@@ -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,
|
||||
|
||||
@@ -280,13 +280,16 @@ pub(super) fn drive_animations(
|
||||
MessageReader<BaseAnimRecompute>,
|
||||
// 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<Res<crate::ui_session::InteractNpc>>,
|
||||
),
|
||||
// The variation roll's LCG, the reference's single CRT `_rand` stream shared by every play.
|
||||
mut rng: ResMut<benilla_assets::AnimRng>,
|
||||
// The last anim trace line per traced unit; the trace writes only on change.
|
||||
mut anim_trace_last: Local<std::collections::HashMap<Entity, String>>,
|
||||
) {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::<Assets<AnimationClip>>()
|
||||
.add(c)
|
||||
})
|
||||
.collect();
|
||||
let (graph, nodes) = AnimationGraph::from_clips(handles);
|
||||
let graph = app
|
||||
.world_mut()
|
||||
.resource_mut::<Assets<AnimationGraph>>()
|
||||
.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::<InteractNpc>();
|
||||
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<u16> {
|
||||
app.world().entity(unit).get::<AnimDriver>().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<Entity>, guid: u64) {
|
||||
*app.world_mut().resource_mut::<InteractNpc>() = InteractNpc(npc, npc.map(|_| guid));
|
||||
app.update();
|
||||
}
|
||||
|
||||
fn interact(app: &mut App, npc: Option<Entity>) {
|
||||
interact_guid(app, npc, CREATURE);
|
||||
}
|
||||
|
||||
fn mode(app: &App, unit: Entity) -> Mode {
|
||||
app.world().entity(unit).get::<AnimDriver>().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::<AnimDriver>()
|
||||
.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::<AnimDriver>().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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u16>,
|
||||
ranged_load: Option<u16>,
|
||||
) -> &'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<u16>,
|
||||
ranged_load: Option<u16>,
|
||||
state_anim: Option<u16>,
|
||||
) -> 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<u16>,
|
||||
ranged_load: Option<u16>,
|
||||
) -> 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<u32>,
|
||||
flags: Option<u32>,
|
||||
interacting: bool,
|
||||
) -> Option<u16> {
|
||||
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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<u32> {
|
||||
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<u32> {
|
||||
self.0.text_emote(text_id)
|
||||
|
||||
@@ -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<String, u32>,
|
||||
@@ -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<u32> {
|
||||
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<u32> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user