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.
*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.
*_ClipsPoseData.bytes rows are near-constant identity.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”).
deploy[179..0/6] = every 6th frame = 6× faster (≈1.25 s). The step
always lands exactly on the end frame. /180 on 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).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:
445..451 on 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).Three different things can lie to you independently:
What finally broke the debugging loop was not a cleverer theory — it was a measurement protocol:
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).frame_set sweep, compare world bone heads + local quats; PASS = 0.0000). The whole Blender
stage can be gated without ever opening Unity or the game.| 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) |
| 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 |
| 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 Amplitude’s 256-bone GPU skinning cap (mech: 332 bones) — verts on bone index >255 get garbage | fixed: zero-weight leaf bones removed to ≤240 (weighted bones untouched) |
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.
animUnitFix) ON → correct render size, but keeps the sandwich → wheels fling.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.
Grounding — the animated path has no automatic keel→z=0 (only the static path does), so a vehicle whose tyres stick out below the hull sinks. Two ways to sit it on the terrain:
−minZ). It’s self-correcting (a raw file lifts fully, an
already-grounded one lifts ~0 → can’t double-apply) and size-proof: the shift is in model space, so the bake’s
globalScale = size/longest scales it automatically — change Size and it stays grounded. (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. OFF for a flyer/hover model (it would be pinned to the ground).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 Auto-ground — 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.
Turret; the
plugin substring-matches it against the renamed b###_<orig> bones) → Turret aim axis → Save (no bake) +
relaunch. Verified on the Ehrhardt armored car (first custom unit with an aiming turret).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):
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:
bones == bindposes, no name gaps.hideMeshes handles it — but only since the hide also patches…SkeletonBoneBudget (plugin config) now sizes it (default 262,144).The method that actually worked — in order of leverage:
verts/bones/bindposes/maxBoneIdxUsed). Every defect above was visible in artifacts.[PawnDiag] dump (per-pawn descriptor
bones/fragments at AddPawnEntry) ended a three-fix guessing streak in one launch.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.
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.global_scale never 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.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.