fix(anim): hold the last frame at a cyclic seam instead of blending into frame 0 (Holtburg windmill flap-back); register AP-233
The Holtburg windmill (Setup 0x020003E5, cycle 0x0300061B) is a 60-frame quarter turn that loops by 4-blade symmetry: frame 59 and frame 0 are the same picture but 88.5 deg apart. BuildBlendedFrame wrapped the cyclic node's next-frame index to frame 0, so the seam slerped 88.5 deg backwards inside one 33 ms interval - the blades visibly flapped back every two seconds. Retail never blends animation frames at all: CPartArray::UpdateParts (0x005190F0) applies get_part_frame(floor(frame_number)), holding every authored frame for its interval and hard-cutting at the wrap. The render-side blend now holds the boundary frame at BOTH ends of a node's window - the same rule the #61 link-tail fix already applied to one-shot nodes - so every seam is retail's cut while interior frames stay smooth (the owner's choice over dropping the blend, 2026-08-23). Register row AP-233 records the blend as the deviation it has been since the R1-P5 cutover. Test: Advance_CyclicSeamHoldsLastFrameInsteadOfBlendingIntoFrame0 (fails on the previous code at ~45 deg, passes held at 90 deg). Core 4,696/0, App 6,068/0 hermetic (Release). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2c2d57b25a
commit
51a5fe99ef
3 changed files with 82 additions and 12 deletions
File diff suppressed because one or more lines are too long
|
|
@ -695,27 +695,37 @@ public sealed class AnimationSequencer
|
|||
int frameIdx = (int)Math.Floor(_core.FrameNumber);
|
||||
frameIdx = Math.Clamp(frameIdx, rangeLo, rangeHi);
|
||||
|
||||
// Next frame for interpolation: step in the playback direction.
|
||||
// Wrap to the opposite end ONLY when this node IS the cyclic tail
|
||||
// (curr == first_cyclic). For one-shot nodes (link transitions,
|
||||
// action overlays), hold the boundary frame instead — otherwise the
|
||||
// fractional tail of the anim blends frame[end] with frame[0],
|
||||
// producing a brief flash through the anim's starting pose at the
|
||||
// link→cycle boundary (issue #61: door swing-open flap; run-stop
|
||||
// twitch). This is render-side clamping, not a CSequence semantic.
|
||||
bool nodeIsCyclic = ReferenceEquals(_core.CurrAnim, _core.FirstCyclic);
|
||||
// Next frame for interpolation: step in the playback direction and
|
||||
// HOLD the boundary frame at either end of the node's window — never
|
||||
// blend across a seam. Retail itself never blends at all:
|
||||
// CPartArray::UpdateParts (0x005190F0) applies
|
||||
// CSequence::get_curr_animframe (0x00524970) = get_part_frame(
|
||||
// floor(frame_number)), so every frame is held for its whole
|
||||
// interval and the wrap from the last frame to the first is a hard
|
||||
// cut. Holding the boundary frame here reproduces exactly that cut
|
||||
// at the seam while keeping the smoothing between interior frames
|
||||
// (register row AP-233).
|
||||
//
|
||||
// Two symptoms drove this: issue #61 (a link's fractional tail
|
||||
// blended into the link's frame 0 — door swing-open flap, run-stop
|
||||
// twitch) and the Holtburg windmill (2026-08-23): its cycle
|
||||
// 0x0300061B is a 60-frame QUARTER turn that relies on 4-blade
|
||||
// symmetry to loop, so blending frame 59 → frame 0 was an 88.5°
|
||||
// sweep backwards inside one 33 ms interval — the blades visibly
|
||||
// flapped back every two seconds. Retail's hard cut is invisible
|
||||
// there because the two poses are the same picture.
|
||||
int nextIdx;
|
||||
if (curr.Framerate >= 0f)
|
||||
{
|
||||
nextIdx = frameIdx + 1;
|
||||
if (nextIdx > rangeHi || nextIdx >= numPartFrames)
|
||||
nextIdx = nodeIsCyclic ? rangeLo : frameIdx;
|
||||
nextIdx = frameIdx;
|
||||
}
|
||||
else
|
||||
{
|
||||
nextIdx = frameIdx - 1;
|
||||
if (nextIdx < rangeLo)
|
||||
nextIdx = nodeIsCyclic ? rangeHi : frameIdx;
|
||||
nextIdx = frameIdx;
|
||||
}
|
||||
|
||||
// Fractional blend weight (always in [0, 1]).
|
||||
|
|
|
|||
|
|
@ -538,6 +538,65 @@ public sealed class AnimationSequencerTests
|
|||
+ "(would be ~5 if nextIdx still wrapped to link frame 0)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_CyclicSeamHoldsLastFrameInsteadOfBlendingIntoFrame0()
|
||||
{
|
||||
// Holtburg windmill (2026-08-23): cycle 0x0300061B is a 60-frame
|
||||
// quarter turn (1.5 deg per frame about the blade axis) that loops
|
||||
// by 4-blade symmetry — frame 59 and frame 0 are the same picture
|
||||
// but 88.5 deg apart numerically. Blending frame 59 → frame 0 at the
|
||||
// cyclic seam swept the blades BACKWARDS 88.5 deg inside one 33 ms
|
||||
// interval. Retail never blends (CPartArray::UpdateParts applies
|
||||
// get_part_frame(floor(frame))), so the seam is a hard cut; our
|
||||
// render-side blend must hold the last frame there exactly like the
|
||||
// #61 link tail does. Modelled with 4 frames about Y: 0, 30, 60, 90
|
||||
// deg — the fractional tail past frame 3 must stay at 90 deg, not
|
||||
// blend toward 0 deg.
|
||||
const uint Style = 0x003Du;
|
||||
const uint Motion = 0x0003u;
|
||||
const uint AnimId = 0x03000002u;
|
||||
|
||||
var anim = new Animation();
|
||||
for (int f = 0; f < 4; f++)
|
||||
{
|
||||
var pf = new AnimationFrame(1);
|
||||
pf.Frames.Add(new Frame
|
||||
{
|
||||
Origin = Vector3.Zero,
|
||||
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, MathF.PI / 6f * f),
|
||||
});
|
||||
anim.PartFrames.Add(pf);
|
||||
}
|
||||
var setup = Fixtures.MakeSetup(1);
|
||||
var mt = new MotionTable();
|
||||
mt.DefaultStyle = (DRWMotionCommand)Style;
|
||||
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
|
||||
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
|
||||
mt.Cycles[cycleKey] = new MotionData();
|
||||
QualifiedDataId<Animation> qid = AnimId;
|
||||
mt.Cycles[cycleKey].Anims.Add(new AnimData
|
||||
{
|
||||
AnimId = qid,
|
||||
LowFrame = 0,
|
||||
HighFrame = 3,
|
||||
Framerate = 10f,
|
||||
});
|
||||
var loader = new FakeLoader();
|
||||
loader.Register(AnimId, anim);
|
||||
var seq = new AnimationSequencer(setup, mt, loader);
|
||||
seq.SetCycle(Style, Motion);
|
||||
|
||||
// Frame position 3.5 at 10 fps: the fractional tail of the LAST frame.
|
||||
seq.Advance(0.35f);
|
||||
var transforms = seq.Advance(0.0001f);
|
||||
Assert.InRange(GetFramePosition(seq), 3.4, 3.7);
|
||||
|
||||
// Angle of the blended orientation about Y.
|
||||
Quaternion q = transforms[0].Orientation;
|
||||
float angleDeg = 2f * MathF.Atan2(MathF.Abs(q.Y), MathF.Abs(q.W)) * 180f / MathF.PI;
|
||||
Assert.InRange(angleDeg, 89f, 91f); // held at frame 3; a seam blend would give ~45 deg
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetCycle_StopFromWalkBackward_FallsBackToWalkForwardStopLink()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue