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:
Sam Sung
2026-10-02 09:02:52 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 4f36c843f1
commit fbea2d9f3a
4 changed files with 297 additions and 13 deletions
+46 -7
View File
@@ -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"
);
}
}
+33 -4
View File
@@ -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: