diff --git a/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs b/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs
index 66cc997d..e5cde52b 100644
--- a/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs
+++ b/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs
@@ -23,8 +23,10 @@
  */
 
 using System;
+using System.Collections.Generic;
 using System.Diagnostics.CodeAnalysis;
 using System.IO;
+using System.Linq;
 using System.Reflection;
 using System.Runtime.CompilerServices;
 using nadena.dev.modular_avatar.editor.ErrorReporting;
@@ -147,77 +149,120 @@ namespace nadena.dev.modular_avatar.core.editor
             {
                 try
                 {
-                    AssetDatabase.StartAssetEditing();
-                    nowProcessing = true;
-
-                    ClearEditorOnlyTagComponents(avatarGameObject.transform);
-
-                    BoneDatabase.ResetBones();
-                    PathMappings.Init(vrcAvatarDescriptor.gameObject);
-                    ClonedMenuMappings.Clear();
-
-                    // Sometimes people like to nest one avatar in another, when transplanting clothing. To avoid issues
-                    // with inconsistently determining the avatar root, we'll go ahead and remove the extra sub-avatars
-                    // here.
-                    foreach (Transform directChild in avatarGameObject.transform)
+                    try
                     {
-                        foreach (var component in directChild.GetComponentsInChildren<VRCAvatarDescriptor>(true))
+                        AssetDatabase.StartAssetEditing();
+                        nowProcessing = true;
+
+                        ClearEditorOnlyTagComponents(avatarGameObject.transform);
+
+                        BoneDatabase.ResetBones();
+                        PathMappings.Init(vrcAvatarDescriptor.gameObject);
+                        ClonedMenuMappings.Clear();
+
+                        // Sometimes people like to nest one avatar in another, when transplanting clothing. To avoid issues
+                        // with inconsistently determining the avatar root, we'll go ahead and remove the extra sub-avatars
+                        // here.
+                        foreach (Transform directChild in avatarGameObject.transform)
                         {
-                            Object.DestroyImmediate(component);
+                            foreach (var component in directChild.GetComponentsInChildren<VRCAvatarDescriptor>(true))
+                            {
+                                Object.DestroyImmediate(component);
+                            }
+
+                            foreach (var component in directChild.GetComponentsInChildren<PipelineSaver>(true))
+                            {
+                                Object.DestroyImmediate(component);
+                            }
                         }
 
-                        foreach (var component in directChild.GetComponentsInChildren<PipelineSaver>(true))
-                        {
-                            Object.DestroyImmediate(component);
-                        }
+                        var context = new BuildContext(vrcAvatarDescriptor);
+
+                        new ActionGenerator(context).OnPreprocessAvatar(vrcAvatarDescriptor);
+                        new RenameParametersHook().OnPreprocessAvatar(avatarGameObject, context);
+                        new MergeAnimatorProcessor().OnPreprocessAvatar(avatarGameObject, context);
+                        context.AnimationDatabase.Bootstrap(vrcAvatarDescriptor);
+
+                        new MenuInstallHook().OnPreprocessAvatar(avatarGameObject, context);
+                        new MergeArmatureHook().OnPreprocessAvatar(context, avatarGameObject);
+                        new BoneProxyProcessor().OnPreprocessAvatar(avatarGameObject);
+                        new VisibleHeadAccessoryProcessor(vrcAvatarDescriptor).Process(context);
+                        new RemapAnimationPass(vrcAvatarDescriptor).Process(context.AnimationDatabase);
+                        new BlendshapeSyncAnimationProcessor().OnPreprocessAvatar(avatarGameObject, context);
+                        PhysboneBlockerPass.Process(avatarGameObject);
+
+                        context.AnimationDatabase.Commit();
+
+                        new GCGameObjectsPass(context, avatarGameObject).OnPreprocessAvatar();
+
+                        AfterProcessing?.Invoke(avatarGameObject);
                     }
+                    finally
+                    {
+                        AssetDatabase.StopAssetEditing();
 
-                    var context = new BuildContext(vrcAvatarDescriptor);
+                        nowProcessing = false;
 
-                    new RenameParametersHook().OnPreprocessAvatar(avatarGameObject, context);
-                    new MergeAnimatorProcessor().OnPreprocessAvatar(avatarGameObject, context);
-                    context.AnimationDatabase.Bootstrap(vrcAvatarDescriptor);
+                        // Ensure that we clean up AvatarTagComponents after failed processing. This ensures we don't re-enter
+                        // processing from the Awake method on the unprocessed AvatarTagComponents
+                        var toDestroy = avatarGameObject.GetComponentsInChildren<AvatarTagComponent>(true).ToList();
+                        var retryDestroy = new List<AvatarTagComponent>();
 
-                    new MenuInstallHook().OnPreprocessAvatar(avatarGameObject, context);
-                    new MergeArmatureHook().OnPreprocessAvatar(context, avatarGameObject);
-                    new BoneProxyProcessor().OnPreprocessAvatar(avatarGameObject);
-                    new VisibleHeadAccessoryProcessor(vrcAvatarDescriptor).Process(context);
-                    new RemapAnimationPass(vrcAvatarDescriptor).Process(context.AnimationDatabase);
-                    new BlendshapeSyncAnimationProcessor().OnPreprocessAvatar(avatarGameObject, context);
-                    PhysboneBlockerPass.Process(avatarGameObject);
+                        // Sometimes AvatarTagComponents have interdependencies and need to be deleted in the right order;
+                        // retry until we purge them all.
+                        bool madeProgress = true;
+                        while (toDestroy.Count > 0)
+                        {
+                            if (!madeProgress)
+                            {
+                                throw new Exception("One or more components failed to destroy." +
+                                                    RuntimeUtil.AvatarRootPath(toDestroy[0].gameObject));
+                            }
 
-                    context.AnimationDatabase.Commit();
+                            foreach (var component in toDestroy)
+                            {
+                                try
+                                {
+                                    if (component != null)
+                                    {
+                                        UnityEngine.Object.DestroyImmediate(component);
+                                        madeProgress = true;
+                                    }
+                                }
+                                catch (Exception e)
+                                {
+                                    retryDestroy.Add(component);
+                                }
+                            }
 
-                    new GCGameObjectsPass(context, avatarGameObject).OnPreprocessAvatar();
+                            toDestroy = retryDestroy;
+                            retryDestroy = new List<AvatarTagComponent>();
+                        }
 
-                    AfterProcessing?.Invoke(avatarGameObject);
+                        var activator = avatarGameObject.GetComponent<AvatarActivator>();
+                        if (activator != null)
+                        {
+                            UnityEngine.Object.DestroyImmediate(activator);
+                        }
+
+                        ClonedMenuMappings.Clear();
+
+                        ErrorReportUI.MaybeOpenErrorReportUI();
+
+                        AssetDatabase.SaveAssets();
+
+                        Resources.UnloadUnusedAssets();
+                    }
                 }
-                finally
+                catch (Exception e)
                 {
-                    AssetDatabase.StopAssetEditing();
+                    BuildReport.LogException(e);
+                    throw;
+                }
 
-                    nowProcessing = false;
-
-                    // Ensure that we clean up AvatarTagComponents after failed processing. This ensures we don't re-enter
-                    // processing from the Awake method on the unprocessed AvatarTagComponents
-                    foreach (var component in avatarGameObject.GetComponentsInChildren<AvatarTagComponent>(true))
-                    {
-                        UnityEngine.Object.DestroyImmediate(component);
-                    }
-
-                    var activator = avatarGameObject.GetComponent<AvatarActivator>();
-                    if (activator != null)
-                    {
-                        UnityEngine.Object.DestroyImmediate(activator);
-                    }
-
-                    ClonedMenuMappings.Clear();
-
-                    ErrorReportUI.MaybeOpenErrorReportUI();
-
-                    AssetDatabase.SaveAssets();
-
-                    Resources.UnloadUnusedAssets();
+                if (!BuildReport.CurrentReport.CurrentAvatar.successful)
+                {
+                    throw new Exception("Fatal error reported during avatar processing.");
                 }
             }
         }
diff --git a/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs
index 26b98951..5aa74499 100644
--- a/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs
+++ b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs
@@ -293,7 +293,8 @@ namespace nadena.dev.modular_avatar.editor.ErrorReporting
 
         internal static void Log(ReportLevel level, string code, object[] strings, params Object[] objects)
         {
-            ErrorLog errorLog = new ErrorLog(level, code, strings, objects);
+            ErrorLog errorLog =
+                new ErrorLog(level, code, strings: strings.Select(s => s.ToString()).ToArray(), objects);
 
             var avatarReport = CurrentReport._currentAvatar;
             if (avatarReport == null)
@@ -307,7 +308,7 @@ namespace nadena.dev.modular_avatar.editor.ErrorReporting
 
         internal static void LogFatal(string code, object[] strings, params Object[] objects)
         {
-            Log(ReportLevel.Error, code, strings, objects);
+            Log(ReportLevel.Error, code, strings: strings, objects: objects);
             if (CurrentReport._currentAvatar != null)
             {
                 CurrentReport._currentAvatar.successful = false;
diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/MAEditorBase.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/MAEditorBase.cs
index 3d9cd96e..b962b83d 100644
--- a/Packages/nadena.dev.modular-avatar/Editor/Inspector/MAEditorBase.cs
+++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/MAEditorBase.cs
@@ -81,10 +81,7 @@ namespace nadena.dev.modular_avatar.core.editor
 
         protected virtual VisualElement CreateInnerInspectorGUI()
         {
-            var throwaway = new InspectorElement();
-            MethodInfo m = typeof(InspectorElement).GetMethod("CreateIMGUIInspectorFromEditor",
-                BindingFlags.NonPublic | BindingFlags.Instance);
-            return m.Invoke(throwaway, new object[] {serializedObject, this, false}) as VisualElement;
+            return null;
         }
 
         public sealed override VisualElement CreateInspectorGUI()
@@ -94,10 +91,19 @@ namespace nadena.dev.modular_avatar.core.editor
 
             var inner = CreateInnerInspectorGUI();
 
+            bool innerIsImgui = (inner == null);
+            if (innerIsImgui)
+            {
+                var throwaway = new InspectorElement();
+                MethodInfo m = typeof(InspectorElement).GetMethod("CreateIMGUIInspectorFromEditor",
+                    BindingFlags.NonPublic | BindingFlags.Instance);
+                inner = m.Invoke(throwaway, new object[] {serializedObject, this, false}) as VisualElement;
+            }
+
             _visualElement = new MAVisualElement();
             _visualElement.Add(inner);
 
-            _suppressOnceDefaultMargins = true;
+            _suppressOnceDefaultMargins = innerIsImgui;
             return _visualElement;
         }
 
diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ActionToggleObjectInspector.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ActionToggleObjectInspector.cs
new file mode 100644
index 00000000..985f32c4
--- /dev/null
+++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ActionToggleObjectInspector.cs
@@ -0,0 +1,173 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEditor;
+using UnityEditorInternal;
+using UnityEngine;
+using static nadena.dev.modular_avatar.core.editor.Localization;
+
+namespace nadena.dev.modular_avatar.core.editor
+{
+    [CustomEditor(typeof(ActionToggleObject))]
+    internal class ActionToggleObjectInspector : MAEditorBase
+    {
+        private ReorderableList _list;
+        private SerializedProperty _listProp;
+
+        private void OnEnable()
+        {
+            _listProp = serializedObject.FindProperty(nameof(ActionToggleObject.Objects));
+            _list = new ReorderableList(
+                serializedObject,
+                _listProp,
+                true,
+                true,
+                true,
+                true
+            );
+
+            _list.drawHeaderCallback += DrawListHeader;
+            _list.drawElementCallback += DrawElement;
+            _list.onRemoveCallback += OnRemoveElement;
+            _list.onAddCallback += OnAddElement;
+
+            _list.elementHeight += 2;
+        }
+
+        private void OnAddElement(ReorderableList list)
+        {
+            _listProp.arraySize++;
+        }
+
+        private void OnRemoveElement(ReorderableList reorderableList)
+        {
+            if (reorderableList.index < _listProp.arraySize)
+            {
+                _listProp.DeleteArrayElementAtIndex(reorderableList.index);
+            }
+        }
+
+        private Rect _checkRect, _objectRect;
+
+        private void DrawListHeader(Rect rect)
+        {
+            var margin = 2;
+
+            var t = EditorStyles.toggle;
+            var toggleSize = t.CalcSize(GUIContent.none);
+            _checkRect = new Rect(0, 0, toggleSize.x, toggleSize.y);
+            _checkRect.y += (rect.height - _checkRect.height);
+
+            _objectRect = new Rect(_checkRect.xMax + margin, 0, rect.width - _checkRect.width - margin,
+                _list.elementHeight);
+
+            EditorGUI.LabelField(rect, G("action.toggle_object.header"));
+        }
+
+        private void DrawElement(Rect rect, int index, bool isactive, bool isfocused)
+        {
+            var element = _listProp.GetArrayElementAtIndex(index);
+            var activeProp = element.FindPropertyRelative(nameof(ActionToggleObject.ObjectEntry.Active));
+            var targetProp = element.FindPropertyRelative(nameof(ActionToggleObject.ObjectEntry.target));
+
+            var checkRect = _checkRect;
+            checkRect.x += rect.x;
+            checkRect.y += rect.y;
+
+            var objectRect = _objectRect;
+            objectRect.x += rect.x;
+            objectRect.y += rect.y;
+            objectRect.xMax = rect.xMax;
+            objectRect.yMin += 1;
+            objectRect.yMax -= 1;
+
+            using (new ZeroIndentScope())
+            {
+                EditorGUI.PropertyField(checkRect, activeProp, GUIContent.none);
+                EditorGUI.PropertyField(objectRect, targetProp, GUIContent.none);
+            }
+        }
+
+
+        protected override void OnInnerInspectorGUI()
+        {
+            serializedObject.Update();
+
+            Rect rect = GUILayoutUtility.GetRect(
+                10f,
+                _list.headerHeight + _list.elementHeight * Math.Max(1, _list.serializedProperty.arraySize) +
+                _list.footerHeight,
+                GUILayout.ExpandWidth(true)
+            );
+
+            _list.DoList(rect);
+
+            EditorGUILayout.Space(8);
+
+            Localization.ShowLanguageUI();
+
+            serializedObject.ApplyModifiedProperties();
+
+            if (rect.Contains(Event.current.mousePosition))
+            {
+                switch (Event.current.type)
+                {
+                    case EventType.DragUpdated:
+                        if (!DragIsGameObject()) break;
+                        DragAndDrop.visualMode = DragAndDropVisualMode.Link;
+                        Event.current.Use();
+                        break;
+                    case EventType.DragPerform:
+                    {
+                        if (!DragIsGameObject()) break;
+                        var targetObj = (ActionToggleObject) target;
+
+                        if (targetObj.Objects == null)
+                        {
+                            targetObj.Objects = new List<ActionToggleObject.ObjectEntry>();
+                        }
+
+                        HashSet<GameObject> currentObjects = new HashSet<GameObject>();
+                        foreach (var obj in targetObj.Objects)
+                        {
+                            if (obj != null && obj.target != null)
+                            {
+                                currentObjects.Add(obj.target);
+                            }
+                        }
+
+                        var objects = targetObj.Objects.ToList();
+
+                        foreach (var obj in DragAndDrop.objectReferences)
+                        {
+                            if (obj is GameObject go && !currentObjects.Contains(go))
+                            {
+                                objects.Add(new ActionToggleObject.ObjectEntry()
+                                {
+                                    target = go,
+                                    Active = go.activeSelf
+                                });
+                            }
+                        }
+
+                        Undo.RecordObject(targetObj, "Add objects");
+                        targetObj.Objects = objects;
+                        EditorUtility.SetDirty(targetObj);
+                        PrefabUtility.RecordPrefabInstancePropertyModifications(targetObj);
+                        break;
+                    }
+                }
+            }
+        }
+
+        private bool DragIsGameObject()
+        {
+            foreach (var obj in DragAndDrop.objectReferences)
+            {
+                if (obj is GameObject) return true;
+            }
+
+            return false;
+        }
+    }
+}
\ No newline at end of file
diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ActionToggleObjectInspector.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ActionToggleObjectInspector.cs.meta
new file mode 100644
index 00000000..786c7d10
--- /dev/null
+++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ActionToggleObjectInspector.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: df175549a5ea45d59d9f2daa031bbbf1
+timeCreated: 1677901132
\ No newline at end of file
diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/AvMenuTreeView.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/AvMenuTreeView.cs
index b37edadc..b4171692 100644
--- a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/AvMenuTreeView.cs
+++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/AvMenuTreeView.cs
@@ -4,13 +4,13 @@ using System.Collections.Immutable;
 using System.Linq;
 using nadena.dev.modular_avatar.core.editor.menu;
 using nadena.dev.modular_avatar.core.menu;
-using NUnit.Framework;
 using UnityEditor;
 using UnityEditor.IMGUI.Controls;
 using UnityEngine;
 using VRC.SDK3.Avatars.Components;
 using VRC.SDK3.Avatars.ScriptableObjects;
-using VRC.SDKBase;
+using static nadena.dev.modular_avatar.core.editor.Localization;
+
 
 namespace nadena.dev.modular_avatar.core.editor
 {
@@ -73,7 +73,7 @@ namespace nadena.dev.modular_avatar.core.editor
             Action<object> OnSelect)
         {
             var window = GetWindow<AvMenuTreeViewWindow>();
-            window.titleContent = new GUIContent("Select menu");
+            window.titleContent = G("menu_tree.title");
 
             window.Avatar = Avatar;
             window.TargetInstaller = Installer;
diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ControlGroupInspector.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ControlGroupInspector.cs
new file mode 100644
index 00000000..0968d6be
--- /dev/null
+++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ControlGroupInspector.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEditor;
+using UnityEngine;
+using static nadena.dev.modular_avatar.core.editor.Localization;
+
+namespace nadena.dev.modular_avatar.core.editor
+{
+    [CustomEditor(typeof(ControlGroup))]
+    internal class ControlGroupInspector : MAEditorBase
+    {
+        private bool _showInner;
+
+        private void OnEnable()
+        {
+            EditorApplication.hierarchyChanged += Invalidate;
+        }
+
+        private void OnDisable()
+        {
+            EditorApplication.hierarchyChanged -= Invalidate;
+        }
+
+        private List<Action> _menuItemActions = null;
+
+        private void Invalidate()
+        {
+            var target = (ControlGroup) this.target;
+            var avatar = RuntimeUtil.FindAvatarInParents(target.transform);
+            var menuItems = avatar.GetComponentsInChildren<ModularAvatarMenuItem>(true);
+
+            _menuItemActions = new List<Action>();
+            foreach (var menuItem in menuItems.Where(item => item.controlGroup == target))
+            {
+                var node = CreateMenuItemNode(menuItem);
+                _menuItemActions.Add(node);
+            }
+        }
+
+        private Action CreateMenuItemNode(ModularAvatarMenuItem menuItem)
+        {
+            bool foldout = false;
+
+            var coreUI = new MenuItemCoreGUI(new SerializedObject(menuItem), Repaint);
+            var enableMenuItem = new SerializedObject(menuItem.gameObject).FindProperty("m_IsActive");
+
+            List<Action> foldoutInspectors = null;
+
+            return () =>
+            {
+                using (new MenuObjectHeader(menuItem, enableMenuItem).Scope())
+                {
+                    coreUI.DoGUI();
+
+                    foldout = EditorGUILayout.Foldout(foldout, G("control_group.foldout.actions"));
+                    if (foldout)
+                    {
+                        if (foldoutInspectors == null)
+                        {
+                            foldoutInspectors = menuItem.GetComponents<MenuAction>()
+                                .Select(action =>
+                                {
+                                    var component = (Component) action;
+                                    var editor = CreateEditor(component);
+                                    var enabled_prop = new SerializedObject(component).FindProperty("m_Enabled");
+
+                                    return (Action) (() =>
+                                    {
+                                        using (new MenuObjectHeader(component, enabled_prop).Scope())
+                                        {
+                                            editor.OnInspectorGUI();
+                                        }
+                                    });
+                                })
+                                .ToList();
+                        }
+
+                        foreach (var inspector in foldoutInspectors)
+                        {
+                            inspector();
+                        }
+                    }
+                }
+            };
+        }
+
+        protected override void OnInnerInspectorGUI()
+        {
+            if (_menuItemActions == null) Invalidate();
+
+            _showInner = EditorGUILayout.Foldout(_showInner, G("control_group.foldout.menu_items"));
+            if (_showInner)
+            {
+                foreach (var action in _menuItemActions)
+                {
+                    try
+                    {
+                        EditorGUI.indentLevel++;
+                        action();
+                    }
+                    finally
+                    {
+                        EditorGUI.indentLevel--;
+                    }
+
+                    EditorGUILayout.Space(4);
+                }
+            }
+
+            Localization.ShowLanguageUI();
+        }
+    }
+}
\ No newline at end of file
diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ControlGroupInspector.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ControlGroupInspector.cs.meta
new file mode 100644
index 00000000..8fbebf1d
--- /dev/null
+++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ControlGroupInspector.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 357527439f084cd6812d6e5dcd9692f8
+timeCreated: 1677595893
\ No newline at end of file
diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuItemGUI.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuItemGUI.cs
index 897b7824..ce3d6d44 100644
--- a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuItemGUI.cs
+++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuItemGUI.cs
@@ -1,10 +1,10 @@
 using System;
 using System.Linq;
 using System.Runtime.Serialization;
-using Codice.CM.Common.Tree.Partial;
 using nadena.dev.modular_avatar.core.menu;
 using UnityEditor;
 using UnityEngine;
+using UnityEngine.PlayerLoop;
 using VRC.SDK3.Avatars.ScriptableObjects;
 using static nadena.dev.modular_avatar.core.editor.Localization;
 
@@ -29,6 +29,8 @@ namespace nadena.dev.modular_avatar.core.editor
         private readonly SerializedProperty _type;
         private readonly SerializedProperty _value;
         private readonly SerializedProperty _submenu;
+        private readonly SerializedProperty _controlGroup;
+        private readonly SerializedProperty _isDefault;
 
         private readonly ParameterGUI _parameterGUI;
 
@@ -48,6 +50,27 @@ namespace nadena.dev.modular_avatar.core.editor
         public bool AlwaysExpandContents = false;
         public bool ExpandContents = false;
 
+        private bool _hasActions;
+
+        private bool HasActions(TargetParameter? p = null)
+        {
+            return _controlGroup != null && _obj.targetObjects.Any(o =>
+            {
+                if (!(o is ModularAvatarMenuItem c)) return false;
+                if (p.HasValue)
+                {
+                    foreach (var component in c.GetComponents<MenuAction>())
+                    {
+                        if (component.BindsParameter(p.Value)) return true;
+                    }
+
+                    return false;
+                }
+
+                return true;
+            });
+        }
+
         public MenuItemCoreGUI(SerializedObject obj, Action redraw)
         {
             _obj = obj;
@@ -68,6 +91,9 @@ namespace nadena.dev.modular_avatar.core.editor
             );
 
             _name = gameObjects.FindProperty("m_Name");
+            _controlGroup = obj.FindProperty(nameof(ModularAvatarMenuItem.controlGroup));
+            _isDefault = obj.FindProperty(nameof(ModularAvatarMenuItem.isDefault));
+
             var control = obj.FindProperty(nameof(ModularAvatarMenuItem.Control));
 
             _texture = control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.icon));
@@ -87,6 +113,12 @@ namespace nadena.dev.modular_avatar.core.editor
             _previewGUI = new MenuPreviewGUI(redraw);
         }
 
+        /// <summary>
+        /// Builds a menu item GUI for a raw VRCExpressionsMenu.Control reference.
+        /// </summary>
+        /// <param name="parameterReference"></param>
+        /// <param name="_control"></param>
+        /// <param name="redraw"></param>
         public MenuItemCoreGUI(GameObject parameterReference, SerializedProperty _control, Action redraw)
         {
             _obj = _control.serializedObject;
@@ -100,6 +132,9 @@ namespace nadena.dev.modular_avatar.core.editor
             _value = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.value));
             _submenu = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.subMenu));
 
+            _controlGroup = null;
+            _isDefault = null;
+
             _parameterGUI = new ParameterGUI(parameterReference, parameter, redraw);
 
             _subParamsRoot = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.subParameters));
@@ -112,15 +147,39 @@ namespace nadena.dev.modular_avatar.core.editor
 
         public void DoGUI()
         {
+            _hasActions = HasActions();
+
             EditorGUILayout.BeginHorizontal();
 
             EditorGUILayout.BeginVertical();
+            EditorGUI.BeginChangeCheck();
             EditorGUILayout.PropertyField(_name, G("menuitem.prop.name"));
+            if (EditorGUI.EndChangeCheck())
+            {
+                _name.serializedObject.ApplyModifiedProperties();
+            }
+
             EditorGUILayout.PropertyField(_texture, G("menuitem.prop.icon"));
             EditorGUILayout.PropertyField(_type, G("menuitem.prop.type"));
-            EditorGUILayout.PropertyField(_value, G("menuitem.prop.value"));
 
-            _parameterGUI.DoGUI();
+            if (_hasActions)
+            {
+                EditorGUILayout.PropertyField(_controlGroup, G("menuitem.prop.control_group"));
+                if (_controlGroup.hasMultipleDifferentValues || _controlGroup.objectReferenceValue != null)
+                {
+                    using (new EditorGUI.DisabledScope(_obj.isEditingMultipleObjects))
+                    {
+                        EditorGUILayout.PropertyField(_isDefault, G("menuitem.prop.is_default"));
+                    }
+                }
+            }
+
+            if (!_hasActions || !HasActions(TargetParameter.BaseParameter))
+            {
+                EditorGUILayout.PropertyField(_value, G("menuitem.prop.value"));
+            }
+
+            _parameterGUI.DoGUI(!_hasActions || !HasActions(TargetParameter.BaseParameter));
 
             EditorGUILayout.EndVertical();
 
@@ -172,6 +231,7 @@ namespace nadena.dev.modular_avatar.core.editor
                     case VRCExpressionsMenu.Control.ControlType.SubMenu:
                     {
                         object menuSource = null;
+                        bool canExpand = false;
 
                         if (_prop_submenuSource != null)
                         {
@@ -204,6 +264,8 @@ namespace nadena.dev.modular_avatar.core.editor
                                 case SubmenuSource.MenuAsset:
                                 {
                                     EditorGUILayout.PropertyField(_submenu, G("menuitem.prop.submenu_asset"));
+                                    canExpand = true;
+
                                     if (_submenu.hasMultipleDifferentValues) break;
                                     menuSource = _submenu.objectReferenceValue;
                                     break;
@@ -238,13 +300,43 @@ namespace nadena.dev.modular_avatar.core.editor
                             }
                         }
 
+                        if (canExpand && (_submenu.hasMultipleDifferentValues || _submenu.objectReferenceValue != null))
+                        {
+                            if (GUILayout.Button(G("menuitem.misc.extract")))
+                            {
+                                _obj.ApplyModifiedProperties();
+
+                                foreach (var targetObj in _obj.targetObjects)
+                                {
+                                    var menuItem = (ModularAvatarMenuItem) targetObj;
+                                    if (menuItem.Control.type == VRCExpressionsMenu.Control.ControlType.SubMenu
+                                        && menuItem.Control.subMenu != null
+                                        && menuItem.MenuSource == SubmenuSource.MenuAsset
+                                       )
+                                    {
+                                        Undo.RecordObject(menuItem, "Extract menu");
+                                        MenuExtractor.ExtractSingleLayerMenu(menuItem.Control.subMenu,
+                                            menuItem.gameObject);
+                                        menuItem.Control.subMenu = null;
+                                        menuItem.MenuSource = SubmenuSource.Children;
+                                        menuItem.menuSource_otherObjectChildren = null;
+                                        EditorUtility.SetDirty(menuItem);
+                                        PrefabUtility.RecordPrefabInstancePropertyModifications(menuItem);
+                                    }
+                                }
+
+                                _obj.Update();
+                            }
+                        }
+
                         break;
                     }
                     case VRCExpressionsMenu.Control.ControlType.RadialPuppet:
                     {
                         EnsureParameterCount(1);
 
-                        _subParams[0].DoGUI(G("menuitem.param.rotation"));
+                        _subParams[0].DoGUI(!_hasActions || !HasActions(TargetParameter.RadialParam),
+                            G("menuitem.param.rotation"));
 
                         break;
                     }
@@ -253,11 +345,13 @@ namespace nadena.dev.modular_avatar.core.editor
                         EnsureParameterCount(2);
                         EnsureLabelCount(4);
 
-                        EditorGUILayout.LabelField("Parameters", EditorStyles.boldLabel);
+                        EditorGUILayout.LabelField(G("menuitem.label.parameters"), EditorStyles.boldLabel);
                         EditorGUILayout.Space(2);
 
-                        _subParams[0].DoGUI(G("menuitem.param.horizontal"));
-                        _subParams[1].DoGUI(G("menuitem.param.vertical"));
+                        _subParams[0].DoGUI(!_hasActions || !HasActions(TargetParameter.Horizontal),
+                            G("menuitem.param.horizontal"));
+                        _subParams[1].DoGUI(!_hasActions || !HasActions(TargetParameter.Vertical),
+                            G("menuitem.param.vertical"));
 
                         DoFourAxisLabels(false);
 
@@ -354,10 +448,10 @@ namespace nadena.dev.modular_avatar.core.editor
             center.xMin += blockWidth;
             center.xMax -= blockWidth;
 
-            SingleLabel(0, up);
-            SingleLabel(1, right);
-            SingleLabel(2, down);
-            SingleLabel(3, left);
+            SingleLabel(0, up, TargetParameter.Up);
+            SingleLabel(1, right, TargetParameter.Right);
+            SingleLabel(2, down, TargetParameter.Down);
+            SingleLabel(3, left, TargetParameter.Left);
 
             var rect_param_l = center;
             rect_param_l.yMin = rect_param_l.yMax - EditorGUIUtility.singleLineHeight;
@@ -367,7 +461,7 @@ namespace nadena.dev.modular_avatar.core.editor
             if (showParams) CenterLabel(rect_param_l, G("menuitem.prop.parameter"), EditorStyles.label);
             CenterLabel(rect_name_l, G("menuitem.prop.label"), EditorStyles.label);
 
-            void SingleLabel(int index, Rect block)
+            void SingleLabel(int index, Rect block, TargetParameter parameter)
             {
                 var prop_name = _labels[index].FindPropertyRelative(nameof(VRCExpressionsMenu.Control.Label.name));
                 var prop_icon = _labels[index].FindPropertyRelative(nameof(VRCExpressionsMenu.Control.Label.icon));
@@ -384,7 +478,7 @@ namespace nadena.dev.modular_avatar.core.editor
                 EditorGUI.PropertyField(rect_name, prop_name, GUIContent.none);
                 if (showParams)
                 {
-                    _subParams[index].DoGUI(rect_param, GUIContent.none);
+                    _subParams[index].DoGUI(rect_param, !_hasActions || !HasActions(parameter), GUIContent.none);
                 }
 
                 var tex = prop_icon.objectReferenceValue as Texture;
diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuPreviewGUI.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuPreviewGUI.cs
index 5ca1f0c0..d3879178 100644
--- a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuPreviewGUI.cs
+++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuPreviewGUI.cs
@@ -7,11 +7,86 @@ using VRC.SDK3.Avatars.ScriptableObjects;
 
 namespace nadena.dev.modular_avatar.core.editor
 {
-    internal class MenuPreviewGUI
+    internal class MenuObjectHeader
     {
         private const float INDENT_PER_LEVEL = 2;
+        private static float _indentLevel = 0;
+
+        private UnityEngine.Object _headerObj;
+        private SerializedProperty _disableProp;
+
+        public MenuObjectHeader(UnityEngine.Object headerObj, SerializedProperty disableProp = null)
+        {
+            _headerObj = headerObj;
+            _disableProp = disableProp;
+        }
+
+        public static void ClearIndent()
+        {
+            _indentLevel = 0;
+        }
+
+        public IDisposable Scope()
+        {
+            GUILayout.BeginHorizontal();
+            GUILayout.Space(_indentLevel);
+            _indentLevel += INDENT_PER_LEVEL;
+
+            EditorGUILayout.BeginVertical(EditorStyles.helpBox);
+
+            if (_headerObj != null)
+            {
+                var oldIndent = EditorGUI.indentLevel;
+                EditorGUI.indentLevel = 0;
+                try
+                {
+                    GUILayout.BeginHorizontal();
+                    using (new EditorGUI.DisabledScope(true))
+                    {
+                        EditorGUILayout.ObjectField(new GUIContent(), _headerObj, _headerObj.GetType(),
+                            true,
+                            GUILayout.ExpandWidth(true));
+                    }
+
+                    if (_disableProp != null)
+                    {
+                        _disableProp.serializedObject.Update();
+                        GUILayout.Space(10);
+                        GUILayout.Label("Enabled", GUILayout.Width(50));
+                        EditorGUILayout.PropertyField(_disableProp, GUIContent.none,
+                            GUILayout.Width(EditorGUIUtility.singleLineHeight));
+                        _disableProp.serializedObject.ApplyModifiedProperties();
+                    }
+
+                    GUILayout.EndHorizontal();
+                }
+                finally
+                {
+                    EditorGUI.indentLevel = oldIndent;
+                }
+            }
+
+            return new ScopeSentinel();
+        }
+
+        private class ScopeSentinel : IDisposable
+        {
+            public ScopeSentinel()
+            {
+            }
+
+            public void Dispose()
+            {
+                GUILayout.EndVertical();
+                _indentLevel -= INDENT_PER_LEVEL;
+                GUILayout.EndHorizontal();
+            }
+        }
+    }
+
+    internal class MenuPreviewGUI
+    {
         private Action _redraw;
-        private float _indentLevel = 0;
         private readonly Dictionary<object, Action> _guiNodes = new Dictionary<object, Action>();
 
         public MenuPreviewGUI(Action redraw)
@@ -21,19 +96,16 @@ namespace nadena.dev.modular_avatar.core.editor
 
         public void DoGUI(MenuSource root)
         {
-            _indentLevel = 0;
             new VisitorContext(this).PushNode(root);
         }
 
         public void DoGUI(ModularAvatarMenuInstaller root)
         {
-            _indentLevel = 0;
             new VisitorContext(this).PushMenuInstaller(root);
         }
 
         public void DoGUI(VRCExpressionsMenu menu, GameObject parameterReference = null)
         {
-            _indentLevel = 0;
             new VisitorContext(this).PushNode(menu);
         }
 
@@ -48,70 +120,6 @@ namespace nadena.dev.modular_avatar.core.editor
             gui();
         }
 
-        private class Header
-        {
-            private MenuPreviewGUI _gui;
-            private UnityEngine.Object _headerObj;
-            private SerializedProperty _disableProp;
-
-            public Header(MenuPreviewGUI gui, UnityEngine.Object headerObj, SerializedProperty disableProp = null)
-            {
-                _gui = gui;
-                _headerObj = headerObj;
-                _disableProp = disableProp;
-            }
-
-            public IDisposable Scope()
-            {
-                GUILayout.BeginHorizontal();
-                GUILayout.Space(_gui._indentLevel);
-                _gui._indentLevel += INDENT_PER_LEVEL;
-
-                EditorGUILayout.BeginVertical(EditorStyles.helpBox);
-
-                if (_headerObj != null)
-                {
-                    GUILayout.BeginHorizontal();
-                    using (new EditorGUI.DisabledScope(true))
-                    {
-                        EditorGUILayout.ObjectField(new GUIContent(), _headerObj, _headerObj.GetType(),
-                            true,
-                            GUILayout.ExpandWidth(true));
-                    }
-
-                    if (_disableProp != null)
-                    {
-                        _disableProp.serializedObject.Update();
-                        GUILayout.Space(20);
-                        GUILayout.Label("Enabled", GUILayout.Width(50));
-                        EditorGUILayout.PropertyField(_disableProp, GUIContent.none,
-                            GUILayout.Width(EditorGUIUtility.singleLineHeight));
-                        _disableProp.serializedObject.ApplyModifiedProperties();
-                    }
-
-                    GUILayout.EndHorizontal();
-                }
-
-                return new ScopeSentinel(_gui);
-            }
-
-            private class ScopeSentinel : IDisposable
-            {
-                private readonly MenuPreviewGUI _gui;
-
-                public ScopeSentinel(MenuPreviewGUI gui)
-                {
-                    _gui = gui;
-                }
-
-                public void Dispose()
-                {
-                    GUILayout.EndVertical();
-                    _gui._indentLevel -= INDENT_PER_LEVEL;
-                    GUILayout.EndHorizontal();
-                }
-            }
-        }
 
         private class VisitorContext : NodeContext
         {
@@ -127,7 +135,7 @@ namespace nadena.dev.modular_avatar.core.editor
             {
                 _gui.PushGuiNode((expMenu, parameterReference), () =>
                 {
-                    var header = new Header(_gui, expMenu);
+                    var header = new MenuObjectHeader(expMenu);
                     var obj = new SerializedObject(expMenu);
                     var controls = obj.FindProperty(nameof(expMenu.controls));
                     var subGui = new List<MenuItemCoreGUI>();
@@ -143,7 +151,7 @@ namespace nadena.dev.modular_avatar.core.editor
                         {
                             foreach (var gui in subGui)
                             {
-                                using (new Header(_gui, null).Scope())
+                                using (new MenuObjectHeader(null).Scope())
                                 {
                                     gui.DoGUI();
                                 }
@@ -164,7 +172,7 @@ namespace nadena.dev.modular_avatar.core.editor
                 {
                     _gui.PushGuiNode(item, () =>
                     {
-                        var header = new Header(_gui, item,
+                        var header = new MenuObjectHeader(item,
                             new SerializedObject(item.gameObject).FindProperty("m_IsActive"));
                         var gui = new MenuItemCoreGUI(new SerializedObject(item), _gui._redraw);
                         return () =>
@@ -178,7 +186,7 @@ namespace nadena.dev.modular_avatar.core.editor
                 }
                 else
                 {
-                    using (new Header(_gui, source as UnityEngine.Object).Scope())
+                    using (new MenuObjectHeader(source as UnityEngine.Object).Scope())
                     {
                         if (_visited.Contains(source)) return;
                         _visited.Add(source);
@@ -190,7 +198,7 @@ namespace nadena.dev.modular_avatar.core.editor
 
             public void PushNode(ModularAvatarMenuInstaller installer)
             {
-                using (new Header(_gui, installer).Scope())
+                using (new MenuObjectHeader(installer).Scope())
                 {
                     PushMenuInstaller(installer);
                 }
diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ParameterField.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ParameterField.cs
index 1f2589bd..5a809a59 100644
--- a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ParameterField.cs
+++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ParameterField.cs
@@ -23,17 +23,26 @@ namespace nadena.dev.modular_avatar.core.editor
             _redraw = redraw;
         }
 
-        public void DoGUI(GUIContent label = null)
+        public void DoGUI(bool enabled, GUIContent label = null)
         {
             DoGUI(EditorGUILayout.GetControlRect(
                 true,
                 EditorGUIUtility.singleLineHeight
-            ), label);
+            ), enabled, label);
         }
 
-        public void DoGUI(Rect rect, GUIContent label = null)
+        public void DoGUI(Rect rect, bool enabled, GUIContent label = null)
         {
-            if (label == null) label = G("menuitem.prop.parameter");
+            label = label ?? G("menuitem.prop.parameter");
+
+            if (!enabled)
+            {
+                using (new EditorGUI.DisabledScope(true))
+                {
+                    EditorGUI.TextField(rect, label, S("menuitem.param.controlled_by_action"));
+                    return;
+                }
+            }
 
             if (_parameterReference != null) GUILayout.Space(-2);
             GUILayout.BeginHorizontal();
diff --git a/Packages/nadena.dev.modular-avatar/Editor/Localization/en.json b/Packages/nadena.dev.modular-avatar/Editor/Localization/en.json
index a0dfca7d..95b406c7 100644
--- a/Packages/nadena.dev.modular-avatar/Editor/Localization/en.json
+++ b/Packages/nadena.dev.modular-avatar/Editor/Localization/en.json
@@ -106,5 +106,15 @@
   "menuitem.label.control_labels_and_params": "Control Labels and Parameters",
   "menuitem.label.control_labels": "Control Labels",
   "menuitem.misc.multiple": "(multiple)",
-  "menuitem.misc.no_icon": "(no icon)"
+  "menuitem.misc.no_icon": "(no icon)",
+  "menuitem.misc.extract": "Extract to objects",
+  "menuitem.label.parameters": "Parameters",
+  "action.toggle_object.header": "Objects to show/hide",
+  "menu_tree.title": "Select menu",
+  "menuitem.param.controlled_by_action": "<controlled by action>",
+  "control_group.foldout.actions": "Actions",
+  "control_group.foldout.menu_items": "Bound menu items",
+  "menuitem.prop.control_group": "Control Group",
+  "menuitem.prop.control_group.tooltip": "Only one toggle in a given group can be selected at the same time",
+  "menuitem.prop.is_default": "Is Group Default"
 }
\ No newline at end of file
diff --git a/Packages/nadena.dev.modular-avatar/Editor/Localization/ja.json b/Packages/nadena.dev.modular-avatar/Editor/Localization/ja.json
index c8a461ae..d91bc6ea 100644
--- a/Packages/nadena.dev.modular-avatar/Editor/Localization/ja.json
+++ b/Packages/nadena.dev.modular-avatar/Editor/Localization/ja.json
@@ -103,5 +103,15 @@
   "menuitem.label.control_labels_and_params": "表示名・パラメーター",
   "menuitem.label.control_labels": "表示名",
   "menuitem.misc.multiple": "(複数設定)",
-  "menuitem.misc.no_icon": "(アイコン無し)"
+  "menuitem.misc.no_icon": "(アイコン無し)",
+  "menuitem.misc.extract": "オブジェクトに展開",
+  "menuitem.label.parameters": "パラメーター",
+  "action.toggle_object.header": "表示・非表示にするオブジェクト",
+  "menu_tree.title": "メニューを選択",
+  "menuitem.param.controlled_by_action": "<アクションで制御されています>",
+  "control_group.foldout.actions": "アクション",
+  "control_group.foldout.menu_items": "関連付けされたメニューアイテム",
+  "menuitem.prop.control_group": "コントロールグループ",
+  "menuitem.prop.control_group.tooltip": "同じグループ内では、一つのトグルしか起動できません",
+  "menuitem.prop.is_default": "グループの初期設定にする"
 }
diff --git a/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing.meta b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing.meta
new file mode 100644
index 00000000..a4f309b9
--- /dev/null
+++ b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing.meta
@@ -0,0 +1,4 @@
+fileFormatVersion: 2
+guid: d1cf0a36e200446cb099ec448b446495
+timeCreated: 1677334996
+folderAsset: yes
\ No newline at end of file
diff --git a/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs
new file mode 100644
index 00000000..bbc8afe9
--- /dev/null
+++ b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs
@@ -0,0 +1,430 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using nadena.dev.modular_avatar.editor.ErrorReporting;
+using UnityEditor;
+using UnityEditor.Animations;
+using UnityEngine;
+using VRC.SDK3.Avatars.Components;
+using VRC.SDK3.Avatars.ScriptableObjects;
+using VRC.SDKBase;
+using Object = UnityEngine.Object;
+
+namespace nadena.dev.modular_avatar.core.editor
+{
+    internal class ActionGenerator
+    {
+        private const string DIRECT_BLEND_TREE_PARAM = "_MA/ONE";
+        private readonly BuildContext _context;
+
+        public ActionGenerator(BuildContext context)
+        {
+            _context = context;
+        }
+
+        public void OnPreprocessAvatar(VRCAvatarDescriptor avatar)
+        {
+            // Locate MenuActions
+            var actionMenus = avatar.GetComponentsInChildren<MenuAction>(true)
+                .Select(a => ((Component) a).gameObject.GetComponent<ModularAvatarMenuItem>())
+                .Where(item => item != null)
+                .ToImmutableHashSet();
+
+            // Generate the root blendtree and animation; insert into the FX layer
+            var animLayers = avatar.baseAnimationLayers;
+            int fxLayerIndex = -1;
+            AnimatorController controller = null;
+
+            // TODO: refactor out layer manipulation here (+ the base state generator)
+
+            for (int i = 0; i < animLayers.Length; i++)
+            {
+                if (animLayers[i].type == VRCAvatarDescriptor.AnimLayerType.FX)
+                {
+                    fxLayerIndex = i;
+                    controller = _context.DeepCloneAnimator(animLayers[i].animatorController);
+                    break;
+                }
+            }
+
+            if (controller == null)
+            {
+                controller = new AnimatorController();
+                controller.name = "FX Controller";
+                _context.SaveAsset(controller);
+            }
+
+            animLayers[fxLayerIndex].animatorController = controller;
+            avatar.baseAnimationLayers = animLayers;
+
+            var parameters = controller.parameters.ToList();
+            parameters.Add(new AnimatorControllerParameter()
+            {
+                name = DIRECT_BLEND_TREE_PARAM,
+                type = AnimatorControllerParameterType.Float,
+                defaultFloat = 1,
+            });
+
+            var actions = GenerateActions(avatar, actionMenus, parameters);
+
+            controller.parameters = parameters.ToArray();
+
+            int layersToInsert = 2; // TODO
+
+            var rootBlendTree = GenerateRootBlendLayer(actions);
+            AdjustAllBehaviors(controller, b =>
+            {
+                if (b is VRCAnimatorLayerControl lc && lc.playable == VRC_AnimatorLayerControl.BlendableLayer.FX)
+                {
+                    lc.layer += layersToInsert;
+                }
+            });
+            foreach (var layer in controller.layers)
+            {
+                layer.syncedLayerIndex += layersToInsert;
+            }
+
+            var layerList = controller.layers.ToList();
+            layerList.Insert(0, GenerateBlendshapeBaseLayer(avatar));
+            rootBlendTree.defaultWeight = 1;
+            layerList.Insert(0, rootBlendTree);
+            layerList[2].defaultWeight = 1;
+            controller.layers = layerList.ToArray();
+
+            foreach (var action in avatar.GetComponentsInChildren<MenuAction>(true))
+            {
+                Object.DestroyImmediate((UnityEngine.Object) action);
+            }
+        }
+
+        private AnimatorControllerLayer GenerateBlendshapeBaseLayer(VRCAvatarDescriptor avatar)
+        {
+            AnimatorControllerLayer layer = new AnimatorControllerLayer();
+            AnimationClip clip = new AnimationClip();
+            foreach (var renderer in avatar.GetComponentsInChildren<SkinnedMeshRenderer>())
+            {
+                int nShapes = renderer.sharedMesh.blendShapeCount;
+                for (int i = 0; i < nShapes; i++)
+                {
+                    float value = renderer.GetBlendShapeWeight(i);
+                    if (value > 0.000001f)
+                    {
+                        clip.SetCurve(
+                            RuntimeUtil.AvatarRootPath(renderer.gameObject),
+                            typeof(SkinnedMeshRenderer),
+                            "blendShape." + renderer.sharedMesh.GetBlendShapeName(i),
+                            AnimationCurve.Constant(0, 1, value)
+                        );
+                    }
+                }
+            }
+
+            layer.stateMachine = new AnimatorStateMachine();
+            _context.SaveAsset(layer.stateMachine);
+
+            var state = layer.stateMachine.AddState("Base");
+            state.motion = clip;
+            state.writeDefaultValues = false;
+
+            layer.defaultWeight = 1;
+
+            return layer;
+        }
+
+        private void AdjustAllBehaviors(AnimatorController controller, Action<StateMachineBehaviour> action)
+        {
+            HashSet<object> visited = new HashSet<object>();
+            foreach (var layer in controller.layers)
+            {
+                VisitStateMachine(layer.stateMachine);
+            }
+
+            void VisitStateMachine(AnimatorStateMachine layerStateMachine)
+            {
+                if (!visited.Add(layerStateMachine)) return;
+                foreach (var state in layerStateMachine.states)
+                {
+                    foreach (var behaviour in state.state.behaviours)
+                    {
+                        action(behaviour);
+                    }
+                }
+
+                foreach (var child in layerStateMachine.stateMachines)
+                {
+                    VisitStateMachine(child.stateMachine);
+                }
+            }
+        }
+
+        private AnimatorControllerLayer GenerateRootBlendLayer(List<BlendTree> actions)
+        {
+            var motion = new BlendTree();
+            motion.name = "Menu Actions (generated)";
+            motion.blendParameter = DIRECT_BLEND_TREE_PARAM;
+            motion.blendType = BlendTreeType.Direct;
+            motion.children = actions.Select(a => new ChildMotion()
+            {
+                motion = a,
+                directBlendParameter = DIRECT_BLEND_TREE_PARAM,
+                timeScale = 1,
+            }).ToArray();
+
+            var layer = new AnimatorControllerLayer();
+            layer.name = "Menu Actions (generated)";
+            layer.defaultWeight = 1;
+            layer.blendingMode = AnimatorLayerBlendingMode.Override;
+            layer.stateMachine = new AnimatorStateMachine();
+            layer.stateMachine.name = "Menu Actions (generated)";
+            //layer.stateMachine.hideFlags = HideFlags.HideInHierarchy;
+            _context.SaveAsset(layer.stateMachine);
+
+            var rootState = layer.stateMachine.AddState("Root");
+            rootState.motion = motion;
+
+            return layer;
+        }
+
+        private List<BlendTree> GenerateActions(
+            VRCAvatarDescriptor descriptor,
+            IEnumerable<ModularAvatarMenuItem> items,
+            List<AnimatorControllerParameter> acParameters)
+        {
+            var expParams = descriptor.expressionParameters;
+            if (expParams != null)
+            {
+                expParams = Object.Instantiate(expParams);
+                _context.SaveAsset(expParams);
+            }
+            else
+            {
+                expParams = ScriptableObject.CreateInstance<VRCExpressionParameters>();
+                expParams.name = "Expression Parameters";
+                _context.SaveAsset(expParams);
+                descriptor.expressionParameters = expParams;
+            }
+
+            List<VRCExpressionParameters.Parameter> expParameters = expParams.parameters.ToList();
+            List<BlendTree> blendTrees = new List<BlendTree>();
+
+            Dictionary<Component, List<ModularAvatarMenuItem>> groupedItems =
+                new Dictionary<Component, List<ModularAvatarMenuItem>>();
+
+            foreach (var item in items)
+            {
+                List<ModularAvatarMenuItem> group;
+                if (item.controlGroup)
+                {
+                    if (!groupedItems.TryGetValue(item.controlGroup, out group))
+                    {
+                        group = new List<ModularAvatarMenuItem>();
+                        groupedItems.Add(item.controlGroup, group);
+                    }
+                }
+                else
+                {
+                    group = new List<ModularAvatarMenuItem>();
+                    groupedItems.Add(item, group);
+                }
+
+                group.Add(item);
+            }
+
+            int paramIndex = 0;
+            foreach (var kvp in groupedItems)
+            {
+                // sort default first
+                var group = kvp.Value;
+                group.Sort((a, b) => b.isDefault.CompareTo(a.isDefault));
+
+                // Generate parameter
+                var paramname = "_MA/A/" + kvp.Key.gameObject.name + "/" + (paramIndex++);
+
+                var expParamType = group.Count > 1
+                    ? VRCExpressionParameters.ValueType.Int
+                    : VRCExpressionParameters.ValueType.Bool;
+
+                expParameters.Add(new VRCExpressionParameters.Parameter()
+                {
+                    name = paramname,
+                    defaultValue = 0, // TODO
+                    valueType = expParamType,
+                    saved = false, // TODO
+                });
+                acParameters.Add(new AnimatorControllerParameter()
+                {
+                    name = paramname,
+                    type = AnimatorControllerParameterType.Float,
+                    defaultFloat = 0, // TODO
+                });
+
+                var hasDefault = group[0].isDefault;
+                for (int i = 0; i < group.Count; i++)
+                {
+                    var control = group[i].Control;
+                    control.parameter = new VRCExpressionsMenu.Control.Parameter() {name = paramname};
+                    control.value = hasDefault ? i : i + 1;
+                }
+
+                var blendTree = new BlendTree();
+                blendTree.name = paramname;
+                blendTree.blendParameter = paramname;
+                blendTree.blendType = BlendTreeType.Simple1D;
+                blendTree.useAutomaticThresholds = false;
+
+                List<ChildMotion> children = new List<ChildMotion>();
+
+                List<Motion> motions = GenerateMotions(group, out var inactiveMotion);
+
+                if (!hasDefault)
+                {
+                    motions.Insert(0, inactiveMotion);
+                }
+
+                for (int i = 0; i < motions.Count; i++)
+                {
+                    children.Add(new ChildMotion()
+                    {
+                        motion = motions[i],
+                        position = new Vector2(i, 0),
+                        threshold = i - 0.1f,
+                        timeScale = 1,
+                    });
+                    children.Add(new ChildMotion()
+                    {
+                        motion = motions[i],
+                        position = new Vector2(i, 0),
+                        threshold = i + 0.1f,
+                        timeScale = 1,
+                    });
+                }
+
+                blendTree.children = children.ToArray();
+
+                _context.SaveAsset(blendTree);
+                blendTrees.Add(blendTree);
+            }
+
+            expParams.parameters = expParameters.ToArray();
+            descriptor.expressionParameters = expParams;
+
+            return blendTrees;
+        }
+
+        void MergeCurves(
+            IDictionary<MenuCurveBinding, (Component, AnimationCurve)> curves,
+            ModularAvatarMenuItem item,
+            Func<SwitchedMenuAction, IDictionary<MenuCurveBinding, AnimationCurve>> getCurves,
+            bool ignoreDuplicates
+        )
+        {
+            foreach (var action in item.GetComponents<SwitchedMenuAction>())
+            {
+                var newCurves = getCurves(action);
+
+                foreach (var curvePair in newCurves)
+                {
+                    var binding = curvePair.Key;
+                    var curve = curvePair.Value;
+
+                    if (curves.TryGetValue(binding, out var existing))
+                    {
+                        if (!ignoreDuplicates)
+                        {
+                            BuildReport.LogFatal("animation_gen.conflict", new object[]
+                            {
+                                binding,
+                                existing.Item1.gameObject.name,
+                                existing.Item1.GetType().Name,
+                                item.gameObject.name,
+                                item.GetType().Name
+                            }, binding.target, existing.Item1, item);
+                        }
+                    }
+                    else
+                    {
+                        curves.Add(binding, (item, curve));
+                    }
+                }
+            }
+        }
+
+        private List<Motion> GenerateMotions(List<ModularAvatarMenuItem> items, out Motion inactiveMotion)
+        {
+            Dictionary<MenuCurveBinding, (Component, AnimationCurve)> inactiveCurves =
+                new Dictionary<MenuCurveBinding, (Component, AnimationCurve)>();
+
+            var defaultItems = items.Where(i => i.isDefault).ToList();
+            if (defaultItems.Count > 1)
+            {
+                BuildReport.LogFatal("animation_gen.multiple_defaults",
+                    strings: Array.Empty<object>(),
+                    objects: defaultItems.ToArray<UnityEngine.Object>()
+                );
+                defaultItems.RemoveRange(1, defaultItems.Count - 1);
+            }
+
+            if (defaultItems.Count > 0)
+            {
+                MergeCurves(inactiveCurves, defaultItems[0], a => a.GetInactiveCurves(true), false);
+            }
+
+            foreach (var item in items)
+            {
+                if (defaultItems.Count == 0 || defaultItems[0] != item)
+                {
+                    MergeCurves(inactiveCurves, item, a => a.GetInactiveCurves(false), true);
+                }
+            }
+
+            inactiveMotion = CurvesToMotion(inactiveCurves);
+            var groupName = (items[0].controlGroup != null
+                ? items[0].controlGroup.gameObject.name
+                : items[0].gameObject.name);
+            inactiveMotion.name =
+                groupName
+                + " (Inactive)";
+
+            List<Motion> motions = new List<Motion>();
+
+            foreach (var item in items)
+            {
+                Dictionary<MenuCurveBinding, (Component, AnimationCurve)> activeCurves =
+                    new Dictionary<MenuCurveBinding, (Component, AnimationCurve)>();
+
+                MergeCurves(activeCurves, item, a => a.GetCurves(), false);
+                foreach (var kvp in inactiveCurves)
+                {
+                    if (!activeCurves.ContainsKey(kvp.Key))
+                    {
+                        activeCurves.Add(kvp.Key, kvp.Value);
+                    }
+                }
+
+                var clip = CurvesToMotion(activeCurves);
+                clip.name = groupName + " (" + item.gameObject.name + ")";
+                motions.Add(clip);
+            }
+
+            return motions;
+        }
+
+        Motion CurvesToMotion(IDictionary<MenuCurveBinding, (Component, AnimationCurve)> curves)
+        {
+            var clip = new AnimationClip();
+            _context.SaveAsset(clip);
+            foreach (var entry in curves)
+            {
+                clip.SetCurve(
+                    RuntimeUtil.AvatarRootPath(entry.Key.target),
+                    entry.Key.type,
+                    entry.Key.property,
+                    entry.Value.Item2 // curve
+                );
+            }
+
+            return clip;
+        }
+    }
+}
\ No newline at end of file
diff --git a/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs.meta
new file mode 100644
index 00000000..afb8152e
--- /dev/null
+++ b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: be5c2a67d3b448c5bd8c439f537a1766
+timeCreated: 1677335026
\ No newline at end of file
diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions.meta b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions.meta
new file mode 100644
index 00000000..e354c23a
--- /dev/null
+++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions.meta
@@ -0,0 +1,4 @@
+fileFormatVersion: 2
+guid: 31ce1123a74443c1ba7a126d4b8919b1
+timeCreated: 1677317241
+folderAsset: yes
\ No newline at end of file
diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs
new file mode 100644
index 00000000..d62ac780
--- /dev/null
+++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using UnityEngine;
+using Object = System.Object;
+
+namespace nadena.dev.modular_avatar.core
+{
+    [RequireComponent(typeof(ModularAvatarMenuItem))]
+    [AddComponentMenu("Modular Avatar/MA Action Toggle Object")]
+    public class ActionToggleObject : AvatarTagComponent, SwitchedMenuAction
+    {
+        [Serializable]
+        public class ObjectEntry
+        {
+            public GameObject target;
+            public bool Active;
+        }
+
+        public List<ObjectEntry> Objects;
+
+        protected override void OnValidate()
+        {
+            base.OnValidate();
+
+            if (Objects == null)
+            {
+                Objects = new List<ObjectEntry>();
+            }
+        }
+
+        public ImmutableDictionary<MenuCurveBinding, AnimationCurve> GetCurves()
+        {
+            return Objects.Select(obj =>
+                new KeyValuePair<MenuCurveBinding, AnimationCurve>(
+                    new MenuCurveBinding(obj.target, typeof(GameObject), "m_IsActive"),
+                    AnimationCurve.Constant(0, 1, obj.Active ? 1 : 0))
+            ).ToImmutableDictionary();
+        }
+
+        public ImmutableDictionary<MenuCurveBinding, AnimationCurve> GetInactiveCurves(bool isDefault)
+        {
+            return Objects.Select(obj =>
+                {
+                    bool active;
+                    if (isDefault)
+                    {
+                        active = !obj.Active; // inactive state is the opposite of the default state
+                    }
+                    else
+                    {
+                        active = obj.target.activeSelf; // inactive state is the current state
+                    }
+
+                    return new KeyValuePair<MenuCurveBinding, AnimationCurve>(
+                        new MenuCurveBinding(obj.target, typeof(GameObject), "m_IsActive"),
+                        AnimationCurve.Constant(0, 1, active ? 1 : 0));
+                }
+            ).ToImmutableDictionary();
+        }
+
+        public bool BindsParameter(TargetParameter parameter)
+        {
+            return parameter == TargetParameter.BaseParameter;
+        }
+    }
+}
\ No newline at end of file
diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs.meta b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs.meta
new file mode 100644
index 00000000..a0b00d61
--- /dev/null
+++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: fcac4d9412424173bd294ffd5fc5f9db
+timeCreated: 1677316809
\ No newline at end of file
diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ControlGroup.cs b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ControlGroup.cs
new file mode 100644
index 00000000..c0abf028
--- /dev/null
+++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ControlGroup.cs
@@ -0,0 +1,9 @@
+using UnityEngine;
+
+namespace nadena.dev.modular_avatar.core
+{
+    [AddComponentMenu("Modular Avatar/MA Control Group")]
+    public class ControlGroup : AvatarTagComponent
+    {
+    }
+}
\ No newline at end of file
diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ControlGroup.cs.meta b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ControlGroup.cs.meta
new file mode 100644
index 00000000..c04947a3
--- /dev/null
+++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ControlGroup.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 99c60d83ad614e81a0488d98b83b5c1c
+timeCreated: 1677317301
\ No newline at end of file
diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/MenuAction.cs b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/MenuAction.cs
new file mode 100644
index 00000000..156b1400
--- /dev/null
+++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/MenuAction.cs
@@ -0,0 +1,102 @@
+using System;
+using System.Collections.Immutable;
+using UnityEngine;
+
+namespace nadena.dev.modular_avatar.core
+{
+    public interface MenuAction
+    {
+        /// <summary>
+        /// Returns whether this action binds to the given parameter.
+        /// </summary>
+        /// <param name="parameter"></param>
+        /// <returns></returns>
+        bool BindsParameter(TargetParameter parameter);
+    }
+
+    public interface SwitchedMenuAction : MenuAction
+    {
+        /// <summary>
+        /// Returns the curves applied when this action is active
+        /// </summary>
+        /// <returns></returns>
+        ImmutableDictionary<MenuCurveBinding, AnimationCurve> GetCurves();
+
+        /// <summary>
+        /// Returns the curves applied when this action is inactive (and no other actions override).
+        /// </summary>
+        /// <param name="isDefault">True if this action is part of the default toggle option.</param>
+        /// <returns></returns>
+        ImmutableDictionary<MenuCurveBinding, AnimationCurve> GetInactiveCurves(bool isDefault);
+    }
+
+    public enum TargetParameter
+    {
+        BaseParameter,
+        RadialParam,
+        Horizontal,
+        Vertical,
+        Up,
+        Right,
+        Down,
+        Left
+    }
+
+    public static class TargetParameterExtension
+    {
+        public static int Index(this TargetParameter p)
+        {
+            switch (p)
+            {
+                case TargetParameter.BaseParameter:
+                    return -1;
+                case TargetParameter.RadialParam: return 0;
+                case TargetParameter.Horizontal: return 0;
+                case TargetParameter.Vertical: return 1;
+                case TargetParameter.Up: return 0;
+                case TargetParameter.Right: return 1;
+                case TargetParameter.Down: return 2;
+                case TargetParameter.Left: return 3;
+                default: throw new NotImplementedException();
+            }
+        }
+    }
+
+    public sealed class MenuCurveBinding
+    {
+        public readonly GameObject target;
+        public readonly Type type;
+        public readonly string property;
+
+        public MenuCurveBinding(GameObject target, Type type, string property)
+        {
+            this.target = target;
+            this.type = type;
+            this.property = property;
+        }
+
+        private bool Equals(MenuCurveBinding other)
+        {
+            return target == other.target && type == other.type && property == other.property;
+        }
+
+        public override bool Equals(object obj)
+        {
+            if (ReferenceEquals(null, obj)) return false;
+            if (ReferenceEquals(this, obj)) return true;
+            if (obj.GetType() != this.GetType()) return false;
+            return Equals((MenuCurveBinding) obj);
+        }
+
+        public override int GetHashCode()
+        {
+            unchecked
+            {
+                var hashCode = (target != null ? target.GetHashCode() : 0);
+                hashCode = (hashCode * 397) ^ (type != null ? type.GetHashCode() : 0);
+                hashCode = (hashCode * 397) ^ (property != null ? property.GetHashCode() : 0);
+                return hashCode;
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/MenuAction.cs.meta b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/MenuAction.cs.meta
new file mode 100644
index 00000000..8b49f440
--- /dev/null
+++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/MenuAction.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 33005b045768474d8ae6f789aa03361b
+timeCreated: 1677595006
\ No newline at end of file
diff --git a/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs
index 79c35be5..051fa26b 100644
--- a/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs
+++ b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs
@@ -2,6 +2,7 @@
 using System.Linq;
 using nadena.dev.modular_avatar.core.menu;
 using UnityEngine;
+using UnityEngine.Serialization;
 using VRC.SDK3.Avatars.ScriptableObjects;
 
 namespace nadena.dev.modular_avatar.core
@@ -20,6 +21,9 @@ namespace nadena.dev.modular_avatar.core
 
         public GameObject menuSource_otherObjectChildren;
 
+        [FormerlySerializedAs("toggleGroup")] public ControlGroup controlGroup;
+        public bool isDefault;
+
         public override void Visit(NodeContext context)
         {
             var cloned = new VirtualControl(Control);