From fbea2d9f3ac19cdb33be69668168aaaf59cfb48b Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:02:52 +0700 Subject: [PATCH] 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) --- .../benilla-app/src/creature_anim/driver.rs | 53 +++++- .../src/creature_anim/driver/tests.rs | 160 ++++++++++++++++++ .../benilla-app/src/creature_anim/select.rs | 37 +++- .../src/creature_anim/select/tests.rs | 60 ++++++- 4 files changed, 297 insertions(+), 13 deletions(-) diff --git a/crates/benilla-app/src/creature_anim/driver.rs b/crates/benilla-app/src/creature_anim/driver.rs index f3fadb08..569d4775 100644 --- a/crates/benilla-app/src/creature_anim/driver.rs +++ b/crates/benilla-app/src/creature_anim/driver.rs @@ -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>, MessageReader, + // 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, // The last anim trace line per traced unit; the trace writes only on change. mut anim_trace_last: Local>, ) { - let (emote_sounds, loot_kneel, time, names, mut recomputes) = 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 = 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) = diff --git a/crates/benilla-app/src/creature_anim/driver/tests.rs b/crates/benilla-app/src/creature_anim/driver/tests.rs index a48c5090..c153f6c9 100644 --- a/crates/benilla-app/src/creature_anim/driver/tests.rs +++ b/crates/benilla-app/src/creature_anim/driver/tests.rs @@ -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) -> 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::().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" + ); + } +} diff --git a/crates/benilla-app/src/creature_anim/select.rs b/crates/benilla-app/src/creature_anim/select.rs index 0c7a574e..d9d3f233 100644 --- a/crates/benilla-app/src/creature_anim/select.rs +++ b/crates/benilla-app/src/creature_anim/select.rs @@ -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; } diff --git a/crates/benilla-app/src/creature_anim/select/tests.rs b/crates/benilla-app/src/creature_anim/select/tests.rs index fbe71543..b036807f 100644 --- a/crates/benilla-app/src/creature_anim/select/tests.rs +++ b/crates/benilla-app/src/creature_anim/select/tests.rs @@ -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 = [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 = [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: