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:
Sam Sung
2026-10-02 10:32:20 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 2dc7d8a9e6
commit 56d8011ab7
10 changed files with 576 additions and 107 deletions
+13
View File
@@ -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,
+34 -1
View File
@@ -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");
}
}
+109 -39
View File
@@ -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,
+1 -1
View File
@@ -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.
+5
View File
@@ -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)
+22
View File
@@ -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> {
+1 -1
View File
@@ -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;