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..fdb441343a
--- /dev/null
+++ b/osu.Game.Tests/Mods/ModUtilsTest.cs
@@ -0,0 +1,64 @@
+// 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[] { mod1.Object, mod2.Object }), Is.False);
+ Assert.That(ModUtils.CheckCompatibleSet(new[] { mod2.Object, mod1.Object }), Is.False);
+ }
+
+ [Test]
+ public void TestIncompatibleThroughMultiMod()
+ {
+ 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[] { multiMod, mod1.Object }), Is.False);
+ Assert.That(ModUtils.CheckCompatibleSet(new[] { mod1.Object, multiMod }), 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);
+ }
+ }
+}
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..808dba2900
--- /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.Linq;
+using osu.Framework.Extensions.TypeExtensions;
+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) && 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)
+ {
+ var incompatibleTypes = new HashSet();
+ var incomingTypes = new HashSet();
+
+ foreach (var mod in combination.SelectMany(FlattenMod))
+ {
+ // Add the new mod incompatibilities, checking whether any match the existing mod types.
+ foreach (var t in mod.IncompatibleMods)
+ {
+ if (incomingTypes.Contains(t))
+ return false;
+
+ incompatibleTypes.Add(t);
+ }
+
+ // Add the new mod types, checking whether any match the incompatible types.
+ foreach (var t in mod.GetType().EnumerateBaseTypes())
+ {
+ if (incomingTypes.Contains(t))
+ return false;
+
+ incomingTypes.Add(t);
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// 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;
+ }
+ }
+}