diff --git a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj
index c44ed69c4d..19e36a63f1 100644
--- a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj
+++ b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj
@@ -69,5 +69,9 @@
osu.Game
+
+
+
+
\ No newline at end of file
diff --git a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj
index ca68369ebb..67b2298f4c 100644
--- a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj
+++ b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj
@@ -45,6 +45,7 @@
+
\ No newline at end of file
diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs
new file mode 100644
index 0000000000..e4ded602aa
--- /dev/null
+++ b/osu.Game.Tests/Mods/ModUtilsTest.cs
@@ -0,0 +1,110 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using Moq;
+using NUnit.Framework;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Utils;
+
+namespace osu.Game.Tests.Mods
+{
+ [TestFixture]
+ public class ModUtilsTest
+ {
+ [Test]
+ public void TestModIsCompatibleByItself()
+ {
+ var mod = new Mock();
+ Assert.That(ModUtils.CheckCompatibleSet(new[] { mod.Object }));
+ }
+
+ [Test]
+ public void TestIncompatibleThroughTopLevel()
+ {
+ var mod1 = new Mock();
+ var mod2 = new Mock();
+
+ mod1.Setup(m => m.IncompatibleMods).Returns(new[] { mod2.Object.GetType() });
+
+ // Test both orderings.
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, mod2.Object }), Is.False);
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.False);
+ }
+
+ [Test]
+ public void TestMultiModIncompatibleWithTopLevel()
+ {
+ var mod1 = new Mock();
+
+ // The nested mod.
+ var mod2 = new Mock();
+ mod2.Setup(m => m.IncompatibleMods).Returns(new[] { mod1.Object.GetType() });
+
+ var multiMod = new MultiMod(new MultiMod(mod2.Object));
+
+ // Test both orderings.
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { multiMod, mod1.Object }), Is.False);
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, multiMod }), Is.False);
+ }
+
+ [Test]
+ public void TestTopLevelIncompatibleWithMultiMod()
+ {
+ // The nested mod.
+ var mod1 = new Mock();
+ var multiMod = new MultiMod(new MultiMod(mod1.Object));
+
+ var mod2 = new Mock();
+ mod2.Setup(m => m.IncompatibleMods).Returns(new[] { typeof(CustomMod1) });
+
+ // Test both orderings.
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { multiMod, mod2.Object }), Is.False);
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, multiMod }), Is.False);
+ }
+
+ [Test]
+ public void TestCompatibleMods()
+ {
+ var mod1 = new Mock();
+ var mod2 = new Mock();
+
+ // Test both orderings.
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, mod2.Object }), Is.True);
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.True);
+ }
+
+ [Test]
+ public void TestIncompatibleThroughBaseType()
+ {
+ var mod1 = new Mock();
+ var mod2 = new Mock();
+ mod2.Setup(m => m.IncompatibleMods).Returns(new[] { typeof(Mod) });
+
+ // Test both orderings.
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, mod2.Object }), Is.False);
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.False);
+ }
+
+ [Test]
+ public void TestAllowedThroughMostDerivedType()
+ {
+ var mod = new Mock();
+ Assert.That(ModUtils.CheckAllowed(new[] { mod.Object }, new[] { mod.Object.GetType() }));
+ }
+
+ [Test]
+ public void TestNotAllowedThroughBaseType()
+ {
+ var mod = new Mock();
+ Assert.That(ModUtils.CheckAllowed(new[] { mod.Object }, new[] { typeof(Mod) }), Is.False);
+ }
+
+ public abstract class CustomMod1 : Mod
+ {
+ }
+
+ public abstract class CustomMod2 : Mod
+ {
+ }
+ }
+}
diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj
index c0c0578391..d29ed94b5f 100644
--- a/osu.Game.Tests/osu.Game.Tests.csproj
+++ b/osu.Game.Tests/osu.Game.Tests.csproj
@@ -7,6 +7,7 @@
+
WinExe
diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs
new file mode 100644
index 0000000000..8ac5bde65a
--- /dev/null
+++ b/osu.Game/Utils/ModUtils.cs
@@ -0,0 +1,109 @@
+// Copyright (c) ppy Pty Ltd . 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.Diagnostics.CodeAnalysis;
+using System.Linq;
+using osu.Game.Rulesets.Mods;
+
+#nullable enable
+
+namespace osu.Game.Utils
+{
+ ///
+ /// A set of utilities to handle combinations.
+ ///
+ public static class ModUtils
+ {
+ ///
+ /// Checks that all s are compatible with each-other, and that all appear within a set of allowed types.
+ ///
+ ///
+ /// The allowed types must contain exact types for the respective s to be allowed.
+ ///
+ /// The s to check.
+ /// The set of allowed types.
+ /// Whether all s are compatible with each-other and appear in the set of allowed types.
+ public static bool CheckCompatibleSetAndAllowed(IEnumerable combination, IEnumerable allowedTypes)
+ {
+ // Prevent multiple-enumeration.
+ var combinationList = combination as ICollection ?? combination.ToArray();
+ return CheckCompatibleSet(combinationList, out _) && CheckAllowed(combinationList, allowedTypes);
+ }
+
+ ///
+ /// Checks that all s in a combination are compatible with each-other.
+ ///
+ /// The combination to check.
+ /// Whether all s in the combination are compatible with each-other.
+ public static bool CheckCompatibleSet(IEnumerable combination)
+ => CheckCompatibleSet(combination, out _);
+
+ ///
+ /// Checks that all s in a combination are compatible with each-other.
+ ///
+ /// The combination to check.
+ /// Any invalid mods in the set.
+ /// Whether all s in the combination are compatible with each-other.
+ public static bool CheckCompatibleSet(IEnumerable combination, [NotNullWhen(false)] out List? invalidMods)
+ {
+ combination = FlattenMods(combination).ToArray();
+ invalidMods = null;
+
+ foreach (var mod in combination)
+ {
+ foreach (var type in mod.IncompatibleMods)
+ {
+ foreach (var invalid in combination.Where(m => type.IsInstanceOfType(m)))
+ {
+ invalidMods ??= new List();
+ invalidMods.Add(invalid);
+ }
+ }
+ }
+
+ return invalidMods == null;
+ }
+
+ ///
+ /// Checks that all s in a combination appear within a set of allowed types.
+ ///
+ ///
+ /// The set of allowed types must contain exact types for the respective s to be allowed.
+ ///
+ /// The combination to check.
+ /// The set of allowed types.
+ /// Whether all s in the combination are allowed.
+ public static bool CheckAllowed(IEnumerable combination, IEnumerable allowedTypes)
+ {
+ var allowedSet = new HashSet(allowedTypes);
+
+ return combination.SelectMany(FlattenMod)
+ .All(m => allowedSet.Contains(m.GetType()));
+ }
+
+ ///
+ /// Flattens a set of s, returning a new set with all s removed.
+ ///
+ /// The set of s to flatten.
+ /// The new set, containing all s in recursively with all s removed.
+ public static IEnumerable FlattenMods(IEnumerable mods) => mods.SelectMany(FlattenMod);
+
+ ///
+ /// Flattens a , returning a set of s in-place of any s.
+ ///
+ /// The to flatten.
+ /// A set of singular "flattened" s
+ public static IEnumerable FlattenMod(Mod mod)
+ {
+ if (mod is MultiMod multi)
+ {
+ foreach (var m in multi.Mods.SelectMany(FlattenMod))
+ yield return m;
+ }
+ else
+ yield return mod;
+ }
+ }
+}