-
Notifications
You must be signed in to change notification settings - Fork 0
Animated Runtime
Auto-generated from the repo docs by
tools/sync_wiki.sh— edit the source Markdown in the repo, not this wiki page.
Which animation page do I want?
- Can HAF import my model, and how do I bake it? → Animated-Models.md (start here)
- It baked, but it looks wrong / broke → Animation-Pitfalls.md — the failure catalogue
- How the engine drives the pose frame by frame (extending the plugin) → Animated-Runtime.md
- Fly the donor's own clip on your rig → Donor-Clip-Flight.md · turn, aim, fire → Turn-Ease.md
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.
Baked assets (per model, in the mod bundle):
-
<name>_Skeleton.asset—BoneInfos[]: per boneName,BindPose(inverse-bind TRS),Local(parent-relative rest, derivedparentBind * bindInverse),ParentIndex,Depth; plusskinnedMeshInfos(the FxMesh geometry with per-vertex bone indices, baked byImportMeshes). -
<name>_Clips.asset—ClipEntry[](clip guid,Duration,FrameCount,BonesCount,CurveIndex) andClipCurveEntry[](per bone:EncodingFormat,BboxMin/Max,PoseDataIndex). -
<name>_ClipsPoseData.bytes— the quantized pose stream (see §4).
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.
AnimationLoadfires once per PROCESS, not per save-load (proven 2026-08-16: a two-save load-order repro loggedEnsureRegisteredexactly once, even across a main-menu round trip). So the plugin cannot rely on it to re-arm when a second game session starts in the same app run — the game rebuilds itsAnimationManager(fresh skeleton/mesh slots), but our registration would stay bound to the first session's manager. Left unfixed, an animated custom unit skins against stale slots and tears (a save-load), or a unit built in a New Game after a load repoints onto stale registration.The re-arm therefore also hangs off the seams that DO fire per session:
PawnManager.Load— the universal seam: fires on every session (save-load, in-session reload, and a New Game). This is what closes the new-game gap. (Piggybacks theHk_AnimatedBonePoolHeadroomhook.)Sandbox.Load— save-load only; used additionally so the district axis gets its reset before the district presentation hooks bind (see §5 and the district docs). Since 2026-08-21 this hook only flags the reset (districtResetPending): the hook may be off the main thread and the resetClear()s collections the per-frame polls read, soConsumePendingDistrictResetperforms it on the main thread — at the entry of every district Harmony handler (the first district to build in the new world resets before binding) and on theUpdatetick. Ordering preserved, cross-thread mutation gone.All triggers just set a flag (
RequestReloadRearm; the hooks may run off the main thread) that is consumed on the next main-threadUpdate(ConsumePendingReloadRearm → RearmModelRegistration), which unlatchesregisteredand drops session-scoped state;RepointMatchthen lazily re-registers into the new manager as each unit's addon loads. Multiple triggers per load coalesce into one consume. See §5.
- The plugin loads each registry model's ClipCollection by GUID and appends it to the private
loadedAnimationClipCollectionsarray beforeApply()runs — Apply's builder then bakes our clip into the GPU buffers exactly like vanilla content. -
Apply()flattens every collection:-
gpuAnimationEntryBuffer[animBase + boneIndex]— oneGPUAnimationEntryper 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 exactlyBonesCountcurve entries in skeleton bone order (the bake guarantees it). -
gpuSkeletonBoneEntiesBuffer— per bone:Local,InverseBindPose, globalizedParentIndex,Depth.
-
- The plugin resolves our clip's id via
GetAnimationId(clipGuid)and capturesGetAnimationDuration(id)— needed because pose time is NORMALIZED (§3). - Each skeleton's runtime
SkeletonId(its GPU slot, assigned during Apply) is captured for the pose hook.
Every frame the game writes each pawn's PawnEntry; our postfix rewrites it for injected models:
-
Match & force: pawns are matched by PawnDescriptorId (learned from the first correct pawn — NOT by
SkeletonId, which differs across instances of the same unit type), and
entry.SkeletonIdis forced to ours. -
Pose0 = our clip:
AnimationId = animId,Weight = 1,Time = seconds / clipDuration— the sampler computesframe = (FrameCount-1) * Repeat(Time, 1), so feeding raw seconds playsduration×too fast. Pose1..8 weights are zeroed — but never ALL poses: the blender divides bysumWeight, and an all-zero pawn isNaN= invisible. -
Which Time comes from the model's behavior (
ComputePoseTime): continuous loop (Time.time/dur), fire-once (rest at 0; one 0→1 pass, per-instance-matched to the nearest active fire by render position), or deploy-on-stop (a per-unit ramped hold, driven byProcessDeployState's settle-immune render-position-delta movement signal — deliberately notIsAnyPawnMoving, whose wait-to-idle settle reads as "moving"). -
BoneRotation layer policy: the game turns pawns through
BoneRotation0..3(each slot:SkeletonBoneIndex,AxisIndex,Angle), and vehicle donors also stream wheel-spin into it. The plugin clears the layer only for artillery models (fire/deploy behaviors — the game's aim would twist the barrel) and zeroes junk axis-0 slots elsewhere. Note: a slot whoseSkeletonBoneIndexmatches no bone'sLocalIndex(e.g. the0xFFFFFFFFsentinel) is a no-op —ApplyBoneRotationfires only on an exact index match. -
Runtime extras: the registry
positionis applied in the pawn's frame (planar part rotated byObjectSpace.Rotationeach frame; z = world-up altitude), andscalemultipliesObjectSpace.Scale.
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 per-frame pose hook is light. Per animated pawn it reads already-computed state and writes a handful of
PawnEntryfields (Pose0AnimationId/Weight/Time, occasionally anObjectSpaceorBoneRotationslot) through cached reflection — no iteration over bones. The actual bone skinning is done by the engine's GPU sampler, which is instanced: 1 or 500 pawns of a model cost the same on the GPU. -
The expensive work is throttled, not per-frame. Movement/state detection (
ProcessAnimStates), deploy + recoil ramps (ProcessDeployState), formation/respawn scans, and movement audio each run behind a frame-modulo gate — ~10–20×/s, not every frame (Combat.cs:stateFrame % 3/deployFrame % 3≈ 20×/s; era re-scale polls every 2 s). The per-frame hook only consumes their published result. -
Registration is once per load (§2): clips and skeletons resolve to cached
AnimationIds atAnimationLoad(and re-resolve on each save-reload via theSandbox.Loadre-arm), never per frame — so there are no string-keyed clip lookups in the hot path. - Cost scales by model type, not instance. The GPU mesh buffer is the real ceiling, spent per distinct model type — see Vertex-Budget. A hundred instances of one animated model is cheap; a hundred distinct animated types would exhaust the buffer long before CPU mattered.
-
Fail-soft and presentation-only. Every injection path — repoint, register, clip reload, and the per-frame pose
hook — is wrapped in its own try/catch that isolates the failure, logs once, and increments
InjectionErrors(surfaced by the F8 smoke test). A mismatch (stale skeleton, unresolved clip, member rename after a game update) disables only that one pawn/model, never the game loop. A bone-hierarchy mismatch degrades even more gently: aBoneRotationslot whoseSkeletonBoneIndexmatches no bone is a no-op, not an exception (§3) — a missing bone is skipped, not thrown. And because the system writes only presentation state (pawn entries, poses,ObjectSpace, atlases) and never the simulation model or serialized save data, a failure does not corrupt saves or alter the simulation — those code paths are never touched. (It's still a runtime patch, so a plugin bug can throw or, rarely, crash the process; the per-path isolation keeps that rare and localized rather than a save-integrity risk.)
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.
Per bone, per pose slot (ApplyPose → GetPoseTRS):
-
entry = gpuAnimationEntryBuffer[animationId + boneIndex]; frame positionf = (FrameCount-1) * Repeat(Time,1); the two neighboring frames are decoded, then lerped (translation/scale) and fast-slerped (rotation). -
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 + asqrt(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'sBboxMin..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.
-
-
local = TRS.Mul(BoneInfos.Local, decodedPose)— pose data is stored relative to the rest (the bake sampledLocal.Inverse * animatorLocalthrough a real Unity Animator on the skeleton prefab), so this reconstructs the animated local transform. - Weighted accumulation across the pose slots (quaternion hemisphere-corrected), normalized by
sumWeight; then the BoneRotation layer multiplies in. -
Hierarchy composition (
GetBoneTRS): walk theParentIndexchain multiplying locals — bounded byMaxBoneDepth = 15— then applyObjectSpace. Skinning usesInverseBindPoseagainst the composed world.
The contracts that fall out of this math (and that §16's conversion enforces):
-
Rotation-only clips — translations are dropped (
Rotationformat) or bbox-quantized; a rig whose animation moves bones (location keys) cannot survive as-is. -
Uniform scale ≈ 1 everywhere —
TRS.Scaleis a single float; a file-scale "sandwich" (0.01 bindposes + ×100 root) degrades composition, worst on deep chains. -
Parents must sort before children — the Skeleton bake sorts bones alphabetically
(
BuildBoneEntry.Compare: roots first, thenstring.Compare); consumers assume topological array order. Hence the conversion'sb###_rename. - Depth ≤ 15 — no-op root bones are collapsed to preserve budget.
-
The full pawn array uploads to the GPU EVERY FRAME:
PawnManager.DoComputation()runs per evolve pass and does a whole-arraypawnEntriesBuffer.SetData(pawnEntries). There is no id latching — every field the pose hook writes (AnimationId included) reaches the GPU each frame, so per-frameAnimationIdswitching on Pose0 is safe and is how the state machine (idle / run / after-move) is implemented. -
The secondary pose slots (Pose1/Pose2) misbehave in the GPU pass: driving states by weight-switching constant-id
slots rendered the pawn invisible while moving (most plausibly a garbage id sampling an arbitrary buffer entry —
a scale-0 entry collapses the mesh to a point). The C# mirror (
GetLocalBoneTRS/ApplyPose) is slot-agnostic, so the divergence lives in the compute shader; the state machine simply avoids the secondary slots. -
Rotation-format clip data cannot explode a mesh:
GetPoseTRSforces translation to zero and scale to 1 forRotation-encoded curves. This yields a sharp diagnostic dichotomy: a pawn rendering invisible ⇒ a wrong animation id (sampling foreign entries); a pawn rendering frozen ⇒ constant clip data (see the frozen-runner bug in Factory-Manual §16: Blender's bone rename syncs fcurve paths only for the ASSIGNED action, so dormant state-role clips exported as statues until patched explicitly). -
Byte-level clip forensics: the
_Clips*PoseData.byteslayout is per-curve blocks (Rotation format:ceil(frames/2) × 3uints per curve). A healthy clip shows a MIX of varying and constant curve blocks (animated vs still bones); ALL-constant blocks = a frozen bake. This check runs from PowerShell in seconds and settled in minutes what in-game observation could not. -
The ATTACK trigger (fifth state role): a Harmony postfix on
Amplitude.Mercury.Presentation.PawnRangedFightSequence.InitializeCommon— all five constructors funnel through it (battle volleys, unit-target shots, district bombards), and the sequence is built on the presentation/main thread, so the shooter'sTransformis read directly into the entry's fire windows (no sim-thread GUID queue like the artillery hook needs). The window spansattackRepeats × clipDurationand the pose Time is fed UNCLAMPED — the sampler'sRepeat(Time,1)wraps each pass, replaying the clip back-to-back. Trap: the plugin registers hooks from an EXPLICIT list inPlugin.cs(per-hook isolation); a new[HarmonyPatch]class that isn't added there fails 100% silently — no TargetMethod log at all. -
Battles spawn a SECOND PresentationUnit per combatant on its combat tile
(
Presentation.PresentationBattleReportController.Battles → AllUnits → PresentationUnit), while the map army's own unit stays at the STACK position — 27.7u away in the field log, far outside the 4u sample-match radius. The state poll walks BOTH collections (battle samples alwayscombat=true, and the two bookkeeping streams are key-salted: same sim GUID, two objects at different positions would ping-pong the movement detector into a permanent "moving"). The COMBAT-IDLE state readsPresentationArmy.IsLockedByBattleon the map walk. -
Single-frame stance clips (
CombatIdle1, range 0..0) are auto-padded to 2 identical frames by the conversion rebake — Unity's FBX importer can drop a zero-length animation whole. Amplitude then bakes FrameCount 1, which pins the GPU sampler to frame 0 at any Time: a held pose, exactly what a stance wants.
- Same-unit instances get different SkeletonIds — hence descriptor keying + SkeletonId forcing (a second instance left on a vanilla skeleton renders mis-skinned).
-
Save-load spawn race (models borrowing a donor's animated sub-part, e.g. a rotor): fixed by re-running the
game's own
PresentationUnit.UpdatePawnsshortly after load (respawnAfterLoad, per model). -
Per-session re-arm (2026-08-16):
AnimationLoadfires only ONCE per process (even across a main-menu round trip), so a second game session doesn't re-arm through it. Registration is therefore re-armed onPawnManager.Load(the universal per-session seam — save-load, reload, and New Game) and additionally onSandbox.Load(so the district axis resets before its hooks bind; main-thread-consumed since 2026-08-21) — see §2. Skipping this left our skeletons bound to the first session'sAnimationManager: an animated unit in both saves (the organ gun) tore on the second save-load, and a New Game after a load repointed units onto stale registration. The re-arm reset also re-runs the whole model-axis session cleanup (audio sources, deploy/state maps, textures), so other second-session glitches clear with it. (Minor known cost: each re-arm re-registers our skeletons at fresh manager slots without unregistering the old ones, so the skeleton list grows ~21/session — benign within buffer headroom, same as the long-standing save-load behavior.) -
Never
DestroyaLoadAsset'd skin. The re-arm cleanup destroys textures the plugin creates (LoadSkinPng/BuildAdjustedAtlas, tracked byModelEntry.texOwned) but never the raw bundle atlas fromLoadAtlas(AssetDatabase.LoadAsset<Texture2D>) — that is a shared game asset, and destroying it makes the nextLoadAssetreturnnull, so the model loses its skin and falls back to the donor look (the organ gun rendered red for exactly this reason once the re-arm began running every load). - A corrupted skeleton state can disrupt more than the pose: while the soldier's rig was broken, the unit's projectile visual also vanished (attack sim + audio unaffected); it returned with the clean rig.
-
Litmus rig (
Tools/make_litmus.py): a 12-deep chain of colored cubes through the full pipeline — renders as a straight chain in-game when everything above holds. The fastest "is it the pipeline or the model?" answer: one launch. -
Baked-asset greps (plain YAML):
<name>_Skeleton.asset— everyScale:must be 1 and every bone'sParentIndexsmaller than its own index;<name>_Clips.asset—EncodingFormat: 1with zero bboxes on every bone is the healthy rotation-only profile. -
Plugin logs:
[Uni]registration lines (clip injected, animId + duration), the pose-hook one-shot, and the temporary[Uni][facing]dump (ObjectSpace rotation + all BoneRotation slots, 3s period). -
Decompile refresh: editor-bake code = the SDK's
Amplitude.Mercury.Animation.dll(Unity project, AnyCPU plugins folder); runtime code = the same-named DLL inHumankind_Data/Managed.ilspycmd -t <type>suffices. The compute shaders themselves live in theInstancingAndFxbundle (not extracted — the C# mirrorsGetBoneTRS/ApplyPosehave matched observed behavior everywhere tested, litmus included).
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.)
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:
- One save reproduces it on every load while others never do — the load order is what differs, not the data. Nothing corrupt is stored: saves hold that the units exist, pawns are rebuilt from scratch each load.
- Zoom can trigger it — an LOD swap re-creates pawns (the same fact that forced the per-instance phase to be keyed by position rather than array slot, §6).
- Re-summoning the units clears it — mid-session spawns happen long after injection settles.
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.
Get started
- Getting Started
- Installation
- Troubleshooting
- Authoring State and Deployment
- Mod Editor version.xml Recovery
- Building
- Backup
Author models and behavior
- Editor Tools
- Factory Manual
- Vehicle Lab Quickstart
- Animated Models
- Animation Pitfalls
- Textures
- Unit Size
- Unit Combat Behavior
- Formations
- Pawn Props
- Projectiles
- Game Sound Lab
- Firing on Attack
- Turn Ease
- Facing Persistence
- Donor Clip Flight
Districts and wonders
Ship and operate
Internals and project