Skip to content

Pass list of active mods when calculating score multiplier#37355

Closed
tsunyoku wants to merge 1 commit into
ppy:masterfrom
tsunyoku:score-multiplier-mod-combinations
Closed

Pass list of active mods when calculating score multiplier#37355
tsunyoku wants to merge 1 commit into
ppy:masterfrom
tsunyoku:score-multiplier-mod-combinations

Conversation

@tsunyoku
Copy link
Copy Markdown
Member

Required to support some changes in the ongoing mod multipliers work.

With the existing set of multipliers there are no changes expected here, but have changed some mods locally and tested this works as expected.

To support this server-side, a game bump alongside any multiplier changes should be enough. Score reverification is the only place this matters, and it falls back onto StandardisedScoreMigrationTools so no code changes necessary.

Required to support some changes in the ongoing mod multipliers work.

With the existing set of multipliers there are no changes expected here, but have changed some mods locally and tested this works as expected.

To support this server-side, a game bump alongside any multiplier changes should be enough. Score reverification is the only place this matters, and it falls back onto `StandardisedScoreMigrationTools` so no code changes necessary.
@bdach
Copy link
Copy Markdown
Collaborator

bdach commented May 14, 2026

@tsunyoku For transparency, this PR was briefly discussed last week internally (me, @peppy, @smoogipoo) and I wasn't sold on this approach because for the changes you want wherein mod A's multiplier affects mod B it requires arbitrarily choosing whether you ascribe the different behaviour to A or B and it becomes weirdly asymmetrical. Pretty sure I wasn't alone in that concern. I was probably going to try writing a counterproposal wherein the mod multiplier calculation was centralized to one object and it would handle the combinations somehow.

This also ties into a somewhat-frequent feature request of having mod multipliers be adjustable in certain multiplayer rooms, which could be facilitated by such a centralized object if it could be replaceable depending on context.

@bdach
Copy link
Copy Markdown
Collaborator

bdach commented May 14, 2026

So I typed up a counterproposal and it's here: https://github.com/ppy/osu/compare/master...bdach:osu:mod-multiplier-refactor?expand=1

Notably that branch is VERY UNTESTED, VERY UNBENCHMARKED, AND ONLY AIMS TO BE A SIMPLE, SINGLE-RULESET, NON-VALUE-CHANGING DEMONSTRATION OF A POSSIBLE REFACTOR OF MASTER MULTIPLIERS. ALL PASSERBY GITHUB VULTURES ARE HEREFORE ASKED TO LEAVE.

Having gotten that out of the way...


I'm generally not that convinced that what I typed up is better than this PR.

Upsides:

  • Easier to check / cross-reference against Ruleset.GetAllMods().

  • No need to chase down 3 levels of inheritance to find a mod's multiplier.

  • Marginally more explicit when e.g. having mods A and B active is supposed to turn A's multiplier completely off while preserving B's, because then you do

    Register<ModA, ModB>(static (a, _) => getMultiplierForModA(a));
  • Results in better / easier reuse of logic across mods (see GetMultiplierForRateAdjustment()).

  • One big upside that this PR does nothing to help with is that e.g. if you wanted to change Difficulty Adjust's multiplier so that it depends on the map's baseline difficulty settings. This is something that is not accessible to mods reliably right now. An "unchanged" difficulty setting in DA terms is serialised as null generally speaking.

    The closest thing to accessing the original value is DifficultyBindable.ReadCurrentFromDifficulty but that still needs an instance of IBeatmapDifficultyInfo to actually work. Therefore you'd probably have to change this PR to pass in those baseline difficulty settings to ScoreMultiplier() too and I think that'd make this PR much less palatable than currently. Mine would get uglier too, but not by much.

Downsides:

  • Generics abuse.
  • Casting abuse.
  • You have to register mods in the right order (combinations first, then individual mods) or else it's going to do the wrong thing.
  • Probably slow.
  • When wanting to do more complicated stuff you still end up having to factor out methods like getMultiplierForModA() mentioned above. This matters when wanting to implement stuff like "if mods A and B are active, decrease A's multiplier by X amount".

@tsunyoku @peppy @smoogipoo would like your comments at this stage if possible.

@bdach bdach moved this from Inbox to Pending Review in osu! team task tracker May 14, 2026
@tsunyoku
Copy link
Copy Markdown
Member Author

I generally agree with the upsides and downsides mentioned.

I think the general idea is in the right direction - the fact that all multipliers are visible in a central place, especially when combinations are involved, is incredibly valuable. Like you mentioned, the alternative is several levels of inheritance and even then you might be unsure which of the mods in the combination is actually responsible for the reduction. This way of doing things completely avoids that debate.

Not to mention that it may help with other multiplier work later down the line (i.e. multiplayer lobbies).

The only 2 things I would point to being potential issues (for me) is the casting and performance. The casting I can look past, and performance I am expecting to be worse but hopefully not considerably.

@peppy peppy self-requested a review May 18, 2026 08:20
@peppy
Copy link
Copy Markdown
Member

peppy commented May 18, 2026

I definitely prefer @bdach's branch over this PR.

The following is a bit of a stream of thoughts, and I don't expect a reply to each of my points (feel free to dismiss any which make zero sense, and apply any which make sense in a future iteration of the branch).


Regarding performance concerns, is it for the registration part? Because this should fix that without causing any problems:

diff --git a/osu.Game.Rulesets.Osu/OsuModMultiplierCalculator.cs b/osu.Game.Rulesets.Osu/OsuModMultiplierCalculator.cs
index 1ad28b338f..b3bac5655f 100644
--- a/osu.Game.Rulesets.Osu/OsuModMultiplierCalculator.cs
+++ b/osu.Game.Rulesets.Osu/OsuModMultiplierCalculator.cs
@@ -13,22 +13,22 @@ public class OsuModMultiplierCalculator
     {
         private record ModCombination(Type[] ModTypes, Func<Mod[], double> CalculateMultiplier);
 
-        private readonly List<ModCombination> modCombinations = [];
+        private static readonly List<ModCombination> mod_combinations = [];
 
-        public void Register<TMod>(Func<TMod, double> multiplier)
+        public static void Register<TMod>(Func<TMod, double> multiplier)
             where TMod : Mod
         {
-            modCombinations.Add(new ModCombination([typeof(TMod)], mods => multiplier((TMod)mods[0])));
+            mod_combinations.Add(new ModCombination([typeof(TMod)], mods => multiplier((TMod)mods[0])));
         }
 
-        public void Register<T1, T2>(T1 first, T2 second, Func<T1, T2, double> multiplier)
+        public static void Register<T1, T2>(T1 first, T2 second, Func<T1, T2, double> multiplier)
             where T1 : Mod
             where T2 : Mod
         {
-            modCombinations.Add(new ModCombination([typeof(T1), typeof(T2)], mods => multiplier((T1)mods[0], (T2)mods[1])));
+            mod_combinations.Add(new ModCombination([typeof(T1), typeof(T2)], mods => multiplier((T1)mods[0], (T2)mods[1])));
         }
 
-        public OsuModMultiplierCalculator()
+        static OsuModMultiplierCalculator()
         {
             #region Difficulty Reduction
 
@@ -116,7 +116,7 @@ public double CalculateFor(IEnumerable<Mod> mods)
 
             double multiplier = 1;
 
-            foreach (var combination in modCombinations)
+            foreach (var combination in mod_combinations)
             {
                 if (remainingModTypes.IsSupersetOf(combination.ModTypes))
                 {

If it's for the IsSupersetOf and other reflection in the check method, then yeah, maybe that's of concern. Benchmarking setup should be simple to get some answers to this question. I can see ways to optimise things (and I'm sure you can too) if it doesn't meet required perf goals.

I'd probably see us having tests to ensure mods aren't missed in the registration. Especially for cases like DT/NC where – at least with the current code – you have to register each derived version.

You have to register mods in the right order (combinations first, then individual mods) or else it's going to do the wrong thing.

I think this can be easily asserted.

When wanting to do more complicated stuff you still end up having to factor out methods like getMultiplierForModA() mentioned above. This matters when wanting to implement stuff like "if mods A and B are active, decrease A's multiplier by X amount".

I am not sure I'm seeing the concern here.


I'd also want to consider whether things can be simplified by splitting out the two paths:

  • Base multiplier when mod is not considered as a compound with other mods
  • Compound cases

And what I mean by this is potentially storing them in separate arrays and adjusting a bit like this:

diff --git a/osu.Game.Rulesets.Osu/OsuModMultiplierCalculator.cs b/osu.Game.Rulesets.Osu/OsuModMultiplierCalculator.cs
index 1ad28b338f..12a299ecc5 100644
--- a/osu.Game.Rulesets.Osu/OsuModMultiplierCalculator.cs
+++ b/osu.Game.Rulesets.Osu/OsuModMultiplierCalculator.cs
@@ -11,45 +11,52 @@ namespace osu.Game.Rulesets.Osu
 {
     public class OsuModMultiplierCalculator
     {
-        private record ModCombination(Type[] ModTypes, Func<Mod[], double> CalculateMultiplier);
+        private static readonly List<(Type mod, Func<Mod, double> calculate)> single = [];
+        private static readonly List<(Type[] mods, Func<Mod[], double> calculate)> compound = [];
 
-        private readonly List<ModCombination> modCombinations = [];
+        public static void Single<TMod>(double multiplier)
+            where TMod : Mod
+        {
+            single.Add((typeof(TMod), _ => multiplier));
+        }
 
-        public void Register<TMod>(Func<TMod, double> multiplier)
+        public static void Single<TMod>(Func<TMod, double> multiplier)
             where TMod : Mod
         {
-            modCombinations.Add(new ModCombination([typeof(TMod)], mods => multiplier((TMod)mods[0])));
+            single.Add((typeof(TMod), mod => multiplier((TMod)mod)));
         }
 
-        public void Register<T1, T2>(T1 first, T2 second, Func<T1, T2, double> multiplier)
+        public static void Compound<T1, T2>(Func<T1, T2, double> multiplier)
             where T1 : Mod
             where T2 : Mod
         {
-            modCombinations.Add(new ModCombination([typeof(T1), typeof(T2)], mods => multiplier((T1)mods[0], (T2)mods[1])));
+            compound.Add(([typeof(T1), typeof(T2)], mods => multiplier((T1)mods[0], (T2)mods[1])));
         }
 
-        public OsuModMultiplierCalculator()
+        static OsuModMultiplierCalculator()
         {
+            Compound<OsuModEasy, OsuModNoFail>((e, nf) => 0.1);
+
             #region Difficulty Reduction
 
-            Register<OsuModEasy>(static _ => 0.5);
-            Register<OsuModNoFail>(static _ => 0.5);
-            Register<OsuModHalfTime>(static ht => GetMultiplierForRateAdjustment(ht.SpeedChange.Value));
-            Register<OsuModDaycore>(static dc => GetMultiplierForRateAdjustment(dc.SpeedChange.Value));
+            Single<OsuModEasy>(0.5);
+            Single<OsuModNoFail>(0.5);
+            Single<OsuModHalfTime>(static ht => GetMultiplierForRateAdjustment(ht.SpeedChange.Value));
+            Single<OsuModDaycore>(static dc => GetMultiplierForRateAdjustment(dc.SpeedChange.Value));
 
             #endregion
 
             #region Difficulty Increase
 
-            Register<OsuModHardRock>(static hr => hr.UsesDefaultConfiguration ? 1.06 : 1);
+            Single<OsuModHardRock>(static hr => hr.UsesDefaultConfiguration ? 1.06 : 1);
             // Sudden Death
             // Perfect
-            Register<OsuModDoubleTime>(static dt => GetMultiplierForRateAdjustment(dt.SpeedChange.Value));
-            Register<OsuModNightcore>(static nc => GetMultiplierForRateAdjustment(nc.SpeedChange.Value));
-            Register<OsuModHidden>(static hd => hd.UsesDefaultConfiguration ? 1.06 : 1);
+            Single<OsuModDoubleTime>(static dt => GetMultiplierForRateAdjustment(dt.SpeedChange.Value));
+            Single<OsuModNightcore>(static nc => GetMultiplierForRateAdjustment(nc.SpeedChange.Value));
+            Single<OsuModHidden>(static hd => hd.UsesDefaultConfiguration ? 1.06 : 1);
             // Traceable
-            Register<OsuModFlashlight>(static fl => fl.UsesDefaultConfiguration ? 1.12 : 1);
-            Register<OsuModBlinds>(static bl => bl.UsesDefaultConfiguration ? 1.12 : 1);
+            Single<OsuModFlashlight>(static fl => fl.UsesDefaultConfiguration ? 1.12 : 1);
+            Single<OsuModBlinds>(static bl => bl.UsesDefaultConfiguration ? 1.12 : 1);
             // Strict Tracking
             // Accuracy Challenge
 
@@ -57,9 +64,9 @@ public OsuModMultiplierCalculator()
 
             #region Conversion
 
-            Register<OsuModTargetPractice>(static _ => 0.1);
-            Register<OsuModDifficultyAdjust>(static _ => 0.5);
-            Register<OsuModClassic>(static _ => 0.96);
+            Single<OsuModTargetPractice>(0.1);
+            Single<OsuModDifficultyAdjust>(0.5);
+            Single<OsuModClassic>(0.96);
             // Random
             // Mirror
             // Alternate
@@ -71,9 +78,9 @@ public OsuModMultiplierCalculator()
 
             // Autoplay
             // Cinema
-            Register<OsuModRelax>(static _ => 0.1);
-            Register<OsuModAutopilot>(static _ => 0.1);
-            Register<OsuModSpunOut>(static _ => 0.9);
+            Single<OsuModRelax>(0.1);
+            Single<OsuModAutopilot>(0.1);
+            Single<OsuModSpunOut>(0.9);
 
             #endregion
 
@@ -84,18 +91,18 @@ public OsuModMultiplierCalculator()
             // Spin In
             // Grow
             // Deflate
-            Register<ModWindUp>(static _ => 0.5);
-            Register<ModWindDown>(static _ => 0.5);
+            Single<ModWindUp>(0.5);
+            Single<ModWindDown>(0.5);
             // Barrel Roll
             // Approach Different
             // Muted
             // No Scope
-            Register<OsuModMagnetised>(static _ => 0.5);
+            Single<OsuModMagnetised>(0.5);
             // Repel
-            Register<ModAdaptiveSpeed>(static _ => 0.5);
+            Single<ModAdaptiveSpeed>(0.5);
             // Freeze Frame
             // Bubbles
-            Register<OsuModSynesthesia>(static _ => 0.8);
+            Single<OsuModSynesthesia>(0.8);
             // Depth
             // Bloom
 
@@ -116,13 +123,25 @@ public double CalculateFor(IEnumerable<Mod> mods)
 
             double multiplier = 1;
 
-            foreach (var combination in modCombinations)
+            foreach (var combination in compound)
+            {
+                if (remainingModTypes.IsSupersetOf(combination.mods))
+                {
+                    var instances = combination.mods.Select(t => allModsByType[t]).ToArray();
+                    multiplier *= combination.calculate(instances);
+                    remainingModTypes.ExceptWith(combination.mods);
+                }
+            }
+
+            // doing as a second pass guarantees ordering
+            foreach (var mod in remainingModTypes)
             {
-                if (remainingModTypes.IsSupersetOf(combination.ModTypes))
+                var found = single.FirstOrDefault(m => m.mod == mod);
+
+                if (found != default)
                 {
-                    var instances = combination.ModTypes.Select(t => allModsByType[t]).ToArray();
-                    multiplier *= combination.CalculateMultiplier(instances);
-                    remainingModTypes.ExceptWith(combination.ModTypes);
+                    multiplier *= found.calculate(allModsByType[mod]);
+                    remainingModTypes.Remove(found.mod);
                 }
             }
 

Note that this is half-assed but hopefully gives some food for thought.


I'd also consider using mod names rather than types for this whole thing. It may make things more performant and easier to manage (can always work with Mod instances rather than types). Register calls would need to be provided a basic instance to get the name from but this may work okay.

May somehow allow the case to be removed, but not sure.

@tsunyoku
Copy link
Copy Markdown
Member Author

Agree with the above. @bdach if you're happy, I can close this PR and let you work on your branch separately.

@bdach
Copy link
Copy Markdown
Collaborator

bdach commented May 19, 2026

Regarding performance concerns, is it for the registration part?

Don't know yet. static is an option, sure, though I wasn't sure I wanted to go there.

I'd probably see us having tests to ensure mods aren't missed in the registration. Especially for cases like DT/NC where – at least with the current code – you have to register each derived version.

I'm not sure how these tests would look. To my awareness there is no higher-level way to assert that those two mods should always have the same multiplier than to just test them manually. Which can be done but it's a chicken-and-egg problem: the test will fail to catch errors if it isn't remembered that the test needs to be updated for some specific new future scenario.

I'd also want to consider whether things can be simplified by splitting out the two paths:

  • Base multiplier when mod is not considered as a compound with other mods
  • Compound cases

And what I mean by this is potentially storing them in separate arrays and adjusting a bit like this

Sure.

I'd also consider using mod names rather than types for this whole thing. It may make things more performant and easier to manage (can always work with Mod instances rather than types). Register calls would need to be provided a basic instance to get the name from but this may work okay.

Note that the dictionaries in the discussed variant cannot be easily keyed by mod instances because Mod equality members take mod settings into account. Would require custom comparer specifications at least.


I will work on finalizing the proof of concept of the variant I proposed today.

@tsunyoku tsunyoku closed this May 19, 2026
@github-project-automation github-project-automation Bot moved this from Pending Review to Done in osu! team task tracker May 19, 2026
peppy pushed a commit that referenced this pull request May 19, 2026
- Part of #37818
- Continued from
#37355 (comment)

## Overview

This PR introduces an alternative API, `ScoreMultiplierCalculator`, to
be used going forward for calculating mod multipliers.

The reason for introducing this new API is that it has been requested
that:
- For any two given mods, it should be possible to have the combined mod
multipliers of them in combination be *different* than the product of
the individual mods' multipliers in isolation, i.e. $mult( \\{ A, B \\}
) \neq mult( \\{ A \\} ) \cdot mult( \\{ B \\} )$.
- For an individual mod, it should be possible to have the mod
multipliers depend on a quantity that is *not* the presence of another
mod or the direct value of a setting on the mod.

This capability is being demonstrated in this PR via the
`osu.Game.Tests.Rulesets.Scoring.ScoreMultiplierCalculatorTest` test
fixture.

## Parity with `Mod.ScoreMultiplier`

This PR contains a `ScoreMultiplierCalculator` implementation for each
of the built-in four rulesets.

The abstract `osu.Game.Tests.Rulesets.RulesetScoreMultiplierTest` and
its four derived ruleset-specific test fixtures were written to ensure
that the new implementations do not diverge from the current state of
affairs.

`Mod.ScoreMultiplier` is not removed in this diff to keep size low. It
will be removed as a follow-up.

## Performance

This PR contains a benchmark comparing the current implementation via
`Mod.ScoreMultiplier` and the new `ScoreMultiplierCalculator` API.
Results below.

<details>

| Method | Times | Mods | Mean | Error | StdDev | Gen0 | Allocated |
|---------------------- |------ |---------------------
|--------------:|------------:|------------:|--------:|----------:|
| ViaModScoreMultiplier | 1 | mods (...)tings [27] | 121.171 ns | 1.5284
ns | 1.4297 ns | 0.0782 | 656 B |
| ViaCalculator | 1 | mods (...)tings [27] | 248.509 ns | 1.9313 ns |
1.6127 ns | 0.1364 | 1144 B |
| ViaModScoreMultiplier | 1 | multiple mods | 128.357 ns | 0.4282 ns |
0.4006 ns | 0.0782 | 656 B |
| ViaCalculator | 1 | multiple mods | 252.953 ns | 1.2860 ns | 1.2029 ns
| 0.1364 | 1144 B |
| ViaModScoreMultiplier | 1 | no mods | 3.007 ns | 0.0345 ns | 0.0288 ns
| - | - |
| ViaCalculator | 1 | no mods | 14.802 ns | 0.0616 ns | 0.0576 ns |
0.0134 | 112 B |
| ViaModScoreMultiplier | 1 | single mod | 40.271 ns | 0.1238 ns |
0.1098 ns | 0.0258 | 216 B |
| ViaCalculator | 1 | single mod | 113.033 ns | 0.3140 ns | 0.2937 ns |
0.0842 | 704 B |
| ViaModScoreMultiplier | 1 | single mod 2 | 3.653 ns | 0.0384 ns |
0.0359 ns | 0.0038 | 32 B |
| ViaCalculator | 1 | single mod 2 | 78.172 ns | 0.0680 ns | 0.0603 ns |
0.0621 | 520 B |
| ViaModScoreMultiplier | 10 | mods (...)tings [27] | 1,169.609 ns |
4.3058 ns | 4.0276 ns | 0.7839 | 6560 B |
| ViaCalculator | 10 | mods (...)tings [27] | 2,575.264 ns | 21.2705 ns
| 19.8964 ns | 1.3657 | 11440 B |
| ViaModScoreMultiplier | 10 | multiple mods | 1,171.775 ns | 6.2332 ns
| 5.2050 ns | 0.7839 | 6560 B |
| ViaCalculator | 10 | multiple mods | 2,579.593 ns | 22.1010 ns |
20.6733 ns | 1.3657 | 11440 B |
| ViaModScoreMultiplier | 10 | no mods | 35.943 ns | 0.1665 ns | 0.1476
ns | - | - |
| ViaCalculator | 10 | no mods | 154.980 ns | 0.2381 ns | 0.1988 ns |
0.1338 | 1120 B |
| ViaModScoreMultiplier | 10 | single mod | 404.185 ns | 1.3190 ns |
1.2338 ns | 0.2580 | 2160 B |
| ViaCalculator | 10 | single mod | 1,167.279 ns | 6.1641 ns | 5.7659 ns
| 0.8411 | 7040 B |
| ViaModScoreMultiplier | 10 | single mod 2 | 42.128 ns | 0.2878 ns |
0.2692 ns | 0.0382 | 320 B |
| ViaCalculator | 10 | single mod 2 | 775.435 ns | 2.3318 ns | 2.1811 ns
| 0.6208 | 5200 B |
| ViaModScoreMultiplier | 100 | mods (...)tings [27] | 11,623.346 ns |
51.7174 ns | 43.1863 ns | 7.8430 | 65600 B |
| ViaCalculator | 100 | mods (...)tings [27] | 25,252.987 ns | 44.4352
ns | 39.3906 ns | 13.6719 | 114400 B |
| ViaModScoreMultiplier | 100 | multiple mods | 11,928.536 ns | 35.2079
ns | 32.9334 ns | 7.8430 | 65600 B |
| ViaCalculator | 100 | multiple mods | 25,399.378 ns | 152.4597 ns |
127.3108 ns | 13.6719 | 114400 B |
| ViaModScoreMultiplier | 100 | no mods | 328.158 ns | 0.5827 ns |
0.5165 ns | - | - |
| ViaCalculator | 100 | no mods | 1,517.485 ns | 10.2304 ns | 9.5695 ns
| 1.3390 | 11200 B |
| ViaModScoreMultiplier | 100 | single mod | 3,986.251 ns | 24.2523 ns |
21.4991 ns | 2.5787 | 21600 B |
| ViaCalculator | 100 | single mod | 11,479.514 ns | 23.3738 ns |
20.7203 ns | 8.4076 | 70400 B |
| ViaModScoreMultiplier | 100 | single mod 2 | 385.679 ns | 3.5190 ns |
3.2917 ns | 0.3824 | 3200 B |
| ViaCalculator | 100 | single mod 2 | 7,658.646 ns | 21.8274 ns |
19.3494 ns | 6.2103 | 52000 B |

</details>

While the calculator is obviously slower, in my view it is not
egregiously so. The main overheads both time- and memory-wise are
collection allocations for the dictionary and the set which I consider
to be directly caused by the requested additional complexity and as such
I don't really consider them eliminable.

I have tried and applied some micro-optimisations
(e2469ce,
cb33abe), albeit with negligible
effect. I have also tried to key the mods by `Acronym` instead of by
`Type` and the difference was basically nil.

<details>
<summary>patch for keying by acronym</summary>

```diff
diff --git a/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs b/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs
index 772f9d1..7f5907cbda 100644
--- a/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs
+++ b/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs
@@ -13,26 +13,26 @@ namespace osu.Game.Rulesets.Scoring
     /// </summary>
     public class ScoreMultiplierCalculator
     {
-        private static readonly List<(Type[] mods, Func<Mod[], double> multiplier)> combination_multipliers = [];
-        private static readonly Dictionary<Type, Func<Mod, ScoreMultiplierCalculator, double>> single_multipliers_with_context = [];
-        private static readonly Dictionary<Type, Func<Mod, double>> single_multipliers = [];
+        private static readonly List<(string[] modAcronyms, Func<Mod[], double> multiplier)> combination_multipliers = [];
+        private static readonly Dictionary<string, Func<Mod, ScoreMultiplierCalculator, double>> single_multipliers_with_context = [];
+        private static readonly Dictionary<string, Func<Mod, double>> single_multipliers = [];
 
         /// <summary>
         /// Defines a flat, setting-independent score multiplier for the given <typeparamref name="TMod"/>.
         /// </summary>
         public static void Single<TMod>(double hasMultiplier)
-            where TMod : Mod
+            where TMod : Mod, new()
         {
-            single_multipliers[typeof(TMod)] = _ => hasMultiplier;
+            single_multipliers[new TMod().Acronym] = _ => hasMultiplier;
         }
 
         /// <summary>
         /// Defines a setting-dependent score multiplier for the given <typeparamref name="TMod"/>.
         /// </summary>
         public static void Single<TMod>(Func<TMod, double> hasMultiplier)
-            where TMod : Mod
+            where TMod : Mod, new()
         {
-            single_multipliers[typeof(TMod)] = mod => hasMultiplier.Invoke((TMod)mod);
+            single_multipliers[new TMod().Acronym] = mod => hasMultiplier.Invoke((TMod)mod);
         }
 
         /// <summary>
@@ -40,20 +40,20 @@ public static void Single<TMod>(Func<TMod, double> hasMultiplier)
         /// The multiplier calculation is given additional context to calculate the multiplier via the <typeparamref name="TContext"/> type instance.
         /// </summary>
         public static void Single<TMod, TContext>(Func<TMod, TContext, double> hasMultiplier)
-            where TMod : Mod
+            where TMod : Mod, new()
             where TContext : ScoreMultiplierCalculator
         {
-            single_multipliers_with_context[typeof(TMod)] = (mod, context) => hasMultiplier.Invoke((TMod)mod, (TContext)context);
+            single_multipliers_with_context[new TMod().Acronym] = (mod, context) => hasMultiplier.Invoke((TMod)mod, (TContext)context);
         }
 
         /// <summary>
         /// Defines a score multiplier specific to when both <typeparamref name="T1"/> and <typeparamref name="T2"/> mods are present.
         /// </summary>
         public static void Combination<T1, T2>(Func<T1, T2, double> hasMultiplier)
-            where T1 : Mod
-            where T2 : Mod
+            where T1 : Mod, new()
+            where T2 : Mod, new()
         {
-            combination_multipliers.Add(([typeof(T1), typeof(T2)], mods => hasMultiplier((T1)mods[0], (T2)mods[1])));
+            combination_multipliers.Add(([new T1().Acronym, new T2().Acronym], mods => hasMultiplier((T1)mods[0], (T2)mods[1])));
         }
 
         /// <summary>
@@ -61,7 +61,7 @@ public static void Combination<T1, T2>(Func<T1, T2, double> hasMultiplier)
         /// </summary>
         public double CalculateFor(IEnumerable<Mod> mods)
         {
-            var allModsByType = mods.ToDictionary(m => m.GetType());
+            var allModsByType = mods.ToDictionary(m => m.Acronym);
 
             if (allModsByType.Count == 0)
                 return 1;
@@ -83,7 +83,7 @@ public double CalculateFor(IEnumerable<Mod> mods)
                 }
             }
 
-            foreach (var modType in remainingModTypes)
+            foreach (string modType in remainingModTypes)
             {
                 if (single_multipliers.TryGetValue(modType, out var multiplier))
                     result *= multiplier(allModsByType[modType]);

```

</details>

One particular parallel thread that may warrant follow-up is that
`Mod.UsesDefaultConfiguration` is disproportionately expensive due to
calling into regexes via Humanizer internals.

<img width="1517" height="517" alt="Screenshot_2026-05-19_at_10 58 30"
src="https://github.com/user-attachments/assets/68309a8c-74e7-4f96-8ef9-62868eeca337"
/>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants