From 49c1a9d29e32e8b2deb2973bd1639ace897effa1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 20:25:22 +0200 Subject: [PATCH] fix(core): resolve AnimData HighFrame=-1 sentinel before filtering cycles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostic spawn dump revealed the player character arrives with `low=0 high=-1 framerate=30.00 partFrames=33`. The -1 is ACViewer's "play the whole animation" sentinel (see references/ACViewer/.../Physics/Animation/AnimSequenceNode.cs:96-113 set_animation_id). My Phase 6.1 code used the raw int values, so the downstream registration filter evaluated `HighFrame(-1) > LowFrame(0)` as false and threw away every animated entity whose AnimData used the sentinel — which, from the live dump, appears to be basically all of them. MotionResolver.GetIdleCycle now does the same four-step clamp ACViewer does: -1 HighFrame → NumFrames-1, clamp LowFrame and HighFrame to NumFrames-1, and collapse to LowFrame if LowFrame > HighFrame. The IdleCycle carried up to GameWindow is always in terms of real frame indices the playback loop can step through. Static poses (framerate==0 or single frame after resolution) still skip registration correctly. 168 tests green. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 17 +++++++++++ src/AcDream.Core/Meshing/MotionResolver.cs | 34 +++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 351bf9b..b211b81 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -946,6 +946,23 @@ public sealed class GameWindow : IDisposable else if (idleCycle.Animation.PartFrames.Count <= 1) _liveAnimRejectPartFrames++; + // Per-entity dump for the first N spawns so we can see actual + // AnimData values for live creatures and compare against retail. + if (_liveSpawnReceived <= 30) + { + if (idleCycle is not null) + Console.WriteLine($"live: SPAWN[{_liveSpawnReceived}] name='{spawn.Name ?? "?"}' " + + $"setup=0x{spawn.SetupTableId:X8} mt=0x{(spawn.MotionTableId ?? 0):X8} " + + $"cycle: anim=0x{(uint)idleCycle.Animation.Id:X8} " + + $"low={idleCycle.LowFrame} high={idleCycle.HighFrame} " + + $"framerate={idleCycle.Framerate:F2} partFrames={idleCycle.Animation.PartFrames.Count} " + + $"numParts={idleCycle.Animation.NumParts}"); + else + Console.WriteLine($"live: SPAWN[{_liveSpawnReceived}] name='{spawn.Name ?? "?"}' " + + $"setup=0x{spawn.SetupTableId:X8} mt=0x{(spawn.MotionTableId ?? 0):X8} " + + $"cycle=null (no motion table / resolver failed)"); + } + if (idleCycle is not null && idleCycle.Framerate != 0f && idleCycle.HighFrame > idleCycle.LowFrame && idleCycle.Animation.PartFrames.Count > 1) diff --git a/src/AcDream.Core/Meshing/MotionResolver.cs b/src/AcDream.Core/Meshing/MotionResolver.cs index fdd118f..6e7e9b8 100644 --- a/src/AcDream.Core/Meshing/MotionResolver.cs +++ b/src/AcDream.Core/Meshing/MotionResolver.cs @@ -78,7 +78,39 @@ public static class MotionResolver var resolved = ResolveIdleCycleInternal(setup, dats, motionTableIdOverride, stanceOverride, commandOverride); if (resolved is null) return null; var (anim, ad) = resolved.Value; - return new IdleCycle(anim, ad.LowFrame, ad.HighFrame, ad.Framerate); + + // Sentinel resolution — matches ACViewer's AnimSequenceNode.set_animation_id + // (references/ACViewer/.../Physics/Animation/AnimSequenceNode.cs:96-113). + // + // AnimData in the dats encodes a few sentinel values that the raw int + // fields can't represent directly: + // * HighFrame == -1 → "play the whole animation" (use NumFrames - 1) + // * HighFrame > NumFrames - 1 → clamp (defensive for malformed data) + // * LowFrame >= NumFrames → clamp to NumFrames - 1 + // * LowFrame > HighFrame after clamping → collapse to a single frame + // + // Our Phase 6.1 code naively used the raw LowFrame/HighFrame and then + // rejected the cycle if `HighFrame <= LowFrame`, which silently threw + // away every animated creature whose AnimData used the -1 sentinel — + // i.e. almost all of them. Resolving here makes the IdleCycle carry + // the real frame range, so downstream playback/filtering sees a span + // it can advance through. + int numFrames = anim.PartFrames.Count; + int lowFrame = ad.LowFrame; + int highFrame = ad.HighFrame; + + if (highFrame < 0) + highFrame = numFrames - 1; + if (lowFrame >= numFrames) + lowFrame = numFrames - 1; + if (highFrame >= numFrames) + highFrame = numFrames - 1; + if (lowFrame < 0) + lowFrame = 0; + if (lowFrame > highFrame) + highFrame = lowFrame; + + return new IdleCycle(anim, lowFrame, highFrame, ad.Framerate); } public static AnimationFrame? GetIdleFrame(