-
Notifications
You must be signed in to change notification settings - Fork 0
Animation Pitfalls
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
Every trap in this document was hit for real, cost hours, and is now either fixed in the tooling or has a one-field recipe answer.
THE GHOST ROTOR (2026-08-03, four hours, ~19 relaunches). If a donor's part survives replacing the entire mesh, surviving descriptor zeroing, pawn sweeps, and even degenerating every vertex of every Fx ContentLayer — stop looking for geometry. It may be a VFX billboard: a 2D animated sprite (the gunship's rotor) played by the donor's animator through a Mecanim event, drawn procedurally with shader-generated quads. It has no mesh, no renderer, no pawn, and ignores every geometry lever by definition. The tell: it looks flat/depthless from every angle. The fix:
silenceDonorVfx(all donor Mecanim VFX) — per-name filtering is planned. Related traps from the same hunt: gunship-class donors spawn a pawn SQUADRON the formation override cannot cap (usehideSubPawns), andrespawnAfterLoadleaks a live sub-pawn per attempt (turn it off for own-rotor models). Read this BEFORE debugging an animated model that "looks wrong": the odds are your problem is on this page, and the odds are the first three explanations you'll think of are not the cause. (Case study behind almost every entry: migrating the M114 howitzer from the legacy fire/deploy behaviors to the state-driven machine, 2026-07-19 — a day in which the model file was accused of corruption, three preview renderers were rewritten, and the actual causes turned out to be four engine constraints nobody had written down.)
These are engine-level facts. They are not bugs, they cannot be patched away, and every recipe must respect them.
REVISION 2026-07-25 — Laws 1 and 5 are OUR PIPELINE'S defaults, not engine walls. The caterpillar investigation decompiled the runtime: the clip format has
RotationTranslation(+Scale) encodings, vanilla tank clips use them liberally (tread shuttle bones, gun recoil), andGetPoseTRSzeroes translation ONLY for Rotation-encoded curves. Our bake historically (a) keyed rotations only in the conversion rebake and (b) stripped every location fcurve. Both now have an opt-out: the per-modelkeepTranslationsflag (Animation Lab ▸ "Keep bone translations", conversion path only) keeps genuinely translation-animated bones — verified end-to-end in-game (a translating test bone baked asRotationTranslationand played with CORRECT amplitude; the clean-unit conversion export sidesteps the native-scale trap that motivated the strip). Laws 1/5 below remain the DEFAULT behavior and stay true for every model that doesn't opt in; the re-express-as-rotation recipes remain valid and battle-tested.Worked example — the M114's REAL kickback (2026-07-26, in-game verified):
keepTranslations+ Recoil frames442..530,305..441/2+ Return slow 0 + Slam 0 plays the source's authored fire cycle complete: translation slam, slide home, reload, aiming raise (multi-segment epilogue). Implementation notes that matter: translations are kept ONLY in the attack-role clip (kept elsewhere they double-render pose offsets rotations already cover — the hovering-gun symptom), delta-rebased to zero at the clip's first frame (pure motion), and ×100-compensated on the legacy path (the m→cm sandwich folds 0.01 into bindposes, which Amplitude carries into translation curves while rotations pass scale-free — the exact mechanism behind the original Law-1 evidence). Also fixed en route: the slam-0 sentinel placed the RecoilArm pivot at 1e9, collapsing bone chains via float32 cancellation — the source of every NaN/garbage-import symptom this pipeline ever showed.
The engine's baked clips keep per-bone rotation and discard translation (GetPoseTRS forces
translation 0, scale 1). Any part whose source motion slides plays pivoting about the wrong point in-game.
- Symptom: a part sweeps through/into the model in-game (the M114's trail legs crossed inward) while every preview — Blender, the ▶ picker — looks perfect. Previews play full curves; the game doesn't.
-
Answer: re-express the motion as pure rotation. For deploy-converted models that's one recipe field
(Leg spread scale re-keys
*leg*parts; the hidden far-pivot RecoilArm does it for barrel slides). For other rigs: author the motion rotationally, or accept the drift.
The primary clip defines the skeleton's reference pose; clip data encodes against it. Bake a 1–2-frame stance as the primary and it encodes ~identity — in-game the unit shows the rest pose (usually the travel pose) no matter what the stance was.
-
Symptom: idle shows the travel/fold pose ("it forgot to deploy") although the stance clip previews
correctly everywhere else. The byte tell: the baked
*_ClipsPoseData.bytesrows are near-constant identity. -
Answer: the primary (Idle/reference clip) must be the FULL source motion; the stance goes in
Idle stance (override) — a role clip, encoding real deltas against the full clip's reference (e.g.
deploy[179..180]). This is why the Lab has two idle fields.
The runtime plays every clip at its authored length (24 fps). A 7.5 s authored fold outlasts a one-tile map move — the unit spends the whole trip in the fold's first frames ("it forgets to fold").
-
Answer: the slice speed step:
deploy[179..0/6]= every 6th frame = 6× faster (≈1.25 s). The step always lands exactly on the end frame./180on a 180-frame motion degenerates to a 2-frame near-snap; an empty Pre-movement clip is a true instant snap (which is all the legacy howitzer ever did — its "instant fold" was the absence of a fold animation).
Law 5 — Bone POSITIONS are pinned at the bind pose; only ROTATIONS animate (default — see the revision above)
Stricter than Law 1, discovered by experiment (Arc-R scaling test): the engine keeps every bone at its bind-pose position and plays only orientations through the hierarchy. Consequences:
- a whole-body lurch (the M114's carriage recoiling backward) cannot be expressed in a clip at all;
- the far-pivot "RecoilArm" arc — designed to fake a slide as rotation — renders as a small in-place pitch (the tube tilts by θ instead of swinging R·θ along the arc). The beloved legacy "kickback" was always exactly this modest pitch, read at map zoom; up close it looks like a nose-dip. Judge fire effects at PLAYING distance.
- the practical kick recipe: slam-only recoil range (
445..451on the M114) + the palindrome Return slow (the same frames played backward slowed, gliding home). Whole-body motion would need a runtime ObjectSpace nudge (possible, unbuilt).
LAW 5, RESTATED PRECISELY (2026-08-22, the Vehicle Lab recoil).
keepTranslationsgets a translation curve into the clip, and the engine's ownGetPoseTRSdecodes it correctly — both were measured end-to-end on a barrel that never moved a pixel in game ([AnimDiag] … SLID 0,3 (0,0,-0.001)->(-0.001,-0.013,-0.301), matching the authored frame to three decimals). Decoding is not rendering. A bone's OWN local translation still does not move it; what moves a bone is its ancestors' ROTATION, rebuilt by forward kinematics.deploy_convertrecorded the same finding years of debugging earlier — "sliding cannon2's local left its baked bbox unchanged". So the far-pivot arm is not a workaround for a pipeline that can't keep translations: it is how you move a bone. Diagnostic:[AnimDiag]reportsSLID d (t0->tm)per bone per role, so "the data is there" and "the model moves" can be told apart in one line instead of a day.
Three different things can lie to you independently:
- a custom editor renderer can corrupt the view itself (two hand-rolled BakeMesh draw paths each mangled the M114 — un-mirrored legs, then giant parts — while the data was provably fine; the ▶ picker now renders the real instance through Unity's own pipeline for exactly this reason);
- the ▶ picker is a raw player — it deliberately shows the source's FULL curves (translations included), so on translation-driven parts it will look better than the game (Law 1);
- in-game is the only truth for the final bake — and even there, remember the unit may be standing on a slope, mid-state, or showing a donor-layer artifact.
What finally broke the debugging loop was not a cleverer theory — it was a measurement protocol:
- Sandbox on a donor unit. Never iterate on a shipping entry. Clone the recipe onto a throwaway unit (the SiegeHowitzersCar donor) so every failed bake costs nothing and the working entry stays as reference.
-
Gate against the proven bake. When a "should-be-identical" bake misbehaves, don't stare at the screen —
diff the artifacts:
-
Assets/Resources/<name>_ClipsPoseData.bytes— byte-compare against the proven model's file. Identical prefix + divergence at frame N is a location, not a mystery (a scene-state leak was found at frame ~103 this way). - slim FBXs — sample both in headless Blender and diff evaluated bone poses per frame (the gate scripts:
load both,
frame_setsweep, compare world bone heads + local quats; PASS = 0.0000). The whole Blender stage can be gated without ever opening Unity or the game.
-
- Change ONE delta per bake. The sandbox failures compounded exactly when several knobs moved at once (settings drift + stale caches + new file). The gate tells you which delta mattered.
- Bytes over eyes. Every "the file is corrupt!" accusation this day was wrong; every byte/pose diff was right. Measure chirality, don't eyeball crossed legs; measure pose rows, don't squint at stances.
Three long-standing traps used to ship a broken rig with exit 0 — a silent bad bake you'd only catch in-game.
The bake scripts (Tools/rig_anim.py, Tools/deploy_convert.py) now abort with a clear error instead:
-
RIGANIM ERROR: rest-fold did NOT converge …— the rest-normalization left a bone displaced (the "head off shoulders" class). If you hit this, the source rig's rest pose and frame-0 pose genuinely disagree — check the bind poses / parent scales, don't just re-run. -
RIGANIM ERROR: transform_apply(rotation+scale) failed …— the conversion path couldn't fold object scale into the data (the skeleton would ship ~100× off the mesh). Usually multi-user mesh data or a locked object. -
DEPLOY ERROR: no animated parts to convert …— you ran the deploy conversion on a source with no per-part TRS animation (node/matrix-level animation is unsupported), so there was nothing to rig. Either it's the wrong source, or the parts were all culled.
A green bake (ANIMATED DONE with residual = 0.000000 and, on deploy models, DEPLOY baked N bones for N > 0)
means all three guards passed. They never trip a valid bake — a converged fold reads ~1e-4, ~1000× under the floor.
| Symptom (in-game unless said otherwise) | Cause | Fix |
|---|---|---|
| Part sweeps through the model; previews fine | Law 1 (translation stripped) | re-key as rotation (legScale / RecoilArm) |
| An authored wheel/bone roll sweeps through the air or pivots at the ground; the FBX previews perfectly | a converted rig's clips sit in a different frame than its rest (90° + scale) — see "Authoring INTO a converted rig" | don't author into a converted rig: carry the source's own motion, rebuild on a Vehicle Lab rig, or fix the converter's frame |
| Clip preview stands the model on end while the rest pose stands upright | same — the clip and rest frames disagree | not a preview artifact; measure the bbox both ways |
| Left/right pair (wheels) rotate about points at different heights | non-identity rest rotation on those bones; the skeleton bake mangles the offset | leave bone rests alone; compose the _Skeleton.asset chain and check the pair is symmetric |
| Idle shows travel pose; stance previews fine | Law 2 (stance-as-primary) | full clip as Idle/reference; stance in Idle override |
| "Forgets to fold" — travels deployed | Law 3 (fold outlasts the move) | speed step /6…, or empty Pre-move = instant snap |
| Fold/unfold plays but glacially | Law 3 | speed step |
| Unit invisible | wrong animation id (invisible ⇒ id, frozen ⇒ constant data — the diagnostic dichotomy) | check [Uni] clip log lines resolve |
| Unit frozen mid-pose | constant clip data (hollow bake) | byte-check PoseData; re-slim (cache busters below) |
| Whole unit tiny/huge/floating | FBX unit scale | Fix 100× oversize per model |
| A rotating part (wheel) flings off / orbits in the air while the body sits still; idle fine, only movement flings | the m→cm ×100 export sandwich mangles rotating bones' TRS (and Fix 100× ON re-creates it) | Convert raw rig ON (cancels the ×100) + Fix 100× OFF — see below |
| Baked skin scrambled on one part (wheel) | multi-material albedos missing — the animated path now generates them (glbconv) but a failed extraction falls back to a single atlas, loudly | check [glbconv] Console errors, re-bake |
| Preview (custom window) shows mirrored/giant parts | Law 4 (renderer bug) | render real instances (AddSingleGO), never hand-rolled BakeMesh draws |
| Settings revert / edits ignored after compile | stale window form (survives domain reload) | the Lab re-syncs on reload + ↻ Reload button; registry file is the truth |
| A knob change bakes identical output | stale slim cache — edits made through the Lab re-slim automatically; edits made directly to the registry file behind an open window do NOT (the cache compares form vs file) | ↻ Reload first, or delete anim*/…_anim.fbx
|
| Crossed/wrong limbs in a stance ROLE clip (historical) | role slicing leaked pose values into channels the primary doesn't key | fixed: slicing saves/restores all pose bones (rig_anim.py) |
| Same bake differs run to run (historical) | export-time pose was whatever frame the tool last touched — it becomes the engine's reference | fixed: every export pins the scene to the clip's first frame |
| Whole gun pitches/dives when firing (close zoom) | Law 5: the arc kick IS a pitch; it reads as a dive only nose-to-the-glass | judge at map zoom; tune via recoil range/Return slow |
| Attack plays stale/old animation after a recipe edit (historical) | Blender exits 0 even when the conversion script CRASHES — the baker reused the old converted GLB and recorded the bad args as success | fixed: success = the script's own final marker; reversed recoil ranges rejected with a clear error |
| Aim-layer suspicion during fire | the donor streams runaway angles (5000°+) — but at the INVALID bone index sentinel (0xFFFFFFFF): applied to nothing | exonerated; a throttled [Aim] log in ClearAimLayer shows what streams |
A BoneRotation feature does nothing, and its own log says it applied |
ClearAimLayer zeroes ALL FOUR slots, and it runs LATER in the same frame than the feature writing one. Gun elevation owns slot 3 and was erased every frame — measured as a live 12.8° write on the right bone with no movement at all |
ClearAimLayer skips the slot the elevation owns; any future slot-writing feature must be excluded the same way. clearAimLayer is ticked on most artillery, so this bites exactly the models that want a gun |
| Model collapses flat onto the root, limbs flung (mech) | rig has NO skin weights — parts rigidly bone-parented; the join drops the binding, all verts fall to bone #0 (Unity warns) | fixed: conversion path converts bone-parenting to full-weight vertex groups (rig_anim.py) |
| Skeleton ~100× off the mesh, rigid parts become a "wing" | wrapper empty with non-identity scale (mech: 0.010) survives export; Amplitude reads bind poses without it | fixed: conversion path flattens wrapper empties before transform_apply
|
| Huge stretch spikes in-game, Blender preview fine (detailed rig) | over the 128-bone-INDEX GPU wall — NOT 256 (mech: 222 bones; verts on bone index >127 collapse — see The 128-bone-index GPU wall below) | keep bone indices ≤127: zero-weight leaf bones removed, and the deploy path pair-merges instanced link chains to ≤126 |
Case study: the Ehrhardt armored car (first custom spinning-wheels vehicle, 2026-07-24). Wheels attached and still at idle, but the moment the movement clip rotated them they flew off and orbited through the air while the hull stayed put. The same class of bug as the Combine soldier whose "head rode off his shoulders."
Why: Blender's FBX exporter writes metres→centimetres by scaling the ROOT objects ×100. Unity compensates with 0.01 in every skinned-mesh bindpose + a ×100 root — a sandwich Amplitude's uniform-scale TRS composition mangles on any bone that rotates (a static bone composes fine; a rotating one orbits about a mis-scaled pivot). That is why idle looked perfect (0° rotation) and only movement flung. The ▶ picker and Unity preview also look perfect — they use a clean import, not the sandwiched bake (Law 4).
The cruel part: the size fix and the fling fix pull in opposite directions on the legacy path.
-
Fix 100× oversize (
animUnitFix) ON → correct render size, but keeps the sandwich → wheels fling. - Fix 100× OFF → no sandwich, but the model bakes ~100× too big ("too large to see").
The answer is neither toggle — it's convertRig. The conversion path exports with global_scale=0.01, which
cancels the exporter's ×100 at the source (rig_anim.py ~L691-699): net node scale 1, UnitScaleFactor 1, bind
clusters 1 — the clean profile. So a rotating-bone rig bakes correct and grounded with:
Convert raw rig ON + Fix 100× oversize OFF.
This overturns the old "convertRig OFF for clean purpose-made rigs" guidance: a purpose-made rig with rotating bones (wheels, turret, propeller-on-bone) still needs convertRig ON, unless its source file happens to carry a 0.01 object scale that already cancels the ×100 (the ReconDrone's luck — which is why the drone bakes fine OFF). When in doubt for a rig with any spinning part: convertRig ON.
Placement is automatic on both paths (2026-09-20 — "switching between static and animated should give the same result in both facing and offset"). Every bake, static or animated, centres the model's box on the origin horizontally and drops its lowest point to it. Until this change the animated path did neither — only the opt-in Auto-ground toggle did the vertical half — so an animated bake sat wherever the artist left the model inside its file, and the same model switched between the two paths jumped (the steam frigate: 0.97 × 1.84 game units at size 5). The toggle is gone; there is nothing left to tick.
- It is self-correcting (an already-placed file moves by ~0 → a re-bake can never double-apply it) and
size-proof: the shift is in model space, so the bake's
globalScale = size/longestscales it — change Size and it stays placed. (An earlier attempt used a "wheels-on minus wheels-off" protrusion measure — a fixed lift that FLOATED an already-grounded file; keel→origin replaced it.) Verified on the Ehrhardt: model-space lift 0.671 × size-scale (4/6) ≈ 0.45 in-game, matching the hand-dialed 0.42. - A flyer is grounded too, exactly as the static path has always grounded one, and its flying height is the Position offset Z dial — the same dial, in the same units, on both paths.
-
The catch, once: an entry dialed to compensate for the OLD animated placement now over-corrects, because the
bake has removed what the dial was cancelling. Same one-time cost as the 2026-09-12 rotation unification — and
Tools~/placement_shift.pycomputes the new dials for you, see just below.
Nothing moves until an entry is re-baked — placement is baked in — so this is per-entry and can wait. When you do re-bake, the model centres itself, and whatever your Position offset was cancelling is suddenly an over-correction.
Run the migration tool against your pack:
blender --background --python placement_shift.py -- "…/BepInEx/config/haf_packs/<mod>/pack.json"
It measures every animated entry the way rig_anim.py does — junk cull, the entry's reference-clip pose, the
registry rotation folded into the geometry, then the box and size/longest into game units — and prints the
Position offset each entry needs to look exactly as it does today:
new_x = old_x + move_x new_y = old_y + move_y new_z = old_z − lift (lift only if it was not auto-grounded)
How you know the sign is right: an entry whose dial was pure compensation collapses to ~0. On the shipped ENC
pack four independently hand-dialed entries collapse at once — GatlingGuns −3.70 + 3.34 = −0.36, AntiTankIFV
+0.50 − 0.497 = +0.003, StealthHelicopter (−0.50, +0.50) + (+0.391, −0.525) = (−0.11, −0.03), TOW-Infantry
−0.30 + 0.247 = −0.05. With the sign the other way each would double (the Gatling guns to −7.04 on a size-2.5
model), which nobody would have shipped.
Two things it has to get right, and both are easy to get quietly wrong (PR #72 review found both):
-
Rotate the geometry, then measure. The bake folds the rotation into the data and measures afterwards, so the
box belongs to the rotated cloud. Rotating an unrotated box's centre is a different number for asymmetric
geometry, and the longest axis — hence
size/longest— differs too. On a size-5 prism at 45° that was 1.45 game units of error. At 0/±90/180 the two agree exactly, which is why a pack of axis-aligned entries shows no symptom until someone dials an odd angle. -
Measure the pose the bake actually places — which is often the raw rest. The bake folds the reference pose
into the mesh only on the conversion path and only when the resolved clip carries location curves
(
rig_anim'sif _loc0 and convert_rig), and slicing a clip synthesizes those curves. So a converted rig with a sliced or location-keyed reference is measured posed — reference a struck clip there and the lowest point is a yard under the hull rather than the keel — while a legacy rig keeps its raw mesh and posing it would invent a displacement the bake never applies. The tool mirrors that gate and says which branch each row took.
An entry you have ALREADY re-baked is done. Its dial is in the new frame, and the table would move it a second time. The tool cannot tell which is which — it prints the warning and leaves that to you. It also reports deploy-converted entries as not measured: that recipe synthesizes its own rig and clips, so the honest answer is to re-bake and read the bake's own
RIGANIM placement: world centre …line, which is the ground truth for any row here.
Work the largest change first: if it lands on its hex, the rest follow. If it lands twice as wrong, stop and re-derive the sign rather than hand-fixing a dozen entries.
-
The sky-lift trap now applies to everyone (it used to need Auto-ground ON): the rest skeleton comes from the
Idle/reference clip's frame, so referencing a clip that holds a STRUCK pose — a yard swung under the hull —
grounds the model on that part and lifts the whole ship into the air. Reference the DEPLOYED frame (
Furl[0..0]). The bake says so in the log: "grounding lifted this model by N% of its own height". -
Position offset Z (waterline) — the manual/runtime knob, applied at spawn by the plugin
(
ApplyPositionOffset:ObjectSpace.Translation.y += z), the same one you use for drone/aircraft height. It's in in-game units, so it does NOT scale with Size (a value dialed at Size 4 is wrong at Size 5). Use it for hover height, or as a small fine-tune on top of the automatic placement — Save + relaunch, no re-bake.
The game already computes the aim and streams it as a HEADING angle into a PawnEntry.BoneRotation0-3 slot
({SkeletonBoneIndex, AxisIndex, Angle}) — but on an injected model that slot's SkeletonBoneIndex is the invalid
0xFFFFFFFF sentinel, so it drives nothing. Turretize retargets that slot to your turret bone, so the engine's
own aim math rotates it — no per-frame trig.
-
Setup (runtime, no re-bake): Animation Lab → Turret bone = a bone-name substring (e.g.
Turret; the plugin substring-matches it against the renamedb###_<orig>bones) → Turret aim axis → Save (no bake) + relaunch. Verified on the Ehrhardt armored car (first custom unit with an aiming turret). - THE gotcha — the axis is per-model, and the game's default reads as PITCH, not YAW. The streamed channel is "axis 1 = up in the GAME's frame", but on your turret bone (after the convert rebake folds the rig) that lands on whatever local axis it lands on — so it usually tilts (pitches/rolls) instead of yawing. There are only THREE local axes: try 0 / 1 / 2 in the Turret aim axis dropdown until it turns the way you want. (Ehrhardt: axis 2 = yaw. axis 1 pitched up, axis 0 pitched down.)
- Yaw for a turret, PITCH for a barrel — same feature. The axis that's "wrong" (tilts) for a turret is exactly what a mechanized howitzer / artillery barrel needs to ELEVATE at range. One knob, two unit types.
Making the Jagdpanzer's tread move took seventeen rig revisions; these are the lessons that survived, so nobody walks the dead ends again (the working system is documented in Animated-Models.md → treadize):
- Continuous-band skinning cannot look tight. Blended carrier bones (wheel wraps + sliding runs) were refined until measured edge-tears fell 0.43 → 0.03 — and the user still called it loose, correctly: molded links visibly bending is what the eye reads as slack, no matter how small the numbers get. Rigid per-link instancing was the only cure. Corollary: metrics saturate — thin side-faces flip 180° for any seam mismatch bigger than the tread's thickness, so past a point only renders tell the truth.
- Diagnose with tools, not eyes. Three tiny headless scripts broke every impasse: a tear finder (edge length change between frames, ranked, with each endpoint's bone weights — names the exact seam), a fold finder (dihedral-angle change — catches what tears can't), and a per-link displacement probe (every link should move the same distance; outliers = parameterization bugs). Plus Workbench renders from the user's own camera angle before asking them to look.
- θ-around-a-centroid is not a loop parameter. Any concavity (a raised idler's rear ramp) makes a radial ray cross the band twice — two distant path sections merge, links teleport. The belt-around-pulleys construction (wheel centers + measured band radii, external tangents + wrap arcs) is exact, and all the inputs are measurable from the mesh.
- Wheels and tread want different quanta. Spoke symmetry pins the wheel spin (60° for six spokes); the tread restart pins the advance (integer links per loop). Decouple them — the tread system rides its own bones; never let tread geometry borrow the visible wheel bones (their rim radius isn't the band radius: rim-based rotation ran wraps 20–60 % fast).
- Fades must be flow-aware. A positional fade lets the animation carry a still-weighted vert PAST its exit tangent (tread drooping below the ground line at the road wheel). Exits hand off one advance-length upstream; entries don't care. (Moot under rigid links, still true for any blended carrier setup.)
The first in-game launch of the 242-bone translating tread skeleton exploded into map-spanning spike ribbons, missing tread geometry, and twitching that touched VANILLA units. One afternoon of one-change-per-launch debugging found FIVE independent real defects stacked on top of each other — worth recording because any high-bone custom unit can hit each of them again:
- Decimation shreds link-cell skinning (the biggest). The animated path's "Reduce to ~tris (0 = off)" silently substituted 12,000 — and a decimator eats flat, dense geometry (the subdivided tread band) first, merging verts ACROSS rigid-cell boundaries. Blended weights between distant link bones = vertices torn across the map as links move. For link-cell treads decimation must be OFF (0 now honestly means off).
-
Zero-weight bones get silently dropped between Blender and the baked assets — the side-skirt-hidden
tread stretch produced empty cells whose bones vanished, shifting every bone index above them. The rig now
creates bones ONLY for vert-owning cells; verify with the mesh line:
bones == bindposes, no name gaps. -
A two-fragment donor draws its own skinned tread submesh over yours, skinned by donor bone indices
against YOUR skeleton (garbage).
hideMesheshandles it — but only since the hide also patches… - …the GPU pawn descriptor SNAPSHOTS (fragments AND BonesCount) taken at RegisterPawnDefinition, BEFORE the plugin's swap. Both are now patched in place (the same surgical mechanism the hand-prop append uses).
-
The shared per-frame animated-bone pool (65,535 entries for ALL pawns on screen) overflows once
high-bone customs multiply — overflowing pawns read other pawns' matrices (vanilla units spiking!).
SkeletonBoneBudget(plugin config) now sizes it (default 262,144).
The method that actually worked — in order of leverage:
-
Read the artifacts, not the preview (Law 4's corollary): grep the baked assets. EncodingFormat census
of the clips, FrameCount calibrated against a known clip, bone-NAME gap scans of the skeleton, the SKMESH
console line (
verts/bones/bindposes/maxBoneIdxUsed). Every defect above was visible in artifacts. - One change per launch, and verify the change actually landed (a slider that lies, a session that predates the registry write, a Factory field that went stale — three launches were wasted on phantoms).
-
Instrument the live path when artifacts look clean: the
[PawnDiag]dump (per-pawn descriptor bones/fragments at AddPawnEntry) ended a three-fix guessing streak in one launch. - Isolate with a static bake on a spare unit (the statue test): the same GLB baked static renders perfectly → everything mesh-side exonerated in one launch. Cheap, decisive, should have been first.
- Keep an elimination board. By session's end: mesh ✓, bone count ✓ (84 still twitched), state machine ✓ (transition log steady), tread-system-off ✓ (twitch stops). OPEN: idle micro-twitch triggered by the link system; next split = links with translations stripped (bones vs RotationTranslation playback).
The "old functionality" everyone remembers was one clip + two runtime tricks: hold the full deploy clip at
normalized time 0.999 when idle (0.999, not 1.0 — Repeat(1.0) wraps to frame 0, the folded pose: the original
edge-overflow bug), and snap to frame 0 while moving. No stance clips, no fold animation, no state machine.
Recreating it state-driven therefore wasn't porting — it was building five clips through machinery the legacy
path never exercised, which is why "it worked before" was true and useless at the same time. The state-driven
equivalent that ends up matching it, entirely in data:
| Role | Clip | Why |
|---|---|---|
| Idle / reference |
deploy (full) |
Law 2 — defines the reference pose |
| Idle stance (override) | deploy[179..180] |
the deployed hold, as a role |
| Movement | deploy[0..0] |
travel stance |
| Pre-movement |
deploy[179..0/12] (or empty) |
fast fold (empty = legacy instant snap) |
| After-movement | deploy[0..179/3] |
the unfold |
| Attack | deploy[180..250] |
the source's own recoil kick |
One evening, one Sketchfab T-62 with object-baked animation, and seven consecutive in-game failure modes —
each one a real engine constraint nobody had written down. The instruments that ended the guessing are now
permanent plugin residents: [AnimDiag] (one-shot per entry: the engine's live per-bone GPUAnimationEntry
records — FrameCount/Format/StartPoseData/BBox — plus the engine's OWN GetPoseTRS decode at frame 0 and
mid-clip, plus the skeleton rest TRS) and [PawnLive] (throttled: the pawn entry AS THE GAME LEAVES IT —
pose slot ids/weights/times, BoneRotation records). Read both from BepInEx LogOutput.log; read the editor
side from Unity's Editor.log instead of squinting at the console.
The contract itself. Amplitude's clip encoder normalizes every clip against the skeleton's BIND rest and
discards any constant frame-0 offset. Every working unit shows the same shape in [AnimDiag]: skeleton
rest carries the full pose, clips decode to ~identity deltas at frame 0. Therefore BIND must equal animation
frame 0 — a model whose bind differs from f0 renders its bind, forever, no matter what plays. The m114
satisfied this by accident (raw local verts + node transforms carried each part's rotation into the
bindposes); the clean-unit rework broke it, and the fix is structural, in deploy_convert: verts folded to
their full frame-0 world state, translation-only axis-aligned bones (safe through Blender→FBX bone-axis
conversion), pose-scale fcurves stripped (a cm-source's constant 0.01 lands in pose scale keys the engine
mishandles — the AW101 missing-fuselage class), and every bone's keys delta-form rebased
(basis_f' = basis_f @ basis_0⁻¹, hemisphere-continuous — identity at f0 by construction).
The 128-bone-index GPU wall. Per-vertex bone indices break past 127 — not 256. Bones 128+ render
collapsed/invisible (the T-62's turret and wheels, bones 128–140, vanished while links 1–120 animated). This
retroactively closes two cold cases: the Jagdpanzer's 241-bone spike ceiling and the mech's broken wings at
222 bones. deploy_convert clamps to 126 total by pair-merging instanced link chains (a dropped link
binds to its numeric neighbor's bone and rides it rigidly). Merges MUST be spread evenly across all chains —
clustered merges put every rider on one half of one track and that half fails together in-game; distributed,
each rider only mis-swings during its own brief wrap transit (~2 links visible at cinematic zoom, invisible at
gameplay zoom).
Three smaller laws from the same night.
-
1-frame stances wrap the sampler: Unity's constant-curve dedupe collapses two identical padded frames back
to FrameCount 1, and the engine's
Clamp(f, 0, FrameCount-2)returns −1 → uint-wraps → a constant garbage pose-pool read (a STABLE wrong pose, not flicker — it looks like a broken bind). The slicer now nudges the pad frame by ~0.03° on one bone so the second frame survives import. -
The ×100 translation amplify is legacy-only: the FBX exporter's
global_scalenever scales ANIMATION curves, so on clean-unit exports the amplify made link crawls bake with ~300-unit bboxes (links crawling 300 units off-map). Clean-unit sources skip it; raw-legacy sources still need it. - Per-role translation floors: the move role keeps only LARGE slides (track links crawl ~6 u) and drops small ones (suspension bob 0.02–0.04 u — the source rode bumpy terrain; replayed on flat game ground the wheels wiggle in the air). The attack role keeps its historic 1e-4 floor (the m114's recoil slide is ~0.1 u raw). The two populations sit two orders of magnitude apart; the 0.5 floor splits them with margin.
The from-source tracked-vehicle recipe (what all of the above buys): any model whose animation is baked as
rigid-part object motion — no armature needed — becomes a fully animated vehicle with NO rigging work:
Deploy conversion ✓ + frame range, Idle/reference = full clip, Idle stance = clip[0..0], Movement = full
clip (slice later for pacing), Keep bone translations ✓, Clear aim layer ✓ (artillery-family donors stream
aim junk onto arbitrary bones), Fix 100× OFF, Convert raw rig OFF. deploy_convert handles unit
normalization, recentering, root-motion anchoring (a source that drives across its scene bakes hull-relative,
in-place), bone slimming (bones only for binding targets — 1033-node wrapper rigs collapse to ~139) and the
128-wall budget automatically. "Has baked animation" is now a BONUS when sourcing models, not a complication.
Meta-lesson (the trap that burned three bakes): the Factory and Animation Lab windows hold separate
in-memory copies of shared entry state; baking from one silently reverts fields edited via the other (or via
the registry file directly) — keepTranslations was lost three times this way. Until the root cause is fixed:
after ANY field change, Reload in the window you'll bake from and eyeball the checkbox before pressing Bake.
Authoring INTO a converted rig — you can carry motion, you can't add it (2026-08-22, the howitzer's wheels)
deploy_convert gives a rigid-part Sketchfab rip a working unit for free. What it does not give is a rig
you can author new bone motion into. Spending a day proving that produced the sharpest statement of the limit:
Motion the source already animates rides through correctly. Motion you key onto a converted bone does not.
The T-62 is the proof of the first half — deploy-converted, and its wheels spin and its tracks crawl in-game, because its Sketchfab source animated them, so that motion sits in the same frame as everything else in the clip. The M114 howitzer is the proof of the second half: its source never rotates its wheels, so we keyed a roll onto the wheel bones — and no version of that roll ever looked right in-game.
Why. A converted rig does not share a frame with itself. Measured on the shipped M114:
| measurement | |
|---|---|
| model at rest pose | bbox (52.1, 135.7, 37.6) — gun along Y, 37.6 tall |
| the same model inside any clip |
folded (41.7, 27.6, 119.3), deployed (41.7, 55.8, 143.9) — gun along Z: rotated ~90° |
the legacy deploy clip |
(83.4, 55.3, 238.6) — same rotation, and 2× scale
|
| baked skeleton scales |
howitzer:main Local 2, wheel BindPose 0.005 (a clean rig — the ArmouredCar — reads 1 / 1) |
Everything that acts at pawn level is unaffected: the engine's own orientation hides the mismatch, which is why the model looks perfect in-game and every pawn-level feature (fold-before-moving, deploy-on-stop, the bombard hold, turn-in-place) works. Everything that acts at bone level inherits a frame that disagrees with the geometry — so an authored wheel roll pivots about a point that is not the hub, however correct the input.
The tell you can see for free: open the clip in the Lab's ▶ Play in preview. If the model stands on end there while its rest pose stands upright, the clip and the rest are in different frames. (The user spotted this immediately from the preview; it was explained away twice as "a harmless preview artifact" before the bbox numbers above confirmed it. It is not harmless — it is the whole defect.)
Four in-game drills can only report "it still looks wrong". These answer why, in seconds, on the bake output:
-
Pivots —
Assets/Resources/<name>_Skeleton.assetis plain YAML. Compose each bone'sLocalTRS up itsParentIndexchain. A left/right pair (wheels, legs) must come out symmetric, differing only sideways. Asymmetric heights = the pivot is wrong. This is what finally localised the wheel bug:l_wheel y −0.4038vsr_wheel y +1.4612, while the legs read0.5851/0.5751. - Frame — import the converted GLB in Blender, measure the mesh bbox at rest and inside the clip. Different orientation = the table above.
- Does the part actually spin in place? — evaluate the armature per frame and track the centroid of the verts weighted to that bone. Constant centroid = spinning; drifting = orbiting.
-
In-game, what does the clip really animate? —
[AnimDiag] <model>:<role> bones that MOVE (f0 vs mid)names every bone whose rotation changes between frame 0 and the middle frame, with its angle. A flattened clip printsNONE — the baked clip is a held pose; a working wheel roll prints the wheel bones. Run it on a model you believe works as the control.
-
Runtime aim-layer wheel spin (rotate the bones through
BoneRotationslots by distance travelled): two drills, never once seen to move a wheel; deleted. The aim layer is also cleared per frame on artillery (clearAimLayer), so any writer must run after that clear. -
Tail-along-the-axle bones (the Vehicle Lab's convention for rigs it builds): gives a converted wheel bone
a non-identity rest rotation, and the skeleton bake then mangles its offset — a clean FBX
local T (21.096, 0, 0)baked as(-0.00932, 0, -0.00466), one wheel's pivot a radius below its hub, the other a radius above. Identity-rest bones (the trail legs) bake correctly; that contrast is the diagnostic. -
Pre-compensating the pivot (raise the bone by a tyre radius so the displaced pivot lands on the hub):
treats the symptom, and the displacement isn't a clean radius anyway once the
Scale 2chain is involved.
If you need authored bone motion on a converted model — the Vehicle Lab route (VERIFIED in-game 2026-08-22)
Rebuild the model on a Vehicle Lab rig. Done on the M114 the same afternoon the limit was found, and every measurement that failed on the converted rig passes on the generated one:
| check | converted rig | Vehicle Lab rig |
|---|---|---|
| clip frame vs rest frame |
(41.7, 27.6, 119.3) vs (52.1, 135.7, 37.6) — 90 deg out |
identical (52.1, 135.7, 37.6)
|
| wheel pivots (baked skeleton) |
l −0.40 / r +1.46 — one below its hub, one above |
symmetric ±0.9325, 0.5424, −0.001
|
| chain scales |
main Local 2, wheel Bind 0.005
|
all 1 |
| bone count | 28 (bone-per-part) | 4 (VehicleRig, Root, two wheels) |
Recipe: Vehicle Lab -> Browse the STATIC source (m114_gun_only.glb — no armature, no crew), mark the two road
wheels W (watch the auto-guess: it also grabs the crews hand-cranks by name), Generate rig; then Factory/Lab with Deploy conversion **OFF**, Convert raw rig **ON**, Fix 100x **OFF**, Idle stance Spin[0..0], Movement Spin, and every deploy…/recoil` clip field cleared. Cost: the fold, deploy and
recoil the converter gave you are gone until re-authored on the clean rig.
The alternatives remain: find a source that animates the part itself (the T-62 route), or fix the converter so
clips are authored in the rest poses frame — the real repair, with the acceptance test **folded at frame 1 must have the rest poses bbox orientation**.
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