diff --git a/osu.Android.props b/osu.Android.props
index d701aaf199..dc3e14c141 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -52,6 +52,6 @@
   </ItemGroup>
   <ItemGroup>
     <PackageReference Include="ppy.osu.Game.Resources" Version="2020.904.0" />
-    <PackageReference Include="ppy.osu.Framework.Android" Version="2020.923.1" />
+    <PackageReference Include="ppy.osu.Framework.Android" Version="2020.925.0" />
   </ItemGroup>
 </Project>
diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaPlacementBlueprintTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs
similarity index 97%
rename from osu.Game.Rulesets.Mania.Tests/ManiaPlacementBlueprintTestScene.cs
rename to osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs
index 0fe4a3c669..ece523e84c 100644
--- a/osu.Game.Rulesets.Mania.Tests/ManiaPlacementBlueprintTestScene.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs
@@ -16,7 +16,7 @@ using osu.Game.Rulesets.UI.Scrolling;
 using osu.Game.Tests.Visual;
 using osuTK.Graphics;
 
-namespace osu.Game.Rulesets.Mania.Tests
+namespace osu.Game.Rulesets.Mania.Tests.Editor
 {
     public abstract class ManiaPlacementBlueprintTestScene : PlacementBlueprintTestScene
     {
diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaSelectionBlueprintTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs
similarity index 95%
rename from osu.Game.Rulesets.Mania.Tests/ManiaSelectionBlueprintTestScene.cs
rename to osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs
index 149f6582ab..176fbba921 100644
--- a/osu.Game.Rulesets.Mania.Tests/ManiaSelectionBlueprintTestScene.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs
@@ -8,7 +8,7 @@ using osu.Game.Rulesets.Mania.UI;
 using osu.Game.Tests.Visual;
 using osuTK.Graphics;
 
-namespace osu.Game.Rulesets.Mania.Tests
+namespace osu.Game.Rulesets.Mania.Tests.Editor
 {
     public abstract class ManiaSelectionBlueprintTestScene : SelectionBlueprintTestScene
     {
diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneEditor.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneEditor.cs
similarity index 96%
rename from osu.Game.Rulesets.Mania.Tests/TestSceneEditor.cs
rename to osu.Game.Rulesets.Mania.Tests/Editor/TestSceneEditor.cs
index 3b9c03b86a..d3afbc63eb 100644
--- a/osu.Game.Rulesets.Mania.Tests/TestSceneEditor.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneEditor.cs
@@ -8,7 +8,7 @@ using osu.Game.Rulesets.Mania.Configuration;
 using osu.Game.Rulesets.Mania.UI;
 using osu.Game.Tests.Visual;
 
-namespace osu.Game.Rulesets.Mania.Tests
+namespace osu.Game.Rulesets.Mania.Tests.Editor
 {
     [TestFixture]
     public class TestSceneEditor : EditorTestScene
diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNotePlacementBlueprint.cs
similarity index 93%
rename from osu.Game.Rulesets.Mania.Tests/TestSceneHoldNotePlacementBlueprint.cs
rename to osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNotePlacementBlueprint.cs
index b4332264b9..87c74a12cf 100644
--- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNotePlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNotePlacementBlueprint.cs
@@ -8,7 +8,7 @@ using osu.Game.Rulesets.Mania.Objects.Drawables;
 using osu.Game.Rulesets.Objects;
 using osu.Game.Rulesets.Objects.Drawables;
 
-namespace osu.Game.Rulesets.Mania.Tests
+namespace osu.Game.Rulesets.Mania.Tests.Editor
 {
     public class TestSceneHoldNotePlacementBlueprint : ManiaPlacementBlueprintTestScene
     {
diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs
similarity index 97%
rename from osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteSelectionBlueprint.cs
rename to osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs
index 90394f3d1b..24f4c6858e 100644
--- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs
@@ -12,7 +12,7 @@ using osu.Game.Rulesets.Mania.Objects.Drawables;
 using osu.Game.Rulesets.UI.Scrolling;
 using osu.Game.Tests.Visual;
 
-namespace osu.Game.Rulesets.Mania.Tests
+namespace osu.Game.Rulesets.Mania.Tests.Editor
 {
     public class TestSceneHoldNoteSelectionBlueprint : ManiaSelectionBlueprintTestScene
     {
diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs
similarity index 98%
rename from osu.Game.Rulesets.Mania.Tests/TestSceneManiaBeatSnapGrid.cs
rename to osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs
index 639be0bc11..654b752001 100644
--- a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaBeatSnapGrid.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs
@@ -20,7 +20,7 @@ using osu.Game.Screens.Edit;
 using osu.Game.Tests.Visual;
 using osuTK;
 
-namespace osu.Game.Rulesets.Mania.Tests
+namespace osu.Game.Rulesets.Mania.Tests.Editor
 {
     public class TestSceneManiaBeatSnapGrid : EditorClockTestScene
     {
diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs
similarity index 99%
rename from osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectComposer.cs
rename to osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs
index 1a3fa29d4a..c9551ee79e 100644
--- a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectComposer.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs
@@ -23,7 +23,7 @@ using osu.Game.Tests.Visual;
 using osuTK;
 using osuTK.Input;
 
-namespace osu.Game.Rulesets.Mania.Tests
+namespace osu.Game.Rulesets.Mania.Tests.Editor
 {
     public class TestSceneManiaHitObjectComposer : EditorClockTestScene
     {
diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.cs
similarity index 97%
rename from osu.Game.Rulesets.Mania.Tests/TestSceneNotePlacementBlueprint.cs
rename to osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.cs
index 2d97e61aa5..36c34a8fb9 100644
--- a/osu.Game.Rulesets.Mania.Tests/TestSceneNotePlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.cs
@@ -18,7 +18,7 @@ using osu.Game.Tests.Visual;
 using osuTK;
 using osuTK.Input;
 
-namespace osu.Game.Rulesets.Mania.Tests
+namespace osu.Game.Rulesets.Mania.Tests.Editor
 {
     public class TestSceneNotePlacementBlueprint : ManiaPlacementBlueprintTestScene
     {
diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs
similarity index 96%
rename from osu.Game.Rulesets.Mania.Tests/TestSceneNoteSelectionBlueprint.cs
rename to osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs
index 1514bdf0bd..0e47a12a8e 100644
--- a/osu.Game.Rulesets.Mania.Tests/TestSceneNoteSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs
@@ -12,7 +12,7 @@ using osu.Game.Rulesets.UI.Scrolling;
 using osu.Game.Tests.Visual;
 using osuTK;
 
-namespace osu.Game.Rulesets.Mania.Tests
+namespace osu.Game.Rulesets.Mania.Tests.Editor
 {
     public class TestSceneNoteSelectionBlueprint : ManiaSelectionBlueprintTestScene
     {
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCirclePlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs
similarity index 94%
rename from osu.Game.Rulesets.Osu.Tests/TestSceneHitCirclePlacementBlueprint.cs
rename to osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs
index 4c6abc45f7..7bccec6c97 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCirclePlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs
@@ -9,7 +9,7 @@ using osu.Game.Rulesets.Osu.Objects;
 using osu.Game.Rulesets.Osu.Objects.Drawables;
 using osu.Game.Tests.Visual;
 
-namespace osu.Game.Rulesets.Osu.Tests
+namespace osu.Game.Rulesets.Osu.Tests.Editor
 {
     public class TestSceneHitCirclePlacementBlueprint : PlacementBlueprintTestScene
     {
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCircleSelectionBlueprint.cs
similarity index 98%
rename from osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleSelectionBlueprint.cs
rename to osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCircleSelectionBlueprint.cs
index 0ecce42e88..66cd405195 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCircleSelectionBlueprint.cs
@@ -11,7 +11,7 @@ using osu.Game.Rulesets.Osu.Objects.Drawables;
 using osu.Game.Tests.Visual;
 using osuTK;
 
-namespace osu.Game.Rulesets.Osu.Tests
+namespace osu.Game.Rulesets.Osu.Tests.Editor
 {
     public class TestSceneHitCircleSelectionBlueprint : SelectionBlueprintTestScene
     {
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs
new file mode 100644
index 0000000000..1ca94df26b
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs
@@ -0,0 +1,90 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Testing;
+using osu.Framework.Utils;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.Osu.UI;
+using osu.Game.Tests.Beatmaps;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor
+{
+    [TestFixture]
+    public class TestSceneObjectObjectSnap : TestSceneOsuEditor
+    {
+        private OsuPlayfield playfield;
+
+        protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(Ruleset.Value, false);
+
+        public override void SetUpSteps()
+        {
+            base.SetUpSteps();
+            AddStep("get playfield", () => playfield = Editor.ChildrenOfType<OsuPlayfield>().First());
+        }
+
+        [TestCase(true)]
+        [TestCase(false)]
+        public void TestHitCircleSnapsToOtherHitCircle(bool distanceSnapEnabled)
+        {
+            AddStep("move mouse to centre", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre));
+
+            if (!distanceSnapEnabled)
+                AddStep("disable distance snap", () => InputManager.Key(Key.Q));
+
+            AddStep("enter placement mode", () => InputManager.Key(Key.Number2));
+
+            AddStep("place first object", () => InputManager.Click(MouseButton.Left));
+
+            AddStep("move mouse slightly", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.02f, 0)));
+
+            AddStep("place second object", () => InputManager.Click(MouseButton.Left));
+
+            AddAssert("both objects at same location", () =>
+            {
+                var objects = EditorBeatmap.HitObjects;
+
+                var first = (OsuHitObject)objects.First();
+                var second = (OsuHitObject)objects.Last();
+
+                return first.Position == second.Position;
+            });
+        }
+
+        [Test]
+        public void TestHitCircleSnapsToSliderEnd()
+        {
+            AddStep("move mouse to centre", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre));
+
+            AddStep("disable distance snap", () => InputManager.Key(Key.Q));
+
+            AddStep("enter slider placement mode", () => InputManager.Key(Key.Number3));
+
+            AddStep("start slider placement", () => InputManager.Click(MouseButton.Left));
+
+            AddStep("move to place end", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.185f, 0)));
+
+            AddStep("end slider placement", () => InputManager.Click(MouseButton.Right));
+
+            AddStep("enter circle placement mode", () => InputManager.Key(Key.Number2));
+
+            AddStep("move mouse slightly", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.20f, 0)));
+
+            AddStep("place second object", () => InputManager.Click(MouseButton.Left));
+
+            AddAssert("circle is at slider's end", () =>
+            {
+                var objects = EditorBeatmap.HitObjects;
+
+                var first = (Slider)objects.First();
+                var second = (OsuHitObject)objects.Last();
+
+                return Precision.AlmostEquals(first.EndPosition, second.Position);
+            });
+        }
+    }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs
similarity index 99%
rename from osu.Game.Rulesets.Osu.Tests/TestSceneOsuDistanceSnapGrid.cs
rename to osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs
index 0d0be2953b..1232369a0b 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuDistanceSnapGrid.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs
@@ -19,7 +19,7 @@ using osu.Game.Tests.Visual;
 using osuTK;
 using osuTK.Graphics;
 
-namespace osu.Game.Rulesets.Osu.Tests
+namespace osu.Game.Rulesets.Osu.Tests.Editor
 {
     public class TestSceneOsuDistanceSnapGrid : OsuManualInputManagerTestScene
     {
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneEditor.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditor.cs
similarity index 76%
rename from osu.Game.Rulesets.Osu.Tests/TestSceneEditor.cs
rename to osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditor.cs
index 9239034a53..e1ca3ddd61 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneEditor.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditor.cs
@@ -4,10 +4,10 @@
 using NUnit.Framework;
 using osu.Game.Tests.Visual;
 
-namespace osu.Game.Rulesets.Osu.Tests
+namespace osu.Game.Rulesets.Osu.Tests.Editor
 {
     [TestFixture]
-    public class TestSceneEditor : EditorTestScene
+    public class TestSceneOsuEditor : EditorTestScene
     {
         protected override Ruleset CreateEditorRuleset() => new OsuRuleset();
     }
diff --git a/osu.Game.Rulesets.Osu.Tests/TestScenePathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs
similarity index 97%
rename from osu.Game.Rulesets.Osu.Tests/TestScenePathControlPointVisualiser.cs
rename to osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs
index 21fa283b6d..738a21b17e 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestScenePathControlPointVisualiser.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs
@@ -12,7 +12,7 @@ using osu.Game.Rulesets.Osu.Objects;
 using osu.Game.Tests.Visual;
 using osuTK;
 
-namespace osu.Game.Rulesets.Osu.Tests
+namespace osu.Game.Rulesets.Osu.Tests.Editor
 {
     public class TestScenePathControlPointVisualiser : OsuTestScene
     {
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs
similarity index 99%
rename from osu.Game.Rulesets.Osu.Tests/TestSceneSliderPlacementBlueprint.cs
rename to osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs
index fe9973f4d8..49d7d9249c 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderPlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs
@@ -14,7 +14,7 @@ using osu.Game.Tests.Visual;
 using osuTK;
 using osuTK.Input;
 
-namespace osu.Game.Rulesets.Osu.Tests
+namespace osu.Game.Rulesets.Osu.Tests.Editor
 {
     public class TestSceneSliderPlacementBlueprint : PlacementBlueprintTestScene
     {
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs
similarity index 99%
rename from osu.Game.Rulesets.Osu.Tests/TestSceneSliderSelectionBlueprint.cs
rename to osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs
index d5be538d94..f6e1be693b 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs
@@ -16,7 +16,7 @@ using osu.Game.Tests.Visual;
 using osuTK;
 using osuTK.Input;
 
-namespace osu.Game.Rulesets.Osu.Tests
+namespace osu.Game.Rulesets.Osu.Tests.Editor
 {
     public class TestSceneSliderSelectionBlueprint : SelectionBlueprintTestScene
     {
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs
similarity index 94%
rename from osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerPlacementBlueprint.cs
rename to osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs
index d74d072857..fa6c660b01 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerPlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs
@@ -9,7 +9,7 @@ using osu.Game.Rulesets.Osu.Objects;
 using osu.Game.Rulesets.Osu.Objects.Drawables;
 using osu.Game.Tests.Visual;
 
-namespace osu.Game.Rulesets.Osu.Tests
+namespace osu.Game.Rulesets.Osu.Tests.Editor
 {
     public class TestSceneSpinnerPlacementBlueprint : PlacementBlueprintTestScene
     {
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerSelectionBlueprint.cs
similarity index 96%
rename from osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerSelectionBlueprint.cs
rename to osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerSelectionBlueprint.cs
index 011463ab14..4248f68a60 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerSelectionBlueprint.cs
@@ -11,7 +11,7 @@ using osu.Game.Rulesets.Osu.Objects.Drawables;
 using osu.Game.Tests.Visual;
 using osuTK;
 
-namespace osu.Game.Rulesets.Osu.Tests
+namespace osu.Game.Rulesets.Osu.Tests.Editor
 {
     public class TestSceneSpinnerSelectionBlueprint : SelectionBlueprintTestScene
     {
diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs
index e1cbfa93f6..912a705d16 100644
--- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs
+++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs
@@ -9,7 +9,9 @@ using osu.Framework.Bindables;
 using osu.Framework.Caching;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Sprites;
 using osu.Game.Beatmaps;
+using osu.Game.Graphics.UserInterface;
 using osu.Game.Rulesets.Edit;
 using osu.Game.Rulesets.Edit.Tools;
 using osu.Game.Rulesets.Mods;
@@ -17,6 +19,7 @@ using osu.Game.Rulesets.Objects;
 using osu.Game.Rulesets.Objects.Drawables;
 using osu.Game.Rulesets.Osu.Objects;
 using osu.Game.Rulesets.UI;
+using osu.Game.Screens.Edit.Components.TernaryButtons;
 using osu.Game.Screens.Edit.Compose.Components;
 using osuTK;
 
@@ -39,12 +42,12 @@ namespace osu.Game.Rulesets.Osu.Edit
             new SpinnerCompositionTool()
         };
 
-        private readonly BindableBool distanceSnapToggle = new BindableBool(true) { Description = "Distance Snap" };
+        private readonly Bindable<TernaryState> distanceSnapToggle = new Bindable<TernaryState>();
 
-        protected override IEnumerable<BindableBool> Toggles => new[]
+        protected override IEnumerable<TernaryButton> CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[]
         {
-            distanceSnapToggle
-        };
+            new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler })
+        });
 
         private BindableList<HitObject> selectedHitObjects;
 
@@ -94,6 +97,10 @@ namespace osu.Game.Rulesets.Osu.Edit
 
         public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition)
         {
+            if (snapToVisibleBlueprints(screenSpacePosition, out var snapResult))
+                return snapResult;
+
+            // will be null if distance snap is disabled or not feasible for the current time value.
             if (distanceSnapGrid == null)
                 return base.SnapScreenSpacePositionToValidTime(screenSpacePosition);
 
@@ -102,13 +109,57 @@ namespace osu.Game.Rulesets.Osu.Edit
             return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, PlayfieldAtScreenSpacePosition(screenSpacePosition));
         }
 
+        private bool snapToVisibleBlueprints(Vector2 screenSpacePosition, out SnapResult snapResult)
+        {
+            // check other on-screen objects for snapping/stacking
+            var blueprints = BlueprintContainer.SelectionBlueprints.AliveChildren;
+
+            var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition);
+
+            float snapRadius =
+                playfield.GamefieldToScreenSpace(new Vector2(OsuHitObject.OBJECT_RADIUS / 5)).X -
+                playfield.GamefieldToScreenSpace(Vector2.Zero).X;
+
+            foreach (var b in blueprints)
+            {
+                if (b.IsSelected)
+                    continue;
+
+                var hitObject = (OsuHitObject)b.HitObject;
+
+                Vector2? snap = checkSnap(hitObject.Position);
+                if (snap == null && hitObject.Position != hitObject.EndPosition)
+                    snap = checkSnap(hitObject.EndPosition);
+
+                if (snap != null)
+                {
+                    // only return distance portion, since time is not really valid
+                    snapResult = new SnapResult(snap.Value, null, playfield);
+                    return true;
+                }
+
+                Vector2? checkSnap(Vector2 checkPos)
+                {
+                    Vector2 checkScreenPos = playfield.GamefieldToScreenSpace(checkPos);
+
+                    if (Vector2.Distance(checkScreenPos, screenSpacePosition) < snapRadius)
+                        return checkScreenPos;
+
+                    return null;
+                }
+            }
+
+            snapResult = null;
+            return false;
+        }
+
         private void updateDistanceSnapGrid()
         {
             distanceSnapGridContainer.Clear();
             distanceSnapGridCache.Invalidate();
             distanceSnapGrid = null;
 
-            if (!distanceSnapToggle.Value)
+            if (distanceSnapToggle.Value != TernaryState.True)
                 return;
 
             switch (BlueprintContainer.CurrentTool)
diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneEditor.cs b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditor.cs
similarity index 88%
rename from osu.Game.Rulesets.Taiko.Tests/TestSceneEditor.cs
rename to osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditor.cs
index 411fe08bcf..e3c1613bd9 100644
--- a/osu.Game.Rulesets.Taiko.Tests/TestSceneEditor.cs
+++ b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditor.cs
@@ -4,7 +4,7 @@
 using NUnit.Framework;
 using osu.Game.Tests.Visual;
 
-namespace osu.Game.Rulesets.Taiko.Tests
+namespace osu.Game.Rulesets.Taiko.Tests.Editor
 {
     [TestFixture]
     public class TestSceneEditor : EditorTestScene
diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectComposer.cs b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoHitObjectComposer.cs
similarity index 97%
rename from osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectComposer.cs
rename to osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoHitObjectComposer.cs
index 34d5fdf857..626537053a 100644
--- a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectComposer.cs
+++ b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoHitObjectComposer.cs
@@ -12,7 +12,7 @@ using osu.Game.Rulesets.Taiko.Objects;
 using osu.Game.Screens.Edit;
 using osu.Game.Tests.Visual;
 
-namespace osu.Game.Rulesets.Taiko.Tests
+namespace osu.Game.Rulesets.Taiko.Tests.Editor
 {
     public class TestSceneTaikoHitObjectComposer : EditorClockTestScene
     {
diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs
index 40565048c2..a3ecf7ed95 100644
--- a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs
+++ b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs
@@ -1,9 +1,10 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
-using System;
 using System.Collections.Generic;
 using System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
 using osu.Framework.Graphics.UserInterface;
 using osu.Game.Graphics.UserInterface;
 using osu.Game.Rulesets.Edit;
@@ -14,75 +15,80 @@ namespace osu.Game.Rulesets.Taiko.Edit
 {
     public class TaikoSelectionHandler : SelectionHandler
     {
+        private readonly Bindable<TernaryState> selectionRimState = new Bindable<TernaryState>();
+        private readonly Bindable<TernaryState> selectionStrongState = new Bindable<TernaryState>();
+
+        [BackgroundDependencyLoader]
+        private void load()
+        {
+            selectionStrongState.ValueChanged += state =>
+            {
+                switch (state.NewValue)
+                {
+                    case TernaryState.False:
+                        SetStrongState(false);
+                        break;
+
+                    case TernaryState.True:
+                        SetStrongState(true);
+                        break;
+                }
+            };
+
+            selectionRimState.ValueChanged += state =>
+            {
+                switch (state.NewValue)
+                {
+                    case TernaryState.False:
+                        SetRimState(false);
+                        break;
+
+                    case TernaryState.True:
+                        SetRimState(true);
+                        break;
+                }
+            };
+        }
+
+        public void SetStrongState(bool state)
+        {
+            var hits = SelectedHitObjects.OfType<Hit>();
+
+            ChangeHandler.BeginChange();
+
+            foreach (var h in hits)
+                h.IsStrong = state;
+
+            ChangeHandler.EndChange();
+        }
+
+        public void SetRimState(bool state)
+        {
+            var hits = SelectedHitObjects.OfType<Hit>();
+
+            ChangeHandler.BeginChange();
+
+            foreach (var h in hits)
+                h.Type = state ? HitType.Rim : HitType.Centre;
+
+            ChangeHandler.EndChange();
+        }
+
         protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint> selection)
         {
             if (selection.All(s => s.HitObject is Hit))
-            {
-                var hits = selection.Select(s => s.HitObject).OfType<Hit>();
-
-                yield return new TernaryStateMenuItem("Rim", action: state =>
-                {
-                    ChangeHandler.BeginChange();
-
-                    foreach (var h in hits)
-                    {
-                        switch (state)
-                        {
-                            case TernaryState.True:
-                                h.Type = HitType.Rim;
-                                break;
-
-                            case TernaryState.False:
-                                h.Type = HitType.Centre;
-                                break;
-                        }
-                    }
-
-                    ChangeHandler.EndChange();
-                })
-                {
-                    State = { Value = getTernaryState(hits, h => h.Type == HitType.Rim) }
-                };
-            }
+                yield return new TernaryStateMenuItem("Rim") { State = { BindTarget = selectionRimState } };
 
             if (selection.All(s => s.HitObject is TaikoHitObject))
-            {
-                var hits = selection.Select(s => s.HitObject).OfType<TaikoHitObject>();
-
-                yield return new TernaryStateMenuItem("Strong", action: state =>
-                {
-                    ChangeHandler.BeginChange();
-
-                    foreach (var h in hits)
-                    {
-                        switch (state)
-                        {
-                            case TernaryState.True:
-                                h.IsStrong = true;
-                                break;
-
-                            case TernaryState.False:
-                                h.IsStrong = false;
-                                break;
-                        }
-
-                        EditorBeatmap?.UpdateHitObject(h);
-                    }
-
-                    ChangeHandler.EndChange();
-                })
-                {
-                    State = { Value = getTernaryState(hits, h => h.IsStrong) }
-                };
-            }
+                yield return new TernaryStateMenuItem("Strong") { State = { BindTarget = selectionStrongState } };
         }
 
-        private TernaryState getTernaryState<T>(IEnumerable<T> selection, Func<T, bool> func)
+        protected override void UpdateTernaryStates()
         {
-            if (selection.Any(func))
-                return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate;
+            base.UpdateTernaryStates();
 
-            return TernaryState.False;
+            selectionRimState.Value = GetStateFromSelection(SelectedHitObjects.OfType<Hit>(), h => h.Type == HitType.Rim);
+            selectionStrongState.Value = GetStateFromSelection(SelectedHitObjects.OfType<TaikoHitObject>(), h => h.IsStrong);
         }
     }
 }
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs
index 92ae7e0fd3..3a6eaa83db 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs
@@ -36,35 +36,64 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
 
         private bool pressHandledThisFrame;
 
-        private Bindable<HitType> type;
+        private readonly Bindable<HitType> type;
 
         public DrawableHit(Hit hit)
             : base(hit)
         {
+            type = HitObject.TypeBindable.GetBoundCopy();
             FillMode = FillMode.Fit;
+
+            updateActionsFromType();
         }
 
         [BackgroundDependencyLoader]
         private void load()
         {
-            type = HitObject.TypeBindable.GetBoundCopy();
             type.BindValueChanged(_ =>
             {
-                updateType();
+                updateActionsFromType();
+
+                // will overwrite samples, should only be called on change.
+                updateSamplesFromTypeChange();
+
                 RecreatePieces();
             });
-
-            updateType();
         }
 
-        private void updateType()
+        private HitSampleInfo[] getRimSamples() => HitObject.Samples.Where(s => s.Name == HitSampleInfo.HIT_CLAP || s.Name == HitSampleInfo.HIT_WHISTLE).ToArray();
+
+        protected override void LoadSamples()
+        {
+            base.LoadSamples();
+
+            type.Value = getRimSamples().Any() ? HitType.Rim : HitType.Centre;
+        }
+
+        private void updateSamplesFromTypeChange()
+        {
+            var rimSamples = getRimSamples();
+
+            bool isRimType = HitObject.Type == HitType.Rim;
+
+            if (isRimType != rimSamples.Any())
+            {
+                if (isRimType)
+                    HitObject.Samples.Add(new HitSampleInfo { Name = HitSampleInfo.HIT_CLAP });
+                else
+                {
+                    foreach (var sample in rimSamples)
+                        HitObject.Samples.Remove(sample);
+                }
+            }
+        }
+
+        private void updateActionsFromType()
         {
             HitActions =
                 HitObject.Type == HitType.Centre
                     ? new[] { TaikoAction.LeftCentre, TaikoAction.RightCentre }
                     : new[] { TaikoAction.LeftRim, TaikoAction.RightRim };
-
-            RecreatePieces();
         }
 
         protected override SkinnableDrawable CreateMainPiece() => HitObject.Type == HitType.Centre
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs
index 929cf8a937..9cd23383c4 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs
@@ -1,19 +1,19 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
-using osu.Framework.Graphics;
-using osu.Framework.Input.Bindings;
-using osu.Game.Rulesets.Objects.Drawables;
-using osuTK;
-using System.Linq;
-using osu.Game.Audio;
 using System.Collections.Generic;
+using System.Linq;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
+using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Primitives;
+using osu.Framework.Input.Bindings;
+using osu.Game.Audio;
 using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Drawables;
 using osu.Game.Skinning;
+using osuTK;
 
 namespace osu.Game.Rulesets.Taiko.Objects.Drawables
 {
@@ -120,7 +120,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
         protected Vector2 BaseSize;
         protected SkinnableDrawable MainPiece;
 
-        private Bindable<bool> isStrong;
+        private readonly Bindable<bool> isStrong;
 
         private readonly Container<DrawableStrongNestedHit> strongHitContainer;
 
@@ -128,6 +128,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
             : base(hitObject)
         {
             HitObject = hitObject;
+            isStrong = HitObject.IsStrongBindable.GetBoundCopy();
 
             Anchor = Anchor.CentreLeft;
             Origin = Anchor.Custom;
@@ -140,8 +141,40 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
         [BackgroundDependencyLoader]
         private void load()
         {
-            isStrong = HitObject.IsStrongBindable.GetBoundCopy();
-            isStrong.BindValueChanged(_ => RecreatePieces(), true);
+            isStrong.BindValueChanged(_ =>
+            {
+                // will overwrite samples, should only be called on change.
+                updateSamplesFromStrong();
+
+                RecreatePieces();
+            });
+
+            RecreatePieces();
+        }
+
+        private HitSampleInfo[] getStrongSamples() => HitObject.Samples.Where(s => s.Name == HitSampleInfo.HIT_FINISH).ToArray();
+
+        protected override void LoadSamples()
+        {
+            base.LoadSamples();
+
+            isStrong.Value = getStrongSamples().Any();
+        }
+
+        private void updateSamplesFromStrong()
+        {
+            var strongSamples = getStrongSamples();
+
+            if (isStrong.Value != strongSamples.Any())
+            {
+                if (isStrong.Value)
+                    HitObject.Samples.Add(new HitSampleInfo { Name = HitSampleInfo.HIT_FINISH });
+                else
+                {
+                    foreach (var sample in strongSamples)
+                        HitObject.Samples.Remove(sample);
+                }
+            }
         }
 
         protected virtual void RecreatePieces()
diff --git a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs
index 89e3b48aa3..33e3c7cb8c 100644
--- a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs
+++ b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs
@@ -16,12 +16,14 @@ using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.OpenGL.Textures;
 using osu.Framework.Graphics.Shapes;
 using osu.Framework.Graphics.Textures;
+using osu.Framework.Testing;
 using osu.Game.Rulesets.Osu;
 using osu.Game.Rulesets.UI;
 using osu.Game.Tests.Visual;
 
 namespace osu.Game.Tests.Rulesets
 {
+    [HeadlessTest]
     public class TestSceneDrawableRulesetDependencies : OsuTestScene
     {
         [Test]
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs
new file mode 100644
index 0000000000..720cf51f2c
--- /dev/null
+++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs
@@ -0,0 +1,69 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.IO;
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Osu;
+using osu.Game.Screens.Edit.Setup;
+using osu.Game.Tests.Resources;
+using SharpCompress.Archives;
+using SharpCompress.Archives.Zip;
+
+namespace osu.Game.Tests.Visual.Editing
+{
+    public class TestSceneEditorBeatmapCreation : EditorTestScene
+    {
+        protected override Ruleset CreateEditorRuleset() => new OsuRuleset();
+
+        protected override bool EditorComponentsReady => Editor.ChildrenOfType<SetupScreen>().SingleOrDefault()?.IsLoaded == true;
+
+        public override void SetUpSteps()
+        {
+            AddStep("set dummy", () => Beatmap.Value = new DummyWorkingBeatmap(Audio, null));
+
+            base.SetUpSteps();
+
+            // if we save a beatmap with a hash collision, things fall over.
+            // probably needs a more solid resolution in the future but this will do for now.
+            AddStep("make new beatmap unique", () => EditorBeatmap.Metadata.Title = Guid.NewGuid().ToString());
+        }
+
+        [Test]
+        public void TestCreateNewBeatmap()
+        {
+            AddStep("save beatmap", () => Editor.Save());
+            AddAssert("new beatmap persisted", () => EditorBeatmap.BeatmapInfo.ID > 0);
+        }
+
+        [Test]
+        public void TestAddAudioTrack()
+        {
+            AddAssert("switch track to real track", () =>
+            {
+                var setup = Editor.ChildrenOfType<SetupScreen>().First();
+
+                var temp = TestResources.GetTestBeatmapForImport();
+
+                string extractedFolder = $"{temp}_extracted";
+                Directory.CreateDirectory(extractedFolder);
+
+                using (var zip = ZipArchive.Open(temp))
+                    zip.WriteToDirectory(extractedFolder);
+
+                bool success = setup.ChangeAudioTrack(Path.Combine(extractedFolder, "03. Renatus - Soleily 192kbps.mp3"));
+
+                File.Delete(temp);
+                Directory.Delete(extractedFolder, true);
+
+                return success;
+            });
+
+            AddAssert("track length changed", () => Beatmap.Value.Track.Length > 60000);
+        }
+    }
+}
diff --git a/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs b/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs
index 0cd0f13b5f..082d85603e 100644
--- a/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs
+++ b/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs
@@ -3,7 +3,6 @@
 
 using osu.Framework.Allocation;
 using osu.Framework.Graphics;
-using osu.Framework.Platform;
 using osu.Game.Graphics.UserInterfaceV2;
 
 namespace osu.Game.Tests.Visual.Settings
@@ -11,7 +10,7 @@ namespace osu.Game.Tests.Visual.Settings
     public class TestSceneDirectorySelector : OsuTestScene
     {
         [BackgroundDependencyLoader]
-        private void load(GameHost host)
+        private void load()
         {
             Add(new DirectorySelector { RelativeSizeAxes = Axes.Both });
         }
diff --git a/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs b/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs
new file mode 100644
index 0000000000..311e4c3362
--- /dev/null
+++ b/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs
@@ -0,0 +1,24 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using NUnit.Framework;
+using osu.Framework.Graphics;
+using osu.Game.Graphics.UserInterfaceV2;
+
+namespace osu.Game.Tests.Visual.Settings
+{
+    public class TestSceneFileSelector : OsuTestScene
+    {
+        [Test]
+        public void TestAllFiles()
+        {
+            AddStep("create", () => Child = new FileSelector { RelativeSizeAxes = Axes.Both });
+        }
+
+        [Test]
+        public void TestJpgFilesOnly()
+        {
+            AddStep("create", () => Child = new FileSelector(validFileExtensions: new[] { ".jpg" }) { RelativeSizeAxes = Axes.Both });
+        }
+    }
+}
diff --git a/osu.Game.Tournament/Screens/StablePathSelectScreen.cs b/osu.Game.Tournament/Screens/StablePathSelectScreen.cs
index b4d56f60c7..717b43f704 100644
--- a/osu.Game.Tournament/Screens/StablePathSelectScreen.cs
+++ b/osu.Game.Tournament/Screens/StablePathSelectScreen.cs
@@ -129,7 +129,7 @@ namespace osu.Game.Tournament.Screens
 
         protected virtual void ChangePath()
         {
-            var target = directorySelector.CurrentDirectory.Value.FullName;
+            var target = directorySelector.CurrentPath.Value.FullName;
             var fileBasedIpc = ipc as FileBasedIPC;
             Logger.Log($"Changing Stable CE location to {target}");
 
diff --git a/osu.Game/Audio/HitSampleInfo.cs b/osu.Game/Audio/HitSampleInfo.cs
index f6b0107bd2..8b1f5a366a 100644
--- a/osu.Game/Audio/HitSampleInfo.cs
+++ b/osu.Game/Audio/HitSampleInfo.cs
@@ -17,6 +17,11 @@ namespace osu.Game.Audio
         public const string HIT_NORMAL = @"hitnormal";
         public const string HIT_CLAP = @"hitclap";
 
+        /// <summary>
+        /// All valid sample addition constants.
+        /// </summary>
+        public static IEnumerable<string> AllAdditions => new[] { HIT_WHISTLE, HIT_CLAP, HIT_FINISH };
+
         /// <summary>
         /// The bank to load the sample from.
         /// </summary>
diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs
index e9f41f6bff..b48ab6112e 100644
--- a/osu.Game/Beatmaps/BeatmapManager.cs
+++ b/osu.Game/Beatmaps/BeatmapManager.cs
@@ -260,7 +260,7 @@ namespace osu.Game.Beatmaps
                     fileInfo.Filename = beatmapInfo.Path;
 
                     stream.Seek(0, SeekOrigin.Begin);
-                    UpdateFile(setInfo, fileInfo, stream);
+                    ReplaceFile(setInfo, fileInfo, stream);
                 }
             }
 
diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs
index 76bc4f7755..bbe2604216 100644
--- a/osu.Game/Database/ArchiveModelManager.cs
+++ b/osu.Game/Database/ArchiveModelManager.cs
@@ -401,12 +401,27 @@ namespace osu.Game.Database
         }
 
         /// <summary>
-        /// Update an existing file, or create a new entry if not already part of the <paramref name="model"/>'s files.
+        /// Replace an existing file with a new version.
         /// </summary>
         /// <param name="model">The item to operate on.</param>
-        /// <param name="file">The file model to be updated or added.</param>
+        /// <param name="file">The existing file to be replaced.</param>
         /// <param name="contents">The new file contents.</param>
-        public void UpdateFile(TModel model, TFileModel file, Stream contents)
+        /// <param name="filename">An optional filename for the new file. Will use the previous filename if not specified.</param>
+        public void ReplaceFile(TModel model, TFileModel file, Stream contents, string filename = null)
+        {
+            using (ContextFactory.GetForWrite())
+            {
+                DeleteFile(model, file);
+                AddFile(model, contents, filename ?? file.Filename);
+            }
+        }
+
+        /// <summary>
+        /// Delete new file.
+        /// </summary>
+        /// <param name="model">The item to operate on.</param>
+        /// <param name="file">The existing file to be deleted.</param>
+        public void DeleteFile(TModel model, TFileModel file)
         {
             using (var usage = ContextFactory.GetForWrite())
             {
@@ -415,15 +430,28 @@ namespace osu.Game.Database
                 {
                     Files.Dereference(file.FileInfo);
 
-                    // Remove the file model.
+                    // This shouldn't be required, but here for safety in case the provided TModel is not being change tracked
+                    // Definitely can be removed once we rework the database backend.
                     usage.Context.Set<TFileModel>().Remove(file);
                 }
 
-                // Add the new file info and containing file model.
                 model.Files.Remove(file);
+            }
+        }
+
+        /// <summary>
+        /// Add a new file.
+        /// </summary>
+        /// <param name="model">The item to operate on.</param>
+        /// <param name="contents">The new file contents.</param>
+        /// <param name="filename">The filename for the new file.</param>
+        public void AddFile(TModel model, Stream contents, string filename)
+        {
+            using (ContextFactory.GetForWrite())
+            {
                 model.Files.Add(new TFileModel
                 {
-                    Filename = file.Filename,
+                    Filename = filename,
                     FileInfo = Files.Add(contents)
                 });
 
diff --git a/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs b/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs
index ae34281bfb..a1cd074619 100644
--- a/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs
+++ b/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs
@@ -28,11 +28,11 @@ namespace osu.Game.Graphics.UserInterfaceV2
         private GameHost host { get; set; }
 
         [Cached]
-        public readonly Bindable<DirectoryInfo> CurrentDirectory = new Bindable<DirectoryInfo>();
+        public readonly Bindable<DirectoryInfo> CurrentPath = new Bindable<DirectoryInfo>();
 
         public DirectorySelector(string initialPath = null)
         {
-            CurrentDirectory.Value = new DirectoryInfo(initialPath ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
+            CurrentPath.Value = new DirectoryInfo(initialPath ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
         }
 
         [BackgroundDependencyLoader]
@@ -74,7 +74,7 @@ namespace osu.Game.Graphics.UserInterfaceV2
                 }
             };
 
-            CurrentDirectory.BindValueChanged(updateDisplay, true);
+            CurrentPath.BindValueChanged(updateDisplay, true);
         }
 
         private void updateDisplay(ValueChangedEvent<DirectoryInfo> directory)
@@ -92,22 +92,27 @@ namespace osu.Game.Graphics.UserInterfaceV2
                 }
                 else
                 {
-                    directoryFlow.Add(new ParentDirectoryPiece(CurrentDirectory.Value.Parent));
+                    directoryFlow.Add(new ParentDirectoryPiece(CurrentPath.Value.Parent));
 
-                    foreach (var dir in CurrentDirectory.Value.GetDirectories().OrderBy(d => d.Name))
-                    {
-                        if ((dir.Attributes & FileAttributes.Hidden) == 0)
-                            directoryFlow.Add(new DirectoryPiece(dir));
-                    }
+                    directoryFlow.AddRange(GetEntriesForPath(CurrentPath.Value));
                 }
             }
             catch (Exception)
             {
-                CurrentDirectory.Value = directory.OldValue;
+                CurrentPath.Value = directory.OldValue;
                 this.FlashColour(Color4.Red, 300);
             }
         }
 
+        protected virtual IEnumerable<DisplayPiece> GetEntriesForPath(DirectoryInfo path)
+        {
+            foreach (var dir in path.GetDirectories().OrderBy(d => d.Name))
+            {
+                if ((dir.Attributes & FileAttributes.Hidden) == 0)
+                    yield return new DirectoryPiece(dir);
+            }
+        }
+
         private class CurrentDirectoryDisplay : CompositeDrawable
         {
             [Resolved]
@@ -126,7 +131,7 @@ namespace osu.Game.Graphics.UserInterfaceV2
                         Origin = Anchor.Centre,
                         RelativeSizeAxes = Axes.X,
                         Spacing = new Vector2(5),
-                        Height = DirectoryPiece.HEIGHT,
+                        Height = DisplayPiece.HEIGHT,
                         Direction = FillDirection.Horizontal,
                     },
                 };
@@ -150,7 +155,7 @@ namespace osu.Game.Graphics.UserInterfaceV2
 
                 flow.ChildrenEnumerable = new Drawable[]
                 {
-                    new OsuSpriteText { Text = "Current Directory: ", Font = OsuFont.Default.With(size: DirectoryPiece.HEIGHT), },
+                    new OsuSpriteText { Text = "Current Directory: ", Font = OsuFont.Default.With(size: DisplayPiece.HEIGHT), },
                     new ComputerPiece(),
                 }.Concat(pathPieces);
             }
@@ -198,24 +203,44 @@ namespace osu.Game.Graphics.UserInterfaceV2
             }
         }
 
-        private class DirectoryPiece : CompositeDrawable
+        protected class DirectoryPiece : DisplayPiece
         {
-            public const float HEIGHT = 20;
-
-            protected const float FONT_SIZE = 16;
-
             protected readonly DirectoryInfo Directory;
 
-            private readonly string displayName;
-
-            protected FillFlowContainer Flow;
-
             [Resolved]
             private Bindable<DirectoryInfo> currentDirectory { get; set; }
 
             public DirectoryPiece(DirectoryInfo directory, string displayName = null)
+                : base(displayName)
             {
                 Directory = directory;
+            }
+
+            protected override bool OnClick(ClickEvent e)
+            {
+                currentDirectory.Value = Directory;
+                return true;
+            }
+
+            protected override string FallbackName => Directory.Name;
+
+            protected override IconUsage? Icon => Directory.Name.Contains(Path.DirectorySeparatorChar)
+                ? FontAwesome.Solid.Database
+                : FontAwesome.Regular.Folder;
+        }
+
+        protected abstract class DisplayPiece : CompositeDrawable
+        {
+            public const float HEIGHT = 20;
+
+            protected const float FONT_SIZE = 16;
+
+            private readonly string displayName;
+
+            protected FillFlowContainer Flow;
+
+            protected DisplayPiece(string displayName = null)
+            {
                 this.displayName = displayName;
             }
 
@@ -259,20 +284,14 @@ namespace osu.Game.Graphics.UserInterfaceV2
                 {
                     Anchor = Anchor.CentreLeft,
                     Origin = Anchor.CentreLeft,
-                    Text = displayName ?? Directory.Name,
+                    Text = displayName ?? FallbackName,
                     Font = OsuFont.Default.With(size: FONT_SIZE)
                 });
             }
 
-            protected override bool OnClick(ClickEvent e)
-            {
-                currentDirectory.Value = Directory;
-                return true;
-            }
+            protected abstract string FallbackName { get; }
 
-            protected virtual IconUsage? Icon => Directory.Name.Contains(Path.DirectorySeparatorChar)
-                ? FontAwesome.Solid.Database
-                : FontAwesome.Regular.Folder;
+            protected abstract IconUsage? Icon { get; }
         }
     }
 }
diff --git a/osu.Game/Graphics/UserInterfaceV2/FileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/FileSelector.cs
new file mode 100644
index 0000000000..e10b8f7033
--- /dev/null
+++ b/osu.Game/Graphics/UserInterfaceV2/FileSelector.cs
@@ -0,0 +1,94 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Input.Events;
+
+namespace osu.Game.Graphics.UserInterfaceV2
+{
+    public class FileSelector : DirectorySelector
+    {
+        private readonly string[] validFileExtensions;
+
+        [Cached]
+        public readonly Bindable<FileInfo> CurrentFile = new Bindable<FileInfo>();
+
+        public FileSelector(string initialPath = null, string[] validFileExtensions = null)
+            : base(initialPath)
+        {
+            this.validFileExtensions = validFileExtensions ?? Array.Empty<string>();
+        }
+
+        protected override IEnumerable<DisplayPiece> GetEntriesForPath(DirectoryInfo path)
+        {
+            foreach (var dir in base.GetEntriesForPath(path))
+                yield return dir;
+
+            IEnumerable<FileInfo> files = path.GetFiles();
+
+            if (validFileExtensions.Length > 0)
+                files = files.Where(f => validFileExtensions.Contains(f.Extension));
+
+            foreach (var file in files.OrderBy(d => d.Name))
+            {
+                if ((file.Attributes & FileAttributes.Hidden) == 0)
+                    yield return new FilePiece(file);
+            }
+        }
+
+        protected class FilePiece : DisplayPiece
+        {
+            private readonly FileInfo file;
+
+            [Resolved]
+            private Bindable<FileInfo> currentFile { get; set; }
+
+            public FilePiece(FileInfo file)
+            {
+                this.file = file;
+            }
+
+            protected override bool OnClick(ClickEvent e)
+            {
+                currentFile.Value = file;
+                return true;
+            }
+
+            protected override string FallbackName => file.Name;
+
+            protected override IconUsage? Icon
+            {
+                get
+                {
+                    switch (file.Extension)
+                    {
+                        case ".ogg":
+                        case ".mp3":
+                        case ".wav":
+                            return FontAwesome.Regular.FileAudio;
+
+                        case ".jpg":
+                        case ".jpeg":
+                        case ".png":
+                            return FontAwesome.Regular.FileImage;
+
+                        case ".mp4":
+                        case ".avi":
+                        case ".mov":
+                        case ".flv":
+                            return FontAwesome.Regular.FileVideo;
+
+                        default:
+                            return FontAwesome.Regular.File;
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs
index 290aba3468..4aeda74be8 100644
--- a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs
+++ b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs
@@ -44,12 +44,18 @@ namespace osu.Game.Graphics.UserInterfaceV2
             Component.BorderColour = colours.Blue;
         }
 
-        protected override OsuTextBox CreateComponent() => new OsuTextBox
+        protected virtual OsuTextBox CreateTextBox() => new OsuTextBox
         {
+            CommitOnFocusLost = true,
             Anchor = Anchor.Centre,
             Origin = Anchor.Centre,
             RelativeSizeAxes = Axes.X,
             CornerRadius = CORNER_RADIUS,
-        }.With(t => t.OnCommit += (sender, newText) => OnCommit?.Invoke(sender, newText));
+        };
+
+        protected override OsuTextBox CreateComponent() => CreateTextBox().With(t =>
+        {
+            t.OnCommit += (sender, newText) => OnCommit?.Invoke(sender, newText);
+        });
     }
 }
diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs
index b568e4d02b..0764f34697 100644
--- a/osu.Game/Overlays/MusicController.cs
+++ b/osu.Game/Overlays/MusicController.cs
@@ -81,6 +81,11 @@ namespace osu.Game.Overlays
             mods.BindValueChanged(_ => ResetTrackAdjustments(), true);
         }
 
+        /// <summary>
+        /// Forcefully reload the current <see cref="WorkingBeatmap"/>'s track from disk.
+        /// </summary>
+        public void ReloadCurrentTrack() => changeTrack();
+
         /// <summary>
         /// Change the position of a <see cref="BeatmapSetInfo"/> in the current playlist.
         /// </summary>
diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs
index 79d842a617..ad540e3691 100644
--- a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs
+++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs
@@ -106,7 +106,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
 
         private void start()
         {
-            var target = directorySelector.CurrentDirectory.Value;
+            var target = directorySelector.CurrentPath.Value;
 
             try
             {
diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs
index b81e0ce159..b9b7c1ef54 100644
--- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs
+++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs
@@ -6,7 +6,6 @@ using System.Collections.Generic;
 using System.Collections.Specialized;
 using System.Linq;
 using osu.Framework.Allocation;
-using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Input;
@@ -14,7 +13,6 @@ using osu.Framework.Input.Events;
 using osu.Framework.Logging;
 using osu.Game.Beatmaps;
 using osu.Game.Beatmaps.ControlPoints;
-using osu.Game.Overlays.Settings;
 using osu.Game.Rulesets.Configuration;
 using osu.Game.Rulesets.Edit.Tools;
 using osu.Game.Rulesets.Mods;
@@ -24,6 +22,7 @@ using osu.Game.Rulesets.UI;
 using osu.Game.Rulesets.UI.Scrolling;
 using osu.Game.Screens.Edit;
 using osu.Game.Screens.Edit.Components.RadioButtons;
+using osu.Game.Screens.Edit.Components.TernaryButtons;
 using osu.Game.Screens.Edit.Compose;
 using osu.Game.Screens.Edit.Compose.Components;
 using osuTK;
@@ -31,6 +30,11 @@ using osuTK.Input;
 
 namespace osu.Game.Rulesets.Edit
 {
+    /// <summary>
+    /// Top level container for editor compose mode.
+    /// Responsible for providing snapping and generally gluing components together.
+    /// </summary>
+    /// <typeparam name="TObject">The base type of supported objects.</typeparam>
     [Cached(Type = typeof(IPlacementHandler))]
     public abstract class HitObjectComposer<TObject> : HitObjectComposer, IPlacementHandler
         where TObject : HitObject
@@ -58,7 +62,7 @@ namespace osu.Game.Rulesets.Edit
 
         private RadioButtonCollection toolboxCollection;
 
-        private ToolboxGroup togglesCollection;
+        private FillFlowContainer togglesCollection;
 
         protected HitObjectComposer(Ruleset ruleset)
         {
@@ -116,14 +120,19 @@ namespace osu.Game.Rulesets.Edit
                     Spacing = new Vector2(10),
                     Children = new Drawable[]
                     {
-                        new ToolboxGroup("toolbox") { Child = toolboxCollection = new RadioButtonCollection { RelativeSizeAxes = Axes.X } },
-                        togglesCollection = new ToolboxGroup("toggles")
+                        new ToolboxGroup("toolbox (1-9)")
                         {
-                            ChildrenEnumerable = Toggles.Select(b => new SettingsCheckbox
+                            Child = toolboxCollection = new RadioButtonCollection { RelativeSizeAxes = Axes.X }
+                        },
+                        new ToolboxGroup("toggles (Q~P)")
+                        {
+                            Child = togglesCollection = new FillFlowContainer
                             {
-                                Bindable = b,
-                                LabelText = b?.Description ?? "unknown"
-                            })
+                                RelativeSizeAxes = Axes.X,
+                                AutoSizeAxes = Axes.Y,
+                                Direction = FillDirection.Vertical,
+                                Spacing = new Vector2(0, 5),
+                            },
                         }
                     }
                 },
@@ -134,6 +143,9 @@ namespace osu.Game.Rulesets.Edit
                                       .Select(t => new RadioButton(t.Name, () => toolSelected(t), t.CreateIcon))
                                       .ToList();
 
+            TernaryStates = CreateTernaryButtons().ToArray();
+            togglesCollection.AddRange(TernaryStates.Select(b => new DrawableTernaryButton(b)));
+
             setSelectTool();
 
             EditorBeatmap.SelectedHitObjects.CollectionChanged += selectionChanged;
@@ -162,10 +174,14 @@ namespace osu.Game.Rulesets.Edit
         protected abstract IReadOnlyList<HitObjectCompositionTool> CompositionTools { get; }
 
         /// <summary>
-        /// A collection of toggles which will be displayed to the user.
-        /// The display name will be decided by <see cref="Bindable{T}.Description"/>.
+        /// A collection of states which will be displayed to the user in the toolbox.
         /// </summary>
-        protected virtual IEnumerable<BindableBool> Toggles => Enumerable.Empty<BindableBool>();
+        public TernaryButton[] TernaryStates { get; private set; }
+
+        /// <summary>
+        /// Create all ternary states required to be displayed to the user.
+        /// </summary>
+        protected virtual IEnumerable<TernaryButton> CreateTernaryButtons() => BlueprintContainer.TernaryStates;
 
         /// <summary>
         /// Construct a relevant blueprint container. This will manage hitobject selection/placement input handling and display logic.
@@ -192,6 +208,9 @@ namespace osu.Game.Rulesets.Edit
 
         protected override bool OnKeyDown(KeyDownEvent e)
         {
+            if (e.ControlPressed || e.AltPressed || e.SuperPressed)
+                return false;
+
             if (checkLeftToggleFromKey(e.Key, out var leftIndex))
             {
                 var item = toolboxCollection.Items.ElementAtOrDefault(leftIndex);
@@ -205,11 +224,11 @@ namespace osu.Game.Rulesets.Edit
 
             if (checkRightToggleFromKey(e.Key, out var rightIndex))
             {
-                var item = togglesCollection.Children[rightIndex];
+                var item = togglesCollection.ElementAtOrDefault(rightIndex);
 
-                if (item is SettingsCheckbox checkbox)
+                if (item is DrawableTernaryButton button)
                 {
-                    checkbox.Bindable.Value = !checkbox.Bindable.Value;
+                    button.Button.Toggle();
                     return true;
                 }
             }
diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs
index 02d5955ae6..d986b71380 100644
--- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs
+++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs
@@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Edit
         /// <summary>
         /// The <see cref="HitObject"/> that is being placed.
         /// </summary>
-        protected readonly HitObject HitObject;
+        public readonly HitObject HitObject;
 
         [Resolved(canBeNull: true)]
         protected EditorClock EditorClock { get; private set; }
diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
index 581617b567..7c05bc9aa7 100644
--- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
+++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
@@ -157,6 +157,9 @@ namespace osu.Game.Rulesets.Objects.Drawables
             updateState(ArmedState.Idle, true);
         }
 
+        /// <summary>
+        /// Invoked by the base <see cref="DrawableHitObject"/> to populate samples, once on initial load and potentially again on any change to the samples collection.
+        /// </summary>
         protected virtual void LoadSamples()
         {
             if (Samples != null)
@@ -183,6 +186,7 @@ namespace osu.Game.Rulesets.Objects.Drawables
         private void onDefaultsApplied(HitObject hitObject)
         {
             apply(hitObject);
+            updateState(state.Value, true);
             DefaultsApplied?.Invoke(this);
         }
 
diff --git a/osu.Game/Screens/Edit/Components/BottomBarContainer.cs b/osu.Game/Screens/Edit/Components/BottomBarContainer.cs
index cb5078a479..08091fc3f7 100644
--- a/osu.Game/Screens/Edit/Components/BottomBarContainer.cs
+++ b/osu.Game/Screens/Edit/Components/BottomBarContainer.cs
@@ -18,7 +18,8 @@ namespace osu.Game.Screens.Edit.Components
         private const float contents_padding = 15;
 
         protected readonly IBindable<WorkingBeatmap> Beatmap = new Bindable<WorkingBeatmap>();
-        protected Track Track => Beatmap.Value.Track;
+
+        protected readonly IBindable<Track> Track = new Bindable<Track>();
 
         private readonly Drawable background;
         private readonly Container content;
@@ -42,9 +43,11 @@ namespace osu.Game.Screens.Edit.Components
         }
 
         [BackgroundDependencyLoader]
-        private void load(IBindable<WorkingBeatmap> beatmap, OsuColour colours)
+        private void load(IBindable<WorkingBeatmap> beatmap, OsuColour colours, EditorClock clock)
         {
             Beatmap.BindTo(beatmap);
+            Track.BindTo(clock.Track);
+
             background.Colour = colours.Gray1;
         }
     }
diff --git a/osu.Game/Screens/Edit/Components/PlaybackControl.cs b/osu.Game/Screens/Edit/Components/PlaybackControl.cs
index 59b3d1c565..9739f2876a 100644
--- a/osu.Game/Screens/Edit/Components/PlaybackControl.cs
+++ b/osu.Game/Screens/Edit/Components/PlaybackControl.cs
@@ -62,12 +62,12 @@ namespace osu.Game.Screens.Edit.Components
                 }
             };
 
-            Track?.AddAdjustment(AdjustableProperty.Tempo, tempo);
+            Track.BindValueChanged(tr => tr.NewValue?.AddAdjustment(AdjustableProperty.Tempo, tempo), true);
         }
 
         protected override void Dispose(bool isDisposing)
         {
-            Track?.RemoveAdjustment(AdjustableProperty.Tempo, tempo);
+            Track.Value?.RemoveAdjustment(AdjustableProperty.Tempo, tempo);
 
             base.Dispose(isDisposing);
         }
diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs
new file mode 100644
index 0000000000..c72fff5c91
--- /dev/null
+++ b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs
@@ -0,0 +1,112 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Effects;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Graphics.Sprites;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Graphics.UserInterface;
+using osuTK;
+using osuTK.Graphics;
+
+namespace osu.Game.Screens.Edit.Components.TernaryButtons
+{
+    internal class DrawableTernaryButton : TriangleButton
+    {
+        private Color4 defaultBackgroundColour;
+        private Color4 defaultBubbleColour;
+        private Color4 selectedBackgroundColour;
+        private Color4 selectedBubbleColour;
+
+        private Drawable icon;
+
+        public readonly TernaryButton Button;
+
+        public DrawableTernaryButton(TernaryButton button)
+        {
+            Button = button;
+
+            Text = button.Description;
+
+            RelativeSizeAxes = Axes.X;
+        }
+
+        [BackgroundDependencyLoader]
+        private void load(OsuColour colours)
+        {
+            defaultBackgroundColour = colours.Gray3;
+            defaultBubbleColour = defaultBackgroundColour.Darken(0.5f);
+            selectedBackgroundColour = colours.BlueDark;
+            selectedBubbleColour = selectedBackgroundColour.Lighten(0.5f);
+
+            Triangles.Alpha = 0;
+
+            Content.EdgeEffect = new EdgeEffectParameters
+            {
+                Type = EdgeEffectType.Shadow,
+                Radius = 2,
+                Offset = new Vector2(0, 1),
+                Colour = Color4.Black.Opacity(0.5f)
+            };
+
+            Add(icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b =>
+            {
+                b.Blending = BlendingParameters.Additive;
+                b.Anchor = Anchor.CentreLeft;
+                b.Origin = Anchor.CentreLeft;
+                b.Size = new Vector2(20);
+                b.X = 10;
+            }));
+        }
+
+        protected override void LoadComplete()
+        {
+            base.LoadComplete();
+
+            Button.Bindable.BindValueChanged(selected => updateSelectionState(), true);
+
+            Action = onAction;
+        }
+
+        private void onAction()
+        {
+            Button.Toggle();
+        }
+
+        private void updateSelectionState()
+        {
+            if (!IsLoaded)
+                return;
+
+            switch (Button.Bindable.Value)
+            {
+                case TernaryState.Indeterminate:
+                    icon.Colour = selectedBubbleColour.Darken(0.5f);
+                    BackgroundColour = selectedBackgroundColour.Darken(0.5f);
+                    break;
+
+                case TernaryState.False:
+                    icon.Colour = defaultBubbleColour;
+                    BackgroundColour = defaultBackgroundColour;
+                    break;
+
+                case TernaryState.True:
+                    icon.Colour = selectedBubbleColour;
+                    BackgroundColour = selectedBackgroundColour;
+                    break;
+            }
+        }
+
+        protected override SpriteText CreateText() => new OsuSpriteText
+        {
+            Depth = -1,
+            Origin = Anchor.CentreLeft,
+            Anchor = Anchor.CentreLeft,
+            X = 40f
+        };
+    }
+}
diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs
new file mode 100644
index 0000000000..7f64695bde
--- /dev/null
+++ b/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs
@@ -0,0 +1,44 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Game.Graphics.UserInterface;
+
+namespace osu.Game.Screens.Edit.Components.TernaryButtons
+{
+    public class TernaryButton
+    {
+        public readonly Bindable<TernaryState> Bindable;
+
+        public readonly string Description;
+
+        /// <summary>
+        /// A function which creates a drawable icon to represent this item. If null, a sane default should be used.
+        /// </summary>
+        public readonly Func<Drawable> CreateIcon;
+
+        public TernaryButton(Bindable<TernaryState> bindable, string description, Func<Drawable> createIcon = null)
+        {
+            Bindable = bindable;
+            Description = description;
+            CreateIcon = createIcon;
+        }
+
+        public void Toggle()
+        {
+            switch (Bindable.Value)
+            {
+                case TernaryState.False:
+                case TernaryState.Indeterminate:
+                    Bindable.Value = TernaryState.True;
+                    break;
+
+                case TernaryState.True:
+                    Bindable.Value = TernaryState.False;
+                    break;
+            }
+        }
+    }
+}
diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs
index 4a7c3f26bc..5b8f7c747b 100644
--- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs
+++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs
@@ -3,6 +3,7 @@
 
 using System;
 using osu.Framework.Allocation;
+using osu.Framework.Audio.Track;
 using osu.Framework.Bindables;
 using osuTK;
 using osu.Framework.Graphics;
@@ -22,6 +23,8 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts
     {
         protected readonly IBindable<WorkingBeatmap> Beatmap = new Bindable<WorkingBeatmap>();
 
+        protected readonly IBindable<Track> Track = new Bindable<Track>();
+
         private readonly Container<T> content;
 
         protected override Container<T> Content => content;
@@ -35,12 +38,15 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts
                 updateRelativeChildSize();
                 LoadBeatmap(b.NewValue);
             };
+
+            Track.ValueChanged += _ => updateRelativeChildSize();
         }
 
         [BackgroundDependencyLoader]
-        private void load(IBindable<WorkingBeatmap> beatmap)
+        private void load(IBindable<WorkingBeatmap> beatmap, EditorClock clock)
         {
             Beatmap.BindTo(beatmap);
+            Track.BindTo(clock.Track);
         }
 
         private void updateRelativeChildSize()
diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs
index bf1e18771f..8908520cd7 100644
--- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs
@@ -24,15 +24,15 @@ namespace osu.Game.Screens.Edit.Compose.Components
 {
     /// <summary>
     /// A container which provides a "blueprint" display of hitobjects.
-    /// Includes selection and manipulation support via a <see cref="SelectionHandler"/>.
+    /// Includes selection and manipulation support via a <see cref="Components.SelectionHandler"/>.
     /// </summary>
     public abstract class BlueprintContainer : CompositeDrawable, IKeyBindingHandler<PlatformAction>
     {
         protected DragBox DragBox { get; private set; }
 
-        protected Container<SelectionBlueprint> SelectionBlueprints { get; private set; }
+        public Container<SelectionBlueprint> SelectionBlueprints { get; private set; }
 
-        private SelectionHandler selectionHandler;
+        protected SelectionHandler SelectionHandler { get; private set; }
 
         [Resolved(CanBeNull = true)]
         private IEditorChangeHandler changeHandler { get; set; }
@@ -41,7 +41,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
         private EditorClock editorClock { get; set; }
 
         [Resolved]
-        private EditorBeatmap beatmap { get; set; }
+        protected EditorBeatmap Beatmap { get; private set; }
 
         private readonly BindableList<HitObject> selectedHitObjects = new BindableList<HitObject>();
 
@@ -56,22 +56,22 @@ namespace osu.Game.Screens.Edit.Compose.Components
         [BackgroundDependencyLoader]
         private void load()
         {
-            selectionHandler = CreateSelectionHandler();
-            selectionHandler.DeselectAll = deselectAll;
+            SelectionHandler = CreateSelectionHandler();
+            SelectionHandler.DeselectAll = deselectAll;
 
             AddRangeInternal(new[]
             {
                 DragBox = CreateDragBox(selectBlueprintsFromDragRectangle),
-                selectionHandler,
+                SelectionHandler,
                 SelectionBlueprints = CreateSelectionBlueprintContainer(),
-                selectionHandler.CreateProxy(),
+                SelectionHandler.CreateProxy(),
                 DragBox.CreateProxy().With(p => p.Depth = float.MinValue)
             });
 
-            foreach (var obj in beatmap.HitObjects)
+            foreach (var obj in Beatmap.HitObjects)
                 AddBlueprintFor(obj);
 
-            selectedHitObjects.BindTo(beatmap.SelectedHitObjects);
+            selectedHitObjects.BindTo(Beatmap.SelectedHitObjects);
             selectedHitObjects.CollectionChanged += (selectedObjects, args) =>
             {
                 switch (args.Action)
@@ -94,15 +94,15 @@ namespace osu.Game.Screens.Edit.Compose.Components
         {
             base.LoadComplete();
 
-            beatmap.HitObjectAdded += AddBlueprintFor;
-            beatmap.HitObjectRemoved += removeBlueprintFor;
+            Beatmap.HitObjectAdded += AddBlueprintFor;
+            Beatmap.HitObjectRemoved += removeBlueprintFor;
         }
 
         protected virtual Container<SelectionBlueprint> CreateSelectionBlueprintContainer() =>
             new Container<SelectionBlueprint> { RelativeSizeAxes = Axes.Both };
 
         /// <summary>
-        /// Creates a <see cref="SelectionHandler"/> which outlines <see cref="DrawableHitObject"/>s and handles movement of selections.
+        /// Creates a <see cref="Components.SelectionHandler"/> which outlines <see cref="DrawableHitObject"/>s and handles movement of selections.
         /// </summary>
         protected virtual SelectionHandler CreateSelectionHandler() => new SelectionHandler();
 
@@ -130,7 +130,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
                 return false;
 
             // store for double-click handling
-            clickedBlueprint = selectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered);
+            clickedBlueprint = SelectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered);
 
             // Deselection should only occur if no selected blueprints are hovered
             // A special case for when a blueprint was selected via this click is added since OnClick() may occur outside the hitobject and should not trigger deselection
@@ -147,7 +147,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
                 return false;
 
             // ensure the blueprint which was hovered for the first click is still the hovered blueprint.
-            if (clickedBlueprint == null || selectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered) != clickedBlueprint)
+            if (clickedBlueprint == null || SelectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered) != clickedBlueprint)
                 return false;
 
             editorClock?.SeekTo(clickedBlueprint.HitObject.StartTime);
@@ -208,7 +208,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
             if (DragBox.State == Visibility.Visible)
             {
                 DragBox.Hide();
-                selectionHandler.UpdateVisibility();
+                SelectionHandler.UpdateVisibility();
             }
         }
 
@@ -217,7 +217,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
             switch (e.Key)
             {
                 case Key.Escape:
-                    if (!selectionHandler.SelectedBlueprints.Any())
+                    if (!SelectionHandler.SelectedBlueprints.Any())
                         return false;
 
                     deselectAll();
@@ -271,7 +271,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
             blueprint.Selected += onBlueprintSelected;
             blueprint.Deselected += onBlueprintDeselected;
 
-            if (beatmap.SelectedHitObjects.Contains(hitObject))
+            if (Beatmap.SelectedHitObjects.Contains(hitObject))
                 blueprint.Select();
 
             SelectionBlueprints.Add(blueprint);
@@ -298,14 +298,14 @@ namespace osu.Game.Screens.Edit.Compose.Components
             bool allowDeselection = e.ControlPressed && e.Button == MouseButton.Left;
 
             // Todo: This is probably incorrectly disallowing multiple selections on stacked objects
-            if (!allowDeselection && selectionHandler.SelectedBlueprints.Any(s => s.IsHovered))
+            if (!allowDeselection && SelectionHandler.SelectedBlueprints.Any(s => s.IsHovered))
                 return;
 
             foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren)
             {
                 if (blueprint.IsHovered)
                 {
-                    selectionHandler.HandleSelectionRequested(blueprint, e.CurrentState);
+                    SelectionHandler.HandleSelectionRequested(blueprint, e.CurrentState);
                     clickSelectionBegan = true;
                     break;
                 }
@@ -358,23 +358,23 @@ namespace osu.Game.Screens.Edit.Compose.Components
         private void selectAll()
         {
             SelectionBlueprints.ToList().ForEach(m => m.Select());
-            selectionHandler.UpdateVisibility();
+            SelectionHandler.UpdateVisibility();
         }
 
         /// <summary>
         /// Deselects all selected <see cref="SelectionBlueprint"/>s.
         /// </summary>
-        private void deselectAll() => selectionHandler.SelectedBlueprints.ToList().ForEach(m => m.Deselect());
+        private void deselectAll() => SelectionHandler.SelectedBlueprints.ToList().ForEach(m => m.Deselect());
 
         private void onBlueprintSelected(SelectionBlueprint blueprint)
         {
-            selectionHandler.HandleSelected(blueprint);
+            SelectionHandler.HandleSelected(blueprint);
             SelectionBlueprints.ChangeChildDepth(blueprint, 1);
         }
 
         private void onBlueprintDeselected(SelectionBlueprint blueprint)
         {
-            selectionHandler.HandleDeselected(blueprint);
+            SelectionHandler.HandleDeselected(blueprint);
             SelectionBlueprints.ChangeChildDepth(blueprint, 0);
         }
 
@@ -391,16 +391,16 @@ namespace osu.Game.Screens.Edit.Compose.Components
         /// </summary>
         private void prepareSelectionMovement()
         {
-            if (!selectionHandler.SelectedBlueprints.Any())
+            if (!SelectionHandler.SelectedBlueprints.Any())
                 return;
 
             // Any selected blueprint that is hovered can begin the movement of the group, however only the earliest hitobject is used for movement
             // A special case is added for when a click selection occurred before the drag
-            if (!clickSelectionBegan && !selectionHandler.SelectedBlueprints.Any(b => b.IsHovered))
+            if (!clickSelectionBegan && !SelectionHandler.SelectedBlueprints.Any(b => b.IsHovered))
                 return;
 
             // Movement is tracked from the blueprint of the earliest hitobject, since it only makes sense to distance snap from that hitobject
-            movementBlueprint = selectionHandler.SelectedBlueprints.OrderBy(b => b.HitObject.StartTime).First();
+            movementBlueprint = SelectionHandler.SelectedBlueprints.OrderBy(b => b.HitObject.StartTime).First();
             movementBlueprintOriginalPosition = movementBlueprint.ScreenSpaceSelectionPoint; // todo: unsure if correct
         }
 
@@ -425,14 +425,14 @@ namespace osu.Game.Screens.Edit.Compose.Components
             var result = snapProvider.SnapScreenSpacePositionToValidTime(movePosition);
 
             // Move the hitobjects.
-            if (!selectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprint, result.ScreenSpacePosition)))
+            if (!SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprint, result.ScreenSpacePosition)))
                 return true;
 
             if (result.Time.HasValue)
             {
                 // Apply the start time at the newly snapped-to position
                 double offset = result.Time.Value - draggedObject.StartTime;
-                foreach (HitObject obj in selectionHandler.SelectedHitObjects)
+                foreach (HitObject obj in SelectionHandler.SelectedHitObjects)
                     obj.StartTime += offset;
             }
 
@@ -460,10 +460,10 @@ namespace osu.Game.Screens.Edit.Compose.Components
         {
             base.Dispose(isDisposing);
 
-            if (beatmap != null)
+            if (Beatmap != null)
             {
-                beatmap.HitObjectAdded -= AddBlueprintFor;
-                beatmap.HitObjectRemoved -= removeBlueprintFor;
+                Beatmap.HitObjectAdded -= AddBlueprintFor;
+                Beatmap.HitObjectRemoved -= removeBlueprintFor;
             }
         }
     }
diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs
index e1f311f1b8..81d7fa4b32 100644
--- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs
@@ -3,14 +3,21 @@
 
 using System.Collections.Generic;
 using System.Linq;
+using Humanizer;
 using osu.Framework.Allocation;
+using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Sprites;
 using osu.Framework.Input;
+using osu.Game.Audio;
+using osu.Game.Graphics.UserInterface;
 using osu.Game.Rulesets.Edit;
 using osu.Game.Rulesets.Edit.Tools;
 using osu.Game.Rulesets.Objects;
 using osu.Game.Rulesets.Objects.Drawables;
+using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Screens.Edit.Components.TernaryButtons;
 using osuTK;
 
 namespace osu.Game.Screens.Edit.Compose.Components
@@ -46,6 +53,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
         [BackgroundDependencyLoader]
         private void load()
         {
+            TernaryStates = CreateTernaryButtons().ToArray();
+
             AddInternal(placementBlueprintContainer);
         }
 
@@ -54,6 +63,92 @@ namespace osu.Game.Screens.Edit.Compose.Components
             base.LoadComplete();
 
             inputManager = GetContainingInputManager();
+
+            // updates to selected are handled for us by SelectionHandler.
+            NewCombo.BindTo(SelectionHandler.SelectionNewComboState);
+
+            // we are responsible for current placement blueprint updated based on state changes.
+            NewCombo.ValueChanged += _ => updatePlacementNewCombo();
+
+            // we own SelectionHandler so don't need to worry about making bindable copies (for simplicity)
+            foreach (var kvp in SelectionHandler.SelectionSampleStates)
+            {
+                kvp.Value.BindValueChanged(_ => updatePlacementSamples());
+            }
+        }
+
+        private void updatePlacementNewCombo()
+        {
+            if (currentPlacement == null) return;
+
+            if (currentPlacement.HitObject is IHasComboInformation c)
+                c.NewCombo = NewCombo.Value == TernaryState.True;
+        }
+
+        private void updatePlacementSamples()
+        {
+            if (currentPlacement == null) return;
+
+            foreach (var kvp in SelectionHandler.SelectionSampleStates)
+                sampleChanged(kvp.Key, kvp.Value.Value);
+        }
+
+        private void sampleChanged(string sampleName, TernaryState state)
+        {
+            if (currentPlacement == null) return;
+
+            var samples = currentPlacement.HitObject.Samples;
+
+            var existingSample = samples.FirstOrDefault(s => s.Name == sampleName);
+
+            switch (state)
+            {
+                case TernaryState.False:
+                    if (existingSample != null)
+                        samples.Remove(existingSample);
+                    break;
+
+                case TernaryState.True:
+                    if (existingSample == null)
+                        samples.Add(new HitSampleInfo { Name = sampleName });
+                    break;
+            }
+        }
+
+        public readonly Bindable<TernaryState> NewCombo = new Bindable<TernaryState> { Description = "New Combo" };
+
+        /// <summary>
+        /// A collection of states which will be displayed to the user in the toolbox.
+        /// </summary>
+        public TernaryButton[] TernaryStates { get; private set; }
+
+        /// <summary>
+        /// Create all ternary states required to be displayed to the user.
+        /// </summary>
+        protected virtual IEnumerable<TernaryButton> CreateTernaryButtons()
+        {
+            //TODO: this should only be enabled (visible?) for rulesets that provide combo-supporting HitObjects.
+            yield return new TernaryButton(NewCombo, "New combo", () => new SpriteIcon { Icon = FontAwesome.Regular.DotCircle });
+
+            foreach (var kvp in SelectionHandler.SelectionSampleStates)
+                yield return new TernaryButton(kvp.Value, kvp.Key.Replace("hit", string.Empty).Titleize(), () => getIconForSample(kvp.Key));
+        }
+
+        private Drawable getIconForSample(string sampleName)
+        {
+            switch (sampleName)
+            {
+                case HitSampleInfo.HIT_CLAP:
+                    return new SpriteIcon { Icon = FontAwesome.Solid.Hands };
+
+                case HitSampleInfo.HIT_WHISTLE:
+                    return new SpriteIcon { Icon = FontAwesome.Solid.Bullhorn };
+
+                case HitSampleInfo.HIT_FINISH:
+                    return new SpriteIcon { Icon = FontAwesome.Solid.DrumSteelpan };
+            }
+
+            return null;
         }
 
         #region Placement
@@ -86,7 +181,9 @@ namespace osu.Game.Screens.Edit.Compose.Components
                 removePlacement();
 
             if (currentPlacement != null)
+            {
                 updatePlacementPosition();
+            }
         }
 
         protected sealed override SelectionBlueprint CreateBlueprintFor(HitObject hitObject)
@@ -119,6 +216,10 @@ namespace osu.Game.Screens.Edit.Compose.Components
 
                 // Fixes a 1-frame position discrepancy due to the first mouse move event happening in the next frame
                 updatePlacementPosition();
+
+                updatePlacementSamples();
+
+                updatePlacementNewCombo();
             }
         }
 
diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs
index 6e2c8bd01c..6ca85fe026 100644
--- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs
@@ -4,7 +4,9 @@
 using System;
 using System.Collections.Generic;
 using System.Linq;
+using Humanizer;
 using osu.Framework.Allocation;
+using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Cursor;
@@ -35,6 +37,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
         public IEnumerable<SelectionBlueprint> SelectedBlueprints => selectedBlueprints;
         private readonly List<SelectionBlueprint> selectedBlueprints;
 
+        public int SelectedCount => selectedBlueprints.Count;
+
         public IEnumerable<HitObject> SelectedHitObjects => selectedBlueprints.Select(b => b.HitObject);
 
         private Drawable content;
@@ -59,6 +63,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
         [BackgroundDependencyLoader]
         private void load(OsuColour colours)
         {
+            createStateBindables();
+
             InternalChild = content = new Container
             {
                 Children = new Drawable[]
@@ -283,7 +289,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
                 var comboInfo = h as IHasComboInformation;
 
                 if (comboInfo == null)
-                    throw new InvalidOperationException($"Tried to change combo state of a {h.GetType()}, which doesn't implement {nameof(IHasComboInformation)}");
+                    continue;
 
                 comboInfo.NewCombo = state;
                 EditorBeatmap?.UpdateHitObject(h);
@@ -308,6 +314,93 @@ namespace osu.Game.Screens.Edit.Compose.Components
 
         #endregion
 
+        #region Selection State
+
+        /// <summary>
+        /// The state of "new combo" for all selected hitobjects.
+        /// </summary>
+        public readonly Bindable<TernaryState> SelectionNewComboState = new Bindable<TernaryState>();
+
+        /// <summary>
+        /// The state of each sample type for all selected hitobjects. Keys match with <see cref="HitSampleInfo"/> constant specifications.
+        /// </summary>
+        public readonly Dictionary<string, Bindable<TernaryState>> SelectionSampleStates = new Dictionary<string, Bindable<TernaryState>>();
+
+        /// <summary>
+        /// Set up ternary state bindables and bind them to selection/hitobject changes (in both directions)
+        /// </summary>
+        private void createStateBindables()
+        {
+            foreach (var sampleName in HitSampleInfo.AllAdditions)
+            {
+                var bindable = new Bindable<TernaryState>
+                {
+                    Description = sampleName.Replace("hit", string.Empty).Titleize()
+                };
+
+                bindable.ValueChanged += state =>
+                {
+                    switch (state.NewValue)
+                    {
+                        case TernaryState.False:
+                            RemoveHitSample(sampleName);
+                            break;
+
+                        case TernaryState.True:
+                            AddHitSample(sampleName);
+                            break;
+                    }
+                };
+
+                SelectionSampleStates[sampleName] = bindable;
+            }
+
+            // new combo
+            SelectionNewComboState.ValueChanged += state =>
+            {
+                switch (state.NewValue)
+                {
+                    case TernaryState.False:
+                        SetNewCombo(false);
+                        break;
+
+                    case TernaryState.True:
+                        SetNewCombo(true);
+                        break;
+                }
+            };
+
+            // bring in updates from selection changes
+            EditorBeatmap.HitObjectUpdated += _ => UpdateTernaryStates();
+            EditorBeatmap.SelectedHitObjects.CollectionChanged += (sender, args) => UpdateTernaryStates();
+        }
+
+        /// <summary>
+        /// Called when context menu ternary states may need to be recalculated (selection changed or hitobject updated).
+        /// </summary>
+        protected virtual void UpdateTernaryStates()
+        {
+            SelectionNewComboState.Value = GetStateFromSelection(SelectedHitObjects.OfType<IHasComboInformation>(), h => h.NewCombo);
+
+            foreach (var (sampleName, bindable) in SelectionSampleStates)
+            {
+                bindable.Value = GetStateFromSelection(SelectedHitObjects, h => h.Samples.Any(s => s.Name == sampleName));
+            }
+        }
+
+        /// <summary>
+        /// Given a selection target and a function of truth, retrieve the correct ternary state for display.
+        /// </summary>
+        protected TernaryState GetStateFromSelection<T>(IEnumerable<T> selection, Func<T, bool> func)
+        {
+            if (selection.Any(func))
+                return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate;
+
+            return TernaryState.False;
+        }
+
+        #endregion
+
         #region Context Menu
 
         public MenuItem[] ContextMenuItems
@@ -322,7 +415,9 @@ namespace osu.Game.Screens.Edit.Compose.Components
                 items.AddRange(GetContextMenuItemsForSelection(selectedBlueprints));
 
                 if (selectedBlueprints.All(b => b.HitObject is IHasComboInformation))
-                    items.Add(createNewComboMenuItem());
+                {
+                    items.Add(new TernaryStateMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } });
+                }
 
                 if (selectedBlueprints.Count == 1)
                     items.AddRange(selectedBlueprints[0].ContextMenuItems);
@@ -331,12 +426,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
                 {
                     new OsuMenuItem("Sound")
                     {
-                        Items = new[]
-                        {
-                            createHitSampleMenuItem("Whistle", HitSampleInfo.HIT_WHISTLE),
-                            createHitSampleMenuItem("Clap", HitSampleInfo.HIT_CLAP),
-                            createHitSampleMenuItem("Finish", HitSampleInfo.HIT_FINISH)
-                        }
+                        Items = SelectionSampleStates.Select(kvp =>
+                            new TernaryStateMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray()
                     },
                     new OsuMenuItem("Delete", MenuItemType.Destructive, deleteSelected),
                 });
@@ -353,76 +444,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
         protected virtual IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint> selection)
             => Enumerable.Empty<MenuItem>();
 
-        private MenuItem createNewComboMenuItem()
-        {
-            return new TernaryStateMenuItem("New combo", MenuItemType.Standard, setNewComboState)
-            {
-                State = { Value = getHitSampleState() }
-            };
-
-            void setNewComboState(TernaryState state)
-            {
-                switch (state)
-                {
-                    case TernaryState.False:
-                        SetNewCombo(false);
-                        break;
-
-                    case TernaryState.True:
-                        SetNewCombo(true);
-                        break;
-                }
-            }
-
-            TernaryState getHitSampleState()
-            {
-                int countExisting = selectedBlueprints.Select(b => (IHasComboInformation)b.HitObject).Count(h => h.NewCombo);
-
-                if (countExisting == 0)
-                    return TernaryState.False;
-
-                if (countExisting < SelectedHitObjects.Count())
-                    return TernaryState.Indeterminate;
-
-                return TernaryState.True;
-            }
-        }
-
-        private MenuItem createHitSampleMenuItem(string name, string sampleName)
-        {
-            return new TernaryStateMenuItem(name, MenuItemType.Standard, setHitSampleState)
-            {
-                State = { Value = getHitSampleState() }
-            };
-
-            void setHitSampleState(TernaryState state)
-            {
-                switch (state)
-                {
-                    case TernaryState.False:
-                        RemoveHitSample(sampleName);
-                        break;
-
-                    case TernaryState.True:
-                        AddHitSample(sampleName);
-                        break;
-                }
-            }
-
-            TernaryState getHitSampleState()
-            {
-                int countExisting = SelectedHitObjects.Count(h => h.Samples.Any(s => s.Name == sampleName));
-
-                if (countExisting == 0)
-                    return TernaryState.False;
-
-                if (countExisting < SelectedHitObjects.Count())
-                    return TernaryState.Indeterminate;
-
-                return TernaryState.True;
-            }
-        }
-
         #endregion
     }
 }
diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs
index 04983ca597..d7a4661fa0 100644
--- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs
+++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs
@@ -13,6 +13,11 @@ namespace osu.Game.Screens.Edit.Compose
     {
         private HitObjectComposer composer;
 
+        public ComposeScreen()
+            : base(EditorScreenMode.Compose)
+        {
+        }
+
         protected override Drawable CreateMainContent()
         {
             var ruleset = Beatmap.Value.BeatmapInfo.Ruleset?.CreateInstance();
diff --git a/osu.Game/Screens/Edit/Design/DesignScreen.cs b/osu.Game/Screens/Edit/Design/DesignScreen.cs
index 9f1fcf55b2..f15639733c 100644
--- a/osu.Game/Screens/Edit/Design/DesignScreen.cs
+++ b/osu.Game/Screens/Edit/Design/DesignScreen.cs
@@ -6,6 +6,7 @@ namespace osu.Game.Screens.Edit.Design
     public class DesignScreen : EditorScreen
     {
         public DesignScreen()
+            : base(EditorScreenMode.Design)
         {
             Child = new ScreenWhiteBox.UnderConstructionMessage("Design mode");
         }
diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs
index b7a59bc2e2..fd090e0959 100644
--- a/osu.Game/Screens/Edit/Editor.cs
+++ b/osu.Game/Screens/Edit/Editor.cs
@@ -43,6 +43,7 @@ using osuTK.Input;
 namespace osu.Game.Screens.Edit
 {
     [Cached(typeof(IBeatSnapProvider))]
+    [Cached]
     public class Editor : ScreenWithBeatmapBackground, IKeyBindingHandler<GlobalAction>, IKeyBindingHandler<PlatformAction>, IBeatSnapProvider
     {
         public override float BackgroundParallaxAmount => 0.1f;
@@ -68,7 +69,7 @@ namespace osu.Game.Screens.Edit
         private string lastSavedHash;
 
         private Box bottomBackground;
-        private Container screenContainer;
+        private Container<EditorScreen> screenContainer;
 
         private EditorScreen currentScreen;
 
@@ -91,6 +92,9 @@ namespace osu.Game.Screens.Edit
         [Resolved]
         private IAPIProvider api { get; set; }
 
+        [Resolved]
+        private MusicController music { get; set; }
+
         [BackgroundDependencyLoader]
         private void load(OsuColour colours, GameHost host)
         {
@@ -98,9 +102,9 @@ namespace osu.Game.Screens.Edit
             beatDivisor.BindValueChanged(divisor => Beatmap.Value.BeatmapInfo.BeatDivisor = divisor.NewValue);
 
             // Todo: should probably be done at a DrawableRuleset level to share logic with Player.
-            var sourceClock = (IAdjustableClock)Beatmap.Value.Track ?? new StopwatchClock();
             clock = new EditorClock(Beatmap.Value, beatDivisor) { IsCoupled = false };
-            clock.ChangeSource(sourceClock);
+
+            UpdateClockSource();
 
             dependencies.CacheAs(clock);
             AddInternal(clock);
@@ -163,7 +167,7 @@ namespace osu.Game.Screens.Edit
                         Name = "Screen container",
                         RelativeSizeAxes = Axes.Both,
                         Padding = new MarginPadding { Top = 40, Bottom = 60 },
-                        Child = screenContainer = new Container
+                        Child = screenContainer = new Container<EditorScreen>
                         {
                             RelativeSizeAxes = Axes.Both,
                             Masking = true
@@ -271,6 +275,15 @@ namespace osu.Game.Screens.Edit
             bottomBackground.Colour = colours.Gray2;
         }
 
+        /// <summary>
+        /// If the beatmap's track has changed, this method must be called to keep the editor in a valid state.
+        /// </summary>
+        public void UpdateClockSource()
+        {
+            var sourceClock = (IAdjustableClock)Beatmap.Value.Track ?? new StopwatchClock();
+            clock.ChangeSource(sourceClock);
+        }
+
         protected void Save()
         {
             // apply any set-level metadata changes.
@@ -512,7 +525,21 @@ namespace osu.Game.Screens.Edit
 
         private void onModeChanged(ValueChangedEvent<EditorScreenMode> e)
         {
-            currentScreen?.Exit();
+            var lastScreen = currentScreen;
+
+            lastScreen?
+                .ScaleTo(0.98f, 200, Easing.OutQuint)
+                .FadeOut(200, Easing.OutQuint);
+
+            if ((currentScreen = screenContainer.SingleOrDefault(s => s.Type == e.NewValue)) != null)
+            {
+                screenContainer.ChangeChildDepth(currentScreen, lastScreen?.Depth + 1 ?? 0);
+
+                currentScreen
+                    .ScaleTo(1, 200, Easing.OutQuint)
+                    .FadeIn(200, Easing.OutQuint);
+                return;
+            }
 
             switch (e.NewValue)
             {
diff --git a/osu.Game/Screens/Edit/EditorClock.cs b/osu.Game/Screens/Edit/EditorClock.cs
index d4d0feb813..ec203df064 100644
--- a/osu.Game/Screens/Edit/EditorClock.cs
+++ b/osu.Game/Screens/Edit/EditorClock.cs
@@ -3,6 +3,8 @@
 
 using System;
 using System.Linq;
+using osu.Framework.Audio.Track;
+using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Transforms;
 using osu.Framework.Utils;
@@ -17,7 +19,11 @@ namespace osu.Game.Screens.Edit
     /// </summary>
     public class EditorClock : Component, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock
     {
-        public readonly double TrackLength;
+        public IBindable<Track> Track => track;
+
+        private readonly Bindable<Track> track = new Bindable<Track>();
+
+        public double TrackLength => track.Value?.Length ?? 60000;
 
         public ControlPointInfo ControlPointInfo;
 
@@ -35,7 +41,6 @@ namespace osu.Game.Screens.Edit
             this.beatDivisor = beatDivisor;
 
             ControlPointInfo = controlPointInfo;
-            TrackLength = trackLength;
 
             underlyingClock = new DecoupleableInterpolatingFramedClock();
         }
@@ -190,7 +195,11 @@ namespace osu.Game.Screens.Edit
 
         public FrameTimeInfo TimeInfo => underlyingClock.TimeInfo;
 
-        public void ChangeSource(IClock source) => underlyingClock.ChangeSource(source);
+        public void ChangeSource(IClock source)
+        {
+            track.Value = source as Track;
+            underlyingClock.ChangeSource(source);
+        }
 
         public IClock Source => underlyingClock.Source;
 
diff --git a/osu.Game/Screens/Edit/EditorScreen.cs b/osu.Game/Screens/Edit/EditorScreen.cs
index 8b5f0aaa71..52bffc4342 100644
--- a/osu.Game/Screens/Edit/EditorScreen.cs
+++ b/osu.Game/Screens/Edit/EditorScreen.cs
@@ -23,8 +23,12 @@ namespace osu.Game.Screens.Edit
         protected override Container<Drawable> Content => content;
         private readonly Container content;
 
-        protected EditorScreen()
+        public readonly EditorScreenMode Type;
+
+        protected EditorScreen(EditorScreenMode type)
         {
+            Type = type;
+
             Anchor = Anchor.Centre;
             Origin = Anchor.Centre;
             RelativeSizeAxes = Axes.Both;
diff --git a/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs b/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs
index 66d90809db..34eddbefad 100644
--- a/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs
+++ b/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs
@@ -25,6 +25,11 @@ namespace osu.Game.Screens.Edit
 
         private Container timelineContainer;
 
+        protected EditorScreenWithTimeline(EditorScreenMode type)
+            : base(type)
+        {
+        }
+
         [BackgroundDependencyLoader(true)]
         private void load([CanBeNull] BindableBeatDivisor beatDivisor)
         {
diff --git a/osu.Game/Screens/Edit/Setup/SetupScreen.cs b/osu.Game/Screens/Edit/Setup/SetupScreen.cs
index a2c8f19016..f6eb92e1ec 100644
--- a/osu.Game/Screens/Edit/Setup/SetupScreen.cs
+++ b/osu.Game/Screens/Edit/Setup/SetupScreen.cs
@@ -1,17 +1,24 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
+using System;
+using System.IO;
 using System.Linq;
 using osu.Framework.Allocation;
+using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Shapes;
 using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Input.Events;
+using osu.Game.Beatmaps;
 using osu.Game.Beatmaps.Drawables;
 using osu.Game.Graphics;
 using osu.Game.Graphics.Containers;
 using osu.Game.Graphics.Sprites;
+using osu.Game.Graphics.UserInterface;
 using osu.Game.Graphics.UserInterfaceV2;
+using osu.Game.Overlays;
 using osuTK;
 
 namespace osu.Game.Screens.Edit.Setup
@@ -23,10 +30,31 @@ namespace osu.Game.Screens.Edit.Setup
         private LabelledTextBox titleTextBox;
         private LabelledTextBox creatorTextBox;
         private LabelledTextBox difficultyTextBox;
+        private LabelledTextBox audioTrackTextBox;
+
+        [Resolved]
+        private MusicController music { get; set; }
+
+        [Resolved]
+        private BeatmapManager beatmaps { get; set; }
+
+        [Resolved(canBeNull: true)]
+        private Editor editor { get; set; }
+
+        public SetupScreen()
+            : base(EditorScreenMode.SongSetup)
+        {
+        }
 
         [BackgroundDependencyLoader]
         private void load(OsuColour colours)
         {
+            Container audioTrackFileChooserContainer = new Container
+            {
+                RelativeSizeAxes = Axes.X,
+                AutoSizeAxes = Axes.Y,
+            };
+
             Child = new Container
             {
                 RelativeSizeAxes = Axes.Both,
@@ -70,6 +98,18 @@ namespace osu.Game.Screens.Edit.Setup
                                         },
                                     },
                                     new OsuSpriteText
+                                    {
+                                        Text = "Resources"
+                                    },
+                                    audioTrackTextBox = new FileChooserLabelledTextBox
+                                    {
+                                        Label = "Audio Track",
+                                        Current = { Value = Beatmap.Value.Metadata.AudioFile ?? "Click to select a track" },
+                                        Target = audioTrackFileChooserContainer,
+                                        TabbableContentContainer = this
+                                    },
+                                    audioTrackFileChooserContainer,
+                                    new OsuSpriteText
                                     {
                                         Text = "Beatmap metadata"
                                     },
@@ -104,10 +144,47 @@ namespace osu.Game.Screens.Edit.Setup
                 }
             };
 
+            audioTrackTextBox.Current.BindValueChanged(audioTrackChanged);
+
             foreach (var item in flow.OfType<LabelledTextBox>())
                 item.OnCommit += onCommit;
         }
 
+        public bool ChangeAudioTrack(string path)
+        {
+            var info = new FileInfo(path);
+
+            if (!info.Exists)
+                return false;
+
+            var set = Beatmap.Value.BeatmapSetInfo;
+
+            // remove the previous audio track for now.
+            // in the future we probably want to check if this is being used elsewhere (other difficulties?)
+            var oldFile = set.Files.FirstOrDefault(f => f.Filename == Beatmap.Value.Metadata.AudioFile);
+
+            using (var stream = info.OpenRead())
+            {
+                if (oldFile != null)
+                    beatmaps.ReplaceFile(set, oldFile, stream, info.Name);
+                else
+                    beatmaps.AddFile(set, stream, info.Name);
+            }
+
+            Beatmap.Value.Metadata.AudioFile = info.Name;
+
+            music.ReloadCurrentTrack();
+
+            editor?.UpdateClockSource();
+            return true;
+        }
+
+        private void audioTrackChanged(ValueChangedEvent<string> filePath)
+        {
+            if (!ChangeAudioTrack(filePath.NewValue))
+                audioTrackTextBox.Current.Value = filePath.OldValue;
+        }
+
         private void onCommit(TextBox sender, bool newText)
         {
             if (!newText) return;
@@ -120,4 +197,60 @@ namespace osu.Game.Screens.Edit.Setup
             Beatmap.Value.BeatmapInfo.Version = difficultyTextBox.Current.Value;
         }
     }
+
+    internal class FileChooserLabelledTextBox : LabelledTextBox
+    {
+        public Container Target;
+
+        private readonly IBindable<FileInfo> currentFile = new Bindable<FileInfo>();
+
+        public FileChooserLabelledTextBox()
+        {
+            currentFile.BindValueChanged(onFileSelected);
+        }
+
+        private void onFileSelected(ValueChangedEvent<FileInfo> file)
+        {
+            if (file.NewValue == null)
+                return;
+
+            Target.Clear();
+            Current.Value = file.NewValue.FullName;
+        }
+
+        protected override OsuTextBox CreateTextBox() =>
+            new FileChooserOsuTextBox
+            {
+                Anchor = Anchor.Centre,
+                Origin = Anchor.Centre,
+                RelativeSizeAxes = Axes.X,
+                CornerRadius = CORNER_RADIUS,
+                OnFocused = DisplayFileChooser
+            };
+
+        public void DisplayFileChooser()
+        {
+            Target.Child = new FileSelector(validFileExtensions: new[] { ".mp3", ".ogg" })
+            {
+                RelativeSizeAxes = Axes.X,
+                Height = 400,
+                Anchor = Anchor.Centre,
+                Origin = Anchor.Centre,
+                CurrentFile = { BindTarget = currentFile }
+            };
+        }
+
+        internal class FileChooserOsuTextBox : OsuTextBox
+        {
+            public Action OnFocused;
+
+            protected override void OnFocus(FocusEvent e)
+            {
+                OnFocused?.Invoke();
+                base.OnFocus(e);
+
+                GetContainingInputManager().TriggerFocusContention(this);
+            }
+        }
+    }
 }
diff --git a/osu.Game/Screens/Edit/Timing/TimingScreen.cs b/osu.Game/Screens/Edit/Timing/TimingScreen.cs
index 8c40c8e721..d7da29218f 100644
--- a/osu.Game/Screens/Edit/Timing/TimingScreen.cs
+++ b/osu.Game/Screens/Edit/Timing/TimingScreen.cs
@@ -24,6 +24,11 @@ namespace osu.Game.Screens.Edit.Timing
         [Resolved]
         private EditorClock clock { get; set; }
 
+        public TimingScreen()
+            : base(EditorScreenMode.Timing)
+        {
+        }
+
         protected override Drawable CreateMainContent() => new GridContainer
         {
             RelativeSizeAxes = Axes.Both,
diff --git a/osu.Game/Tests/Visual/EditorTestScene.cs b/osu.Game/Tests/Visual/EditorTestScene.cs
index 8f76f247cf..a9ee8e2668 100644
--- a/osu.Game/Tests/Visual/EditorTestScene.cs
+++ b/osu.Game/Tests/Visual/EditorTestScene.cs
@@ -26,13 +26,15 @@ namespace osu.Game.Tests.Visual
             Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value);
         }
 
+        protected virtual bool EditorComponentsReady => Editor.ChildrenOfType<HitObjectComposer>().FirstOrDefault()?.IsLoaded == true
+                                                        && Editor.ChildrenOfType<TimelineArea>().FirstOrDefault()?.IsLoaded == true;
+
         public override void SetUpSteps()
         {
             base.SetUpSteps();
 
             AddStep("load editor", () => LoadScreen(Editor = CreateEditor()));
-            AddUntilStep("wait for editor to load", () => Editor.ChildrenOfType<HitObjectComposer>().FirstOrDefault()?.IsLoaded == true
-                                                          && Editor.ChildrenOfType<TimelineArea>().FirstOrDefault()?.IsLoaded == true);
+            AddUntilStep("wait for editor to load", () => EditorComponentsReady);
             AddStep("get beatmap", () => EditorBeatmap = Editor.ChildrenOfType<EditorBeatmap>().Single());
             AddStep("get clock", () => EditorClock = Editor.ChildrenOfType<EditorClock>().Single());
         }
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index 71826e161c..6412f707d0 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -24,7 +24,7 @@
     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
     <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
-    <PackageReference Include="ppy.osu.Framework" Version="2020.923.1" />
+    <PackageReference Include="ppy.osu.Framework" Version="2020.925.0" />
     <PackageReference Include="ppy.osu.Game.Resources" Version="2020.904.0" />
     <PackageReference Include="Sentry" Version="2.1.6" />
     <PackageReference Include="SharpCompress" Version="0.26.0" />
diff --git a/osu.iOS.props b/osu.iOS.props
index 90aa903318..f1e13169a5 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -70,7 +70,7 @@
     <Reference Include="System.Net.Http" />
   </ItemGroup>
   <ItemGroup Label="Package References">
-    <PackageReference Include="ppy.osu.Framework.iOS" Version="2020.923.1" />
+    <PackageReference Include="ppy.osu.Framework.iOS" Version="2020.925.0" />
     <PackageReference Include="ppy.osu.Game.Resources" Version="2020.904.0" />
   </ItemGroup>
   <!-- Xamarin.iOS does not automatically handle transitive dependencies from NuGet packages. -->
@@ -80,7 +80,7 @@
     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
     <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
-    <PackageReference Include="ppy.osu.Framework" Version="2020.923.1" />
+    <PackageReference Include="ppy.osu.Framework" Version="2020.925.0" />
     <PackageReference Include="SharpCompress" Version="0.26.0" />
     <PackageReference Include="NUnit" Version="3.12.0" />
     <PackageReference Include="SharpRaven" Version="2.4.0" />
diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings
index 29ca385275..64f3d41acb 100644
--- a/osu.sln.DotSettings
+++ b/osu.sln.DotSettings
@@ -909,6 +909,7 @@ private void load()
 	<s:Boolean x:Key="/Default/UserDictionary/Words/=beatmaps/@EntryIndexedValue">True</s:Boolean>
 	<s:Boolean x:Key="/Default/UserDictionary/Words/=beatmap_0027s/@EntryIndexedValue">True</s:Boolean>
 	<s:Boolean x:Key="/Default/UserDictionary/Words/=bindable/@EntryIndexedValue">True</s:Boolean>
+	<s:Boolean x:Key="/Default/UserDictionary/Words/=bindables/@EntryIndexedValue">True</s:Boolean>
 	<s:Boolean x:Key="/Default/UserDictionary/Words/=Catmull/@EntryIndexedValue">True</s:Boolean>
 	<s:Boolean x:Key="/Default/UserDictionary/Words/=Drawables/@EntryIndexedValue">True</s:Boolean>
 	<s:Boolean x:Key="/Default/UserDictionary/Words/=gameplay/@EntryIndexedValue">True</s:Boolean>