From 3ca0f2eebb1fc4ce228271568af178884bf476b4 Mon Sep 17 00:00:00 2001 From: tsunyoku Date: Tue, 30 Jun 2026 22:09:12 +0100 Subject: [PATCH 1/7] Remove unused `ReverseQueue` --- osu.Game.Tests/NonVisual/ReverseQueueTest.cs | 146 ------------------ .../Rulesets/Difficulty/Utils/ReverseQueue.cs | 134 ---------------- 2 files changed, 280 deletions(-) delete mode 100644 osu.Game.Tests/NonVisual/ReverseQueueTest.cs delete mode 100644 osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs diff --git a/osu.Game.Tests/NonVisual/ReverseQueueTest.cs b/osu.Game.Tests/NonVisual/ReverseQueueTest.cs deleted file mode 100644 index 7b87ba248115..000000000000 --- a/osu.Game.Tests/NonVisual/ReverseQueueTest.cs +++ /dev/null @@ -1,146 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; -using NUnit.Framework; -using NUnit.Framework.Legacy; -using osu.Game.Rulesets.Difficulty.Utils; - -namespace osu.Game.Tests.NonVisual -{ - [TestFixture] - public class ReverseQueueTest - { - private ReverseQueue queue; - - [SetUp] - public void Setup() - { - queue = new ReverseQueue(4); - } - - [Test] - public void TestEmptyQueue() - { - ClassicAssert.AreEqual(0, queue.Count); - - Assert.Throws(() => - { - char unused = queue[0]; - }); - - int count = 0; - foreach (char unused in queue) - count++; - - ClassicAssert.AreEqual(0, count); - } - - [Test] - public void TestEnqueue() - { - // Assert correct values and reverse index after enqueueing - queue.Enqueue('a'); - queue.Enqueue('b'); - queue.Enqueue('c'); - - ClassicAssert.AreEqual('c', queue[0]); - ClassicAssert.AreEqual('b', queue[1]); - ClassicAssert.AreEqual('a', queue[2]); - - // Assert correct values and reverse index after enqueueing beyond initial capacity of 4 - queue.Enqueue('d'); - queue.Enqueue('e'); - queue.Enqueue('f'); - - ClassicAssert.AreEqual('f', queue[0]); - ClassicAssert.AreEqual('e', queue[1]); - ClassicAssert.AreEqual('d', queue[2]); - ClassicAssert.AreEqual('c', queue[3]); - ClassicAssert.AreEqual('b', queue[4]); - ClassicAssert.AreEqual('a', queue[5]); - } - - [Test] - public void TestDequeue() - { - queue.Enqueue('a'); - queue.Enqueue('b'); - queue.Enqueue('c'); - queue.Enqueue('d'); - queue.Enqueue('e'); - queue.Enqueue('f'); - - // Assert correct item return and no longer in queue after dequeueing - ClassicAssert.AreEqual('a', queue[5]); - char dequeuedItem = queue.Dequeue(); - - ClassicAssert.AreEqual('a', dequeuedItem); - ClassicAssert.AreEqual(5, queue.Count); - ClassicAssert.AreEqual('f', queue[0]); - ClassicAssert.AreEqual('b', queue[4]); - Assert.Throws(() => - { - char unused = queue[5]; - }); - - // Assert correct state after enough enqueues and dequeues to wrap around array (queue.start = 0 again) - queue.Enqueue('g'); - queue.Enqueue('h'); - queue.Enqueue('i'); - queue.Dequeue(); - queue.Dequeue(); - queue.Dequeue(); - queue.Dequeue(); - queue.Dequeue(); - queue.Dequeue(); - queue.Dequeue(); - - ClassicAssert.AreEqual(1, queue.Count); - ClassicAssert.AreEqual('i', queue[0]); - } - - [Test] - public void TestClear() - { - queue.Enqueue('a'); - queue.Enqueue('b'); - queue.Enqueue('c'); - queue.Enqueue('d'); - queue.Enqueue('e'); - queue.Enqueue('f'); - - // Assert queue is empty after clearing - queue.Clear(); - - ClassicAssert.AreEqual(0, queue.Count); - Assert.Throws(() => - { - char unused = queue[0]; - }); - } - - [Test] - public void TestEnumerator() - { - queue.Enqueue('a'); - queue.Enqueue('b'); - queue.Enqueue('c'); - queue.Enqueue('d'); - queue.Enqueue('e'); - queue.Enqueue('f'); - - char[] expectedValues = { 'f', 'e', 'd', 'c', 'b', 'a' }; - int expectedValueIndex = 0; - - // Assert items are enumerated in correct order - foreach (char item in queue) - { - ClassicAssert.AreEqual(expectedValues[expectedValueIndex], item); - expectedValueIndex++; - } - } - } -} diff --git a/osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs b/osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs deleted file mode 100644 index 17c3c51cfba7..000000000000 --- a/osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; -using System.Collections; -using System.Collections.Generic; - -namespace osu.Game.Rulesets.Difficulty.Utils -{ - /// - /// An indexed queue where items are indexed beginning from the most recently enqueued item. - /// Enqueuing an item pushes all existing indexes up by one and inserts the item at index 0. - /// Dequeuing an item removes the item from the highest index and returns it. - /// - public class ReverseQueue : IEnumerable - { - /// - /// The number of elements in the . - /// - public int Count { get; private set; } - - private T[] items; - private int capacity; - private int start; - - public ReverseQueue(int initialCapacity) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(initialCapacity); - - items = new T[initialCapacity]; - capacity = initialCapacity; - start = 0; - Count = 0; - } - - /// - /// Retrieves the item at an index in the . - /// - /// The index of the item to retrieve. The most recently enqueued item is at index 0. - public T this[int index] - { - get - { - if (index < 0 || index > Count - 1) - throw new ArgumentOutOfRangeException(nameof(index)); - - int reverseIndex = Count - 1 - index; - return items[(start + reverseIndex) % capacity]; - } - } - - /// - /// Enqueues an item to this . - /// - /// The item to enqueue. - public void Enqueue(T item) - { - if (Count == capacity) - { - // Double the buffer size - var buffer = new T[capacity * 2]; - - // Copy items to new queue - for (int i = 0; i < Count; i++) - { - buffer[i] = items[(start + i) % capacity]; - } - - // Replace array with new buffer - items = buffer; - capacity *= 2; - start = 0; - } - - items[(start + Count) % capacity] = item; - Count++; - } - - /// - /// Dequeues the least recently enqueued item from the and returns it. - /// - /// The item dequeued from the . - public T Dequeue() - { - var item = items[start]; - start = (start + 1) % capacity; - Count--; - return item; - } - - /// - /// Clears the of all items. - /// - public void Clear() - { - start = 0; - Count = 0; - } - - /// - /// Returns an enumerator which enumerates items in the starting from the most recently enqueued item. - /// - public IEnumerator GetEnumerator() => new Enumerator(this); - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - public struct Enumerator : IEnumerator - { - private ReverseQueue reverseQueue; - private int currentIndex; - - internal Enumerator(ReverseQueue reverseQueue) - { - this.reverseQueue = reverseQueue; - currentIndex = -1; // The first MoveNext() should bring the iterator to 0 - } - - public bool MoveNext() => ++currentIndex < reverseQueue.Count; - - public void Reset() => currentIndex = -1; - - public readonly T Current => reverseQueue[currentIndex]; - - readonly object IEnumerator.Current => Current; - - public void Dispose() - { - reverseQueue = null; - } - } - } -} From 63f56e9b06ee97b9bcd376bd86a1f693a30ec2b5 Mon Sep 17 00:00:00 2001 From: tsunyoku Date: Tue, 30 Jun 2026 22:10:27 +0100 Subject: [PATCH 2/7] Avoid double `calculateAimDifficultyRating` call --- osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 7afe5391df11..0bcfb8c158e7 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -66,11 +66,13 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat int totalHits = beatmap.HitObjects.Count; + double aimRating = calculateAimDifficultyRating(aimDifficultyValue); + double aimNoSlidersRating = calculateAimDifficultyRating(aimNoSlidersDifficultyValue); + double sliderFactor = aimDifficultyValue > 0 - ? calculateAimDifficultyRating(aimNoSlidersDifficultyValue) / calculateAimDifficultyRating(aimDifficultyValue) + ? aimNoSlidersRating / aimRating : 1; - double aimRating = calculateAimDifficultyRating(aimDifficultyValue); double speedRating = calculateDifficultyRating(speedDifficultyValue); double readingRating = calculateDifficultyRating(readingDifficultyValue); From 412e5a1cff1f41e2f53862a9cd8596a68e1767f3 Mon Sep 17 00:00:00 2001 From: tsunyoku Date: Tue, 30 Jun 2026 22:11:40 +0100 Subject: [PATCH 3/7] Clean-up `DifficultyValue` in `HarmonicSkill` --- osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs index c6e081272efa..f514a11281ee 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs @@ -53,18 +53,13 @@ public override double DifficultyValue() if (ObjectDifficulties.Count == 0) return 0; - // Objects with 0 difficulty are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). - // These objects will not contribute to the difficulty. - var difficulties = ObjectDifficulties; - - if (difficulties.Count == 0) - return 0; - - difficulties = GetTransformedDifficulties(difficulties); + List difficulties = GetTransformedDifficulties(ObjectDifficulties); double difficulty = 0; int index = 0; + // Objects with 0 difficulty are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). + // These objects will not contribute to the difficulty. foreach (double obj in difficulties.OrderDescending().Where(v => v > 0)) { // Use a harmonic sum that considers each object of the map according to a predefined weight. From 2273d9ebc11bd3b8678fa7b5b597fae9e6a9a2bf Mon Sep 17 00:00:00 2001 From: tsunyoku Date: Tue, 30 Jun 2026 22:14:38 +0100 Subject: [PATCH 4/7] Refactor various logistic calculations to use `DiffUtils.Logistic` --- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 2 +- osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs | 3 +-- osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs | 3 ++- .../Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs | 3 ++- osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs | 4 ++-- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 11e78c00bbf1..70441c545ca2 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -151,7 +151,7 @@ public double GetDifficultSliders() if (maxSliderStrain == 0) return 0; - return sliderStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxSliderStrain * 12.0 - 6.0)))); + return sliderStrains.Sum(strain => DiffUtils.Logistic(strain / maxSliderStrain, 0.5, 12.0)); } public double CountTopWeightedSliders(double difficultyValue) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index a4432d445816..f8ab313cb76a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -1,7 +1,6 @@ // 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.Game.Rulesets.Difficulty.Preprocessing; @@ -76,7 +75,7 @@ public double RelevantObjectCount() if (maxStrain == 0) return 0; - return ObjectDifficulties.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); + return ObjectDifficulties.Sum(strain => DiffUtils.Logistic(strain / maxStrain, 0.5, 12.0)); } public double CountTopWeightedSliders(double difficultyValue) diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index e26ce15dd26c..269888ebd7ed 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Difficulty.Skills @@ -77,7 +78,7 @@ public double CountTopWeightedStrains(double difficultyValue) return ObjectDifficulties.Count; // Use a weighted sum of all strains. Constants are arbitrary and give nice values - return ObjectDifficulties.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88)))); + return ObjectDifficulties.Sum(s => DiffUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1)); } /// diff --git a/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs index c94f469a1c9e..4d18f76669f5 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs @@ -6,6 +6,7 @@ using System.Linq; using osu.Framework.Extensions; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Difficulty.Skills @@ -232,7 +233,7 @@ public virtual double CountTopWeightedStrains(double difficultyValue) return ObjectDifficulties.Count; // Use a weighted sum of all strains. Constants are arbitrary and give nice values - return ObjectDifficulties.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88)))); + return ObjectDifficulties.Sum(s => DiffUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1)); } /// diff --git a/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs index 03d712a75e74..4548e0a18f81 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs @@ -39,9 +39,9 @@ public static double MillisecondsToBPM(double ms, int delimiter = 4) /// Calculates a S-shaped logistic function (https://en.wikipedia.org/wiki/Logistic_function) /// /// Value to calculate the function for - /// Maximum value returnable by the function - /// Growth rate of the function /// How much the function midpoint is offset from zero + /// Growth rate of the function + /// Maximum value returnable by the function /// The output of logistic function of public static double Logistic(double x, double midpointOffset, double multiplier, double maxValue = 1) => maxValue / (1 + Math.Exp(multiplier * (midpointOffset - x))); From 1c25694402ad0c72536b5195e9c6c498aaab968c Mon Sep 17 00:00:00 2001 From: tsunyoku Date: Tue, 30 Jun 2026 22:16:15 +0100 Subject: [PATCH 5/7] Avoid unnecessary `Previous` calls --- .../Difficulty/Evaluators/Aim/FlowAimEvaluator.cs | 7 ++++--- .../Difficulty/Evaluators/Aim/SnapAimEvaluator.cs | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs index 3707d4061342..cea98ff010f0 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs @@ -17,13 +17,14 @@ public static class FlowAimEvaluator /// public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance) { - if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner) + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuLastObj = (OsuDifficultyHitObject)current.Previous(); + + if (current.BaseObject is Spinner || current.Index <= 1 || osuLastObj.BaseObject is Spinner) return 0; const double velocity_change_multiplier = 0.52; - var osuCurrObj = (OsuDifficultyHitObject)current; - var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1); double currDistance = withSliderTravelDistance ? osuCurrObj.LazyJumpDistance : osuCurrObj.JumpDistance; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs index 451f67b45090..a345b2aa5fb7 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs @@ -22,7 +22,10 @@ public static class SnapAimEvaluator /// public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance) { - if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner) + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuLastObj = (OsuDifficultyHitObject)current.Previous(); + + if (current.BaseObject is Spinner || current.Index <= 1 || osuLastObj.BaseObject is Spinner) return 0; const double wide_angle_multiplier = 9.67; @@ -33,8 +36,6 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation const double wiggle_multiplier = 1.02; - var osuCurrObj = (OsuDifficultyHitObject)current; - var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); var osuLast2Obj = (OsuDifficultyHitObject)current.Previous(2); const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS; From 1d8dbdd7ac169d71120579837621e65938c80c05 Mon Sep 17 00:00:00 2001 From: tsunyoku Date: Tue, 30 Jun 2026 22:16:49 +0100 Subject: [PATCH 6/7] Make `linearSpacingCount` an integer so it goes down optimised `Pow` calculations --- .../Difficulty/Evaluators/MovementEvaluator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs b/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs index 12d455478a4d..9f103b810f27 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs @@ -46,7 +46,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) } // Linear spacing nerf. - double linearSpacingCount = 0; + int linearSpacingCount = 0; for (int i = 0; i < Math.Min(current.Index, 10); i++) { From f58a117e1b44c39f676593f409d0d7168701d445 Mon Sep 17 00:00:00 2001 From: tsunyoku Date: Tue, 30 Jun 2026 22:17:28 +0100 Subject: [PATCH 7/7] Turn reduced section time into a constant --- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 70441c545ca2..9655c77582a4 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -35,7 +35,7 @@ public Aim(Mod[] mods, bool includeSliders) /// The number of sections with the highest strains, which the peak strain reductions will apply to. /// This is done in order to decrease their impact on the overall difficulty of the map for this skill. /// - private int reducedSectionTime => 4000; + private const int reduced_section_time = 4000; /// /// The baseline multiplier applied to the section with the biggest strain. @@ -225,13 +225,13 @@ private IEnumerable getReducedStrainPeaks() // We are reducing the highest strains first to account for extreme difficulty spikes // Strains are split into 20ms chunks to try to mitigate inconsistencies caused by reducing strains - while (strains.Count > skipCount && time < reducedSectionTime) + while (strains.Count > skipCount && time < reduced_section_time) { StrainPeak strain = strains[skipCount]; for (double addedTime = 0; addedTime < strain.SectionLength; addedTime += chunk_size) { - double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((time + addedTime) / reducedSectionTime, 0, 1))); + double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((time + addedTime) / reduced_section_time, 0, 1))); // intentionally add at end and sort afterwards, should be cheaper. strains.Add(new StrainPeak(