From 37873484485dc59e3e1f1506a013ee57db849f4d Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 13 Oct 2023 16:29:02 +0900
Subject: [PATCH 01/25] Change `Perfect` judgement to not give extra score

---
 osu.Game/Rulesets/Judgements/Judgement.cs | 5 ++---
 osu.Game/Rulesets/Scoring/HitResult.cs    | 7 +++++++
 2 files changed, 9 insertions(+), 3 deletions(-)

diff --git a/osu.Game/Rulesets/Judgements/Judgement.cs b/osu.Game/Rulesets/Judgements/Judgement.cs
index f60b3a6c02..cd1e81046d 100644
--- a/osu.Game/Rulesets/Judgements/Judgement.cs
+++ b/osu.Game/Rulesets/Judgements/Judgement.cs
@@ -190,10 +190,9 @@ namespace osu.Game.Rulesets.Judgements
                     return 200;
 
                 case HitResult.Great:
-                    return 300;
-
+                // Perfect doesn't actually give more score / accuracy directly.
                 case HitResult.Perfect:
-                    return 315;
+                    return 300;
 
                 case HitResult.SmallBonus:
                     return SMALL_BONUS_SCORE;
diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs
index ccd1f49de4..fed338b012 100644
--- a/osu.Game/Rulesets/Scoring/HitResult.cs
+++ b/osu.Game/Rulesets/Scoring/HitResult.cs
@@ -55,6 +55,13 @@ namespace osu.Game.Rulesets.Scoring
         [Order(1)]
         Great,
 
+        /// <summary>
+        /// This is an optional timing window tighter than <see cref="Great"/>.
+        /// </summary>
+        /// <remarks>
+        /// By default, this does not give any bonus accuracy or score.
+        /// To have it affect scoring, consider adding a nested bonus object.
+        /// </remarks>
         [Description(@"Perfect")]
         [EnumMember(Value = "perfect")]
         [Order(0)]

From 94b64044e01681d3ce3da221255b26822b52c8e0 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 13 Oct 2023 17:01:48 +0900
Subject: [PATCH 02/25] Add nested bonus judgement to `DrawableNote` for
 perfect bonus score

---
 .../Objects/Drawables/DrawableNote.cs         | 39 +++++++++++++++++++
 .../Drawables/DrawablePerfectBonusNote.cs     | 25 ++++++++++++
 osu.Game.Rulesets.Mania/Objects/Note.cs       |  8 ++++
 .../Objects/PerfectBonusNote.cs               | 19 +++++++++
 osu.Game.Rulesets.Mania/UI/Column.cs          |  1 +
 5 files changed, 92 insertions(+)
 create mode 100644 osu.Game.Rulesets.Mania/Objects/Drawables/DrawablePerfectBonusNote.cs
 create mode 100644 osu.Game.Rulesets.Mania/Objects/PerfectBonusNote.cs

diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
index 0819e8401c..dbc9446585 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
@@ -4,6 +4,7 @@
 #nullable disable
 
 using System.Diagnostics;
+using JetBrains.Annotations;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
@@ -13,6 +14,8 @@ using osu.Game.Beatmaps;
 using osu.Game.Graphics;
 using osu.Game.Rulesets.Mania.Configuration;
 using osu.Game.Rulesets.Mania.Skinning.Default;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Drawables;
 using osu.Game.Rulesets.Scoring;
 using osu.Game.Rulesets.UI.Scrolling;
 using osu.Game.Screens.Edit;
@@ -38,6 +41,9 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
 
         private Drawable headPiece;
 
+        [CanBeNull]
+        private DrawablePerfectBonusNote bonusNote;
+
         public DrawableNote()
             : this(null)
         {
@@ -89,7 +95,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
             if (!userTriggered)
             {
                 if (!HitObject.HitWindows.CanBeHit(timeOffset))
+                {
+                    bonusNote!.TriggerResult(false);
                     ApplyResult(r => r.Type = r.Judgement.MinResult);
+                }
+
                 return;
             }
 
@@ -97,6 +107,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
             if (result == HitResult.None)
                 return;
 
+            bonusNote!.TriggerResult(result == HitResult.Perfect);
             ApplyResult(r => r.Type = result);
         }
 
@@ -115,6 +126,34 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
         {
         }
 
+        protected override void AddNestedHitObject(DrawableHitObject hitObject)
+        {
+            switch (hitObject)
+            {
+                case DrawablePerfectBonusNote bonus:
+                    AddInternal(bonusNote = bonus);
+                    break;
+            }
+        }
+
+        protected override void ClearNestedHitObjects()
+        {
+            if (bonusNote != null)
+                RemoveInternal(bonusNote, false);
+            bonusNote = null;
+        }
+
+        protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject)
+        {
+            switch (hitObject)
+            {
+                case PerfectBonusNote bonus:
+                    return new DrawablePerfectBonusNote(bonus);
+            }
+
+            return base.CreateNestedHitObject(hitObject);
+        }
+
         private void updateSnapColour()
         {
             if (beatmap == null || HitObject == null) return;
diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawablePerfectBonusNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawablePerfectBonusNote.cs
new file mode 100644
index 0000000000..9f51f9c5b3
--- /dev/null
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawablePerfectBonusNote.cs
@@ -0,0 +1,25 @@
+// 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.
+
+namespace osu.Game.Rulesets.Mania.Objects.Drawables
+{
+    public partial class DrawablePerfectBonusNote : DrawableManiaHitObject<PerfectBonusNote>
+    {
+        public DrawablePerfectBonusNote()
+            : this(null!)
+        {
+            AlwaysPresent = true;
+        }
+
+        public DrawablePerfectBonusNote(PerfectBonusNote hitObject)
+            : base(hitObject)
+        {
+        }
+
+        /// <summary>
+        /// Apply a judgement result.
+        /// </summary>
+        /// <param name="hit">Whether this tick was reached.</param>
+        internal void TriggerResult(bool hit) => ApplyResult(r => r.Type = hit ? r.Judgement.MaxResult : r.Judgement.MinResult);
+    }
+}
diff --git a/osu.Game.Rulesets.Mania/Objects/Note.cs b/osu.Game.Rulesets.Mania/Objects/Note.cs
index 0035960c63..955d3e9c7d 100644
--- a/osu.Game.Rulesets.Mania/Objects/Note.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Note.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 System.Threading;
 using osu.Game.Rulesets.Judgements;
 using osu.Game.Rulesets.Mania.Judgements;
 
@@ -12,5 +13,12 @@ namespace osu.Game.Rulesets.Mania.Objects
     public class Note : ManiaHitObject
     {
         public override Judgement CreateJudgement() => new ManiaJudgement();
+
+        protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
+        {
+            base.CreateNestedHitObjects(cancellationToken);
+
+            AddNested(new PerfectBonusNote { StartTime = StartTime });
+        }
     }
 }
diff --git a/osu.Game.Rulesets.Mania/Objects/PerfectBonusNote.cs b/osu.Game.Rulesets.Mania/Objects/PerfectBonusNote.cs
new file mode 100644
index 0000000000..b601a0614b
--- /dev/null
+++ b/osu.Game.Rulesets.Mania/Objects/PerfectBonusNote.cs
@@ -0,0 +1,19 @@
+// 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.Game.Rulesets.Judgements;
+using osu.Game.Rulesets.Mania.Judgements;
+using osu.Game.Rulesets.Scoring;
+
+namespace osu.Game.Rulesets.Mania.Objects
+{
+    public class PerfectBonusNote : ManiaHitObject
+    {
+        public override Judgement CreateJudgement() => new PerfectBonusNoteJudgement();
+
+        public class PerfectBonusNoteJudgement : ManiaJudgement
+        {
+            public override HitResult MaxResult => HitResult.SmallBonus;
+        }
+    }
+}
diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs
index 6cd55bb099..68d1e929be 100644
--- a/osu.Game.Rulesets.Mania/UI/Column.cs
+++ b/osu.Game.Rulesets.Mania/UI/Column.cs
@@ -109,6 +109,7 @@ namespace osu.Game.Rulesets.Mania.UI
             TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy());
 
             RegisterPool<Note, DrawableNote>(10, 50);
+            RegisterPool<PerfectBonusNote, DrawablePerfectBonusNote>(10, 50);
             RegisterPool<HoldNote, DrawableHoldNote>(10, 50);
             RegisterPool<HeadNote, DrawableHoldNoteHead>(10, 50);
             RegisterPool<TailNote, DrawableHoldNoteTail>(10, 50);

From 125f28219dd32b8039f27a721bd2926eb3bfa48a Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 13 Oct 2023 17:36:19 +0900
Subject: [PATCH 03/25] Fix hodl tail notes not correctly handling nested bonus
 judgement

---
 .../Objects/Drawables/DrawableHoldNoteTail.cs | 35 +++++--------------
 .../Objects/Drawables/DrawableNote.cs         |  4 ++-
 2 files changed, 12 insertions(+), 27 deletions(-)

diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs
index a559e91f1b..a183231310 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs
@@ -3,7 +3,6 @@
 
 #nullable disable
 
-using System.Diagnostics;
 using osu.Framework.Graphics;
 using osu.Framework.Input.Events;
 using osu.Game.Rulesets.Scoring;
@@ -33,35 +32,19 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
 
         public void UpdateResult() => base.UpdateResult(true);
 
-        protected override void CheckForResult(bool userTriggered, double timeOffset)
-        {
-            Debug.Assert(HitObject.HitWindows != null);
-
+        protected override void CheckForResult(bool userTriggered, double timeOffset) =>
             // Factor in the release lenience
-            timeOffset /= TailNote.RELEASE_WINDOW_LENIENCE;
+            base.CheckForResult(userTriggered, timeOffset / TailNote.RELEASE_WINDOW_LENIENCE);
 
-            if (!userTriggered)
-            {
-                if (!HitObject.HitWindows.CanBeHit(timeOffset))
-                    ApplyResult(r => r.Type = r.Judgement.MinResult);
+        protected override HitResult MutateResultApplication(HitResult result)
+        {
+            // If the head wasn't hit or the hold note was broken, cap the max score to Meh.
+            bool hasComboBreak = !HoldNote.Head.IsHit || HoldNote.Body.HasHoldBreak;
 
-                return;
-            }
+            if (result > HitResult.Meh && hasComboBreak)
+                return HitResult.Meh;
 
-            var result = HitObject.HitWindows.ResultFor(timeOffset);
-            if (result == HitResult.None)
-                return;
-
-            ApplyResult(r =>
-            {
-                // If the head wasn't hit or the hold note was broken, cap the max score to Meh.
-                bool hasComboBreak = !HoldNote.Head.IsHit || HoldNote.Body.HasHoldBreak;
-
-                if (result > HitResult.Meh && hasComboBreak)
-                    result = HitResult.Meh;
-
-                r.Type = result;
-            });
+            return result;
         }
 
         public override bool OnPressed(KeyBindingPressEvent<ManiaAction> e) => false; // Handled by the hold note
diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
index dbc9446585..eaeac33a11 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
@@ -108,9 +108,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
                 return;
 
             bonusNote!.TriggerResult(result == HitResult.Perfect);
-            ApplyResult(r => r.Type = result);
+            ApplyResult(r => r.Type = MutateResultApplication(result));
         }
 
+        protected virtual HitResult MutateResultApplication(HitResult result) => result;
+
         public virtual bool OnPressed(KeyBindingPressEvent<ManiaAction> e)
         {
             if (e.Action != Action.Value)

From 850950ba610018e87980bb2e2dfa80c371b16578 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 13 Oct 2023 17:36:30 +0900
Subject: [PATCH 04/25] Add note about why `SmallBonus` is not listed in
 `GetValidHitResults`

---
 osu.Game.Rulesets.Mania/ManiaRuleset.cs | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
index ff34b69d19..0055e10ada 100644
--- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs
+++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
@@ -385,6 +385,9 @@ namespace osu.Game.Rulesets.Mania
                 HitResult.Good,
                 HitResult.Ok,
                 HitResult.Meh,
+
+                // HitResult.SmallBonus is used for awarding perfect bonus score but is not included here as
+                // it would be a bit redundant to show this to the user.
             };
         }
 

From ddbda69751c8bb0c4674e35ee6cee86cdbdbb2aa Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 16 Oct 2023 13:14:58 +0900
Subject: [PATCH 05/25] Remove nullability of `bonusNote`

---
 .../Objects/Drawables/DrawableNote.cs                  | 10 +++-------
 1 file changed, 3 insertions(+), 7 deletions(-)

diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
index eaeac33a11..99bd66a147 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
@@ -4,7 +4,6 @@
 #nullable disable
 
 using System.Diagnostics;
-using JetBrains.Annotations;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
@@ -41,7 +40,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
 
         private Drawable headPiece;
 
-        [CanBeNull]
         private DrawablePerfectBonusNote bonusNote;
 
         public DrawableNote()
@@ -96,7 +94,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
             {
                 if (!HitObject.HitWindows.CanBeHit(timeOffset))
                 {
-                    bonusNote!.TriggerResult(false);
+                    bonusNote.TriggerResult(false);
                     ApplyResult(r => r.Type = r.Judgement.MinResult);
                 }
 
@@ -107,7 +105,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
             if (result == HitResult.None)
                 return;
 
-            bonusNote!.TriggerResult(result == HitResult.Perfect);
+            bonusNote.TriggerResult(result == HitResult.Perfect);
             ApplyResult(r => r.Type = MutateResultApplication(result));
         }
 
@@ -140,9 +138,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
 
         protected override void ClearNestedHitObjects()
         {
-            if (bonusNote != null)
-                RemoveInternal(bonusNote, false);
-            bonusNote = null;
+            RemoveInternal(bonusNote, false);
         }
 
         protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject)

From e85c0397223c162720004bcb66c249cfbaf0b1d1 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 16 Oct 2023 13:31:14 +0900
Subject: [PATCH 06/25] Adjust method flow to hopefully be more legible

---
 .../Objects/Drawables/DrawableHoldNoteTail.cs            | 2 +-
 .../Objects/Drawables/DrawableNote.cs                    | 9 +++++++--
 2 files changed, 8 insertions(+), 3 deletions(-)

diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs
index a183231310..79002b3819 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs
@@ -36,7 +36,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
             // Factor in the release lenience
             base.CheckForResult(userTriggered, timeOffset / TailNote.RELEASE_WINDOW_LENIENCE);
 
-        protected override HitResult MutateResultApplication(HitResult result)
+        protected override HitResult GetCappedResult(HitResult result)
         {
             // If the head wasn't hit or the hold note was broken, cap the max score to Meh.
             bool hasComboBreak = !HoldNote.Head.IsHit || HoldNote.Body.HasHoldBreak;
diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
index 99bd66a147..c7eabaf616 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
@@ -105,11 +105,16 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
             if (result == HitResult.None)
                 return;
 
+            result = GetCappedResult(result);
+
             bonusNote.TriggerResult(result == HitResult.Perfect);
-            ApplyResult(r => r.Type = MutateResultApplication(result));
+            ApplyResult(r => r.Type = result);
         }
 
-        protected virtual HitResult MutateResultApplication(HitResult result) => result;
+        /// <summary>
+        /// Some objects in mania may want to limit the max result.
+        /// </summary>
+        protected virtual HitResult GetCappedResult(HitResult result) => result;
 
         public virtual bool OnPressed(KeyBindingPressEvent<ManiaAction> e)
         {

From 7c49843411c750565a3ab80f64b9dbd7233484ae Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 16 Oct 2023 14:01:47 +0900
Subject: [PATCH 07/25] Fix various tests

---
 .../Mods/TestSceneManiaModDoubleTime.cs       | 39 +++++++++++--------
 .../TestSceneHoldNoteInput.cs                 | 18 ++++-----
 .../TestSceneMaximumScore.cs                  |  4 +-
 .../Rulesets/Scoring/ScoreProcessorTest.cs    |  4 +-
 4 files changed, 36 insertions(+), 29 deletions(-)

diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModDoubleTime.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModDoubleTime.cs
index 00b79529a9..9f2530eb31 100644
--- a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModDoubleTime.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModDoubleTime.cs
@@ -17,12 +17,14 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
     {
         private const double offset = 18;
 
+        protected override bool AllowFail => true;
+
         protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset();
 
         [Test]
         public void TestHitWindowWithoutDoubleTime() => CreateModTest(new ModTestData
         {
-            PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 && Player.ScoreProcessor.Accuracy.Value != 1,
+            PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 && Player.ScoreProcessor.Accuracy.Value == 1 && Player.ScoreProcessor.TotalScore.Value == 1_000_000,
             Autoplay = false,
             Beatmap = new Beatmap
             {
@@ -40,24 +42,29 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
         });
 
         [Test]
-        public void TestHitWindowWithDoubleTime() => CreateModTest(new ModTestData
+        public void TestHitWindowWithDoubleTime()
         {
-            Mod = new ManiaModDoubleTime(),
-            PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 && Player.ScoreProcessor.Accuracy.Value == 1,
-            Autoplay = false,
-            Beatmap = new Beatmap
+            var doubleTime = new ManiaModDoubleTime();
+
+            CreateModTest(new ModTestData
             {
-                BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
-                Difficulty = { OverallDifficulty = 10 },
-                HitObjects = new List<HitObject>
+                Mod = doubleTime,
+                PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 && Player.ScoreProcessor.Accuracy.Value == 1 && Player.ScoreProcessor.TotalScore.Value / doubleTime.ScoreMultiplier == 100010,
+                Autoplay = false,
+                Beatmap = new Beatmap
                 {
-                    new Note { StartTime = 1000 }
+                    BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
+                    Difficulty = { OverallDifficulty = 10 },
+                    HitObjects = new List<HitObject>
+                    {
+                        new Note { StartTime = 1000 }
+                    },
                 },
-            },
-            ReplayFrames = new List<ReplayFrame>
-            {
-                new ManiaReplayFrame(1000 + offset, ManiaAction.Key1)
-            }
-        });
+                ReplayFrames = new List<ReplayFrame>
+                {
+                    new ManiaReplayFrame(1000 + offset, ManiaAction.Key1)
+                }
+            });
+        }
     }
 }
diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs
index 93128c512f..ccd5e0600d 100644
--- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs
+++ b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs
@@ -200,10 +200,12 @@ namespace osu.Game.Rulesets.Mania.Tests
             });
 
             assertHeadJudgement(HitResult.Perfect);
-            assertComboAtJudgement(0, 1);
+            // judgement combo offset by perfect bonus judgement. see logic in DrawableNote.CheckForResult.
+            assertComboAtJudgement(1, 1);
             assertTailJudgement(HitResult.Meh);
-            assertComboAtJudgement(1, 0);
-            assertComboAtJudgement(2, 1);
+            assertComboAtJudgement(2, 0);
+            // judgement combo offset by perfect bonus judgement. see logic in DrawableNote.CheckForResult.
+            assertComboAtJudgement(4, 1);
         }
 
         /// <summary>
@@ -392,13 +394,11 @@ namespace osu.Game.Rulesets.Mania.Tests
                         Duration = time_tail - time_head,
                         Column = 0,
                     },
+                    // Next note within tail lenience
+                    (ManiaHitObject)(note = new Note
                     {
-                        // Next note within tail lenience
-                        note = new Note
-                        {
-                            StartTime = time_tail + 50
-                        }
-                    }
+                        StartTime = time_tail + 50
+                    })
                 },
                 BeatmapInfo =
                 {
diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs
index 3d0abaceb5..3a74f87f1a 100644
--- a/osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs
+++ b/osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs
@@ -54,7 +54,7 @@ namespace osu.Game.Rulesets.Mania.Tests
             AddAssert("all objects perfectly judged",
                 () => judgementResults.Select(result => result.Type),
                 () => Is.EquivalentTo(judgementResults.Select(result => result.Judgement.MaxResult)));
-            AddAssert("score is 1 million", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_000));
+            AddAssert("score is correct", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_030));
         }
 
         [Test]
@@ -87,7 +87,7 @@ namespace osu.Game.Rulesets.Mania.Tests
             AddAssert("all objects perfectly judged",
                 () => judgementResults.Select(result => result.Type),
                 () => Is.EquivalentTo(judgementResults.Select(result => result.Judgement.MaxResult)));
-            AddAssert("score is 1 million", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_000));
+            AddAssert("base score is 1 million", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_040));
         }
 
         private void performTest(List<ManiaHitObject> hitObjects, List<ReplayFrame> frames)
diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs
index 92e94bd02d..cba90b2ebe 100644
--- a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs
+++ b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs
@@ -74,7 +74,7 @@ namespace osu.Game.Tests.Rulesets.Scoring
         [TestCase(ScoringMode.Standardised, HitResult.Miss, HitResult.Great, 0)]
         [TestCase(ScoringMode.Standardised, HitResult.Meh, HitResult.Great, 79_333)]
         [TestCase(ScoringMode.Standardised, HitResult.Ok, HitResult.Great, 158_667)]
-        [TestCase(ScoringMode.Standardised, HitResult.Good, HitResult.Perfect, 302_402)]
+        [TestCase(ScoringMode.Standardised, HitResult.Good, HitResult.Perfect, 317_626)]
         [TestCase(ScoringMode.Standardised, HitResult.Great, HitResult.Great, 492_894)]
         [TestCase(ScoringMode.Standardised, HitResult.Perfect, HitResult.Perfect, 492_894)]
         [TestCase(ScoringMode.Standardised, HitResult.SmallTickMiss, HitResult.SmallTickHit, 0)]
@@ -86,7 +86,7 @@ namespace osu.Game.Tests.Rulesets.Scoring
         [TestCase(ScoringMode.Classic, HitResult.Miss, HitResult.Great, 0)]
         [TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 7_975)]
         [TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 15_949)]
-        [TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 30_398)]
+        [TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 31_928)]
         [TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 49_546)]
         [TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 49_546)]
         [TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, HitResult.SmallTickHit, 0)]

From 14fedffcc1c0d753a395b16a5521286c826ebb37 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 16 Oct 2023 14:53:36 +0900
Subject: [PATCH 08/25] Fix `MissForcefully` not considering the bonus object

---
 osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
index c7eabaf616..27039d2f37 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
@@ -111,6 +111,12 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
             ApplyResult(r => r.Type = result);
         }
 
+        public override void MissForcefully()
+        {
+            bonusNote.TriggerResult(false);
+            base.MissForcefully();
+        }
+
         /// <summary>
         /// Some objects in mania may want to limit the max result.
         /// </summary>

From 43f619f92a7761c95d9b4632373eb2b9ed0996a6 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 16 Oct 2023 14:57:55 +0900
Subject: [PATCH 09/25] Add `DisplayResult` flag and remove unnecessary
 `AlwaysPresent`

---
 .../Objects/Drawables/DrawablePerfectBonusNote.cs              | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawablePerfectBonusNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawablePerfectBonusNote.cs
index 9f51f9c5b3..1761f675f5 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawablePerfectBonusNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawablePerfectBonusNote.cs
@@ -5,10 +5,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
 {
     public partial class DrawablePerfectBonusNote : DrawableManiaHitObject<PerfectBonusNote>
     {
+        public override bool DisplayResult => false;
+
         public DrawablePerfectBonusNote()
             : this(null!)
         {
-            AlwaysPresent = true;
         }
 
         public DrawablePerfectBonusNote(PerfectBonusNote hitObject)

From 1a957364aeb9aefa9c0ce5a08e1de5961e55db8a Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 16 Oct 2023 14:58:49 +0900
Subject: [PATCH 10/25] Add empty hit windows on `PefectBonusNote`

---
 osu.Game.Rulesets.Mania/Objects/PerfectBonusNote.cs | 1 +
 1 file changed, 1 insertion(+)

diff --git a/osu.Game.Rulesets.Mania/Objects/PerfectBonusNote.cs b/osu.Game.Rulesets.Mania/Objects/PerfectBonusNote.cs
index b601a0614b..e98b2ff1c1 100644
--- a/osu.Game.Rulesets.Mania/Objects/PerfectBonusNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/PerfectBonusNote.cs
@@ -10,6 +10,7 @@ namespace osu.Game.Rulesets.Mania.Objects
     public class PerfectBonusNote : ManiaHitObject
     {
         public override Judgement CreateJudgement() => new PerfectBonusNoteJudgement();
+        protected override HitWindows CreateHitWindows() => HitWindows.Empty;
 
         public class PerfectBonusNoteJudgement : ManiaJudgement
         {

From 3f09ed396f511bc45e4dfbe0eb1d205ae0e58a52 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 16 Oct 2023 15:11:33 +0900
Subject: [PATCH 11/25] Fix legacy skin body piece dimming when it shouldn't

---
 .../Skinning/Legacy/LegacyBodyPiece.cs            | 15 ++++++++++++---
 1 file changed, 12 insertions(+), 3 deletions(-)

diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs
index 660f72e565..5c353887c3 100644
--- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs
+++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs
@@ -123,9 +123,18 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
 
         private void applyCustomUpdateState(DrawableHitObject hitObject, ArmedState state)
         {
-            // ensure that the hold note is also faded out when the head/tail/any tick is missed.
-            if (state == ArmedState.Miss)
-                missFadeTime.Value ??= hitObject.HitStateUpdateTime;
+            switch (hitObject)
+            {
+                // Ensure that the hold note is also faded out when the head/tail/any tick is missed.
+                // Importantly, we filter out unrelated objects like DrawablePerfectBonusNote.
+                case DrawableHoldNoteTail:
+                case DrawableHoldNoteHead:
+                case DrawableHoldNoteBody:
+                    if (state == ArmedState.Miss)
+                        missFadeTime.Value ??= hitObject.HitStateUpdateTime;
+
+                    break;
+            }
         }
 
         private void onIsHittingChanged(ValueChangedEvent<bool> isHitting)

From 4f1546c4743fea3a9ee89dded4086d710fd50557 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 16 Oct 2023 15:14:26 +0900
Subject: [PATCH 12/25] Rename `PerfectBonusNote` to `NotePerfectBonus`

---
 .../Objects/Drawables/DrawableNote.cs          | 18 +++++++++---------
 ...onusNote.cs => DrawableNotePerfectBonus.cs} |  6 +++---
 osu.Game.Rulesets.Mania/Objects/Note.cs        |  2 +-
 ...PerfectBonusNote.cs => NotePerfectBonus.cs} |  6 +++---
 .../Skinning/Legacy/LegacyBodyPiece.cs         |  2 +-
 osu.Game.Rulesets.Mania/UI/Column.cs           |  2 +-
 6 files changed, 18 insertions(+), 18 deletions(-)
 rename osu.Game.Rulesets.Mania/Objects/Drawables/{DrawablePerfectBonusNote.cs => DrawableNotePerfectBonus.cs} (76%)
 rename osu.Game.Rulesets.Mania/Objects/{PerfectBonusNote.cs => NotePerfectBonus.cs} (70%)

diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
index 27039d2f37..c70dfcb761 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
@@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
 
         private Drawable headPiece;
 
-        private DrawablePerfectBonusNote bonusNote;
+        private DrawableNotePerfectBonus perfectBonus;
 
         public DrawableNote()
             : this(null)
@@ -94,7 +94,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
             {
                 if (!HitObject.HitWindows.CanBeHit(timeOffset))
                 {
-                    bonusNote.TriggerResult(false);
+                    perfectBonus.TriggerResult(false);
                     ApplyResult(r => r.Type = r.Judgement.MinResult);
                 }
 
@@ -107,13 +107,13 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
 
             result = GetCappedResult(result);
 
-            bonusNote.TriggerResult(result == HitResult.Perfect);
+            perfectBonus.TriggerResult(result == HitResult.Perfect);
             ApplyResult(r => r.Type = result);
         }
 
         public override void MissForcefully()
         {
-            bonusNote.TriggerResult(false);
+            perfectBonus.TriggerResult(false);
             base.MissForcefully();
         }
 
@@ -141,23 +141,23 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
         {
             switch (hitObject)
             {
-                case DrawablePerfectBonusNote bonus:
-                    AddInternal(bonusNote = bonus);
+                case DrawableNotePerfectBonus bonus:
+                    AddInternal(perfectBonus = bonus);
                     break;
             }
         }
 
         protected override void ClearNestedHitObjects()
         {
-            RemoveInternal(bonusNote, false);
+            RemoveInternal(perfectBonus, false);
         }
 
         protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject)
         {
             switch (hitObject)
             {
-                case PerfectBonusNote bonus:
-                    return new DrawablePerfectBonusNote(bonus);
+                case NotePerfectBonus bonus:
+                    return new DrawableNotePerfectBonus(bonus);
             }
 
             return base.CreateNestedHitObject(hitObject);
diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawablePerfectBonusNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNotePerfectBonus.cs
similarity index 76%
rename from osu.Game.Rulesets.Mania/Objects/Drawables/DrawablePerfectBonusNote.cs
rename to osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNotePerfectBonus.cs
index 1761f675f5..70ddb60296 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawablePerfectBonusNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNotePerfectBonus.cs
@@ -3,16 +3,16 @@
 
 namespace osu.Game.Rulesets.Mania.Objects.Drawables
 {
-    public partial class DrawablePerfectBonusNote : DrawableManiaHitObject<PerfectBonusNote>
+    public partial class DrawableNotePerfectBonus : DrawableManiaHitObject<NotePerfectBonus>
     {
         public override bool DisplayResult => false;
 
-        public DrawablePerfectBonusNote()
+        public DrawableNotePerfectBonus()
             : this(null!)
         {
         }
 
-        public DrawablePerfectBonusNote(PerfectBonusNote hitObject)
+        public DrawableNotePerfectBonus(NotePerfectBonus hitObject)
             : base(hitObject)
         {
         }
diff --git a/osu.Game.Rulesets.Mania/Objects/Note.cs b/osu.Game.Rulesets.Mania/Objects/Note.cs
index 955d3e9c7d..5914132624 100644
--- a/osu.Game.Rulesets.Mania/Objects/Note.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Note.cs
@@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Mania.Objects
         {
             base.CreateNestedHitObjects(cancellationToken);
 
-            AddNested(new PerfectBonusNote { StartTime = StartTime });
+            AddNested(new NotePerfectBonus { StartTime = StartTime });
         }
     }
 }
diff --git a/osu.Game.Rulesets.Mania/Objects/PerfectBonusNote.cs b/osu.Game.Rulesets.Mania/Objects/NotePerfectBonus.cs
similarity index 70%
rename from osu.Game.Rulesets.Mania/Objects/PerfectBonusNote.cs
rename to osu.Game.Rulesets.Mania/Objects/NotePerfectBonus.cs
index e98b2ff1c1..def4c01268 100644
--- a/osu.Game.Rulesets.Mania/Objects/PerfectBonusNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/NotePerfectBonus.cs
@@ -7,12 +7,12 @@ using osu.Game.Rulesets.Scoring;
 
 namespace osu.Game.Rulesets.Mania.Objects
 {
-    public class PerfectBonusNote : ManiaHitObject
+    public class NotePerfectBonus : ManiaHitObject
     {
-        public override Judgement CreateJudgement() => new PerfectBonusNoteJudgement();
+        public override Judgement CreateJudgement() => new NotePerfectBonusJudgement();
         protected override HitWindows CreateHitWindows() => HitWindows.Empty;
 
-        public class PerfectBonusNoteJudgement : ManiaJudgement
+        public class NotePerfectBonusJudgement : ManiaJudgement
         {
             public override HitResult MaxResult => HitResult.SmallBonus;
         }
diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs
index 5c353887c3..f27b3bdd5c 100644
--- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs
+++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs
@@ -126,7 +126,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
             switch (hitObject)
             {
                 // Ensure that the hold note is also faded out when the head/tail/any tick is missed.
-                // Importantly, we filter out unrelated objects like DrawablePerfectBonusNote.
+                // Importantly, we filter out unrelated objects like DrawableNotePerfectBonus.
                 case DrawableHoldNoteTail:
                 case DrawableHoldNoteHead:
                 case DrawableHoldNoteBody:
diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs
index 68d1e929be..9489281176 100644
--- a/osu.Game.Rulesets.Mania/UI/Column.cs
+++ b/osu.Game.Rulesets.Mania/UI/Column.cs
@@ -109,7 +109,7 @@ namespace osu.Game.Rulesets.Mania.UI
             TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy());
 
             RegisterPool<Note, DrawableNote>(10, 50);
-            RegisterPool<PerfectBonusNote, DrawablePerfectBonusNote>(10, 50);
+            RegisterPool<NotePerfectBonus, DrawableNotePerfectBonus>(10, 50);
             RegisterPool<HoldNote, DrawableHoldNote>(10, 50);
             RegisterPool<HeadNote, DrawableHoldNoteHead>(10, 50);
             RegisterPool<TailNote, DrawableHoldNoteTail>(10, 50);

From d9d062915722a7fafe2c8d987067032157c21439 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 16 Oct 2023 15:16:10 +0900
Subject: [PATCH 13/25] Fix code quality inspection (weird one)

---
 osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs | 9 +++------
 1 file changed, 3 insertions(+), 6 deletions(-)

diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs
index ccd5e0600d..044ce37832 100644
--- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs
+++ b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs
@@ -382,7 +382,8 @@ namespace osu.Game.Rulesets.Mania.Tests
         [Test]
         public void TestPressAndReleaseJustAfterTailWithNearbyNote()
         {
-            Note note;
+            // Next note within tail lenience
+            Note note = new Note { StartTime = time_tail + 50 };
 
             var beatmap = new Beatmap<ManiaHitObject>
             {
@@ -394,11 +395,7 @@ namespace osu.Game.Rulesets.Mania.Tests
                         Duration = time_tail - time_head,
                         Column = 0,
                     },
-                    // Next note within tail lenience
-                    (ManiaHitObject)(note = new Note
-                    {
-                        StartTime = time_tail + 50
-                    })
+                    note
                 },
                 BeatmapInfo =
                 {

From db00b794a24bc21c1e637027d9c2c0ecd1fe057c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Mon, 16 Oct 2023 08:52:32 +0200
Subject: [PATCH 14/25] Fix test failure due to missing zero (and FP
 shenanigans)

---
 .../Mods/TestSceneManiaModDoubleTime.cs                         | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModDoubleTime.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModDoubleTime.cs
index 9f2530eb31..f1a432cc06 100644
--- a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModDoubleTime.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModDoubleTime.cs
@@ -49,7 +49,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
             CreateModTest(new ModTestData
             {
                 Mod = doubleTime,
-                PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 && Player.ScoreProcessor.Accuracy.Value == 1 && Player.ScoreProcessor.TotalScore.Value / doubleTime.ScoreMultiplier == 100010,
+                PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 && Player.ScoreProcessor.Accuracy.Value == 1 && Player.ScoreProcessor.TotalScore.Value == (long)(1000010 * doubleTime.ScoreMultiplier),
                 Autoplay = false,
                 Beatmap = new Beatmap
                 {

From b9a84127ac876c9f153f16f2cfe06da2cde3ff89 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Mon, 16 Oct 2023 08:57:35 +0200
Subject: [PATCH 15/25] Remove mention of "any tick"

They're very dead now.
---
 osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs
index f27b3bdd5c..66e67136df 100644
--- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs
+++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs
@@ -125,7 +125,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
         {
             switch (hitObject)
             {
-                // Ensure that the hold note is also faded out when the head/tail/any tick is missed.
+                // Ensure that the hold note is also faded out when the head/tail/body is missed.
                 // Importantly, we filter out unrelated objects like DrawableNotePerfectBonus.
                 case DrawableHoldNoteTail:
                 case DrawableHoldNoteHead:

From 624c05e0ff3b783ceb626e8461ce3e2d3c1e1ad1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Mon, 16 Oct 2023 09:04:53 +0200
Subject: [PATCH 16/25] Rename test step

---
 osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs
index 3a74f87f1a..edf866952b 100644
--- a/osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs
+++ b/osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs
@@ -87,7 +87,7 @@ namespace osu.Game.Rulesets.Mania.Tests
             AddAssert("all objects perfectly judged",
                 () => judgementResults.Select(result => result.Type),
                 () => Is.EquivalentTo(judgementResults.Select(result => result.Judgement.MaxResult)));
-            AddAssert("base score is 1 million", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_040));
+            AddAssert("score is correct", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_040));
         }
 
         private void performTest(List<ManiaHitObject> hitObjects, List<ReplayFrame> frames)

From c48142816c63d0d2d27f920f573435b47198b1d3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Mon, 16 Oct 2023 09:06:34 +0200
Subject: [PATCH 17/25] Reformat long lines

---
 .../Mods/TestSceneManiaModDoubleTime.cs                   | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModDoubleTime.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModDoubleTime.cs
index f1a432cc06..c717f03f51 100644
--- a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModDoubleTime.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModDoubleTime.cs
@@ -24,7 +24,9 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
         [Test]
         public void TestHitWindowWithoutDoubleTime() => CreateModTest(new ModTestData
         {
-            PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 && Player.ScoreProcessor.Accuracy.Value == 1 && Player.ScoreProcessor.TotalScore.Value == 1_000_000,
+            PassCondition = () => Player.ScoreProcessor.JudgedHits > 0
+                                  && Player.ScoreProcessor.Accuracy.Value == 1
+                                  && Player.ScoreProcessor.TotalScore.Value == 1_000_000,
             Autoplay = false,
             Beatmap = new Beatmap
             {
@@ -49,7 +51,9 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
             CreateModTest(new ModTestData
             {
                 Mod = doubleTime,
-                PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 && Player.ScoreProcessor.Accuracy.Value == 1 && Player.ScoreProcessor.TotalScore.Value == (long)(1000010 * doubleTime.ScoreMultiplier),
+                PassCondition = () => Player.ScoreProcessor.JudgedHits > 0
+                                      && Player.ScoreProcessor.Accuracy.Value == 1
+                                      && Player.ScoreProcessor.TotalScore.Value == (long)(1_000_010 * doubleTime.ScoreMultiplier),
                 Autoplay = false,
                 Beatmap = new Beatmap
                 {

From 84be714d6bd100e69e464dafda6c471aa8e292e8 Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Sat, 30 Sep 2023 02:19:11 +0900
Subject: [PATCH 18/25] Fix large instantaneous delta on first frame

Happens when the first update frame comes in before any mouse input.
---
 .../Default/SpinnerRotationTracker.cs         | 25 +++++++++++--------
 1 file changed, 14 insertions(+), 11 deletions(-)

diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs
index 719cf57d98..41d6e689b1 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs
@@ -22,11 +22,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
 
         private readonly DrawableSpinner drawableSpinner;
 
-        private Vector2 mousePosition;
+        private Vector2? mousePosition;
+        private float? lastAngle;
 
-        private float lastAngle;
         private float currentRotation;
-
         private bool rotationTransferred;
 
         [Resolved(canBeNull: true)]
@@ -63,17 +62,19 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
         protected override void Update()
         {
             base.Update();
-            float thisAngle = -MathUtils.RadiansToDegrees(MathF.Atan2(mousePosition.X - DrawSize.X / 2, mousePosition.Y - DrawSize.Y / 2));
 
-            float delta = thisAngle - lastAngle;
+            if (mousePosition is Vector2 pos)
+            {
+                float thisAngle = -MathUtils.RadiansToDegrees(MathF.Atan2(pos.X - DrawSize.X / 2, pos.Y - DrawSize.Y / 2));
+                float delta = lastAngle == null ? 0 : thisAngle - lastAngle.Value;
 
-            if (Tracking)
-                AddRotation(delta);
+                if (Tracking)
+                    AddRotation(delta);
 
-            lastAngle = thisAngle;
+                lastAngle = thisAngle;
+            }
 
             IsSpinning.Value = isSpinnableTime && Math.Abs(currentRotation - Rotation) > 10f;
-
             Rotation = (float)Interpolation.Damp(Rotation, currentRotation, 0.99, Math.Abs(Time.Elapsed));
         }
 
@@ -116,8 +117,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
         {
             Tracking = false;
             IsSpinning.Value = false;
-            mousePosition = default;
-            lastAngle = currentRotation = Rotation = 0;
+            mousePosition = null;
+            lastAngle = null;
+            currentRotation = 0;
+            Rotation = 0;
             rotationTransferred = false;
         }
 

From 159b24acf767b07e1dc803d7875788bd8ebf8e72 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 16 Oct 2023 18:25:03 +0900
Subject: [PATCH 19/25] Rename `RateAdjustedRotation` to `TotalRotation`

---
 .../TestSceneSpinnerApplication.cs                   |  4 ++--
 .../TestSceneSpinnerRotation.cs                      | 12 ++++++------
 .../Judgements/OsuSpinnerJudgementResult.cs          |  2 +-
 .../Objects/Drawables/DrawableSpinner.cs             |  6 +++---
 .../Skinning/Argon/ArgonSpinnerDisc.cs               |  2 +-
 .../Skinning/Default/DefaultSpinnerDisc.cs           |  2 +-
 .../Skinning/Default/SpinnerRotationTracker.cs       |  2 +-
 7 files changed, 15 insertions(+), 15 deletions(-)

diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs
index 1ae17432be..dae81f4cff 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs
@@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Tests
             });
 
             AddStep("rotate some", () => dho.RotationTracker.AddRotation(180));
-            AddAssert("rotation is set", () => dho.Result.RateAdjustedRotation == 180);
+            AddAssert("rotation is set", () => dho.Result.TotalRotation == 180);
 
             AddStep("apply new spinner", () => dho.Apply(prepareObject(new Spinner
             {
@@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Tests
                 Duration = 1000,
             })));
 
-            AddAssert("rotation is reset", () => dho.Result.RateAdjustedRotation == 0);
+            AddAssert("rotation is reset", () => dho.Result.TotalRotation == 0);
         }
 
         private Spinner prepareObject(Spinner circle)
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs
index 116c974f32..8711aa9c09 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs
@@ -63,11 +63,11 @@ namespace osu.Game.Rulesets.Osu.Tests
                 trackerRotationTolerance = Math.Abs(drawableSpinner.RotationTracker.Rotation * 0.1f);
             });
             AddAssert("is disc rotation not almost 0", () => drawableSpinner.RotationTracker.Rotation, () => Is.Not.EqualTo(0).Within(100));
-            AddAssert("is disc rotation absolute not almost 0", () => drawableSpinner.Result.RateAdjustedRotation, () => Is.Not.EqualTo(0).Within(100));
+            AddAssert("is disc rotation absolute not almost 0", () => drawableSpinner.Result.TotalRotation, () => Is.Not.EqualTo(0).Within(100));
 
             addSeekStep(0);
             AddAssert("is disc rotation almost 0", () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(0).Within(trackerRotationTolerance));
-            AddAssert("is disc rotation absolute almost 0", () => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(0).Within(100));
+            AddAssert("is disc rotation absolute almost 0", () => drawableSpinner.Result.TotalRotation, () => Is.EqualTo(0).Within(100));
         }
 
         [Test]
@@ -82,7 +82,7 @@ namespace osu.Game.Rulesets.Osu.Tests
                 finalTrackerRotation = drawableSpinner.RotationTracker.Rotation;
                 trackerRotationTolerance = Math.Abs(finalTrackerRotation * 0.05f);
             });
-            AddStep("retrieve cumulative disc rotation", () => finalCumulativeTrackerRotation = drawableSpinner.Result.RateAdjustedRotation);
+            AddStep("retrieve cumulative disc rotation", () => finalCumulativeTrackerRotation = drawableSpinner.Result.TotalRotation);
 
             addSeekStep(spinner_start_time + 2500);
             AddAssert("disc rotation rewound",
@@ -92,13 +92,13 @@ namespace osu.Game.Rulesets.Osu.Tests
                 () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(finalTrackerRotation / 2).Within(trackerRotationTolerance));
             AddAssert("is cumulative rotation rewound",
                 // cumulative rotation is not damped, so we're treating it as the "ground truth" and allowing a comparatively smaller margin of error.
-                () => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(finalCumulativeTrackerRotation / 2).Within(100));
+                () => drawableSpinner.Result.TotalRotation, () => Is.EqualTo(finalCumulativeTrackerRotation / 2).Within(100));
 
             addSeekStep(spinner_start_time + 5000);
             AddAssert("is disc rotation almost same",
                 () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(finalTrackerRotation).Within(trackerRotationTolerance));
             AddAssert("is cumulative rotation almost same",
-                () => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(finalCumulativeTrackerRotation).Within(100));
+                () => drawableSpinner.Result.TotalRotation, () => Is.EqualTo(finalCumulativeTrackerRotation).Within(100));
         }
 
         [Test]
@@ -135,7 +135,7 @@ namespace osu.Game.Rulesets.Osu.Tests
             {
                 // multipled by 2 to nullify the score multiplier. (autoplay mod selected)
                 long totalScore = ((ScoreExposedPlayer)Player).ScoreProcessor.TotalScore.Value * 2;
-                return totalScore == (int)(drawableSpinner.Result.RateAdjustedRotation / 360) * new SpinnerTick().CreateJudgement().MaxNumericResult;
+                return totalScore == (int)(drawableSpinner.Result.TotalRotation / 360) * new SpinnerTick().CreateJudgement().MaxNumericResult;
             });
 
             addSeekStep(0);
diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs b/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs
index 941cb667cf..c5e15d63ea 100644
--- a/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs
+++ b/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs
@@ -36,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Judgements
         /// If Double Time is active instead (with a speed multiplier of 1.5x),
         /// in the same scenario the property will return 720 * 1.5 = 1080.
         /// </example>
-        public float RateAdjustedRotation;
+        public float TotalRotation;
 
         /// <summary>
         /// Time instant at which the spin was started (the first user input which caused an increase in spin).
diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs
index 24446db92a..9fa180cf93 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs
@@ -218,7 +218,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
                     // these become implicitly hit.
                     return 1;
 
-                return Math.Clamp(Result.RateAdjustedRotation / 360 / HitObject.SpinsRequired, 0, 1);
+                return Math.Clamp(Result.TotalRotation / 360 / HitObject.SpinsRequired, 0, 1);
             }
         }
 
@@ -279,7 +279,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
             // don't update after end time to avoid the rate display dropping during fade out.
             // this shouldn't be limited to StartTime as it causes weirdness with the underlying calculation, which is expecting updates during that period.
             if (Time.Current <= HitObject.EndTime)
-                spmCalculator.SetRotation(Result.RateAdjustedRotation);
+                spmCalculator.SetRotation(Result.TotalRotation);
 
             updateBonusScore();
         }
@@ -293,7 +293,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
             if (ticks.Count == 0)
                 return;
 
-            int spins = (int)(Result.RateAdjustedRotation / 360);
+            int spins = (int)(Result.TotalRotation / 360);
 
             if (spins < completedFullSpins)
             {
diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerDisc.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerDisc.cs
index bdc93eb63f..079758c21e 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerDisc.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerDisc.cs
@@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
         {
             get
             {
-                int rotations = (int)(drawableSpinner.Result.RateAdjustedRotation / 360);
+                int rotations = (int)(drawableSpinner.Result.TotalRotation / 360);
 
                 if (wholeRotationCount == rotations) return false;
 
diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs
index 75f3247448..b498975a83 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs
@@ -200,7 +200,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
         {
             get
             {
-                int rotations = (int)(drawableSpinner.Result.RateAdjustedRotation / 360);
+                int rotations = (int)(drawableSpinner.Result.TotalRotation / 360);
 
                 if (wholeRotationCount == rotations) return false;
 
diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs
index 41d6e689b1..77d410887c 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs
@@ -110,7 +110,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
             currentRotation += angle;
             // rate has to be applied each frame, because it's not guaranteed to be constant throughout playback
             // (see: ModTimeRamp)
-            drawableSpinner.Result.RateAdjustedRotation += (float)(Math.Abs(angle) * (gameplayClock?.GetTrueGameplayRate() ?? Clock.Rate));
+            drawableSpinner.Result.TotalRotation += (float)(Math.Abs(angle) * (gameplayClock?.GetTrueGameplayRate() ?? Clock.Rate));
         }
 
         private void resetState(DrawableHitObject obj)

From cfa4adb24d2ed5282ef0e4c8616e3ab9f70c0b43 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 16 Oct 2023 18:25:40 +0900
Subject: [PATCH 20/25] Add `SpinFramesGenerator` class to simplify creating
 spinner tests

---
 .../SpinFramesGenerator.cs                    | 111 ++++++++++++++++++
 .../TestSceneLegacyHitPolicy.cs               |  15 +--
 .../TestSceneSpinnerJudgement.cs              |  26 +---
 .../TestSceneStartTimeOrderedHitPolicy.cs     |  15 +--
 4 files changed, 130 insertions(+), 37 deletions(-)
 create mode 100644 osu.Game.Rulesets.Osu.Tests/SpinFramesGenerator.cs

diff --git a/osu.Game.Rulesets.Osu.Tests/SpinFramesGenerator.cs b/osu.Game.Rulesets.Osu.Tests/SpinFramesGenerator.cs
new file mode 100644
index 0000000000..43adfb7f1f
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/SpinFramesGenerator.cs
@@ -0,0 +1,111 @@
+// 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 osu.Game.Rulesets.Osu.Replays;
+using osu.Game.Rulesets.Replays;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Tests
+{
+    public class SpinFramesGenerator
+    {
+        /// <summary>
+        /// A small amount to spin beyond a given angle to mitigate floating-point precision errors.
+        /// </summary>
+        public const float SPIN_ERROR = MathF.PI / 8;
+
+        /// <summary>
+        /// The offset from the centre of the spinner at which to spin.
+        /// </summary>
+        private const float centre_spin_offset = 50;
+
+        private readonly double startTime;
+        private readonly float startAngle;
+        private readonly List<(float deltaAngle, double duration)> sequences = new List<(float deltaAngle, double duration)>();
+
+        /// <summary>
+        /// Creates a new <see cref="SpinFramesGenerator"/> that can be used to generate spinner spin frames.
+        /// </summary>
+        /// <param name="startTime">The time at which to start spinning.</param>
+        /// <param name="startAngle">The angle, in radians, at which to start spinning from. Defaults to the positive-y-axis.</param>
+        public SpinFramesGenerator(double startTime, float startAngle = -MathF.PI / 2f)
+        {
+            this.startTime = startTime;
+            this.startAngle = startAngle;
+        }
+
+        /// <summary>
+        /// Performs a single spin.
+        /// </summary>
+        /// <param name="delta">The amount, relative to a full circle, to spin.</param>
+        /// <param name="duration">The time to spend to perform the spin.</param>
+        /// <returns>This <see cref="SpinFramesGenerator"/>.</returns>
+        public SpinFramesGenerator Spin(float delta, double duration)
+        {
+            sequences.Add((delta * 2 * MathF.PI, duration));
+            return this;
+        }
+
+        /// <summary>
+        /// Constructs the replay frames.
+        /// </summary>
+        /// <returns>The replay frames.</returns>
+        public List<ReplayFrame> Build()
+        {
+            List<ReplayFrame> frames = new List<ReplayFrame>();
+
+            double lastTime = startTime;
+            float lastAngle = startAngle;
+            int lastDirection = 0;
+
+            for (int i = 0; i < sequences.Count; i++)
+            {
+                var seq = sequences[i];
+
+                int seqDirection = Math.Sign(seq.deltaAngle);
+                float seqError = SPIN_ERROR * seqDirection;
+
+                if (seqDirection == lastDirection)
+                {
+                    // Spinning in the same direction, but the error was already added in the last rotation.
+                    seqError = 0;
+                }
+                else if (lastDirection != 0)
+                {
+                    // Spinning in a different direction, we need to account for the error of the start angle, so double it.
+                    seqError *= 2;
+                }
+
+                double seqStartTime = lastTime;
+                double seqEndTime = lastTime + seq.duration;
+                float seqStartAngle = lastAngle;
+                float seqEndAngle = seqStartAngle + seq.deltaAngle + seqError;
+
+                // Intermediate spin frames.
+                for (; lastTime < seqEndTime; lastTime += 10)
+                    frames.Add(new OsuReplayFrame(lastTime, calcOffsetAt((lastTime - seqStartTime) / (seqEndTime - seqStartTime), seqStartAngle, seqEndAngle), OsuAction.LeftButton));
+
+                // Final frame at the end of the current spin.
+                frames.Add(new OsuReplayFrame(lastTime, calcOffsetAt(1, seqStartAngle, seqEndAngle), OsuAction.LeftButton));
+
+                lastTime = seqEndTime;
+                lastAngle = seqEndAngle;
+                lastDirection = seqDirection;
+            }
+
+            // Key release frame.
+            if (frames.Count > 0)
+                frames.Add(new OsuReplayFrame(frames[^1].Time, ((OsuReplayFrame)frames[^1]).Position));
+
+            return frames;
+        }
+
+        private static Vector2 calcOffsetAt(double p, float startAngle, float endAngle)
+        {
+            float angle = startAngle + (endAngle - startAngle) * (float)p;
+            return new Vector2(256, 192) + centre_spin_offset * new Vector2(MathF.Cos(angle), MathF.Sin(angle));
+        }
+    }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs
index a2ef72fe57..e0a618b187 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs
@@ -356,15 +356,16 @@ namespace osu.Game.Rulesets.Osu.Tests
                 },
             };
 
-            performTest(hitObjects, new List<ReplayFrame>
+            List<ReplayFrame> frames = new List<ReplayFrame>
             {
                 new OsuReplayFrame { Time = time_spinner - 90, Position = positionCircle, Actions = { OsuAction.LeftButton } },
-                new OsuReplayFrame { Time = time_spinner + 10, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } },
-                new OsuReplayFrame { Time = time_spinner + 20, Position = new Vector2(256, 172), Actions = { OsuAction.RightButton } },
-                new OsuReplayFrame { Time = time_spinner + 30, Position = new Vector2(276, 192), Actions = { OsuAction.RightButton } },
-                new OsuReplayFrame { Time = time_spinner + 40, Position = new Vector2(256, 212), Actions = { OsuAction.RightButton } },
-                new OsuReplayFrame { Time = time_spinner + 50, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } },
-            });
+            };
+
+            frames.AddRange(new SpinFramesGenerator(time_spinner + 10)
+                            .Spin(1, 500)
+                            .Build());
+
+            performTest(hitObjects, frames);
 
             addJudgementAssert(hitObjects[0], HitResult.Great);
             addJudgementAssert(hitObjects[1], HitResult.Meh);
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerJudgement.cs
index c969cb11b4..6a50f08508 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerJudgement.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerJudgement.cs
@@ -1,7 +1,6 @@
 // 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;
@@ -11,14 +10,12 @@ using osu.Game.Beatmaps;
 using osu.Game.Replays;
 using osu.Game.Rulesets.Judgements;
 using osu.Game.Rulesets.Osu.Objects;
-using osu.Game.Rulesets.Osu.Replays;
 using osu.Game.Rulesets.Osu.UI;
 using osu.Game.Rulesets.Replays;
 using osu.Game.Rulesets.Scoring;
 using osu.Game.Scoring;
 using osu.Game.Screens.Play;
 using osu.Game.Tests.Visual;
-using osuTK;
 
 namespace osu.Game.Rulesets.Osu.Tests
 {
@@ -59,26 +56,9 @@ namespace osu.Game.Rulesets.Osu.Tests
             AddAssert("all max judgements", () => judgementResults.All(result => result.Type == result.Judgement.MaxResult));
         }
 
-        private static List<ReplayFrame> generateReplay(int spins)
-        {
-            var replayFrames = new List<ReplayFrame>();
-
-            const int frames_per_spin = 30;
-
-            for (int i = 0; i < spins * frames_per_spin; ++i)
-            {
-                float totalProgress = i / (float)(spins * frames_per_spin);
-                float spinProgress = (i % frames_per_spin) / (float)frames_per_spin;
-                double time = time_spinner_start + (time_spinner_end - time_spinner_start) * totalProgress;
-                float posX = MathF.Cos(2 * MathF.PI * spinProgress);
-                float posY = MathF.Sin(2 * MathF.PI * spinProgress);
-                Vector2 finalPos = OsuPlayfield.BASE_SIZE / 2 + new Vector2(posX, posY) * 50;
-
-                replayFrames.Add(new OsuReplayFrame(time, finalPos, OsuAction.LeftButton));
-            }
-
-            return replayFrames;
-        }
+        private static List<ReplayFrame> generateReplay(int spins) => new SpinFramesGenerator(time_spinner_start)
+                                                                      .Spin(spins, time_spinner_end - time_spinner_start)
+                                                                      .Build();
 
         private void performTest(List<ReplayFrame> frames)
         {
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs
index f0af3f0c39..19413a50a8 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs
@@ -284,15 +284,16 @@ namespace osu.Game.Rulesets.Osu.Tests
                 },
             };
 
-            performTest(hitObjects, new List<ReplayFrame>
+            List<ReplayFrame> frames = new List<ReplayFrame>
             {
                 new OsuReplayFrame { Time = time_spinner - 100, Position = positionCircle, Actions = { OsuAction.LeftButton } },
-                new OsuReplayFrame { Time = time_spinner + 10, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } },
-                new OsuReplayFrame { Time = time_spinner + 20, Position = new Vector2(256, 172), Actions = { OsuAction.RightButton } },
-                new OsuReplayFrame { Time = time_spinner + 30, Position = new Vector2(276, 192), Actions = { OsuAction.RightButton } },
-                new OsuReplayFrame { Time = time_spinner + 40, Position = new Vector2(256, 212), Actions = { OsuAction.RightButton } },
-                new OsuReplayFrame { Time = time_spinner + 50, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } },
-            });
+            };
+
+            frames.AddRange(new SpinFramesGenerator(time_spinner + 10)
+                            .Spin(1, 500)
+                            .Build());
+
+            performTest(hitObjects, frames);
 
             addJudgementAssert(hitObjects[0], HitResult.Great);
             addJudgementAssert(hitObjects[1], HitResult.Great);

From 28ee99f132bf48a3368bcac3f51fc2721b079225 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 16 Oct 2023 18:31:01 +0900
Subject: [PATCH 21/25] Add prospective test coverage of spinner input handling

---
 .../TestSceneSpinnerInput.cs                  | 290 ++++++++++++++++++
 1 file changed, 290 insertions(+)
 create mode 100644 osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs

diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs
new file mode 100644
index 0000000000..d7151f9370
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs
@@ -0,0 +1,290 @@
+// 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.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Screens;
+using osu.Framework.Testing;
+using osu.Framework.Timing;
+using osu.Game.Beatmaps;
+using osu.Game.Replays;
+using osu.Game.Rulesets.Judgements;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.Osu.Objects.Drawables;
+using osu.Game.Rulesets.Osu.Replays;
+using osu.Game.Rulesets.Replays;
+using osu.Game.Rulesets.Scoring;
+using osu.Game.Rulesets.UI;
+using osu.Game.Scoring;
+using osu.Game.Screens.Play;
+using osu.Game.Storyboards;
+using osu.Game.Tests.Visual;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Tests
+{
+    public partial class TestSceneSpinnerInput : RateAdjustedBeatmapTestScene
+    {
+        private const int centre_x = 256;
+        private const int centre_y = 192;
+        private const double time_spinner_start = 1500;
+        private const double time_spinner_end = 8000;
+
+        private readonly List<JudgementResult> judgementResults = new List<JudgementResult>();
+
+        private ScoreAccessibleReplayPlayer currentPlayer = null!;
+        private ManualClock? manualClock;
+
+        protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null)
+        {
+            return manualClock == null
+                ? base.CreateWorkingBeatmap(beatmap, storyboard)
+                : new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(manualClock), Audio);
+        }
+
+        [SetUp]
+        public void Setup() => Schedule(() =>
+        {
+            manualClock = null;
+        });
+
+        /// <summary>
+        /// While off-centre, vibrates backwards and forwards on the x-axis, from centre-50 to centre+50, every 50ms.
+        /// </summary>
+        [Test]
+        [Ignore("An upcoming implementation will fix this case")]
+        public void TestVibrateWithoutSpinningOffCentre()
+        {
+            List<ReplayFrame> frames = new List<ReplayFrame>();
+
+            const int vibrate_time = 50;
+            const float y_pos = centre_y - 50;
+
+            int direction = -1;
+
+            for (double i = time_spinner_start; i <= time_spinner_end; i += vibrate_time)
+            {
+                frames.Add(new OsuReplayFrame(i, new Vector2(centre_x + direction * 50, y_pos), OsuAction.LeftButton));
+                frames.Add(new OsuReplayFrame(i + vibrate_time, new Vector2(centre_x - direction * 50, y_pos), OsuAction.LeftButton));
+
+                direction *= -1;
+            }
+
+            performTest(frames);
+
+            assertTicksHit(0);
+            assertSpinnerHit(false);
+        }
+
+        /// <summary>
+        /// While centred on the slider, vibrates backwards and forwards on the x-axis, from centre-50 to centre+50, every 50ms.
+        /// </summary>
+        [Test]
+        [Ignore("An upcoming implementation will fix this case")]
+        public void TestVibrateWithoutSpinningOnCentre()
+        {
+            List<ReplayFrame> frames = new List<ReplayFrame>();
+
+            const int vibrate_time = 50;
+
+            int direction = -1;
+
+            for (double i = time_spinner_start; i <= time_spinner_end; i += vibrate_time)
+            {
+                frames.Add(new OsuReplayFrame(i, new Vector2(centre_x + direction * 50, centre_y), OsuAction.LeftButton));
+                frames.Add(new OsuReplayFrame(i + vibrate_time, new Vector2(centre_x - direction * 50, centre_y), OsuAction.LeftButton));
+
+                direction *= -1;
+            }
+
+            performTest(frames);
+
+            assertTicksHit(0);
+            assertSpinnerHit(false);
+        }
+
+        /// <summary>
+        /// Spins in a single direction.
+        /// </summary>
+        [TestCase(0.5f, 0)]
+        [TestCase(-0.5f, 0)]
+        [TestCase(1, 1)]
+        [TestCase(-1, 1)]
+        [TestCase(1.5f, 1)]
+        [TestCase(-1.5f, 1)]
+        [TestCase(2f, 2)]
+        [TestCase(-2f, 2)]
+        public void TestSpinSingleDirection(float amount, int expectedTicks)
+        {
+            performTest(new SpinFramesGenerator(time_spinner_start)
+                        .Spin(amount, 500)
+                        .Build());
+
+            assertTicksHit(expectedTicks);
+            assertSpinnerHit(false);
+        }
+
+        /// <summary>
+        /// Spin half-way clockwise then perform one full spin counter-clockwise.
+        /// No ticks should be hit since the total rotation is -0.5 (0.5 CW + 1 CCW = 0.5 CCW).
+        /// </summary>
+        [Test]
+        [Ignore("An upcoming implementation will fix this case")]
+        public void TestSpinHalfBothDirections()
+        {
+            performTest(new SpinFramesGenerator(time_spinner_start)
+                        .Spin(0.5f, 500) // Rotate to +0.5.
+                        .Spin(-1f, 500) // Rotate to -0.5
+                        .Build());
+
+            assertTicksHit(0);
+            assertSpinnerHit(false);
+        }
+
+        /// <summary>
+        /// Spin in one direction then spin in the other.
+        /// </summary>
+        [TestCase(0.5f, -1.5f, 1)]
+        [TestCase(-0.5f, 1.5f, 1)]
+        [TestCase(0.5f, -2.5f, 2)]
+        [TestCase(-0.5f, 2.5f, 2)]
+        [Ignore("An upcoming implementation will fix this case")]
+        public void TestSpinOneDirectionThenChangeDirection(float direction1, float direction2, int expectedTicks)
+        {
+            performTest(new SpinFramesGenerator(time_spinner_start)
+                        .Spin(direction1, 500)
+                        .Spin(direction2, 500)
+                        .Build());
+
+            assertTicksHit(expectedTicks);
+            assertSpinnerHit(false);
+        }
+
+        [Test]
+        [Ignore("An upcoming implementation will fix this case")]
+        public void TestRewind()
+        {
+            AddStep("set manual clock", () => manualClock = new ManualClock { Rate = 1 });
+
+            List<ReplayFrame> frames = new SpinFramesGenerator(time_spinner_start)
+                                       .Spin(1f, 500) // 2000ms -> 1 full CW spin
+                                       .Spin(-0.5f, 500) // 2500ms -> 0.5 CCW spins
+                                       .Spin(0.25f, 500) // 3000ms -> 0.25 CW spins
+                                       .Spin(1.25f, 500) // 3500ms -> 1 full CW spin
+                                       .Spin(0.5f, 500) // 4000ms -> 0.5 CW spins
+                                       .Build();
+
+            loadPlayer(frames);
+
+            GameplayClockContainer clock = null!;
+            DrawableRuleset drawableRuleset = null!;
+            AddStep("get gameplay objects", () =>
+            {
+                clock = currentPlayer.ChildrenOfType<GameplayClockContainer>().Single();
+                drawableRuleset = currentPlayer.ChildrenOfType<DrawableRuleset>().Single();
+            });
+
+            addSeekStep(frames.Last().Time);
+
+            DrawableSpinner drawableSpinner = null!;
+            AddUntilStep("get spinner", () => (drawableSpinner = currentPlayer.ChildrenOfType<DrawableSpinner>().Single()) != null);
+
+            assertTotalRotation(4000, 900);
+            assertTotalRotation(3750, 810);
+            assertTotalRotation(3500, 720);
+            assertTotalRotation(3250, 530);
+            assertTotalRotation(3000, 540);
+            assertTotalRotation(2750, 540);
+            assertTotalRotation(2500, 540);
+            assertTotalRotation(2250, 360);
+            assertTotalRotation(2000, 180);
+            assertTotalRotation(1500, 0);
+
+            void assertTotalRotation(double time, float expected)
+            {
+                addSeekStep(time);
+                AddAssert($"total rotation @ {time} is {expected}", () => drawableSpinner.Result.TotalRotation,
+                    () => Is.EqualTo(expected).Within(MathHelper.RadiansToDegrees(SpinFramesGenerator.SPIN_ERROR * 2)));
+            }
+
+            void addSeekStep(double time)
+            {
+                AddStep($"seek to {time}", () => clock.Seek(time));
+                AddUntilStep("wait for seek to finish", () => drawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time));
+            }
+        }
+
+        private void assertTicksHit(int count)
+        {
+            AddAssert($"{count} ticks hit", () => judgementResults.Where(r => r.HitObject is SpinnerTick).Count(r => r.IsHit), () => Is.EqualTo(count));
+        }
+
+        private void assertSpinnerHit(bool shouldBeHit)
+        {
+            AddAssert($"spinner is {(shouldBeHit ? "hit" : "missed")}", () => judgementResults.Single(r => r.HitObject is Spinner).IsHit, () => Is.EqualTo(shouldBeHit));
+        }
+
+        private void loadPlayer(List<ReplayFrame> frames)
+        {
+            AddStep("load player", () =>
+            {
+                Beatmap.Value = CreateWorkingBeatmap(new Beatmap<OsuHitObject>
+                {
+                    HitObjects =
+                    {
+                        new Spinner
+                        {
+                            StartTime = time_spinner_start,
+                            EndTime = time_spinner_end,
+                            Position = new Vector2(centre_x, centre_y)
+                        }
+                    },
+                    BeatmapInfo =
+                    {
+                        Difficulty = new BeatmapDifficulty(),
+                        Ruleset = new OsuRuleset().RulesetInfo
+                    },
+                });
+
+                var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } });
+
+                p.OnLoadComplete += _ =>
+                {
+                    p.ScoreProcessor.NewJudgement += result =>
+                    {
+                        if (currentPlayer == p) judgementResults.Add(result);
+                    };
+                };
+
+                LoadScreen(currentPlayer = p);
+                judgementResults.Clear();
+            });
+
+            AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0);
+            AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen());
+        }
+
+        private void performTest(List<ReplayFrame> frames)
+        {
+            loadPlayer(frames);
+            AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value);
+        }
+
+        private partial class ScoreAccessibleReplayPlayer : ReplayPlayer
+        {
+            public new ScoreProcessor ScoreProcessor => base.ScoreProcessor;
+
+            protected override bool PauseOnFocusLost => false;
+
+            public ScoreAccessibleReplayPlayer(Score score)
+                : base(score, new PlayerConfiguration
+                {
+                    AllowPause = false,
+                    ShowResults = false,
+                })
+            {
+            }
+        }
+    }
+}

From 04af46b8c72a03030aa61b69bc4a1ef72b8d9843 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 16 Oct 2023 18:34:56 +0900
Subject: [PATCH 22/25] Change `SpinFramesGenerator` to take degrees as input

---
 .../SpinFramesGenerator.cs                    |  4 +-
 .../TestSceneLegacyHitPolicy.cs               |  2 +-
 .../TestSceneSpinnerInput.cs                  | 38 +++++++++----------
 .../TestSceneSpinnerJudgement.cs              |  2 +-
 .../TestSceneStartTimeOrderedHitPolicy.cs     |  2 +-
 5 files changed, 24 insertions(+), 24 deletions(-)

diff --git a/osu.Game.Rulesets.Osu.Tests/SpinFramesGenerator.cs b/osu.Game.Rulesets.Osu.Tests/SpinFramesGenerator.cs
index 43adfb7f1f..dbdfa1f258 100644
--- a/osu.Game.Rulesets.Osu.Tests/SpinFramesGenerator.cs
+++ b/osu.Game.Rulesets.Osu.Tests/SpinFramesGenerator.cs
@@ -39,12 +39,12 @@ namespace osu.Game.Rulesets.Osu.Tests
         /// <summary>
         /// Performs a single spin.
         /// </summary>
-        /// <param name="delta">The amount, relative to a full circle, to spin.</param>
+        /// <param name="delta">The amount of degrees to spin.</param>
         /// <param name="duration">The time to spend to perform the spin.</param>
         /// <returns>This <see cref="SpinFramesGenerator"/>.</returns>
         public SpinFramesGenerator Spin(float delta, double duration)
         {
-            sequences.Add((delta * 2 * MathF.PI, duration));
+            sequences.Add((delta / 360 * 2 * MathF.PI, duration));
             return this;
         }
 
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs
index e0a618b187..fa6aa580a3 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs
@@ -362,7 +362,7 @@ namespace osu.Game.Rulesets.Osu.Tests
             };
 
             frames.AddRange(new SpinFramesGenerator(time_spinner + 10)
-                            .Spin(1, 500)
+                            .Spin(360, 500)
                             .Build());
 
             performTest(hitObjects, frames);
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs
index d7151f9370..c4bf0d4e2e 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs
@@ -107,14 +107,14 @@ namespace osu.Game.Rulesets.Osu.Tests
         /// <summary>
         /// Spins in a single direction.
         /// </summary>
-        [TestCase(0.5f, 0)]
-        [TestCase(-0.5f, 0)]
-        [TestCase(1, 1)]
-        [TestCase(-1, 1)]
-        [TestCase(1.5f, 1)]
-        [TestCase(-1.5f, 1)]
-        [TestCase(2f, 2)]
-        [TestCase(-2f, 2)]
+        [TestCase(180, 0)]
+        [TestCase(-180, 0)]
+        [TestCase(360, 1)]
+        [TestCase(-360, 1)]
+        [TestCase(540, 1)]
+        [TestCase(-540, 1)]
+        [TestCase(720, 2)]
+        [TestCase(-720, 2)]
         public void TestSpinSingleDirection(float amount, int expectedTicks)
         {
             performTest(new SpinFramesGenerator(time_spinner_start)
@@ -134,8 +134,8 @@ namespace osu.Game.Rulesets.Osu.Tests
         public void TestSpinHalfBothDirections()
         {
             performTest(new SpinFramesGenerator(time_spinner_start)
-                        .Spin(0.5f, 500) // Rotate to +0.5.
-                        .Spin(-1f, 500) // Rotate to -0.5
+                        .Spin(180, 500) // Rotate to +0.5.
+                        .Spin(-360, 500) // Rotate to -0.5
                         .Build());
 
             assertTicksHit(0);
@@ -145,10 +145,10 @@ namespace osu.Game.Rulesets.Osu.Tests
         /// <summary>
         /// Spin in one direction then spin in the other.
         /// </summary>
-        [TestCase(0.5f, -1.5f, 1)]
-        [TestCase(-0.5f, 1.5f, 1)]
-        [TestCase(0.5f, -2.5f, 2)]
-        [TestCase(-0.5f, 2.5f, 2)]
+        [TestCase(180, -540, 1)]
+        [TestCase(-180, 540, 1)]
+        [TestCase(180, -900, 2)]
+        [TestCase(-180, 900, 2)]
         [Ignore("An upcoming implementation will fix this case")]
         public void TestSpinOneDirectionThenChangeDirection(float direction1, float direction2, int expectedTicks)
         {
@@ -168,11 +168,11 @@ namespace osu.Game.Rulesets.Osu.Tests
             AddStep("set manual clock", () => manualClock = new ManualClock { Rate = 1 });
 
             List<ReplayFrame> frames = new SpinFramesGenerator(time_spinner_start)
-                                       .Spin(1f, 500) // 2000ms -> 1 full CW spin
-                                       .Spin(-0.5f, 500) // 2500ms -> 0.5 CCW spins
-                                       .Spin(0.25f, 500) // 3000ms -> 0.25 CW spins
-                                       .Spin(1.25f, 500) // 3500ms -> 1 full CW spin
-                                       .Spin(0.5f, 500) // 4000ms -> 0.5 CW spins
+                                       .Spin(360, 500) // 2000ms -> 1 full CW spin
+                                       .Spin(-180, 500) // 2500ms -> 0.5 CCW spins
+                                       .Spin(90, 500) // 3000ms -> 0.25 CW spins
+                                       .Spin(450, 500) // 3500ms -> 1 full CW spin
+                                       .Spin(180, 500) // 4000ms -> 0.5 CW spins
                                        .Build();
 
             loadPlayer(frames);
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerJudgement.cs
index 6a50f08508..8d8c2e9639 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerJudgement.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerJudgement.cs
@@ -57,7 +57,7 @@ namespace osu.Game.Rulesets.Osu.Tests
         }
 
         private static List<ReplayFrame> generateReplay(int spins) => new SpinFramesGenerator(time_spinner_start)
-                                                                      .Spin(spins, time_spinner_end - time_spinner_start)
+                                                                      .Spin(spins * 360, time_spinner_end - time_spinner_start)
                                                                       .Build();
 
         private void performTest(List<ReplayFrame> frames)
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs
index 19413a50a8..3475680c71 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs
@@ -290,7 +290,7 @@ namespace osu.Game.Rulesets.Osu.Tests
             };
 
             frames.AddRange(new SpinFramesGenerator(time_spinner + 10)
-                            .Spin(1, 500)
+                            .Spin(360, 500)
                             .Build());
 
             performTest(hitObjects, frames);

From 10bab614412a17752af5ae50bfba5c65ecf6538a Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 16 Oct 2023 19:23:35 +0900
Subject: [PATCH 23/25] Tidy up `lastAngle` usage and add assertion of maximum
 delta

---
 .../Default/SpinnerRotationTracker.cs         | 27 +++++++++----------
 1 file changed, 13 insertions(+), 14 deletions(-)

diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs
index 77d410887c..174ba1c402 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs
@@ -2,6 +2,7 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using System;
+using System.Diagnostics;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Extensions.ObjectExtensions;
@@ -68,6 +69,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
                 float thisAngle = -MathUtils.RadiansToDegrees(MathF.Atan2(pos.X - DrawSize.X / 2, pos.Y - DrawSize.Y / 2));
                 float delta = lastAngle == null ? 0 : thisAngle - lastAngle.Value;
 
+                // Normalise the delta to -180 .. 180
+                if (delta > 180) delta -= 360;
+                if (delta < -180) delta += 360;
+
                 if (Tracking)
                     AddRotation(delta);
 
@@ -84,8 +89,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
         /// <remarks>
         /// Will be a no-op if not a valid time to spin.
         /// </remarks>
-        /// <param name="angle">The delta angle.</param>
-        public void AddRotation(float angle)
+        /// <param name="delta">The delta angle.</param>
+        public void AddRotation(float delta)
         {
             if (!isSpinnableTime)
                 return;
@@ -96,21 +101,15 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
                 rotationTransferred = true;
             }
 
-            if (angle > 180)
-            {
-                lastAngle += 360;
-                angle -= 360;
-            }
-            else if (-angle > 180)
-            {
-                lastAngle -= 360;
-                angle += 360;
-            }
+            currentRotation += delta;
+
+            double rate = gameplayClock?.GetTrueGameplayRate() ?? Clock.Rate;
+
+            Debug.Assert(Math.Abs(delta) <= 180);
 
-            currentRotation += angle;
             // rate has to be applied each frame, because it's not guaranteed to be constant throughout playback
             // (see: ModTimeRamp)
-            drawableSpinner.Result.TotalRotation += (float)(Math.Abs(angle) * (gameplayClock?.GetTrueGameplayRate() ?? Clock.Rate));
+            drawableSpinner.Result.TotalRotation += (float)(Math.Abs(delta) * rate);
         }
 
         private void resetState(DrawableHitObject obj)

From 0bb95cfa88fb798f07ad7a35aca0ef61f755790d Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 16 Oct 2023 19:34:55 +0900
Subject: [PATCH 24/25] Fix incorrect initial rotation transfer value

Should have been removed as part of https://github.com/ppy/osu/pull/24360.
---
 .../Skinning/Default/SpinnerRotationTracker.cs                  | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs
index 174ba1c402..69c2bf3dd0 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs
@@ -97,7 +97,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
 
             if (!rotationTransferred)
             {
-                currentRotation = Rotation * 2;
+                currentRotation = Rotation;
                 rotationTransferred = true;
             }
 

From 3065c9f23dfa7f9018320392e03a29ea25dee28b Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Mon, 16 Oct 2023 22:49:41 +0900
Subject: [PATCH 25/25] Fix potential frame misordering in generator

---
 osu.Game.Rulesets.Osu.Tests/SpinFramesGenerator.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game.Rulesets.Osu.Tests/SpinFramesGenerator.cs b/osu.Game.Rulesets.Osu.Tests/SpinFramesGenerator.cs
index dbdfa1f258..e6dc72033a 100644
--- a/osu.Game.Rulesets.Osu.Tests/SpinFramesGenerator.cs
+++ b/osu.Game.Rulesets.Osu.Tests/SpinFramesGenerator.cs
@@ -88,7 +88,7 @@ namespace osu.Game.Rulesets.Osu.Tests
                     frames.Add(new OsuReplayFrame(lastTime, calcOffsetAt((lastTime - seqStartTime) / (seqEndTime - seqStartTime), seqStartAngle, seqEndAngle), OsuAction.LeftButton));
 
                 // Final frame at the end of the current spin.
-                frames.Add(new OsuReplayFrame(lastTime, calcOffsetAt(1, seqStartAngle, seqEndAngle), OsuAction.LeftButton));
+                frames.Add(new OsuReplayFrame(seqEndTime, calcOffsetAt(1, seqStartAngle, seqEndAngle), OsuAction.LeftButton));
 
                 lastTime = seqEndTime;
                 lastAngle = seqEndAngle;