A pane's blend slot keeps the outgoing sequence on its own clock (#1881)

A held paperdoll turn re-arms a shuffle once per cycle. When it fell inside Stand's half-blend, the pane reused the one track it held for that clip, so the fading shuffle jumped back about 3 frames every 0.53 s, and the fade's end restarted the primary.

1.12.1's arm (`0x7121a0`) copies the outgoing primary into the blend slot with its own clock (`0x7125d6`). A refused arm (`0x7125c9`/`0x7125d4`) keeps that slot, the latched shortcut (`0x712549`) leaves it alone, and the slot ends on its own (`0x7147b1`), weighted by the smoothstep (`0x714880`).

Now each sequence has a second graph node, and the pane's blend slot runs on whichever node the primary is not on. A refused arm leaves it untouched, and the window's end stops only the secondary. Headless player tests cover the held turn, the fade end and the shortcut.

Fixes #1845

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-18 11:39:40 +07:00
committed by GitHub
parent 94fb118517
commit 9e55f481f4
16 changed files with 220 additions and 82 deletions
@@ -29,6 +29,7 @@ fn clip(anim_id: u16, node: u32, looping: bool) -> AnimClip {
anim_id,
seq_index: 0,
node: AnimationNodeIndex::new(node as usize),
blend_node: None,
looping,
duration: 1.0,
move_speed: 0.0,
@@ -284,6 +285,7 @@ fn spawn_fidgeter(app: &mut App) -> (Entity, Vec<AnimationNodeIndex>) {
.add(graph);
let seq = |id: u16, node, duration: f32, frequency: u16| AnimClip {
node,
blend_node: None,
duration,
blend_time: 0.0,
frequency,
@@ -3113,6 +3115,7 @@ fn spawn_vendor(app: &mut App) -> (Entity, Vec<AnimationNodeIndex>) {
.add(graph);
let seq = |id: u16, node, duration: f32, blend_time: f32| AnimClip {
node,
blend_node: None,
duration,
blend_time,
replay: (0, 0),
@@ -39,6 +39,7 @@ fn spawn_orc(app: &mut App) -> (Entity, [AnimationNodeIndex; 3]) {
.add(graph);
let seq = |id: u16, node, looping| AnimClip {
node,
blend_node: None,
duration: SPAN,
blend_time: 0.0,
replay: (0, 0),
@@ -392,6 +393,7 @@ fn spawn_runner(app: &mut App) -> (Entity, [AnimationNodeIndex; 6]) {
.add(graph);
let seq = |id: u16, node, looping| AnimClip {
node,
blend_node: None,
duration: SPAN,
blend_time: 0.0,
replay: (0, 0),
@@ -413,6 +413,7 @@ mod tests {
anim_id: 11,
seq_index: 38,
node: AnimationNodeIndex::new(7),
blend_node: None,
looping: true,
duration: 0.5,
move_speed: 0.0,
@@ -201,6 +201,7 @@ mod tests {
anim_id: 0,
seq_index: 0,
node,
blend_node: None,
looping: true,
duration: DUR,
move_speed: 0.0,
@@ -16,6 +16,7 @@ fn clip(anim_id: u16, move_speed: f32) -> AnimClip {
anim_id,
seq_index: 0,
node: AnimationNodeIndex::new(0),
blend_node: None,
looping: true,
duration: 1.0,
move_speed,
@@ -154,6 +154,7 @@ mod tests {
anim_id,
seq_index: node,
node: AnimationNodeIndex::new(node),
blend_node: None,
looping,
duration: 1.0,
move_speed: 0.0,
+1
View File
@@ -871,6 +871,7 @@ mod tests {
anim_id: id,
seq_index: 0,
node,
blend_node: None,
// The crate's clips are bit-0-clear bands: the kernel loops them.
looping: true,
duration: dur,
+1
View File
@@ -1192,6 +1192,7 @@ mod tests {
anim_id: 0,
seq_index: 0,
node: bevy::animation::graph::AnimationNodeIndex::new(1),
blend_node: None,
looping: true,
duration: 0.5,
move_speed: 0.0,
@@ -2624,6 +2624,7 @@ mod tests {
anim_id: 0,
seq_index: 0,
node: node(STAND_NODE),
blend_node: None,
looping: true,
duration: 2.0,
move_speed: 0.0,
+62 -72
View File
@@ -178,7 +178,8 @@ struct StandIn {
pub(super) struct Turn {
/// The serial of the engine arm last played.
played: Option<u64>,
/// The node the primary runs; the spawn's idle Stand until the first arm (`0x70ebd0`).
/// The node the primary runs, either of its clip's two tracks; the spawn's idle Stand until
/// the first arm (`0x70ebd0`).
pub(super) playing: Option<AnimationNodeIndex>,
pub(super) fade: Option<Fade>,
/// When the primary's window closes, `start + span·R` (`0x712692`); `None` once reported.
@@ -187,7 +188,8 @@ pub(super) struct Turn {
completed: bool,
}
/// The secondary blend slot (`[blk+0xc4]`, `0x7125d6`): the pose an arm replaced, faded out by λ.
/// The secondary blend slot (`[blk+0xc4]`, `0x7125d6`): the pose an arm replaced, on its own clock
/// and a track the primary is not using, faded out by λ.
#[derive(Clone, Copy)]
pub(super) struct Fade {
pub(super) node: AnimationNodeIndex,
@@ -202,6 +204,13 @@ fn fade_frac(fade: &Fade, now: f64) -> f32 {
((fade.until - now) / f64::from(fade.span)) as f32
}
/// Empty the secondary slot, stopping its track.
fn clear_fade(player: &mut AnimationPlayer, turn: &mut Turn) {
if let Some(old) = turn.fade.take() {
player.stop(old.node);
}
}
/// One engine arm, `0x7121a0(bone −1, id, variation −1, offset, 1.0, blend, 1)`. The id resolves
/// through the model's fallback chain (`0x711bf0` at `0x71245d`); a variation is rolled and the
/// play count drawn (`0x712692`). False only when nothing resolves (`0x712484`).
@@ -222,100 +231,81 @@ fn play_arm(
return false;
};
let replays = rng.replay_count(clip.replay);
let (node, looping, blend_time) = (clip.node, clip.looping, clip.blend_time.max(0.0));
let same = outgoing == Some(node);
// The shortcut (`0x712553`): a blended arm of the sequence that already ran to its end
// restarts it with no blend slot.
let shortcut = blend && same && turn.completed;
if !blend || shortcut {
let blend_time = clip.blend_time.max(0.0);
// The primary's record (`[blk+0xa4]`), on either of its clip's tracks.
let same = outgoing.is_some_and(|n| n == clip.node || Some(n) == clip.blend_node);
if !blend {
// No blend: the secondary slot is cleared (`0x712635`, `[blk+0xd0] = −1`).
if let Some(old) = turn.fade.take() {
if old.node != node {
player.stop(old.node);
}
}
}
// The half-blend refusal (`0x7125c9`/`0x7125d4`): a running blend with λ > 0.5 keeps its
// secondary and window, and the outgoing primary is dropped.
let refused = blend
&& !shortcut
&& turn
.fade
.is_some_and(|f| crate::creature_anim::select::blend_lambda(fade_frac(&f, now)) > 0.5);
if refused {
if let Some(out) = outgoing.filter(|o| *o != node) {
player.stop(out);
}
} else if blend && !shortcut {
// One secondary slot: a fade in flight is displaced, unless it is the node replaying.
if let Some(old) = turn.fade.take() {
if old.node != node && Some(old.node) != outgoing {
player.stop(old.node);
}
}
}
let fade = if refused {
turn.fade
} else if blend && !shortcut && !same && blend_time > 0.0 {
outgoing.map(|node| Fade {
clear_fade(player, turn);
} else if same && turn.completed {
// The shortcut (`0x712553` → `0x71262d`): the sequence that ran to its end is written
// again as the primary alone; the secondary and its window stay as they are.
} else if turn
.fade
.is_none_or(|f| crate::creature_anim::select::blend_lambda(fade_frac(&f, now)) <= 0.5)
{
// The copy (`0x7125d6`): the outgoing primary becomes the secondary, its clock kept, over
// the incoming clip's blendTime (`0x7125f2`). A zero blendTime ends it on the next pass.
clear_fade(player, turn);
turn.fade = outgoing.filter(|_| blend_time > 0.0).map(|node| Fade {
node,
until: now + f64::from(blend_time),
span: blend_time,
})
} else {
None
};
// A blended re-arm of the running sequence, latch clear, fades from that sequence on its own
// clock (`0x71255f` → `0x7125d6`). The player holds one track per clip, so the track runs on
// through the window instead: no restart, no jump.
let continues = blend && same && !shortcut && !refused;
{
// Set explicitly: `play` is idempotent on a live node, which would keep its old clock and
// weight.
let active = player.play(node);
active.set_repeat(if looping {
RepeatAnimation::Forever
} else {
RepeatAnimation::Never
});
active.set_speed(1.0);
if !continues {
active.seek_to(offset);
}
// λ = 1 on a blended arm's first frame, so the pose does not move on the frame it lands.
active.set_weight(if fade.is_some() { 0.0 } else { 1.0 });
}
if fade.is_none() {
if let Some(out) = outgoing.filter(|o| *o != node) {
player.stop(out);
// Else the half-blend refusal (`0x7125c9`/`0x7125d4`): λ > 0.5 keeps the secondary and its
// window, and the primary alone is written.
// The primary runs on the clip's track the secondary is not on, so a re-arm of the sequence
// the secondary holds leaves that clock alone.
let node = match (turn.fade, clip.blend_node) {
(Some(f), Some(twin)) if f.node == clip.node => twin,
(Some(f), None) if f.node == clip.node => {
// A model built with one track per clip: the arm takes it, and the secondary ends.
turn.fade = None;
clip.node
}
_ => clip.node,
};
// The primary write replaces the outgoing primary (`0x712692`), unless the copy kept it.
if let Some(out) = outgoing.filter(|&o| o != node && turn.fade.is_none_or(|f| f.node != o)) {
player.stop(out);
}
let active = player.start(node);
active.set_repeat(if clip.looping {
RepeatAnimation::Forever
} else {
RepeatAnimation::Never
});
active.set_speed(1.0);
active.seek_to(offset);
// λ = 1 on a blended arm's first frame, so the pose does not move on the frame it lands.
active.set_weight(if turn.fade.is_some() { 0.0 } else { 1.0 });
turn.playing = Some(node);
turn.fade = fade;
turn.completed = false;
turn.ends = Some(now - f64::from(offset) + f64::from(clip.duration) * f64::from(replays));
true
}
/// Advance the fade: λ, smoothstep of the window still to run (`0x714880`), weights the outgoing
/// pose and the incoming takes `1 − λ`.
/// Advance the fade: λ, smoothstep of the window still to run (`0x714880`), weights the secondary
/// and the primary takes `1 − λ`. At the window's end (`0x7147b1`) the secondary alone stops.
fn advance_fade(player: &mut AnimationPlayer, turn: &mut Turn, now: f64) {
let Some(fade) = turn.fade else {
return;
};
let frac = fade_frac(&fade, now);
if frac <= 0.0 {
let lambda = if frac <= 0.0 {
turn.fade = None;
player.stop(fade.node);
if let Some(cur) = turn.playing {
player.play(cur).set_weight(1.0);
}
0.0
} else {
let lambda = crate::creature_anim::select::blend_lambda(frac);
player.play(fade.node).set_weight(lambda);
if let Some(cur) = turn.playing {
player.play(cur).set_weight(1.0 - lambda);
if let Some(out) = player.animation_mut(fade.node) {
out.set_weight(lambda);
}
lambda
};
if let Some(cur) = turn.playing.and_then(|n| player.animation_mut(n)) {
cur.set_weight(1.0 - lambda);
}
}
+126 -9
View File
@@ -11,6 +11,8 @@ fn turning_model() -> benilla_assets::ModelAnimations {
anim_id,
seq_index: 0,
node: AnimationNodeIndex::new(node),
// The blend slot's own track of each, nodes 5 to 8.
blend_node: Some(AnimationNodeIndex::new(node + 4)),
looping: true,
duration: 0.5,
move_speed: 0.0,
@@ -316,11 +318,12 @@ fn an_arm_reports_its_natural_end_once() {
}
/// `0x7121a0` resolves an id the model lacks through its fallback (`0x711bf0`, default Stand),
/// so a pet with no shuffles plays Stand for one; the paint's Stand return then re-arms the
/// running sequence with the latch clear, which blends (`0x71255f`) rather than snapping the idle
/// back to its first frame.
/// so a pet with no shuffles plays Stand for one. That re-arm of the running Stand, latch clear,
/// copies it to the secondary on its own clock (`0x71255f` → `0x7125d6`) and restarts the primary
/// on the clip's other track; the paint's Stand return, inside that blend's first half, is refused
/// (`0x7125d4`) and restarts the primary alone while the idle fades on.
#[test]
fn a_display_with_no_shuffle_turns_on_stand_and_returns_without_a_snap() {
fn a_display_with_no_shuffle_turns_on_stand_and_fades_from_the_idles_own_clock() {
let mut anims = turning_model();
anims.clips.retain(|c| c.anim_id == ANIM_STAND);
// The model's own baked resolution: both shuffles fall back to Stand.
@@ -340,17 +343,131 @@ fn a_display_with_no_shuffle_turns_on_stand_and_returns_without_a_snap() {
c.step(arm(ANIM_STAND, 1, true), 0.6); // the idle's fade done
c.player.play(node(2)).seek_to(0.3);
c.step(arm(ANIM_SHUFFLE_LEFT, 2, true), 1.0);
assert_eq!(c.turn.playing, Some(node(2)), "the substitute plays");
assert_eq!(
c.turn.playing,
Some(node(6)),
"the substitute restarts on Stand's second track"
);
assert!(c.turn.ends.is_some(), "an arm that played owes its end");
// The paint's return, 100 ms on.
assert_eq!(c.turn.fade.map(|f| (f.node, f.until)), Some((node(2), 1.5)));
// The paint's return, 110 ms on.
c.step(arm(ANIM_STAND, 3, true), 1.11);
assert!(c.turn.fade.is_none(), "nothing else to fade from");
assert_eq!(weight(&c.player, 2), Some(1.0));
assert_eq!(c.turn.playing, Some(node(6)));
assert_eq!(
c.turn.fade.map(|f| (f.node, f.until)),
Some((node(2), 1.5)),
"the refused arm keeps the window"
);
assert_eq!(
c.player.animation(node(2)).map(|a| a.seek_time()),
Some(0.3),
"the idle runs on, no snap to frame 0"
"the idle fades out on its own clock, no snap to frame 0"
);
assert_eq!(
c.player.animation(node(6)).map(|a| a.seek_time()),
Some(0.0)
);
let (out, inc) = (weight(&c.player, 2).unwrap(), weight(&c.player, 6).unwrap());
assert!((out + inc - 1.0).abs() < 1e-5 && out > 0.5, "{out} / {inc}");
}
/// A held arrow, frame by frame: the shuffle runs to its end, the callback arms Stand (`0x5062e4`),
/// which copies the shuffle to the secondary (`0x7125d6`), and the next `SetRotation` re-arms the
/// shuffle inside Stand's first half, refused (`0x7125d4`). The secondary keeps the old shuffle on
/// its clock while the new one runs from 0 on the clip's other track.
fn held_turn() -> Copy {
let mut c = Copy::standing();
let first = arm(ANIM_SHUFFLE_RIGHT, 2, true);
c.step(first, 1.0);
c.step(first, 1.3); // the shuffle's own fade done
c.player.play(node(4)).seek_to(0.45);
assert_eq!(c.step(first, 1.5), Some(2), "its window closes");
c.step(arm(ANIM_STAND, 3, true), 1.517);
c.step(arm(ANIM_SHUFFLE_RIGHT, 4, true), 1.533);
c
}
#[test]
fn a_held_turns_rearm_inside_stands_blend_keeps_the_old_shuffle_on_its_own_clock() {
let c = held_turn();
let fade = c.turn.fade.expect("Stand's fade still runs");
assert_eq!(fade.node, node(4), "fading the old shuffle");
assert!(
(fade.until - 2.017).abs() < 1e-9,
"on Stand's window, untouched"
);
assert_eq!(
c.player.animation(node(4)).map(|a| a.seek_time()),
Some(0.45),
"the old shuffle is not seeked back"
);
assert_eq!(c.turn.playing, Some(node(8)), "the new one is on the twin");
assert_eq!(
c.player.animation(node(8)).map(|a| a.seek_time()),
Some(0.0)
);
let (out, inc) = (weight(&c.player, 4).unwrap(), weight(&c.player, 8).unwrap());
assert!(
out > 0.99 && (out + inc - 1.0).abs() < 1e-5,
"{out} / {inc}"
);
assert_eq!(
weight(&c.player, 2),
None,
"the overwritten Stand is dropped"
);
}
/// At the window's end (`0x7147b1`) the secondary slot alone is emptied; the primary runs on.
#[test]
fn the_fade_window_ends_the_secondary_alone() {
let mut c = held_turn();
c.player.play(node(8)).seek_to(0.3);
c.step(arm(ANIM_SHUFFLE_RIGHT, 4, true), 2.02);
assert!(c.turn.fade.is_none());
assert_eq!(weight(&c.player, 4), None, "the old shuffle is stopped");
let primary = c
.player
.animation(node(8))
.expect("the primary still plays");
assert_eq!(
(primary.seek_time(), primary.weight(), primary.repeat_mode()),
(0.3, 1.0, RepeatAnimation::Forever),
"on its clock, whole, still looping"
);
}
/// The shortcut (`0x712553` → `0x71262d`) writes the primary alone: a fade in flight keeps its
/// slot, clock and window.
#[test]
fn a_latched_same_sequence_rearm_leaves_a_live_fade_alone() {
let mut c = Copy::standing();
// 400 ms in, the shuffle's window closes before its own 0.25 s fade from Stand does.
let late = PaneArm {
offset_ms: 400,
..arm(ANIM_SHUFFLE_LEFT, 2, true)
};
c.step(late, 1.0);
c.player.play(node(2)).seek_to(0.2);
assert_eq!(c.step(late, 1.1), Some(2));
c.step(arm(ANIM_SHUFFLE_LEFT, 3, true), 1.15);
assert_eq!(
c.turn.fade.map(|f| (f.node, f.until)),
Some((node(2), 1.25)),
"the secondary slot is left as it is"
);
assert_eq!(
c.player.animation(node(2)).map(|a| a.seek_time()),
Some(0.2)
);
assert_eq!(c.turn.playing, Some(node(3)));
assert_eq!(
c.player.animation(node(3)).map(|a| a.seek_time()),
Some(0.0),
"restarted"
);
let (out, inc) = (weight(&c.player, 2).unwrap(), weight(&c.player, 3).unwrap());
assert!(out > 0.0 && (out + inc - 1.0).abs() < 1e-5, "{out} / {inc}");
}
// ── The composite ─────────────────────────────────────────────────────────────────────────
+1
View File
@@ -235,6 +235,7 @@ mod tests {
anim_id,
seq_index: 0,
node: AnimationNodeIndex::new(node as usize),
blend_node: None,
looping,
duration,
move_speed: 0.0,
+5
View File
@@ -510,6 +510,10 @@ impl AssetLoader for M2ModelLoader {
let clip = ctx.add_labeled_asset(format!("clip{i}"), clip);
let node = graph.add_clip(clip.clone(), 1.0, root);
pose.set_node(node, pose_idx, 0);
// The key bone's blend slot keeps the outgoing sequence on its own clock
// (`0x7125ea`), and it can be the sequence the primary re-arms.
let blend_node = graph.add_clip(clip.clone(), 1.0, root);
pose.set_node(blend_node, pose_idx, 0);
// The sheath family (89/90) gets per-arm masked nodes.
let arm_nodes =
(matches!(anim.anim_id, 89 | 90) && arm_roots.is_some()).then(|| {
@@ -532,6 +536,7 @@ impl AssetLoader for M2ModelLoader {
anim_id: anim.anim_id,
seq_index: anim.seq_index,
node,
blend_node: Some(blend_node),
looping: anim.looping,
duration: anim.duration,
move_speed: anim.move_speed,
+11
View File
@@ -32,6 +32,9 @@ pub struct AnimClip {
/// The file slot the per-sequence bakes key on, not the index in [`ModelAnimations::clips`].
pub seq_index: usize,
pub node: AnimationNodeIndex,
/// A second full-body node on the same clip, so a key bone's blend slot can run this sequence
/// on a clock of its own while the primary re-arms it (`+0xc4`, copied whole at `0x7125ea`).
pub blend_node: Option<AnimationNodeIndex>,
pub looping: bool,
/// Length in seconds; a unit streamed in dead seeks its one-shot to the end pose.
pub duration: f32,
@@ -146,6 +149,13 @@ impl ModelAnimations {
.or_else(|| self.clips.first())
}
/// The clip a graph node plays as its full-body pose, on either of its two tracks.
pub fn clip_at(&self, node: AnimationNodeIndex) -> Option<&AnimClip> {
self.clips
.iter()
.find(|c| c.node == node || c.blend_node == Some(node))
}
/// Pick a variation of `anim_id` for a `rand()` roll, the reference's weighted walk
/// (`0x7121a0`, variation −1). Exhaustion keeps the head: only the win path (`0x7124e9`)
/// overwrites the slot set at `0x71247a`. Variations are the sequences sharing `anim_id` in
@@ -223,6 +233,7 @@ mod tests {
anim_id,
seq_index: 0,
node: AnimationNodeIndex::new(0),
blend_node: None,
looping: true,
duration: 1.0,
move_speed: 0.0,
@@ -121,7 +121,7 @@ pub fn playing_seq(player: &AnimationPlayer, anims: &ModelAnimations) -> Option<
player
.playing_animations()
.filter_map(|(node, active)| {
let clip = anims.clips.iter().find(|c| c.node == *node)?;
let clip = anims.clip_at(*node)?;
Some((clip.seq_index, active.seek_time(), active.weight()))
})
.max_by(|a, b| a.2.total_cmp(&b.2))
@@ -487,6 +487,7 @@ mod tests {
anim_id,
seq_index: 0,
node: AnimationNodeIndex::new(1),
blend_node: None,
looping: true,
duration: 2.0,
move_speed: 0.0,
@@ -554,6 +555,7 @@ mod tests {
AnimClip {
seq_index,
node: AnimationNodeIndex::new(node),
blend_node: None,
..clip(anim_id)
}
}