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:
Erik 2026-08-23 10:36:47 +02:00
parent 2c2d57b25a
commit 51a5fe99ef
3 changed files with 82 additions and 12 deletions

File diff suppressed because one or more lines are too long

View file

@ -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]).

View file

@ -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()
{