diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs
new file mode 100644
index 0000000000..40bb83aece
--- /dev/null
+++ b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs
@@ -0,0 +1,51 @@
+// 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.Game.Rulesets.Mania.Beatmaps;
+using osu.Game.Rulesets.Mania.Replays;
+
+namespace osu.Game.Rulesets.Mania.Tests
+{
+    [TestFixture]
+    public class ManiaLegacyReplayTest
+    {
+        [TestCase(ManiaAction.Key1)]
+        [TestCase(ManiaAction.Key1, ManiaAction.Key2)]
+        [TestCase(ManiaAction.Special1)]
+        [TestCase(ManiaAction.Key8)]
+        public void TestEncodeDecodeSingleStage(params ManiaAction[] actions)
+        {
+            var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 9 });
+
+            var frame = new ManiaReplayFrame(0, actions);
+            var legacyFrame = frame.ToLegacy(beatmap);
+
+            var decodedFrame = new ManiaReplayFrame();
+            decodedFrame.FromLegacy(legacyFrame, beatmap);
+
+            Assert.That(decodedFrame.Actions, Is.EquivalentTo(frame.Actions));
+        }
+
+        [TestCase(ManiaAction.Key1)]
+        [TestCase(ManiaAction.Key1, ManiaAction.Key2)]
+        [TestCase(ManiaAction.Special1)]
+        [TestCase(ManiaAction.Special2)]
+        [TestCase(ManiaAction.Special1, ManiaAction.Special2)]
+        [TestCase(ManiaAction.Special1, ManiaAction.Key5)]
+        [TestCase(ManiaAction.Key8)]
+        public void TestEncodeDecodeDualStage(params ManiaAction[] actions)
+        {
+            var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 5 });
+            beatmap.Stages.Add(new StageDefinition { Columns = 5 });
+
+            var frame = new ManiaReplayFrame(0, actions);
+            var legacyFrame = frame.ToLegacy(beatmap);
+
+            var decodedFrame = new ManiaReplayFrame();
+            decodedFrame.FromLegacy(legacyFrame, beatmap);
+
+            Assert.That(decodedFrame.Actions, Is.EquivalentTo(frame.Actions));
+        }
+    }
+}
diff --git a/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs b/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs
index 8c73c36e99..dbab54d1d0 100644
--- a/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs
+++ b/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs
@@ -1,8 +1,8 @@
 // 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.Game.Beatmaps;
 using osu.Game.Replays.Legacy;
 using osu.Game.Rulesets.Mania.Beatmaps;
@@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Mania.Replays
 
             while (activeColumns > 0)
             {
-                var isSpecial = maniaBeatmap.Stages.First().IsSpecialColumn(counter);
+                bool isSpecial = isColumnAtIndexSpecial(maniaBeatmap, counter);
 
                 if ((activeColumns & 1) > 0)
                     Actions.Add(isSpecial ? specialAction : normalAction);
@@ -58,33 +58,87 @@ namespace osu.Game.Rulesets.Mania.Replays
 
             int keys = 0;
 
-            var specialColumns = new List<int>();
-
-            for (int i = 0; i < maniaBeatmap.TotalColumns; i++)
-            {
-                if (maniaBeatmap.Stages.First().IsSpecialColumn(i))
-                    specialColumns.Add(i);
-            }
-
             foreach (var action in Actions)
             {
                 switch (action)
                 {
                     case ManiaAction.Special1:
-                        keys |= 1 << specialColumns[0];
+                        keys |= 1 << getSpecialColumnIndex(maniaBeatmap, 0);
                         break;
 
                     case ManiaAction.Special2:
-                        keys |= 1 << specialColumns[1];
+                        keys |= 1 << getSpecialColumnIndex(maniaBeatmap, 1);
                         break;
 
                     default:
-                        keys |= 1 << (action - ManiaAction.Key1);
+                        // the index in lazer, which doesn't include special keys.
+                        int nonSpecialKeyIndex = action - ManiaAction.Key1;
+
+                        // the index inclusive of special keys.
+                        int overallIndex = 0;
+
+                        // iterate to find the index including special keys.
+                        for (; overallIndex < maniaBeatmap.TotalColumns; overallIndex++)
+                        {
+                            // skip over special columns.
+                            if (isColumnAtIndexSpecial(maniaBeatmap, overallIndex))
+                                continue;
+                            // found a non-special column to use.
+                            if (nonSpecialKeyIndex == 0)
+                                break;
+                            // found a non-special column but not ours.
+                            nonSpecialKeyIndex--;
+                        }
+
+                        keys |= 1 << overallIndex;
                         break;
                 }
             }
 
             return new LegacyReplayFrame(Time, keys, null, ReplayButtonState.None);
         }
+
+        /// <summary>
+        /// Find the overall index (across all stages) for a specified special key.
+        /// </summary>
+        /// <param name="maniaBeatmap">The beatmap.</param>
+        /// <param name="specialOffset">The special key offset (0 is S1).</param>
+        /// <returns>The overall index for the special column.</returns>
+        private int getSpecialColumnIndex(ManiaBeatmap maniaBeatmap, int specialOffset)
+        {
+            for (int i = 0; i < maniaBeatmap.TotalColumns; i++)
+            {
+                if (isColumnAtIndexSpecial(maniaBeatmap, i))
+                {
+                    if (specialOffset == 0)
+                        return i;
+
+                    specialOffset--;
+                }
+            }
+
+            throw new ArgumentException("Special key index is too high.", nameof(specialOffset));
+        }
+
+        /// <summary>
+        /// Check whether the column at an overall index (across all stages) is a special column.
+        /// </summary>
+        /// <param name="beatmap">The beatmap.</param>
+        /// <param name="index">The overall index to check.</param>
+        private bool isColumnAtIndexSpecial(ManiaBeatmap beatmap, int index)
+        {
+            foreach (var stage in beatmap.Stages)
+            {
+                if (index >= stage.Columns)
+                {
+                    index -= stage.Columns;
+                    continue;
+                }
+
+                return stage.IsSpecialColumn(index);
+            }
+
+            throw new ArgumentException("Column index is too high.", nameof(index));
+        }
     }
 }
diff --git a/osu.Game.Tests/Editor/EditorChangeHandlerTest.cs b/osu.Game.Tests/Editor/EditorChangeHandlerTest.cs
index ef16976130..9613f250c4 100644
--- a/osu.Game.Tests/Editor/EditorChangeHandlerTest.cs
+++ b/osu.Game.Tests/Editor/EditorChangeHandlerTest.cs
@@ -15,15 +15,18 @@ namespace osu.Game.Tests.Editor
         {
             var handler = new EditorChangeHandler(new EditorBeatmap(new Beatmap()));
 
-            Assert.That(handler.HasUndoState, Is.False);
+            Assert.That(handler.CanUndo.Value, Is.False);
+            Assert.That(handler.CanRedo.Value, Is.False);
 
             handler.SaveState();
 
-            Assert.That(handler.HasUndoState, Is.True);
+            Assert.That(handler.CanUndo.Value, Is.True);
+            Assert.That(handler.CanRedo.Value, Is.False);
 
             handler.RestoreState(-1);
 
-            Assert.That(handler.HasUndoState, Is.False);
+            Assert.That(handler.CanUndo.Value, Is.False);
+            Assert.That(handler.CanRedo.Value, Is.True);
         }
 
         [Test]
@@ -31,20 +34,20 @@ namespace osu.Game.Tests.Editor
         {
             var handler = new EditorChangeHandler(new EditorBeatmap(new Beatmap()));
 
-            Assert.That(handler.HasUndoState, Is.False);
+            Assert.That(handler.CanUndo.Value, Is.False);
 
             for (int i = 0; i < EditorChangeHandler.MAX_SAVED_STATES; i++)
                 handler.SaveState();
 
-            Assert.That(handler.HasUndoState, Is.True);
+            Assert.That(handler.CanUndo.Value, Is.True);
 
             for (int i = 0; i < EditorChangeHandler.MAX_SAVED_STATES; i++)
             {
-                Assert.That(handler.HasUndoState, Is.True);
+                Assert.That(handler.CanUndo.Value, Is.True);
                 handler.RestoreState(-1);
             }
 
-            Assert.That(handler.HasUndoState, Is.False);
+            Assert.That(handler.CanUndo.Value, Is.False);
         }
 
         [Test]
@@ -52,20 +55,20 @@ namespace osu.Game.Tests.Editor
         {
             var handler = new EditorChangeHandler(new EditorBeatmap(new Beatmap()));
 
-            Assert.That(handler.HasUndoState, Is.False);
+            Assert.That(handler.CanUndo.Value, Is.False);
 
             for (int i = 0; i < EditorChangeHandler.MAX_SAVED_STATES * 2; i++)
                 handler.SaveState();
 
-            Assert.That(handler.HasUndoState, Is.True);
+            Assert.That(handler.CanUndo.Value, Is.True);
 
             for (int i = 0; i < EditorChangeHandler.MAX_SAVED_STATES; i++)
             {
-                Assert.That(handler.HasUndoState, Is.True);
+                Assert.That(handler.CanUndo.Value, Is.True);
                 handler.RestoreState(-1);
             }
 
-            Assert.That(handler.HasUndoState, Is.False);
+            Assert.That(handler.CanUndo.Value, Is.False);
         }
     }
 }
diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs
new file mode 100644
index 0000000000..f611f2717e
--- /dev/null
+++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs
@@ -0,0 +1,346 @@
+// 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 System.Threading.Tasks;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Audio;
+using osu.Framework.IO.Stores;
+using osu.Framework.Testing;
+using osu.Framework.Timing;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.Formats;
+using osu.Game.IO;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Osu;
+using osu.Game.Skinning;
+using osu.Game.Storyboards;
+using osu.Game.Tests.Resources;
+using osu.Game.Tests.Visual;
+using osu.Game.Users;
+
+namespace osu.Game.Tests.Gameplay
+{
+    [HeadlessTest]
+    public class TestSceneHitObjectSamples : PlayerTestScene
+    {
+        private readonly SkinInfo userSkinInfo = new SkinInfo();
+
+        private readonly BeatmapInfo beatmapInfo = new BeatmapInfo
+        {
+            BeatmapSet = new BeatmapSetInfo(),
+            Metadata = new BeatmapMetadata
+            {
+                Author = User.SYSTEM_USER
+            }
+        };
+
+        private readonly TestResourceStore userSkinResourceStore = new TestResourceStore();
+        private readonly TestResourceStore beatmapSkinResourceStore = new TestResourceStore();
+
+        protected override bool HasCustomSteps => true;
+
+        public TestSceneHitObjectSamples()
+            : base(new OsuRuleset())
+        {
+        }
+
+        private SkinSourceDependencyContainer dependencies;
+
+        protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
+            => new DependencyContainer(dependencies = new SkinSourceDependencyContainer(base.CreateChildDependencies(parent)));
+
+        /// <summary>
+        /// Tests that a hitobject which provides no custom sample set retrieves samples from the user skin.
+        /// </summary>
+        [Test]
+        public void TestDefaultSampleFromUserSkin()
+        {
+            const string expected_sample = "normal-hitnormal";
+
+            setupSkins(expected_sample, expected_sample);
+
+            createTestWithBeatmap("hitobject-skin-sample.osu");
+
+            assertUserLookup(expected_sample);
+        }
+
+        /// <summary>
+        /// Tests that a hitobject which provides a sample set of 1 retrieves samples from the beatmap skin.
+        /// </summary>
+        [Test]
+        public void TestDefaultSampleFromBeatmap()
+        {
+            const string expected_sample = "normal-hitnormal";
+
+            setupSkins(expected_sample, expected_sample);
+
+            createTestWithBeatmap("hitobject-beatmap-sample.osu");
+
+            assertBeatmapLookup(expected_sample);
+        }
+
+        /// <summary>
+        /// Tests that a hitobject which provides a sample set of 1 retrieves samples from the user skin when the beatmap does not contain the sample.
+        /// </summary>
+        [Test]
+        public void TestDefaultSampleFromUserSkinFallback()
+        {
+            const string expected_sample = "normal-hitnormal";
+
+            setupSkins(null, expected_sample);
+
+            createTestWithBeatmap("hitobject-beatmap-sample.osu");
+
+            assertUserLookup(expected_sample);
+        }
+
+        /// <summary>
+        /// Tests that a hitobject which provides a custom sample set of 2 retrieves the following samples from the beatmap skin:
+        /// normal-hitnormal2
+        /// normal-hitnormal
+        /// </summary>
+        [TestCase("normal-hitnormal2")]
+        [TestCase("normal-hitnormal")]
+        public void TestDefaultCustomSampleFromBeatmap(string expectedSample)
+        {
+            setupSkins(expectedSample, expectedSample);
+
+            createTestWithBeatmap("hitobject-beatmap-custom-sample.osu");
+
+            assertBeatmapLookup(expectedSample);
+        }
+
+        /// <summary>
+        /// Tests that a hitobject which provides a custom sample set of 2 retrieves the following samples from the user skin when the beatmap does not contain the sample:
+        /// normal-hitnormal2
+        /// normal-hitnormal
+        /// </summary>
+        [TestCase("normal-hitnormal2")]
+        [TestCase("normal-hitnormal")]
+        public void TestDefaultCustomSampleFromUserSkinFallback(string expectedSample)
+        {
+            setupSkins(string.Empty, expectedSample);
+
+            createTestWithBeatmap("hitobject-beatmap-custom-sample.osu");
+
+            assertUserLookup(expectedSample);
+        }
+
+        /// <summary>
+        /// Tests that a hitobject which provides a sample file retrieves the sample file from the beatmap skin.
+        /// </summary>
+        [Test]
+        public void TestFileSampleFromBeatmap()
+        {
+            const string expected_sample = "hit_1.wav";
+
+            setupSkins(expected_sample, expected_sample);
+
+            createTestWithBeatmap("file-beatmap-sample.osu");
+
+            assertBeatmapLookup(expected_sample);
+        }
+
+        /// <summary>
+        /// Tests that a default hitobject and control point causes <see cref="TestDefaultSampleFromUserSkin"/>.
+        /// </summary>
+        [Test]
+        public void TestControlPointSampleFromSkin()
+        {
+            const string expected_sample = "normal-hitnormal";
+
+            setupSkins(expected_sample, expected_sample);
+
+            createTestWithBeatmap("controlpoint-skin-sample.osu");
+
+            assertUserLookup(expected_sample);
+        }
+
+        /// <summary>
+        /// Tests that a control point that provides a custom sample set of 1 causes <see cref="TestDefaultSampleFromBeatmap"/>.
+        /// </summary>
+        [Test]
+        public void TestControlPointSampleFromBeatmap()
+        {
+            const string expected_sample = "normal-hitnormal";
+
+            setupSkins(expected_sample, expected_sample);
+
+            createTestWithBeatmap("controlpoint-beatmap-sample.osu");
+
+            assertBeatmapLookup(expected_sample);
+        }
+
+        /// <summary>
+        /// Tests that a control point that provides a custom sample of 2 causes <see cref="TestDefaultCustomSampleFromBeatmap"/>.
+        /// </summary>
+        [TestCase("normal-hitnormal2")]
+        [TestCase("normal-hitnormal")]
+        public void TestControlPointCustomSampleFromBeatmap(string sampleName)
+        {
+            setupSkins(sampleName, sampleName);
+
+            createTestWithBeatmap("controlpoint-beatmap-custom-sample.osu");
+
+            assertBeatmapLookup(sampleName);
+        }
+
+        /// <summary>
+        /// Tests that a hitobject's custom sample overrides the control point's.
+        /// </summary>
+        [Test]
+        public void TestHitObjectCustomSampleOverride()
+        {
+            const string expected_sample = "normal-hitnormal3";
+
+            setupSkins(expected_sample, expected_sample);
+
+            createTestWithBeatmap("hitobject-beatmap-custom-sample-override.osu");
+
+            assertBeatmapLookup(expected_sample);
+        }
+
+        protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => currentTestBeatmap;
+
+        protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null)
+            => new TestWorkingBeatmap(beatmapInfo, beatmapSkinResourceStore, beatmap, storyboard, Clock, Audio);
+
+        private IBeatmap currentTestBeatmap;
+
+        private void createTestWithBeatmap(string filename)
+        {
+            CreateTest(() =>
+            {
+                AddStep("clear performed lookups", () =>
+                {
+                    userSkinResourceStore.PerformedLookups.Clear();
+                    beatmapSkinResourceStore.PerformedLookups.Clear();
+                });
+
+                AddStep($"load {filename}", () =>
+                {
+                    using (var reader = new LineBufferedReader(TestResources.OpenResource($"SampleLookups/{filename}")))
+                        currentTestBeatmap = Decoder.GetDecoder<Beatmap>(reader).Decode(reader);
+                });
+            });
+        }
+
+        private void setupSkins(string beatmapFile, string userFile)
+        {
+            AddStep("setup skins", () =>
+            {
+                userSkinInfo.Files = new List<SkinFileInfo>
+                {
+                    new SkinFileInfo
+                    {
+                        Filename = userFile,
+                        FileInfo = new IO.FileInfo { Hash = userFile }
+                    }
+                };
+
+                beatmapInfo.BeatmapSet.Files = new List<BeatmapSetFileInfo>
+                {
+                    new BeatmapSetFileInfo
+                    {
+                        Filename = beatmapFile,
+                        FileInfo = new IO.FileInfo { Hash = beatmapFile }
+                    }
+                };
+
+                // Need to refresh the cached skin source to refresh the skin resource store.
+                dependencies.SkinSource = new SkinProvidingContainer(new LegacySkin(userSkinInfo, userSkinResourceStore, Audio));
+            });
+        }
+
+        private void assertBeatmapLookup(string name) => AddAssert($"\"{name}\" looked up from beatmap skin",
+            () => !userSkinResourceStore.PerformedLookups.Contains(name) && beatmapSkinResourceStore.PerformedLookups.Contains(name));
+
+        private void assertUserLookup(string name) => AddAssert($"\"{name}\" looked up from user skin",
+            () => !beatmapSkinResourceStore.PerformedLookups.Contains(name) && userSkinResourceStore.PerformedLookups.Contains(name));
+
+        private class SkinSourceDependencyContainer : IReadOnlyDependencyContainer
+        {
+            public ISkinSource SkinSource;
+
+            private readonly IReadOnlyDependencyContainer fallback;
+
+            public SkinSourceDependencyContainer(IReadOnlyDependencyContainer fallback)
+            {
+                this.fallback = fallback;
+            }
+
+            public object Get(Type type)
+            {
+                if (type == typeof(ISkinSource))
+                    return SkinSource;
+
+                return fallback.Get(type);
+            }
+
+            public object Get(Type type, CacheInfo info)
+            {
+                if (type == typeof(ISkinSource))
+                    return SkinSource;
+
+                return fallback.Get(type, info);
+            }
+
+            public void Inject<T>(T instance) where T : class
+            {
+                // Never used directly
+            }
+        }
+
+        private class TestResourceStore : IResourceStore<byte[]>
+        {
+            public readonly List<string> PerformedLookups = new List<string>();
+
+            public byte[] Get(string name)
+            {
+                markLookup(name);
+                return Array.Empty<byte>();
+            }
+
+            public Task<byte[]> GetAsync(string name)
+            {
+                markLookup(name);
+                return Task.FromResult(Array.Empty<byte>());
+            }
+
+            public Stream GetStream(string name)
+            {
+                markLookup(name);
+                return new MemoryStream();
+            }
+
+            private void markLookup(string name) => PerformedLookups.Add(name.Substring(name.LastIndexOf(Path.DirectorySeparatorChar) + 1));
+
+            public IEnumerable<string> GetAvailableResources() => Enumerable.Empty<string>();
+
+            public void Dispose()
+            {
+            }
+        }
+
+        private class TestWorkingBeatmap : ClockBackedTestWorkingBeatmap
+        {
+            private readonly BeatmapInfo skinBeatmapInfo;
+            private readonly IResourceStore<byte[]> resourceStore;
+
+            public TestWorkingBeatmap(BeatmapInfo skinBeatmapInfo, IResourceStore<byte[]> resourceStore, IBeatmap beatmap, Storyboard storyboard, IFrameBasedClock referenceClock, AudioManager audio,
+                                      double length = 60000)
+                : base(beatmap, storyboard, referenceClock, audio, length)
+            {
+                this.skinBeatmapInfo = skinBeatmapInfo;
+                this.resourceStore = resourceStore;
+            }
+
+            protected override ISkin GetSkin() => new LegacyBeatmapSkin(skinBeatmapInfo, resourceStore, AudioManager);
+        }
+    }
+}
diff --git a/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-custom-sample.osu b/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-custom-sample.osu
new file mode 100644
index 0000000000..91dbc6a60e
--- /dev/null
+++ b/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-custom-sample.osu
@@ -0,0 +1,7 @@
+osu file format v14
+
+[TimingPoints]
+0,300,4,0,2,100,1,0
+
+[HitObjects]
+444,320,1000,5,0,0:0:0:0:
\ No newline at end of file
diff --git a/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-sample.osu b/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-sample.osu
new file mode 100644
index 0000000000..3274820100
--- /dev/null
+++ b/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-sample.osu
@@ -0,0 +1,7 @@
+osu file format v14
+
+[TimingPoints]
+0,300,4,0,1,100,1,0
+
+[HitObjects]
+444,320,1000,5,0,0:0:0:0:
\ No newline at end of file
diff --git a/osu.Game.Tests/Resources/SampleLookups/controlpoint-skin-sample.osu b/osu.Game.Tests/Resources/SampleLookups/controlpoint-skin-sample.osu
new file mode 100644
index 0000000000..c53ec465fb
--- /dev/null
+++ b/osu.Game.Tests/Resources/SampleLookups/controlpoint-skin-sample.osu
@@ -0,0 +1,7 @@
+osu file format v14
+
+[TimingPoints]
+0,300,4,0,0,100,1,0
+
+[HitObjects]
+444,320,1000,5,0,0:0:0:0:
\ No newline at end of file
diff --git a/osu.Game.Tests/Resources/SampleLookups/file-beatmap-sample.osu b/osu.Game.Tests/Resources/SampleLookups/file-beatmap-sample.osu
new file mode 100644
index 0000000000..65b5ea8707
--- /dev/null
+++ b/osu.Game.Tests/Resources/SampleLookups/file-beatmap-sample.osu
@@ -0,0 +1,4 @@
+osu file format v14
+
+[HitObjects]
+255,193,2170,1,0,0:0:0:0:hit_1.wav
diff --git a/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample-override.osu b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample-override.osu
new file mode 100644
index 0000000000..13dc2faab1
--- /dev/null
+++ b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample-override.osu
@@ -0,0 +1,7 @@
+osu file format v14
+
+[TimingPoints]
+0,300,4,0,2,100,1,0
+
+[HitObjects]
+444,320,1000,5,0,0:0:3:0:
\ No newline at end of file
diff --git a/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample.osu b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample.osu
new file mode 100644
index 0000000000..4ab672dbb0
--- /dev/null
+++ b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample.osu
@@ -0,0 +1,4 @@
+osu file format v14
+
+[HitObjects]
+444,320,1000,5,0,0:0:2:0:
\ No newline at end of file
diff --git a/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-sample.osu b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-sample.osu
new file mode 100644
index 0000000000..33bc34949a
--- /dev/null
+++ b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-sample.osu
@@ -0,0 +1,4 @@
+osu file format v14
+
+[HitObjects]
+444,320,1000,5,0,0:0:1:0:
\ No newline at end of file
diff --git a/osu.Game.Tests/Resources/SampleLookups/hitobject-skin-sample.osu b/osu.Game.Tests/Resources/SampleLookups/hitobject-skin-sample.osu
new file mode 100644
index 0000000000..47f5b44c90
--- /dev/null
+++ b/osu.Game.Tests/Resources/SampleLookups/hitobject-skin-sample.osu
@@ -0,0 +1,4 @@
+osu file format v14
+
+[HitObjects]
+444,320,1000,5,0,0:0:0:0:
\ No newline at end of file
diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMenu.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMenu.cs
new file mode 100644
index 0000000000..9ea76c2c7b
--- /dev/null
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMenu.cs
@@ -0,0 +1,91 @@
+// 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 NUnit.Framework;
+using osu.Framework.Graphics;
+using osu.Framework.Testing;
+using osu.Game.Graphics.UserInterface;
+using osuTK.Input;
+
+namespace osu.Game.Tests.Visual.UserInterface
+{
+    public class TestSceneOsuMenu : OsuManualInputManagerTestScene
+    {
+        public override IReadOnlyList<Type> RequiredTypes => new[]
+        {
+            typeof(OsuMenu),
+            typeof(DrawableOsuMenuItem)
+        };
+
+        private OsuMenu menu;
+        private bool actionPerformed;
+
+        [SetUp]
+        public void Setup() => Schedule(() =>
+        {
+            actionPerformed = false;
+
+            Child = menu = new OsuMenu(Direction.Vertical, true)
+            {
+                Anchor = Anchor.Centre,
+                Origin = Anchor.Centre,
+                Items = new[]
+                {
+                    new OsuMenuItem("standard", MenuItemType.Standard, performAction),
+                    new OsuMenuItem("highlighted", MenuItemType.Highlighted, performAction),
+                    new OsuMenuItem("destructive", MenuItemType.Destructive, performAction),
+                }
+            };
+        });
+
+        [Test]
+        public void TestClickEnabledMenuItem()
+        {
+            AddStep("move to first menu item", () => InputManager.MoveMouseTo(menu.ChildrenOfType<DrawableOsuMenuItem>().First()));
+            AddStep("click", () => InputManager.Click(MouseButton.Left));
+
+            AddAssert("action performed", () => actionPerformed);
+        }
+
+        [Test]
+        public void TestDisableMenuItemsAndClick()
+        {
+            AddStep("disable menu items", () =>
+            {
+                foreach (var item in menu.Items)
+                    ((OsuMenuItem)item).Action.Disabled = true;
+            });
+
+            AddStep("move to first menu item", () => InputManager.MoveMouseTo(menu.ChildrenOfType<DrawableOsuMenuItem>().First()));
+            AddStep("click", () => InputManager.Click(MouseButton.Left));
+
+            AddAssert("action not performed", () => !actionPerformed);
+        }
+
+        [Test]
+        public void TestEnableMenuItemsAndClick()
+        {
+            AddStep("disable menu items", () =>
+            {
+                foreach (var item in menu.Items)
+                    ((OsuMenuItem)item).Action.Disabled = true;
+            });
+
+            AddStep("enable menu items", () =>
+            {
+                foreach (var item in menu.Items)
+                    ((OsuMenuItem)item).Action.Disabled = false;
+            });
+
+            AddStep("move to first menu item", () => InputManager.MoveMouseTo(menu.ChildrenOfType<DrawableOsuMenuItem>().First()));
+            AddStep("click", () => InputManager.Click(MouseButton.Left));
+
+            AddAssert("action performed", () => actionPerformed);
+        }
+
+        private void performAction() => actionPerformed = true;
+    }
+}
diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs
index 561707f9ef..5b2b213322 100644
--- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs
+++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs
@@ -8,6 +8,7 @@ using osu.Framework.Logging;
 using osu.Game.Audio;
 using osu.Game.Beatmaps.ControlPoints;
 using osu.Game.IO;
+using osu.Game.Rulesets.Objects.Legacy;
 using osuTK.Graphics;
 
 namespace osu.Game.Beatmaps.Formats
@@ -168,8 +169,11 @@ namespace osu.Game.Beatmaps.Formats
             {
                 var baseInfo = base.ApplyTo(hitSampleInfo);
 
-                if (string.IsNullOrEmpty(baseInfo.Suffix) && CustomSampleBank > 1)
-                    baseInfo.Suffix = CustomSampleBank.ToString();
+                if (baseInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy
+                    && legacy.CustomSampleBank == 0)
+                {
+                    legacy.CustomSampleBank = CustomSampleBank;
+                }
 
                 return baseInfo;
             }
diff --git a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs
index a3ca851341..abaae7b43c 100644
--- a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs
+++ b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs
@@ -42,6 +42,8 @@ namespace osu.Game.Graphics.UserInterface
             BackgroundColourHover = Color4Extensions.FromHex(@"172023");
 
             updateTextColour();
+
+            Item.Action.BindDisabledChanged(_ => updateState(), true);
         }
 
         private void updateTextColour()
@@ -65,19 +67,33 @@ namespace osu.Game.Graphics.UserInterface
 
         protected override bool OnHover(HoverEvent e)
         {
-            sampleHover.Play();
-            text.BoldText.FadeIn(transition_length, Easing.OutQuint);
-            text.NormalText.FadeOut(transition_length, Easing.OutQuint);
+            updateState();
             return base.OnHover(e);
         }
 
         protected override void OnHoverLost(HoverLostEvent e)
         {
-            text.BoldText.FadeOut(transition_length, Easing.OutQuint);
-            text.NormalText.FadeIn(transition_length, Easing.OutQuint);
+            updateState();
             base.OnHoverLost(e);
         }
 
+        private void updateState()
+        {
+            Alpha = Item.Action.Disabled ? 0.2f : 1;
+
+            if (IsHovered && !Item.Action.Disabled)
+            {
+                sampleHover.Play();
+                text.BoldText.FadeIn(transition_length, Easing.OutQuint);
+                text.NormalText.FadeOut(transition_length, Easing.OutQuint);
+            }
+            else
+            {
+                text.BoldText.FadeOut(transition_length, Easing.OutQuint);
+                text.NormalText.FadeIn(transition_length, Easing.OutQuint);
+            }
+        }
+
         protected override bool OnClick(ClickEvent e)
         {
             sampleClick.Play();
diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs
index 8d3ad5984f..9a60a0a75c 100644
--- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs
+++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs
@@ -409,22 +409,34 @@ namespace osu.Game.Rulesets.Objects.Legacy
             public SampleBankInfo Clone() => (SampleBankInfo)MemberwiseClone();
         }
 
-        private class LegacyHitSampleInfo : HitSampleInfo
+        internal class LegacyHitSampleInfo : HitSampleInfo
         {
+            private int customSampleBank;
+
             public int CustomSampleBank
             {
+                get => customSampleBank;
                 set
                 {
-                    if (value > 1)
+                    customSampleBank = value;
+
+                    if (value >= 2)
                         Suffix = value.ToString();
                 }
             }
         }
 
-        private class FileHitSampleInfo : HitSampleInfo
+        private class FileHitSampleInfo : LegacyHitSampleInfo
         {
             public string Filename;
 
+            public FileHitSampleInfo()
+            {
+                // Make sure that the LegacyBeatmapSkin does not fall back to the user skin.
+                // Note that this does not change the lookup names, as they are overridden locally.
+                CustomSampleBank = 1;
+            }
+
             public override IEnumerable<string> LookupNames => new[]
             {
                 Filename,
diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs
index 14a227eb07..9a1f450dc6 100644
--- a/osu.Game/Screens/Edit/Editor.cs
+++ b/osu.Game/Screens/Edit/Editor.cs
@@ -107,6 +107,8 @@ namespace osu.Game.Screens.Edit
             dependencies.CacheAs<IEditorChangeHandler>(changeHandler);
 
             EditorMenuBar menuBar;
+            OsuMenuItem undoMenuItem;
+            OsuMenuItem redoMenuItem;
 
             var fileMenuItems = new List<MenuItem>
             {
@@ -155,8 +157,8 @@ namespace osu.Game.Screens.Edit
                                 {
                                     Items = new[]
                                     {
-                                        new EditorMenuItem("Undo", MenuItemType.Standard, undo),
-                                        new EditorMenuItem("Redo", MenuItemType.Standard, redo)
+                                        undoMenuItem = new EditorMenuItem("Undo", MenuItemType.Standard, undo),
+                                        redoMenuItem = new EditorMenuItem("Redo", MenuItemType.Standard, redo)
                                     }
                                 }
                             }
@@ -214,6 +216,9 @@ namespace osu.Game.Screens.Edit
                 }
             });
 
+            changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true);
+            changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true);
+
             menuBar.Mode.ValueChanged += onModeChanged;
 
             bottomBackground.Colour = colours.Gray2;
diff --git a/osu.Game/Screens/Edit/EditorChangeHandler.cs b/osu.Game/Screens/Edit/EditorChangeHandler.cs
index a8204715cd..1553c2d2ef 100644
--- a/osu.Game/Screens/Edit/EditorChangeHandler.cs
+++ b/osu.Game/Screens/Edit/EditorChangeHandler.cs
@@ -5,6 +5,7 @@ using System;
 using System.Collections.Generic;
 using System.IO;
 using System.Text;
+using osu.Framework.Bindables;
 using osu.Game.Beatmaps.Formats;
 using osu.Game.Rulesets.Objects;
 
@@ -15,8 +16,10 @@ namespace osu.Game.Screens.Edit
     /// </summary>
     public class EditorChangeHandler : IEditorChangeHandler
     {
-        private readonly LegacyEditorBeatmapPatcher patcher;
+        public readonly Bindable<bool> CanUndo = new Bindable<bool>();
+        public readonly Bindable<bool> CanRedo = new Bindable<bool>();
 
+        private readonly LegacyEditorBeatmapPatcher patcher;
         private readonly List<byte[]> savedStates = new List<byte[]>();
 
         private int currentState = -1;
@@ -45,8 +48,6 @@ namespace osu.Game.Screens.Edit
             SaveState();
         }
 
-        public bool HasUndoState => currentState > 0;
-
         private void hitObjectAdded(HitObject obj) => SaveState();
 
         private void hitObjectRemoved(HitObject obj) => SaveState();
@@ -90,6 +91,8 @@ namespace osu.Game.Screens.Edit
             }
 
             currentState = savedStates.Count - 1;
+
+            updateBindables();
         }
 
         /// <summary>
@@ -114,6 +117,14 @@ namespace osu.Game.Screens.Edit
             currentState = newState;
 
             isRestoring = false;
+
+            updateBindables();
+        }
+
+        private void updateBindables()
+        {
+            CanUndo.Value = savedStates.Count > 0 && currentState > 0;
+            CanRedo.Value = currentState < savedStates.Count - 1;
         }
     }
 }
diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs
index 2520c70989..3e4798a812 100644
--- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs
+++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs
@@ -49,12 +49,13 @@ namespace osu.Game.Screens.Select.Carousel
         }
 
         [BackgroundDependencyLoader(true)]
-        private void load(SongSelect songSelect, BeatmapManager manager)
+        private void load(BeatmapManager manager, SongSelect songSelect)
         {
             if (songSelect != null)
             {
                 startRequested = b => songSelect.FinaliseSelection(b);
-                editRequested = songSelect.Edit;
+                if (songSelect.AllowEditing)
+                    editRequested = songSelect.Edit;
             }
 
             if (manager != null)
@@ -187,15 +188,19 @@ namespace osu.Game.Screens.Select.Carousel
         {
             get
             {
-                List<MenuItem> items = new List<MenuItem>
-                {
-                    new OsuMenuItem("Play", MenuItemType.Highlighted, () => startRequested?.Invoke(beatmap)),
-                    new OsuMenuItem("Edit", MenuItemType.Standard, () => editRequested?.Invoke(beatmap)),
-                    new OsuMenuItem("Hide", MenuItemType.Destructive, () => hideRequested?.Invoke(beatmap)),
-                };
+                List<MenuItem> items = new List<MenuItem>();
 
-                if (beatmap.OnlineBeatmapID.HasValue)
-                    items.Add(new OsuMenuItem("Details", MenuItemType.Standard, () => beatmapOverlay?.FetchAndShowBeatmap(beatmap.OnlineBeatmapID.Value)));
+                if (startRequested != null)
+                    items.Add(new OsuMenuItem("Play", MenuItemType.Highlighted, () => startRequested(beatmap)));
+
+                if (editRequested != null)
+                    items.Add(new OsuMenuItem("Edit", MenuItemType.Standard, () => editRequested(beatmap)));
+
+                if (hideRequested != null)
+                    items.Add(new OsuMenuItem("Hide", MenuItemType.Destructive, () => hideRequested(beatmap)));
+
+                if (beatmap.OnlineBeatmapID.HasValue && beatmapOverlay != null)
+                    items.Add(new OsuMenuItem("Details", MenuItemType.Standard, () => beatmapOverlay.FetchAndShowBeatmap(beatmap.OnlineBeatmapID.Value)));
 
                 return items.ToArray();
             }
diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs
index a53b74c1b8..5acb6d1946 100644
--- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs
+++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs
@@ -46,6 +46,7 @@ namespace osu.Game.Screens.Select.Carousel
         private void load(BeatmapManager manager, BeatmapSetOverlay beatmapOverlay)
         {
             restoreHiddenRequested = s => s.Beatmaps.ForEach(manager.Restore);
+
             if (beatmapOverlay != null)
                 viewDetails = beatmapOverlay.FetchAndShowBeatmapSet;
 
@@ -131,13 +132,14 @@ namespace osu.Game.Screens.Select.Carousel
                 if (Item.State.Value == CarouselItemState.NotSelected)
                     items.Add(new OsuMenuItem("Expand", MenuItemType.Highlighted, () => Item.State.Value = CarouselItemState.Selected));
 
-                if (beatmapSet.OnlineBeatmapSetID != null)
-                    items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => viewDetails?.Invoke(beatmapSet.OnlineBeatmapSetID.Value)));
+                if (beatmapSet.OnlineBeatmapSetID != null && viewDetails != null)
+                    items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => viewDetails(beatmapSet.OnlineBeatmapSetID.Value)));
 
                 if (beatmapSet.Beatmaps.Any(b => b.Hidden))
-                    items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested?.Invoke(beatmapSet)));
+                    items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested(beatmapSet)));
 
-                items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay?.Push(new BeatmapDeleteDialog(beatmapSet))));
+                if (dialogOverlay != null)
+                    items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay.Push(new BeatmapDeleteDialog(beatmapSet))));
 
                 return items.ToArray();
             }
diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs
index f164056ede..5bc2e1aa56 100644
--- a/osu.Game/Screens/Select/SongSelect.cs
+++ b/osu.Game/Screens/Select/SongSelect.cs
@@ -34,7 +34,6 @@ using System.Linq;
 using System.Threading.Tasks;
 using osu.Framework.Graphics.Sprites;
 using osu.Framework.Input.Bindings;
-using osu.Game.Overlays.Notifications;
 using osu.Game.Scoring;
 
 namespace osu.Game.Screens.Select
@@ -71,9 +70,6 @@ namespace osu.Game.Screens.Select
         /// </summary>
         public virtual bool AllowEditing => true;
 
-        [Resolved(canBeNull: true)]
-        private NotificationOverlay notificationOverlay { get; set; }
-
         [Resolved]
         private Bindable<IReadOnlyList<Mod>> selectedMods { get; set; }
 
@@ -329,10 +325,7 @@ namespace osu.Game.Screens.Select
         public void Edit(BeatmapInfo beatmap = null)
         {
             if (!AllowEditing)
-            {
-                notificationOverlay?.Post(new SimpleNotification { Text = "Editing is not available from the current mode." });
-                return;
-            }
+                throw new InvalidOperationException($"Attempted to edit when {nameof(AllowEditing)} is disabled");
 
             Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap ?? beatmapNoDebounce);
             this.Push(new Editor());
diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs
index 1190a330fe..21533e58cd 100644
--- a/osu.Game/Skinning/LegacyBeatmapSkin.cs
+++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs
@@ -2,9 +2,12 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using osu.Framework.Audio;
+using osu.Framework.Audio.Sample;
 using osu.Framework.Bindables;
 using osu.Framework.IO.Stores;
+using osu.Game.Audio;
 using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Objects.Legacy;
 
 namespace osu.Game.Skinning
 {
@@ -33,6 +36,17 @@ namespace osu.Game.Skinning
             return base.GetConfig<TLookup, TValue>(lookup);
         }
 
+        public override SampleChannel GetSample(ISampleInfo sampleInfo)
+        {
+            if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy && legacy.CustomSampleBank == 0)
+            {
+                // When no custom sample bank is provided, always fall-back to the default samples.
+                return null;
+            }
+
+            return base.GetSample(sampleInfo);
+        }
+
         private static SkinInfo createSkinInfo(BeatmapInfo beatmap) =>
             new SkinInfo { Name = beatmap.ToString(), Creator = beatmap.Metadata.Author.ToString() };
     }
diff --git a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs
index 6db34af20c..8f8afb87d4 100644
--- a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs
+++ b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs
@@ -1,6 +1,7 @@
 // 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.Audio;
 using osu.Framework.Audio.Track;
 using osu.Framework.Graphics.Textures;
 using osu.Game.Beatmaps;
@@ -18,8 +19,9 @@ namespace osu.Game.Tests.Beatmaps
         /// </summary>
         /// <param name="beatmap">The beatmap.</param>
         /// <param name="storyboard">An optional storyboard.</param>
-        public TestWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null)
-            : base(beatmap.BeatmapInfo, null)
+        /// <param name="audioManager">The <see cref="AudioManager"/>.</param>
+        public TestWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null, AudioManager audioManager = null)
+            : base(beatmap.BeatmapInfo, audioManager)
         {
             this.beatmap = beatmap;
             this.storyboard = storyboard;
diff --git a/osu.Game/Tests/Visual/OsuTestScene.cs b/osu.Game/Tests/Visual/OsuTestScene.cs
index d1d8059cb1..5dc8714c07 100644
--- a/osu.Game/Tests/Visual/OsuTestScene.cs
+++ b/osu.Game/Tests/Visual/OsuTestScene.cs
@@ -179,7 +179,7 @@ namespace osu.Game.Tests.Visual
             /// <param name="audio">Audio manager. Required if a reference clock isn't provided.</param>
             /// <param name="length">The length of the returned virtual track.</param>
             public ClockBackedTestWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard, IFrameBasedClock referenceClock, AudioManager audio, double length = 60000)
-                : base(beatmap, storyboard)
+                : base(beatmap, storyboard, audio)
             {
                 if (referenceClock != null)
                 {