The command

avatar migrate sdk3 <extracted-project> -o <out-dir> --name MyAvatar \ --strip BhapticsVRC_Vest --drop-cloth --capsules-to-physbone-colliders \ --physbone "Hips|Spine,Left leg,Right leg|L cap,R cap|SkirtRoot" \ --eyes "Eye_L,Eye_R" --exclude Assets/Bhaptics \ --vpm-package com.poiyomi.toon-9.3.64.zip --relink-locked-shaders [--dry-run] [--json]

What is migrated

Verified end to end: a real SDK2 avatar (Cloth skirt, DynamicBones, gesture overrides, a haptics vest to strip) went through this command, opened in the Creator Companion, built, and uploaded. The synthetic Sdk2Project fixture pins the rewritten prefab and FX controller as goldens.
SDK2SDK3How
VRC_AvatarDescriptorVRCAvatarDescriptorRetyped at the same fileID so its slot in the root's m_Component list is untouched. View position, viseme setup, portrait offsets carried; playable layers on SDK defaults except FX (generated); expressions menu/parameters (generated); eye look derived (below).
PipelineManagerPipelineManager (Standalone DLL)Retyped in place; blueprintId cleared (an SDK3 upload is a new avatar). Added if missing.
root Animatorsamem_ApplyRootMotion: 0, controller cleared — SDK3's playable layers drive it; root motion left on is the classic "avatar drifts / spins" bug.
DynamicBone / DynamicBoneColliderVRCPhysBone / VRCPhysBoneColliderRetyped in place with the SDK's own conversion rules (next section). Because fileIDs are kept, converted PhysBones' colliders lists resolve without rewriting.
Unity Cloth (--drop-cloth)Removed; the mesh falls back to its skinning. Pair with --physbone to simulate the skirt's bones instead.
Unity CapsuleCollider (--capsules-to-physbone-colliders)VRCPhysBoneColliderRetyped in place (class 136 → 114).
— (--physbone ROOT|IGNORE…|COLLIDERS…|GROUP)new VRCPhysBoneA new PhysBone over ROOT's bone-only children with skirt-ish defaults; with GROUP the chain roots are re-parented under a new empty child (identity local pose — nothing moves, skinning references bones by fileID) so colliders under ROOT don't trip VRChat's cyclic-dependency check.
--strip NAME subtreesThe whole subtree (GameObjects, Transforms, components) removed and the parent's m_Children entry dropped — a haptics vest with cameras and its own Animator, say.
CustomStandingAnims gesture slotsFX layerSee FX from gesture overrides, below.
Locomotion / emote / sitting overridesReported, not migrated — SDK3's Base/Action/Sitting layers are a different design, and an SDK2 idle/walk clip dropped into them is the source of the drift bugs the migration exists to remove.

Anything else in the prefab (renderers, meshes, materials, non-VRChat scripts, transforms) is untouched — byte for byte — because the rewrite is span-splicing over the raw text (editing Unity YAML). The FBX and its .meta (humanoid map, T-pose) are copied unchanged.

DynamicBone → PhysBone: the SDK's own rules

PhysBoneSpec::from_dynamic_bone reproduces VRC.SDK3.Dynamics.PhysBone.PhysBoneMigration.Convert (SDK 3.10.4, disassembled) — the conversion the SDK's own "Auto Fix" runs on import: pull from elasticity, spring = 1 − damping, immobile from inert, radius and gravity scaled by the transforms' world scale, and stiffness mapped through the SDK's StiffToMaxAngle table to an Angle limit (a frozen axis becomes a Hinge). Colliders convert capsule-or-sphere by their dimensions with the direction axis re-expressed as a rotation. Distribution curves are not carried (rare on avatars); m_EndLength is reported.

SDK3 script references are DLL class hashes, not 11500000: m_Script is {fileID: <MD4 class hash>, guid: <dll guid>, type: 3}, derived by avatar_unity_yaml::script_file_id and test-pinned against the SDK's own serialized assets. Field layouts come from the same packages, so a body is what Unity would write itself.

FX from gesture overrides

SDK2's CustomStandingAnims override controller becomes a generated FX.controller with one either-hand layer. For each gesture, only the override clip's blendshape curves are lifted into a clean Gesture_<Name>.anim (SDK2 clips also carried finger-muscle curves; in SDK3 hand poses are the Gesture layer's job, and muscle curves in FX would fight it), plus a Gesture_Neutral.anim zeroing every touched shape.

By default the layer is analog: each gesture gets a per-hand state whose motion is a 1D blend tree on that hand's GestureLeftWeight/GestureRightWeight, blending Neutral → the gesture clip — SDK2's Vive "advanced controls", where trigger depth is expression depth. Transitions are mutually exclusive and weight-gated (the squeezing hand owns the face; a thumb resting at weight 0 can never mask the other hand), and both hands on the same gesture route to a capped-sum 2D state with generated half-strength midpoint clips. --no-analog-gestures emits discrete states instead. Empty Parameters.asset/Menu.asset are generated alongside so toggles can be added later (avatar toggle, anim-gen).

Eye look and blink

SDK3 eye look stores the local rotation of each eye bone per look state. It is derived geometrically — local_state = (R_parent⁻¹ · R_delta · R_parent) · local_rest, with the turn taken about the avatar-space axes — so it is right even when the eye bones' own axes are wildly rolled (ripped/MMD rigs). Default angles 10° up/down, 12° left/right (--eye-angles). The blink blendshape is found on the viseme mesh's FBX by name (Blink, vrc.blink, まばたき, … or --blink NAME) and its import-order index written into eyelidsBlendshapes.

Bundled packages and locked-shader relink

--vpm-package PATH (repeatable) bundles a VPM package — a directory with package.json, or a .zip of one, e.g. a shader package's GitHub release — into <out>/Packages/<name>/ and records it in vpm-manifest.json; its legacyFolders and any source asset whose GUID the package already provides are excluded from the copy, so the project opens without duplicate-GUID reassignments.

--relink-locked-shaders handles what shader lockers (Poiyomi/Thry's optimizer) leave behind at export: each material points at a generated Hidden/… shader copy whose #includes were never exported, remembering the real one in stringTagMap.OriginalShader. The relink finds a matching shader among the source assets and bundled packages, re-points m_Shader, disables the locker flag, and excludes the generated copy. Materials whose original shader can't be found are reported and left alone.

Output layout

<out>/Assets/<copied source assets, minus --exclude and the SDK2 VRCSDK/, VRChat Examples/>
<out>/Assets/<Name>_SDK3/<Name>.prefab (+ .meta)         # the migrated prefab
<out>/Assets/<Name>_SDK3/FX/FX.controller, Gesture_*.anim (+ .meta)
<out>/Assets/<Name>_SDK3/Parameters.asset, Menu.asset (+ .meta)
<out>/Packages/<bundled package>/…                        # each --vpm-package
<out>/Packages/vpm-manifest.json                          # com.vrchat.avatars/base + bundled
<out>/Packages/manifest.json                              # VCC-template Unity packages
<out>/ProjectSettings/ProjectVersion.txt

The output must not already contain Assets/ (refuse-before-write); --dry-run plans and reports without touching the filesystem, --json makes the report machine-readable. Generated GUIDs/fileIDs are deterministic (seeded from names). Then: open <out> in the Creator Companion (Add Existing Project), drag the prefab into a scene, check the descriptor, Build & Publish — and retune PhysBones offline with avatar physbone.

Limits

  • Locomotion/emote/sitting overrides are not migrated (by design); rebuild them on SDK3 layers if wanted.
  • DynamicBone distribution curves and m_EndLength are not carried; endpoint set from m_EndOffset only.
  • Prefabs only (one root Transform); nested prefab instances (stripped documents) are not resolved.
  • The Unity import itself is not verified here (no editor in this toolchain); avatar lint / avatar stats on the output are the offline checks.