Commit graph

5 commits

Author SHA1 Message Date
Erik
5666e05a85 fix(core+app): Phase 6.8 — keep NPCs animated when post-spawn motion update is unmappable
User reported that some NPCs (Pathwarden, Town Crier) didn't breathe.
Diagnostic logging revealed:

1. Both NPCs are correctly registered as animated at CreateObject
   time with the standard 30fps human breath cycle (anim=0x03000001).
2. Immediately after spawn the server sends an UpdateMotion with
   stance=0x0003 cmd=0x0000 for these NPCs.
3. MotionResolver.GetIdleCycle returned NULL for that combination,
   because StyleDefaults didn't have an entry for stance=3.
4. OnLiveMotionUpdated treated the NULL as "switch to a static pose"
   and removed the entity from _animatedEntities.

Two fixes:

A. MotionResolver.ResolveIdleCycleInternal — when stance is set but
   StyleDefaults has no entry for it, fall back to the table's
   DefaultStyle/DefaultSubstate instead of returning null. The
   server-supplied stance was just an unmappable override; the table
   default is the correct "I have no better information" answer.
   Pulled the table-default lookup into a small TryGetTableDefault
   helper so both fallback paths use the same code.

B. OnLiveMotionUpdated — never REMOVE an animated entity. If the
   re-resolved cycle is bad (null, framerate=0, or single-frame),
   leave the existing cycle running so the entity continues to
   breathe with whatever it already had. The defensive "remove on
   re-resolve failure" was the bug — it silently un-registered NPCs
   the moment the server sent any partial motion update.

Together these mean: any NPC that successfully registers as animated
at spawn stays animated, even if the server's subsequent motion
updates are incomplete or use stance values our resolver doesn't
have a mapping for.

Strips the [BREATHE] and [MOTION] diagnostic spew added during the
investigation now that the cause is identified.

220 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:05:23 +02:00
Erik
49c1a9d29e fix(core): resolve AnimData HighFrame=-1 sentinel before filtering cycles
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) <noreply@anthropic.com>
2026-04-11 20:25:22 +02:00
Erik
f0fa067566 feat(core+app): Phase 6.4 — per-frame animation playback (breathing/idle cycles)
Phase 6.1-6.3 resolved the right cycle and rendered its first frame
as a static pose. Phase 6.4 actually walks the cycle over time so
creatures, characters, and props animate their idle motion — the
breathing the user noticed was missing after Phase 6.1.

MotionResolver gains GetIdleCycle() returning IdleCycle(Animation,
LowFrame, HighFrame, Framerate). The existing GetIdleFrame() now
shares a private ResolveIdleCycleInternal helper, so the resolution
algorithm (motion-table override, stance/command priority, fallback)
is identical for both entry points and stays in one place.

WorldEntity.MeshRefs becomes a get/set so the per-frame tick can
swap in fresh per-part transforms without rebuilding the entity.
Static decorations never get touched.

GameWindow keeps a Dictionary<entityId, AnimatedEntity> for entities
whose motion table resolved to a multi-frame, non-zero-framerate
cycle. AnimatedEntity caches a per-part template (gfxObjId +
surfaceOverrides + scale) snapshot taken from the hydration pass so
the tick doesn't redo AnimPartChange/TextureChange resolution every
frame — only the per-part transform matrices are recomputed.

OnRender calls TickAnimations(dt) before Draw. The tick advances each
entity's CurrFrame by dt*Framerate, wraps it inside [LowFrame, HighFrame],
samples the corresponding AnimationFrame, and rebuilds the entity's
MeshRefs by composing scale → quaternion rotate → translate per part
in the same order SetupMesh.Flatten uses, then baking the entity's
ObjScale on top in the same PartTransform * scaleMat order as the
hydration path.

160 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:08:08 +02:00
Erik
120e801ecf feat(net+core): Phase 6.2 — honor server CurrentMotionState for idle pose
CreateObject's MovementData was being skipped past, so the renderer
always fell back to the MotionTable's default style/substate. That's
correct for most NPCs and characters but wrong for entities the
server explicitly puts into a non-default stance — most visibly the
Foundry's Nullified Statue of a Drudge, which the server sends with
a combat stance + Crouch ForwardCommand override and which therefore
rendered as an upright drudge instead of the aggressive crouched
statue you see on the retail client.

CreateObject.TryParse now extracts ServerMotionState (Stance +
optional ForwardCommand) from the inner MovementData. The header=false
layout was confirmed via ACE/.../WorldObject_Networking.cs:326 plus
MovementData.cs::Write and InterpretedMotionState.cs::Write. Only the
two fields the resolver needs are read; remaining InterpretedMotionState
bytes are skipped via the outer length so we don't have to handle
alignment of fields we don't care about.

MotionResolver.GetIdleFrame now takes optional stanceOverride and
commandOverride. Resolution priority is server-stance+command →
server-stance + style-default substate → MotionTable default. If the
composed cycle key doesn't resolve we fall back to the table default
rather than returning null, so a partial server override never makes
the entity worse than Phase 6.1.

160 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:48:33 +02:00
Erik
090d265a4e feat(core+app): Phase 6.1 — resolve idle motion frame from MotionTable
Walks each entity's Setup → MotionTable → Animation chain to get the
per-part frame for its default idle pose, then uses that frame in
SetupMesh.Flatten instead of the static PlacementFrames lookup. For
creatures and characters this should produce the upright "Resting"
pose (e.g. for the Nullified Statue of a Drudge) instead of the
Setup-default crouch.

This is a minimal Phase 6 cut: render the FIRST frame of the IDLE
motion as a static pose. No per-frame interpolation, no walking, no
attack motions, no transitions. Those are larger pieces tracked in
docs/plans/2026-04-11-roadmap.md under Phase 6.

Algorithm ported from references/ACViewer/.../Physics/Animation/
MotionTable.SetDefaultState:

  1. Look up Setup.DefaultMotionTable (0x09XXXXXX). 0 → no motion,
     fall back to PlacementFrames.
  2. MotionTable.StyleDefaults[DefaultStyle] → default substate.
  3. cycleKey = (DefaultStyle << 16) | (substate & 0xFFFFFF)
  4. MotionTable.Cycles[cycleKey] → MotionData.
  5. MotionData.Anims[0].AnimId → Animation dat.
  6. Animation.PartFrames[animData.LowFrame] → AnimationFrame
     containing the per-part transforms for the idle pose.

Added:
  - Core/Meshing/MotionResolver.cs: pure function GetIdleFrame(setup,
    dats) that walks the chain and returns an AnimationFrame or null.
    null is the "no motion data" sentinel and means caller should fall
    back to PlacementFrames.
  - SetupMesh.Flatten now takes an optional AnimationFrame override
    parameter. Pose source priority is:
      override → PlacementFrames[Resting] → PlacementFrames[Default]
    So existing call sites that don't pass an override get the Phase 5d
    Resting-fallback behavior unchanged. Static scenery is unaffected.
  - GameWindow.OnLiveEntitySpawned (live-mode hydrator) calls
    MotionResolver.GetIdleFrame and passes the result to Flatten.

Other Flatten call sites (offline scenery, interior EnvCells, scenery
generator) NOT yet wired — those use static dat hydration where the
entities don't have meaningful motion tables. The user-visible win
from this commit is in the live spawn pipeline only.

Things I'm not certain about and will check via the live run:
  - Whether Animation.PartFrames are in entity-root-relative space
    (matching PlacementFrames) or parent-relative (would need a parent
    walk we don't do). ACViewer's UpdateParts applies frames per-part
    without walking parents, suggesting root-relative — same convention
    as PlacementFrames.
  - Whether the resolver's null fallback is hit for creatures whose
    Setup.DefaultMotionTable happens to be 0 (would silently regress
    to Default placement). Worth checking if drudge looks the same.

Tests: 77 core + 83 net = 160, all green. No new tests yet because
the change is data-driven and best validated end-to-end via the live
run rather than synthetic dat fixtures (which would require fabricating
a complete MotionTable + Animation chain just to test the lookup).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:36:59 +02:00