HumankindAssetFramework

Animated Runtime — how an injected model is driven, frame by frame

The runtime companion to Factory-Manual §16 (which covers converting a model into Amplitude’s dialect). This documents what happens after the bake: how the game’s animation system consumes our Skeleton + ClipCollection and how the plugin steers it. Everything here is grounded in decompiled, behavior-verified engine code (Amplitude.Mercury.Animation.dll — editor bake AND game runtime; decompile with ilspycmd -t <TypeName> <dll>), plus the litmus-rig verification of the composed result.


1. The cast

Baked assets (per model, in the mod bundle):

Runtime managers: AnimationManager (owns the GPU buffers + the compute passes CSAnimateFirstPass / CSAnimateSecondPass, which live in the game’s InstancingAndFx asset bundle) and PawnManager (a PawnEntry per rendered pawn: SkeletonId, ObjectSpace TRS, Pose0..Pose8 blend slots, the BoneRotation0..3 procedural layer).

The plugin (UniversalInject): a Harmony postfix at registration time (AnimationLoad) and one on PawnManager.AddPawnEntry — the per-frame pose write.

2. Registration (once per session, at AnimationLoad)

  1. The plugin loads each registry model’s ClipCollection by GUID and appends it to the private loadedAnimationClipCollections array before Apply() runs — Apply’s builder then bakes our clip into the GPU buffers exactly like vanilla content.
  2. Apply() flattens every collection:
    • gpuAnimationEntryBuffer[animBase + boneIndex] — one GPUAnimationEntry per bone per clip (format, frame count, bbox, StartPoseData). A clip’s runtime animation id IS its base index into this array — which is why a clip must carry exactly BonesCount curve entries in skeleton bone order (the bake guarantees it).
    • gpuSkeletonBoneEntiesBuffer — per bone: Local, InverseBindPose, globalized ParentIndex, Depth.
  3. The plugin resolves our clip’s id via GetAnimationId(clipGuid) and captures GetAnimationDuration(id) — needed because pose time is NORMALIZED (§3).
  4. Each skeleton’s runtime SkeletonId (its GPU slot, assigned during Apply) is captured for the pose hook.

3. The per-frame drive (the pose hook)

Every frame the game writes each pawn’s PawnEntry; our postfix rewrites it for injected models:

3b. Runtime cost — why the per-frame drive stays cheap

The chain is deliberately structured so per-frame CPU work is small and flat, and everything expensive is throttled or one-off. There is no managed per-frame per-bone loop — a common wrong assumption about runtime pose injection.

The one scaling lever — only if a stutter is ever measured at very high animated-pawn counts — is the reflection funnel (GetMember / SetMember, a (Type, name)-keyed MemberInfo cache hit once per field per pawn — a small constant, not per bone, and not a per-frame clip re-resolve). Swapping those cached lookups for compiled delegates would shave it. It is not a current bottleneck and should not be optimized speculatively.

4. The pose math (decompiled — what actually gets computed)

Per bone, per pose slot (ApplyPoseGetPoseTRS):

  1. entry = gpuAnimationEntryBuffer[animationId + boneIndex]; frame position f = (FrameCount-1) * Repeat(Time,1); the two neighboring frames are decoded, then lerped (translation/scale) and fast-slerped (rotation).
  2. Decode by EncodingFormat (all channels 16-bit quantized):
    • Rotation (the target format — bbox all zero): quaternions only, pair-packed (2 frames per 3 uints; oct-encoded direction + a sqrt(1-w) word); translation is forced to zero — the bone sits exactly at its rest offset.
    • RotationTranslation: 3 uints/frame — quat in the low 16 bits, translation in the high 16 bits, normalized into the bone’s BboxMin..BboxMax.
    • RotationTranslationScale: + a uniform-scale word. Fixe: a single static frame.
    • The bake picks per bone: translation range within ±0.01 (MinTranslationToBeEncoded) of the rest ⇒ rotation-only.
  3. local = TRS.Mul(BoneInfos.Local, decodedPose) — pose data is stored relative to the rest (the bake sampled Local.Inverse * animatorLocal through a real Unity Animator on the skeleton prefab), so this reconstructs the animated local transform.
  4. Weighted accumulation across the pose slots (quaternion hemisphere-corrected), normalized by sumWeight; then the BoneRotation layer multiplies in.
  5. Hierarchy composition (GetBoneTRS): walk the ParentIndex chain multiplying locals — bounded by MaxBoneDepth = 15 — then apply ObjectSpace. Skinning uses InverseBindPose against the composed world.

The contracts that fall out of this math (and that §16’s conversion enforces):

4b. State-driven playback facts (Phase 2, 2026-07-19 — decompiled + experimentally proven)

5. Multi-instance & lifecycle notes

6. Verifying the whole chain

6. Per-instance phase (animPhaseSpread) — don’t let a unit move as one body

Every pawn of a model is fed the same Pose0.Time (Time.time / dur), so a multi-pawn unit animates in perfect lockstep: twelve canoes rocking as a single rigid raft, eight monsters swinging their heads in unison. Uncanny, and it reads as one object rather than a group.

animPhaseSpread offsets each pawn by a share of the clip. Default 0.5 (half the clip) — enough to desynchronise convincingly while the unit still reads as one group; 1 spreads over the whole clip; 0 restores lockstep. Animation Lab ▸ Per-instance offset. RUNTIME-ONLY: Save (no bake) + relaunch.

Applies to looping poses only — the single-clip loop and the state-driven idle/move/combat-idle. Deploy-on-stop and fire-once are measured from the moment the unit stopped or fired; shifting them would start the clip part-way through its own one-shot (a gun snapping to half-deployed), so they keep their trigger’s clock.

Identity is by POSITION, not array slot. The pawn entry carries no stable per-instance id — only poses, bone rotations, ObjectSpace and the descriptor id. The first implementation seeded the phase from the pawn’s slot in the entries array, which looked right until the camera moved: changing zoom swaps LODs, the engine re-adds every pawn, the slots come back in a different order, and each pawn inherits a different phase — a hard jump mid-cycle on every zoom. A nearest-match tracker keyed on world position survives the rebuild (same position → same track) and follows a pawn as it moves. Match radius 0.75u: under formation spacing (a wedge’s canoes sit ~1.5–2u apart), far over per-frame travel. A track already claimed this frame is skipped so two close pawns can’t collapse onto one phase; tracks unseen for 5s are pruned.

The engine’s own CoordinationValues.AnimationDelay (on the PresentationUnitDefinition) cannot do this job for injected models: we overwrite Pose0.Time every frame, discarding whatever the engine computed.

Trap: the field is Animation-Lab-owned. Editing it in the registry by hand is futile while a Factory/Lab window holds the entry — its in-memory copy is written back on Save/Bake. Set it in the Lab. (ModelFactoryWindow’s rebase list carries it for the same reason keepTranslations is there.)

7. The wrong-skeleton net, and why it must be armed BEFORE the first pawn

OnPawnAdded matches a pawn to one of our entries by our baked skeleton id, and falls back to matching by descriptor id — that fallback is the safety net for the pawn the game spawns on the donor skeleton (a unit’s later instances, and anything rebuilt mid-session). Without it, such a pawn keeps the donor rig: its weights address the wrong bones and the geometry is flung into long spikes.

The trap (fixed 2026-07-31): descId used to be learned only from a pawn that had already arrived on our skeleton — one-directional. If the first pawns of a model appeared before injection had matched anything, nothing was learned, the net stayed disarmed for the whole session, and every pawn of that model kept the donor rig.

Symptoms, all of which point here:

The fix: the AddOn exposes PawnDefinitionId before any pawn exists, and it is the same id space OnPawnAdded reads as ctx.descId (the Resize path keys unitScaleByDesc with it). Seed descId at injection time and the net is armed from the first frame regardless of who wins the race. Confirmed by one line per animated model:

[Uni] '<model>' descriptor seeded at injection: desc=NN (wrong-skeleton net armed before any pawn spawns)

If an entry ever reaches a pawn spawn still without a descriptor, the plugin now warns once naming the model — a should-be-unreachable state that means the seed failed.

respawnAfterLoad (re-run UpdatePawns ~3s post-load) remains available and is a workaround for this class, at the cost of a flicker on every load. With the seed in place it should not be needed.

Widened 2026-07-31 (c6154a6): the rescue was additionally gated on Hooked (animated-or-freeze), so a repointed model with no pose behaviour had no rescue path at all — eight shipped STATIC models (cruiser, hovercraft, helicopters, submarine, organ/volley gun). Which RIG a pawn binds to is independent of whether we drive its pose, so the gate is now Rescuable(x) = x.skeletonId >= 0 && x.repointed, with the pose decision left at the dispatch (a third branch forces the skeleton and persists the entry without touching the pose). The per-pawn early-out gained anyRescuable for the same reason — a purely static pack has both pose flags false and used to return before reaching the rescue. See Audit-2026-07-31 finding 1.