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

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