Emote routing follows 1.12.1's upper-body and mount tables (#767)
One-shot emotes were routed by a hand-made class table that wrongly let `/pray` (75), the `/beg` family (79), and 126, 127, 131, 132 and 135 play upper-body. A mounted rider played every id on its upper body. 1.12.1 reads two byte tables: - CLASS_A (`0x5fed90`): the ids that may play on the key bone. - The mount set (`0x5fec80`): what goes to the mount model while mounted (`0x5fe7b5`..`0x5fe803`). Anything else plays nowhere. What changed: - Both tables now match the bytes, and a mount-set id is forwarded to the mount child. - Unmounted `/eat` (61) always plays upper-body (`0x5fe834`). - A model with no split bone arms nothing for an upper-body play (`0x5fdcc4`). Tests pin both tables for every id 0..300, plus the mounted, eat and no-bone driver cases. 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
4f36c843f1
commit
fbea2d9f3a
@@ -19,8 +19,8 @@ use crate::net::{
|
||||
use crate::sound::EmoteSounds;
|
||||
|
||||
use super::select::{
|
||||
self, current_special, defense_anim, is_swing_id, route_oneshot, swing_anim_main,
|
||||
swing_anim_off, unify, Mode, OneShotRoute, DEATH, DEFAULT_WALK_SPEED, STAND,
|
||||
self, current_special, defense_anim, is_swing_id, route_mounted, route_oneshot,
|
||||
swing_anim_main, swing_anim_off, unify, Mode, OneShotRoute, DEATH, DEFAULT_WALK_SPEED, STAND,
|
||||
};
|
||||
use super::sheath::{advance_sheath_ceremony, start_sheath_ceremony};
|
||||
use super::{
|
||||
@@ -53,6 +53,7 @@ const ONESHOT_RELEASE_FADE: f32 = 0.150;
|
||||
const JUMP_ARC_MIN_UP: f32 = 0.5;
|
||||
|
||||
/// A one-shot play request from this frame's messages, resolved to an anim id per unit.
|
||||
#[derive(Clone, Copy)]
|
||||
enum OneShotReq {
|
||||
Swing(u32),
|
||||
Emote(u16),
|
||||
@@ -277,13 +278,15 @@ pub(super) fn drive_animations(
|
||||
// Read for `DO_NOT_PLAY_WOUND_ANIM`; a missing cache reads as an unreceived template.
|
||||
Option<Res<crate::names::NameCache>>,
|
||||
MessageReader<BaseAnimRecompute>,
|
||||
// A rider's mount child, where a mount-set request goes (`0x5fe7c1`).
|
||||
Query<(&ObjectStore, &crate::entities::mount::MountChild)>,
|
||||
),
|
||||
// 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) = aux;
|
||||
let (emote_sounds, loot_kneel, time, names, mut recomputes, riders) = 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
|
||||
@@ -334,6 +337,25 @@ pub(super) fn drive_animations(
|
||||
.or_default()
|
||||
.push((OneShotReq::Emote(e.anim_id), e.seq));
|
||||
}
|
||||
// A mounted rider's mount-set emotes go to the mount model (`0x5fe7c1`); the rider keeps the
|
||||
// request for its own CLASS_A and nowhere routes.
|
||||
let mut to_mount: Vec<(Entity, (OneShotReq, u64))> = Vec::new();
|
||||
for (rider, reqs) in pending.iter() {
|
||||
let Ok((store, child)) = riders.get(*rider) else {
|
||||
continue;
|
||||
};
|
||||
if store.0.unit_mount_display_id() == 0 {
|
||||
continue;
|
||||
}
|
||||
for &(req, seq) in reqs {
|
||||
if matches!(req, OneShotReq::Emote(id) if route_mounted(id).mount) {
|
||||
to_mount.push((child.0, (req, seq)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (child, req) in to_mount {
|
||||
pending.entry(child).or_default().push(req);
|
||||
}
|
||||
// Stage-2 base recomputes by unit, last wins: of two in a frame the second's verdict stands.
|
||||
let mut pending_recompute: bevy::ecs::entity::EntityHashMap<u16> = default();
|
||||
for r in recomputes.read() {
|
||||
@@ -794,13 +816,30 @@ pub(super) fn drive_animations(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Mounted forces the masked route (`0x5fe2f0`'s mounted branch).
|
||||
let masked =
|
||||
mounted || route_oneshot(id, mv.flags, mv.stand_state) == OneShotRoute::Masked;
|
||||
// A mounted rider plays only its CLASS_A ids, on the key bone, and nothing else
|
||||
// (`0x5fe7b5`); a mount child plays the clip on its own bone 0 (`0x5fe7c1`).
|
||||
let masked = if mounted {
|
||||
if !route_mounted(id).rider_upper {
|
||||
continue;
|
||||
}
|
||||
true
|
||||
} else {
|
||||
mount_body.is_none()
|
||||
&& route_oneshot(id, mv.flags, mv.stand_state) == OneShotRoute::Masked
|
||||
};
|
||||
// Resolve to a clip this model has, roll its variation and its replay budget (a clamp
|
||||
// one-shot authored `(min,max)` plays R times).
|
||||
let picked =
|
||||
find_resolved(anims, id, catalog).map(|h| roll_oneshot(anims, h, &mut rng));
|
||||
// No split bone (`[+0xd5c] == -1`): a key-bone play arms nothing (`0x5fdcc4`).
|
||||
if masked
|
||||
&& match picked {
|
||||
Some((c, _)) => c.upper_node.is_none(),
|
||||
None => mounted,
|
||||
}
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let upper = masked
|
||||
.then(|| picked.and_then(|(c, r)| c.upper_node.map(|n| (n, r))))
|
||||
.flatten();
|
||||
@@ -826,7 +865,7 @@ pub(super) fn drive_animations(
|
||||
masked_played = true;
|
||||
played_oneshot = Some(id);
|
||||
} else {
|
||||
// Full-body, or a model with no key bone (the −1 sentinel arms bone 0): the clip
|
||||
// Full-body: the clip
|
||||
// replaces the base on bone 0 even over a Special, last writer wins (`0x5fe6c8`).
|
||||
// An airborne arc's own clip freezes first, op4's pose-snapshot decay.
|
||||
if let Some(sp) =
|
||||
|
||||
@@ -3978,3 +3978,163 @@ mod base_anim_lock {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-shot routing seams `0x5fe2f0` runs ahead of the clip: the mounted three-way split
|
||||
/// (`0x5fe7b5`), and a key-bone play on a model with no split bone (`0x5fdcc4`).
|
||||
mod routing {
|
||||
use super::*;
|
||||
|
||||
const FIELD_MOUNTDISPLAYID: u16 = 133;
|
||||
const MOUNT: u16 = 91;
|
||||
const PRAY: u16 = 75; // EmoteKneel, not CLASS_A
|
||||
const CHEER: u16 = 68; // CLASS_A
|
||||
const SHUFFLE: u16 = 12; // mount-set, not CLASS_A
|
||||
const EAT: u16 = 61;
|
||||
|
||||
fn masked(anim_id: u16, node: u32, upper: u32) -> AnimClip {
|
||||
let mut c = clip(anim_id, node, false);
|
||||
c.upper_node = Some(AnimationNodeIndex::new(upper as usize));
|
||||
c
|
||||
}
|
||||
|
||||
fn model(clips: Vec<AnimClip>) -> ModelAnimations {
|
||||
ModelAnimations {
|
||||
graph: Handle::default(),
|
||||
clips,
|
||||
hand_close: [None, None],
|
||||
playable_animation_lookup: Vec::new(),
|
||||
animation_lookup: Vec::new(),
|
||||
global_bones: Vec::new(),
|
||||
first_seq: None,
|
||||
pose: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rider_model() -> ModelAnimations {
|
||||
model(vec![
|
||||
clip(0, 1, true),
|
||||
clip(MOUNT, 2, true),
|
||||
masked(PRAY, 3, 13),
|
||||
masked(CHEER, 4, 14),
|
||||
masked(SHUFFLE, 5, 15),
|
||||
])
|
||||
}
|
||||
|
||||
fn spawn(app: &mut App, model: ModelAnimations, mount_display: u32) -> Entity {
|
||||
app.world_mut()
|
||||
.spawn((
|
||||
model,
|
||||
AnimationPlayer::default(),
|
||||
AnimationTransitions::new(),
|
||||
AnimDriver::default(),
|
||||
crate::net::ObjectStore(ObjectFields::from_pairs(&[(
|
||||
FIELD_MOUNTDISPLAYID,
|
||||
mount_display,
|
||||
)])),
|
||||
MovementState::default(),
|
||||
))
|
||||
.id()
|
||||
}
|
||||
|
||||
fn emote(app: &mut App, entity: Entity, anim_id: u16) {
|
||||
app.world_mut().write_message(EmoteAnim {
|
||||
entity,
|
||||
anim_id,
|
||||
seq: 1,
|
||||
});
|
||||
app.update();
|
||||
}
|
||||
|
||||
fn drv(app: &App, e: Entity) -> &AnimDriver {
|
||||
app.world().entity(e).get::<AnimDriver>().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mounted_pray_arms_nothing_and_keeps_the_mounted_base() {
|
||||
let mut app = app();
|
||||
let rider = spawn(&mut app, rider_model(), 2404);
|
||||
app.update();
|
||||
assert_eq!(drv(&app, rider).active_anim(), Some(MOUNT));
|
||||
emote(&mut app, rider, PRAY);
|
||||
assert!(drv(&app, rider).overlay.is_none(), "no key-bone play");
|
||||
assert_eq!(drv(&app, rider).active_anim(), Some(MOUNT), "no base play");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mounted_class_a_id_plays_on_the_riders_upper_body() {
|
||||
let mut app = app();
|
||||
let rider = spawn(&mut app, rider_model(), 2404);
|
||||
app.update();
|
||||
emote(&mut app, rider, CHEER);
|
||||
assert!(drv(&app, rider).overlay.is_some_and(|o| o.id == CHEER));
|
||||
assert_eq!(drv(&app, rider).active_anim(), Some(MOUNT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mounted_mount_set_id_goes_to_the_mount_child() {
|
||||
let mut app = app();
|
||||
let rider = spawn(&mut app, rider_model(), 2404);
|
||||
let child = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
model(vec![clip(0, 1, true), masked(SHUFFLE, 2, 12)]),
|
||||
AnimationPlayer::default(),
|
||||
AnimationTransitions::new(),
|
||||
AnimDriver::default(),
|
||||
crate::entities::mount::MountBody { host: rider },
|
||||
))
|
||||
.id();
|
||||
app.world_mut()
|
||||
.entity_mut(rider)
|
||||
.insert(crate::entities::mount::MountChild(child));
|
||||
app.update();
|
||||
emote(&mut app, rider, SHUFFLE);
|
||||
assert_eq!(
|
||||
drv(&app, child).active_anim(),
|
||||
Some(SHUFFLE),
|
||||
"the mount's bone 0, full-body"
|
||||
);
|
||||
assert!(drv(&app, child).overlay.is_none());
|
||||
assert!(
|
||||
drv(&app, rider).overlay.is_none(),
|
||||
"not CLASS_A: the rider idles"
|
||||
);
|
||||
assert_eq!(drv(&app, rider).active_anim(), Some(MOUNT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standing_eat_takes_the_key_bone() {
|
||||
let mut app = app();
|
||||
let unit = spawn(
|
||||
&mut app,
|
||||
model(vec![clip(0, 1, true), masked(EAT, 2, 12)]),
|
||||
0,
|
||||
);
|
||||
app.update();
|
||||
emote(&mut app, unit, EAT);
|
||||
assert!(drv(&app, unit).overlay.is_some_and(|o| o.id == EAT));
|
||||
assert_eq!(
|
||||
drv(&app, unit).active_anim(),
|
||||
Some(0),
|
||||
"the legs keep Stand"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_bone_play_without_a_split_bone_arms_nothing() {
|
||||
let mut app = app();
|
||||
let unit = spawn(
|
||||
&mut app,
|
||||
model(vec![clip(0, 1, true), clip(EAT, 2, false)]),
|
||||
0,
|
||||
);
|
||||
app.update();
|
||||
emote(&mut app, unit, EAT);
|
||||
assert!(drv(&app, unit).overlay.is_none());
|
||||
assert_eq!(
|
||||
drv(&app, unit).active_anim(),
|
||||
Some(0),
|
||||
"no full-body fallback"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,11 +630,33 @@ pub(super) enum OneShotRoute {
|
||||
FullBody,
|
||||
}
|
||||
|
||||
/// The CLASS_A set (`0x5fed90`), the only ids that may mask; its ranges' interior is inferred,
|
||||
/// but only swings, emotes and casts reach [`route_oneshot`].
|
||||
/// The CLASS_A set (`0x5fed90`'s byte table over `id - 2`), the only ids that may play on the
|
||||
/// key bone; every other id plays full-body.
|
||||
fn is_class_a(id: u16) -> bool {
|
||||
matches!(id,
|
||||
2 | 8..=10 | 14..=36 | 46..=49 | 51..=90 | 105..=113 | 117..=118 | 122..=138 | 185..=186 | 195)
|
||||
2 | 8..=10 | 14..=36 | 46..=49 | 51..=74 | 76..=78 | 80..=90 | 105..=113 | 117 | 118
|
||||
| 122..=125 | 128..=130 | 133 | 134 | 136..=138 | 185 | 186 | 195)
|
||||
}
|
||||
|
||||
/// The mount-set (`0x5fec80`'s byte table), the ids a mounted rider's request sends to the mount
|
||||
/// model.
|
||||
fn is_mount_set(id: u16) -> bool {
|
||||
matches!(id, 0 | 1 | 3..=6 | 8..=13 | 37..=45 | 92..=95 | 119 | 120 | 127 | 131 | 132 | 135 | 143 | 187)
|
||||
}
|
||||
|
||||
/// Where a request goes while the unit has a mount model (`0x5fe7b5`..`0x5fe803`): a mount-set id
|
||||
/// to the mount, a CLASS_A id to the rider's key bone (8-10 are both), anything else nowhere.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(super) struct MountedRoute {
|
||||
pub(super) mount: bool,
|
||||
pub(super) rider_upper: bool,
|
||||
}
|
||||
|
||||
pub(super) fn route_mounted(id: u16) -> MountedRoute {
|
||||
MountedRoute {
|
||||
mount: is_mount_set(id),
|
||||
rider_upper: is_class_a(id),
|
||||
}
|
||||
}
|
||||
|
||||
/// The COMBAT set (`0x5fcc10`), the ids an airborne unit masks: every swing, no emote and no
|
||||
@@ -649,10 +671,17 @@ fn is_forced_full_body(id: u16) -> bool {
|
||||
matches!(id, 1 | 6 | 131 | 132 | 57 | 58 | 118)
|
||||
}
|
||||
|
||||
/// Route a one-shot by live state (`0x5fe6c8`..`0x5fe74d`): masked while the lower body is
|
||||
/// EmoteEat, the `/eat` and `/drink` loop.
|
||||
const EMOTE_EAT: u16 = 61;
|
||||
|
||||
/// Route an unmounted one-shot by live state (`0x5fe6c8`..`0x5fe74d`): masked while the lower body is
|
||||
/// committed (moving, turning, swimming, a non-zero stand state, or a COMBAT id airborne), else
|
||||
/// full-body, so one Attack1H is full-body standing and masked running.
|
||||
pub(super) fn route_oneshot(id: u16, flags: u32, stand_state: u8) -> OneShotRoute {
|
||||
// EmoteEat (61) unmounted always takes the key bone, whatever the state (`0x5fe834`).
|
||||
if id == EMOTE_EAT {
|
||||
return OneShotRoute::Masked;
|
||||
}
|
||||
if is_forced_full_body(id) || !is_class_a(id) {
|
||||
return OneShotRoute::FullBody;
|
||||
}
|
||||
|
||||
@@ -631,8 +631,36 @@ fn route_classifier_memberships_are_the_decoded_bytes() {
|
||||
for id in [16, 17, 18, 19, 85, 87, 88, 117] {
|
||||
assert!(is_combat(id) && is_class_a(id), "swing {id}");
|
||||
}
|
||||
for id in [17, 66, 68, 80] {
|
||||
assert!(is_class_a(id), "class-A {id}");
|
||||
// The whole table, member and non-member alike, from the decoded bytes of `0x5fed90`.
|
||||
let class_a: Vec<u16> = [2, 8, 9, 10, 14, 15]
|
||||
.into_iter()
|
||||
.chain(16..=36)
|
||||
.chain(46..=49)
|
||||
.chain(51..=74)
|
||||
.chain(76..=78)
|
||||
.chain(80..=90)
|
||||
.chain(105..=113)
|
||||
.chain([117, 118])
|
||||
.chain(122..=125)
|
||||
.chain(128..=130)
|
||||
.chain([133, 134])
|
||||
.chain(136..=138)
|
||||
.chain([185, 186, 195])
|
||||
.collect();
|
||||
for id in 0..=300u16 {
|
||||
assert_eq!(is_class_a(id), class_a.contains(&id), "CLASS_A {id}");
|
||||
}
|
||||
// The mount-set, `0x5fec80`.
|
||||
let mount: Vec<u16> = [0, 1]
|
||||
.into_iter()
|
||||
.chain(3..=6)
|
||||
.chain(8..=13)
|
||||
.chain(37..=45)
|
||||
.chain(92..=95)
|
||||
.chain([119, 120, 127, 131, 132, 135, 143, 187])
|
||||
.collect();
|
||||
for id in 0..=300u16 {
|
||||
assert_eq!(is_mount_set(id), mount.contains(&id), "mount-set {id}");
|
||||
}
|
||||
// Forced-full-body carve-outs never mask, even seated.
|
||||
for id in [1, 6, 131, 132, 57, 58, 118] {
|
||||
@@ -640,6 +668,34 @@ fn route_classifier_memberships_are_the_decoded_bytes() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_routing_sends_each_id_where_the_bytes_do() {
|
||||
let r = |id| route_mounted(id);
|
||||
// EmoteKneel and EmoteBeg are in neither set: they play nowhere.
|
||||
for id in [75, 79] {
|
||||
assert_eq!((r(id).mount, r(id).rider_upper), (false, false), "{id}");
|
||||
}
|
||||
assert_eq!((r(68).mount, r(68).rider_upper), (false, true));
|
||||
assert_eq!((r(12).mount, r(12).rider_upper), (true, false));
|
||||
// Wound ids 8-10 are in both.
|
||||
assert_eq!((r(9).mount, r(9).rider_upper), (true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standing_eat_is_masked_whatever_the_state() {
|
||||
assert_eq!(route_oneshot(61, 0, 0), Masked);
|
||||
assert_eq!(route_oneshot(61, move_flags::FORWARD, 0), Masked);
|
||||
assert_eq!(route_oneshot(61, 0, 1), Masked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whirlwind_and_birth_play_full_body_while_moving() {
|
||||
for id in [126, 127] {
|
||||
assert_eq!(route_oneshot(id, move_flags::FORWARD, 0), FullBody, "{id}");
|
||||
assert_eq!(route_oneshot(id, 0, 1), FullBody, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_emote_idle_never_reached_during_a_special() {
|
||||
// `current_special` takes jumps and poses before `gait_candidates` runs, so the order is:
|
||||
|
||||
Reference in New Issue
Block a user